Skip to content

Sovryn Perimeter Delay: lending and borrowing - #582

Draft
tjcloa wants to merge 53 commits into
developmentfrom
sovryn-perimeter-delay
Draft

Sovryn Perimeter Delay: lending and borrowing#582
tjcloa wants to merge 53 commits into
developmentfrom
sovryn-perimeter-delay

Conversation

@tjcloa

@tjcloa tjcloa commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Phase 2 of the Sovryn security perimeter — the lending and borrowing half.

Where the Perimeter Fee takes a cut of a user-initiated exit, the Perimeter Delay can hold the remainder in the ExitDelayQueue for a governance-configured period, so a detected theft can be frozen or blacklisted and routed to recovery before the funds leave.

What this carries

  • Delay hooks on the surfaces the fee already covers: the LM / WrbtcLM iToken burn paths, borrower collateral withdrawal, loan closing, and the closeWithSwap excess-collateral refund. The fee leg is paid immediately and only the net is escrowed, so fee and delay compose without double-charging.
  • One delay quote is taken per exit and governs every payout site within it, including the full-gross path taken when the fee leg fails — a fee-vault fault cannot route around the delay.
  • Fail-open pointer, fail-closed quote: a missing queue or controller pointer leaves exits paying direct, while a controller that answers incorrectly reverts the exit rather than silently disabling the perimeter.
  • With the perimeter off, every path is the existing payout unchanged — the queue is not touched unless a delay is actually established.
  • WRBTC exits escrow wrapped and unwrap on delivery, keeping the native transfer at the end of the delay rather than at queueing time.
  • ExitFeeModule gains the governance setter for the queue pointer. The pointer lives in an unstructured slot, so nothing in the lending perimeter adds storage — enforced by the layout zero-diff guard.

Keeper, liquidation and rollover paths are untouched: only a voluntary borrower or delegate exit is delayed. The delay ships disabled and is enabled only by governance after post-deployment verification.

Base branch

Opened against sovryn-perimeter-fee rather than development so the diff is the delay delta alone. Re-target to development once SIP-0094 is approved and executed — the Perimeter Fee must land first.

Not ready to merge — two known blockers

  1. Contract size (EIP-170). LoanClosingsWith, LoanMaintenance and LoanClosingsRollover exceed the 24,576-byte limit with the delay hooks (26,568 / 26,162 / 26,154 against a limit of 24,576, optimizer on). They cannot be deployed as they stand. The fix under consideration is moving the delay legs behind a delegatecall module in the shape ColFeeBorrowerExitOps already uses, and/or converting the shared helpers to a deployed library.
  2. Controller storage layout (fixed in the core repo, tracked here because this branch depends on it).

Verification so far

  • Compiles clean.
  • tests/colfee: 107 passing, 0 failing.
  • Storage-layout zero-diff guard green — no hooked contract adds state.

Deeper review and the audit gates (reentrancy, queue custody, cross-repo fork integration) are still outstanding and are tracked separately.

tjcloa added 30 commits August 17, 2026 15:43
Phase 2 of the Sovryn security perimeter: user-initiated exits that already
pay the Perimeter Fee can additionally be held in the ExitDelayQueue for a
governance-configured delay, so a detected theft can be frozen or blacklisted
and routed to recovery before the funds leave. This change carries the
lending/borrowing half.

- delay hooks on the same surfaces the fee already covers: the LM/WrbtcLM
  iToken burn paths (beacon modules), borrower collateral withdrawal, loan
  closing, and the closeWithSwap excess-collateral refund. The fee leg is
  paid immediately and only the NET is escrowed, so fee and delay compose
  without double-charging;
- a single delay quote is taken once per exit and governs every payout site
  in it, including the full-gross path taken when the fee leg fails, so a
  fee-vault fault can never route around the delay;
- fail-open POINTER, fail-closed QUOTE: a missing queue or controller pointer
  leaves exits paying direct, but a controller that answers incorrectly
  reverts the exit rather than silently disabling the perimeter;
