镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Sovryn Perimeter Delay: core contracts - #1

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

tjcloa wants to merge 94 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 30 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.
…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 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.
The global pause is the locked door: it stops payouts so an incident can be
worked out, and blocking the parties responsible has to remain possible
underneath it. Nothing gated blocking on the pause, but nothing pinned that
either, so a modifier added later could have taken the workflow away.

The test pauses first, blocks by request and by address, then lifts the pause
and shows the holds standing while an unrelated party is paid.
Scopes Slither to src/ only (lib, test, script excluded). No source
change: this cycle's findings all resolved to false positives or
accepted design choices on triage, none required a code fix.
A mutation campaign against ExitDelayQueue.sol found 51 lines the unit
suite reaches but never depends on. 46 were genuine gaps: the owner-block
check in execution, the single-id freeze/blacklist authority gate, the
recovery gas floor's exact threshold, three of four ingress entry points
skipping the shared validation guard in tests, escrow arithmetic only ever
exercised where subtraction and other operators coincide, missing
active-index cleanup and solvency checks on two of the three settlement
legs, and nearly every config-setter's event going unchecked. Each gets a
test that fails on the exact mutated line and passes on the real source.
The other 5 survivors are confirmed equivalent (redundant OZ init calls,
a reentrancy-guard default that behaves like its initialized value, an
empty upstream library function, and Solidity's own fallthrough-return
default) and are left undocumented in code, since no test can meaningfully
distinguish them.
… fuzz and invariant harnesses

Four new invariants pin what the operator levers promise: a blocked party is
never paid, a paused queue pays nobody, a route resolves only blacklisted
funds, and blocking by a request blocks exactly that request's parties. The
same actions and properties are mirrored in the Echidna harness, and a
release-gate script runs both at campaign depth outside CI.

Each invariant is a "this never happened" assertion, so a companion test drives
the same handler through a scripted sequence and proves every guarded path was
actually entered before the counters are read.
…he same floor

The gate swallowed a failed Echidna phase: both phases ended in `|| true`, and
the summary grep over an empty log then killed the script under pipefail before
it could say anything. Each phase now reports its exit code, a phase that prints
no verdict fails the gate, and the deep campaign passes its own --timeout so the
config's five-minute wall clock cannot truncate a 500k-call run into a green
result. A phase argument lets either half run on its own.

Blocking by request now holds a flagged receiver to the same floor as the source
parties. Checking only that it was blocked at all would accept a blacklist that
left the receiver merely frozen, and a freeze is clearable and does not
authorize a route resolution.

The Echidna harness keeps permanent reachability counters so its coverage can be
re-checked rather than taken on trust, and a new property keeps those counters
consistent with the payout ledger they are measured against.
A mistyped phase argument matched neither block, so both phases were skipped,
the failure counter stayed at zero and the gate exited 0 having done no work —
the same silent-success shape the exit-code handling was added to remove.
Bring in the fee release landing on main and the foundry-toolchain pin
bump that makes CI install Foundry again.
Three things about the block state machine were wrong at once, and they all
live in the same function.

The trigger request id is the only on-chain link from a blocked address back
to the evidence that explains it, and every re-block was overwriting it. A
responder who blocks forty addresses by request id and then runs the flat
freeze batch over the same list for safety was left with forty zeroes. The
trigger is now written only by a call that carries evidence, or by the first
block on a clear address, and the announcement names the trigger that holds
after the call so the log alone reconstructs it.

A plain freeze over an already-blacklisted address changed nothing and said
nothing, and a successful transaction reads as "this address is now frozen" —
weaker than the truth. It reverts now. The by-request levers carry a trigger
and a reason, so they stay unconditional on a known id: over a blacklisted
party they hold the stronger state, record the evidence and announce it.

That left no way down from a blacklist except clearing it and re-freezing,
with the address completely unblocked in between. downgradeToFrozen does it in
one call, keeps the evidence, and emits its own transition.

