Skip to content

Sovryn Perimeter Delay: core contracts - #1

Draft
tjcloa wants to merge 3 commits into
mainfrom
sovryn-perimeter-delay
Draft

Sovryn Perimeter Delay: core contracts#1
tjcloa wants to merge 3 commits into
mainfrom
sovryn-perimeter-delay

Conversation

@tjcloa

@tjcloa tjcloa commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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.
  • Three ways out: 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 — 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, so a healthy exit cannot be redirected.
  • ExitFeeController delay 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.
  • Deploy and verification scripts for the queue, its host wiring and the go-live gates; unit, invariant and Echidna coverage.

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. admin keeps 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 moved admin within it — which would have made the live proxy read the perimeter as enabled, the delay as roughly 112 years, and admin as an address nobody holds, with nothing reverting. That is corrected here, and tools/diff-storage-layouts.py now reports the candidate upgrade-safe against the recorded mainnet layout.

Verification

  • Builds; forge fmt clean.
  • 389 tests passing, 0 failing.
  • tools/diff-storage-layouts.py against deployments/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.

tjcloa added 3 commits August 17, 2026 18:04
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant