Sovryn Perimeter Delay: lending and borrowing - #582
Draft
tjcloa wants to merge 53 commits into
Draft
Conversation
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.
…ion, so both dapps can be tested by hand
…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.
…, and stop overwriting live balances
… command, so both dapps can be tested against known ones
…no delay is asked for
…efused send by the queue's own error name
…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.
…tage, refund's reach stated per leg
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of the Sovryn security perimeter — the 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
ExitDelayQueuefor a governance-configured period, so a detected theft can be frozen or blacklisted and routed to recovery before the funds leave.What this carries
closeWithSwapexcess-collateral refund. The fee leg is paid immediately and only the net is escrowed, so fee and delay compose without double-charging.ExitFeeModulegains 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-feerather thandevelopmentso the diff is the delay delta alone. Re-target todevelopmentonce SIP-0094 is approved and executed — the Perimeter Fee must land first.Not ready to merge — two known blockers
LoanClosingsWith,LoanMaintenanceandLoanClosingsRolloverexceed 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 adelegatecallmodule in the shapeColFeeBorrowerExitOpsalready uses, and/or converting the shared helpers to a deployed library.Verification so far
tests/colfee: 107 passing, 0 failing.Deeper review and the audit gates (reentrancy, queue custody, cross-repo fork integration) are still outstanding and are tracked separately.