The operator preview gains the downgrade action and now refuses the flat
freeze that would revert, instead of printing "no state change" and spending a
Safe round to find out.
The launch shape has one governance Safe holding both roles, and the equality
checks that once enforced a separation are gone from both contracts. The
deploy script's usage block still told the operator the Admin "must != Owner",
contradicting its own code a hundred and fifty lines below and inviting a
second signer set nobody asked for.

The two contract notes beside it were written as an account of what changed
rather than of what the code does, which is not what a reader of the source
needs; they now state the shape and the consequence of it.
The two hosts the gate verified are the ones that hold the queue pointer. They
are not the addresses the queue sees. On the lender surface each iToken proxy
calls the queue from its own context, so the allowed-source check is evaluated
against each pool separately — and a deploy that registers iXUSD and misses
iDOC passed this gate green. Every iDOC withdrawal would then revert from the
moment the delay was armed: no funds lost, the pool simply shut, and the check
that exists to catch exactly that reported success.

The checked list is now the union of the two pointer-holders and every hooked
record caller named in QUEUE_ALLOWED_SOURCES — the same variable the deploy
script registers the allow-list from, so the deploy and the gate cannot
describe different systems. Any length, deduplicated, and every entry held to
both existing predicates. An empty list is refused the same way a missing host
already was, and a blank entry is always a typo.

What it still cannot see is a pool left out of that variable and off the chain
at once. The banner says so now instead of claiming the record callers are out
of scope.
… be found

Neither is a defect, and both were being inherited silently.

The queue promises that every reachable held state has a way out. That promise
holds over its own state machine and stops at the token's edge: all four
disposal legs end in the same transfer, so an escrowed token that later refuses
to move this contract's balance — an upgradeable one adding a blocklist, a
pause, a migration — takes the escrow with it, beyond the reach of the owner
included. A test now drives all four legs against exactly that token and proves
the request survives every one, so the claim is bounded by something executable
rather than by a sentence.

The other is a number a caller has to know. Stuck-exit recovery reserves three
million gas for the attempt on the stored receiver, and refuses the call when
the frame cannot hold it — that reservation is what stops a caller starving the
attempt into a false bounce and redirecting a healthy exit. It is far above
what a wallet's own estimate offers, so a caller who lets the wallet decide is
refused outright. The ABI documentation says so now.
…y off

A global delay of zero switched the delay off for every non-bypassed
exit while securityPerimeterEnabled still read true and the console
still showed the delay holding. The queue's floor cannot catch it: a
zero quote means the hook pays direct and never calls the queue.

setGlobalDelaySeconds now reverts on zero, and setSecurityPerimeterEnabled
refuses to switch on while the length is unset, so a freshly upgraded
controller cannot be enabled into that state. Switching off is never
blocked. Tests that pinned zero-as-off now pin the refusal; fuzz inputs
are bounded away from zero; the never-set case keeps its own test.
A contract that withdraws is recorded as the request's originator and
owner, and only those two could deliver it. A contract with no code that
calls the queue therefore left its users' money in escrow until an
operator resolved it by hand.

When the recorded owner has code, anyone may deliver after unlock. The
delivery still goes only to the receiver fixed at escrow time, the
redirect lever stays with the originator and owner, the three recorded
parties are still block-gated, and the executor — whoever it is — must
not be frozen or blacklisted. Wallet-owned requests are unchanged.
A contract that withdraws is the caller the hook sees and the owner of
what it withdraws, so the queue records it as both; blocking it stops
every withdrawal through it, and the person it pays is reachable through
the receiver. The passthrough only ever let such a contract's users
deliver their own requests, and the queue now lets anyone deliver a
contract-owned request. What remained was a second owner lever that
looked like the bypass in every read and did nothing for a caller that
names itself as receiver.