- the queue is never touched unless a delay is actually established, so with
  the perimeter off every path is byte-for-byte the existing payout;
- WRBTC exits escrow wrapped and unwrap on delivery, keeping the native
  transfer at the end of the delay rather than at queueing time;
- ExitFeeModule gains the governance setter for the queue pointer; the
  pointer itself lives in an unstructured slot, so no contract in the
  lending perimeter adds storage (guarded by the layout zero-diff check).

Keeper, liquidation and rollover paths stay untouched: only a voluntary
borrower or delegate exit is delayed. The delay ships disabled and is
enabled only by governance after post-deployment verification.
The delay suites were written when they lived in a top-level tests-colfee/
directory and were moved under tests/colfee/ so CI would run them. Their
relative requires were not adjusted with them, so every one of the six
resolved ../tests/Utils from one directory too shallow and CI failed at load
time with MODULE_NOT_FOUND before a single test ran. The storage-layout helper
resolved the repo root the same way and had the same fault.

Also completes MockArbitraryQuoteExitFeeController. It implements
IExitFeeController, which the delay work widened with the delay members, and
the mock was added on the fee line after this work branched — so it was never
extended and quietly compiled to an abstract contract with no bytecode. The
one suite that deploys it failed in its fixture. The delay members are
implemented inert: no delay, raw identities, which is the answer a controller
with the perimeter off returns, so the fee-side gate this mock exists to
exercise is unaffected.

tests/colfee now runs 107 passing, 0 failing.
The delay reroute was compiled into LoanClosingsWith, LoanMaintenance and
LoanClosingsRollover in full, three copies of the same code, and it is what
pushed all three past the EIP-170 limit. The queue lookup, the vault push and
the queue record now live in ColFeeBorrowerExitOps and run there under
delegatecall, in the protocol proxy's context and the same outer transaction,
so a record revert still rolls back the push.

The delay QUOTE deliberately stays in the module. Moving it out as well is
worth about a further kilobyte per module, but it makes the perimeter's own
decision depend on the charge-hook pointer: a mis-set pointer, which the fee
leg is specified to survive by paying full gross, would then either revert the
exit or silently skip an imposed delay. The existing pointer test catches
exactly that. Resolving the delay in the module keeps the two independent — the
hook is consulted only once a delay is established, and from that point any
failure to reach it reverts the exit rather than paying direct.

Sizes fall by ~600 bytes per module. All three remain over the limit, so this
is a step and not the fix; the remaining overage needs the module split.

tests/colfee 107 passing, storage-layout zero-diff green.
A close payout used to run through one shared function that decided at runtime
whether to charge, based on an origin flag. Rollover and liquidation always
passed an origin that skips the fee and the delay, but the compiler cannot see
that: the perimeter code was compiled into those modules regardless, and it is
what pushed LoanClosingsRollover past the deployable size limit.

The payout is now an overridable settlement point. The base implementation in
LoanClosingsShared pays the receiver directly and knows nothing about the
perimeter, which is the whole behaviour a forced close needs. Voluntary-close
modules inherit LoanClosingsCharged, whose override takes the fee and applies
the delay before paying out.

The exemption is therefore structural rather than conditional. A forced-close
module has no reachable path to the fee or the delay, so a reader no longer has
to trust an origin check to see that a liquidator cannot be charged or a keeper
payout escrowed — the code is not there.

LoanClosingsRollover drops 1,992 bytes to 23,521 and is now 1,055 under the
limit; LoanClosingsLiquidation lands at 16,119. LoanClosingsWith and
LoanMaintenance are unchanged, as expected: both settle voluntary exits and
keep the perimeter.

The inheritance change alters C3 linearization for four modules, so the
storage-layout zero-diff guard is the gate here: all 63 entries identical on
every hooked contract. tests/colfee 107 passing.
The programme is the Sovryn security perimeter; ColFee was the internal
working name and should not survive into the public code. Contracts, files,
directories, test suites and comments now use Perimeter naming, with the
mixin and its hook named for what they do: BorrowerExitPerimeter and
BorrowerExitPerimeterOps, plus PerimeterLib and IPerimeterEvents.

