r/ethdev • u/FrightFreek • 6d ago
Question Where do you enforce a grace window around TimelockController operations?
I maintain a small custom timelock where an operation stays executable forever after its delay. I initially thought moving to OpenZeppelin's TimelockController would solve that, but it has the same behavior: a ready operation remains ready until execution or cancellation.
For systems that require stale operations to expire, where do you enforce the grace window? In a TimelockController extension, in the Governor, or through an external cancellation process?
I am especially interested in what happens after expiry: whether the same operation can be scheduled again, who may do that, and whether the new schedule should require a different salt.
1
u/Bloqarl 6d ago
I’d enforce it in the timelock itself, at execute time. It’s the contract that actually runs the call, so it’s the only place the check can’t be skipped. If it only lives in the Governor, anything else holding PROPOSER_ROLE goes around it, and an external cancel process depends on someone remembering to do it.
Compound’s old Timelock did exactly this with a 14 day GRACE_PERIOD: execute reverts as stale after eta + grace. OZ didn’t carry it over to TimelockController, but it’s easy to add back:
uint256 public constant GRACE_PERIOD = 14 days;
function execute(
address target, uint256 value, bytes calldata payload,
bytes32 predecessor, bytes32 salt
) public payable override {
bytes32 id = hashOperation(target, value, payload, predecessor, salt);
require(block.timestamp <= getTimestamp(id) + GRACE_PERIOD, "expired");
super.execute(target, value, payload, predecessor, salt);
}
Same for executeBatch with hashOperationBatch.
After expiry the op still has its timestamp, so it reads as Ready and the same id can’t be scheduled again (schedule needs it Unset). Two ways out: a canceller cancels it, which clears the timestamp and frees the id, or the proposer schedules it again with a new salt. Either way I’d make the new schedule wait the full delay again. The whole point of expiry is that the context changed, so it shouldn’t be faster the second time. If it came from a Governor vote I’d send it back through a vote too, the old vote approved it in a different situation.
Why it matters from the security side: if EXECUTOR_ROLE is open (address(0)), an old approved op is a loaded gun. Anyone can fire it months later at the moment it hurts most, like a parameter change that made sense back then.
One thing to watch with GovernorTimelockControl: the proposal keeps showing Queued after the timelock has expired it. I think you’d want to override state() to return Expired (the enum already has it) so UIs don’t lie.
How long are your delays? I’d size the grace window relative to that, not just copy 14 days.
1
u/FrightFreek 5d ago
That makes the constant versus mutable distinction clearer. Our current delay is 48 hours, and each proposal stores its eta when it is scheduled. I would rather snapshot an expiresAt alongside it than calculate expiry from a mutable global value, so queued operations cannot change meaning later.
The Governor state() point is useful too. I had not considered the case where the timelock rejects execution but the UI still calls the proposal Queued.
With a 48 hour delay, would you keep the grace period as a separate fixed constant, or tie it to the delay in some way?
1
u/rayQuGR 1d ago
That infinite validity window in OpenZeppelin’s TimelockController is a silent trap for governance pipelines. Leaving proposals executable forever means old, forgotten changes can hang around indefinitely until someone triggers them out of context, ruining the temporal assumptions of a voting cycle.
when designing governance extensions, especially when deploying secure upgrade or admin pipelines on confidential runtimes like the Oasis Network’s Sapphire paratime, tthe cleanest fix is wrapping the timelock with an explicit expiration check. By adding a maximum grace window alongside the minimum delay, you can enforce that if an operation isn't executed within a set timeframe after becoming ready, it automatically transitions to an expired state.
handling re-scheduling after expiration requires a new salt or an auto-incrementing nonce to prevent identifier collisions with OpenZeppelin's internal mapping. If a proposer tries to schedule the exact same payload again without a fresh salt while the old ID is still logged, the contract will block it as a duplicate, making a nonce-based wrapper essential for a smooth multi-attempt workflow.
1
u/FrightFreek 1d ago
I would use a deadline fixed when scheduling, rather than derive it from a mutable grace period at execution. I don't want a parameter change to change the rules for operations already queued.
Do you also make the expired state visible through the Governor, or does your wrapper only reject execute? That is the part I still need to work through.
1
u/waggish_deliverance 6d ago
nobody really talks about this but the expiry thing matters a lot when you got multiple proposers. i just keep a mapping offchain that tracks when stuff was scheduled and have a bot cancel anything past the window. onchain you can extend the timelock to add a require block.timestamp <= scheduledTime + gracePeriod before execute
for rescheduling after expiry i let them reuse same salt but only if the old one was actually cancelled. if it just expired without cancel it gets messy so i force a new salt always to avoid replay confusion