The setter, the identity rewrite, the key sets, the event and the
inspector rows go. The delay quote returns the originator and owner
unchanged; its signature is untouched. The four slots the registry held
are reclaimed by the reserved gap, which grows from 29 to 33; nothing
deployed carried them.
Removing the passthrough deleted its three storage declarations and grew the
reserve from 29 to 33 slots. That moved the bypass discovery set from slots
267-268 to 265-266 and the packed delay switch and length from 271 to 267. A
proxy already running the delay build and upgraded in place would read its
bypass-set length as the switch and the length: switched on with a zero
length, exactly the state the two setters now refuse, reached without either
setter running.

The design note keeps those slots declared and never written so the layout
only appends. Slots 265, 266 and 269-270 are declared again with their
original types, under names that say they are unused; the reserve is back to
29 and every later variable is back at its position. A test pins the
positions by reading the proxy's slots.

Both layout fixtures mirror the restored layout, so the upgrade-safety
regression accepts the safe candidate again (GoodPackedV2, reserve 29 -> 27)
and still rejects BadV2 and BadV3. Before this change it rejected the safe
candidate.
The controller now refuses to switch the withdrawal delay on while its
length is unset. The block script's enable-perimeter action still emitted
that call whenever the switch read off. Through the multisig the inner revert
does not fail the outer transaction, so an operator could execute it, see
success, and leave every withdrawal paying out at once. The script now reads
the length, prints it, and refuses before producing calldata while it is 0,
naming setGlobalDelaySeconds as the Owner's prior step. After producing either
switch call it says to read the switch back, because the multisig reports
success even when the call inside failed. Its messages now describe only the
delay switch; the Perimeter fee has its own.

The controller inspector printed a zero length bare. It now says that 0 means
unset and that switching on is refused while it is, and warns when the switch
reads on with no length.

Two comments described the removed passthrough model: the queue interface
said recovery shares delivery's caller set, and the inner delay quote spoke
of an "already-effective actor" rather than the originator.
Ingress refused only the zero receiver. A request whose receiver is the queue
itself goes terminal on delivery while the money never leaves: the payout
sends it back into the queue, escrow drops, solvency still holds, and the
amount sits as surplus the Owner can sweep. The same happens when native RBTC
is paid to WRBTC, whose deposit credits the sender - the queue - with wrapped
tokens.

Every ingress path now reverts InvalidReceiver(receiver) for the queue as
receiver, and for WRBTC as receiver whenever delivery sends native RBTC:
native escrow, or WRBTC escrow that unwraps on delivery. A request that pays
WRBTC as a token, without unwrapping, is unchanged. Tests cover all four
ingress functions, both refusals, and the token case that stays accepted.
tjcloa and others added 30 commits September 17, 2026 02:25
…wn behavior

The comment named "the review" that caught this case rather than describing
what the test pins. Reworded to state the behavior only: a blocked stored
receiver refuses recovery even with a clean alternate receiver named.

Documentation only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Exemption

Granting an actor-tier exemption took two separate calls (setActorPolicy,
then setActorBypass), each its own Exchequer multisig transaction with its
own confirmation round. Between the two executing, the actor is either
fee-exempt but still held, or - if the delay half lands first - fee-charged
but paid instantly with no hold at all, defeating the perimeter for that
actor during the gap. --half fee / --half delay make a permanently
half-applied exemption directly reachable.

grantExemption writes both entries in one call, mirroring revokeExemption's
existing two-write atomicity in the opposite direction, so the hardhat
tooling in the lending repository can submit one multisig transaction for a
full grant instead of two.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…oup correctly

_verifyByRequest determined whether a request's receiver belonged to the
clean group (blocked alongside it) or the held group (left alone) by
checking queue.blockTrigger(receiver) == this request's id. That trigger
is last-write-wins: when two ids in the same clean-group call resolve to
the same receiver - the shape a set of malicious withdrawals routed to
one payout address produces - only the later-processed id's trigger
survives, so the earlier one reads as held even though its receiver was
genuinely blocked, and verify wrongly reports NOT CONFIRMED.

Replace the single-id trigger check with _receiverIncludedInBatch, which
also accepts a trigger that names any other id in the same verify batch
resolving to the same receiver. Add coverage for two clean requests
sharing one receiver, under both freeze and blacklist.
…uest verify batch scan

