Skip to content

Sovryn Perimeter Delay: core contracts - #1

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

Sovryn Perimeter Delay: core contracts#1
tjcloa wants to merge 20 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 20 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.
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.
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