Mechanics of a Hard Fork

December 10, 2022

This guide will walk through the changes required to fork the catapult client by using the recent 1.0.3.4 fork as an example. The specific fix in that fork will not be covered in detail because this guide is meant to be descriptive of common changes required for all or most forks.

To see the full change, please review this commit.

Background

A hard fork is a non-backwards compatible protocol update. Importantly, clients that do not upgrade are unable to sync with clients that do upgrade, and there is a resulting chain split.

The chain that attracts at least 2/3 of the voting importance will be able to finalize blocks and the other chain will end up like DHealth - in a state of purgatory where no blocks can be finalized without external intervention. In other chains without finalization (like NEM and Bitcoin), the longest chain wins the fork. In the case of Symbol this is true too, but the chains will be neck and neck for a while due to aggressive difficulty adjustment (lower) in the orphaned chain.

Notice that as of this writing, the orphaned chain (1.0.3.3) has a height of 1757636 compared the upgraded chain (1.0.3.4+) with a height of 1800243.

Fork Block

A fork block is a special block at which (incompatible) new or changed functionality is activated. At or after this block, the new functionality can be used.

It's important to note that the original and upgraded chains will not necessarily split at the fork block. The split will occur when the new functionality is first used.

For example, in the 1.0.3.4 upgrade, the fork block was 1690500 but the new functionality (a version 2 aggregate) was first used in block 1690553 (this transaction). As a result, the chains forked at block 1690553, not 1690500, because it contained an aggregate with version 2 that the original 1.0.3.3 client rejected.

Upgrade

It is critical that an upgraded client only allows original functionality up to the fork block. To understand why, consider potential alternative behaviors:

Only new functionality is allowed for all blocks

This would break nodes from syncing from scratch because new functionality could be applied to blocks prior to the fork block, which is undesirable. Even in the case of a bug fix, this could prevent nodes from syncing from scratch. In the 1.0.3.4 upgrade, we had to specifically allow (and validate) incorrect calculations in order to allow nodes to sync from scratch.

Both new and old functionality is allowed prior to the fork block

This would allow a block with new functionality to be pushed to the network after a client upgrade but prior to the fork block. This would complicate things for node operators because there would be no agreed upon upgrade deadline since the split block would be completely unknowable. It would also give a somewhat unfair advantage to node operators who are more online.

Configuration

The first step is to define the block that will include the new (incompatible) functionality. We do this in our configuration. This allows us to perform (and test!) a similar upgrade on testnet. We have added the strictAggregateTransactionHash property that indicates the new functionality will activate in block 1690500.

[fork_heights]

totalVotingBalanceCalculationFix = 528000
treasuryReissuance = 689761
strictAggregateTransactionHash = 1690500

We also need to add a corresponding field (StrictAggregateTransactionHash) to BlockChainConfiguration, which is used by the client to load and access the settings in config-network.settings.

/// Fork heights configuration.
struct ForkHeights {
    /// Height of fork to fix TotalVotingBalance calculation.
    Height TotalVotingBalanceCalculationFix;

    /// Height of fork at which to reissue the treasury.
    Height TreasuryReissuance;

    /// Height of fork at which aggregate transaction hash is strictly enforced.
    Height StrictAggregateTransactionHash;
};

The following line is all that is needed to connect the property in config-network.properties with the StrictAggregateTransactionHeight field:

LOAD_FORK_HEIGHT_PROPERTY(StrictAggregateTransactionHash);

Protocol Change

In the 1.0.3.4 upgrade, all aggregate transactions at and after the fork block must have version 2. In addition, all aggregates prior to the fork block must have version 1.

In order to enforce this constraint, we added a new AggregateTransactionVersion validator. Its implementation is quite simple. If the validator detects an aggregate transaction (either complete or bonded), it compares the current height to the fork height. If the height is before the fork height, it requires the aggregate to have version 1. If the height is at or after the fork height, it requires the aggregate to have version 2.

The validator is stateful instead of stateless because it is dependent on the chain height.

DECLARE_STATEFUL_VALIDATOR(AggregateTransactionVersion, Notification)(Height v2ForkHeight) {
    return MAKE_STATEFUL_VALIDATOR(AggregateTransactionVersion, ([v2ForkHeight](
            const Notification& notification,
            const ValidatorContext& context) {
        if (!IsAggregate(notification.EntityType))
            return ValidationResult::Success;

        if (context.Height < v2ForkHeight)
            return 1 == notification.EntityVersion ? ValidationResult::Success : Failure_Aggregate_V2_Prohibited;
        else
            return 2 <= notification.EntityVersion ? ValidationResult::Success : Failure_Aggregate_V1_Prohibited;
    }));
}

The fork height is passed down from the AggregatePlugin as the v2ForkHeight argument.

auto v2ForkHeight = manager.config().ForkHeights.StrictAggregateTransactionHash;
manager.addStatefulValidatorHook([v2ForkHeight](auto& builder) {
    builder.add(validators::CreateAggregateTransactionVersionValidator(v2ForkHeight));
});

Postscript

New features should be thoughtfully considered and added with care. The exact mechanism for enabling new functionality will depend on the specific functionality being added.

Ideally, features should be mostly self contained and have minimal changes outside of the targeted plugin and/or extension being modified or added. There should be a minimal but sufficient number of checks for feature activation. If you find yourself needing to check the fork height in many validators, take a step back and reconsider your design.

Remember, "move fast and break things" doesn't really work in the context of a financial fabric. Move slowly and don't break things!