Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
967aede
Introduce the Sovryn Perimeter Delay across lending and borrowing
tjcloa Aug 17, 2026
f8be299
Fix the delay suites after their move under tests/
tjcloa Aug 17, 2026
604dd68
Move the borrower-exit escrow into the delegatecall hook
tjcloa Aug 18, 2026
271c27b
Separate the charged and uncharged close paths by inheritance
tjcloa Aug 19, 2026
a2e45de
Rename ColFee to Perimeter across the lending integration
tjcloa Aug 19, 2026
f34388c
Rename the surface ids and the hook slot to Perimeter
tjcloa Aug 19, 2026
c00d3d0
Split the two oversized close and maintenance modules
tjcloa Aug 19, 2026
8ace1c7
Prefix the perimeter pointer slots with their own namespace
tjcloa Aug 19, 2026
e06e332
Register the two new modules for deployment
tjcloa Aug 19, 2026
5f947f1
Add the aggregate perimeter release proposal builders
tjcloa Aug 19, 2026
5387210
Perimeter Delay: take the reviewed fee line and the guards that came …
tjcloa Aug 23, 2026
bca43de
Fix merged perimeter rehearsal helper declaration
tjcloa Aug 23, 2026
05b1bd4
Classify the delay release set against the deployed mainnet bodies
tjcloa Aug 27, 2026
739230c
review cycle 1: deploy LoanClosingsLiquidation; drop review-history f…
tjcloa Aug 27, 2026
be2b60b
Adopt the fee branch as authoritative for naming, addresses and deplo…
tjcloa Aug 29, 2026
153651c
Correct stale perimeter addresses in the external mainnet records
tjcloa Aug 29, 2026
56e5fb1
Format the release-set baseline to the repo style
tjcloa Aug 30, 2026
9abdd34
Record the fee branch as merged into the delay line
tjcloa Aug 30, 2026
a582318
Merge remote-tracking branch 'origin/sovryn-perimeter-fee' into sovry…
tjcloa Aug 30, 2026
9268695
Merge the fee branch into the delay line
tjcloa Sep 1, 2026
e5cc1e7
Give the Phase-2 Zero implementation inputs their own env names
tjcloa Sep 1, 2026
23a2999
Add a fork-node adapter so one rehearsal drives hardhat, anvil or Ten…
tjcloa Sep 1, 2026
92b4bdc
Finish the live Phase 1 proposals on a fork, only as far as the chain…
tjcloa Sep 1, 2026
a964bf9
Identify the first Phase 1 proposal by its whole shape, not one action
tjcloa Sep 1, 2026
6bfb5cb
Judge every clause of a proposal's shape on the same action
tjcloa Sep 2, 2026
654a32b
Let the queue be deployed into the hands that will hold it
tjcloa Sep 2, 2026
9c15abd
Rebuild the rehearsal fixtures from the delay sources
tjcloa Sep 2, 2026
2ae3154
Give the delay its own release shape, layered on the live perimeter
tjcloa Sep 2, 2026
74cadbd
Build the activated delay perimeter once, on the controller that is live
tjcloa Sep 2, 2026
58a34e0
Guard the Zero controller pointer, and stop the fixture reconfiguring…
tjcloa Sep 2, 2026
541d924
Rehearse every operator lever of the delay perimeter on a fork
tjcloa Sep 2, 2026
f427d00
Hand the real price feeds back when the delay rehearsal finishes
tjcloa Sep 2, 2026
b31f025
Run the whole delay rehearsal with one command
tjcloa Sep 2, 2026
64ac4c7
Stream each file's test output live instead of only after the fact
tjcloa Sep 2, 2026
26aa27f
Name the deployment records the Zero resolvers actually read
tjcloa Sep 2, 2026
f473dde
Refuse the delay proposals until the controller carries the delay
tjcloa Sep 2, 2026
99063aa
Make the rehearsal stop when a fork node outlives its own teardown
tjcloa Sep 2, 2026
325aea6
Say plainly that the preflight can only create the subsidy part
tjcloa Sep 2, 2026
7ef567c
Tidy the rehearsal files the reviewer flagged
tjcloa Sep 2, 2026
22e717c
Strip process narrative from perimeter interface headers, keep NatSpe…
tjcloa Sep 2, 2026
9f6bed1
Trim narrative from perimeter SIP-args JSDoc, keep action lists and i…
tjcloa Sep 2, 2026
36cb2b2
Rename delay builders' Zero env vars to match the fee branch's shared…
tjcloa Sep 2, 2026
ebb99e6
Add a standalone QA fork node reporting RSK chain id 30 for MetaMask/…
tjcloa Sep 2, 2026
f348e52
Bootstrap a QA fork to the armed withdrawal-delay state by impersonat…
tjcloa Sep 2, 2026
4a3defe
Harden qa-node.sh: port-scoped pidfile, refuse-if-live guard, whole-t…
tjcloa Sep 2, 2026
1bb5b84
QA bootstrap: arm the charge with the hold, verify the release landed…
tjcloa Sep 2, 2026
f0d572e
QA scenario engine: drive the withdrawal queue through every state by…
tjcloa Sep 2, 2026
2f69275
QA bootstrap: keep whatever hold the controller already carries when …
tjcloa Sep 2, 2026
ca71598
QA engine: prove the payout gates on unlocked requests and report a r…
tjcloa Sep 2, 2026
b13687f
phase2Stack: attach to an installed release instead of redeploying, a…
tjcloa Sep 2, 2026
8baba6d
Add a fork test that carries Phase 1 on the real Bitocracy vote, not …
tjcloa Sep 3, 2026
a585349
QA fork guard asks the node itself, delay builders refuse a stale vin…
tjcloa Sep 3, 2026
8a9563f
Merge branch 'sovryn-perimeter-fee' into sovryn-perimeter-delay
tjcloa Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ typechain/
!.github/
*/deployments/localhost
*/deployments/*forked*
*/deployments/rskForkedMainnetQa
venv/
!/external/artifacts/
cache_hardhat/
secrets/
secrets/
crytic-export/
/qa/
165 changes: 162 additions & 3 deletions contracts/connectors/loantoken/LoanTokenLogicShared.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import "../../farm/ILiquidityMining.sol";
import "../../governance/Staking/interfaces/IStaking.sol";
import "../../governance/Vesting/IVesting.sol";
import "../../interfaces/perimeter/IExitFeeController.sol";
import "../../interfaces/perimeter/IExitDelayQueueHook.sol";
import "../../interfaces/perimeter/IPerimeterEvents.sol";
import "../../utils/PerimeterLib.sol";

