Skip to content
Merged
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
28 changes: 18 additions & 10 deletions src/interfaces/manager/rebalancer/IRetargetter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ interface IRetargetter {
/// @notice Emitted when the owner force-repays the operation's Request.
/// @param request The operation's Request
/// @param amount The amount transferred to the Request
/// @param minBalance The owner-chosen lower balance bound passed to setRepaid
/// @param maxBalance The owner-chosen upper balance bound passed to setRepaid
/// @param minBalance The owner-chosen lower bound the Request's balance had to meet
/// @param maxBalance The owner-chosen upper bound the Request's balance had to meet
event RequestForceRepaid(address indexed request, uint256 amount, uint256 minBalance, uint256 maxBalance);

/// @notice Emitted when an asynchronous operation settles and its state is cleared.
Expand Down Expand Up @@ -272,11 +272,11 @@ interface IRetargetter {
/// one balance snapshot taken before any leg executes and do not compose: every leg
/// resolves against the same pre-call balances, never the state left by earlier legs,
/// so REPAY sentinels across several modules can together commit more than the shared
/// balance and revert. While the operation's Request is unrepaid, the position's net
/// value (quoted collateral minus debt over every module, underwater ones included)
/// must not grow across the call, for every caller including the owner: Request
/// capital may enter the position only against equivalent value flowing back out in
/// the same call.
/// balance and revert. Until the operation's Request has been marked repaid through
/// repay or forceRepay, the position's net value (quoted collateral minus debt over
/// every module, underwater ones included) must not grow across the call, for every
/// caller including the owner: Request capital may enter the position only against
/// equivalent value flowing back out in the same call.
/// @param data The rebalancing data forwarded to the position manager
function rebalance(RebalancingData calldata data) external;

Expand All @@ -287,10 +287,13 @@ interface IRetargetter {
/// @return owedAmount The amount owed and settled
function repay() external returns (uint256 owedAmount);

/// @notice Owner override to settle the Request outside the trustless formula.
/// @notice Owner override to settle the Request outside the trustless formula, including
/// late deliveries to an expired Request.
/// @param amount The amount to transfer to the Request before marking it repaid
/// @param minBalance The lower balance bound passed to setRepaid
/// @param maxBalance The upper balance bound passed to setRepaid
/// @param minBalance The lower bound the Request's balance must meet (enforced by
/// setRepaid, or locally once the Request no longer accepts it)
/// @param maxBalance The upper bound the Request's balance must meet (enforced by
/// setRepaid, or locally once the Request no longer accepts it)
function forceRepay(uint256 amount, uint256 minBalance, uint256 maxBalance) external;

/// @notice Settles the asynchronous operation once the Request is repaid, no order is
Expand Down Expand Up @@ -410,6 +413,11 @@ interface IRetargetter {
/// flash-loan window for the duration of its transaction).
function isActive() external view returns (bool);

/// @notice Returns whether the operation's bridge is outstanding: its Request exists and
/// has not been marked repaid through repay or forceRepay (deadline auto-expiry
/// never counts), which is what arms the rebalance value-conservation gate.
function bridgeOutstanding() external view returns (bool);

/// @notice Computes the current principal cap for the bound position manager.
/// @dev Quoter formula on live state with the owner estimates, auto-detected direction,
/// times one plus the principal buffer. Below target the cap is further bounded by
Expand Down
18 changes: 11 additions & 7 deletions src/libs/manager/rebalancer/LibRetargetterConstants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ uint256 constant MAX_AUTHORIZED_ACCOUNTS = 16;
/// @dev Repayment deadline offset applied to every deployed Request (the maximum the
/// Request accepts). The deadline anchors at operation start while the loan clock
/// starts at the first capital commitment; MIN_DEADLINE_BUFFER floors what must remain
/// of this offset when the clock starts. Acknowledged limitation: past the deadline the
/// Request auto-expires and bypasses the local repayment checks, so the guaranteed
/// buffer is sized as effectively infinite for every supported settlement flow. If an
/// operation ever runs into it regardless, remediation is arranged offchain and
/// delivered only through beacon upgrades governed by an extensive multisig behind its
/// own timelock; if the durations become too short for some assets, the Request and
/// the Retargetter get upgraded with longer ones.
/// of this offset when the clock starts, and the buffer is sized as effectively
/// infinite for every supported settlement flow. Past the deadline the Request
/// auto-expires: the trustless repay closes and holders redeem what sits in the
/// Request, but the rebalance value-conservation gate stays armed (expiry never
/// disarms it; see Retargetter._bridgeOutstanding), so pulled principal cannot be
/// folded into the position and the owner delivers it late through forceRepay instead.
/// Acknowledged limitation of that late delivery: Request redemptions price on the
/// live balance, so holders who burn their PT/YT before the delivery lands crystallize
/// their shortfall and are not made whole by it; holders expecting a delivery should
/// not redeem until it arrives. If the durations become too short for some assets, the
/// Request and the Retargetter get upgraded with longer ones.
/// @custom:value 7,776,000 (90 days)
uint256 constant REPAYMENT_DEADLINE_OFFSET = 90 days;

Expand Down
5 changes: 5 additions & 0 deletions src/libs/manager/rebalancer/LibStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ struct RetargetterWhitelists {
/// @param horizon The repayment yield annualization basis, snapshotted from the config at
/// start so a later setConfig cannot reprice a funded operation
/// @param request The Request deployed for the operation
/// @param requestRepaid Whether this instance marked the operation's Request repaid through
/// repay or forceRepay; deadline auto-expiry never sets it, so the rebalance
/// value-conservation gate stays armed on a defaulted bridge until the owner settles
/// @param repaymentDeadline The Request's repayment deadline, mirrored at start because the
/// Request does not expose it; the loan clock cannot start once less than
/// MIN_DEADLINE_BUFFER remains before it (zero inside a SYNC window)
Expand All @@ -81,6 +84,7 @@ struct RetargetterOperation {
bool consumptionClosed;
uint32 horizon;
address request;
bool requestRepaid;
uint40 repaymentDeadline;
uint24 tickDuration;
uint24 tickThreshold;
Expand Down Expand Up @@ -312,6 +316,7 @@ library LibStorage {
self.consumptionClosed = false;
self.horizon = 0;
self.request = address(0);
self.requestRepaid = false;
self.repaymentDeadline = 0;
self.tickDuration = 0;
self.tickThreshold = 0;
Expand Down
72 changes: 47 additions & 25 deletions src/manager/rebalancer/Retargetter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {IBorrowPosition} from "../../interfaces/borrow/IBorrowPosition.sol";
import {IFund} from "../../interfaces/funds/IFund.sol";
import {Order, Mode, State} from "../../libs/funds/Order.sol";
import {LibRetargetterErrors} from "../../libs/manager/rebalancer/LibRetargetterErrors.sol";
import {LibRequestErrors} from "../../libs/request/LibRequestErrors.sol";
import {LibStorage, RetargetterAssets, RetargetterOperation} from "../../libs/manager/rebalancer/LibStorage.sol";
import {
REBALANCER_ROLE,
Expand Down Expand Up @@ -188,7 +189,7 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali

/// @inheritdoc IRetargetter
/// @dev The deployed Request gets the maximum 90-day repayment deadline (treated as
/// effectively infinite; see REPAYMENT_DEADLINE_OFFSET for the acknowledgment) and a
/// effectively infinite; see REPAYMENT_DEADLINE_OFFSET for the expiry posture) and a
/// zero mint-to-repaid delay. That delay exists to keep a Request consumer from minting
/// disproportionate yield tokens right before repayment; here the minting paths are
/// boxed in instead: the yield gates cap yield proportional to principal, the principal
Expand Down Expand Up @@ -350,12 +351,12 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali
/// funds cannot overpay YT holders. The owed yield floors at one full tick, so consumed
/// principal left sitting in the Request (never pulled) still owes that tick out of
/// position-derived funds. The open upper bound at setRepaid keeps third-party
/// donations to the Request from blocking repayment. Once the Request passes its
/// 90-day deadline it auto-expires and this function reverts AlreadyRepaid; proceeds
/// settling after that point cannot be delivered to lenders locally. The expiry
/// bypassing the local repayment flow is an acknowledged limitation and, like the
/// expiry itself, delivery of late proceeds runs through the governed upgrade path;
/// see REPAYMENT_DEADLINE_OFFSET for the remediation posture.
/// donations to the Request from blocking repayment. Marks the operation repaid
/// locally, which is what disarms the rebalance value-conservation gate; see
/// {_bridgeOutstanding}. Once the Request passes its 90-day deadline it auto-expires
/// and this function reverts AlreadyRepaid (the owed formula keeps accruing after
/// the deadline, so a late trustless repay would overpay lenders from
/// position-derived funds); late proceeds are delivered through {forceRepay} instead.
function repay() external onlyOwnerOrRebalancer nonReentrant returns (uint256 owedAmount) {
RetargetterOperation storage operation_ = LibStorage.operationStorage();
address request = operation_.checkRequest();
Expand All @@ -369,16 +370,32 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali
debtAsset.safeApprove(request, 0);
}
IRequest(request).setRepaid(owedAmount, type(uint256).max);
operation_.requestRepaid = true;
emit RequestRepaid(request, owedAmount, shortfall);
}

/// @inheritdoc IRetargetter
/// @dev Owner path for defaults and disputes. For a true default nothing needs calling:
/// the Request auto-expires at its deadline and holders redeem what sits there.
/// @dev Owner path for defaults, disputes and late deliveries. An expired (or already
/// repaid) Request rejects setRepaid, so on that branch the balance bounds are
/// enforced locally instead while the transfer still lands for the remaining holders
/// to redeem; deliver before holders exit, because redemptions price on the live
/// balance and an early exit's shortfall is not made whole by a later top-up. Both
/// branches mark the operation repaid locally: the governed write-off that disarms
/// the value-conservation gate on a defaulted bridge and unlocks resolve; see
/// {_bridgeOutstanding}.
function forceRepay(uint256 amount, uint256 minBalance, uint256 maxBalance) external onlyOwner nonReentrant {
address request = LibStorage.operationStorage().checkRequest();
if (amount > 0) LibStorage.assetsStorage().debtAsset.safeTransfer(request, amount);
IRequest(request).setRepaid(minBalance, maxBalance);
RetargetterOperation storage operation_ = LibStorage.operationStorage();
address request = operation_.checkRequest();
address debtAsset = LibStorage.assetsStorage().debtAsset;
if (amount > 0) debtAsset.safeTransfer(request, amount);
if (IRequest(request).syncRepaidStatus()) {
uint256 balance = debtAsset.balanceOf(request);
if (balance < minBalance) revert LibRequestErrors.InsufficientBalance(balance, minBalance);
if (balance > maxBalance) revert LibRequestErrors.ExcessiveBalance(balance, maxBalance);
} else {
IRequest(request).setRepaid(minBalance, maxBalance);
}
operation_.requestRepaid = true;
emit RequestForceRepaid(request, amount, minBalance, maxBalance);
}

Expand Down Expand Up @@ -488,9 +505,10 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali
/// one-atom improvement flooring flattens to an equal value, passes), and no module
/// may end with debt against zero collateral. LTV convention throughout: zero when
/// debt is zero (idle modules and an emptied position pass), the max sentinel for bad
/// debt. While the operation's Request is unrepaid, the position's net value must not
/// grow across the call, for every caller including the owner; see
/// {_bridgeOutstanding} and {_checkValueConservation}. The position manager's own
/// debt. Until the operation's Request has been marked repaid through {repay} or
/// {forceRepay}, the position's net value must not grow across the call, for every
/// caller including the owner; see {_bridgeOutstanding} and
/// {_checkValueConservation}. The position manager's own
/// loss, cooldown and safe-LTV checks apply underneath. A sentinel resolving to zero (an
/// empty balance, or a REPAY leg on a debt-free module) produces a zero-amount leg,
/// which the borrow modules reject; the whole call reverts atomically.
Expand Down Expand Up @@ -791,6 +809,11 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali
return LibStorage.operationStorage().fund != address(0);
}

/// @inheritdoc IRetargetter
function bridgeOutstanding() external view returns (bool) {
return _bridgeOutstanding();
}

/// @inheritdoc IRetargetter
/// @dev Sized on the config ceiling maxYieldBps, the largest yield cap any operation may
/// carry, so this view is a safe floor for every permissible operation; the operation
Expand Down Expand Up @@ -1025,16 +1048,15 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali
}

/// @dev Whether the bridge is still outstanding, which is what arms the rebalance
/// value-conservation gate. Once the Request is repaid, or auto-expired past its
/// deadline with holders redeeming its remaining balance, folding residual value back
/// into the position must reopen, which is why this keys on the effective repaid
/// state rather than the operation being active. Read through the state-mutating sync
/// (the same one resolve gates on) so a past-deadline Request reads repaid here
/// instead of wedging the gate on a stale flag; that makes this the only non-view
/// step of the rebalance guardrails.
function _bridgeOutstanding() internal returns (bool) {
address request = LibStorage.operationStorage().request;
return request != address(0) && !IRequest(request).syncRepaidStatus();
/// value-conservation gate. Keys on the operation's local repaid flag, set only by
/// {repay} and {forceRepay} (the only paths that can settle the Request, since this
/// contract is its owner); once it is set, folding residual value back into the
/// position reopens. Deadline auto-expiry never sets it, so a defaulted bridge stays
/// gated: pulled principal can only leave through {forceRepay} delivering it to the
/// Request, never by being folded into the position ahead of the lenders.
function _bridgeOutstanding() internal view returns (bool) {
RetargetterOperation storage operation_ = LibStorage.operationStorage();
return operation_.request != address(0) && !operation_.requestRepaid;
}

/// @dev Value-conservation gate, applied to every caller (unlike the direction checks,
Expand Down
5 changes: 3 additions & 2 deletions test/manager/rebalancer/Retargetter.invariant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ contract RetargetterInvariantTest is RetargetterBaseTest {
}

/// @notice RT-11: Bridge value conservation: no successful rebalance grew the position
/// manager's totalAssets while the operation's Request was unrepaid and short of
/// its deadline (recorded by the handler around every act_rebalance).
/// manager's totalAssets while the operation's Request was unsettled (no repay or
/// forceRepay yet; deadline expiry does not count as settlement; recorded by the
/// handler around every act_rebalance).
function invariant_bridgeValueConservation() public view {
assertFalse(
handler.valueConservationViolated(), "RT-11: rebalance grew totalAssets while the bridge was outstanding"
Expand Down
21 changes: 11 additions & 10 deletions test/manager/rebalancer/Retargetter.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1072,19 +1072,20 @@ contract RetargetterTest is RetargetterBaseTest {
assertEq(uint32(slotA >> 64), DEFAULT_HORIZON, "horizon bits");
assertEq(address(uint160(slotA >> 96)), request, "request bits");

// Slot B: repaymentDeadline (bits 0..39) | tickDuration (40..63) | tickThreshold (64..87)
// | fund (88..247) | orderMode (248..255): full
// Slot B: requestRepaid (bits 0..7) | repaymentDeadline (8..47) | tickDuration (48..71)
// | tickThreshold (72..95) | fund (96..255): full
uint256 slotB = uint256(vm.load(address(retargetter), bytes32(uint256(OPERATION_STORAGE_SLOT) + 1)));
assertEq(uint40(slotB), uint40(startTime + 90 days), "repayment deadline bits");
assertEq(uint24(slotB >> 40), DEFAULT_TICK_DURATION, "tick duration bits");
assertEq(uint24(slotB >> 64), DEFAULT_TICK_THRESHOLD, "tick threshold bits");
assertEq(address(uint160(slotB >> 88)), address(fund), "fund bits");
assertEq(uint8(slotB >> 248), uint8(Mode.REDEEM), "order mode bits");
assertEq(uint8(slotB), 0, "request repaid bit clear");
assertEq(uint40(slotB >> 8), uint40(startTime + 90 days), "repayment deadline bits");
assertEq(uint24(slotB >> 48), DEFAULT_TICK_DURATION, "tick duration bits");
assertEq(uint24(slotB >> 72), DEFAULT_TICK_THRESHOLD, "tick threshold bits");
assertEq(address(uint160(slotB >> 96)), address(fund), "fund bits");

// Slot C: orderLive (bits 0..7)
// Slot C: orderMode (bits 0..7) | orderLive (8..15)
uint256 slotC = uint256(vm.load(address(retargetter), bytes32(uint256(OPERATION_STORAGE_SLOT) + 2)));
assertEq(uint8(slotC), 1, "order live bits");
assertEq(slotC >> 8, 0, "slot C upper bits clean");
assertEq(uint8(slotC), uint8(Mode.REDEEM), "order mode bits");
assertEq(uint8(slotC >> 8), 1, "order live bits");
assertEq(slotC >> 16, 0, "slot C upper bits clean");

// Slots D to F: the stored order's input, output and salt
assertEq(
Expand Down
Loading