Two literals deliberately keep the old spelling, because they are live
on-chain identifiers rather than names:

  keccak256("COLFEE:SURFACE_LENDING_*_WITHDRAW") are the surface ids the
  deployed controller already holds rate policies against. Rewriting the
  string changes the hash, and the configured policy would silently stop
  matching — the fee would quietly stop being charged.

  keccak256("sovryn.colFeeBorrowerExitOps") - 1 is the storage slot holding
  the hook pointer that governance has already pinned on mainnet. Rewriting
  the string moves the slot and orphans the pointer.

Both now carry a comment saying so, so the next person does not "finish the
rename" and break a deployed system. Revert-message prefixes carry no on-chain
meaning and were renamed to PERIMETER:.

Deployment records under deployment/deployments/ are left untouched: they
record what is deployed on mainnet, under the name it was deployed with.

Verified: protocolFee, which contains the substring colFee and would have been
corrupted by a naive replace, is unchanged. 107 tests passing, storage-layout
zero-diff green.
Completes the rename into the two identifiers that carry on-chain meaning.
Surface constants are now PERIMETER_SURFACE_*, and each hashes a literal
identical to its own name, so there is no separate prefix convention to
remember: PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW hashes exactly that
string. The borrower-exit hook pointer moves to a slot derived from
"sovryn.borrowerExitPerimeterOps".

Both are deliberate value changes, not cosmetic ones, and they do not migrate
themselves:

  The surface ids change, so the rate policies the controller currently holds
  no longer match. The controller resolves a policy by hashing the id; the new
  hashes resolve to nothing until governance configures them. Until then the
  affected surface quotes INACTIVE and no fee is charged — silently, with no
  revert.

  The hook pointer lives at an unstructured slot addressed by the hash of its
  name, not at a position fixed by declaration order, so renaming the string
  moves the slot. The pointer already pinned on mainnet stays at the old
  address, invisible to the new code, until it is re-pinned.

The activation proposal therefore has to configure the new ids and re-pin the
pointer before the modules that use them are registered, and then zero the
superseded policies and the old slot so a stale value cannot be picked up
later. Those actions are enumerated in the Phase-2 plan; this commit is the
code half only.

107 tests passing, storage-layout zero-diff green — the slot is unstructured,
so no declared layout moved.
Both modules carried more than one job and had grown past the deployable size
limit once the perimeter hooks were added. The protocol dispatches per selector,
so hosting a selector elsewhere is a routing change and not a behaviour change:
no logic moved, and each module now compiles only the path it serves.

closeWithSwap moves to LoanClosingsWithSwap, leaving LoanClosingsWith with the
deposit close. The eight read-only loan and interest getters move to
LoanMaintenanceViews, leaving LoanMaintenance with the state-changing
maintenance calls; the views touch no state and no perimeter code.

Sizes, against the 24,576 limit:

  LoanClosingsWith         25,927 -> 18,270    6,306 under
  LoanClosingsWithSwap              22,595     1,981 under
  LoanMaintenance          25,589 -> 19,660    4,916 under
  LoanMaintenanceViews              11,512    13,064 under

With the earlier inheritance split already covering rollover and liquidation,
every lending module is now under the limit with room to spare, rather than
trimmed to fit.

Also fixes a trap in the test initializer. Its library linking ran as one
try/catch over a fixed list, so the first artifact that does not reference the
library throws and every link after it is skipped. LoanClosingsWith stops
referencing the swap library once closeWithSwap moves out, which silently left
SwapsImplSovrynSwapModule unlinked and failed eighteen suites far from the
cause. Each link is now guarded on its own.

Deploy scripts and the activation proposal still need the two new modules
registered — that is not in this commit.