Expand All @@ -24,6 +25,10 @@ contract LoanTokenLogicShared is LoanTokenLogicStorage, IPerimeterEvents {
/// Perimeter keeps no state on the iToken; the controller is read from the
/// protocol below.

/// @dev The literal is the surface id: its keccak hash is the key the
/// controller resolves a rate policy under. Changing the string
/// changes the id, so a policy must be configured against the new
/// hash before this surface can charge.
/// keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW")
bytes32 internal constant PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW =
keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW");
Expand All @@ -35,6 +40,118 @@ contract LoanTokenLogicShared is LoanTokenLogicStorage, IPerimeterEvents {
return PerimeterLib.safeControllerLookup(sovrynContractAddress);
}

/// @notice The ExitDelayQueue, read from the protocol singleton via a
/// fail-open staticcall (address(0) until governance pins one ⇒ the
/// security-perimeter reroute is unwired ⇒ the burn pays direct).
/// Mirrors `exitFeeController()`: a single protocol-side pointer that
/// every iToken reads through, so rotation is one Owner/SIP action.
/// @return queue ExitDelayQueue address, or address(0).
function exitDelayQueue() public view returns (address queue) {
return PerimeterLib.safeQueueLookup(sovrynContractAddress);
}

/// @notice Fail-CLOSED delay quote for the lender-exit surface. A
/// quote failure reverts the exit; the disabled-perimeter case is a
/// healthy `d = 0` return from inside the controller (liveness).
function _safeQuoteExitDelay(
address rawOriginator,
address owner,
address receiver,
bytes32 surfaceId,
address subProduct
) internal view returns (uint32 d, address effOrig, address effOwner) {
return
PerimeterLib.safeQuoteDelay(
exitFeeController(),
rawOriginator,
owner,
receiver,
surfaceId,
subProduct
);
}

/// @notice Pay the ERC20 user leg of a lender exit, rerouting into the
/// ExitDelayQueue when the security perimeter imposes a delay
/// (`d > 0`). Fee leg is UNCHANGED and already ran in the caller; this
/// only governs the USER leg's destination.
///
/// The quote is taken ONCE per exit and drives BOTH the net (fee-ok)
/// and the full-gross (fee-failed) payout sites, so a fee-vault
/// failure still escrows gross behind the delay and cannot bypass it.
/// The queue is NEVER touched until `d > 0` is established:
/// when `d == 0` this is the existing direct primitive, byte-identical
/// to today's behaviour.
///
/// Fail-open POINTER / fail-closed QUOTE split: the
/// controller-pointer read behind `_safeQuoteExitDelay`
/// (`exitFeeController()` → `PerimeterLib.safeControllerLookup`) is
/// FAIL-OPEN — an unset/unreachable pointer resolves to address(0),
/// yields `d == 0`, and pays direct (a missing pointer silently
/// disables the perimeter for this host rather than bricking the
/// exit on a botched module rotation). Once the controller IS
/// reachable the QUOTE stays FAIL-CLOSED: a reverting
/// `quoteExitDelayFor` reverts the whole exit, so an active
/// perimeter can never be bypassed. Accepted residual + monitoring:
/// see `PerimeterLib.safeControllerLookup`.
/// @param receiver Immutable payout destination (the burn's `receiver` arg).
/// @param userAmount Net on fee-success, full gross on fee-failure.
/// @param errorMsg Revert reason for the direct fail-closed transfer.
function _payExitUserLeg(
address receiver,
uint256 userAmount,
string memory errorMsg
) internal {
if (userAmount == 0) return;

// owner == rawOriginator == msg.sender: `burn(receiver, amt)` burns the
// CALLER's iTokens (the position/pool share), so the burner is both the
// withdrawal originator and the position owner. The controller normalizes
// a registered wrapper passthrough (→ receiver) for BOTH inside the quote.
(uint32 d, address effOrig, address effOwner) = _safeQuoteExitDelay(
msg.sender,
msg.sender,
receiver,
PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW,
address(this)
);

if (d > 0) {
// fail-CLOSED escrow. Only now — with the delay path active — do we
// touch the queue. Narrow guard is the caller's responsibility;
// enforce it here before any escrow accounting.
require(userAmount <= uint256(uint128(-1)), "PERIMETER:amount-too-large");
address queue = exitDelayQueue();
require(queue != address(0), "PERIMETER:queue-unset");
// Pull path: approve the queue for exactly `userAmount`, then it
// `safeTransferFrom`s and proves the received amount == amount.
// Optional-return approve: `loanTokenAddress` is the iToken
// UNDERLYING and MAY be a USDT-style no-return ERC20 (this repo
// supports them via `_callOptionalReturn`/`_safeTransfer`). A raw
// high-level `.approve()` would revert on such tokens and DoS every
// delayed lender exit; `_safeApprove` tolerates the missing return.
// Allowance is provably 0 at entry (the queue pulls EXACTLY
// `userAmount`, returning the allowance to 0), so no zero-first
// reset is needed.
_safeApprove(loanTokenAddress, queue, userAmount);
IExitDelayQueueHook(queue).recordERC20Exit(
loanTokenAddress,
uint128(userAmount),
d,
PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW,
address(this),
effOrig,
effOwner,
receiver,
false // unwrapOnDelivery: this is the plain-ERC20 burn path
);
} else {
// direct — the QUEUE IS NEVER TOUCHED (liveness). Existing
// fail-closed user-payout primitive, unchanged from today.
_transferUnderlyingToken(receiver, userAmount, false, errorMsg);
}
}

/// @notice Quote the lender-exit fee from this iToken's controller
/// (fail-open).
function _safeQuoteExitFee(
Expand Down Expand Up @@ -102,7 +219,9 @@ contract LoanTokenLogicShared is LoanTokenLogicStorage, IPerimeterEvents {
q.netAmount,
q.feeReceiver
);
_transferUnderlyingToken(receiver, q.netAmount, false, errorMsg);
// USER leg (net): reroute into the delay queue when d > 0,
// else the existing fail-closed direct transfer.
_payExitUserLeg(receiver, q.netAmount, errorMsg);
return;
}
emit ExitFeeSkipped(
Expand All @@ -125,8 +244,9 @@ contract LoanTokenLogicShared is LoanTokenLogicStorage, IPerimeterEvents {
);
}
// Fallback to full-gross to user (covers !active, INVALID_QUOTE,
// and fee-leg failure).
_transferUnderlyingToken(receiver, gross, false, errorMsg);
// and fee-leg failure). Full-gross fallback site: reroute behind
// the delay too, so a fee-vault failure cannot bypass the perimeter.
_payExitUserLeg(receiver, gross, errorMsg);
}