_receiverIncludedInBatch only matched another id's own receiver field
against the address in question, so a request whose receiver was also
touched through a different id's originator or owner role in the same
batch (several malicious withdrawals from one wallet, one paying out to
an address the wallet also used to originate a different withdrawal)
was misread as held even though the on-chain freeze fully succeeded -
verify-freeze/verify-blacklist reverted NOT CONFIRMED for a genuinely
successful block. Extend the same-batch scan to originator and owner
too, and add regression tests pairing a receiver with another request's
originator and with its owner.
…clean

The by-request verify classified a receiver as clean or held purely
from its current, mutable blockTrigger. A clean request's freeze call
blocks its receiver alongside it - but an Admin can separately unfreeze
just the receiver afterward, a real onlyAdminOrOwner action unrelated
to the batch under review. That clears the receiver's trigger, so
verify misread it as "held, receiver correctly left alone" and printed
a clean CONFIRMED even though the receiver no longer reads blocked.

Freeze only ever submits the clean group, always with the receiver
included, so a request whose own call is evidenced on its originator
and owner but whose receiver reads excluded either way was clean, not
held - its exclusion means a later action cleared what this call set,
not that the call skipped it by design. That case is now flagged
rather than confirmed, since current chain state alone cannot tell
"never touched" apart from "touched, then cleared". Split the receiver
check into its own function to keep the loop inside the stack limit,
and add a regression test that freezes a clean request, unfreezes its
receiver alone, and asserts verify does not print a clean CONFIRMED
for it.
…ims was actually reachable

executedWhileBlockedAttempts incremented as soon as a party was
observed blocked, before the call ran - but _executeOne checks status,
the unlock time and the executor before it ever reaches
_requireNotBlocked, so a call that reverted on NotUnlocked or
NotExecutor for reasons unrelated to the block gate still counted,
letting leversReached() read true without the block check ever firing.
Gate the counter on those same preconditions (plus not-paused, since
the pause check runs before anything else in _executeOne) in both
execute and executeMany, mirroring _executeOne's own order.

Also add recoveredAsOriginator/recoveredAsOwner/recoveredAsReceiver:
recoveredPayouts alone only shows some recovery authority fired at
least once, not that the harness exercised all three independently,
since it is always the recovered request's own originator, owner, or
receiver for the call to succeed at all.

This changes what the currently running Echidna campaign measures for
executedWhileBlockedAttempts, since it is the harness the campaign is
already running against; the campaign's own record names the harness
it ran on. Not run here - forge build/test only.
…ust one

The single-guardian identity assertion's comment claimed the
controller's local admin is used ONLY by the kill switch, but
onlyAdminOrOwner also gates setFeeReceiver, which re-points where the
Perimeter fee is paid - a second, distinct lever. Runtime behavior is
unaffected; this corrects what the comment tells a maintainer reading
it before the identity assertion.
…cleared after the fact

R3-2's fix (4560527) scoped the by-request verify guard to target ==
Frozen only, reasoning that blacklist's held group already has a
legitimate excluded-receiver shape and so needed no equivalent guard.
That reasoning covers the held group but not the clean one: blacklist
escalates a held party exactly as it does a clean one, so a held id's
own call is evidenced (_requestEvidenced) exactly like a clean id's.
The identical false-CONFIRMED shape R3-2 fixed for freeze therefore
reproduces on blacklist for the clean group - a clean request's
blacklist call blocks its receiver, an Admin unblacklist on the
receiver alone later clears it, and by-request verify printed a clean
CONFIRMED for a receiver that no longer reads Blacklisted.

Extending the guard naively (dropping the target == Frozen check)
would also misfire on blacklist's genuinely-held group: unlike freeze,
where a held id's call never runs at all (so it is never evidenced),
blacklist submits the held group too, just without its receiver - so
a held id's own evidence looks identical to a clean id's whose
receiver was blocked then cleared. There is no on-chain signal left
that tells these two cases apart once the receiver's own trigger has
been cleared, so by-request verify can no longer soundly confirm a
held id's receiver claim under blacklist either way. It now refuses
rather than guess, and a held id's parties - which never made a
receiver claim in the first place - are verified by address instead
(BLOCK_ACTORS), which was already sound and unaffected by any of this.

