Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
277 changes: 147 additions & 130 deletions contracts/BorrowerOperations.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import "./BorrowerOperationsStorage.sol";
import "./Dependencies/Mynt/MyntLib.sol";
import "./Interfaces/IPermit2.sol";
import "./Interfaces/perimeter/IExitFeeController.sol";
import "./Interfaces/perimeter/IExitDelayQueueHook.sol";
import "./Dependencies/BorrowerOperationsPerimeterOps.sol";

contract BorrowerOperations is
LiquityBase,
Expand All @@ -37,7 +39,17 @@ contract BorrowerOperations is
bytes32 private constant PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS =
keccak256("PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS");

// --- Security-perimeter exit-delay hook ---
// The delay queue pointer also lives in an EIP-1967-style unstructured
// slot, so `BorrowerOperations` storage-layout is unchanged (zero-diff). It is
// rotated with `setExitDelayQueue` under the SAME owner as the controller
// pointer. Because the pointer redirects ESCROW it is more sensitive than the
// controller pointer — rotation is an Owner/SIP action.
bytes32 private constant EXIT_DELAY_QUEUE_SLOT =
bytes32(uint256(keccak256("sovryn.perimeterExitDelayQueue")) - 1);

event ExitFeeControllerSet(address indexed previous, address indexed current);
event ExitDelayQueueSet(address indexed previous, address indexed current);
event ExitFeeApplied(
bytes32 indexed surfaceId,
address indexed actor,
Expand Down Expand Up @@ -817,78 +829,26 @@ contract BorrowerOperations is
}

/**
* Claim remaining collateral from a redemption or from a liquidation with ICR > MCR in Recovery Mode,
* charging the Perimeter exit fee when the PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS policy is active.
* Fail-open like every Perimeter hook: on any Perimeter failure (controller missing/
* reverting, invalid quote, fee-leg transfer failure inside the pool) the claimant
* receives the full surplus — a Perimeter failure can never brick a claim. The
* non-charging path is the untouched claimColl flow (plus the ExitFeeSkipped
* event, same convention as _sendCollWithExitFee).
* Claim remaining collateral from a redemption or from a liquidation with ICR > MCR in
* Recovery Mode, settled through the security perimeter: the exit fee leg when the
* PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS policy is active, then the delay leg.
*
* The FEE leg is fail-open, as every Perimeter fee hook is: a missing or reverting
* controller, an invalid quote or a failed fee transfer leaves the claimant with the
* full surplus. The DELAY leg is fail-CLOSED: once the perimeter resolves a hold the
* net must escrow, so an unwired or reverting queue reverts the claim and the claimant
* keeps their surplus balance to claim again later.
*
* With no fee and no delay this is the untouched claimColl flow, plus the
* ExitFeeSkipped event.
*/
function claimCollateral() external override {
uint256 gross = collSurplusPool.getCollateral(msg.sender);
// Single Zero deployment: subProduct = address(0). Asset is native RBTC.
IExitFeeController.ExitFeeQuote memory q = _safeQuote(
PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS,
address(0),
msg.sender,
gross
_delegateToPerimeterOps(
abi.encodeWithSelector(
BorrowerOperationsPerimeterOps(address(0)).claimSurplusWithPerimeter.selector,
collSurplusPool
)
);

// Defensive: a quote that charges into address(0) would burn the fee (a
// value call to a no-code address succeeds). Demote to the non-charging
// path — DISABLED is the enum's "feeReceiver == address(0)" reason.
if (q.active && q.feeAmount > 0 && q.feeReceiver == address(0)) {
q.active = false;
q.netAmount = gross;
q.reason = uint8(IExitFeeController.SkipReason.DISABLED);
}

if (q.active && q.feeAmount > 0) {
// Two-leg split inside the pool (fee → feeReceiver, net → claimant);
// the pool's fee leg is fail-open and reports which event is truthful.
bool feePaid = collSurplusPool.claimCollWithFee(
msg.sender,
q.feeReceiver,
q.feeAmount
);
if (feePaid) {
emit ExitFeeApplied(
PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS,
msg.sender,
address(0),
address(0),
msg.sender,
gross,
q.feeAmount,
q.netAmount,
q.feeReceiver
);
} else {
emit ExitFeeSkipped(
PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS,
msg.sender,
address(0),
gross,
q.rateBps,
uint8(IExitFeeController.SkipReason.VAULT_REVERT)
);
}
} else {
// !active (INACTIVE / DISABLED / INVALID_QUOTE / CONTROLLER_REVERT)
// OR active-but-zero-fee (dust / zero-rate / gross == 0 → reason NONE).
emit ExitFeeSkipped(
PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS,
msg.sender,
address(0),
gross,
q.rateBps,
q.reason
);
// send ETH from CollSurplus Pool to owner — untouched original path
// (gross == 0 falls through to claimColl's own revert, identical to today)
collSurplusPool.claimColl(msg.sender);
}
}

// --- Helper functions ---
Expand Down Expand Up @@ -1004,6 +964,86 @@ contract BorrowerOperations is
emit ExitFeeControllerSet(prev, ctrl);
}

/// @notice Address of the ExitDelayQueue this instance escrows delayed
/// collateral exits into. Held in an EIP-1967-style unstructured slot
/// (no regular-storage footprint). address(0) until governance pins
/// one ⇒ the security-perimeter reroute is unwired ⇒ exits pay direct
/// at `d == 0` and fail CLOSED at `d > 0` (a delay is never silently
/// bypassed by a missing pointer).
function exitDelayQueue() public view returns (address queue) {
bytes32 slot = EXIT_DELAY_QUEUE_SLOT;
assembly {
queue := sload(slot)
}
}

/// @notice Pin/rotate the ExitDelayQueue pointer. `onlyOwner` — the same
/// BorrowerOperations proxy owner (= TimelockOwner on mainnet) that
/// gates `setExitFeeController`. Because the pointer redirects ESCROW
/// it is MORE sensitive than the controller pointer; rotation is an
/// Owner/SIP action. Reverts on a non-contract so a typo cannot point
/// the reroute at a no-code address (address(0) is likewise rejected —
/// unwiring, if ever needed, is a deliberate distinct governance path,
/// and the perimeter is instead disabled via the controller kill
/// switch, which quotes `d == 0` and pays direct).
function setExitDelayQueue(address queue) external onlyOwner {
require(queue != address(0), "EDQ:zero");
checkContract(queue);
address prev = exitDelayQueue();
bytes32 slot = EXIT_DELAY_QUEUE_SLOT;
assembly {
sstore(slot, queue)
}
emit ExitDelayQueueSet(prev, queue);
}

/// @dev Fail-CLOSED delay quote wrapper. Resolves the single hook
/// entry `quoteExitDelayFor` on the shared Perimeter controller and returns
/// `(d, effOrig, effOwner)`. Two levels, deliberately distinct:
/// 1. controller-POINTER lookup is FAIL-OPEN — a missing OR code-less
/// controller ⇒ perimeter unwired ⇒ `(0, raw, raw)` ⇒ pay direct
/// (mirrors the fee path; also, 0.6.11 try/catch does NOT catch a
/// call to a no-code address, so the extcodesize guard is required);
/// 2. once a controller is resolved, the `quoteExitDelayFor` CALL is
/// FAIL-CLOSED — a revert reverts the whole exit and MUST NOT be
/// interpreted as `d = 0`-direct (that would silently disable the
/// perimeter — the hazard this guards against). Uses a DISTINCT revert selector for
/// halt monitoring.
/// The `!securityPerimeterEnabled` short-circuit is the FIRST statement
/// inside `quoteExitDelayFor`, so a healthy-but-disabled perimeter returns
/// `(0, raw, owner)` normally (liveness escape). The hook ignores
/// `effOrig`/`effOwner` whenever `d == 0`.
function _safeQuoteExitDelay(
address rawOriginator,
address owner,
address receiver
) private view returns (uint32 d, address effOrig, address effOwner) {
address ctrl = exitFeeController();
uint256 ctrlSize;
assembly {
ctrlSize := extcodesize(ctrl)
}
// Level 1 — FAIL-OPEN pointer lookup: unwired/unreachable ⇒ direct pay.
// Raw identities are returned but the caller ignores them when d == 0.
if (ctrl == address(0) || ctrlSize == 0) {
return (0, rawOriginator, owner);
}
// Level 2 — FAIL-CLOSED quote: a controller revert reverts the exit.
try
IExitFeeController(ctrl).quoteExitDelayFor(
rawOriginator,
owner,
receiver,
PERIMETER_SURFACE_ZERO_WITHDRAW_COLL,
address(0)
)
returns (uint32 d_, address effOrig_, address effOwner_) {
return (d_, effOrig_, effOwner_);
} catch {
revert("PERIMETER:delay-quote-failed");
}
}

