Catalog of reliability properties for blockchains

A big hurdle when using property-based testing is coming up with properties to test for your system. Scott Wlaschin’s Choosing properties for property-based testing blog series provides some excellent suggestions, but applying these patterns to your system often feels like crossing a chasm.

This is a catalog of properties that apply to most (if not all) blockchains. These are high-level holistic properties that ensure that the system as whole behaves as expected and delivers on all promised guarantees. It’s meant as a reference and to help you think about other properties that your specific blockchain should have. We’d love your help adding more properties to this list, please reach out to us at support@antithesis.com.

Blockchain layers

Here’s an outline of the layers in a blockchain, inspired by the Open Systems Interconnection model:

  1. Application layer
    Software built on top of the chain but running outside it. For example: a wallet app on your phone, Etherscan, etc.
  2. Chain layer
    The set of nodes that apply transactions in each block updating the state of assets.
  3. Consensus layer
    The set of nodes running a consensus protocol – they may not have a concept of block or transaction as their job is to create a total order sequence of messages.
  4. Network layer
    A set of nodes exchanging messages over the internet.

Layers of a blockchain system.

Property catalog

Software reliability properties are often grouped as either safety properties or liveness properties.

  • Safety properties express things that should never happen. For example: if I roll a 6-sided die, I never roll a 7.
  • Liveness properties express things that should happen eventually happen. For example: if I roll a die, it eventually lands on a side.

We list both safety and liveness properties for each layer and tag them as such, but have left some hard-to-classify properties untagged.

Network layer

Bad requests can’t crash nodes (Safety)

Nodes should be resilient to bad requests.

How to test this
  • Send randomly generated messages (both good and malformed requests) to a node. E.g. invalid message headers, outsize payloads, etc.
  • Assert the node doesn’t crash for any of the inputs.

This is akin to standard input fuzzing.

Why is this important

On the internet, nodes constantly interact with unknown and untrusted peers. If a single node is able to crash others by sending malformed or malicious messages, a cascade can easily bring down the entire network. Nodes should be resilient in the face of bad requests to ensure a robust network.

Nodes can’t poison each other (Safety)

No node should be able to poison another node in the network.

How to test this
  • Send malicious messages (e.g. corrupted peer discovery messages, poisoning the routing table) to a peer or a set of peers.
  • Assert that nodes detect and drop malicious messages.
Why is this important

Malicious attacks can poison a node’s routing table or peer list. A poisoned routing table can disrupt communication in the network, artificially creating partitions that lead to forks.

No Denial of Service (Liveness)

No node should be able to flood another node in the network and cause denial of service.

How to test this
  • Simulate a DoS or DDoS attack by flooding a peer or a set of peers with bogus messages.
  • Monitor the target node’s responsiveness and CPU usage.
  • Assert that legitimate communication still succeeds under (reasonably) heavy load.
Why is this important

A node that becomes unresponsive under DoS attack will cause delays in message propagation and consensus. Nodes should remain reasonably responsive under high traffic.

Nodes can reconnect (Liveness)

Two nodes whose connection was broken in presence of network faults can eventually reach each other when the fault is resolved.

How to test this
  • Set up two or more nodes
  • Inject network faults that temporarily break their connection, drop or delay packets.
  • Assert that any two nodes can eventually (using retries) reconnect and resume communication.
Why is this important

Network partitions are inevitable in real-world conditions. Ensuring nodes can reestablish connection when network conditions improve keeps the chain progressing.

Nodes can discover each other (Liveness)

Nodes that join a P2P network can discover other nodes in the network. In such networks, nodes join and leave continuously. So, the set of nodes in the network and routing keeps changing – nodes discover new peers that join the network, and drop the peers that leave the network.

How to test this
  • Bring up a P2P network with N total nodes.
  • Add and remove nodes.
  • Periodically query a node’s peer tables to verify nodes can discover each other.
Why is this important

In a healthy peer-to-peer network, nodes are regularly updating the list of known peers. If discovery isn’t working, new nodes can’t participate. This makes the network unreliable and reduces its capacity.

Nodes can rejoin network (Liveness)

A node accidentally dropped from the network due to connectivity issues should eventually be able to rejoin when its network connection recovers.

How to test this
  • Choose a node in the network to isolate.
  • Inject network faults that partition the node from the rest of the network.
  • After the network heals, assert that the node can communicate with the network.
Why is this important

If a node that drops due to network faults or restarts can’t rejoin, the network loses capacity and reliability over time. A robust network is resilient to faults and maintains decentralization and redundancy.

Resource utilization is within limits (Liveness)

A node’s memory, CPU, and I/O usage should remain within acceptable limits.