test_verify_blacklist_confirms_both_groups updated to verify the
clean group by request and the held group by address, matching the
new, honest split. New regression test
test_verify_blacklist_does_not_confirm_a_receiver_unblacklisted_after_the_fact
reproduces the reviewer's proof of concept against the pre-fix
behavior (fails first) and passes at head.

Ref: sovryn-spec/sovryn-perimeter/docs/reviews/2026-09-17-contracts-codex-round3.md,
re-verification of R3-2.
_reachesBlockGate's party check compared msg.sender - the address
Echidna used to call the harness's own external execute/executeMany -
against r.originator/r.owner. But _executeOne actually sees the
harness's own address as its msg.sender, since execute/executeMany
call queue.executeExit/executeExits directly. echidna.yaml sets no
custom sender list, so Echidna's default fuzzing senders never equal
address(this)/actors[], making party effectively always false and
collapsing the check to r.owner.code.length > 0 alone - inconsistent
with executeMany's own pre-existing pair check four lines above,
which already tests b.originator == address(this) || b.owner ==
address(this).

Effect was under-counting only (never over-counting, so the R3-3
false-positive shape does not reopen): executedWhileBlockedAttempts
missed every genuinely-reachable attempt via the originator role with
a non-contract owner - a combination the harness's own actor-keyed
request generation produces routinely. Fixed by comparing
address(this) instead, mirroring executeMany's own convention.

Not forge-testable directly: the harness is driven by the Echidna
campaign, not forge test (no forge test targeted it before this fix
either). Verified by forge build and the full forge test suite (543
unit, 11 invariant) staying green - this file is inert to both, since
neither instantiates the Echidna harness. The fix only widens when
`party` reads true, and every earlier gate (paused, status, unlock
time) is untouched, so the reachability counters still only advance
past a genuine reach and never over-count.

Ref: sovryn-spec/sovryn-perimeter/docs/reviews/2026-09-17-contracts-codex-round3.md,
re-verification of R3-3.
executeExit/executeExits treated the receiver as a payout destination only;
the originator or owner had to press the button (or, when the owner is a
contract, anyone). The receiver can now deliver too, since it already knows
its own request is unlocked and has every incentive to move it.

_activeByParty now indexes a request under the receiver alongside the
originator and owner, so getActive(receiver, ...) lists it while Queued.
_removeActive takes the receiver and prunes it from all three sets on every
terminal transition (execute, recover, resolve-to-protocol, resolve-by-owner),
de-duplicating when the receiver coincides with the originator or owner.
Storage layout is unchanged (no new slots, no fields added).

NatSpec in ExitDelayQueue.sol and IExitDelayQueue.sol updated to match.
Unit tests: the receiver now delivers its own request, single and batch,
after unlock; it is refused before unlock, while paused, on a terminal
request, and while the originator or owner is frozen/blacklisted, mirroring
the existing originator/owner coverage. getActive(receiver, ...) lists a
request after enqueue and drops it after each of the four terminal paths
(execute, recover, resolve-to-protocol, resolve-by-owner) with no double add
or double remove when the receiver coincides with the originator or owner.
A caller matching none of the three parties is still NotExecutor when the
owner carries no code.

Two pre-existing tests asserted the receiver could NOT execute; flipped to
match the new rule (one now proves the outsider-only revert, the other
proves the receiver succeeds).

Invariant handler + forge invariant test: the active-index consistency check
now covers the receiver's set alongside originator/owner, and the
executeMany batch-pairing heuristic treats the receiver as a valid
co-executor.

Echidna harness: the block-gate reachability predicate and the batch-pairing
heuristic both gained the receiver as a valid executor, mirroring the
contract's own rule. recoverStuckExit's own three-party rule is untouched.
… path

Adds ExitDelayQueuePayeeReentrancy.t.sol, nine tests where the payee is
also the caller and re-enters the queue from inside its own payout:

- executeExit and executeExits, native payout
- executeExit, WRBTC-unwrap payout
- executeExit, hooked-ERC20 payout (hook called after the balance moves)
- executeExit, hooked-ERC20 payout with the hook called BEFORE the balance
  moves -- the case that briefly exposes a transient surplus equal to the
  payout amount
- recoverStuckExit, a healthy receiver (paid; the alternate goes unused)
- recoverStuckExit, a receiver that bounces after its attempts (the
  alternate is paid exactly once)
- resolveByOwner, to a destination that is itself the re-entering payee
- sweepSurplus, to a destination that is itself the re-entering payee

Each test has the payee attempt six re-entry paths while it is being paid
(executeExit, executeExits, recoverStuckExit, payoutExternal,
recordNativeExit, recordReceivedNativeExit) and asserts the concrete
outcome: five attempts stopped by the shared reentrancy guard, one by
payoutExternal's self-only check, the paid request's status/index/escrow
already updated while the payee runs, and a balance that moved exactly
once for exactly the paid amount. The pre-hook case additionally asserts
the transient surplus is recorded as escrow by no record* call and moved
by no sweep while a sibling payout sits in the queue. Ingress reentrancy
and resolveToProtocol reentrancy already had re-entering-payee coverage
elsewhere in the suite; this file covers the remaining value-out paths.

New mocks, self-contained in the file: StipendWRBTC (the WETH9-style
2300-gas withdraw already used by ExitDelayQueueUnwrapStipend.t.sol),
HookToken (calls a registered address before or after its own transfer),
Source (a registered allowed-source helper, matching the suite's existing
SourceHarness/Source pattern), and Hostile (the re-entering payee).
The per-address filter matches a request whenever the address is the
originator, owner OR receiver. Add one sentence where the filter is
described: filtering by a suspect address and choosing "Select all shown"
therefore also selects requests where that address is only the receiver,
pulling in their originators and owners -- the payers -- for a
from-request freeze or blacklist. The selection panel already lists every
address that would be blocked before anything is sent; the sentence just
states the consequence next to the filter it follows from. No other
change to the tool.
…d escrow

Covers the state where the queue holds less of an asset than its recorded
escrow (negative rebase, clawback, a token that takes from its holder), for
ERC20 and native.

Ingress: recordReceivedERC20Exit and recordReceivedNativeExit measure a zero
surplus, so a deposit smaller than, equal to or larger than the shortfall is
refused with ReceivedAmountMismatch(asset, max(deposit - shortfall, 0),
deposit), leaving the escrow total, request count and queue balance
unchanged. A partial top-up still refuses; a full top-up lets the same
deposit record normally. A fuzz test per path covers the general case. The
ERC20 pull path, which measures its own transfer and never compares against
the escrow, is pinned as unaffected.

Value-out: executeExit, executeExits, recoverStuckExit, resolveToProtocol and
resolveByOwner are each refused with SolvencyViolated while backing is short,
leave the requests queued and the escrow untouched, and pay once a third
party tops the backing up. sweepSurplus is refused and moves nothing while
short; after the top-up it succeeds and moves exactly the excess over the
escrow.
…view

Refuse zero block actors and duplicate clear/downgrade actors before collecting multisig signatures. Add five regressions proven failing on the original preview and passing after repair; keep idempotent freeze/blacklist repeats.

Qualify caller-gas recovery delivery guarantees and correct exemption and queue interface documentation. Preserve the owner-approved no-cap runtime behavior pending review of the reproduced healthy-receiver redirect.
… readiness

Validate exact Foundry and Echidna inventories, requested run/depth/call counts, passing statuses and the expected reachability exit rather than loose log markers. Clear inherited selection filters, pin the default profile, and compare pristine tracked source/config/dependency snapshots before and after execution.