/// @notice ERC20 transfer of `loanTokenAddress` shared by both Perimeter legs.
Expand Down Expand Up @@ -481,6 +601,45 @@ contract LoanTokenLogicShared is LoanTokenLogicStorage, IPerimeterEvents {
);
}

/**
* @notice Execute the ERC20 token's `approve` function, tolerating
* USDT-style no-return implementations exactly as `_safeTransfer` /
* `_safeTransferFrom` do (via `_callOptionalReturn`): a no-return call is
* treated as success, a returned `false` reverts. A raw high-level
* `IERC20(token).approve(...)` would revert on no-return underlyings and
* DoS the delayed-exit pull path; this helper avoids that.
*
* @dev Zero-first reset (approve(0) then approve(amount)) for full
* USDT-style safety: some no-return tokens REVERT on a non-zero → non-zero
* approve, so any residual allowance is first cleared to 0 before the new
* amount is set — matching `VaultController.vaultApprove`. Callers on the
* delayed-exit pull path normally hold a provably-zero prior allowance (the
* queue pulls EXACTLY the approved amount, returning the allowance to 0), so
* the reset is defensive; the `allowance != 0` guard skips the extra SSTORE
* in the common zero-residual case. Fail-closed is preserved: a returned
* `false` from either approve reverts here (PERIMETER:approve-failed), and a
* silently-failed approve still surfaces as the queue's `safeTransferFrom`
* revert on the pull.
*
* @param token The ERC20 token address.
* @param spender The address being approved.
* @param amount The approval amount.
*/
function _safeApprove(address token, address spender, uint256 amount) internal {
if (amount != 0 && IERC20(token).allowance(address(this), spender) != 0) {
_callOptionalReturn(
token,
abi.encodeWithSelector(IERC20(token).approve.selector, spender, 0),
"PERIMETER:approve-failed"
);
}
_callOptionalReturn(
token,
abi.encodeWithSelector(IERC20(token).approve.selector, spender, amount),
"PERIMETER:approve-failed"
);
}

