MIP-37: FFS: Postconfirmation
- Description: Confirmations of superBlocks on L1. A sub-protocol of Fast Finality Settlement.
- Authors: Andreas Penzkofer
- Desiderata: MD-37, MD-4, MD-5
Abstract
Fast-Finality-Settlement (FFS) is proposed in MIP-34, with two confirmation mechanisms: one on the base chain level (L1) and one on the rollup/sidechain level (L2). This MIP details the mechanism on Level 1 (L1), which is called Postconfirmation.
The L2 produces L2Blocks. At certain intervals validators commit a sequence of L2Blocks in a superBlock, to L1. The L1 contract will verify if >2/3 of the validators have attested to a given superBlock height. The action for this validation is called Postconfirmation and it is initiated by the acceptor. The acceptor is a specific validator selected for some interval and it is added to the protocol to provide separation of concerns (attestations vs. Postconfirmation) and provide more predictable costs and rewards.
This provides an L1-protected guarantee that a superBlock (i.e. a sequence of L2Blocks) is accepted and correctly executed. This anchoring mechanism increases the security of the L2 as it protects the L2-state against long range attacks, see MD-5.
A introduction to Postconfirmation can also be found in this blog post and a more detailed description of a (partial) implementation of the mechanism is available at this blog post.
Motivation
We require from the FFS protocol that it is secure and efficient, yet simple in its initial design. In order for the protocol to fulfill the requirement for simplicity, validators only communicate to the L1-contract and not with each other. This is a key design decision to reduce the complexity of the protocol, but can be improved in the future.
We also request that rewards and costs are made more predictable for validators. For this, we propose a special role – the acceptor – to perform the action of updating the L1 contract to accept a given state by super majority. This action is called Postconfirmation.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Figure 1 shows the leader-independent (deterministic) block generation process, which is also discussed in MIP-34: Fast Finality Settlement.
Figure 1: Leader-independent (deterministic) block generation process.
Since this document introduces a large number of new terms, we provide a specification by defining the terms and their interactions.
Domains - one staking contract to rule them all
The L1 staking contract is intended to handle multiple chains. We differentiate between the chains by their unique identifier domain
(of type address
).
L2Blocks
L2Blocks are deterministically derived from the sequencer-batches, which are called protoBlocks. Validators calculate the next deterministic transition (imposed through the sequence of transactions ) , where and are L2Blocks.
SuperBlock
The Postconfirmation protocol is unlikely to attest to each individual L2Block. L2Blocks may be produced at higher frequency than L1Blocks or the cost of Postconfirmation is high leading to low frequency commitment. Therefore, after a defined number of L2blocks (or L1Blocks, if this is preferred), validators calculate the next (deterministic) superBlock and commit to it in the L1 contract. The L1 contract will verify if >2/3 of the validators have attested at a given superBlock height to a superBlock. Each incremental hight increase may be considered a round.
Commitment
Validators commit the hash of the superBlock on the L1-contract. It commits the validator to a certain superBlock at a given height, with no option for changing their opinion. (This is intentional - validators should not be able to revert).
struct superBlockCommitment {
uint256 height;
bytes32 commitment;
bytes32 superBlockId;
}
Epochs
We require epochs in order to provide
- secure on- and off-boarding process of validators. A validator MUST live in a well-defined state for a sufficient amount of time. The available states are
stake
,unstake
, andnot-staking
(in code only the former two may be required). This longevity of a given state both provides stability to the protocol, as well as is a security measure to prevent validators from harming the protocol towards the end of an epoch without implications, as they remain accountable for at least one epoch. - reward and penalty application. The borders between periods are also used to manage rewards and penalties. This also renders inefficiencies as calculation of voting weights could be expensive or attackable and thus it should be performed infrequent.
The epochDuration
MUST be set when initializing a chain. It MAY be changeable later on through a governance mechanism. The epochDuration
should be set to a value that is large enough to allow for the staking
and unstaking
process to be completed. Moreover, it should be long enough for human operators to react.
:bulb: The initial recommendation for
epochDuration
is 1 day.
There are three relevant epoch types
1. presentEpoch
presentEpoch
is the epoch that is currently active on L1.
uint256 presentEpoch = getEpochFromL1BlockTime();
where
function getEpochFromL1BlockTime(address domain) public view returns (uint256) {
return (block.timestamp - L1GenesisTime ) / epochDuration;
}
and L1GenesisTime
is the time when the L1 contract was deployed.
2. assignedEpoch
If a superBlock height is new, the current presentEpoch
value is assigned to the superBlock height.
/// map each block height to an epoch
mapping(uint256 blockHeight => uint256 epoch) public superBlockHeightAssignedEpoch;
superBlockCommitment memory superBlockCommitment
if (superBlockHeightAssignedEpoch[blockCommitment.height] == 0) {
superBlockHeightAssignedEpoch[blockCommitment.height] = getEpochFromL1BlockTime();
}
Any validator can commit the hash of a superBlock. The rollover function should update to the correct epoch for a given superBlock height (and the heights above).
:warning: This may result in an attack vector. An adversary could commit to far in the future superBlock heights. While this has no implications on the security, it may increase costs within the contract operation for the acceptor.
As an initial measure, the height of the superBlock should not be able to be set too far into the future. Hence there SHOULD be a leadingBlockTolerance
, that limits how far into the future a block can be added.
if (lastPostconfirmedBlockHeight + leadingBlockTolerance < blockCommitment.height) {
revert ValidatorAlreadyCommitted();
}
The validators have to check if the current superBlock height (off-L1) is within the above window. Otherwise the commitment of the (honest) validator will not be added to the L1 contract.
3. acceptingEpoch
Votes are counted in the current acceptingEpoch
. If there are enough commitments for a superBlockId
the superBlock height receives a Postconfirmation status.
The current acceptingEpoch
can be queried by
function getAcceptingEpoch() public view returns (uint256) {
return acceptingEpoch;
}
Staking and Unstaking
Validators can stake and unstake their tokens. The staking and unstaking process is managed by the staking contract. Validators can stake their tokens for a certain epoch. The staking process is initiated by the validator. The validator can also unstake their tokens, such that the stake is released at the end of the next epoch.
The reason for the delay in the unstaking process is to prevent validators from harming the protocol towards the end of an epoch without implications, and to remain accountable for at least one epoch. See also Section Epochs.
gantt
title Unstaking Timeline
dateFormat DD HH:mm
axisFormat %d %H:%M
section Epochs
Current epoch: active, currentEpoch, 01 00:00, 02 00:00
Unstake epoch: unstakeEpoch, 02 00:00, 03 00:00
Next epoch: releaseOfFundsEpoch, 03 00:01, 04 00:00
section Actions
Request for unstaking: milestone, 01 16:00, 1min
Release of Funds: milestone, 03 00:00, 0min
We require functions addStake
, removeStake
, addUnstake
, removeUnstake
to manage the staking and unstaking process.
We require the following mappings
// Type aliases for better readability
type Domain is address;
type Epoch is uint256;
type Custodian is address;
type Attester is address;
// Mappings
mapping(Domain => mapping(Epoch => mapping(Custodian => mapping(Attester => uint256)))) public epochStakesByDomain;
mapping(Domain => mapping(Epoch => mapping(Custodian => uint256))) public epochTotalStakeByDomain;
mapping(Domain => mapping(Epoch => mapping(Custodian => mapping(Attester => uint256)))) public epochUnstakesByDomain;
For example, the addition functions are
function _addStake(
address domain,
uint256 epoch,
address custodian,
address attester,
uint256 amount
) internal {
epochStakesByDomain[domain][epoch][custodian][attester] += amount;
epochTotalStakeByDomain[domain][epoch][custodian] += amount;
}
and
function _addUnstake(
address domain,
uint256 epoch,
address custodian,
address attester,
uint256 amount
) internal {
epochUnstakesByDomain[domain][epoch][custodian][attester] += amount;
}
Rollover
The protocol increases the acceptingEpoch
incrementally by one, i.e. the protocol progresses from one accepting epoch to the next. Whenever, such an incrementation happens, the stakes of the validators get adjusted to account for staking
and unstaking
events. This transition is called Rollover. On the default path the rolloverEpoch
function is called by the acceptor.
A rollover can occur in two types of paths:
- If the
assignedEpoch
of the next superBlock height falls into the next epoch, the protocol progresses to the next epoch.
uint256 nextSuperBlockHeight = thisPostconfirmationBlockHeight + 1;
uint256 nextSuperBlockEpoch = superBlockHeightAssignedEpoch[nextSuperBlockHeight];
while (getAcceptingEpoch() < NextSuperBlockEpoch) {
rollOverEpoch(); // this also increments the acceptingEpoch
}
- If the votes in the current
acceptingEpoch
are not sufficient to postconfirm a superBlock, and theacceptingEpoch
is less than thecurrentEpoch
, the protocol progresses to the next epoch.
if (!superMajorityReached(thisSuperBlockHeight) && getAcceptingEpoch() < presentEpoch) {
rollOverEpoch(); // this also increments the acceptingEpoch
}
:warning: Close to the epoch border there should be some buffer. Otherwise the protocol rolls over too early.
This step protects against liveness issues through inactive validators by taking advantage the L1 clock. Either the acceptor (or the volunteer-acceptor) has not been active for some time that is considered problematic for liveness, or over 1/3 of the validators have not been active in the acceptingEpoch
. Either way, the current acceptingEpoch
has not been live and should be skipped.
Acceptor
Every interval acceptorTerm
one of the validators takes on the role to perform the Postconfirmation functionality. This acceptor is responsible for updating the contract state once a super-majority is reached for a superBlock height. The acceptor is rewarded for this service, see the Rewards section. We note that this does not equate to a leader in a traditional consensus protocol, as the acceptor does not propose new states. Its role can also be taken over by a volunteer-acceptor.
:bulb: We separate the acceptor from the validators to achieve separation of concerns and simplify the reward mechanism for the validators. This addresses MD-4:D1.
The acceptor MUST be selected via L1-randomness and weighted by the stake. The randomness MAY be provided through L1Block hashes, which can be considered sufficiently random, initially. Alternative higher-quality randomness from L1 SHOULD be considered.
function getCurrentAcceptor() public view returns (address) {
uint256 currentL1BlockHeight = block.number;
// deduct finalizationBlockDepth because we should only consider finalized L1 blocks
uint256 relevantL1BlockHeight = currentL1BlockHeight - (currentL1BlockHeight / acceptorTerm ) - finalizationBlockDepth;
bytes32 blockHash = blockhash(relevantL1BlockHeight);
return = getAcceptorFromL1Randomness(blockHash);
function getAcceptorFromL1Randomness(bytes32 blockHash) internal view returns (address) {
// Implement logic to get the acceptor from the L1 randomness
// and that considers the stake.
}
Volunteer-acceptor
If the acceptor does not update the contract state for some time, this is negative for the liveness of the protocol. In particular if the acceptorTerm
is in the range for the time that is required for the leadingBlockTolerance
. Thus it is recommended that acceptorTerm
<< time(leadingBlockTolerance
). However, such a requirement may not be trivial to solve.
In order to guarantee liveness the protocol ensures that anyone can voluntarily provide the service of the acceptor. However, no reward is being issued for this service (unless the acceptor has missed the liveness window, see Rewards). The first volunteer-acceptor to provide the service after elapse of the liveness window will be accepted and receives the reward for the service. This is a liveness measure.
Rewards
Validators are rewarded for their service. The reward is calculated proportional to the validator stake and activity. The reward is issued in the next epoch.
The acceptor is rewarded for the service. The reward is calculated proportional to the activity. The reward is issued in the next epoch. The volunteer-acceptor is not rewarded unless the acceptor has missed the liveness window acceptorLivenessWindow
.
function rewardAcceptor(address acceptor) internal {
uint256 reward = calculateAcceptorReward(acceptor);
// Check if the acceptor has missed the liveness window
if (!getCurrentAcceptor() == acceptor) {
require(hasAcceptorMissedLivenessWindow(), "Volunteer-acceptor can only be rewarded if the acceptor missed the liveness window");
}
// add the reward to the reward queue, which gets processed at the end of the next epoch
addReward(acceptor, reward);
}
function hasAcceptorMissedLivenessWindow() internal view returns (bool) {
uint256 lastActivity = getAcceptorLastActivity();
uint256 currentTime = block.timestamp;
return currentTime > lastActivity + acceptorLivenessWindow;
}
function getAcceptorLastActivity() internal view returns (uint256) {
// This function should return the last recorded activity timestamp of the acceptor
// Placeholder implementation, replace with actual storage retrieval
return acceptorLastActivity;
}
(Optional) Slashing
With Postconfirmations alone nodes do not need to get slashed if they voted for an invalid commitment. Since only their first vote for a given height gets accepted by the contract, they cannot equivocate. More abstractly the L1 provides consensus on the votes. Slashing MAY be considered to prevent flooding the L1 contract with invalid commitments, however, this is also expensive for the adversary and at most causes additional cost to the acceptor.
:bulb: Nodes MAY get slashed if it has been proven that the validator voted more than once for a given block height on L2. This is a security measure to protect against long-range attacks. However, this is part of the scope of MIP-65: Fastconfirmations and not Postconfirmations.
Reference Implementation
A reference implementation for Postconfirmation is provided by MCR, see here.