Add sixteen deterministic positive and negative gate checks, including seven meaningful failures on the original guard, and the mutation-proven paused activation regression. Contract runtime, recovery policy, dependency pins and unit-only CI policy are unchanged.
…ion receipts

Add an explicit read-only receipt route instead of fabricating a Foundry 99 broadcast. Bind reviewed artifact/profile and real implementation creation, exact wallet intent and 3-of-7 quorum, ordered proxy/wallet logs, canonical blocks, slot/code, preserved controller state and rollback record before atomic conflict-safe update.

Keep direct finalization unchanged and support receipt-backed source verification with source/settings profile guards. Add real receipt-shaped positive, wrong-receipt/target/data/slot/state/quorum and conflict/tamper/replay controls; all 27 tooling tests pass. Runtime, recovery policy, CI and dependency pins are unchanged; genuine fork proof follows through the coordinator.
…zation

Validate timestamp and timestampUnit alongside the other stable chain-derived record fields before accepting an idempotent replay. Cover timestamp-only, unit-only and combined corruption with refusal and exact unchanged-record assertions.
Describe sampled nested queue-call budgets rather than exact minimum or live block-limit proof. Distinguish ordinary MockERC20 call success from native receiver gas consumption and qualify the stored-payment case to its supplied harness gas. Change notices and diagnostic labels only; retain assertion conditions and recovery behavior.
…nits

Promote distinct unwrap rollback and underpayment properties, atomic four-mode 86400-second floor checks, and typed denial checks for uncovered public entry variants. Strengthen four existing owner-error assertions so later eligibility errors cannot hide a missing guard. Keep production behavior, roles, recovery policy and unit-only CI unchanged; verify in isolated outputs with meaningful mutation controls.
Preserve capped-address metadata and show a persistent partial-read warning alongside the selected-request workflow. Update the warning with replacement rows so a pending refresh cannot hide the old partial state. Keep valid rows, exact selection, calldata and read-only operation unchanged; cover complete and capped boundaries plus meaningful removed-warning controls.
Phase 2 ("Perimeter Delay") step 1 deploys ONLY a new ExitFeeController
implementation: the ERC1967 proxy is already live on chain 30
(0x99994b45...440E) and owned by the Exchequer 3-of-7 multisig, so the
upgrade itself must be a multisig upgradeTo transaction, never a script.
Until now 03_DeployController unconditionally deployed impl + new proxy
(running it on mainnet would create an unwanted second proxy) and
finalize-deployment.sh had no shape for an impl-only broadcast.

03_DeployController.s.sol: detect a live recorded proxy via
deployments/<chainId>/ExitFeeController.json plus an on-chain code check;
with a live proxy, deploy only `new ExitFeeController()` (never
initialized, never upgradeTo) and print a loud warning block: staged
impl, existing proxy, current on-chain owner() read through the proxy,
multisig vs governance-timelock upgrade guidance (EXCHEQUER_MULTISIG
env-confirmed, else the known mainnet Exchequer hint marked unverified,
else both paths printed), a do-not-merge-until-upgrade-verified rule,
and the exact finalize command. Fresh-deploy behaviour is unchanged when
no live proxy exists.

finalize-deployment.sh: recognise a third broadcast shape -- exactly one
CREATE, no proxy CREATE, no upgradeTo. With an existing artifact naming
the proxy, record the new impl as a nested pendingImplementation object
(implAddress, on-chain implBytecodeHash via cast code, block, tx,
timestamp, gitSha) WITHOUT touching the live implAddress/implBytecodeHash
(the proxy still runs the old impl until the upgrade executes). Refuse
impl-only broadcasts with no recorded proxy as ambiguous. Usage header
updated.

finalize-multisig-upgrade.py: when the receipt-backed multisig upgrade
lands, require the manifest implementation to match the staged
pendingImplementation (when present), promote it to the live fields and
clear the staged record; rollback keeps the original staged JSON.