107 perimeter tests passing, 18 protocol close/deposit tests passing,
storage-layout zero-diff green.
The pointer slots read "sovryn.exitFeeController", "sovryn.exitDelayQueue" and
"sovryn.borrowerExitPerimeterOps" — names that say what is stored but not which
system owns it, in a protocol that has several kinds of fee. They are now
sovryn.perimeterExitFeeController, sovryn.perimeterExitDelayQueue and
sovryn.perimeterBorrowerExitOps, so every slot the perimeter owns carries the
same prefix.

These strings are shared across the lending and Zero integrations and the
perimeter contracts: the slot address is the hash of the string, so the three
repos must carry identical spellings or the same pointer would live at
different addresses in each. The rename is applied to all three together for
that reason.

Free to do right now, and checked rather than assumed: every one of these slots
reads zero on mainnet today, on both the protocol and the Zero
BorrowerOperations proxy. Nothing has been pinned, because pinning needs the
activation proposal and that has not executed. Once it has, moving a slot would
mean re-pinning it.
LoanClosingsWithSwap and LoanMaintenanceViews are added to the protocol module
registry and to the module deploy script, so they are built, recorded and
verified alongside the modules they were split out of. The views module needs
no swap library, so it is marked accordingly.

Also corrects LoanMaintenance's sample function. The registry named
getActiveLoans, which moved to LoanMaintenanceViews in the split — the
registration check would have been reading the wrong module. It now names
withdrawCollateral, a selector LoanMaintenance still registers, which is also
correct against the currently deployed module.

The proposal actions are deliberately NOT added to the SIP-0094 builders. Those
are the frozen Phase-1 fee release: their descriptions are sha256-pinned, their
action counts are asserted exactly, and they have been rehearsed. These two
modules exist because of the delay, so they belong to whichever proposal ships
it — a second release under one shape, or a single combined proposal under the
other. Either way, six module replacements no longer fit beside the rest of
Part 1's ten, so that proposal re-splits; the arithmetic is in the plan.
Prepares the shape that ships fee and delay in one release: three proposals
(two GovernorOwner parts of 10 and 8 actions, one GovernorAdmin) against about
five and roughly 26 actions if Phase 1 and Phase 2 are sequenced. The frozen
SIP-0094 builders are untouched -- the two shapes are alternatives and the
Phase-1 descriptions are pinned by sha256 to an approved document.

Ordering invariants are asserted rather than left to reading order: the
ExitFeeModule registration precedes every protocol pointer action, the
CollSurplusPool upgrade precedes the BorrowerOperations swap, the three
BorrowerOperations setters immediately follow the implementation that defines
them, and the protocol controller pointer is the last action of the release.

Module set determined by measurement against mainnet, not by assumption.
LoanClosingsWithSwap and LoanMaintenanceViews join; LoanClosingsLiquidation
stays out (its runtime bytecode differs by 149 bytes of inherited virtual
dispatch, with no selector or behaviour change), as do LoanOpenings,
SwapsExternal and SwapsImplSovrynSwapModule (byte-identical once the library
link is accounted for).

The Zero group gains a fifth action the earlier ledger did not count: the
settlement companion pointer, owner-gated on BorrowerOperations whose owner is
TimelockOwner. Inputs are resolved from PERIMETER_* env vars, deliberately
distinct from the frozen builders' COLFEE_* so a shell holding one shape's
inputs fails loudly rather than half-resolving the other.
…with it

Brings this branch up to the reviewed state of the fee line and carries the
delay work alongside it.

From the fee line: identifier pins asserting slots and surface ids against
literal bytes; the release-set comparison that keeps metadata-only modules out
of a release; the rehearsal fixture freshness guard; the swaps library kept
linked rather than redeployed, decided by comparing runtime bodies with the
metadata trailer stripped; the rehearsal made runnable on a fork by deploying
from local artifacts instead of deployments.fixture(); and comments rewritten to
state properties rather than history.

Resolution where the two lines differ: contracts keep this branch's side, since
the fee line's differing code is what the delay leg supersedes rather than
anything it lacks. Shared helpers and deploy scripts keep both sides.
Fee-line-only rehearsal specs take the fee side.

One resolution was wrong and is corrected here: combining both sides of the
withdraw-collateral spec reintroduced a fail-OPEN expectation for a controller
returning short data. The delay leg is fail-CLOSED, so the exit reverts instead.

The release-set spec fails on this branch, correctly: it is pinned to the fee
line's module set, and this one splits two modules into four and changes a
third. Classifying that set is the next piece of work.
Consolidate the two deployLendingReleaseContracts implementations left by the perimeter branch merge. Preserve the delay release's complete module set while carrying forward deployed-address overrides, so the pre-push JavaScript parser and post-deployment rehearsal mode both work.
Measured (runtime bodies, link addresses normalised, metadata stripped)
against deployment/deployments/rskSovrynMainnet:

  ships   ExitFeeModule, LoanClosingsRollover, LoanClosingsWith,
          LoanMaintenance - differ from their deployed records
  ships   LoanClosingsLiquidation - one-line call-site change from the
          reshaped shared close base; its payout stays direct and uncharged
  ships   LoanClosingsWithSwap, LoanMaintenanceViews - carved out of
          deployed modules, no record under their own name (NEW_MODULES:
          record-absence asserted so a later deploy flips them into the
          differs-from-record comparison)
  out     Affiliates, LoanOpenings, LoanSettings, ProtocolSettings,
          SwapsExternal, SwapsImplSovrynSwapModule - byte-identical bodies
  library SwapsImplSovrynSwapLib - still byte-identical on chain: linked,
          not redeployed

27/27 green. The completeness check binds the lists to getProtocolModules(),
so an added or dropped module fails the pin rather than passing silently.
…rom comments

- 2070 excluded LoanClosingsLiquidation on the premise it is byte-identical to
  the registered module. The premise is false: the module now threads a
  close-origin argument and compiles against the reshaped shared close base, so
  its metadata-stripped runtime body differs, and ReleaseSet.pinned already
  classifies it MUST_SHIP. Uncommented so the deploy matches the pinned release
  set and the running bytecode matches the audited source. The liquidation
  payout itself stays direct and uncharged.
- Stripped a reviewer-finding id from an interface comment and change-history
  narration ('was previously un-hooked', 'used to do so with a direct
  transfer') from two test comments, keeping the property each stated.
…yments

Squashed adoption of sovryn-perimeter-fee into the delay line, per the decision
to complete Phase 2 (delay) before Phase 1's SIPs land:

- Deployment records resolved to the fee branch's deployed addresses for every
  shipping module (ExitFeeModule now 0x0562e396..., LoanClosings*, LoanMaintenance,
  the LM token logics); fee's renamed BorrowerExitPerimeterOps record and its
  ColFeeBorrowerExitOps + solcInput taken as-is. Fee wins on every record that
  diverged, including the ones only the delay branch had touched.
- ReleaseSet.pinned adopts fee's frozen pre-perimeter baseline framework (a
  deployment record goes vacuous after redeploy) and layers the three delay-only
  shipping modules on top: LoanClosingsLiquidation with a computed baseline
  anchor, and the two split modules LoanClosingsWithSwap / LoanMaintenanceViews
  as new modules with no mainnet counterpart. Record-dependent link checks skip
  modules with no record yet.
- fee's Phase-1 SIP-activation test machinery (perimeterActivationSips,
  perimeterSipTestHelpers) comes in for the delay tests to build on.

Release-set 25/25; lending perimeter suite 146 passing.
The external deployment records carried addresses from a superseded first
deployment, on both the fee and delay lines:

- ExitFeeVault pointed at 0x2ba389B0..., the abandoned first vault deploy
  (broadcast run 1786565025004), not the live 0xDDE75f75... The deployed
  controller's feeReceiver() is the latter, and the abandoned proxy is owned by
  a non-governance address, so anything resolving the vault by name would have
  found a contract governance does not control.