/** Internal view function */
/**
* @notice Compute the token price.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ contract LoanTokenLogicLM is LoanTokenLogicSplit {
pure
returns (bytes4[] memory functionSignatures, bytes32 moduleName)
{
bytes4[] memory res = new bytes4[](5);
bytes4[] memory res = new bytes4[](6);

// Loan Token LM & OVERLOADING function
/**
Expand All @@ -41,6 +41,9 @@ contract LoanTokenLogicLM is LoanTokenLogicSplit {
// Perimeter controller view.
res[4] = bytes4(keccak256("exitFeeController()"));

// Security-perimeter delay-queue view.
res[5] = bytes4(keccak256("exitDelayQueue()"));

return (res, stringToBytes32("LoanTokenLogicLM"));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity 0.5.17;
pragma experimental ABIEncoderV2;

import "../../LoanTokenLogicSplit.sol";
import "../../../../interfaces/perimeter/IExitDelayQueueHook.sol";

contract LoanTokenLogicWrbtcLM is LoanTokenLogicSplit {
/**
Expand All @@ -23,7 +24,7 @@ contract LoanTokenLogicWrbtcLM is LoanTokenLogicSplit {
pure
returns (bytes4[] memory functionSignatures, bytes32 moduleName)
{
bytes4[] memory res = new bytes4[](5);
bytes4[] memory res = new bytes4[](6);

// Loan Token Mint and Burn.
res[0] = this.mint.selector;
Expand All @@ -37,6 +38,9 @@ contract LoanTokenLogicWrbtcLM is LoanTokenLogicSplit {
// under overloaded names.
res[4] = bytes4(keccak256("exitFeeController()"));

// Security-perimeter delay-queue view.
res[5] = bytes4(keccak256("exitDelayQueue()"));

return (res, stringToBytes32("LoanTokenLogicWrbtcLM"));
}

Expand Down Expand Up @@ -99,7 +103,10 @@ contract LoanTokenLogicWrbtcLM is LoanTokenLogicSplit {
q.netAmount,
q.feeReceiver
);
_transferNativeRBTC(receiver, q.netAmount, false);
// USER leg (net): reroute WRBTC into the delay queue when
// d > 0 (queue unwraps at delivery), else the existing native
// primitive (WRBTC escrow, deferred unwrap).
_payExitUserLegNative(receiver, q.netAmount);
return;
}
emit ExitFeeSkipped(
Expand All @@ -121,7 +128,60 @@ contract LoanTokenLogicWrbtcLM is LoanTokenLogicSplit {
q.reason
);
}
_transferNativeRBTC(receiver, gross, false);
// Full-gross fallback site: reroute behind the delay too.
_payExitUserLegNative(receiver, gross);
}

/// @notice Pay the native (RBTC) user leg of the `burnToBTC` exit, rerouting
/// into the ExitDelayQueue when the perimeter imposes a delay
/// (`d > 0`): the queue escrows WRBTC and unwraps it on delivery.
/// The iToken STILL holds WRBTC at this point (the unwrap is
/// deferred to `executeExit`), so the delayed user leg transfers
/// WRBTC to the queue with `unwrapOnDelivery=true` and
/// `_transferNativeRBTC` is SKIPPED on the delayed leg. The fee leg
/// is unchanged (native, fail-open, re-wrap on failure). When
/// `d == 0` this is the existing native primitive, paid direct.
/// @param receiver Immutable payout destination.
/// @param userAmount Net on fee-success, full gross on fee-failure.
function _payExitUserLegNative(address receiver, uint256 userAmount) internal {
if (userAmount == 0) return;

// owner == rawOriginator == msg.sender (see `_payExitUserLeg`): the
// burner is both the withdrawal originator and the position owner.
(uint32 d, address effOrig, address effOwner) = _safeQuoteExitDelay(
msg.sender,
msg.sender,
receiver,
PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW,
address(this)
);

if (d > 0) {
require(userAmount <= uint256(uint128(-1)), "PERIMETER:amount-too-large");
address queue = exitDelayQueue();
require(queue != address(0), "PERIMETER:queue-unset");
// Escrow WRBTC (NOT native): the iToken hands the queue WRBTC + the
// unwrap flag; the queue unwraps to native RBTC at executeExit. No
// `_transferNativeRBTC` on this delayed user leg. Use the shared
// optional-return `_safeApprove`: WRBTC returns a bool today,
// but this keeps BOTH exit-leg approve sites on one no-return-safe
// primitive. Allowance is provably 0 at entry (queue pulls exactly
// `userAmount`), so no zero-first reset is needed.
_safeApprove(wrbtcTokenAddress, queue, userAmount);
IExitDelayQueueHook(queue).recordERC20Exit(
wrbtcTokenAddress,
uint128(userAmount),
d,
PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW,
address(this),
effOrig,
effOwner,
receiver,
true // unwrapOnDelivery: queue holds WRBTC, unwraps at delivery
);
} else {
_transferNativeRBTC(receiver, userAmount, false);
}
}

/// @notice Unwrap WRBTC and send native RBTC.
Expand Down
2 changes: 1 addition & 1 deletion contracts/interfaces/ILoanTokenModules.sol
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ contract ILoanTokenModules is IPerimeterEvents {

/** INTERFACE */

/** PERIMETER — controller view */
/** Perimeter — controller view */
function exitFeeController() external view returns (address);

/** START LOAN TOKEN SETTINGS LOWER ADMIN */
Expand Down
4 changes: 4 additions & 0 deletions contracts/interfaces/ISovryn.sol
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ contract ISovryn is

function setBorrowerExitPerimeterOps(address ops) external;

function exitDelayQueue() external view returns (address);

function setExitDelayQueue(address queue) external;

////// Loan Maintenance //////

function depositCollateral(
Expand Down
Loading
Loading