Sovryn Perimeter Delay: core contracts - #1
Draft
tjcloa wants to merge 3 commits into
Draft
Conversation
Phase 2 of the Sovryn security perimeter. Where the Perimeter Fee takes a cut of a user-initiated exit, the Perimeter Delay can hold the remainder for a governance-configured period, so a detected theft can be frozen or blacklisted and routed to recovery before the funds leave the protocol. - ExitDelayQueue: a per-request escrow holding the user leg of an exit until its unlock time. Requests are immutable once recorded, ingress is restricted to registered product sources, and the payout is settled before any external call. Only the originator or the position owner may execute a request — the receiver never can, so a withdrawal split into a fee leg and a delayed leg is always completed by the same actor who started it; - three ways out of the queue: normal execution after unlock, a pre-registered recovery route for funds belonging to a blacklisted party, and an owner-level catch-all bounded to requests that are blocked, paused or still locked, so a healthy in-flight exit can never be touched by governance; - self-service recovery for an undeliverable payout: the stored receiver is attempted first and an alternative is paid only if that genuinely bounces, which keeps a healthy exit from being redirected; - ExitFeeController gains the delay extension: a global kill switch independent of the fee switch, one global delay, and bypass tiers mirroring the fee tiers (actor, sub-product, surface) plus a surface-scoped passthrough registry for contracts that withdraw on a user's behalf; - deploy and verification scripts for the queue, its host wiring, and the go-live gates; unit, invariant and Echidna coverage for the queue. The delay ships disabled and is enabled only by governance after post-deployment verification.
The delay extension declared `securityPerimeterEnabled` and `globalDelaySeconds` ahead of `admin`. All three are small enough to share one slot, so Solidity packed them together and moved `admin` from offset 0 to offset 5 of slot 257 — a slot the deployed controller already uses. Upgrading the live proxy to that layout would have reinterpreted the stored admin address: the perimeter would have read as enabled with no governance action, the global delay as roughly 112 years, and `admin` as an address nobody holds. Nothing would have reverted. `admin` is now declared first and alone, exactly where the fee release put it, and the two delay scalars move into a slot reclaimed from `__gap`. Slot 271 is then closed with an explicit reservation: left half-used, its 27 free bytes would capture the next field any future upgrade appends, landing it outside the reserved gap and tripping the same class of check. The layout mirrors in the upgrade-safety fixtures move with it. tools/diff-storage-layouts.py now reports the candidate upgrade-safe against the recorded mainnet layout, where it previously failed; the fixture harness accepts the positive case and still rejects both negatives. Behaviour is unchanged — no logic, no interface, no ABI difference — and the suite is green at 389 tests.
The storage section had drifted into narrating how the layout came about — which release shipped which slot, why one placement was chosen over another, what a verification tool would or would not accept. None of that helps someone reading or integrating the contract, and it goes stale the moment it deploys. That material belongs in the spec repo, the commit log and the PR. The layout block now states the current layout as fact and keeps the forward-looking rule: new state consumes from __gap, nothing is reordered, and nothing is declared before `admin` or packed into the free bytes of its slot. Also drops __slot271Reserved. It consumed no slot of its own and guarded nothing about the present layout; it existed only so that a later upgrade could not pack a small field into the 27 spare bytes of slot 271 — writing into guaranteed-zero padding, which is safe, and which the layout checker rejects only out of conservatism. A storage variable that exists to satisfy a tool, and needs a paragraph of justification to explain itself, is worse than the spare bytes it was protecting. The upgrade-safety fixture now demonstrates a packed addition in a fresh slot instead. Layout unchanged where it matters: `admin` at slot 257 offset 0, delay scalars packed at 271, __gap[29] at 272..300. Checker still reports upgrade-safe against the recorded mainnet layout, the fixture harness passes all three scenarios, and the suite is green at 389 tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of the Sovryn security perimeter — the core contracts.
Where the Perimeter Fee takes a cut of a user-initiated exit, the Perimeter Delay can hold the remainder for a governance-configured period, so a detected theft can be frozen or blacklisted and routed to recovery before the funds leave the protocol.
What this carries
ExitDelayQueue— a per-request escrow holding the user leg of an exit until its unlock time. Requests are immutable once recorded, ingress is restricted to registered product sources, and payouts settle state before any external call. Only the originator or the position owner may execute a request; the receiver never can, so a withdrawal split into a fee leg and a delayed leg is always completed by the actor who started it.ExitFeeControllerdelay extension: a kill switch independent of the fee switch, one global delay, bypass tiers mirroring the fee tiers (actor, sub-product, surface), and a surface-scoped passthrough registry for contracts that withdraw on a user's behalf.The delay ships disabled and is enabled only by governance after post-deployment verification.
Storage layout
The controller is upgraded in place, so its layout has to stay compatible with the implementation already live on RSK mainnet.
adminkeeps the slot the fee release shipped, alone and at offset 0; all new state comes from__gap. Slot 271 is closed with an explicit reservation so a future upgrade cannot accidentally pack into its free bytes and land outside the reserved gap.An earlier revision of this work declared the two delay scalars ahead of
admin. All three fit in one slot, so Solidity packed them and movedadminwithin it — which would have made the live proxy read the perimeter as enabled, the delay as roughly 112 years, andadminas an address nobody holds, with nothing reverting. That is corrected here, andtools/diff-storage-layouts.pynow reports the candidate upgrade-safe against the recorded mainnet layout.Verification
forge fmtclean.tools/diff-storage-layouts.pyagainstdeployments/30/ExitFeeController.json: upgrade-safe.tools/test-upgrade-safety.sh: positive fixture accepted, both negative fixtures rejected.Still outstanding
Delta and human security review, an independent custody audit of
ExitDelayQueue, deeper fuzz/Echidna, the reentrancy audit gate (Stage 2 introduces the first user-facing payout paths from the vault), and the cross-repo mainnet-fork integration test. The product-side branches carry a separate EIP-170 contract-size blocker.