Verified locally only: forge build, forge test (622 pass),
bash -n, jq dispatch fixtures for all three shapes, and anvil
simulation of both fresh-deploy and impl-only script paths. No network
broadcasts, no mainnet calls.
Add KNOWN_MAINNET_TIMELOCK_ADMIN (0x6c94c8aa...29FB13) and
KNOWN_MAINNET_TIMELOCK_OWNER (0x967c84b7...A7F69f) from the
Sovryn-smart-contracts rskSovrynMainnet hardhat deployment data. When the
live proxy owner() matches either timelock, the staged-impl warning block
now names it and states the required SIP path (GovernorAdmin/GovernorOwner)
instead of falling through to the generic both-paths message. Hints are
marked UNVERIFIED, same policy as the Exchequer hint. Print-only change:
forge build clean, forge test 622/622 pass.
Impl-only deploy via script 03 (commit 4cf3805), tx
0xcc1bcce36c751a1c1749ea25c0679bd9936d37446f2d1f5d8aa41355a3c2ee50 at
block 9317153, 3175549 gas, deployer 0x163463B7...f9c4. Runtime code
byte-identical to the reviewed artifact template with the five self-address
immutables hydrated; source verified on Rootstock Blockscout in the same
run. Artifact records pendingImplementation; live proxy 0x99994b45...440E
still on impl 0x50EC5c1C...552d, owner Exchequer, exitFeeEnabled true.
Activation only via Exchequer 3-of-7 upgradeTo + --multisig-upgrade
finalizer.
Impl-only mode now reuses a recorded pendingImplementation whose on-chain
code hash matches (no second CREATE). Reads the proxy's live ERC-1967 impl
and routes the upgrade: already-upgraded prints finalize guidance;
Exchequer-owned proxy with an Exchequer-owner signer submits the multisig
upgradeTo transaction itself (3-of-7, with the 3,000,000-gas executing
confirmation and state-readback rules printed); non-owner signer gets the
exact calldata to submit later; TimelockOwner/TimelockAdmin owners get the
SIP-required + do-not-merge warning. Verified: forge build clean, 622/622
tests, mainnet simulation (reuse + calldata print), and a full anvil-fork
3-of-7 rehearsal (submit/confirm/confirm@3M gas -> executed, impl slot
updated, securityPerimeterEnabled false, exitFeeEnabled true).
…ay-build impl 0xe85e7e5c (exec tx 0x80ef954d, block 9321661); receipt-backed record with rollback
…86f…c50a (block 9322578); owner/admin Exchequer, floor 86400, no hosts wired (DEFER_HOSTS)
…sion + ExitDelayQueue deploys

The 03_DeployController run-latest record now reflects the owner run that
submitted the Exchequer multisig upgrade of the live ExitFeeController proxy
0x99994b45...3440E to the delay-build implementation
0xe85e7e5c...18e1 (submitTransaction 0x918ff4ef..., block 9319660,
multisig tx 2218), replacing the earlier step-1 CREATE record.

The 05_DeployQueueAndWire broadcast records hold the two ExitDelayQueue
CREATEs on mainnet (chain 30): implementation 0xB19C086f...c50a and ERC1967
proxy 0x6A281FCB...Dc45 (0xe799dc6d... / 0x71cb6343..., block 9322578,
hosts deferred).

Deployment state was already finalized in 951a139 and d74a577; these are the
raw broadcast receipts. test/phaseG/ (Phase G fork-test mock host) is
intentionally untracked.
Stand-in for the product host contracts when fork-testing the live
ExitDelayQueue (proxy 0x6A281FCB...Dc45): wraps the four record* ingress
functions, models the fee-then-hold product flow (quote the live
ExitFeeController, pay the fee leg to the vault, escrow the net into the
queue in one transaction), and doubles as a bouncing receiver for the
recoverStuckExit leg. Used in the 2026-10-14 post-upgrade on-chain test
campaign against mainnet fork state (evidence in sovryn-spec KB,
logs-2026-10-14/phase-g/). Not deployed; test-only.
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