- ExitFeeController.implementation, and the _Implementation record, named
  0x8C1abf36... where the proxy's EIP-1967 slot holds 0x50ec5c1c... The proxy
  address itself was correct, which is why the drift was not visible.

external/deployments/rskMainnet is a hardhat-deploy source for rskMainnet and
rskForkedMainnet, so deployments.get() on the fork network -- the network the
rehearsal runs on -- would have resolved the wrong vault. Nothing reads it yet;
this closes the trap before the sequenced rehearsal starts using it. All three
values now match the chain and core's deployments/30 records.

Also restores the hasRecord guard on the release-set link checks: it was edited
after staging during the merge and lost when that worktree was removed, so the
merge commit shipped 3 failing tests. Suite back to 146 passing.
The baseline entry added for LoanClosingsLiquidation was written with a
different indent than the repo's prettier config, so 'prettier --check .'
failed on it. That blocks the pre-push hook (yarn lint && yarn prettier-check)
and would have failed the CI formatting step as well.
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 -- delay carries every file from fee, and the six files that
differ do so intentionally (the corrected mainnet addresses, the delay SIP
helpers, the release set with its delay modules) -- but git no longer knew the
branches were reconciled, so the merge base stayed at the old ancestor and the
pull request 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 so the relationship git
needs is present.
…n-perimeter-delay

# Conflicts:
#	external/deployments/rskMainnet/ExitFeeController.json
#	external/deployments/rskMainnet/ExitFeeController_Implementation.json
Brings the Zero record names, the PERIMETER_ env prefix, the Tenderly
network and the DevelopmentFund test fix onto the delay branch, and
completes the env rename on delay-only code so one prefix is used
throughout.
The fee-branch merge collapsed two different inputs onto the same
PERIMETER_ZERO_BORROWER_OPERATIONS / PERIMETER_ZERO_COLL_SURPLUS_POOL
env names: the frozen SIP-0094 builders and the Phase-2 delay builders
each resolve a different deployed implementation for those contracts,
so sharing a name meant a shell still holding one release's addresses
would silently feed them into the other release instead of failing.
The Phase-2 builders now read PERIMETER_DELAY_ZERO_BORROWER_OPERATIONS
and PERIMETER_DELAY_ZERO_COLL_SURPLUS_POOL; SIP-0094 keeps its original
names untouched. The shared ExitFeeController env stays shared, since
that contract really is the same one in both releases.
…derly

The tests no longer name node-specific RPC methods. Impersonation, funding,
mining and time travel go through one adapter that picks the method from the
node's client version, or from PERIMETER_FORK_KIND when set.
… still needs

The rehearsal used to recreate the Phase 1 proposals from scratch. They are
now on chain and in voting, so the fork instead finds them by their actions,
votes with real stakers when they are still open, queues and executes what is
left, and creates only the part that does not exist yet. Once Phase 1 is
executed on mainnet every branch resolves to a no-op and this module goes.
Swapping an implementation on the BorrowerOperations proxy is not on its own
proof that a proposal is the one we are looking for: the part swaps two
proxies, and another proposal in the scan window could swap the same one. The
match now needs the beacon module registrations as well, and every signature is
pinned to the address it has to run against instead of merely appearing
somewhere in the action list. A unit test fixes the vote predicate against the
governor's own arithmetic, which the fork test cannot reach while the live
proposals already hold quorum.
Splitting the third part's test into an address check and a separate zero-value
check let a proposal satisfy both on different actions: raise the rate here,
zero something else over there, and it would have passed for the part that
retires the subsidy. The calldata test now rides along with the signature and
the address on one action.

