Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 64 additions & 22 deletions l1-contracts/src/core/EscapeHatch.sol
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ contract EscapeHatch is IEscapeHatch {

require(_frequency > _activeDuration, Errors.EscapeHatch__InvalidConfiguration());

// BOND_SIZE must be non-zero to ensure "something at stake"
require(_bondSize > 0, Errors.EscapeHatch__InvalidConfiguration());

// FAILED_HATCH_PUNISHMENT must be <= BOND_SIZE to avoid underflow
require(_failedHatchPunishment <= _bondSize, Errors.EscapeHatch__InvalidConfiguration());

Expand Down Expand Up @@ -157,19 +160,29 @@ contract EscapeHatch is IEscapeHatch {

BOND_TOKEN.safeTransferFrom(candidate, address(this), BOND_SIZE);

emit CandidateJoined(candidate, BOND_SIZE);
emit CandidateJoined(candidate);
}

/**
* @notice Initiate exit from the candidate set
*
* @dev The exit may be immediate or delayed depending on timing relative to next hatch
* @dev The exit may be immediate or delayed depending on timing relative to next hatch.
*
* Calls selectCandidates() first, which may select the caller as the designated
* proposer for an upcoming hatch. If the caller is selected, their status transitions
* to PROPOSING and they are removed from $activeCandidates, causing the subsequent
* checks to revert. This is intentional - a designated proposer cannot exit and must
* instead follow the PROPOSING -> validateProofSubmission -> EXITING -> leaveCandidateSet
* flow.
*
* @custom:reverts EscapeHatch__NotInCandidateSet if caller is not in the candidate set
* (including when caller was just selected as proposer by selectCandidates)
* @custom:reverts EscapeHatch__InvalidStatus if caller's status is not ACTIVE
*/
function initiateExit() external override(IEscapeHatchCore) {
// Ensure current hatch is prepared (may select the caller). Makes our later checks much simpler.
// Prepare the current hatch. If this selects the caller as designated proposer, their
// status becomes PROPOSING and they are removed from $activeCandidates. The requires
// below will then revert, preventing a designated proposer from exiting.
selectCandidates();

address candidate = msg.sender;
Expand Down Expand Up @@ -275,28 +288,49 @@ contract EscapeHatch is IEscapeHatch {

require(block.timestamp >= data.exitableAt, Errors.EscapeHatch__NotExitableYet(data.exitableAt, block.timestamp));

bool success = true;
uint256 punishment = 0;

// Check success conditions:
// 1. Something must have been proposed
if (data.lastCheckpointNumber == 0) {
success = false;
}

// 2. Proofs must have been submitted at least up to this checkpoint
if (success && ROLLUP.getProvenCheckpointNumber() < data.lastCheckpointNumber) {
success = false;
// Check if this contract was the active escape hatch for the entire active period.
// If not, the proposer may have been unable to fulfill duties due to governance change.
Epoch firstActiveEpoch = _getFirstEpoch(_hatch);
bool wasActiveEntirePeriod = true;
for (uint256 i = 0; i < ACTIVE_DURATION; i++) {
Epoch epoch = firstActiveEpoch + Epoch.wrap(i);
if (address(ROLLUP.getEscapeHatchForEpoch(epoch)) != address(this)) {
wasActiveEntirePeriod = false;
break;
}
}

// 3. The checkpoint archive must still be in the chain (not pruned)
if (success && ROLLUP.archiveAt(data.lastCheckpointNumber) != data.lastSubmittedArchive) {
success = false;
}
bool success = true;
uint256 punishment = 0;

if (!success) {
punishment = FAILED_HATCH_PUNISHMENT;
data.amount -= FAILED_HATCH_PUNISHMENT;
if (!wasActiveEntirePeriod && data.lastCheckpointNumber == 0) {
// Escape hatch was deactivated during the active window and proposer did nothing.
// This is acceptable - they couldn't (or chose not to) propose during disruption.
// Skip punishment, transition to EXITING.
} else {
// Normal validation: either was active the entire time, or proposer proposed something
// (if they proposed, they're on the hook regardless of escape hatch changes,
// since proofs go to the rollup directly and are unaffected by escape hatch changes).

// 1. Something must have been proposed
if (data.lastCheckpointNumber == 0) {
success = false;
}

// 2. Proofs must have been submitted at least up to this checkpoint
if (success && ROLLUP.getProvenCheckpointNumber() < data.lastCheckpointNumber) {
success = false;
}

// 3. The checkpoint archive must still be in the chain (not pruned)
if (success && ROLLUP.archiveAt(data.lastCheckpointNumber) != data.lastSubmittedArchive) {
success = false;
}

if (!success) {
punishment = FAILED_HATCH_PUNISHMENT;
data.amount -= FAILED_HATCH_PUNISHMENT;
}
}

data.status = Status.EXITING;
Expand Down Expand Up @@ -547,6 +581,14 @@ contract EscapeHatch is IEscapeHatch {
* @custom:reverts EscapeHatch__SetUnstable if called before the freeze timestamp (defense in depth)
*/
function selectCandidates() public override(IEscapeHatchCore) {
// Don't select new candidates if this contract is no longer the active escape hatch.
// We check the latest value rather than the epoch-stable one since we sample for the future,
// so if the current differs, the future will as well.
// Early return (not revert) is important because initiateExit() calls selectCandidates() internally.
if (address(ROLLUP.getEscapeHatch()) != address(this)) {
return;
}

Hatch currentHatch = getCurrentHatch();
Hatch targetHatch = currentHatch + Hatch.wrap(LAG_IN_HATCHES);

Expand Down
9 changes: 9 additions & 0 deletions l1-contracts/src/core/Rollup.sol
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,15 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore {
return ValidatorOperationsExtLib.getEscapeHatch();
}

/**
* @notice Get the escape hatch contract that was active at the start of a given epoch
* @param _epoch The epoch to look up the escape hatch for
* @return The escape hatch contract interface that was active at the epoch start
*/
function getEscapeHatchForEpoch(Epoch _epoch) external view override(IValidatorSelection) returns (IEscapeHatch) {
return ValidatorOperationsExtLib.getEscapeHatchForEpoch(_epoch);
}

/**
* @notice Get the sample seed for the current epoch
*
Expand Down
2 changes: 1 addition & 1 deletion l1-contracts/src/core/interfaces/IEscapeHatch.sol
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct CandidateInfo {
}

interface IEscapeHatchCore {
event CandidateJoined(address indexed candidate, uint256 amount);
event CandidateJoined(address indexed candidate);
event CandidateExitInitiated(address indexed candidate, uint256 exitableAt);
event CandidateExited(address indexed candidate, uint256 amountReturned);
event CandidateSelected(Hatch indexed hatch, address indexed candidate);
Expand Down
6 changes: 4 additions & 2 deletions l1-contracts/src/core/interfaces/IValidatorSelection.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ struct ValidatorSelectionStorage {
mapping(Epoch => bytes32 committeeCommitment) committeeCommitments;
// Checkpointed map of epoch -> randao value
Checkpoints.Trace224 randaos;
// The following 3 uint32s + address pack into a single slot (12 + 20 = 32 bytes)
// The following 3 uint32s pack into a single slot (12 bytes)
uint32 targetCommitteeSize;
uint32 lagInEpochsForValidatorSet;
uint32 lagInEpochsForRandao;
IEscapeHatch escapeHatch;
// Checkpointed escape hatch addresses (key = timestamp, value = address as uint160)
Checkpoints.Trace160 escapeHatchCheckpoints;
}

interface IValidatorSelectionCore {
Expand Down Expand Up @@ -60,4 +61,5 @@ interface IValidatorSelection is IValidatorSelectionCore, IEmperor {
function getTargetCommitteeSize() external view returns (uint256);

function getEscapeHatch() external view returns (IEscapeHatch);
function getEscapeHatchForEpoch(Epoch _epoch) external view returns (IEscapeHatch);
}
1 change: 1 addition & 0 deletions l1-contracts/src/core/libraries/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ library Errors {
error TallySlashingProposer__InvalidEpochIndex(uint256 epochIndex, uint256 roundSizeInEpochs);
error TallySlashingProposer__VoteSizeTooBig(uint256 voteSize, uint256 maxSize);
error TallySlashingProposer__VotesMustBeMultipleOf4(uint256 votes);
error TallySlashingProposer__SlashAmountMustBeGtZero(string info);

// SlashPayloadLib
error SlashPayload_ArraySizeMismatch(uint256 expected, uint256 actual);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function subEthValue(EthValue _a, EthValue _b) pure returns (EthValue) {

using {addEthValue as +, subEthValue as -} for EthValue global;

// 64 bit manaTarget, 128 bit congestionUpdateFraction, 64 bit provingCostPerMana
// 32 bit manaTarget, 128 bit congestionUpdateFraction, 64 bit provingCostPerMana
type CompressedFeeConfig is uint256;

struct FeeConfig {
Expand Down
11 changes: 6 additions & 5 deletions l1-contracts/src/core/libraries/rollup/EpochProofLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -303,14 +303,15 @@ library EpochProofLib {
bytes32 providedAttestationsHash = keccak256(abi.encode(_attestations));
require(providedAttestationsHash == checkpointLog.attestationsHash, Errors.Rollup__InvalidAttestations());

// Get the slot and epoch for the last checkpoint
Slot slot = checkpointLog.slotNumber.decompress();
// Get the epoch for the last checkpoint
Epoch epoch = STFLib.getEpochForCheckpoint(_endCheckpointNumber);

// Check if this is an escape hatch epoch - skip attestation verification if so
// since escape hatch blocks are proposed without committee attestations
// since escape hatch blocks are proposed without committee attestations.
// Uses epoch-stable lookup so proof verification uses the escape hatch that was
// active when the epoch started, not whatever is currently configured.
{
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatch();
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(epoch);
if (address(escapeHatch) != address(0)) {
(bool isOpen,) = escapeHatch.isHatchOpen(epoch);
if (isOpen) {
Expand All @@ -320,7 +321,7 @@ library EpochProofLib {
}
}

ValidatorSelectionLib.verifyAttestations(slot, epoch, _attestations, checkpointLog.payloadDigest);
ValidatorSelectionLib.verifyAttestations(epoch, _attestations, checkpointLog.payloadDigest);
}

/**
Expand Down
1 change: 0 additions & 1 deletion l1-contracts/src/core/libraries/rollup/FeeLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ struct ManaMinFeeComponents {
struct FeeStore {
CompressedFeeConfig config;
L1GasOracleValues l1GasOracleValues;
mapping(uint256 checkpointNumber => CompressedFeeHeader feeHeader) feeHeaders;
}

library FeeLib {
Expand Down
6 changes: 4 additions & 2 deletions l1-contracts/src/core/libraries/rollup/InvalidateLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,11 @@ library InvalidateLib {
Epoch epoch = checkpointLog.slotNumber.decompress().epochFromSlot();

// Check if this is an escape hatch epoch - escape hatch checkpoints cannot be invalidated
// since they have no committee attestations by design
// since they have no committee attestations by design.
// Uses epoch-stable lookup so invalidation rules use the escape hatch that was
// active when the epoch started, not whatever is currently configured.
{
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatch();
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(epoch);
if (address(escapeHatch) != address(0)) {
(bool isOpen,) = escapeHatch.isHatchOpen(epoch);
require(!isOpen, Errors.Rollup__CannotInvalidateEscapeHatch());
Expand Down
3 changes: 2 additions & 1 deletion l1-contracts/src/core/libraries/rollup/ProposeLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ library ProposeLib {
v.headerHash = ProposedHeaderLib.hash(v.header);

// Compute current epoch and check escape hatch BEFORE setupEpoch.
// Uses epoch-stable lookup so mid-epoch governance changes don't affect current epoch proposals.
v.currentEpoch = Timestamp.wrap(block.timestamp).epochFromTimestamp();
v.escapeHatch = ValidatorSelectionLib.getEscapeHatch();
v.escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(v.currentEpoch);
if (address(v.escapeHatch) != address(0)) {
(v.isEscapeHatch, v.escapeHatchProposer) = v.escapeHatch.isHatchOpen(v.currentEpoch);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ library RollupOperationsExtLib {

Slot slot = _args.header.slotNumber;
Epoch epoch = slot.epochFromSlot();
ValidatorSelectionLib.verifyAttestations(slot, epoch, _attestations, _args.digest);
ValidatorSelectionLib.verifyAttestations(epoch, _attestations, _args.digest);
ValidatorSelectionLib.verifyProposer(
slot, epoch, _attestations, _signers, _args.digest, _attestationsAndSignersSignature, false
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ library ValidatorOperationsExtLib {
return ValidatorSelectionLib.getEscapeHatch();
}

function getEscapeHatchForEpoch(Epoch _epoch) external view returns (IEscapeHatch) {
return ValidatorSelectionLib.getEscapeHatchForEpoch(_epoch);
}

function getTargetCommitteeSize() external view returns (uint256) {
return ValidatorSelectionLib.getStorage().targetCommitteeSize;
}
Expand Down
41 changes: 27 additions & 14 deletions l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ library ValidatorSelectionLib {
using TimeLib for Epoch;
using TimeLib for Slot;
using Checkpoints for Checkpoints.Trace224;
using Checkpoints for Checkpoints.Trace160;
using SafeCast for *;
using TransientSlot for *;
using SlotDerivation for string;
Expand All @@ -109,14 +110,12 @@ library ValidatorSelectionLib {
/**
* @dev Stack struct used in verifyAttestations to avoid stack too deep errors
* Used when reconstructing the committee commitment from the attestations
* @param proposerIndex Index of the proposer within the committee
* @param index Working index for iteration (unused in current implementation)
* @param needed Number of signatures required (2/3 + 1 of committee size)
* @param signaturesRecovered Number of valid signatures found
* @param reconstructedCommittee Array of committee member addresses reconstructed from attestations
*/
struct VerifyStack {
uint256 proposerIndex;
uint256 index;
uint256 needed;
uint256 signaturesRecovered;
Expand Down Expand Up @@ -157,7 +156,12 @@ library ValidatorSelectionLib {
* @param _escapeHatch The address of the EscapeHatch contract, or address(0) to disable
*/
function updateEscapeHatch(address _escapeHatch) internal {
getStorage().escapeHatch = IEscapeHatch(_escapeHatch);
// Key the checkpoint to the START of the next epoch so the change never affects
// the current epoch. This prevents a same-block governance action from retroactively
// altering the escape hatch for an epoch where proposals may have already been made.
Epoch nextEpoch = Timestamp.wrap(block.timestamp).epochFromTimestamp() + Epoch.wrap(1);
uint96 nextEpochTs = uint96(Timestamp.unwrap(nextEpoch.toTimestamp()));
getStorage().escapeHatchCheckpoints.push(nextEpochTs, uint160(_escapeHatch));
}

/**
Expand Down Expand Up @@ -302,7 +306,6 @@ library ValidatorSelectionLib {
* directly from calldata.
*
* Skips validation entirely if target committee size is 0 (test configurations).
* @param _slot The slot of the checkpoint
* @param _epochNumber The epoch of the checkpoint
* @param _attestations The packed signatures and addresses of committee members
* @param _digest The digest of the checkpoint that attestations are signed over
Expand All @@ -311,12 +314,9 @@ library ValidatorSelectionLib {
* stored commitment
* @custom:reverts Errors.ValidatorSelection__EpochNotStable if the requested epoch is not stable
*/
function verifyAttestations(
Slot _slot,
Epoch _epochNumber,
CommitteeAttestations memory _attestations,
bytes32 _digest
) internal {
function verifyAttestations(Epoch _epochNumber, CommitteeAttestations memory _attestations, bytes32 _digest)
internal
{
(bytes32 committeeCommitment, uint256 targetCommitteeSize) = getCommitteeCommitmentAt(_epochNumber);

// If the rollup is *deployed* with a target committee size of 0, we skip the validation.
Expand All @@ -327,7 +327,6 @@ library ValidatorSelectionLib {
}

VerifyStack memory stack = VerifyStack({
proposerIndex: computeProposerIndex(_epochNumber, _slot, getSampleSeed(_epochNumber), targetCommitteeSize),
needed: (targetCommitteeSize << 1) / 3 + 1, // targetCommitteeSize * 2 / 3 + 1, but cheaper
index: 0,
signaturesRecovered: 0,
Expand Down Expand Up @@ -589,12 +588,26 @@ library ValidatorSelectionLib {
}

/**
* @notice Gets the escape hatch contract
* @dev Returns the configured escape hatch, or a zero-address IEscapeHatch if disabled
* @notice Gets the current escape hatch contract (latest checkpoint)
* @dev Returns the most recently configured escape hatch, or a zero-address IEscapeHatch if none set
* @return The escape hatch contract interface
*/
function getEscapeHatch() internal view returns (IEscapeHatch) {
return getStorage().escapeHatch;
return IEscapeHatch(address(getStorage().escapeHatchCheckpoints.latest()));
}

/**
* @notice Gets the escape hatch contract that was active at the start of a given epoch
* @dev Uses `upperLookupRecent` to find the most recent checkpoint with key <= epoch start timestamp.
* Changes pushed with `block.timestamp` during epoch N take effect for epoch N+1 (since epoch
* N+1's start timestamp > the push timestamp > epoch N's start timestamp), providing implicit
* epoch-boundary activation.
* @param _epoch The epoch to look up the escape hatch for
* @return The escape hatch contract interface that was active at the start of the epoch
*/
function getEscapeHatchForEpoch(Epoch _epoch) internal view returns (IEscapeHatch) {
uint96 ts = uint96(Timestamp.unwrap(TimeLib.toTimestamp(_epoch)));
return IEscapeHatch(address(getStorage().escapeHatchCheckpoints.upperLookupRecent(ts)));
}

/**
Expand Down
Loading
Loading