How to test this
  • Monitor a node’s memory, CPU, and I/O usage while the nodes are under load.
  • Assert resource usage is within expected operational bounds.
Why is this important

Overutilization of resources may be a sign that something is not working as expected.

Consensus layer

Blockchains are based on State Machine Replication (SMR) architecture. Running on an unreliable network, nodes act as replicas maintaining a local version of the main (canonical) chain. Since each node is a state machine, all nodes start from the same state (Genesis state) and keep updating their local replica of the chain, while under network turbulence.

According to SMR, all replicas start from the same state, deterministically apply the same sequence of inputs, and (eventually) reach the same final state.

SMR relies on all replicas receiving the same sequence of inputs. This is also known as the total order delivery problem.

In fault-tolerant distributed computing, atomic broadcast is an abstraction for a protocol that guarantees total order delivery to all nodes.

The following properties characterize an atomic broadcast protocol:

  1. Validity: if a correct participant broadcasts a message 𝑚, then all correct participants will eventually receive 𝑚.
  2. Uniform agreement: if one correct participant receives a message 𝑚, then all correct participants will eventually receive 𝑚.
  3. Uniform integrity: a message 𝑚 is received by each participant at most once, and only if it was previously broadcast.
  4. Uniform total order: if any correct participant receives message 𝑚1 first and message 𝑚2 second, then every other correct participant must receive message 𝑚1 before message 𝑚2.

You might be wondering about the phrase “correct participant” above.

In a fault-tolerant, decentralized network, all kinds of participants join the network, including honest and malfunctioning participants. The malfunctioning participants might exhibit conflicting behavior with different peers, or they could have actively malicious intent. The presence of such nodes may result in Byzantine faults.

Since we can’t control Byzantine behaviors in the network, we expect correct (honest) replicas to follow the atomic broadcast protocol. Additionally, the consensus layer is made Byzantine fault-tolerant.

Atomic broadcast (Safety)

A blockchain’s consensus protocol should satisfy the requirements of atomic broadcast.

How to test this
  • Submit a sequence of transactions to the network under randomized network faults.
  • Assert that the order of commands received across all nodes is the same, and that eventually all nodes receive all of them.
Why is this important

Consensus provides a mechanism for every node to process the same sequence of messages in the same order. Without total order, nodes would diverge in state, leading to inconsistent state across replicas.

Byzantine fault tolerance (Safety)

Correct nodes should be able to function in the presence of byzantine failures. Most blockchains define a tolerance threshold for byzantine failures. For instance, they may guarantee robustness if n >= 3m+1; where n is the number of nodes in the network, m is the number of byzantine (faulty) nodes.

How to test this
  • Set up a set of nodes such that n >= 3m+1 (less than a third of the nodes are byzantine).
  • Make the byzantine nodes conspire to hijack the network by violating the consensus protocol (i.e. cheat).
  • Assert that these failures don’t affect consensus.
Why is this important

In a decentralized network, malicious actors can easily join the network and try to cheat. Such behavior can disrupt the correct operation of the network

Invalid block proposals are rejected (Safety)

Running consensus on invalid block proposals is wasteful and invalid blocks should be discarded with minimum resource waste.

Since the consensus layer’s responsibility is to get consensus on a proposed block, it doesn’t need to check the validity of transactions in the proposed block, but it should check the validity of block metadata like block size and parent block hashes.

How to test this
  • Randomly generate a collection of valid and invalid (e.g., wrong parent hash, oversized block, malformed header) block proposals.
  • Feed them to the consensus process.
  • Assert that valid block proposals are accepted and proceed for consensus.
  • Assert that invalid block proposals are rejected.
Why is this important

Running consensus on invalid blocks is wasteful. Rejecting invalid blocks early in the process prevents unnecessary resource utilization and conserves consensus throughput.

Chain layer

This layer builds upon the total order broadcast and understands the content of each block, the sequence of transactions, and keeps state that is modified by such transactions.

Proposed blocks are valid (Safety)

The chain layer’s responsibility includes validating block metadata and the transactions in the payload, ensuring, for instance, that there are no conflicting transactions, the addresses in the transactions are valid, there’s sufficient balance to perform the transaction, etc.

How to test this
  • Propose an invalid block, containing conflicting transactions, incorrect block hashes, or an insufficient balance.
  • Assert that other nodes ignore it and it’s never serialized via consensus.
Why is this important

Nodes should only propose blocks that are valid to prevent resource wastage during consensus, temporary forks, and to preserve throughput.

Deterministic application of blocks (Safety)

Any two correct nodes in the network reach the same state after applying the same sequence of blocks. This ensures that node states are always eventually consistent (ignoring forks for now).