The first part gained the controller pin against the BorrowerOperations proxy
as well. The beacon registrations and the implementation swap are not enough on
their own, because the later release rewires the same beacons and the same
proxy; the pin is the action only this part carries.
A rehearsal of the activated perimeter needs a queue whose owner and incident
guardian are the multisig that holds those roles in production, with the
deployer keeping none of them, so both roles become deploy inputs that still
default to the deployer for the tests that build and drive a stack themselves.
The fork adapter is re-exported from the shared helpers so a fixture takes one
import for both.
The rehearsal deploys externally-built contracts from committed fixtures, and
every one of them was pinned to a commit its source repo had moved past, so a
drill of the delay would have exercised the fee-only bytes: the controller
carried no delay surface at all. Rebuilt from the current perimeter and Zero
sources. The surplus claim is now settled by the delegatecall companion, so the
surface it quotes is asserted against the companion's bytes instead of the
BorrowerOperations bytes that no longer contain it.
The single-release builders carry the whole perimeter at once, and with it the
one-time treasury sweep and subsidy retirement. Run after the first release has
landed, both of those legs are refused by their own guards, so a sequenced
delay cannot be proposed with them. These two builders carry only what the
delay adds: the lending modules and the queue pointer on one governor
proposal, the Zero implementations and their pointers on the next. The
controller pointer is asserted rather than rewritten, so a chain that never
received the first release fails loudly instead of taking a delay over
nothing.
The rehearsal now starts where the chain will be: the first release executed,
its controller deployed and owned by the multisig. The multisig upgrades that
same proxy to the delay build -- state preserved and asserted, both delay
switches still off -- the queue is deployed into its hands, governance lands
the two delay proposals, and only then does the multisig arm the hold. Every
operator lever the scenarios use is submitted and confirmed through the real
wallet at its real threshold rather than called from an impersonated account,
and the fixture proves an armed perimeter by holding a live withdrawal instead
of only asserting its wiring.
… its neighbours

The delay proposal for Zero installs an implementation that reaches the
controller through a fixed slot, so it now refuses to build unless
BorrowerOperations already points at the same live controller the protocol
does; asserting only the protocol side left the one product this proposal
actually touches unchecked.

The overrides file is resolved once per process, and the fixture was choosing
it at import: requiring the file was enough to make every sibling test in the
same run attach to the live stack, and to discard a path an operator had set.
It is chosen inside the build now, only when nothing else has chosen one, and a
different choice stops the run instead of being overwritten.
Each surface is queued and released; blocking by a queued withdrawal holds
every request of the same parties; the global pause stops payouts without
stopping blocking; the kill-switch turns the queue into pass-through while
existing holds stand; blacklisted funds return to their pool by route or go
to an arbitrary address by the owner. Every lever runs through the Exchequer
multisig with the same call data the admin page builds.

The multisig driver now STATES its gas limit rather than estimating one. The
wallet swallows a failing inner call — it emits ExecutionFailure and clears
the executed flag while the confirming transaction still succeeds — so an
estimator settles on the cheapest limit at which the wrapper succeeds, which
is a limit where EIP-150's 63/64 rule starves the action itself. The lever
then reports success and changes nothing.
The suite installs a settable price feed so the borrower surface can be
priced after the governance clock jumps, and that feed knows one pair. Left
in place, it hands anything else running on the same node a protocol that
quotes nothing for every other token. Restore the protocol's own feed on the
way out, through the same owner, the way the sibling rehearsals do.
Starts a fresh fork node before each of the four test files, finishes
whatever is left of the live release, activates the delay through
governance and drives every operator lever, then tears the node down before
the next file. hardhat is the only node kind that runs today: the shared
governance helpers only talk to a hardhat node's own RPC methods, so anvil
and tenderly fail fast with a pointer to that migration instead of starting
a node the suite can't use.

