Skip to content

Commit a4165af

Browse files
committed
fix: introduce snapshotted escape hatch
1 parent a90fadd commit a4165af

File tree

15 files changed

+262
-73
lines changed

15 files changed

+262
-73
lines changed

l1-contracts/src/core/EscapeHatch.sol

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -275,28 +275,45 @@ contract EscapeHatch is IEscapeHatch {
275275

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

278+
// Check if this contract was the active escape hatch for the entire active period.
279+
// If not, the proposer may have been unable to fulfill duties due to governance change.
280+
Epoch firstActiveEpoch = _getFirstEpoch(_hatch);
281+
Epoch lastActiveEpoch = firstActiveEpoch + Epoch.wrap(ACTIVE_DURATION - 1);
282+
bool wasActiveAtStart = address(ROLLUP.getEscapeHatchForEpoch(firstActiveEpoch)) == address(this);
283+
bool wasActiveAtEnd = address(ROLLUP.getEscapeHatchForEpoch(lastActiveEpoch)) == address(this);
284+
bool wasActiveEntirePeriod = wasActiveAtStart && wasActiveAtEnd;
285+
278286
bool success = true;
279287
uint256 punishment = 0;
280288

281-
// Check success conditions:
282-
// 1. Something must have been proposed
283-
if (data.lastCheckpointNumber == 0) {
284-
success = false;
285-
}
286-
287-
// 2. Proofs must have been submitted at least up to this checkpoint
288-
if (success && ROLLUP.getProvenCheckpointNumber() < data.lastCheckpointNumber) {
289-
success = false;
290-
}
291-
292-
// 3. The checkpoint archive must still be in the chain (not pruned)
293-
if (success && ROLLUP.archiveAt(data.lastCheckpointNumber) != data.lastSubmittedArchive) {
294-
success = false;
295-
}
296-
297-
if (!success) {
298-
punishment = FAILED_HATCH_PUNISHMENT;
299-
data.amount -= FAILED_HATCH_PUNISHMENT;
289+
if (!wasActiveEntirePeriod && data.lastCheckpointNumber == 0) {
290+
// Escape hatch was deactivated during the active window and proposer did nothing.
291+
// This is acceptable - they couldn't (or chose not to) propose during disruption.
292+
// Skip punishment, transition to EXITING.
293+
} else {
294+
// Normal validation: either was active the entire time, or proposer proposed something
295+
// (if they proposed, they're on the hook regardless of escape hatch changes,
296+
// since proofs go to the rollup directly and are unaffected by escape hatch changes).
297+
298+
// 1. Something must have been proposed
299+
if (data.lastCheckpointNumber == 0) {
300+
success = false;
301+
}
302+
303+
// 2. Proofs must have been submitted at least up to this checkpoint
304+
if (success && ROLLUP.getProvenCheckpointNumber() < data.lastCheckpointNumber) {
305+
success = false;
306+
}
307+
308+
// 3. The checkpoint archive must still be in the chain (not pruned)
309+
if (success && ROLLUP.archiveAt(data.lastCheckpointNumber) != data.lastSubmittedArchive) {
310+
success = false;
311+
}
312+
313+
if (!success) {
314+
punishment = FAILED_HATCH_PUNISHMENT;
315+
data.amount -= FAILED_HATCH_PUNISHMENT;
316+
}
300317
}
301318

302319
data.status = Status.EXITING;
@@ -547,6 +564,14 @@ contract EscapeHatch is IEscapeHatch {
547564
* @custom:reverts EscapeHatch__SetUnstable if called before the freeze timestamp (defense in depth)
548565
*/
549566
function selectCandidates() public override(IEscapeHatchCore) {
567+
// Don't select new candidates if this contract is no longer the active escape hatch.
568+
// We check the latest value rather than the epoch-stable one since we sample for the future,
569+
// so if the current differs, the future will as well.
570+
// Early return (not revert) is important because initiateExit() calls selectCandidates() internally.
571+
if (address(ROLLUP.getEscapeHatch()) != address(this)) {
572+
return;
573+
}
574+
550575
Hatch currentHatch = getCurrentHatch();
551576
Hatch targetHatch = currentHatch + Hatch.wrap(LAG_IN_HATCHES);
552577

l1-contracts/src/core/Rollup.sol

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,15 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore {
412412
return ValidatorOperationsExtLib.getEscapeHatch();
413413
}
414414

415+
/**
416+
* @notice Get the escape hatch contract that was active at the start of a given epoch
417+
* @param _epoch The epoch to look up the escape hatch for
418+
* @return The escape hatch contract interface that was active at the epoch start
419+
*/
420+
function getEscapeHatchForEpoch(Epoch _epoch) external view override(IValidatorSelection) returns (IEscapeHatch) {
421+
return ValidatorOperationsExtLib.getEscapeHatchForEpoch(_epoch);
422+
}
423+
415424
/**
416425
* @notice Get the sample seed for the current epoch
417426
*

l1-contracts/src/core/interfaces/IValidatorSelection.sol

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ struct ValidatorSelectionStorage {
1212
mapping(Epoch => bytes32 committeeCommitment) committeeCommitments;
1313
// Checkpointed map of epoch -> randao value
1414
Checkpoints.Trace224 randaos;
15-
// The following 3 uint32s + address pack into a single slot (12 + 20 = 32 bytes)
15+
// The following 3 uint32s pack into a single slot (12 bytes)
1616
uint32 targetCommitteeSize;
1717
uint32 lagInEpochsForValidatorSet;
1818
uint32 lagInEpochsForRandao;
19-
IEscapeHatch escapeHatch;
19+
// Checkpointed escape hatch addresses (key = timestamp, value = address as uint160)
20+
Checkpoints.Trace160 escapeHatchCheckpoints;
2021
}
2122

2223
interface IValidatorSelectionCore {
@@ -60,4 +61,5 @@ interface IValidatorSelection is IValidatorSelectionCore, IEmperor {
6061
function getTargetCommitteeSize() external view returns (uint256);
6162

6263
function getEscapeHatch() external view returns (IEscapeHatch);
64+
function getEscapeHatchForEpoch(Epoch _epoch) external view returns (IEscapeHatch);
6365
}

l1-contracts/src/core/libraries/rollup/EpochProofLib.sol

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,11 @@ library EpochProofLib {
308308
Epoch epoch = STFLib.getEpochForCheckpoint(_endCheckpointNumber);
309309

310310
// Check if this is an escape hatch epoch - skip attestation verification if so
311-
// since escape hatch blocks are proposed without committee attestations
311+
// since escape hatch blocks are proposed without committee attestations.
312+
// Uses epoch-stable lookup so proof verification uses the escape hatch that was
313+
// active when the epoch started, not whatever is currently configured.
312314
{
313-
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatch();
315+
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(epoch);
314316
if (address(escapeHatch) != address(0)) {
315317
(bool isOpen,) = escapeHatch.isHatchOpen(epoch);
316318
if (isOpen) {

l1-contracts/src/core/libraries/rollup/InvalidateLib.sol

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,11 @@ library InvalidateLib {
215215
Epoch epoch = checkpointLog.slotNumber.decompress().epochFromSlot();
216216

217217
// Check if this is an escape hatch epoch - escape hatch checkpoints cannot be invalidated
218-
// since they have no committee attestations by design
218+
// since they have no committee attestations by design.
219+
// Uses epoch-stable lookup so invalidation rules use the escape hatch that was
220+
// active when the epoch started, not whatever is currently configured.
219221
{
220-
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatch();
222+
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(epoch);
221223
if (address(escapeHatch) != address(0)) {
222224
(bool isOpen,) = escapeHatch.isHatchOpen(epoch);
223225
require(!isOpen, Errors.Rollup__CannotInvalidateEscapeHatch());

l1-contracts/src/core/libraries/rollup/ProposeLib.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,9 @@ library ProposeLib {
199199
v.headerHash = ProposedHeaderLib.hash(v.header);
200200

201201
// Compute current epoch and check escape hatch BEFORE setupEpoch.
202+
// Uses epoch-stable lookup so mid-epoch governance changes don't affect current epoch proposals.
202203
v.currentEpoch = Timestamp.wrap(block.timestamp).epochFromTimestamp();
203-
v.escapeHatch = ValidatorSelectionLib.getEscapeHatch();
204+
v.escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(v.currentEpoch);
204205
if (address(v.escapeHatch) != address(0)) {
205206
(v.isEscapeHatch, v.escapeHatchProposer) = v.escapeHatch.isHatchOpen(v.currentEpoch);
206207
}

l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ library ValidatorOperationsExtLib {
150150
return ValidatorSelectionLib.getEscapeHatch();
151151
}
152152

153+
function getEscapeHatchForEpoch(Epoch _epoch) external view returns (IEscapeHatch) {
154+
return ValidatorSelectionLib.getEscapeHatchForEpoch(_epoch);
155+
}
156+
153157
function getTargetCommitteeSize() external view returns (uint256) {
154158
return ValidatorSelectionLib.getStorage().targetCommitteeSize;
155159
}

l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ library ValidatorSelectionLib {
100100
using TimeLib for Epoch;
101101
using TimeLib for Slot;
102102
using Checkpoints for Checkpoints.Trace224;
103+
using Checkpoints for Checkpoints.Trace160;
103104
using SafeCast for *;
104105
using TransientSlot for *;
105106
using SlotDerivation for string;
@@ -157,7 +158,12 @@ library ValidatorSelectionLib {
157158
* @param _escapeHatch The address of the EscapeHatch contract, or address(0) to disable
158159
*/
159160
function updateEscapeHatch(address _escapeHatch) internal {
160-
getStorage().escapeHatch = IEscapeHatch(_escapeHatch);
161+
// Key the checkpoint to the START of the next epoch so the change never affects
162+
// the current epoch. This prevents a same-block governance action from retroactively
163+
// altering the escape hatch for an epoch where proposals may have already been made.
164+
Epoch nextEpoch = Timestamp.wrap(block.timestamp).epochFromTimestamp() + Epoch.wrap(1);
165+
uint96 nextEpochTs = uint96(Timestamp.unwrap(nextEpoch.toTimestamp()));
166+
getStorage().escapeHatchCheckpoints.push(nextEpochTs, uint160(_escapeHatch));
161167
}
162168

163169
/**
@@ -589,12 +595,26 @@ library ValidatorSelectionLib {
589595
}
590596

591597
/**
592-
* @notice Gets the escape hatch contract
593-
* @dev Returns the configured escape hatch, or a zero-address IEscapeHatch if disabled
598+
* @notice Gets the current escape hatch contract (latest checkpoint)
599+
* @dev Returns the most recently configured escape hatch, or a zero-address IEscapeHatch if none set
594600
* @return The escape hatch contract interface
595601
*/
596602
function getEscapeHatch() internal view returns (IEscapeHatch) {
597-
return getStorage().escapeHatch;
603+
return IEscapeHatch(address(getStorage().escapeHatchCheckpoints.latest()));
604+
}
605+
606+
/**
607+
* @notice Gets the escape hatch contract that was active at the start of a given epoch
608+
* @dev Uses `upperLookupRecent` to find the most recent checkpoint with key <= epoch start timestamp.
609+
* Changes pushed with `block.timestamp` during epoch N take effect for epoch N+1 (since epoch
610+
* N+1's start timestamp > the push timestamp > epoch N's start timestamp), providing implicit
611+
* epoch-boundary activation.
612+
* @param _epoch The epoch to look up the escape hatch for
613+
* @return The escape hatch contract interface that was active at the start of the epoch
614+
*/
615+
function getEscapeHatchForEpoch(Epoch _epoch) internal view returns (IEscapeHatch) {
616+
uint96 ts = uint96(Timestamp.unwrap(TimeLib.toTimestamp(_epoch)));
617+
return IEscapeHatch(address(getStorage().escapeHatchCheckpoints.upperLookupRecent(ts)));
598618
}
599619

600620
/**

l1-contracts/src/core/slashing/TallySlashingProposer.sol

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,15 +1020,13 @@ contract TallySlashingProposer is EIP712 {
10201020
function _getEscapeHatchEpochFlags(SlashRound _round) internal view returns (bool[] memory escapeHatchEpochs) {
10211021
escapeHatchEpochs = new bool[](ROUND_SIZE_IN_EPOCHS);
10221022

1023-
IEscapeHatch escapeHatch = IValidatorSelection(INSTANCE).getEscapeHatch();
1024-
1025-
// If no escape hatch is configured, return all-false quickly
1026-
if (address(escapeHatch) == address(0)) {
1027-
return escapeHatchEpochs;
1028-
}
1029-
10301023
for (uint256 epochIndex; epochIndex < ROUND_SIZE_IN_EPOCHS; epochIndex++) {
1031-
(bool isOpen,) = escapeHatch.isHatchOpen(getSlashTargetEpoch(_round, epochIndex));
1024+
Epoch epoch = getSlashTargetEpoch(_round, epochIndex);
1025+
IEscapeHatch escapeHatch = IValidatorSelection(INSTANCE).getEscapeHatchForEpoch(epoch);
1026+
if (address(escapeHatch) == address(0)) {
1027+
continue;
1028+
}
1029+
(bool isOpen,) = escapeHatch.isHatchOpen(epoch);
10321030
escapeHatchEpochs[epochIndex] = isOpen;
10331031
}
10341032
}

l1-contracts/test/escape-hatch/base.sol

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {Errors} from "@aztec/core/libraries/Errors.sol";
1111
import {Timestamp, Epoch} from "@aztec/shared/libraries/TimeMath.sol";
1212
import {IValidatorSelection} from "@aztec/core/interfaces/IValidatorSelection.sol";
1313
import {FakeRollup} from "./mocks/FakeRollup.sol";
14+
import {Ownable} from "@oz/access/Ownable.sol";
1415

1516
/// @notice Configuration struct for EscapeHatch deployment
1617
/// @dev Foundry can fuzz this struct directly when passed as a test parameter
@@ -98,6 +99,10 @@ contract EscapeHatchBase is TestBase {
9899
DEFAULT_PROPOSING_EXIT_DELAY
99100
);
100101

102+
// Register escape hatch with the rollup so selectCandidates deactivation guard passes
103+
vm.prank(Ownable(address(rollup)).owner());
104+
rollup.updateEscapeHatch(address(escapeHatch));
105+
101106
vm.label(address(rollup), "Rollup");
102107
vm.label(address(bondToken), "BondToken");
103108
vm.label(address(escapeHatch), "EscapeHatch");
@@ -132,6 +137,9 @@ contract EscapeHatchBase is TestBase {
132137
config.proposingExitDelay
133138
);
134139
vm.label(address(escapeHatch), "EscapeHatchWithFakeRollup");
140+
141+
// Register escape hatch with the fake rollup so selectCandidates deactivation guard passes
142+
fakeRollup.setEscapeHatch(address(escapeHatch));
135143
}
136144

137145
function _mintAndApprove(address _candidate, uint256 _amount) internal {
@@ -248,6 +256,14 @@ contract EscapeHatchBase is TestBase {
248256

249257
vm.label(address(escapeHatch), "FuzzedEscapeHatch");
250258

259+
// Register the new escape hatch so selectCandidates deactivation guard passes
260+
if (useFakeRollup) {
261+
fakeRollup.setEscapeHatch(address(escapeHatch));
262+
} else {
263+
vm.prank(Ownable(address(rollup)).owner());
264+
rollup.updateEscapeHatch(address(escapeHatch));
265+
}
266+
251267
// Warp to safe epoch to avoid HatchTooEarly errors
252268
_warpToSafeEpoch();
253269
_;

0 commit comments

Comments
 (0)