How to test this
  • Let S(n, b) be the state of node n after applying b blocks.
  • Choose two nodes m, n in the network such that S(m, b) = S(n, b); that is, they start from the same state.
  • On applying a sequence of blocks b’, assert that S(m , b’) == S(n, b’); that is, they eventually reach the same state.
Why is this important

Deterministic state transition ensures that all chain nodes have the same view of the state of the chain.

Invalid blocks are ignored (Safety)

A correct node ignores invalid blocks. When applying a block after consensus, nodes check that the block to be applied is valid. The main difference between “Only propose valid blocks” and this safety property is that the former is applicable when proposing blocks for consensus, and this property is applicable when applying a block after consensus.

For instance, validate that the parent block hash in the block metadata matches the current block hash.

How to test this
  • Send variations of valid and invalid blocks to apply as the next block.
  • Send blocks with invalid parent block hashes (e.g. the parent doesn’t exist).
  • Assert that nodes only apply valid blocks.
Why is this important

If nodes commit invalid blocks, their state will be invalid and the chain data will be irrecoverably corrupted.

Invalid transactions are ignored (Safety)

A correct node should validate incoming transactions before applying them to the state.

For example, a block contains 2 transactions that take $10 from a wallet that contains $10. The second transaction is invalid because the balance by then is $0. Or a transaction tries to pull money from account X but the signature belongs to wallet Y. This is invalid and should not be applied.

How to test this
  • Submit invalid transactions to the network.
  • Assert that invalid transactions are not reflected in the state.
Why is this important

The integrity of the data stored on the chain relies on only valid transactions/transformations being applied to it.

No double spend (Safety)

This property is a special case of “Invalid transactions are ignored” in which a malicious actor tries to double spend, though it could also be attempted via a 51% fork attack, not just with invalid transactions.

No permanent forks (Liveness)

Nodes in a fork can eventually catch up with the canonical chain, that is:

  1. Forks can be detected.
  2. Nodes in the minority fork can drop their diverging state and catch up to the majority fork successfully.
How to test this
  • Create a network partition and submit two sets of transactions, with one set missing some transactions, to two isolated partitions.
  • While the partition lasts, assert that forks are created.
  • After the partition heals, assert that forks are detected.
  • Assert that nodes eventually recover and everyone reaches the same state.
Why is this important

Temporary forks are impossible to avoid, but a chain is only dependable if such forks are detected and resolved quickly.

Chain progression (Liveness)

In most blockchains, nodes keep committing empty blocks to the chain even in the absence of submitted transactions. So under the right conditions and depending on the protocol, the chain should keep progressing.

How to test this
  • Submit transactions while significantly disturbing the environment (via network faults).
  • Periodically pause faults, and verify that the chain makes progress (block count increases).
  • Repeat.
Why is this important

If the chain stalls, no users can transact. Good uptime and resilience to transitory faults are important for user experience.

Node can recover from last saved checkpoint

Nodes store checkpoints on local disk to speed up recovery after a crash or restart.

How to test this
  • Kill/restart nodes at random.
  • Assert nodes eventually catch up to the canonical chain.
  • Measure the time to recover and assert that it’s not correlated with the length of history.
Why is this important

Everytime a node restarts, it shouldn’t start reapplying all blocks from the genesis state. This property ensures recovery efficiency. Blockchain nodes should catch up with the canonical chain as quickly as possible, so they generally store snapshots of state and recover using the last saved snapshot. So they should only have to apply the blocks since that last saved checkpoint.

Rewards are awarded fairly/correctly (Safety)

Block proposers / Validators in the network get rewarded for proposing a new block. These rewards should be awarded correctly.

How to test this
  • Simulate multiple rounds of block proposals (and voting) with different participating nodes.
  • Compare actual rewards with expected values based on node contributions.
Why is this important

Rewards are an essential part of the network, and if they’re not awarded correctly, node operators will stop contributing resources to the network.

Application layer

An application is any software that is built on top of a chain, but not part of the network itself.

Applications generally interact with the chain by submitting transactions, querying state, and observing the stream of committed blocks.

We’ll take an example of the simplest application – a crypto wallet – and identify reliability properties based on the provided functionalities.

A crypto wallet application

Consider a minimal crypto wallet that provides the following functionalities:

  • Users can submit transactions.
  • Users can check their wallet balance.
  • Users can check the status of their transactions.

This wallet app connects users to a chain and provides an interface to interact with them. The app itself is responsible for submitting transactions, querying wallet balance, and watching for new blocks committed to the chain. When new blocks are added to the chain, the app is responsible for processing them and updating any balance impacted.

