Sovryn Perimeter Delay: core contracts - #1
Draft
tjcloa wants to merge 20 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.
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.
The deploy scripts require ten explicit inputs and revert on any that is absent. Until now those lived only in whatever shell the operator happened to be using, which made a deployment impossible to reproduce or review. The template records each one with what it is and why it has the value it has: the Exchequer as owner and administrator, 10 bps on each charging surface, and charging shipped disabled. It also carries the RPC and the broadcast timeout, and marks the vault proxy as the one value that cannot be known before script 01 runs. No secrets: the signing key stays in the keystore behind --account, and .gitignore already excludes .env while keeping this template.
Deployment artifacts and broadcast logs for chain 30, deployed 2026-08-24
after the identifier re-cut:
ExitFeeVault proxy 0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B
impl 0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4
ExitFeeController proxy 0x99994b4522483DE17F31a5bC010c5901AdD3440E
impl 0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d
Each artifact carries the proxy and implementation addresses, the runtime
bytecode hash, deploy block and transaction, the ABI, and the storage layout
future upgrade checks diff against. Both implementations verify on the
Rootstock explorer as full matches.
The system is deployed DISABLED: the four charging surfaces are registered
active at 10 bps under the renamed identifiers and the AMM surface shut, but
charging stays off until governance executes and the Exchequer enables it.
Ownership of both proxies is queued to the Exchequer, pending acceptance —
the previous round's acceptance transactions are void, since they name
proxies that are no longer the release.
Blockscout matches a standard ERC1967Proxy from its own bytecode database, which yields a partial match: the runtime code agrees but the metadata hash belongs to someone else's compilation. This asks the explorer to re-verify against our build so the match becomes full. It does not currently succeed — the explorer replies 'already verified, skipping' and will not re-verify a contract it considers done. Kept because the attempt is worth repeating if that record is ever cleared, and because the next person will otherwise rediscover the same dead end. Verification submits source, never a transaction.
The printed (to, value, data) triple already matched submitTransaction's signature; the output now says where to take it - Blockscout's read/write tab on the Admin multisig, method 20, value 0 - and that the threshold confirmation executes the call itself.
disable-perimeter / enable-perimeter preview and print the calldata for setSecurityPerimeterEnabled on the controller, with the same rules as the queue levers: read-only, no-op guard, submit-from-multisig output. The disable wording states what it is - a liveness escape that makes every charged exit pay straight out and releases nothing already escrowed - so it cannot be mistaken for an incident lever. _emitCalldata now takes the target address since the two levers submit to different contracts. Suite 416 -> 420.
…ination guards, go-live gate Contract correctness: - recoverStuckExit gave the stored-receiver payout 63/64 of remaining gas, so a caller could pick a gas limit that out-of-gases it (caught as a bounce) while the retained 1/64 completed the altReceiver payout -- redirecting a healthy exit. The attempt now runs under a fixed gas budget the caller must cover, so an under-funded call reverts instead of redirecting. - A plain freeze on an already-blacklisted address zeroed its recorded trigger and re-emitted with reason 0, erasing blacklist provenance. A freeze carrying no evidence now leaves the blacklist trigger/reason intact. - resolveBySIP and setRecoveryRoute lacked the this/token/WRBTC destination guard recoverStuckExit already enforces; a fat-fingered destination could trap the escrow. Guard added to both. Go-live and observability: - 06_VerifyActivation now asserts the queue is not left paused before enabling the perimeter -- a pause set during drills would otherwise freeze every withdrawal system-wide the moment the perimeter goes live. - InspectController drove the delay-bypass dump off the surface policy, so a soft-retired surface bypass (the documented disable-while-auditable state) printed as absent. It now consults the surface-bypass key index. Merge casualties restored (weakened when the fee line merged in): - upgrade test re-checks admin + perimeter state survive upgradeTo; non-owner upgrade pins the exact revert; overflow guard pins its exact boundary both ways; removeActorPolicies pins pre-state and stored-policy clearing; disabled-gate test regains its positive control. Deploy shape and comments: - 05 no longer forbids admin == owner: that is the launch shape (the governance Safe holds both roles), and the header NatSpec claiming a hard separation is corrected to match the contract. - Reviewer-finding ids stripped from comments and on-chain revert strings; '(Finding N)' pointers dropped, keeping the property each stated. Suite 420 -> 425.
…yments Squashed adoption of sovryn-perimeter-fee into the delay line: bring in the authoritative Phase-1 deployment records on RSK mainnet, the deployment env template, and the proxy full-match verification helper. Per the decision to complete Phase 2 (delay) before Phase 1's SIPs land. Core suite 425 passing.
The adoption of sovryn-perimeter-fee was squashed with a soft reset, which collapsed it to a single-parent commit and dropped the merge relationship. The tree was correct, but git no longer knew the branches were reconciled, so the merge base stayed at the old ancestor and pull requests recomputed conflicts that are not real. This commit changes no content: its tree is exactly the tested delay tree. It only records sovryn-perimeter-fee as a second parent.
…ction The console was a Foundry script that took request ids typed by hand. Under incident pressure that is the wrong shape: the operator has to source ids from somewhere else and transcribe them, and a single wrong id reverts the whole atomic batch after signatures have been collected. This adds a served page that closes that loop: - requests are read from chain and listed, so ids are selected rather than typed; only Queued rows can be selected, since a terminal id would revert the batch - filtering by one or more addresses resolves through the queue's own per-party index, so it is exact rather than a scan - the parties a selection would block -- originator and owner always, receiver only on request -- are shown before any calldata exists - the generated calldata comes with its destination and value, and the multisig submission instructions live in a tab beside it rather than a separate document It signs nothing and holds no key; the levers stay owner/admin-gated and the transaction is submitted from the Admin multisig. Verified against a queue deployed on a local chain: the request decoder round-trips an abi-encoded struct field for field, the calldata encoder is byte-identical to cast for the same inputs, address filtering returns exactly the indexed requests, and the generated call simulates clean from the admin while reverting for a non-admin caller. The Foundry script stays as the no-browser path and still owns the pause, the clears and the controller kill switch.
…sole - The pause lever was missing. It is the answer to the case selection cannot solve: when too much is happening to pick out the fraudulent requests, pausing stops every payout from the vault while new withdrawals still escrow. It sits above the list and shows the current state rather than making the operator infer it. - Levers now read 'Freeze batch requests' / 'Blacklist batch requests', matching the batch call they build. - Links had no colour rule, so they fell back to the browser's dark blue and were unreadable on the dark ground. They take the accent now. - The README points at the admin panel's Perimeter page as the surface for non-technical operators, and frames this page as the local fallback. Pause calldata cross-checked against cast; the whole page re-verified against a queue on a local chain.
Blocking is by address: freezeFromRequest resolves the parties behind the given ids and blocks those addresses, and executeExit gates on address state. So a block named from one request also holds that party's other queued withdrawals, and any they queue afterwards. Nothing asserted it, though the operator surface depends on it — a fraudulent actor must never be left half-blocked with some exits still executable. The test freezes naming ONE of three requests belonging to the same parties and proves the other two are held, that a request queued after the block is held too, and that an unrelated party still pays out.
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.