Skip to content

Sovryn Perimeter Delay: core contracts - #1

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

Sovryn Perimeter Delay: core contracts#1
tjcloa wants to merge 9 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 9 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.
The core's half of the Phase 1 re-cut. Surface ids are keccak256 of the names
the bootstrap script passes, so renaming the names moves every id -- which is
the point: Phase 2 inherits them rather than re-bootstrapping a live controller
off one set of ids onto another.

  SURFACE_*   -> PERIMETER_SURFACE_*
  COLFEE_*    -> PERIMETER_*   (bootstrap env inputs, matching the delay branch)

The env rename changes the bootstrap invocation: the rate and enable inputs are
now PERIMETER_LENDING_LENDER_BPS, PERIMETER_LENDING_BORROWER_BPS,
PERIMETER_ZERO_WITHDRAW_COLL_BPS, PERIMETER_ZERO_CLAIM_SURPLUS_BPS and
PERIMETER_ENABLE_AT_DEPLOY. Every one stays required -- no silent defaults.

PinnedIdentifiers pins all five ids to the literal 32 bytes they must hash to,
the same values the lending and Zero repos pin. Four repos declare these names
independently, so drift between them does not fail loudly: the controller
resolves no policy and the fee stops being charged.

107 tests passing, unchanged from before the rename.
The bootstrap and inspect scripts hashed keccak256(abi.encodePacked("COLFEE:",
name)). The rename swept that literal to "PERIMETER:" and left the
concatenation in place, so every id would have been keccak256("PERIMETER:" +
"PERIMETER_SURFACE_...") -- matching neither what Phase 1 deployed nor what the
consumer contracts derive.

The namespace is part of the name now, so both scripts hash the name alone and
the ids agree with the lending and Zero consumers byte for byte.
The rename swept three comments in ExitFeeController and its interface. The
controller is already deployed, already verified, and its ownership transfer to
the Exchequer multisig is mid-flight -- Phase 1's re-cut does not redeploy it,
because the controller encodes no surface names and stores policy as storage
keyed by id.

Comments are covered by the metadata hash, so those three edits changed the
runtime bytecode: identical body, different tail. The branch would no longer
reproduce what is on chain, and a later verification against the release source
would come back mismatched for a contract nobody had touched.

The word survives in three comments of a live contract until Phase 2 upgrades
it for the delay, which redeploys and re-verifies anyway. The scripts and tests
keep the rename -- that is where the surface names actually live.
The earlier revert kept src/ byte-reproducible against the deployed controller,
on the assumption that Phase 1 would not redeploy it. Tyrone's call: the
deployed contracts are dropped and redeployed with the back-port, so there is
nothing on chain left to reproduce and no reason to leave the old product name
sitting in a contract that is about to be replaced.

Consequences, which belong in the release plan rather than here: the vault and
controller get new addresses, the Exchequer acceptOwnership transactions 2207
and 2208 point at contracts that will not be the release, and everything that
pins a controller address -- the dapp, the SIP builders, the runbook anchors --
re-pins.
The un-revert that carried the rename back into src/ also restored a NatSpec
line naming PerimeterSkipped and PerimeterApplied. The events are ExitFeeSkipped
and ExitFeeApplied; the Perimeter* names belong to Zero's Echidna harness. Zero's
vendored copy of this interface was already corrected -- this is the same fix on
the original.
…with it

Brings this branch up to the reviewed state of the fee line and carries the
delay work alongside it: the pinned surface ids asserted against literal bytes,
the corrected README env name, and NatSpec naming the events this interface
actually declares.

Resolution where the two lines differ: src keeps this branch's side, since the
fee line's interface lacks the delay bypass types this branch's controller
compiles against. The one correction from the fee line is applied on top rather
than by taking its file wholesale.

416 tests passing.
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