Considering just these basic functionalities, you should test the wallet app for the properties below.

Transactions aren’t submitted more than once (Safety)

The app should ensure that transactions aren’t submitted more than once (imagine what would happen if your wallet submitted a payment twice by mistake). In the presence of network faults, the app should retry safely to prevent duplicate submissions.

How to test this
  • Submit a transaction moving assets from one wallet to another.
  • Inject network turbulence between the application and the node(s) it’s connected to.
  • Verify the app safely retries without submitting the transaction more than once.
Why is this important

Submitting the same transaction twice can cause users to lose their assets or incur duplicate gas fees.

Submitted transactions aren’t invalid (Safety)

Transactions can be invalid in many ways. The application layer is responsible for checking transactions against the wallet balance, user signature (as calculated by the app’s hash function), request metadata, etc. for validity.

How to test this
  • Attempt to transfer more money than is available.
  • Attempt to submit transactions with an invalid key.
  • Assert that these invalid transactions get rejected.
Why is this important

Early rejection of invalid transactions prevents resource wastage.

Valid transactions are submitted (Liveness)

User-requested transactions that are valid should eventually be submitted to the chain.

How to test this
  • Submit multiple valid transactions.
  • Inject network latency or partitions between app and the chain.
  • Once the network stabilizes, assert that valid transactions are eventually confirmed on future committed blocks.
Why is this important

A user’s legitimate and valid transactions should eventually appear in the blockchain despite temporary network faults. If a user is unable to successfully submit a transaction, it’s a major flaw for a wallet app.

Sync with the canonical chain after a fork

The app should eventually sync with the canonical chain to receive newly committed blocks.

How to test this
  • Connect the application to a node in a minority fork.
  • Restore connection such that the fork is resolved by the network.
  • Assert that the app can sync and see the state as it appears on the canonical network.
Why is this important

Being temporarily connected to a minority fork can give the user a wrong view of chain state. It’s essential that the application is able to recover from such situations and display the correct state from the canonical chain.

User can see their correct wallet balance (Liveness)

It’s possible that the client app sometimes lags and doesn’t have the most recently committed block, but it should eventually catch up with the canonical chain and display the correct wallet balance.

How to test this
  • Perform some transactions under network turbulence.
  • Assert that the app eventually reflects the correct balance.
Why is this important

Showing users outdated account information can cause frustration and makes the app unusable.

New blocks are correctly processed (Safety)

A newly committed block, as seen by the app, should be correctly processed, e.g. updating status of transactions included in the new block.

How to test this
  • Send new blocks containing transactions relevant to the wallet transactions.
  • Assert that the wallet updates transaction statuses and balances accordingly.
  • Assert the updated balances against expected values.
Why is this important

The application layer should watch for new blocks and process them correctly to update the status of any transactions it submitted and appear in the newly committed block. Inability to do so results in inconsistent status updates and incorrect user balances.

Gas fees are estimated correctly (Safety)

Gas fees are estimated dynamically based on the computational work required to execute a transaction and the cost per unit of work. The supply and demand dynamics cause the gas prices to fluctuate. The app should correctly calculate the gas fees for a given transaction.

How to test this
  • Perform some transaction(s).
  • Assert that gas fees are within a bounded range (i.e. not too high or not too low).
  • If it’s possible to get an oracle, assert against that value.
Why is this important

Incorrectly calculated gas fees can cause user frustration (estimated: $10, actual $150!). Users lose trust in the app if their transactions fail frequently or if they’re overcharged for successful transactions.

Block integrity is verified correctly (Safety)

The app should verify the signatures of newly seen blocks to detect any block forgeries.

How to test this
  • Send a forged block with invalid signature to the wallet app.
  • Assert that client app rejects the forged block without crashing.
Why is this important

If the application trusts blocks from a node blindly, then a malicious node may be able to pass fraudulent or invalid blocks.

Wallet can reconnect with the blockchain (Liveness)

The app should be able to reconnect with the blockchain via another peer if it can’t reach the current peer.

How to test this
  • Partition the app from the network node it’s connected to.
  • Assert that the app can eventually reconnect to the blockchain (possibly via some other node).
Why is this important

Temporary connection failures shouldn’t make the app unusable. Re-establishing connection allows users to continuously submit transactions, view their balance, and check the status of their transactions.

Other “things” to test

These areas aren’t universal properties but represent cross-compatibility and upgrade resilience tests:

Version upgrades: Ensure that a version upgrade on a node completes successfully without any loss of functionality or data.

Cross-version compatibility: Ensure newer and older node versions can communicate and sync without errors.

Client-server compatibility: Verify that client software correctly interacts with various chain API versions and protocols.