/// @dev Fail-open quote wrapper. On a missing/reverting controller or a
/// semantically invalid quote, returns a non-charging quote with
/// `netAmount == gross`. The validity gate uses subtraction only
Expand Down Expand Up @@ -1057,76 +1097,53 @@ contract BorrowerOperations is
}
}

/// @dev Settle a borrower collateral payout, charging the Perimeter exit fee
/// when the resolved policy is active. The fee leg uses `try/catch`
/// (0.6.11 native) so a fee-receiver failure never bricks the exit; on
/// any non-charging path the full `gross` is sent to the borrower via
/// the existing fail-closed `sendETH`. ActivePool's recorded ETH
/// decrements by exactly `gross` either way (the reverted fee-leg
/// subcall rolls back its `ETH.sub`).
/// @dev Settle a borrower collateral payout through the security perimeter:
/// the fee leg, then the delay leg. The body runs in
/// `BorrowerOperationsPerimeterOps` under `delegatecall`, so it settles
/// in this proxy's context and the same transaction, and its events
/// carry this address.
///
/// Any failure propagates unchanged. Once the perimeter resolves a
/// delay the leg must escrow, so a reverting hook reverts the whole
/// close or adjust rather than paying direct.
function _sendCollWithExitFee(
IActivePool _activePool,
address borrower,
uint256 gross
) private {
// Debt-only adjustments (repay / debt-decrease) reach here with gross == 0:
// no collateral leaves the pool, so there is nothing to settle. Skip the
// controller round-trip and the Perimeter event. (Baseline called
// sendETH(borrower, 0) here — a value-less no-op that only emitted
// EtherSent(_, 0) / ActivePoolETHBalanceUpdated; we drop that redundant
// transfer, so debt-only ops emit fewer events than pre-Perimeter.)
if (gross == 0) {
return;
}

// Single Zero deployment: subProduct = address(0). Asset is native RBTC.
IExitFeeController.ExitFeeQuote memory q = _safeQuote(
PERIMETER_SURFACE_ZERO_WITHDRAW_COLL,
address(0),
borrower,
gross
_delegateToPerimeterOps(
abi.encodeWithSelector(
BorrowerOperationsPerimeterOps(address(0)).sendCollWithExitFee.selector,
_activePool,
borrower,
gross
)
);
}

if (q.active && q.feeAmount > 0) {
try _activePool.sendETH(q.feeReceiver, q.feeAmount) {
_activePool.sendETH(borrower, q.netAmount); // user leg: existing fail-closed behavior
// Emit only after BOTH legs settle, so an ExitFeeApplied event always
// implies a completed borrower payout (truthful by construction).
emit ExitFeeApplied(
PERIMETER_SURFACE_ZERO_WITHDRAW_COLL,
borrower,
address(0),
address(0),
borrower,
gross,
q.feeAmount,
q.netAmount,
q.feeReceiver
);
return;
} catch {
emit ExitFeeSkipped(
PERIMETER_SURFACE_ZERO_WITHDRAW_COLL,
borrower,
address(0),
gross,
q.rateBps,
uint8(IExitFeeController.SkipReason.VAULT_REVERT)
);
/// @dev Run one perimeter settlement in this proxy's context and transaction.
/// Any failure propagates unchanged, so a reverting settlement reverts
/// the whole close, adjust or claim rather than paying direct.
///
/// `checkContract` is what makes that true: a `delegatecall` to a
/// code-less address SUCCEEDS with empty returndata, so an unset hook
/// would otherwise skip the fee and the delay in silence.
function _delegateToPerimeterOps(bytes memory payload) private {
address ops = perimeterOps;
checkContract(ops);
(bool ok, bytes memory ret) = ops.delegatecall(payload);
if (!ok) {
assembly {
revert(add(ret, 0x20), mload(ret))
}
} else {
// !active (INACTIVE / DISABLED / INVALID_QUOTE / CONTROLLER_REVERT)
// OR active-but-zero-fee (dust / zero-rate policy → q.reason == NONE).
emit ExitFeeSkipped(
PERIMETER_SURFACE_ZERO_WITHDRAW_COLL,
borrower,
address(0),
gross,
q.rateBps,
q.reason
);
}
_activePool.sendETH(borrower, gross); // full-gross fallback (any non-charging path)
}

/// @notice Rotate the perimeter settlement hook. `onlyOwner`, mirroring
/// `setTroveManagerRedeemOps`.
function setPerimeterOps(address _perimeterOps) external onlyOwner {
checkContract(_perimeterOps);
perimeterOps = _perimeterOps;
}

/// @notice Read-only preview of the Perimeter exit fee on a Zero borrower collateral
Expand Down
5 changes: 5 additions & 0 deletions contracts/BorrowerOperationsStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,9 @@ contract BorrowerOperationsStorage is Ownable {

IMassetManager public massetManager;
IFeeDistributor public feeDistributor;

/// @notice The perimeter settlement hook `_sendCollWithExitFee` delegates to.
/// Appended last so no existing slot moves; set at init and
/// rotatable by the owner, mirroring `troveManagerRedeemOps`.
address public perimeterOps;
}
44 changes: 44 additions & 0 deletions contracts/CollSurplusPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,50 @@ contract CollSurplusPool is CollSurplusPoolStorage, CheckContract, ICollSurplusP
require(success, "CollSurplusPool: sending ETH failed");
}

/// @notice Two-leg claim that sends the remainder to `_netRecipient`.
/// Same accounting and same CEI ordering as `claimCollWithFee`: the
/// claim is resolved against `_account`'s balance, which is zeroed
/// before either external call, and the single `ETH` decrement equals
/// fee + net exactly. Only the destination of the net leg differs, so
/// the perimeter can escrow it in the exit delay queue instead of
/// paying the claimant directly. The fee leg stays fail-open and the
/// net leg fail-closed.
///
/// `claimCollWithFee` is left exactly as deployed rather than
/// delegating here: it is the selector the shipped BorrowerOperations
/// calls, and it is the rollback target.
function claimCollWithFeeTo(
address _account,
address _feeReceiver,
uint256 _feeAmount,
address _netRecipient
) external override returns (bool feePaid, uint256 netAmount) {
_requireCallerIsBorrowerOperations();
require(_netRecipient != address(0), "CollSurplusPool: zero net recipient");
uint256 claimableColl = balances[_account];
require(claimableColl > 0, "CollSurplusPool: No collateral available to claim");
require(_feeAmount <= claimableColl, "CollSurplusPool: fee exceeds claimable");

balances[_account] = 0;
emit CollBalanceUpdated(_account, 0);

ETH = ETH.sub(claimableColl);

// A zero fee takes no leg at all: a zero-value call would report a fee
// that was never charged, and an uncharged claim can still be delayed.
if (_feeAmount > 0) {
(feePaid, ) = _feeReceiver.call{ value: _feeAmount, gas: FEE_LEG_GAS_CAP }("");
if (feePaid) {
emit EtherSent(_feeReceiver, _feeAmount);
}
}
netAmount = feePaid ? claimableColl.sub(_feeAmount) : claimableColl;

emit EtherSent(_netRecipient, netAmount);
(bool success, ) = _netRecipient.call{ value: netAmount }("");
require(success, "CollSurplusPool: sending ETH failed");
}

// --- 'require' functions ---

function _requireCallerIsBorrowerOperations() internal view {
Expand Down
Loading