Also logs the two delay proposal ids phase2Stack.test.js's fixture creates,
matching what phase1Preflight.test.js already does for its own three — the
rehearsal report has no other way to name which proposals actually ran.
The mocha output and a failed node's log were captured to a file and only
appended to the run log once the whole file finished, so a person watching
the rehearsal saw nothing for minutes at a time. Both now tee to the
terminal as they happen; the pass/fail signal stays the test process's own
exit code, read via PIPESTATUS[0] rather than tee's.
The two Zero implementation resolvers read the CollSurplusPool_Implementation
and BorrowerOperations_Implementation records, while every error they raise
told the operator to look for a record named after the release. Text only; the
resolution logic is unchanged.
Both delay proposals install lending modules that quote a hold on every hooked
exit and fail closed when the controller cannot answer, so a proposal executed
while the controller still serves the earlier build would revert every hooked
withdrawal until the owner's upgrade landed. That ordering lived only in the
fixture's sequence and a runbook. Both builders now resolve the controller and
refuse to build unless it answers the delay views, naming the upgrade as the
prerequisite. The fixture records the refusal on either side of the upgrade so
the rehearsal proves it, and the fixture's own upgrade helper now stops on a
proxy that already carries the delay instead of deploying a second one over it.
The recorded process id is npx's, and killing it does not always take the
hardhat node with it. Both callers ignored a teardown timeout, so the next
file's node could fail to bind while the readiness probe answered against the
old one — the run would continue on the previous fork's state and blame a test
file that was fine. Teardown now kills whatever is listening on the port, and
both a teardown that does not free the port and a port that is already busy at
spawn time end the run with a message naming what to stop.
The preflight's create arm cannot work for the first two parts: the sequenced
rehearsal replaces the very contracts they install and never builds the vintage
those builders demand, so the attempt would die inside a builder guard. It now
refuses those two with a message naming the rehearsal that can be run instead,
and creation stays available only for the subsidy part, which touches nothing
else in the run.
Read the node URL from the network config instead of repeating it, finish the
vault env-var rename the earlier pass missed, and name the recovery-route
setter by its full signature the way every other operator lever is named.
…dapp testing

Adds rskForkedMainnetQa (chainId 30) alongside the existing throwaway
rskForkedMainnet network, and scripts/perimeter/qa-node.sh to boot/stop/
status-check a long-lived fork node for it, independent of the dress
rehearsal's per-file node. The rehearsal's own boot/readiness/teardown
logic moves into a shared scripts/perimeter/fork-node.lib.sh so both
scripts use the same port-free check, readiness wait, and HH604 retry;
that check now also reports who already owns a busy port. qa-node.sh's
--stop only ever kills the PID it recorded itself, never anything else
found listening on the port.
…ree stop

Scopes qa/node.pid and qa/node.log by PERIMETER_QA_PORT so nodes on
different ports don't share (or overwrite) each other's session record,
and refuses to start over a pidfile whose process is still alive. --stop
now walks the full process tree it actually spawned (script -> npm ->
node) instead of only the one process pgrep -P a single level would
catch, so it no longer depends on npx/script forwarding the signal down.
Moves the shared library's nvm activation out of module-load time and
into the boot path only, so --status/--stop don't touch it.
… command, so both dapps can be tested against known ones
…nd let the rehearsal share one node

setupPhase2Stack now recognizes an already-installed Phase 2 release (protocol queue pointer set, controller serving the delay build) and reads it back instead of rebuilding, which the delay proposals refuse anyway on a second pass. dress-rehearsal.sh runs phase2Stack.test.js then perimeterDelayE2E.test.js as one hardhat test invocation on a single node by default (PERIMETER_REHEARSAL_FRESH_NODE=1 restores one node per file), with the fork node's RPC port and listen port both configurable via PERIMETER_REHEARSAL_PORT / PERIMETER_FORK_RPC_URL. perimeterDelayE2E.test.js pins the RBTC/USD collateral price before its live-rate read so it stays green on a node that has been running a while.
…whale stake

Replays every live VoteCast for the three Phase 1 proposals through the
actual voters, advances past their voting windows, and asserts
ensurePhase1Executed executes all three without ever entering its
whale-staker voting branch.
SIP-0094 has executed on mainnet. Carry the fee branch's post-execution
work into the delay line: the on-chain suites' executed-proposals mode
and the dropped round-1 borrower-exit-ops record.
@tjcloa
tjcloa changed the base branch from sovryn-perimeter-fee to development September 4, 2026 21:28
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