From d2fccf952475ee7e2d10fdc0d824cd1ca71620e7 Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Mon, 10 Aug 2026 13:25:22 +0200 Subject: [PATCH 1/7] fix: Retargetter operation snapshot, cap re-check, PM whitelist and clock rewind Cantina audit fixes across the Retargetter operation lifecycle, the Request cap gates, and the fund-adapter assumptions. Code fixes: - 3F-502: snapshot horizon/tickDuration/tickThreshold into the operation at start so setConfig cannot reprice a funded operation (zero extra storage slots; _owed reads the snapshot). - 3F-506: cancelOrder requires IFund.cancel to return State.EMPTY before clearing the local order. - 3F-483: bind operations only to an owner-whitelisted PositionManager (setPositionManager / isPositionManager, pair checked at whitelist time). - 3F-477: move the principal-cap gate in consume to after the Request call so a maker callback cannot invalidate it. - 3F-478: bound maxPrincipal by the quoter's one-trip repayment bound (new ltvUpOneTripPrincipal) so a cap-sized operation keeps settlement headroom. - 3F-485: rewind the loan clock to unstarted when a revocation leaves the operation with no PT, no YT and no pending authorization. Documented in NatSpec (no behaviour change): 3F-494 minimum-tick payout, 3F-496 per-call rebalance cooldown, 3F-499 per-instance operation guard, 3F-503 CONSUMER_ROLE self-authorization, 3F-472 firm-commitment mint policy, 3F-487 exact-transfer fund-asset assumption. Fixes 3F-521 Fixes 3F-515 Fixes 3F-514 --- src/interfaces/funds/IFund.sol | 6 + .../manager/rebalancer/IRetargetter.sol | 73 +++- .../manager/rebalancer/IRetargetterQuoter.sol | 27 ++ .../rebalancer/LibRetargetterErrors.sol | 3 + src/libs/manager/rebalancer/LibStorage.sol | 62 +++- src/manager/rebalancer/Retargetter.sol | 242 ++++++++++---- src/manager/rebalancer/RetargetterFactory.sol | 7 +- src/manager/rebalancer/RetargetterQuoter.sol | 31 ++ .../rebalancer/Retargetter.invariant.t.sol | 18 +- test/manager/rebalancer/Retargetter.t.sol | 311 ++++++++++++++---- .../manager/rebalancer/RetargetterAsync.t.sol | 274 +++++++++++++-- test/manager/rebalancer/RetargetterBase.t.sol | 14 + .../rebalancer/RetargetterQuoter.t.sol | 52 +++ .../rebalancer/RetargetterRebalance.t.sol | 15 +- .../manager/rebalancer/RetargetterSmoke.t.sol | 3 +- test/manager/rebalancer/RetargetterSync.t.sol | 39 ++- .../rebalancer/MockRetargetterFund.sol | 13 + .../manager/rebalancer/RetargetterHandler.sol | 107 ++++-- 18 files changed, 1086 insertions(+), 211 deletions(-) diff --git a/src/interfaces/funds/IFund.sol b/src/interfaces/funds/IFund.sol index e270178a..64467b87 100644 --- a/src/interfaces/funds/IFund.sol +++ b/src/interfaces/funds/IFund.sol @@ -45,6 +45,12 @@ import {Order, State} from "../../libs/funds/Order.sol"; /// - create() and commit() NEVER reach ENDED - they advance the order forward (or revert) /// - Only unlock() and recover() finalize orders by reaching ENDED /// - Callers can batch state transitions when possible (e.g., create + commit if ACCEPTED) +/// +/// Supported assets (binding on every adapter, current and future): asset and share +/// tokens must deliver exactly the requested amount on transfer. Adapters pull +/// `order.input` and then approve and forward that same amount to the venue, so any +/// shortfall breaks the venue request; fee-on-transfer, rebasing and callback tokens +/// are out of scope and must not be admitted. interface IFund { /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* OPERATIONS */ diff --git a/src/interfaces/manager/rebalancer/IRetargetter.sol b/src/interfaces/manager/rebalancer/IRetargetter.sol index 62a57433..bfdc1d39 100644 --- a/src/interfaces/manager/rebalancer/IRetargetter.sol +++ b/src/interfaces/manager/rebalancer/IRetargetter.sol @@ -151,6 +151,11 @@ interface IRetargetter { /// @param whitelisted Whether the module is now whitelisted event FlashLoanModuleSet(address indexed module, bool whitelisted); + /// @notice Emitted when a position manager's whitelist entry is set. + /// @param positionManager The position manager + /// @param whitelisted Whether the position manager is now whitelisted + event PositionManagerSet(address indexed positionManager, bool whitelisted); + /// @notice Emitted when the yield estimates are updated. /// @param estimates The new estimates event EstimatesSet(YieldEstimates estimates); @@ -164,7 +169,10 @@ interface IRetargetter { /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ /// @notice Starts an asynchronous retargetting operation by deploying a fresh Request. - /// @param positionManager The position manager to retarget (must match the bound pair) + /// @dev The repayment terms (horizon, tick duration, tick threshold) and the effective + /// yield cap are snapshotted into the operation here; a later setConfig does not + /// reprice a started operation. + /// @param positionManager The owner-whitelisted position manager to retarget /// @param principal The intended principal, fail-fast checked against the cap /// @param maxYieldBps_ The caller's yield cap; the effective cap is min(config, caller) /// @param fund The whitelisted fund to bind to the operation @@ -189,7 +197,9 @@ interface IRetargetter { /// including the owner: /// repayment prices every yield token from the origin, so capital arriving later would /// be overpaid for time it never covered and, on a default, would dilute the earlier - /// lenders' recovery. + /// lenders' recovery. The principal cap is checked after the Request call, once the + /// minted amount sits in the PT supply, so a maker callback moving the cap mid-call + /// cannot leave the operation committed above it. /// @param offer The signed offer /// @param signature The maker's EIP-712 signature /// @param ptAmount The principal amount to consume @@ -207,7 +217,11 @@ interface IRetargetter { /// directly, so the operator revokes leftovers once the funding round is over. A mint /// front-running that revocation or the settlement itself is bounded by the yield gate /// and the prorated accrual, and requires the consumer to have authorized the account - /// in the first place. + /// in the first place. A stored authorization is a firm commitment: the Request honors + /// it against the amounts fixed here even if the principal cap has fallen since, and + /// the caller (a CONSUMER_ROLE holder) picks the recipient freely, itself included. A + /// revocation that leaves the operation with no PT, no YT and no pending authorization + /// rewinds the loan clock to unstarted, reopening the consumption window. /// @param to The account allowed to call the Request's mint /// @param ptAmount The principal tokens to authorize; the account transfers this much asset /// when it mints @@ -217,9 +231,13 @@ interface IRetargetter { /// @notice Pulls consumed funds from the operation's Request to the Retargetter. /// @dev Pulling ends the funding round: every pending mint authorization is revoked and /// capital entry (consume or new authorizations) stays closed for the rest of the - /// operation, so funds being deployed can no longer be diluted by late lenders. + /// operation, so funds being deployed can no longer be diluted by late lenders. A + /// zero amount is the named close transition: it moves no capital, still ends the + /// funding round the same way, and leaves the Request's balance pullable by later + /// calls. /// @param amount The amount to pull; the full-balance sentinel `type(uint256).max` resolves - /// to the Request's whole balance + /// to the Request's whole balance, and zero closes the funding round without moving + /// capital function pullRequestFunds(uint256 amount) external; /// @notice Creates a fund order owned by and payable to the Retargetter. @@ -234,6 +252,9 @@ interface IRetargetter { function unlock() external returns (uint256 amountOut); /// @notice Cancels the stored fund order and clears it. + /// @dev The fund must report the canceled order EMPTY; any other returned state keeps the + /// local order stored and reverts, so the Retargetter never forgets an order the + /// fund still processes. function cancelOrder() external; /// @notice Recovers the stored fund order's input; clears the order once ENDED. @@ -260,6 +281,9 @@ interface IRetargetter { function rebalance(RebalancingData calldata data) external; /// @notice Trustlessly repays the operation's Request with the formula-computed amount. + /// @dev Transfers only the shortfall between the owed amount and the Request's balance, and + /// the owed yield floors at one full tick, so an operation whose consumed principal + /// was never pulled still tops up that tick from position-derived funds. /// @return owedAmount The amount owed and settled function repay() external returns (uint256 owedAmount); @@ -283,8 +307,11 @@ interface IRetargetter { /// callback. The callback is bound to this exact payload (digest match) and requires /// the loan delivered in full before any step runs. At window close no order may be /// stored and the Retargetter's balances of both bound assets must be within the - /// configured residual tolerance. - /// @param positionManager The position manager to retarget (must match the bound pair) + /// configured residual tolerance. The position manager's rebalance cooldown is + /// enforced per rebalance call, so with a nonzero cooldown compatible legs must be + /// packed into a single rebalance step, and a route needing an intervening fund action + /// between two rebalances runs as separate transactions. + /// @param positionManager The owner-whitelisted position manager to retarget /// @param flashLoanModule The whitelisted flash-loan module to borrow through /// @param flashLoanAmount The flash-loan size, checked against the principal cap /// @param fund The whitelisted fund the payload may create orders against @@ -302,6 +329,9 @@ interface IRetargetter { /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ /// @notice Sets the full configuration (scalars and estimates). + /// @dev Changes take effect immediately, except for an in-flight operation's repayment + /// terms: the horizon, tick duration and tick threshold, like the yield cap, are + /// snapshotted at operation start and a running operation settles on those. /// @param config_ The new configuration, validated against the documented bounds function setConfig(RetargetterConfig calldata config_) external; @@ -324,6 +354,16 @@ interface IRetargetter { /// @param whitelisted Whether the module should be whitelisted function setFlashLoanModule(address module, bool whitelisted) external; + /// @notice Sets a position manager's whitelist entry. + /// @dev Operations only bind to whitelisted position managers: every start-gate and + /// principal-cap read trusts what the bound address reports about itself, so the + /// binding is owner-curated like funds and flash-loan modules. Whitelisting checks + /// the position manager is a contract and its assets match the bound pair; removal + /// skips those checks but reverts while it is bound to the active operation. + /// @param positionManager The position manager + /// @param whitelisted Whether the position manager should be whitelisted + function setPositionManager(address positionManager, bool whitelisted) external; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* VIEWS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -345,6 +385,10 @@ interface IRetargetter { /// (zero inside a SYNC window); the loan clock can only start while at least the /// minimum deadline buffer remains before it /// @return operationMaxYieldBps The effective per-operation yield cap + /// @return horizon The operation's snapshotted yield annualization basis + /// @return tickDuration The operation's snapshotted repayment granularity + /// @return tickThreshold The operation's snapshotted tick grace and consumption window + /// length /// @return order The stored fund order rebuilt in memory /// @return orderLive Whether a fund order is stored function operation() @@ -357,6 +401,9 @@ interface IRetargetter { uint40 startedAt, uint40 repaymentDeadline, uint16 operationMaxYieldBps, + uint32 horizon, + uint24 tickDuration, + uint24 tickThreshold, Order memory order, bool orderLive ); @@ -367,7 +414,13 @@ interface IRetargetter { /// @notice Computes the current principal cap for a position manager. /// @dev Quoter formula on live state with the owner estimates, auto-detected direction, - /// times one plus the principal buffer. + /// times one plus the principal buffer. Below target the cap is further bounded by + /// the one-trip repayment bound, so a cap-sized, fully deployed operation can always + /// borrow its worst permitted repayment back at target in a single settlement trip. + /// This view sizes that bound on the config yield-cap ceiling, a safe floor for + /// every permissible operation; the operation gates size it on the effective + /// per-operation cap (zero for a SYNC window, whose flash repayment carries no + /// yield) and can admit up to that looser bound. /// @param positionManager The position manager to size against /// @return The principal cap in debt-asset units function maxPrincipal(address positionManager) external view returns (uint256); @@ -394,6 +447,10 @@ interface IRetargetter { /// @param module The module to check function isFlashLoanModule(address module) external view returns (bool); + /// @notice Returns whether a position manager is whitelisted. + /// @param positionManager The position manager to check + function isPositionManager(address positionManager) external view returns (bool); + /// @notice Returns the quoter holding the sizing math. function quoter() external view returns (address); diff --git a/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol b/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol index 080f5073..93e20bf4 100644 --- a/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol +++ b/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol @@ -90,6 +90,33 @@ interface IRetargetterQuoter { uint256 duration ) external pure returns (uint256 principal, uint256 collateralToFreeQuoted); + /// @notice Computes the one-trip repayment bound of an LTV-up operation. + /// @dev The largest principal whose worst permitted repayment (the principal plus the full + /// flat yield cap) can still be borrowed at target once the subscription has landed as + /// collateral: `P * (1 + yieldCap) <= targetLtv * (K * (1 + Yc) + P) - D * (1 + Rb)`. + /// Same accrual conventions as `ltvUpPrincipal`; the bridge yield estimate is replaced + /// by the flat cap because repayment prices actual YT supply, not the estimate. + /// Returns 0 when the position is at or above target once the accruals are applied. + /// Reverts with `InvalidParameters` only at a full target LTV combined with a zero + /// yield cap. + /// @param collateralQuoted The aggregate collateral value in debt-asset units + /// @param debt The aggregate debt in debt-asset units + /// @param targetLtv The target loan-to-value ratio (WAD) + /// @param maxYieldBps The flat yield cap bounding the operation's YT supply (basis points) + /// @param borrowRate The venue borrow rate on existing debt (WAD per 365 days) + /// @param collateralYieldRate The collateral yield rate (WAD per 365 days) + /// @param duration The expected subscription settlement duration in seconds + /// @return principal The one-trip repayment bound in debt-asset units (rounded down) + function ltvUpOneTripPrincipal( + uint256 collateralQuoted, + uint256 debt, + uint256 targetLtv, + uint256 maxYieldBps, + uint256 borrowRate, + uint256 collateralYieldRate, + uint256 duration + ) external pure returns (uint256 principal); + /// @notice Quantizes an elapsed loan duration to whole repayment ticks. /// @dev From the first second the borrower owes one full tick; the count promotes to the /// next tick each time the elapsed duration passes a whole number of ticks plus the diff --git a/src/libs/manager/rebalancer/LibRetargetterErrors.sol b/src/libs/manager/rebalancer/LibRetargetterErrors.sol index b309e276..2933a572 100644 --- a/src/libs/manager/rebalancer/LibRetargetterErrors.sol +++ b/src/libs/manager/rebalancer/LibRetargetterErrors.sol @@ -36,6 +36,9 @@ library LibRetargetterErrors { /// @notice Thrown when binding a fund that is not owner-whitelisted. error FundNotWhitelisted(); + /// @notice Thrown when binding a position manager that is not owner-whitelisted. + error PositionManagerNotWhitelisted(); + /// @notice Thrown when using a flash-loan module that is not owner-whitelisted. error ModuleNotWhitelisted(); diff --git a/src/libs/manager/rebalancer/LibStorage.sol b/src/libs/manager/rebalancer/LibStorage.sol index 71ad052b..e8a4ac5a 100644 --- a/src/libs/manager/rebalancer/LibStorage.sol +++ b/src/libs/manager/rebalancer/LibStorage.sol @@ -30,9 +30,11 @@ struct RetargetterAssets { /// @dev Plain mappings without enumeration; indexers use the add/remove events. /// @param funds The owner-approved venues /// @param flashLoanModules The owner-approved flash-loan adapters +/// @param positionManagers The owner-approved position managers an operation may bind to struct RetargetterWhitelists { mapping(address fund => bool) funds; mapping(address module => bool) flashLoanModules; + mapping(address positionManager => bool) positionManagers; } /// @notice The one in-flight operation (namespace "retargetter.operation"). @@ -44,14 +46,20 @@ struct RetargetterWhitelists { /// at create) so {LibStorage.order} rebuilds the full Order in memory. /// @param positionManager The operation's position manager /// @param startedAt The loan clock origin, set at the first consume or nonzero mint -/// authorization (0 until then) +/// authorization (0 until then); rewound to 0 by a revocation that leaves the +/// operation with no PT, no YT and no pending authorization /// @param operationMaxYieldBps The effective yield cap, fixed at start /// @param consumptionClosed Whether capital entry is shut for good; set by the first pull of /// funds, which also revokes every pending mint authorization +/// @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 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) +/// @param tickDuration The repayment granularity, snapshotted from the config at start +/// @param tickThreshold The tick promotion grace and consumption window length, snapshotted +/// from the config at start /// @param fund The operation's venue, owner-whitelisted at start /// @param orderMode The stored order's mode /// @param orderLive Whether an order is stored @@ -67,8 +75,11 @@ struct RetargetterOperation { uint40 startedAt; uint16 operationMaxYieldBps; bool consumptionClosed; + uint32 horizon; address request; uint40 repaymentDeadline; + uint24 tickDuration; + uint24 tickThreshold; address fund; Mode orderMode; bool orderLive; @@ -148,11 +159,11 @@ library LibStorage { clearOrder(self); } - /// @dev Capital gates shared by consume and authorizeMinting. Reverts YieldTooHigh when the - /// offered yield-to-principal ratio exceeds the operation's cap (division-free, so - /// partial fills keep the offer's ratio) and PrincipalCapExceeded when the committed - /// principal (the PT supply, the outstanding authorizations after pruning, and the new - /// amount) exceeds the live cap, then runs {checkConsumptionWindow}. + /// @dev Capital gates of authorizeMinting: the yield-ratio gate, the principal-cap gate on + /// the newly authorized amount, then {checkConsumptionWindow}. Consume runs the same + /// three gates but re-derives the principal cap after the Request call instead (a + /// maker callback inside Request.consume can move the cap), so it composes the pieces + /// directly. /// @param self The storage pointer to the operation /// @param request The operation's Request /// @param amount The offered principal the yield ratio is quoted on @@ -167,18 +178,42 @@ library LibStorage { uint256 ptAmount, uint256 principalCap ) internal { + checkYield(self, amount, expectedReturn); + checkPrincipalCap(self, request, ptAmount, principalCap); + checkConsumptionWindow(self); + } + + /// @dev Reverts YieldTooHigh when the offered yield-to-principal ratio exceeds the + /// operation's cap (division-free, so partial fills keep the offer's ratio). + /// @param self The storage pointer to the operation + /// @param amount The offered principal the yield ratio is quoted on + /// @param expectedReturn The offered yield on that principal + function checkYield(RetargetterOperation storage self, uint256 amount, uint256 expectedReturn) internal view { if (expectedReturn * BPS > amount * self.operationMaxYieldBps) { revert LibRetargetterErrors.YieldTooHigh(); } + } + + /// @dev Reverts PrincipalCapExceeded when the committed principal (the PT supply, the + /// outstanding authorizations after pruning, and the new amount) exceeds the live cap. + /// Consume passes a zero ptAmount after the Request call, when the minted amount + /// already sits in the PT supply. + /// @param self The storage pointer to the operation + /// @param request The operation's Request + /// @param ptAmount The principal being committed on top of the supply (zero post-mint) + /// @param principalCap The live principal cap + function checkPrincipalCap(RetargetterOperation storage self, address request, uint256 ptAmount, uint256 principalCap) + internal + { (uint128 ptSupply,) = ITokenController(request).totalSupplies(); if (ptSupply + pruneNullAuthorizations(self, request) + ptAmount > principalCap) { revert LibRetargetterErrors.PrincipalCapExceeded(); } - checkConsumptionWindow(self); } /// @dev Consumption-window gate and loan clock: reverts once the window has closed (funds - /// were pulled, or the tick threshold elapsed since the origin); otherwise the first + /// were pulled, or the operation's snapshotted tick threshold elapsed since the + /// origin); otherwise the first /// capital commitment (consume or nonzero mint authorization) starts the clock, /// provided at least MIN_DEADLINE_BUFFER remains before the Request's repayment /// deadline (a later start would erode the settlement buffer the deadline is sized @@ -197,7 +232,7 @@ library LibStorage { // Safe: block.timestamp fits in uint40 for ~35,000 years // forge-lint: disable-next-line(unsafe-typecast) self.startedAt = uint40(block.timestamp); - } else if (block.timestamp > startedAt + configStorage().tickThreshold) { + } else if (block.timestamp > startedAt + self.tickThreshold) { revert LibRetargetterErrors.ConsumptionWindowClosed(); } } @@ -262,17 +297,20 @@ library LibStorage { self.orderSalt = bytes32(0); } - /// @dev Clears the operation addresses, the loan clock, the yield cap and the - /// mint-authorization account set (emptied back to front, each removal a plain pop); - /// the order fields are cleared separately as orders end. + /// @dev Clears the operation addresses, the loan clock, the yield cap, the snapshotted + /// repayment terms and the mint-authorization account set (emptied back to front, + /// each removal a plain pop); the order fields are cleared separately as orders end. /// @param self The storage pointer to the operation function clearOperation(RetargetterOperation storage self) internal { self.positionManager = address(0); self.startedAt = 0; self.operationMaxYieldBps = 0; self.consumptionClosed = false; + self.horizon = 0; self.request = address(0); self.repaymentDeadline = 0; + self.tickDuration = 0; + self.tickThreshold = 0; self.fund = address(0); EnumerableSetLib.AddressSet storage accounts = self.authorizedAccounts; for (uint256 remaining = accounts.length(); remaining > 0; --remaining) { diff --git a/src/manager/rebalancer/Retargetter.sol b/src/manager/rebalancer/Retargetter.sol index 684e9faf..76d86b4a 100644 --- a/src/manager/rebalancer/Retargetter.sol +++ b/src/manager/rebalancer/Retargetter.sol @@ -16,7 +16,12 @@ 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 {LibStorage, RetargetterAssets, RetargetterOperation} from "../../libs/manager/rebalancer/LibStorage.sol"; +import { + LibStorage, + RetargetterAssets, + RetargetterOperation, + RetargetterWhitelists +} from "../../libs/manager/rebalancer/LibStorage.sol"; import { REBALANCER_ROLE, CONSUMER_ROLE, @@ -50,7 +55,8 @@ import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// @notice Orchestrator that brings PositionManagers back to their target LTV by composing /// Requests (bridge loans), funds (subscription/redemption) and PositionManager /// rebalancing. Bound to one (collateralAsset, debtAsset) pair at initialization and -/// runs one operation at a time against any PositionManager on that pair. +/// runs one operation at a time against an owner-whitelisted PositionManager on that +/// pair. /// @dev Two entry points: /// - ASYNC (`startRetargetting`): deploys a fresh Request through the audited /// RequestFactory; the operation spans the fund's settlement window and ends with a @@ -62,7 +68,10 @@ import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// /// Trust model: fully trusted owner, semi-trusted rebalancer boxed in by the guardrails /// (direction checks, the bridge value-conservation gate, principal and yield caps, -/// whitelists, residual settlement gates), untrusted everyone else. The Retargetter +/// whitelists, residual settlement gates), untrusted everyone else. The +/// one-operation-at-a-time guarantee is scoped to this instance: grant a +/// PositionManager's rebalancer role to at most one Retargetter, or each holder can +/// admit the full principal cap independently. The Retargetter /// holds no value at rest: `resolve` and the end of the flash-loan window both require /// its balances of the two bound assets to stay within the configured residual /// tolerance (exact zero by default). @@ -206,12 +215,12 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali string calldata requestName, string calldata requestSymbol ) external onlyOwnerOrRebalancer nonReentrant returns (address request) { - RetargetterOperation storage operation_ = LibStorage.operationStorage(); - _checkStart(operation_, positionManager, fund, principal); - uint16 configCap = LibStorage.configStorage().maxYieldBps; uint16 effectiveYieldCap = maxYieldBps_ < configCap ? maxYieldBps_ : configCap; + RetargetterOperation storage operation_ = LibStorage.operationStorage(); + _checkStart(operation_, positionManager, fund, principal, effectiveYieldCap); + // Safe: block.timestamp + 90 days fits in uint40 for ~35,000 years // forge-lint: disable-next-line(unsafe-typecast) uint40 repaymentDeadline = uint40(block.timestamp + REPAYMENT_DEADLINE_OFFSET); @@ -233,6 +242,15 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali operation_.request = request; operation_.repaymentDeadline = repaymentDeadline; operation_.fund = fund; + // The repayment terms join the yield cap in the operation snapshot, so a later setConfig + // cannot reprice a funded operation (block-scoped so the pointer does not deepen the + // stack for the rest of the function) + { + RetargetterConfig storage config_ = LibStorage.configStorage(); + operation_.horizon = config_.horizon; + operation_.tickDuration = config_.tickDuration; + operation_.tickThreshold = config_.tickThreshold; + } emit RetargettingStarted(positionManager, request, fund, principal, effectiveYieldCap); } @@ -242,7 +260,13 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// `expectedReturn * BPS <= amount * operationMaxYieldBps`. The gate is flat because /// repayment itself is duration-prorated by the tick formula. The principal gate is /// cumulative through the Request's PT supply plus the outstanding mint authorizations, - /// which together are the complete capital accounting for the Request. + /// which together are the complete capital accounting for the Request. It runs after + /// the Request call, when the minted amount already sits in the PT supply: the maker + /// callback inside Request.consume can move the live cap (a liquidation shrinks it), + /// and only a post-call read cannot be invalidated. The call is atomic, so failing + /// late rolls back the callback, the transfer and the mint alike, and a maker cannot + /// inflate capacity just for the check: once the call returns, only the position + /// manager's owner or rebalancer can move collateral back out. function consume(Offer calldata offer, bytes calldata signature, uint256 ptAmount) external onlyOwnerOrRoles(CONSUMER_ROLE) @@ -251,10 +275,10 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali { RetargetterOperation storage operation_ = LibStorage.operationStorage(); address request = operation_.checkRequest(); - operation_.checkOffer( - request, offer.amount, offer.expectedReturn, ptAmount, maxPrincipal(operation_.positionManager) - ); + operation_.checkYield(offer.amount, offer.expectedReturn); + operation_.checkConsumptionWindow(); ytAmount = IRequest(request).consume(offer, signature, ptAmount); + operation_.checkPrincipalCap(request, 0, _maxPrincipal(operation_.positionManager, operation_.operationMaxYieldBps)); emit OfferConsumed(request, offer.maker, ptAmount, ytAmount); } @@ -264,7 +288,9 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// account's current one is replaced, not added to). A nonzero authorization starts the /// loan clock, must land inside the consumption window and is capped in count by /// MAX_AUTHORIZED_ACCOUNTS; the zero-amount revocation skips every gate because it - /// only shrinks exposure and must stay available after the window closes. + /// only shrinks exposure and must stay available after the window closes. Unlike + /// consume there is no maker callback between the gates and the Request write, so the + /// principal gate stays pre-call here. function authorizeMinting(address to, uint128 ptAmount, uint128 ytAmount) external onlyOwnerOrRoles(CONSUMER_ROLE) @@ -278,9 +304,22 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali EnumerableSetLib.AddressSet storage accounts = operation_.authorizedAccounts; accounts.remove(to); if (ptAmount != 0 || ytAmount != 0) { - operation_.checkOffer(request, ptAmount, ytAmount, ptAmount, maxPrincipal(operation_.positionManager)); + operation_.checkOffer( + request, + ptAmount, + ytAmount, + ptAmount, + _maxPrincipal(operation_.positionManager, operation_.operationMaxYieldBps) + ); // The capacity bound keeps every loop over the set within gas reach; see the constant accounts.add(to, MAX_AUTHORIZED_ACCOUNTS); + } else if (operation_.startedAt != 0 && accounts.length() == 0) { + // The loan clock only runs while the operation carries an obligation: a revocation + // leaving no supply and no pending authorization rewinds it to unstarted, so an + // economically empty operation is not forced through a resolve-and-restart cycle by + // an expired consumption window + (uint128 ptSupply, uint128 ytSupply) = ITokenController(request).totalSupplies(); + if (ptSupply == 0 && ytSupply == 0) operation_.startedAt = 0; } IRequest(request).authorizeMinting(to, ptAmount, ytAmount); emit MintingAuthorized(request, to, ptAmount, ytAmount); @@ -288,6 +327,8 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// @inheritdoc IRetargetter /// @dev Naturally bounded by the Request's balance, which the principal cap already bounded. + /// A zero amount is the named close of the funding round: no capital moves, the round + /// still shuts the same way. function pullRequestFunds(uint256 amount) external onlyOwnerOrRebalancer nonReentrant { RetargetterOperation storage operation_ = LibStorage.operationStorage(); address request = operation_.checkRequest(); @@ -303,7 +344,9 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// @dev With no consume the owed amount is zero and this simply marks the Request repaid, /// which is how an untouched operation gets abandoned. Transfers only the shortfall /// between the owed amount and the Request's balance, never more, so position-derived - /// funds cannot overpay YT holders. The open upper bound at setRepaid keeps third-party + /// 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 @@ -408,9 +451,12 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali } /// @inheritdoc IRetargetter + /// @dev The fund must report the canceled order EMPTY before the local order state is + /// cleared; a fund still processing the order keeps it stored and reverts, so the + /// expectation is enforced on the IFund contract rather than assumed. function cancelOrder() external onlyOwnerOrRebalancer nonReentrant { (RetargetterOperation storage operation_, address fund, Order memory order_) = _liveOrder(); - IFund(fund).cancel(order_); + if (IFund(fund).cancel(order_) != State.EMPTY) revert LibRetargetterErrors.OrderPending(); operation_.clearOrder(); emit OrderCanceled(fund, order_); } @@ -548,7 +594,9 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali bytes[] calldata data ) external onlyOwnerOrRebalancer { RetargetterOperation storage operation_ = LibStorage.operationStorage(); - _checkStart(operation_, positionManager, fund, flashLoanAmount); + // The flash repayment carries no yield, so the window's principal cap is sized on a + // zero yield cap (the pure one-trip headroom bound) + _checkStart(operation_, positionManager, fund, flashLoanAmount, 0); if (!LibStorage.whitelistsStorage().flashLoanModules[flashLoanModule]) { revert LibRetargetterErrors.ModuleNotWhitelisted(); } @@ -639,8 +687,10 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ /// @inheritdoc IRetargetter - /// @dev Changes take effect immediately, including for an in-flight operation; the - /// recorded per-operation yield cap is the exception, fixed at start. + /// @dev Changes take effect immediately, except for an in-flight operation's snapshot: the + /// repayment terms (horizon, tick duration, tick threshold) and the yield cap are + /// fixed at operation start, so this cannot move value between the borrower and + /// lenders who already committed capital. function setConfig(RetargetterConfig calldata config_) external onlyOwner nonReentrant { _setConfig(config_); } @@ -679,6 +729,22 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali emit FlashLoanModuleSet(module, whitelisted); } + /// @inheritdoc IRetargetter + /// @dev Mirrors setFund: the pair is checked once here instead of at every operation + /// start, and removal is blocked while the position manager is bound to the active + /// operation. Every start-gate and principal-cap read trusts what the bound address + /// reports about itself, which is why the binding is owner-curated. + function setPositionManager(address positionManager, bool whitelisted) external onlyOwner nonReentrant { + if (whitelisted) { + positionManager.checkContract(); + _checkPair(positionManager); + } else if (LibStorage.operationStorage().positionManager == positionManager) { + revert LibRetargetterErrors.OperationActive(); + } + LibStorage.whitelistsStorage().positionManagers[positionManager] = whitelisted; + emit PositionManagerSet(positionManager, whitelisted); + } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* VIEWS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -700,6 +766,9 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali uint40 startedAt, uint40 repaymentDeadline, uint16 operationMaxYieldBps, + uint32 horizon, + uint24 tickDuration, + uint24 tickThreshold, Order memory order, bool orderLive ) @@ -712,6 +781,9 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali operation_.startedAt, operation_.repaymentDeadline, operation_.operationMaxYieldBps, + operation_.horizon, + operation_.tickDuration, + operation_.tickThreshold, operation_.order(), operation_.orderLive ); @@ -723,34 +795,13 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali } /// @inheritdoc IRetargetter - /// @dev Recomputed from live position manager state, so the cap self-corrects: once a - /// rebalance moves the position to target the cap collapses toward zero and further - /// consumption is blocked. An empty position reverts EmptyPosition; debt against zero - /// collateral reverts BadDebtPosition. + /// @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 + /// gates size on the effective per-operation cap instead (zero for a SYNC window, + /// whose flash repayment carries no yield) and can admit up to that looser bound. + /// See {_maxPrincipal} for the formula. function maxPrincipal(address positionManager) public view returns (uint256) { - uint256 collateralQuoted = IPositionManager(positionManager).collateralAmountQuoted(); - uint256 debt = IPositionManager(positionManager).debtAmount(); - if (collateralQuoted == 0) { - if (debt == 0) revert LibRetargetterErrors.EmptyPosition(); - revert LibRetargetterErrors.BadDebtPosition(positionManager); - } - (uint256 target,) = IPositionManager(positionManager).config(); - RetargetterConfig storage config_ = LibStorage.configStorage(); - // The estimates pack into a single slot, so one copy into memory beats a storage - // pointer re-reading the slot for every field passed to the quoter - YieldEstimates memory estimates = config_.estimates; - uint256 principal = IRetargetterQuoter(_QUOTER) - .retargetPrincipal( - collateralQuoted, - debt, - target, - estimates.requestYieldRate, - estimates.borrowRate, - estimates.collateralYieldRate, - estimates.subscriptionDuration, - estimates.redemptionDuration - ); - return principal * (BPS + config_.principalBufferBps) / BPS; + return _maxPrincipal(positionManager, LibStorage.configStorage().maxYieldBps); } /// @inheritdoc IRetargetter @@ -790,6 +841,11 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali return LibStorage.whitelistsStorage().flashLoanModules[module]; } + /// @inheritdoc IRetargetter + function isPositionManager(address positionManager) external view returns (bool) { + return LibStorage.whitelistsStorage().positionManagers[positionManager]; + } + /// @inheritdoc IRetargetter function quoter() external view returns (address) { return _QUOTER; @@ -827,18 +883,26 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// @dev Shared start gate of both entry points. One operation at a time: the position /// manager doubles as the active flag, and a SYNC window keeps it populated, so this - /// also locks out starts smuggled into a window payload. No leftover order, matching - /// asset pair, whitelisted fund, and the requested amount within the live principal - /// cap (fail-fast for ASYNC, where the binding check re-runs at every consume). - function _checkStart(RetargetterOperation storage operation_, address positionManager, address fund, uint256 amount) - internal - view - { + /// also locks out starts smuggled into a window payload. No leftover order, + /// whitelisted position manager (its pair was checked at setPositionManager), + /// whitelisted fund, and the requested amount within the live principal cap sized on + /// the operation's yield cap (fail-fast for ASYNC, where the binding check re-runs at + /// every consume; zero for SYNC, whose flash repayment carries no yield). + function _checkStart( + RetargetterOperation storage operation_, + address positionManager, + address fund, + uint256 amount, + uint16 yieldCapBps + ) internal view { if (operation_.positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); if (operation_.orderLive) revert LibRetargetterErrors.OrderActive(); - _checkPair(positionManager); - if (!LibStorage.whitelistsStorage().funds[fund]) revert LibRetargetterErrors.FundNotWhitelisted(); - if (amount > maxPrincipal(positionManager)) revert LibRetargetterErrors.PrincipalCapExceeded(); + RetargetterWhitelists storage whitelists_ = LibStorage.whitelistsStorage(); + if (!whitelists_.positionManagers[positionManager]) { + revert LibRetargetterErrors.PositionManagerNotWhitelisted(); + } + if (!whitelists_.funds[fund]) revert LibRetargetterErrors.FundNotWhitelisted(); + if (amount > _maxPrincipal(positionManager, yieldCapBps)) revert LibRetargetterErrors.PrincipalCapExceeded(); } /// @dev Shared preamble of the wrappers acting on the live order: requires an active @@ -856,9 +920,10 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali order_ = operation_.order(); } - /// @dev Reverts unless the position manager's assets equal the bound pair. The fund's - /// tokens were validated against the pair at setFund and the Request is created with - /// the debt asset, so a matching position manager makes the whole operation match. + /// @dev Reverts unless the position manager's assets equal the bound pair; run once at + /// setPositionManager. The fund's tokens were validated against the pair at setFund + /// and the Request is created with the debt asset, so a matching position manager + /// makes the whole operation match. function _checkPair(address positionManager) internal view { RetargetterAssets storage assets_ = LibStorage.assetsStorage(); (address collateralAsset, address debtAsset) = IPositionManager(positionManager).assets(); @@ -867,21 +932,68 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali } } - /// @dev Current owed amount on the operation's Request. The supplies only grow through - /// consume and authorized mints, both reachable only once the loan clock has started, - /// so with startedAt still zero both supplies are zero and the owed amount is zero - /// regardless of the elapsed time. + /// @dev The live principal cap for an operation carrying the given yield cap. Recomputed + /// from live position manager state, so the cap self-corrects: once a rebalance moves + /// the position to target the cap collapses toward zero and further consumption is + /// blocked. An empty position reverts EmptyPosition; debt against zero collateral + /// reverts BadDebtPosition. Below target the buffered cap is further bounded by the + /// quoter's one-trip repayment bound: repayment prices actual YT supply, which the + /// flat yield cap bounds rather than the yield estimate, so the buffer alone would + /// let a cap-sized operation owe more than the post-supply target-LTV borrow + /// capacity can fund in one settlement trip. + function _maxPrincipal(address positionManager, uint16 yieldCapBps) internal view returns (uint256) { + uint256 collateralQuoted = IPositionManager(positionManager).collateralAmountQuoted(); + uint256 debt = IPositionManager(positionManager).debtAmount(); + if (collateralQuoted == 0) { + if (debt == 0) revert LibRetargetterErrors.EmptyPosition(); + revert LibRetargetterErrors.BadDebtPosition(positionManager); + } + (uint256 target,) = IPositionManager(positionManager).config(); + RetargetterConfig storage config_ = LibStorage.configStorage(); + // The estimates pack into a single slot, so one copy into memory beats a storage + // pointer re-reading the slot for every field passed to the quoter + YieldEstimates memory estimates = config_.estimates; + uint256 principal = IRetargetterQuoter(_QUOTER) + .retargetPrincipal( + collateralQuoted, + debt, + target, + estimates.requestYieldRate, + estimates.borrowRate, + estimates.collateralYieldRate, + estimates.subscriptionDuration, + estimates.redemptionDuration + ); + uint256 buffered = principal * (BPS + config_.principalBufferBps) / BPS; + if (debt.divWad(collateralQuoted) >= target) return buffered; + uint256 oneTrip = IRetargetterQuoter(_QUOTER) + .ltvUpOneTripPrincipal( + collateralQuoted, + debt, + target, + yieldCapBps, + estimates.borrowRate, + estimates.collateralYieldRate, + estimates.subscriptionDuration + ); + return buffered.min(oneTrip); + } + + /// @dev Current owed amount on the operation's Request, priced on the repayment terms + /// snapshotted at operation start (live config no longer moves them). The supplies + /// only grow through consume and authorized mints, both reachable only once the loan + /// clock has started, so with startedAt still zero both supplies are zero and the + /// owed amount is zero regardless of the elapsed time. function _owed(RetargetterOperation storage operation_) internal view returns (uint256) { (uint128 ptSupply, uint128 ytSupply) = ITokenController(operation_.request).totalSupplies(); - RetargetterConfig storage config_ = LibStorage.configStorage(); return IRetargetterQuoter(_QUOTER) .repaymentOwed( ptSupply, ytSupply, block.timestamp - operation_.startedAt, - config_.tickDuration, - config_.tickThreshold, - config_.horizon + operation_.tickDuration, + operation_.tickThreshold, + operation_.horizon ); } diff --git a/src/manager/rebalancer/RetargetterFactory.sol b/src/manager/rebalancer/RetargetterFactory.sol index 2e2e6de7..0cb1ac22 100644 --- a/src/manager/rebalancer/RetargetterFactory.sol +++ b/src/manager/rebalancer/RetargetterFactory.sol @@ -30,8 +30,11 @@ import {LibClone} from "lib/solady/src/utils/LibClone.sol"; /// 3. Deploy the MorphoFlashLoanAdapter once per chain (its constructor takes Morpho /// Blue's address). /// 4. The instance owner grants the Retargetter's own REBALANCER_ROLE (Solady _ROLE_0, -/// bit value 1) and CONSUMER_ROLE (Solady _ROLE_1, bit value 2), whitelists funds -/// and the adapter, and sets the yield estimates. +/// bit value 1) and CONSUMER_ROLE (Solady _ROLE_1, bit value 2), whitelists funds, +/// the adapter and the served PositionManagers, and sets the yield estimates. Grant +/// a PositionManager's REBALANCER_ROLE to at most one Retargetter: the +/// one-operation-at-a-time guard is per instance, so a second grantee could admit +/// the full principal cap on its own. contract RetargetterFactory { using LibClone for address; diff --git a/src/manager/rebalancer/RetargetterQuoter.sol b/src/manager/rebalancer/RetargetterQuoter.sol index 78b31ba4..0760a063 100644 --- a/src/manager/rebalancer/RetargetterQuoter.sol +++ b/src/manager/rebalancer/RetargetterQuoter.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.22; import {IRetargetterQuoter} from "../../interfaces/manager/rebalancer/IRetargetterQuoter.sol"; import {LibRetargetterErrors} from "../../libs/manager/rebalancer/LibRetargetterErrors.sol"; +import {BPS} from "../../libs/Constants.sol"; import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// @title RetargetterQuoter @@ -51,6 +52,17 @@ import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// /// x = (t * K * (1 + Yc) - D * (1 + Rb)) / (1 - t + Yr) /// +/// One-trip repayment bound (`ltvUpOneTripPrincipal`): repayment prices the actual YT +/// supply, bounded by the flat yield cap y rather than the Yr estimate, so the worst +/// permitted repayment of a fully deployed principal x is `x * (1 + y)`. Requiring it to +/// stay borrowable at target once the subscription has landed, +/// +/// x * (1 + y) <= t * (K * (1 + Yc) + x) - D * (1 + Rb) +/// +/// and solving for x yields the same formula with y in place of Yr: +/// +/// x = (t * K * (1 + Yc) - D * (1 + Rb)) / (1 + y - t) +/// /// LTV-down sizing (`ltvDownPrincipal`), for over-leveraged positions (D / K > t): borrow /// x, repay x of position debt, withdraw collateral of quoted value w and redeem it, and /// at settlement repay the bridge `x * (1 + Yr)` from the redemption proceeds. The freed @@ -156,6 +168,25 @@ contract RetargetterQuoter is IRetargetterQuoter { principal = (grownTarget - driftedDebt).fullMulDiv(WAD, denominator); } + /// @inheritdoc IRetargetterQuoter + function ltvUpOneTripPrincipal( + uint256 collateralQuoted, + uint256 debt, + uint256 targetLtv, + uint256 maxYieldBps, + uint256 borrowRate, + uint256 collateralYieldRate, + uint256 duration + ) public pure returns (uint256 principal) { + uint256 grownTarget = _grownTarget(collateralQuoted, targetLtv, collateralYieldRate, duration); + uint256 driftedDebt = _driftedDebt(debt, borrowRate, duration); + if (grownTarget <= driftedDebt) return 0; + // Denominator 1 + y - targetLtv; zero only at targetLtv == WAD with a zero yield cap + uint256 denominator = WAD + maxYieldBps * WAD / BPS - targetLtv; + if (denominator == 0) revert LibRetargetterErrors.InvalidParameters(); + principal = (grownTarget - driftedDebt).fullMulDiv(WAD, denominator); + } + /// @inheritdoc IRetargetterQuoter function ltvDownPrincipal( uint256 collateralQuoted, diff --git a/test/manager/rebalancer/Retargetter.invariant.t.sol b/test/manager/rebalancer/Retargetter.invariant.t.sol index 9eda4d83..7e31542a 100644 --- a/test/manager/rebalancer/Retargetter.invariant.t.sol +++ b/test/manager/rebalancer/Retargetter.invariant.t.sol @@ -84,7 +84,7 @@ contract RetargetterInvariantTest is RetargetterBaseTest { /// transaction scoping and a RetargetterSync unit test probes the in-window shape. function invariant_oneOperationAtATime() public view { if (!retargetter.isActive()) return; - (, address request, address operationFund,,,,,) = retargetter.operation(); + (, address request, address operationFund,,,,,,,,) = retargetter.operation(); assertTrue(request != address(0), "RT-1: active operation without a Request"); assertTrue(operationFund != address(0), "RT-1: active operation without a fund"); assertTrue(retargetter.isFund(operationFund), "RT-1: active operation's fund not whitelisted"); @@ -99,7 +99,7 @@ contract RetargetterInvariantTest is RetargetterBaseTest { if (retargetter.isActive()) return; assertEq(collateralToken.balanceOf(address(retargetter)), 0, "RT-2: collateral at rest while inactive"); assertEq(debtToken.balanceOf(address(retargetter)), 0, "RT-2: debt at rest while inactive"); - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); assertFalse(orderLive, "RT-2: stored order survived while inactive"); } @@ -114,21 +114,23 @@ contract RetargetterInvariantTest is RetargetterBaseTest { /// cap means a delayed repayment can never draw more than the approved yield. function invariant_owedNeverExceedsPtPlusYt() public view { if (!retargetter.isActive()) return; - (, address request,,,,,,) = retargetter.operation(); + (, address request,,,,,,,,,) = retargetter.operation(); (uint128 ptSupply, uint128 ytSupply) = ITokenController(request).totalSupplies(); assertLe(retargetter.owed(), uint256(ptSupply) + uint256(ytSupply), "RT-4: owed exceeds pt + yt"); } - /// @notice RT-5: The loan clock origin is monotone: once startedAt is nonzero for the - /// current operation it never changes (later consumes accrue from the same origin). - function invariant_startedAtMonotone() public view { - assertFalse(handler.startedAtMutated(), "RT-5: startedAt changed after the first consume"); + /// @notice RT-5: The loan clock origin is stable while a loan is outstanding: once nonzero + /// it never moves to a different nonzero value (later consumes accrue from the same + /// origin), and it rewinds to zero only when the operation is economically empty + /// (no PT, no YT, no pending authorization), reopening the consumption window. + function invariant_startedAtStableOrRewound() public view { + assertFalse(handler.startedAtMutated(), "RT-5: startedAt moved while a loan was outstanding"); } /// @notice RT-6: A live stored order implies an active ASYNC operation: flash-loan windows /// are transaction-scoped and never leak a live order across transactions. function invariant_orderLiveImpliesActive_orWindow() public view { - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); if (orderLive) assertTrue(retargetter.isActive(), "RT-6: live order without an active operation"); } diff --git a/test/manager/rebalancer/Retargetter.t.sol b/test/manager/rebalancer/Retargetter.t.sol index 3d7b8646..f5b60786 100644 --- a/test/manager/rebalancer/Retargetter.t.sol +++ b/test/manager/rebalancer/Retargetter.t.sol @@ -252,6 +252,75 @@ contract RetargetterTest is RetargetterBaseTest { assertEq(stored.estimates.redemptionDuration, 3 days, "redemption duration"); } + /// @notice A started operation settles on the repayment terms snapshotted at start: a + /// later setConfig moves neither the owed pricing nor the consumption window. + function test_setConfig_doesNotRepriceStartedOperation() public { + _seedPosition(10_000e18, 5_000e18); + address request = _startAsync(4_000e18, 100); + _consume(request, 1_000e18, 10e18, 1_000e18); + + // One tick on the snapshotted terms: 1 day over a 365-day horizon (yield rounds up) + uint256 owedBefore = retargetter.owed(); + assertEq(owedBefore, 1_000e18 + (uint256(10e18) * 1 days + 365 days - 1) / 365 days, "one snapshot tick owed"); + + // Read live, the new terms would owe a 30-day tick over a 90-day horizon + RetargetterConfig memory config_ = retargetter.config(); + config_.horizon = 90 days; + config_.tickDuration = 30 days; + config_.tickThreshold = 1 days; + vm.prank(owner); + retargetter.setConfig(config_); + + assertEq(retargetter.owed(), owedBefore, "started operation keeps its snapshotted terms"); + (,,,,,, uint32 horizon, uint24 tickDuration, uint24 tickThreshold,,) = retargetter.operation(); + assertEq(horizon, DEFAULT_HORIZON, "snapshot horizon untouched"); + assertEq(tickDuration, DEFAULT_TICK_DURATION, "snapshot tick duration untouched"); + assertEq(tickThreshold, DEFAULT_TICK_THRESHOLD, "snapshot tick threshold untouched"); + } + + /// @notice The consumption window length is part of the operation snapshot: shrinking the + /// live tick threshold neither shortens a running window, nor does the snapshot + /// hold past its own boundary. + function test_setConfig_windowLengthFixedAtStart() public { + _seedPosition(10_000e18, 5_000e18); + address request = _startAsync(4_000e18, 100); + _consume(request, 1_000e18, 10e18, 1_000e18); + uint256 origin = block.timestamp; + + RetargetterConfig memory config_ = retargetter.config(); + config_.tickThreshold = 1 hours; + vm.prank(owner); + retargetter.setConfig(config_); + + // Five hours in: shut under the live 1-hour threshold, open under the 10-hour snapshot + vm.warp(origin + 5 hours); + _consume(request, 1_000e18, 10e18, 1_000e18); + + // Past the snapshot threshold the window closes (checked before the Request call) + vm.warp(origin + uint256(DEFAULT_TICK_THRESHOLD) + 1); + Offer memory offer = _createOffer(1_000e18, 10e18); + vm.prank(consumer); + vm.expectRevert(LibRetargetterErrors.ConsumptionWindowClosed.selector); + retargetter.consume(offer, "", 1_000e18); + } + + /// @notice Config changes bind the next operation: the snapshot is taken at start. + function test_setConfig_appliesToNextOperation() public { + RetargetterConfig memory config_ = retargetter.config(); + config_.horizon = 180 days; + config_.tickDuration = 2 days; + config_.tickThreshold = 12 hours; + vm.prank(owner); + retargetter.setConfig(config_); + + _seedPosition(10_000e18, 5_000e18); + _startAsync(4_000e18, 100); + (,,,,,, uint32 horizon, uint24 tickDuration, uint24 tickThreshold,,) = retargetter.operation(); + assertEq(horizon, 180 days, "new horizon snapshotted"); + assertEq(tickDuration, 2 days, "new tick duration snapshotted"); + assertEq(tickThreshold, 12 hours, "new tick threshold snapshotted"); + } + function test_setEstimates_revertNonOwner() public { YieldEstimates memory estimates = _zeroEstimates(); @@ -268,16 +337,19 @@ contract RetargetterTest is RetargetterBaseTest { _seedPosition(10_000e18, 5_000e18); uint256 collateralQuoted = positionManager.collateralAmountQuoted(); uint256 debt = positionManager.debtAmount(); + uint256 yieldCapDenominator = WAD + uint256(DEFAULT_MAX_YIELD_BPS) * WAD / BPS - POSITION_MANAGER_LTV; - // Zero-estimate cap: x = (target * K - D) / (1 - target), plus the 1% buffer + // Zero-estimate cap: the one-trip bound (target * K - D) / (1 + yieldCap - target) + // undercuts the buffered ideal (see test_maxPrincipal_upBranchZeroEstimates) uint256 capBefore = retargetter.maxPrincipal(address(positionManager)); - uint256 idealPrincipal = (POSITION_MANAGER_LTV * collateralQuoted / WAD - debt) * WAD / (WAD - POSITION_MANAGER_LTV); - assertEq(capBefore, idealPrincipal * (BPS + DEFAULT_PRINCIPAL_BUFFER_BPS) / BPS, "zero-estimate cap"); + assertEq( + capBefore, (POSITION_MANAGER_LTV * collateralQuoted / WAD - debt) * WAD / yieldCapDenominator, "zero-estimate cap" + ); - // A 10% per-year request yield over a full-year subscription makes Yr = 0.1 + // A 10% per-year borrow rate over a full-year subscription makes Rb = 0.1 YieldEstimates memory estimates = YieldEstimates({ - requestYieldRate: 0.1e18, - borrowRate: 0, + requestYieldRate: 0, + borrowRate: 0.1e18, collateralYieldRate: 0, subscriptionDuration: 365 days, redemptionDuration: 0 @@ -288,12 +360,15 @@ contract RetargetterTest is RetargetterBaseTest { emit IRetargetter.EstimatesSet(estimates); retargetter.setEstimates(estimates); - // Estimated cap: x = (target * K - D) / (1 - target + Yr), plus the 1% buffer + // The drifted debt D * 1.1 tightens both formulas; the one-trip bound still binds uint256 capAfter = retargetter.maxPrincipal(address(positionManager)); - uint256 estimatedPrincipal = - (POSITION_MANAGER_LTV * collateralQuoted / WAD - debt) * WAD / (WAD - POSITION_MANAGER_LTV + 0.1e18); - assertEq(capAfter, estimatedPrincipal * (BPS + DEFAULT_PRINCIPAL_BUFFER_BPS) / BPS, "estimated cap"); - assertLt(capAfter, capBefore, "yield estimate tightens the cap"); + uint256 driftedDebt = debt * (WAD + 0.1e18) / WAD; + assertEq( + capAfter, + (POSITION_MANAGER_LTV * collateralQuoted / WAD - driftedDebt) * WAD / yieldCapDenominator, + "estimated cap" + ); + assertLt(capAfter, capBefore, "borrow estimate tightens the cap"); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -410,6 +485,91 @@ contract RetargetterTest is RetargetterBaseTest { assertFalse(retargetter.isFlashLoanModule(address(flashLoanAdapter)), "removed"); } + function test_setPositionManager_revertNonOwnerAndNonContract() public { + address stranger = makeAddr("strangerPositionManager"); + + vm.prank(rebalancer); + vm.expectRevert(Ownable.Unauthorized.selector); + retargetter.setPositionManager(address(positionManager), false); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(LibCommonErrors.InvalidContract.selector, stranger)); + retargetter.setPositionManager(stranger, true); + } + + function test_setPositionManager_revertAssetMismatch() public { + // A position manager on a different asset pair never gets whitelisted + MockERC20 otherCollateral = new MockERC20("Other Collateral", "OCOLL", 18); + MockERC20 otherDebt = new MockERC20("Other Debt", "ODEBT", 18); + PositionManager otherPositionManager = PositionManager(LibClone.clone(address(new PositionManager()))); + otherPositionManager.initialize( + owner, + PositionManagerMetadata({ + name: "Other Position Manager", + symbol: "OPMS", + collateralAsset: address(otherCollateral), + debtAsset: address(otherDebt) + }), + POSITION_MANAGER_LTV, + address(0), + 0, + 0 + ); + + vm.prank(owner); + vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); + retargetter.setPositionManager(address(otherPositionManager), true); + } + + function test_setPositionManager_emitsAndWhitelists() public { + assertTrue(retargetter.isPositionManager(address(positionManager)), "fixture manager whitelisted"); + + // A fresh manager on the bound pair whitelists cleanly + PositionManager second = PositionManager(LibClone.clone(address(new PositionManager()))); + second.initialize( + owner, + PositionManagerMetadata({ + name: "Second Position Manager", + symbol: "PMS2", + collateralAsset: address(collateralToken), + debtAsset: address(debtToken) + }), + POSITION_MANAGER_LTV, + address(0), + 0, + 0 + ); + assertFalse(retargetter.isPositionManager(address(second)), "not whitelisted before"); + + vm.prank(owner); + vm.expectEmit(address(retargetter)); + emit IRetargetter.PositionManagerSet(address(second), true); + retargetter.setPositionManager(address(second), true); + assertTrue(retargetter.isPositionManager(address(second)), "whitelisted after"); + + // Removal skips the pair check and emits the same event, flag down + vm.prank(owner); + vm.expectEmit(address(retargetter)); + emit IRetargetter.PositionManagerSet(address(second), false); + retargetter.setPositionManager(address(second), false); + assertFalse(retargetter.isPositionManager(address(second)), "removed after"); + } + + function test_setPositionManager_revertOperationActiveForBoundManager() public { + _seedPosition(10_000e18, 5_000e18); + _startAsync(4_000e18, 100); + + // The operation's manager cannot be removed while the operation is active + vm.prank(owner); + vm.expectRevert(LibRetargetterErrors.OperationActive.selector); + retargetter.setPositionManager(address(positionManager), false); + + // Re-whitelisting the bound manager is a harmless no-op + vm.prank(owner); + retargetter.setPositionManager(address(positionManager), true); + assertTrue(retargetter.isPositionManager(address(positionManager)), "still whitelisted"); + } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* NO ESCAPE HATCH */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -448,37 +608,46 @@ contract RetargetterTest is RetargetterBaseTest { uint256 startTime = block.timestamp; address request = _startAsync(6_000e18, 100); - ( - address positionManager_, - address request_, - address fund_, - uint40 startedAt, - uint40 repaymentDeadline, - uint16 operationMaxYieldBps, - Order memory order, - bool orderLive - ) = retargetter.operation(); - assertEq(positionManager_, address(positionManager), "position manager"); - assertEq(request_, request, "request"); - assertEq(fund_, address(fund), "fund"); - assertEq(startedAt, 0, "loan clock not started"); - assertEq(uint256(repaymentDeadline), startTime + 90 days, "mirrored Request deadline"); - assertEq(operationMaxYieldBps, 100, "effective yield cap"); - assertFalse(orderLive, "no stored order"); - assertEq(order.owner, address(retargetter), "rebuilt order owner"); - assertEq(order.receiver, address(retargetter), "rebuilt order receiver"); - assertEq(order.input, 0, "empty order input"); + // Block-scoped so the wide destructuring does not deepen the stack for the rest + { + ( + address positionManager_, + address request_, + address fund_, + uint40 startedAt_, + uint40 repaymentDeadline, + uint16 operationMaxYieldBps, + uint32 horizon, + uint24 tickDuration, + uint24 tickThreshold, + Order memory order, + bool orderLive + ) = retargetter.operation(); + assertEq(positionManager_, address(positionManager), "position manager"); + assertEq(request_, request, "request"); + assertEq(fund_, address(fund), "fund"); + assertEq(startedAt_, 0, "loan clock not started"); + assertEq(uint256(repaymentDeadline), startTime + 90 days, "mirrored Request deadline"); + assertEq(operationMaxYieldBps, 100, "effective yield cap"); + assertEq(horizon, DEFAULT_HORIZON, "snapshotted horizon"); + assertEq(tickDuration, DEFAULT_TICK_DURATION, "snapshotted tick duration"); + assertEq(tickThreshold, DEFAULT_TICK_THRESHOLD, "snapshotted tick threshold"); + assertFalse(orderLive, "no stored order"); + assertEq(order.owner, address(retargetter), "rebuilt order owner"); + assertEq(order.receiver, address(retargetter), "rebuilt order receiver"); + assertEq(order.input, 0, "empty order input"); + } // The first consume sets the loan clock origin, not the operation start vm.warp(startTime + 3 days); _consume(request, 1_000e18, 10e18, 1_000e18); - (,,, startedAt,,,,) = retargetter.operation(); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); assertEq(startedAt, startTime + 3 days, "startedAt is the first consume time"); // Later consumes inside the consumption window leave the origin untouched vm.warp(startTime + 3 days + 5 hours); _consume(request, 1_000e18, 10e18, 1_000e18); - (,,, startedAt,,,,) = retargetter.operation(); + (,,, startedAt,,,,,,,) = retargetter.operation(); assertEq(startedAt, startTime + 3 days, "startedAt unchanged by later consumes"); } @@ -527,11 +696,15 @@ contract RetargetterTest is RetargetterBaseTest { uint256 collateralQuoted = positionManager.collateralAmountQuoted(); uint256 debt = positionManager.debtAmount(); - // x = (target * K - D) / (1 - target); cap = x * 1.01 (100 bps buffer) - uint256 idealPrincipal = (POSITION_MANAGER_LTV * collateralQuoted / WAD - debt) * WAD / (WAD - POSITION_MANAGER_LTV); - uint256 expectedCap = idealPrincipal * (BPS + DEFAULT_PRINCIPAL_BUFFER_BPS) / BPS; + // Buffered ideal x = (target * K - D) / (1 - target) times 1.01 (100 bps buffer), capped + // by the one-trip repayment bound (target * K - D) / (1 + yieldCap - target) sized on + // the config ceiling (10%); the bound is the smaller of the two here + uint256 numerator = POSITION_MANAGER_LTV * collateralQuoted / WAD - debt; + uint256 buffered = numerator * WAD / (WAD - POSITION_MANAGER_LTV) * (BPS + DEFAULT_PRINCIPAL_BUFFER_BPS) / BPS; + uint256 oneTrip = numerator * WAD / (WAD + uint256(DEFAULT_MAX_YIELD_BPS) * WAD / BPS - POSITION_MANAGER_LTV); + assertLt(oneTrip, buffered, "the one-trip bound binds"); - assertEq(retargetter.maxPrincipal(address(positionManager)), expectedCap, "up cap"); + assertEq(retargetter.maxPrincipal(address(positionManager)), oneTrip, "up cap"); } function test_maxPrincipal_downBranchZeroEstimates() public { @@ -572,18 +745,16 @@ contract RetargetterTest is RetargetterBaseTest { ); } - function test_startRetargetting_revertAssetMismatch() public { - // A position manager on a different asset pair - MockERC20 otherCollateral = new MockERC20("Other Collateral", "OCOLL", 18); - MockERC20 otherDebt = new MockERC20("Other Debt", "ODEBT", 18); - PositionManager otherPositionManager = PositionManager(LibClone.clone(address(new PositionManager()))); - otherPositionManager.initialize( + function test_startRetargetting_revertPositionManagerNotWhitelisted() public { + // A position manager on the right pair that the owner never whitelisted + PositionManager strangerPositionManager = PositionManager(LibClone.clone(address(new PositionManager()))); + strangerPositionManager.initialize( owner, PositionManagerMetadata({ - name: "Other Position Manager", - symbol: "OPMS", - collateralAsset: address(otherCollateral), - debtAsset: address(otherDebt) + name: "Stranger Position Manager", + symbol: "SPMS", + collateralAsset: address(collateralToken), + debtAsset: address(debtToken) }), POSITION_MANAGER_LTV, address(0), @@ -592,17 +763,23 @@ contract RetargetterTest is RetargetterBaseTest { ); vm.prank(rebalancer); - vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); - retargetter.startRetargetting(address(otherPositionManager), 1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + vm.expectRevert(LibRetargetterErrors.PositionManagerNotWhitelisted.selector); + retargetter.startRetargetting( + address(strangerPositionManager), 1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL + ); } function test_startRetargetting_revertPrincipalCapExceeded() public { _seedPosition(10_000e18, 5_000e18); + // The view sizes the cap on the config yield-cap ceiling; a start at that same ceiling + // is gated by exactly this value uint256 cap = retargetter.maxPrincipal(address(positionManager)); vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); - retargetter.startRetargetting(address(positionManager), cap + 1, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + retargetter.startRetargetting( + address(positionManager), cap + 1, DEFAULT_MAX_YIELD_BPS, address(fund), REQUEST_NAME, REQUEST_SYMBOL + ); } function test_startRetargetting_revertUnauthorized() public { @@ -622,24 +799,25 @@ contract RetargetterTest is RetargetterBaseTest { emit IRetargetter.RetargettingStarted(address(positionManager), address(0), address(fund), 6_000e18, 100); retargetter.startRetargetting(address(positionManager), 6_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); - (,,,,, uint16 operationMaxYieldBps,,) = retargetter.operation(); + (,,,,, uint16 operationMaxYieldBps,,,,,) = retargetter.operation(); assertEq(operationMaxYieldBps, 100, "caller cap recorded"); } function test_startRetargetting_effectiveYieldCapConfigLower() public { _seedPosition(10_000e18, 5_000e18); - // Caller cap 2000 above the config's 1000: the config cap binds + // Caller cap 2000 above the config's 1000: the config cap binds (and sizes the one-trip + // principal bound, so the announced principal sits under that tighter cap) vm.prank(rebalancer); vm.expectEmit(true, false, true, true, address(retargetter)); emit IRetargetter.RetargettingStarted( - address(positionManager), address(0), address(fund), 6_000e18, DEFAULT_MAX_YIELD_BPS + address(positionManager), address(0), address(fund), 4_000e18, DEFAULT_MAX_YIELD_BPS ); retargetter.startRetargetting( - address(positionManager), 6_000e18, 2_000, address(fund), REQUEST_NAME, REQUEST_SYMBOL + address(positionManager), 4_000e18, 2_000, address(fund), REQUEST_NAME, REQUEST_SYMBOL ); - (,,,,, uint16 operationMaxYieldBps,,) = retargetter.operation(); + (,,,,, uint16 operationMaxYieldBps,,,,,) = retargetter.operation(); assertEq(operationMaxYieldBps, DEFAULT_MAX_YIELD_BPS, "config cap recorded"); } @@ -697,9 +875,13 @@ contract RetargetterTest is RetargetterBaseTest { _consume(request, 6_000e18, 60e18, 6_000e18); - // PT supply 6000 plus another 1000 crosses the cap + // PT supply 6000 plus another 1000 crosses the cap. The maker approves the transfer: + // the gate now runs after the Request call, so the revert must come from the cap + // re-check, not from a failed pull Offer memory offer = _createOffer(1_000e18, 10e18); bytes memory signature = _signOffer(offer, request); + vm.prank(maker.addr); + debtToken.approve(request, 1_000e18); vm.prank(consumer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); retargetter.consume(offer, signature, 1_000e18); @@ -810,7 +992,7 @@ contract RetargetterTest is RetargetterBaseTest { retargetter.multicall(calls); assertEq(debtToken.balanceOf(address(fund)), 6_000e18, "commit pulled the order input"); - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); assertTrue(orderLive, "order stored by the batched create"); } @@ -889,18 +1071,23 @@ contract RetargetterTest is RetargetterBaseTest { assertEq(accounts.length, 1, "one registered account"); assertEq(accounts[0], broker, "broker registered"); - // Slot A: positionManager (bits 0..159) | startedAt (160..199) | operationMaxYieldBps (200..215) + // Slot A: positionManager (bits 0..159) | startedAt (160..199) | operationMaxYieldBps + // (200..215) | consumptionClosed (216..223) | horizon (224..255): full, no wasted bits uint256 slotA = uint256(vm.load(address(retargetter), OPERATION_STORAGE_SLOT)); assertEq(address(uint160(slotA)), address(positionManager), "position manager bits"); assertEq(uint40(slotA >> 160), uint40(startTime + 1 days), "startedAt bits"); assertEq(uint16(slotA >> 200), 100, "operation max yield bits"); - assertEq(slotA >> 216, 0, "slot A upper bits clean"); + assertEq(uint8(slotA >> 216), 0, "consumption closed bit clear"); + assertEq(slotA >> 224, DEFAULT_HORIZON, "horizon bits"); - // Slot B: request (bits 0..159) | repaymentDeadline (160..199) + // Slot B: request (bits 0..159) | repaymentDeadline (160..199) | tickDuration (200..223) + // | tickThreshold (224..247) uint256 slotB = uint256(vm.load(address(retargetter), bytes32(uint256(OPERATION_STORAGE_SLOT) + 1))); assertEq(address(uint160(slotB)), request, "request bits"); assertEq(uint40(slotB >> 160), uint40(startTime + 90 days), "repayment deadline bits"); - assertEq(slotB >> 200, 0, "slot B upper bits clean"); + assertEq(uint24(slotB >> 200), DEFAULT_TICK_DURATION, "tick duration bits"); + assertEq(uint24(slotB >> 224), DEFAULT_TICK_THRESHOLD, "tick threshold bits"); + assertEq(slotB >> 248, 0, "slot B upper bits clean"); // Slot C: fund (bits 0..159) | orderMode (160..167) | orderLive (168..175) uint256 slotC = uint256(vm.load(address(retargetter), bytes32(uint256(OPERATION_STORAGE_SLOT) + 2))); @@ -928,7 +1115,7 @@ contract RetargetterTest is RetargetterBaseTest { assertEq(uint40(slotA >> 160), uint40(startTime + 1 days), "startedAt bits unchanged"); assertEq(uint16(slotA >> 200), 100, "operation max yield bits unchanged"); assertEq(uint8(slotA >> 216), 1, "consumption closed bit set"); - assertEq(slotA >> 224, 0, "slot A upper bits clean after the pull"); + assertEq(slotA >> 224, DEFAULT_HORIZON, "horizon bits unchanged by the pull"); } function test_storageLayout_assetsPacking() public view { diff --git a/test/manager/rebalancer/RetargetterAsync.t.sol b/test/manager/rebalancer/RetargetterAsync.t.sol index 8a72e140..54331dd8 100644 --- a/test/manager/rebalancer/RetargetterAsync.t.sol +++ b/test/manager/rebalancer/RetargetterAsync.t.sol @@ -12,10 +12,50 @@ import {Offer} from "src/interfaces/request/IOfferReceiver.sol"; import {IRequest} from "src/interfaces/request/IRequest.sol"; import {ITokenController} from "src/interfaces/request/ITokenController.sol"; import {IVaultController} from "src/interfaces/request/IVaultController.sol"; +import {IPositionManager} from "src/interfaces/manager/IPositionManager.sol"; +import {WithdrawalStrategy} from "src/interfaces/manager/base/IPositionManagerAdmin.sol"; +import {StickyCancelMockFund} from "test/mock/manager/rebalancer/MockRetargetterFund.sol"; import {Ownable} from "lib/solady/src/auth/Ownable.sol"; import {SafeTransferLib} from "lib/solady/src/utils/SafeTransferLib.sol"; import {EnumerableSetLib} from "lib/solady/src/utils/EnumerableSetLib.sol"; +/// @dev Offer maker whose consume callback burns its whole LP position, shrinking the live +/// principal cap between the Retargetter's pre-call gates and the post-call re-check; +/// ERC-1271 validation accepts any signature so the Request accepts its offers. +contract CapShrinkingMaker { + using SafeTransferLib for address; + + IPositionManager internal immutable _POSITION_MANAGER; + address internal immutable _DEBT_ASSET; + uint256 internal _shares; + + constructor(IPositionManager positionManager_, address debtAsset_) { + _POSITION_MANAGER = positionManager_; + _DEBT_ASSET = debtAsset_; + } + + /// @dev Opens the leveraged LP stake the callback later dumps. + function openPosition(address collateralAsset, uint256 collateral, uint256 debt) external { + collateralAsset.safeApprove(address(_POSITION_MANAGER), collateral); + _shares = uint256(_POSITION_MANAGER.deposit(collateral, debt)); + } + + /// @dev Approves the Request pull that follows the callback. + function approvePull(address request, uint256 amount) external { + _DEBT_ASSET.safeApprove(request, amount); + } + + function onRequestConsumed(Offer calldata, bytes calldata, uint256, uint256) external { + // The burn pulls the proportional debt repayment from this contract + _DEBT_ASSET.safeApprove(address(_POSITION_MANAGER), type(uint256).max); + _POSITION_MANAGER.burn(_shares, WithdrawalStrategy.PROPORTIONAL); + } + + function isValidSignature(bytes32, bytes calldata) external pure returns (bytes4) { + return 0x1626ba7e; + } +} + /// @title RetargetterAsyncTest /// @notice Full ASYNC integration tests for the Retargetter: happy paths in both directions /// (events and exact amounts), the loan clock, order wrappers, repayment and @@ -199,12 +239,12 @@ contract RetargetterAsyncTest is RetargetterBaseTest { function test_async_loanClock_startsAtFirstConsumeAndNeverResets() public { _seedPosition(10_000e18, 5_000e18); address request = _startAsync(6_000e18, 100); - (,,, uint40 startedAt,,,,) = retargetter.operation(); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); assertEq(uint256(startedAt), 0, "clock unset before the first consume"); vm.warp(block.timestamp + 5 days); _consume(request, 3_000e18, 30e18, 3_000e18); - (,,, startedAt,,,,) = retargetter.operation(); + (,,, startedAt,,,,,,,) = retargetter.operation(); assertEq(uint256(startedAt), block.timestamp, "first consume starts the clock"); uint256 clockOrigin = uint256(startedAt); @@ -212,7 +252,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { // the clock vm.warp(block.timestamp + 5 hours); _consume(request, 1_000e18, 10e18, 1_000e18); - (,,, startedAt,,,,) = retargetter.operation(); + (,,, startedAt,,,,,,,) = retargetter.operation(); assertEq(uint256(startedAt), clockOrigin, "second consume keeps the origin"); // Two days elapsed: paidTicks = floor((2 days + 1 day - 10 hours) / 1 day) = 2 @@ -289,7 +329,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { // Exactly 80 days remain before the deadline: the clock may still start vm.warp(operationStart + 10 days); _consume(request, 1_000e18, 10e18, 1_000e18); - (,,, uint40 startedAt,,,,) = retargetter.operation(); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); assertEq(uint256(startedAt), operationStart + 10 days, "clock started at the boundary"); } @@ -326,7 +366,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { function test_authorizeMinting_startsClockAndCountsTowardCap() public { _seedPosition(10_000e18, 5_000e18); address request = _startAsync(6_000e18, 100); - (,,, uint40 startedAt,,,,) = retargetter.operation(); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); assertEq(uint256(startedAt), 0, "clock unset before the authorization"); vm.prank(consumer); @@ -334,19 +374,24 @@ contract RetargetterAsyncTest is RetargetterBaseTest { emit IRetargetter.MintingAuthorized(request, broker, 2_000e18, 20e18); retargetter.authorizeMinting(broker, 2_000e18, 20e18); - (,,, startedAt,,,,) = retargetter.operation(); + (,,, startedAt,,,,,,,) = retargetter.operation(); assertEq(uint256(startedAt), block.timestamp, "authorization starts the loan clock"); assertEq(_outstandingAuthorizedPt(request), 2_000e18, "outstanding authorized principal"); (uint128 ptAuth, uint128 ytAuth) = IRequest(request).mintAuthorization(broker); assertEq(uint256(ptAuth), 2_000e18, "request-side principal authorization"); assertEq(uint256(ytAuth), 20e18, "request-side yield authorization"); - // The commitment occupies the principal cap: consume can fill up to it, not past it - uint256 cap = retargetter.maxPrincipal(address(positionManager)); + // The commitment occupies the principal cap (the gate sizes it on the operation's own + // 1% yield cap): consume can fill up to it, not past it. The offer is signed and + // approved so the revert comes from the post-call cap re-check, not the pull + uint256 cap = _gateCap(100); Offer memory offer = _createOffer(cap, cap / 200); + bytes memory signature = _signOffer(offer, request); + vm.prank(maker.addr); + debtToken.approve(request, cap); vm.prank(consumer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); - retargetter.consume(offer, "", cap - 2_000e18 + 1); + retargetter.consume(offer, signature, cap - 2_000e18 + 1); _consume(request, cap, cap / 200, cap - 2_000e18); } @@ -406,7 +451,8 @@ contract RetargetterAsyncTest is RetargetterBaseTest { function test_authorizeMinting_capAccountingAcrossAccounts() public { _seedPosition(10_000e18, 5_000e18); address request = _startAsync(6_000e18, 100); - uint256 cap = retargetter.maxPrincipal(address(positionManager)); + // The gate sizes the cap on the operation's own 1% yield cap + uint256 cap = _gateCap(100); _authorize(broker, 3_000e18, 0); assertEq(_outstandingAuthorizedPt(request), 3_000e18, "first authorization outstanding"); @@ -448,6 +494,66 @@ contract RetargetterAsyncTest is RetargetterBaseTest { assertEq(retargetter.authorizedAccounts().length, 16, "set back at capacity"); } + /// @notice A revocation that leaves the operation with no supply and no pending + /// authorization rewinds the loan clock to unstarted, so an economically empty + /// operation is not forced through a resolve-and-restart cycle by an expired + /// consumption window. + function test_authorizeMinting_fullRevocationRewindsClock() public { + _seedPosition(10_000e18, 5_000e18); + address request = _startAsync(4_000e18, 100); + + _authorize(broker, 1_000e18, 10e18); + uint256 firstOrigin = block.timestamp; + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); + assertEq(uint256(startedAt), firstOrigin, "clock started"); + + // Past the tick threshold the window is shut; the revocation still passes and rewinds + vm.warp(firstOrigin + DEFAULT_TICK_THRESHOLD + 1); + _authorize(broker, 0, 0); + (,,, startedAt,,,,,,,) = retargetter.operation(); + assertEq(uint256(startedAt), 0, "clock rewound to unstarted"); + (uint128 ptAuth,) = IRequest(request).mintAuthorization(broker); + assertEq(uint256(ptAuth), 0, "request-side authorization revoked"); + + // The window reopened: a fresh commitment starts a fresh clock + _authorize(broker, 500e18, 5e18); + (,,, startedAt,,,,,,,) = retargetter.operation(); + assertEq(uint256(startedAt), block.timestamp, "fresh origin"); + } + + /// @notice The clock survives a revocation while the operation still carries PT supply: + /// it only rewinds once economically empty. + function test_authorizeMinting_revocationKeepsClockWithSupply() public { + _seedPosition(10_000e18, 5_000e18); + address request = _startAsync(4_000e18, 100); + uint256 origin = block.timestamp; + + _consume(request, 1_000e18, 10e18, 1_000e18); + _authorize(broker, 500e18, 5e18); + _authorize(broker, 0, 0); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); + assertEq(uint256(startedAt), origin, "clock kept while PT supply lives"); + } + + /// @notice The clock survives a revocation while another authorization is still pending, + /// and rewinds once the last one is revoked. + function test_authorizeMinting_revocationKeepsClockWithPendingAuthorization() public { + _seedPosition(10_000e18, 5_000e18); + _startAsync(4_000e18, 100); + uint256 origin = block.timestamp; + address secondBroker = makeAddr("secondBroker"); + + _authorize(broker, 500e18, 5e18); + _authorize(secondBroker, 400e18, 4e18); + _authorize(broker, 0, 0); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); + assertEq(uint256(startedAt), origin, "clock kept while an authorization is pending"); + + _authorize(secondBroker, 0, 0); + (,,, startedAt,,,,,,,) = retargetter.operation(); + assertEq(uint256(startedAt), 0, "clock rewound once the last authorization is gone"); + } + /// @notice Pulling funds ends the funding round: every pending authorization is revoked and /// swept from the set, the revoked brokers' mints are no-ops, and neither consume /// nor a nonzero authorization can enter afterwards, threshold notwithstanding. @@ -512,7 +618,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { // The next operation starts with the window open address secondRequest = _startAsync(1_000e18, 100); _consume(secondRequest, 100e18, 1e18, 100e18); - (,,, uint40 startedAt,,,,) = retargetter.operation(); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); assertEq(uint256(startedAt), block.timestamp, "fresh loan clock on the next operation"); } @@ -602,7 +708,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { vm.prank(rebalancer); retargetter.create(order); - (,,,,,, Order memory stored, bool orderLive) = retargetter.operation(); + (,,,,,,,,, Order memory stored, bool orderLive) = retargetter.operation(); assertTrue(orderLive, "order live"); assertEq(uint8(stored.mode), uint8(Mode.DEPOSIT), "mode stored"); assertEq(stored.owner, address(retargetter), "owner rebuilt"); @@ -676,16 +782,38 @@ contract RetargetterAsyncTest is RetargetterBaseTest { emit IRetargetter.OrderCanceled(address(fund), order); retargetter.cancelOrder(); - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); assertFalse(orderLive, "order cleared"); // The slot is free again for a fresh order retargetter.create(_order(Mode.DEPOSIT, 100e18, 100e18, bytes32(uint256(12)))); - (,,,,,,, orderLive) = retargetter.operation(); + (,,,,,,,,,, orderLive) = retargetter.operation(); assertTrue(orderLive, "new order live"); vm.stopPrank(); } + /// @notice A fund whose cancel does not report the order EMPTY keeps the local order + /// stored: the Retargetter never forgets an order the fund still processes. + function test_cancelOrder_fundNotReportingEmpty_reverts() public { + StickyCancelMockFund stickyFund = new StickyCancelMockFund(address(debtToken), address(collateralToken)); + vm.prank(owner); + retargetter.setFund(address(stickyFund), true); + _seedPosition(10_000e18, 5_000e18); + vm.prank(rebalancer); + retargetter.startRetargetting( + address(positionManager), 4_000e18, 100, address(stickyFund), REQUEST_NAME, REQUEST_SYMBOL + ); + + vm.startPrank(rebalancer); + retargetter.create(_order(Mode.DEPOSIT, 100e18, 100e18, bytes32(uint256(14)))); + vm.expectRevert(LibRetargetterErrors.OrderPending.selector); + retargetter.cancelOrder(); + vm.stopPrank(); + + (,,,,,,,,,, bool orderLive) = retargetter.operation(); + assertTrue(orderLive, "order stays stored while the fund still processes it"); + } + function test_unlock_partialFill_keepsOrderLiveUntilFullyPaid() public { _startDefault(); _mintDebt(address(retargetter), 100e18); @@ -699,13 +827,13 @@ contract RetargetterAsyncTest is RetargetterBaseTest { fund.setPartialUnlockBps(5000); vm.prank(rebalancer); assertEq(retargetter.unlock(), 50e18, "first partial payout"); - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); assertTrue(orderLive, "order still live after a partial fill"); // The second unlock pays the rest and clears the order vm.prank(rebalancer); assertEq(retargetter.unlock(), 50e18, "second payout"); - (,,,,,,, orderLive) = retargetter.operation(); + (,,,,,,,,,, orderLive) = retargetter.operation(); assertFalse(orderLive, "order cleared once fully paid"); assertEq(collateralToken.balanceOf(address(retargetter)), 100e18, "full output received"); } @@ -726,7 +854,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { vm.prank(rebalancer); assertEq(retargetter.recoverOrder(), 100e18, "input recovered"); assertEq(debtToken.balanceOf(address(retargetter)), 100e18, "input back on the retargetter"); - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); assertFalse(orderLive, "order cleared after recovery"); } @@ -793,7 +921,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { vm.prank(rebalancer); retargetter.resolve(); assertFalse(retargetter.isActive(), "resolved"); - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); assertFalse(orderLive, "force-ended order cleared by resolve"); } @@ -878,7 +1006,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { address request = _startAsync(1_000e18, 100); // A one-wei-yield offer keeps the owed amount at the unpulled principal plus one wei _consume(request, 100e18, 1, 100e18); - (,,, uint40 startedAt,,,,) = retargetter.operation(); + (,,, uint40 startedAt,,,,,,,) = retargetter.operation(); assertGt(uint256(startedAt), 0, "clock running"); // An unminted leftover authorization dies with the repaid Request and must not survive // into the next operation @@ -899,6 +1027,9 @@ contract RetargetterAsyncTest is RetargetterBaseTest { uint40 clearedStartedAt, uint40 clearedRepaymentDeadline, uint16 operationMaxYieldBps, + uint32 clearedHorizon, + uint24 clearedTickDuration, + uint24 clearedTickThreshold, Order memory order, bool orderLive ) = retargetter.operation(); @@ -908,6 +1039,9 @@ contract RetargetterAsyncTest is RetargetterBaseTest { assertEq(uint256(clearedStartedAt), 0, "clock cleared"); assertEq(uint256(clearedRepaymentDeadline), 0, "repayment deadline cleared"); assertEq(uint256(operationMaxYieldBps), 0, "yield cap cleared"); + assertEq(uint256(clearedHorizon), 0, "horizon cleared"); + assertEq(uint256(clearedTickDuration), 0, "tick duration cleared"); + assertEq(uint256(clearedTickThreshold), 0, "tick threshold cleared"); assertFalse(orderLive, "no order"); assertEq(order.input, 0, "order input cleared"); assertEq(order.output, 0, "order output cleared"); @@ -1092,15 +1226,111 @@ contract RetargetterAsyncTest is RetargetterBaseTest { ); vm.stopPrank(); - // The live cap now sits below the consumed principal: the cumulative gate blocks any size + // The live cap now sits below the consumed principal: the cumulative gate blocks any + // size. Consume is already shut by the pulled funding round (its window check runs + // before the Request call), so the collapsed cap surfaces through the authorization gate (uint128 ptSupply,) = ITokenController(request).totalSupplies(); assertLt(retargetter.maxPrincipal(address(positionManager)), uint256(ptSupply), "cap collapsed"); Offer memory offer = _createOffer(100e18, 1e18); bytes memory signature = _signOffer(offer, request); vm.prank(consumer); - vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); + vm.expectRevert(LibRetargetterErrors.ConsumptionWindowClosed.selector); retargetter.consume(offer, signature, 100e18); + + vm.prank(consumer); + vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); + retargetter.authorizeMinting(broker, 100e18, 1e18); + } + + /// @notice A maker callback that shrinks the position mid-consume (here by dumping a large + /// LP stake) cannot leave the operation committed above the live principal cap: + /// the post-call re-check unwinds the whole consume, callback included. + function test_consume_makerCallbackShrinksCap_reverts() public { + _seedPosition(10_000e18, 5_000e18); + + // The maker holds two thirds of the position it will dump inside its callback (an LP + // exit is the simplest stand-in for anything that shrinks the cap mid-call, such as a + // public liquidation) + CapShrinkingMaker capMaker = new CapShrinkingMaker(IPositionManager(address(positionManager)), address(debtToken)); + vm.prank(owner); + positionManager.grantRoles(address(capMaker), _ROLE_MINTER); + _mintCollateral(address(capMaker), 20_000e18); + capMaker.openPosition(address(collateralToken), 20_000e18, 10_000e18); + + // Position 30,000 / 15,000 at the 0.7 target: the cap comfortably admits the principal. + // The maker keeps enough debt to both repay its callback burn and fund the Request pull + address request = _startAsync(12_000e18, 100); + _mintDebt(address(capMaker), 24_000e18); + capMaker.approvePull(request, 12_000e18); + + Offer memory offer = Offer({ + maker: address(capMaker), + amount: 12_000e18, + expectedReturn: 120e18, + nonce: 1, + expiration: block.timestamp + 1 days, + useCallback: true + }); + + // The callback burn drops the position back to 10,000 / 5,000, far below the committed + // principal: the cap re-check after the Request call reverts + vm.prank(consumer); + vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); + retargetter.consume(offer, "", 12_000e18); + } + + /// @notice A cap-sized operation keeps one-trip settlement headroom: after full deployment + /// the owed repayment is borrowable back through the rebalancer direction checks + /// (LTV lands at or below target, not above) and the operation settles. The cap is + /// now the quoter's one-trip bound, so this holds by construction; the exact + /// worst-case equality is pinned in the quoter unit test. + function test_async_capSizedOperation_oneTripSettlementHeadroom() public { + _seedPosition(10_000e18, 5_000e18); + fund.setSyncSettlement(true); + // The borrow leg funds the lenders' yield, a real NAV loss bounded by the yield; widen + // the position manager's loss tolerance so the loss knob is not the bottleneck + vm.prank(owner); + positionManager.setRebalanceConfig(1000, 0); + + // At the config's 10% cap the one-trip bound is exactly (0.7 * 10,000 - 5,000) / 0.4 + uint256 cap = _gateCap(DEFAULT_MAX_YIELD_BPS); + assertEq(cap, 5_000e18, "clean-number one-trip bound"); + + // The gate admits exactly the cap and rejects a single wei more + vm.prank(rebalancer); + vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); + retargetter.startRetargetting( + address(positionManager), cap + 1, DEFAULT_MAX_YIELD_BPS, address(fund), REQUEST_NAME, REQUEST_SYMBOL + ); + + address request = _startAsync(cap, DEFAULT_MAX_YIELD_BPS); + _consume(request, cap, cap / 10, cap); + vm.startPrank(rebalancer); + retargetter.pullRequestFunds(cap); + retargetter.create(_order(Mode.DEPOSIT, cap, cap, bytes32(uint256(31)))); + retargetter.commit(); + retargetter.unlock(); + vm.stopPrank(); + + // A few ticks in, the owed amount is the deployed principal plus its prorated yield + vm.warp(block.timestamp + 2 days); + uint256 owedAmount = retargetter.owed(); + + // One trip: supply the settled shares, borrow the owed amount back. The rebalancer is a + // non-owner, so the direction guard applies; it passes only because the resulting LTV is + // at or below target, which is exactly the headroom the one-trip cap guarantees + vm.startPrank(rebalancer); + retargetter.rebalance( + _rebalancingData2( + MAX_SENTINEL, 0, RebalancingOperationType.SUPPLY, MAX_SENTINEL, RebalancingOperationType.BORROW, owedAmount + ) + ); + assertLe(_currentLtv(), POSITION_MANAGER_LTV, "settled at or below target"); + retargetter.repay(); + retargetter.resolve(); + vm.stopPrank(); + assertFalse(retargetter.isActive(), "settled in one trip"); } /// @notice The bridge cannot be dissolved into the position: once Request principal has @@ -1181,7 +1411,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { reused.salt = bytes32(uint256(22)); vm.prank(rebalancer); retargetter.create(reused); - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); assertTrue(orderLive, "fresh-salt order created"); } diff --git a/test/manager/rebalancer/RetargetterBase.t.sol b/test/manager/rebalancer/RetargetterBase.t.sol index 3b615b55..0b999ebd 100644 --- a/test/manager/rebalancer/RetargetterBase.t.sol +++ b/test/manager/rebalancer/RetargetterBase.t.sol @@ -94,6 +94,7 @@ contract RetargetterBaseTest is PositionManagerBaseTest { retargetter.grantRoles(consumer, RETARGETTER_CONSUMER_ROLE); retargetter.setFund(address(fund), true); retargetter.setFlashLoanModule(address(flashLoanAdapter), true); + retargetter.setPositionManager(address(positionManager), true); positionManager.grantRoles(address(retargetter), _ROLE_REBALANCER); // One basis point absorbs the Morpho rounding dust, no cooldown between steps positionManager.setRebalanceConfig(1, 0); @@ -269,6 +270,19 @@ contract RetargetterBaseTest is PositionManagerBaseTest { RebalancingOperation({position: address(borrowPosition1), operationType: secondType, amount: secondAmount}); } + /// @dev The gate-side principal cap for an operation carrying `yieldCapBps`, on the + /// fixture's zero estimates: the buffered ideal capped by the quoter's one-trip + /// repayment bound, the same min Retargetter._maxPrincipal computes. + function _gateCap(uint16 yieldCapBps) internal view returns (uint256) { + uint256 collateralQuoted = positionManager.collateralAmountQuoted(); + uint256 debt = positionManager.debtAmount(); + (uint256 target,) = positionManager.config(); + uint256 buffered = retargetterQuoter.retargetPrincipal(collateralQuoted, debt, target, 0, 0, 0, 0, 0) + * (10_000 + DEFAULT_PRINCIPAL_BUFFER_BPS) / 10_000; + uint256 oneTrip = retargetterQuoter.ltvUpOneTripPrincipal(collateralQuoted, debt, target, yieldCapBps, 0, 0, 0); + return buffered < oneTrip ? buffered : oneTrip; + } + /// @dev Current aggregate LTV of the position manager (WAD; 0 when debt is 0). function _currentLtv() internal view returns (uint256) { uint256 debt = positionManager.debtAmount(); diff --git a/test/manager/rebalancer/RetargetterQuoter.t.sol b/test/manager/rebalancer/RetargetterQuoter.t.sol index 7f9621be..d8dbdb04 100644 --- a/test/manager/rebalancer/RetargetterQuoter.t.sol +++ b/test/manager/rebalancer/RetargetterQuoter.t.sol @@ -108,6 +108,58 @@ contract RetargetterQuoterTest is Test { assertEq(principal, uint256(5_000e18) * WAD / 0.1e18, "denominator is Yr alone"); } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* LTV-UP ONE-TRIP BOUND */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + function test_ltvUpOneTripPrincipal_zeroRateFixture() public view { + // Same numerator as the ideal formula with the flat yield cap in the divisor: + // x = (tau * K - D) / (1 + y - tau); K = 10_000, D = 5_000, tau = 0.7, y = 10%: + // x = 2_000 / 0.4 = 5_000 + uint256 principal = quoter.ltvUpOneTripPrincipal(10_000e18, 5_000e18, 0.7e18, 1000, 0, 0, 0); + assertEq(principal, 5_000e18, "one-trip bound"); + // The worst permitted repayment x * (1 + y) equals the post-supply borrow capacity at + // target, tau * (K + x) - D: the bound is exact, not merely sufficient + assertEq( + principal * 11 / 10, uint256(0.7e18).fullMulDiv(10_000e18 + principal, WAD) - 5_000e18, "one-trip exactness" + ); + // A zero yield cap collapses the bound to the zero-rate ideal principal + assertEq( + quoter.ltvUpOneTripPrincipal(10_000e18, 5_000e18, 0.7e18, 0, 0, 0, 0), + quoter.ltvUpPrincipal(10_000e18, 5_000e18, 0.7e18, 0, 0, 0, 0), + "zero cap equals the ideal" + ); + } + + function test_ltvUpOneTripPrincipal_extendedRatesFixture() public view { + // Debt drift and collateral yield over a 30-day window, 10% flat cap + uint256 k = 10_000e18; + uint256 d = 5_000e18; + uint256 tau = 0.7e18; + uint256 rb = 0.05e18; + uint256 yc = 0.03e18; + uint256 dt = 30 days; + + uint256 principal = quoter.ltvUpOneTripPrincipal(k, d, tau, 1000, rb, yc, dt); + + // Exact recomputation: the accrual terms match ltvUpPrincipal, only the divisor differs + uint256 numerator = _grownTarget(k, tau, yc, dt) - _driftedDebt(d, rb, dt); + assertEq(principal, numerator.fullMulDiv(WAD, WAD + 0.1e18 - tau), "extended formula"); + // The flat cap always undercuts the same-numerator ideal whose scaled Yr is below it + assertLt(principal, quoter.ltvUpPrincipal(k, d, tau, 0.04e18, rb, yc, dt), "below the estimated ideal"); + } + + function test_ltvUpOneTripPrincipal_returnsZeroAtOrAboveTarget() public view { + assertEq(quoter.ltvUpOneTripPrincipal(10_000e18, 7_000e18, 0.7e18, 1000, 0, 0, 0), 0, "at target"); + assertEq(quoter.ltvUpOneTripPrincipal(10_000e18, 8_000e18, 0.7e18, 1000, 0, 0, 0), 0, "above target"); + } + + function test_ltvUpOneTripPrincipal_revertsOnFullTargetWithZeroCap() public { + // targetLtv == WAD with a zero yield cap: denominator 1 + y - tau == 0 + vm.expectRevert(LibRetargetterErrors.InvalidParameters.selector); + quoter.ltvUpOneTripPrincipal(10_000e18, 5_000e18, WAD, 0, 0, 0, 0); + } + function testFuzz_ltvUpPrincipal_monotoneInDebt( uint256 collateral, uint256 targetLtv, diff --git a/test/manager/rebalancer/RetargetterRebalance.t.sol b/test/manager/rebalancer/RetargetterRebalance.t.sol index 9fbb358e..160eef26 100644 --- a/test/manager/rebalancer/RetargetterRebalance.t.sol +++ b/test/manager/rebalancer/RetargetterRebalance.t.sol @@ -760,11 +760,12 @@ contract RetargetterRebalanceTest is RetargetterBaseTest { /// collapses to zero and further consumption is blocked. function test_rebalance_principalCapSelfCorrects() public { _seedPosition(10_000e18, 5_000e18); - _startAsync(6_000e18, 100); + address request = _startAsync(6_000e18, 100); _setMaxRebalanceLoss(1000); _mintCollateral(address(retargetter), 6_000e18); - assertGt(retargetter.maxPrincipal(address(positionManager)), 6_000e18, "cap covers the announced principal"); + // The gate sizes the cap on the operation's own 1% yield cap + assertGt(_gateCap(100), 6_000e18, "cap covers the announced principal"); vm.prank(rebalancer); retargetter.rebalance( @@ -775,11 +776,15 @@ contract RetargetterRebalanceTest is RetargetterBaseTest { assertEq(retargetter.maxPrincipal(address(positionManager)), 0, "cap collapsed at target"); - // The principal gate rejects any further consume before the signature is even checked - Offer memory offer = _createOffer(1_000e18, 0); + // The principal gate rejects any further consume once the Request call returns; the + // offer is signed and approved so the revert comes from the cap re-check + Offer memory offer = _createOffer(1_000e18, 10e18); + bytes memory signature = _signOffer(offer, request); + vm.prank(maker.addr); + debtToken.approve(request, 1_000e18); vm.prank(consumer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); - retargetter.consume(offer, "", 1_000e18); + retargetter.consume(offer, signature, 1_000e18); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ diff --git a/test/manager/rebalancer/RetargetterSmoke.t.sol b/test/manager/rebalancer/RetargetterSmoke.t.sol index 07f4800f..cacf1425 100644 --- a/test/manager/rebalancer/RetargetterSmoke.t.sol +++ b/test/manager/rebalancer/RetargetterSmoke.t.sol @@ -13,7 +13,8 @@ contract RetargetterSmokeTest is RetargetterBaseTest { function test_smoke_asyncUpHappyPath() public { // LTV 0.5 with target 0.7: below target, LTV-up operation _seedPosition(10_000e18, 5_000e18); - uint256 cap = retargetter.maxPrincipal(address(positionManager)); + // The gate sizes the cap on the operation's own 1% yield cap + uint256 cap = _gateCap(100); assertGt(cap, 6_000e18, "cap sanity"); address request = _startAsync(6_000e18, 100); diff --git a/test/manager/rebalancer/RetargetterSync.t.sol b/test/manager/rebalancer/RetargetterSync.t.sol index 809e94f7..288988a1 100644 --- a/test/manager/rebalancer/RetargetterSync.t.sol +++ b/test/manager/rebalancer/RetargetterSync.t.sol @@ -139,7 +139,7 @@ contract WindowProbeFund is MockRetargetterFund { function cancel(Order calldata order) public override returns (State) { probed = true; sawActive = IRetargetter(msg.sender).isActive(); - (, sawRequest,,,,,,) = IRetargetter(msg.sender).operation(); + (, sawRequest,,,,,,,,,) = IRetargetter(msg.sender).operation(); return super.cancel(order); } } @@ -198,6 +198,9 @@ contract RetargetterSyncTest is RetargetterBaseTest { uint40 startedAt, uint40 repaymentDeadline, uint16 operationMaxYieldBps, + uint32 horizon, + uint24 tickDuration, + uint24 tickThreshold, Order memory storedOrder, bool orderLive ) = retargetter.operation(); @@ -207,6 +210,9 @@ contract RetargetterSyncTest is RetargetterBaseTest { assertEq(startedAt, 0, "startedAt zeroed"); assertEq(repaymentDeadline, 0, "repayment deadline zeroed"); assertEq(operationMaxYieldBps, 0, "yield cap zeroed"); + assertEq(horizon, 0, "horizon zeroed"); + assertEq(tickDuration, 0, "tick duration zeroed"); + assertEq(tickThreshold, 0, "tick threshold zeroed"); assertEq(storedOrder.input, 0, "order input zeroed"); assertEq(storedOrder.output, 0, "order output zeroed"); assertEq(storedOrder.salt, bytes32(0), "order salt zeroed"); @@ -247,6 +253,25 @@ contract RetargetterSyncTest is RetargetterBaseTest { assertLe(_currentLtv(), POSITION_MANAGER_LTV, "at or below target"); } + /// @notice A flash loan sized exactly at the SYNC principal cap (the zero-yield one-trip + /// bound) deploys, borrows its own repayment back and lands the position exactly + /// at target: the cap is the largest one-trip-settleable size, not just a limit. + function test_startSyncRetargetting_capSizedFlashLoanSettles() public { + _seedPosition(10_000e18, 5_000e18); + fund.setSyncSettlement(true); + + // (0.7 * 10,000 - 5,000) / (1 - 0.7): the flash repayment carries no yield + uint256 cap = _gateCap(0); + bytes[] memory calls = _syncUpPayload(cap, bytes32(uint256(2))); + + _startSync(cap, calls); + + _assertNoTrace(); + _assertZeroResidual(); + assertApproxEqAbs(_currentLtv(), POSITION_MANAGER_LTV, 1e6, "landed exactly at target"); + assertLe(_currentLtv(), POSITION_MANAGER_LTV, "never above target"); + } + function test_startSyncRetargetting_downHappyPathExactProceeds() public { // LTV 0.5 with target 0.3: above target, LTV-down direction _seedPosition(10_000e18, 5_000e18); @@ -426,12 +451,17 @@ contract RetargetterSyncTest is RetargetterBaseTest { function test_startSyncRetargetting_assetMismatchReverts() public { _seedPosition(10_000e18, 5_000e18); - // Reversed pair: the position manager's assets do not match the bound pair + // Reversed pair: whitelisting checks the pair, so the mismatched manager never gets in + // and the start gate rejects it as not whitelisted MismatchedPairPositionManager mismatchedPm = new MismatchedPairPositionManager(address(debtToken), address(collateralToken)); - vm.prank(rebalancer); + vm.prank(owner); vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); + retargetter.setPositionManager(address(mismatchedPm), true); + + vm.prank(rebalancer); + vm.expectRevert(LibRetargetterErrors.PositionManagerNotWhitelisted.selector); retargetter.startSyncRetargetting( address(mismatchedPm), address(flashLoanAdapter), 1_000e18, address(fund), new bytes[](0) ); @@ -439,7 +469,8 @@ contract RetargetterSyncTest is RetargetterBaseTest { function test_startSyncRetargetting_principalCapExceededReverts() public { _seedPosition(10_000e18, 5_000e18); - uint256 cap = retargetter.maxPrincipal(address(positionManager)); + // The SYNC gate sizes the cap on a zero yield cap: the flash repayment carries no yield + uint256 cap = _gateCap(0); vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); diff --git a/test/mock/manager/rebalancer/MockRetargetterFund.sol b/test/mock/manager/rebalancer/MockRetargetterFund.sol index 888e2a52..56ec2e05 100644 --- a/test/mock/manager/rebalancer/MockRetargetterFund.sol +++ b/test/mock/manager/rebalancer/MockRetargetterFund.sol @@ -230,3 +230,16 @@ contract ReenteringMockFund is MockRetargetterFund { return super.cancel(order); } } + +/// @title StickyCancelMockFund +/// @notice MockRetargetterFund whose cancel acknowledges the call but reports the order +/// still PROCESSING instead of EMPTY, mirroring a future fund that keeps processing +/// a canceled order; used to pin the Retargetter's cancel state check. +contract StickyCancelMockFund is MockRetargetterFund { + constructor(address asset_, address share_) MockRetargetterFund(asset_, share_) {} + + function cancel(Order calldata order) public view override returns (State) { + _checkOrder(order); + return State.PROCESSING; + } +} diff --git a/test/mock/manager/rebalancer/RetargetterHandler.sol b/test/mock/manager/rebalancer/RetargetterHandler.sol index de6de208..e86ffa18 100644 --- a/test/mock/manager/rebalancer/RetargetterHandler.sol +++ b/test/mock/manager/rebalancer/RetargetterHandler.sol @@ -11,12 +11,14 @@ import {Offer} from "src/interfaces/request/IOfferReceiver.sol"; import {Order, Mode} from "src/libs/funds/Order.sol"; import {IRequest} from "src/interfaces/request/IRequest.sol"; import {ITokenController} from "src/interfaces/request/ITokenController.sol"; -import {YieldEstimates} from "src/interfaces/manager/rebalancer/IRetargetter.sol"; +import {YieldEstimates, RetargetterConfig} from "src/interfaces/manager/rebalancer/IRetargetter.sol"; +import {IRetargetterQuoter} from "src/interfaces/manager/rebalancer/IRetargetterQuoter.sol"; import { RebalancingData, RebalancingOperation, RebalancingOperationType } from "src/interfaces/manager/base/IPositionManagerRebalancing.sol"; +import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// @title RetargetterHandler /// @notice Foundry invariant-test handler driving the Retargetter through bounded, fuzz-driven @@ -28,6 +30,8 @@ import { /// target call in try/catch so the fuzzer explores freely (fail_on_revert = false). /// Ghost variables record cross-call facts the invariant suite asserts afterwards. contract RetargetterHandler is Test { + using FixedPointMathLib for uint256; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* CONSTANTS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -169,8 +173,10 @@ contract RetargetterHandler is Test { /* GHOST TRACKING */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @dev Checks before and after every action that the active operation's startedAt never - /// changes once recorded nonzero; resets the recording when the operation resolves. + /// @dev Checks before and after every action that the active operation's startedAt only + /// ever moves in the two permitted ways: it never changes while the operation carries + /// an obligation, and it rewinds to zero only when the operation is economically empty + /// (no PT, no YT, no pending authorization). It resets the recording on resolve. modifier trackStartedAt() { _checkStartedAt(); _; @@ -183,14 +189,35 @@ contract RetargetterHandler is Test { ghostStartedAt = 0; return; } - (,,, uint40 startedAt,,,,) = retargetter.operation(); - if (ghostStartedAt == 0) { + (, address request,, uint40 startedAt,,,,,,,) = retargetter.operation(); + if (startedAt == 0) { + // A zero origin is legitimate before the clock starts and after a rewind, but a rewind + // may only happen once the operation is empty; a zero origin against live obligation + // would mean the clock was reset while a loan was outstanding + if (ghostStartedAt != 0 && !_operationEmpty(request)) startedAtMutated = true; + ghostStartedAt = 0; + } else if (ghostStartedAt == 0) { ghostStartedAt = startedAt; } else if (startedAt != ghostStartedAt) { + // Moved from one nonzero origin straight to another without rewinding through zero: an + // outstanding loan clock was repriced startedAtMutated = true; } } + /// @dev Whether the operation carries no obligation: no PT supply, no YT supply and no + /// account holding a live mint authorization. + function _operationEmpty(address request) internal view returns (bool) { + (uint128 ptSupply, uint128 ytSupply) = ITokenController(request).totalSupplies(); + if (ptSupply != 0 || ytSupply != 0) return false; + address[] memory accounts = retargetter.authorizedAccounts(); + for (uint256 i; i < accounts.length; ++i) { + (uint128 ptAmount, uint128 ytAmount) = IRequest(request).mintAuthorization(accounts[i]); + if (ptAmount != 0 || ytAmount != 0) return false; + } + return true; + } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ASYNC FLOW ACTIONS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -222,7 +249,7 @@ contract RetargetterHandler is Test { /// YieldTooHigh and success paths get exercised, and a partial or full fill. function act_consume(uint256 amountSeed, uint256 yieldSeed) external trackStartedAt { if (!retargetter.isActive()) return; - (, address request,,,, uint16 operationCap,,) = retargetter.operation(); + (, address request,,,, uint16 operationCap,,,,,) = retargetter.operation(); uint256 offerAmount = _bound(amountSeed, 1e18, 10_000e18); uint256 ratioBps = _bound(yieldSeed, 0, uint256(operationCap) * 2 + 100); @@ -246,12 +273,13 @@ contract RetargetterHandler is Test { vm.prank(consumer); try retargetter.consume(offer, signature, ptAmount) { consumeCount++; - // The position manager state did not move during consume, so the live cap must still - // cover the whole PT supply the gate just admitted + // The position manager state did not move during consume, so the live cap (sized on + // the operation's own yield cap, as the gate does) must still cover the whole PT + // supply the gate just admitted (uint128 ptSupply,) = ITokenController(request).totalSupplies(); - try retargetter.maxPrincipal(address(positionManager)) returns (uint256 capNow) { - if (uint256(ptSupply) > capNow) capViolatedAtConsume = true; - } catch {} + if (positionManager.collateralAmountQuoted() > 0 && uint256(ptSupply) > _gateCap(operationCap)) { + capViolatedAtConsume = true; + } } catch {} } @@ -259,7 +287,7 @@ contract RetargetterHandler is Test { /// around both caps so every gate and the revocation path get exercised. function act_authorizeMinting(uint256 amountSeed, uint256 yieldSeed) external trackStartedAt { if (!retargetter.isActive()) return; - (, address request,,,, uint16 operationCap,,) = retargetter.operation(); + (, address request,,,, uint16 operationCap,,,,,) = retargetter.operation(); if (request == address(0)) return; uint128 ptAmount = uint128(_bound(amountSeed, 0, 10_000e18)); @@ -270,18 +298,21 @@ contract RetargetterHandler is Test { try retargetter.authorizeMinting(brokerAddr, ptAmount, ytAmount) { if (ptAmount == 0 && ytAmount == 0) return; // The position did not move during the call, so the committed principal the gate just - // admitted must still fit under the live cap + // admitted must still fit under the live cap sized on the operation's own yield cap (uint128 ptSupply,) = ITokenController(request).totalSupplies(); - try retargetter.maxPrincipal(address(positionManager)) returns (uint256 capNow) { - if (uint256(ptSupply) + _outstandingAuthorizedPt(request) > capNow) capViolatedAtAuthorize = true; - } catch {} + if ( + positionManager.collateralAmountQuoted() > 0 + && uint256(ptSupply) + _outstandingAuthorizedPt(request) > _gateCap(operationCap) + ) { + capViolatedAtAuthorize = true; + } } catch {} } /// @notice The broker funds and mints whatever authorization it currently holds. function act_mintAuthorized() external trackStartedAt { if (!retargetter.isActive()) return; - (, address request,,,,,,) = retargetter.operation(); + (, address request,,,,,,,,,) = retargetter.operation(); if (request == address(0)) return; (uint128 ptAuth,) = IRequest(request).mintAuthorization(brokerAddr); if (ptAuth == 0) return; @@ -293,6 +324,38 @@ contract RetargetterHandler is Test { vm.stopPrank(); } + /// @dev The gate-side principal cap for an operation carrying `yieldCapBps`, mirroring + /// Retargetter._maxPrincipal: the buffered ideal principal capped, below target, by + /// the quoter's one-trip repayment bound sized on that yield cap. The gates size on + /// the operation's own cap, so the invariants must compare against this rather than + /// the config-ceiling view maxPrincipal. + function _gateCap(uint16 yieldCapBps) internal view returns (uint256) { + uint256 collateralQuoted = positionManager.collateralAmountQuoted(); + uint256 debt = positionManager.debtAmount(); + (uint256 target,) = positionManager.config(); + RetargetterConfig memory config_ = retargetter.config(); + address quoter = retargetter.quoter(); + YieldEstimates memory e = config_.estimates; + uint256 principal = IRetargetterQuoter(quoter) + .retargetPrincipal( + collateralQuoted, + debt, + target, + e.requestYieldRate, + e.borrowRate, + e.collateralYieldRate, + e.subscriptionDuration, + e.redemptionDuration + ); + uint256 buffered = principal * (BPS + config_.principalBufferBps) / BPS; + if (debt.divWad(collateralQuoted) >= target) return buffered; + uint256 oneTrip = IRetargetterQuoter(quoter) + .ltvUpOneTripPrincipal( + collateralQuoted, debt, target, yieldCapBps, e.borrowRate, e.collateralYieldRate, e.subscriptionDuration + ); + return buffered < oneTrip ? buffered : oneTrip; + } + /// @dev Sums the still-unminted authorized principal over the registered accounts, the same /// derivation the gates use. function _outstandingAuthorizedPt(address request) internal view returns (uint256 total) { @@ -306,7 +369,7 @@ contract RetargetterHandler is Test { /// @notice Pulls consumed funds from the Request, bounded by its balance. function act_pull(uint256 amountSeed) external trackStartedAt { if (!retargetter.isActive()) return; - (, address request,,,,,,) = retargetter.operation(); + (, address request,,,,,,,,,) = retargetter.operation(); uint256 requestBalance = debtToken.balanceOf(request); if (requestBalance == 0) return; uint256 amount = _bound(amountSeed, 1, requestBalance); @@ -324,7 +387,7 @@ contract RetargetterHandler is Test { /// @notice Resolves the operation; on success records the settlement-gate ghosts. function act_resolve() external trackStartedAt { if (!retargetter.isActive()) return; - (, address request,,,,,,) = retargetter.operation(); + (, address request,,,,,,,,,) = retargetter.operation(); vm.prank(rebalancer); try retargetter.resolve() { sawResolve = true; @@ -353,7 +416,7 @@ contract RetargetterHandler is Test { /// input token when it holds any (otherwise an arbitrary size that will fail commit). function act_createOrder(bool redeem, uint256 inputSeed) external trackStartedAt { if (!retargetter.isActive()) return; - (,,,,,,, bool orderLive) = retargetter.operation(); + (,,,,,,,,,, bool orderLive) = retargetter.operation(); if (orderLive) return; Mode mode = redeem ? Mode.REDEEM : Mode.DEPOSIT; uint256 available = @@ -459,7 +522,7 @@ contract RetargetterHandler is Test { /// @dev Whether the operation's Request is unrepaid and short of its deadline, the shape /// that arms the rebalance value-conservation gate. function _bridgeOutstanding() internal view returns (bool) { - (, address request,,, uint40 repaymentDeadline,,,) = retargetter.operation(); + (, address request,,, uint40 repaymentDeadline,,,,,,) = retargetter.operation(); return request != address(0) && block.timestamp < repaymentDeadline && !IRequest(request).isRepaid(); } @@ -567,7 +630,7 @@ contract RetargetterHandler is Test { /// @dev Whether the Retargetter currently stores a live order. function _orderLive() internal view returns (bool orderLive) { - (,,,,,,, orderLive) = retargetter.operation(); + (,,,,,,,,,, orderLive) = retargetter.operation(); } /// @dev Current owed amount of the active operation, zero when the view reverts. From 7d86752fe079cefc780e972f4b8980ad936cb68c Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Mon, 10 Aug 2026 14:44:04 +0200 Subject: [PATCH 2/7] refactor: bind Retargetter to a single owner-set PositionManager (3F-483) Replace the position-manager whitelist with a single instance binding: the Retargetter runs every operation against one owner-bound PositionManager instead of taking it as a caller-supplied argument. - Bind via initialize (optional positionManager_ param, threaded through createRetargetter) or setPositionManager(address), rebindable by the owner only while no operation is active. Both validate the contract and its asset-pair match. - Drop the positionManager argument from startRetargetting and startSyncRetargetting; they read the bound manager. maxPrincipal() drops its argument too. isPositionManager -> boundPositionManager view. - Error PositionManagerNotWhitelisted -> PositionManagerNotBound; event PositionManagerSet -> PositionManagerBound. Smaller than the whitelist (no mapping, no per-call arg): Retargetter runtime 23,949 B on the ci profile, 627 B under EIP-170 (was 24,136 / 440 B). Full suite 2349 passing, 74 invariants passing; the not-bound guard is mutation-checked. --- .../manager/rebalancer/IRetargetter.sol | 66 ++++--- .../rebalancer/LibRetargetterErrors.sol | 4 +- src/libs/manager/rebalancer/LibStorage.sol | 10 +- src/manager/rebalancer/Retargetter.sol | 127 +++++++------- src/manager/rebalancer/RetargetterFactory.sol | 18 +- test/manager/rebalancer/Retargetter.t.sol | 164 +++++++++--------- .../manager/rebalancer/RetargetterAsync.t.sol | 16 +- test/manager/rebalancer/RetargetterBase.t.sol | 10 +- .../rebalancer/RetargetterRebalance.t.sol | 2 +- .../manager/rebalancer/RetargetterSmoke.t.sol | 8 +- test/manager/rebalancer/RetargetterSync.t.sol | 110 ++++-------- .../manager/rebalancer/RetargetterHandler.sol | 10 +- 12 files changed, 248 insertions(+), 297 deletions(-) diff --git a/src/interfaces/manager/rebalancer/IRetargetter.sol b/src/interfaces/manager/rebalancer/IRetargetter.sol index bfdc1d39..10414b39 100644 --- a/src/interfaces/manager/rebalancer/IRetargetter.sol +++ b/src/interfaces/manager/rebalancer/IRetargetter.sol @@ -151,10 +151,9 @@ interface IRetargetter { /// @param whitelisted Whether the module is now whitelisted event FlashLoanModuleSet(address indexed module, bool whitelisted); - /// @notice Emitted when a position manager's whitelist entry is set. - /// @param positionManager The position manager - /// @param whitelisted Whether the position manager is now whitelisted - event PositionManagerSet(address indexed positionManager, bool whitelisted); + /// @notice Emitted when the owner binds the instance to a position manager. + /// @param positionManager The position manager the instance is now bound to + event PositionManagerBound(address indexed positionManager); /// @notice Emitted when the yield estimates are updated. /// @param estimates The new estimates @@ -168,11 +167,11 @@ interface IRetargetter { /* ASYNC FLOW */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Starts an asynchronous retargetting operation by deploying a fresh Request. + /// @notice Starts an asynchronous retargetting operation against the bound position manager + /// by deploying a fresh Request. /// @dev The repayment terms (horizon, tick duration, tick threshold) and the effective /// yield cap are snapshotted into the operation here; a later setConfig does not - /// reprice a started operation. - /// @param positionManager The owner-whitelisted position manager to retarget + /// reprice a started operation. Reverts if no position manager is bound. /// @param principal The intended principal, fail-fast checked against the cap /// @param maxYieldBps_ The caller's yield cap; the effective cap is min(config, caller) /// @param fund The whitelisted fund to bind to the operation @@ -180,7 +179,6 @@ interface IRetargetter { /// @param requestSymbol The base symbol for the Request's PT/YT tokens /// @return request The deployed Request function startRetargetting( - address positionManager, uint256 principal, uint16 maxYieldBps_, address fund, @@ -302,27 +300,23 @@ interface IRetargetter { /* SYNC FLOW */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Runs a whole retargetting operation atomically inside a flash loan. + /// @notice Runs a whole retargetting operation against the bound position manager atomically + /// inside a flash loan. /// @dev The steps are supplied as a multicall payload executed inside the flash-loan /// callback. The callback is bound to this exact payload (digest match) and requires /// the loan delivered in full before any step runs. At window close no order may be /// stored and the Retargetter's balances of both bound assets must be within the - /// configured residual tolerance. The position manager's rebalance cooldown is - /// enforced per rebalance call, so with a nonzero cooldown compatible legs must be - /// packed into a single rebalance step, and a route needing an intervening fund action - /// between two rebalances runs as separate transactions. - /// @param positionManager The owner-whitelisted position manager to retarget + /// configured residual tolerance. Reverts if no position manager is bound. The position + /// manager's rebalance cooldown is enforced per rebalance call, so with a nonzero + /// cooldown compatible legs must be packed into a single rebalance step, and a route + /// needing an intervening fund action between two rebalances runs as separate + /// transactions. /// @param flashLoanModule The whitelisted flash-loan module to borrow through /// @param flashLoanAmount The flash-loan size, checked against the principal cap /// @param fund The whitelisted fund the payload may create orders against /// @param data The step calls executed inside the flash-loan window - function startSyncRetargetting( - address positionManager, - address flashLoanModule, - uint256 flashLoanAmount, - address fund, - bytes[] calldata data - ) external; + function startSyncRetargetting(address flashLoanModule, uint256 flashLoanAmount, address fund, bytes[] calldata data) + external; /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* CONFIGURATION */ @@ -354,15 +348,14 @@ interface IRetargetter { /// @param whitelisted Whether the module should be whitelisted function setFlashLoanModule(address module, bool whitelisted) external; - /// @notice Sets a position manager's whitelist entry. - /// @dev Operations only bind to whitelisted position managers: every start-gate and - /// principal-cap read trusts what the bound address reports about itself, so the - /// binding is owner-curated like funds and flash-loan modules. Whitelisting checks - /// the position manager is a contract and its assets match the bound pair; removal - /// skips those checks but reverts while it is bound to the active operation. - /// @param positionManager The position manager - /// @param whitelisted Whether the position manager should be whitelisted - function setPositionManager(address positionManager, bool whitelisted) external; + /// @notice Binds the single position manager every operation runs against. + /// @dev Operations never take a position manager as an argument: they run against this + /// binding, so every start-gate and principal-cap read trusts an owner-curated + /// address rather than a caller-supplied one. Checks the position manager is a + /// contract and its assets match the bound pair, and reverts while an operation is + /// active (the binding can only be rotated while idle). Also settable at initialize. + /// @param positionManager The position manager to bind + function setPositionManager(address positionManager) external; /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* VIEWS */ @@ -412,7 +405,7 @@ interface IRetargetter { /// flash-loan window for the duration of its transaction). function isActive() external view returns (bool); - /// @notice Computes the current principal cap for a position manager. + /// @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 /// the one-trip repayment bound, so a cap-sized, fully deployed operation can always @@ -420,10 +413,10 @@ interface IRetargetter { /// This view sizes that bound on the config yield-cap ceiling, a safe floor for /// every permissible operation; the operation gates size it on the effective /// per-operation cap (zero for a SYNC window, whose flash repayment carries no - /// yield) and can admit up to that looser bound. - /// @param positionManager The position manager to size against + /// yield) and can admit up to that looser bound. Reverts if no position manager is + /// bound. /// @return The principal cap in debt-asset units - function maxPrincipal(address positionManager) external view returns (uint256); + function maxPrincipal() external view returns (uint256); /// @notice Returns the current amount owed on the active operation's Request. function owed() external view returns (uint256); @@ -447,9 +440,8 @@ interface IRetargetter { /// @param module The module to check function isFlashLoanModule(address module) external view returns (bool); - /// @notice Returns whether a position manager is whitelisted. - /// @param positionManager The position manager to check - function isPositionManager(address positionManager) external view returns (bool); + /// @notice Returns the bound position manager (zero when none is bound). + function boundPositionManager() external view returns (address); /// @notice Returns the quoter holding the sizing math. function quoter() external view returns (address); diff --git a/src/libs/manager/rebalancer/LibRetargetterErrors.sol b/src/libs/manager/rebalancer/LibRetargetterErrors.sol index 2933a572..d8d2aa21 100644 --- a/src/libs/manager/rebalancer/LibRetargetterErrors.sol +++ b/src/libs/manager/rebalancer/LibRetargetterErrors.sol @@ -36,8 +36,8 @@ library LibRetargetterErrors { /// @notice Thrown when binding a fund that is not owner-whitelisted. error FundNotWhitelisted(); - /// @notice Thrown when binding a position manager that is not owner-whitelisted. - error PositionManagerNotWhitelisted(); + /// @notice Thrown when starting an operation before the owner has bound a position manager. + error PositionManagerNotBound(); /// @notice Thrown when using a flash-loan module that is not owner-whitelisted. error ModuleNotWhitelisted(); diff --git a/src/libs/manager/rebalancer/LibStorage.sol b/src/libs/manager/rebalancer/LibStorage.sol index e8a4ac5a..68ef88bb 100644 --- a/src/libs/manager/rebalancer/LibStorage.sol +++ b/src/libs/manager/rebalancer/LibStorage.sol @@ -17,24 +17,26 @@ import { MIN_DEADLINE_BUFFER } from "./LibRetargetterConstants.sol"; -/// @notice The Retargetter's bound asset pair (namespace "retargetter.assets"). -/// @dev Written once at initialize, never mutated afterwards. +/// @notice The Retargetter's instance bindings (namespace "retargetter.assets"). +/// @dev The pair is written once at initialize and never mutated; the position manager is +/// owner-bound and rebindable while no operation is active. /// @param collateralAsset The collateral asset every position manager and fund must match /// @param debtAsset The debt asset every position manager, fund and Request must match +/// @param positionManager The single position manager every operation runs against, bound by +/// the owner (its assets are checked against the pair at bind time) struct RetargetterAssets { address collateralAsset; address debtAsset; + address positionManager; } /// @notice The Retargetter's whitelists (namespace "retargetter.whitelists"). /// @dev Plain mappings without enumeration; indexers use the add/remove events. /// @param funds The owner-approved venues /// @param flashLoanModules The owner-approved flash-loan adapters -/// @param positionManagers The owner-approved position managers an operation may bind to struct RetargetterWhitelists { mapping(address fund => bool) funds; mapping(address module => bool) flashLoanModules; - mapping(address positionManager => bool) positionManagers; } /// @notice The one in-flight operation (namespace "retargetter.operation"). diff --git a/src/manager/rebalancer/Retargetter.sol b/src/manager/rebalancer/Retargetter.sol index 76d86b4a..024c8ead 100644 --- a/src/manager/rebalancer/Retargetter.sol +++ b/src/manager/rebalancer/Retargetter.sol @@ -16,12 +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 { - LibStorage, - RetargetterAssets, - RetargetterOperation, - RetargetterWhitelists -} from "../../libs/manager/rebalancer/LibStorage.sol"; +import {LibStorage, RetargetterAssets, RetargetterOperation} from "../../libs/manager/rebalancer/LibStorage.sol"; import { REBALANCER_ROLE, CONSUMER_ROLE, @@ -166,17 +161,24 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /* INITIALIZATION */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Initializes the proxy instance and binds it to its asset pair. - /// @dev The pair is never mutable afterwards. Whitelists start empty; zero estimates are + /// @notice Initializes the proxy instance, binds it to its asset pair, and optionally binds + /// its position manager. + /// @dev The pair is never mutable afterwards. A nonzero positionManager_ is bound here (its + /// assets are checked against the pair); a zero value leaves the instance unbound until + /// the owner calls {setPositionManager}. Whitelists start empty; zero estimates are /// valid and degrade the principal cap to the zero-rate ideal formula. /// @param owner_ The address that will own the instance /// @param collateralAsset_ The collateral asset of the bound pair /// @param debtAsset_ The debt asset of the bound pair + /// @param positionManager_ The position manager to bind, or the zero address to bind later /// @param config_ The initial configuration, validated against the documented bounds - function initialize(address owner_, address collateralAsset_, address debtAsset_, RetargetterConfig calldata config_) - external - initializer - { + function initialize( + address owner_, + address collateralAsset_, + address debtAsset_, + address positionManager_, + RetargetterConfig calldata config_ + ) external initializer { owner_.checkNotZero(); collateralAsset_.checkContract(); debtAsset_.checkContract(); @@ -185,6 +187,8 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali RetargetterAssets storage assets_ = LibStorage.assetsStorage(); assets_.collateralAsset = collateralAsset_; assets_.debtAsset = debtAsset_; + // Optional bind at init; the owner can (re)bind later while no operation is active + if (positionManager_ != address(0)) _setPositionManager(positionManager_); _initializeOwner(owner_); } @@ -206,20 +210,23 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// operation storage (the Request does not expose it) so the loan clock can only /// start while at least MIN_DEADLINE_BUFFER remains before it; see /// {LibStorage.checkConsumptionWindow}. The Retargetter becomes the Request's owner, - /// puller and consumer. + /// puller and consumer. The operation runs against the bound position manager. function startRetargetting( - address positionManager, uint256 principal, uint16 maxYieldBps_, address fund, string calldata requestName, string calldata requestSymbol ) external onlyOwnerOrRebalancer nonReentrant returns (address request) { - uint16 configCap = LibStorage.configStorage().maxYieldBps; - uint16 effectiveYieldCap = maxYieldBps_ < configCap ? maxYieldBps_ : configCap; + // Block-scoped so configCap does not deepen the stack for the rest of the function + uint16 effectiveYieldCap; + { + uint16 configCap = LibStorage.configStorage().maxYieldBps; + effectiveYieldCap = maxYieldBps_ < configCap ? maxYieldBps_ : configCap; + } RetargetterOperation storage operation_ = LibStorage.operationStorage(); - _checkStart(operation_, positionManager, fund, principal, effectiveYieldCap); + address positionManager = _checkStart(operation_, fund, principal, effectiveYieldCap); // Safe: block.timestamp + 90 days fits in uint40 for ~35,000 years // forge-lint: disable-next-line(unsafe-typecast) @@ -586,17 +593,14 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// window close, so nothing persists past the transaction. Atomicity is the /// settlement invariant: either the whole retarget lands (loan repaid, no stored /// order, residual within the configured tolerance) or the transaction reverts. - function startSyncRetargetting( - address positionManager, - address flashLoanModule, - uint256 flashLoanAmount, - address fund, - bytes[] calldata data - ) external onlyOwnerOrRebalancer { + function startSyncRetargetting(address flashLoanModule, uint256 flashLoanAmount, address fund, bytes[] calldata data) + external + onlyOwnerOrRebalancer + { RetargetterOperation storage operation_ = LibStorage.operationStorage(); // The flash repayment carries no yield, so the window's principal cap is sized on a // zero yield cap (the pure one-trip headroom bound) - _checkStart(operation_, positionManager, fund, flashLoanAmount, 0); + address positionManager = _checkStart(operation_, fund, flashLoanAmount, 0); if (!LibStorage.whitelistsStorage().flashLoanModules[flashLoanModule]) { revert LibRetargetterErrors.ModuleNotWhitelisted(); } @@ -730,19 +734,13 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali } /// @inheritdoc IRetargetter - /// @dev Mirrors setFund: the pair is checked once here instead of at every operation - /// start, and removal is blocked while the position manager is bound to the active - /// operation. Every start-gate and principal-cap read trusts what the bound address - /// reports about itself, which is why the binding is owner-curated. - function setPositionManager(address positionManager, bool whitelisted) external onlyOwner nonReentrant { - if (whitelisted) { - positionManager.checkContract(); - _checkPair(positionManager); - } else if (LibStorage.operationStorage().positionManager == positionManager) { - revert LibRetargetterErrors.OperationActive(); - } - LibStorage.whitelistsStorage().positionManagers[positionManager] = whitelisted; - emit PositionManagerSet(positionManager, whitelisted); + /// @dev Binds the single position manager every operation runs against. Rebindable only + /// while no operation is active, so a live operation's binding never moves under it. + /// Every start-gate and principal-cap read trusts what the bound address reports about + /// itself, which is why the binding is owner-curated rather than caller-supplied. + function setPositionManager(address positionManager) external onlyOwner nonReentrant { + if (LibStorage.operationStorage().positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); + _setPositionManager(positionManager); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -799,9 +797,9 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// carry, so this view is a safe floor for every permissible operation; the operation /// gates size on the effective per-operation cap instead (zero for a SYNC window, /// whose flash repayment carries no yield) and can admit up to that looser bound. - /// See {_maxPrincipal} for the formula. - function maxPrincipal(address positionManager) public view returns (uint256) { - return _maxPrincipal(positionManager, LibStorage.configStorage().maxYieldBps); + /// See {_maxPrincipal} for the formula. Reverts if no position manager is bound. + function maxPrincipal() public view returns (uint256) { + return _maxPrincipal(LibStorage.assetsStorage().positionManager, LibStorage.configStorage().maxYieldBps); } /// @inheritdoc IRetargetter @@ -842,8 +840,8 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali } /// @inheritdoc IRetargetter - function isPositionManager(address positionManager) external view returns (bool) { - return LibStorage.whitelistsStorage().positionManagers[positionManager]; + function boundPositionManager() external view returns (address) { + return LibStorage.assetsStorage().positionManager; } /// @inheritdoc IRetargetter @@ -881,27 +879,34 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali emit ConfigSet(config_); } - /// @dev Shared start gate of both entry points. One operation at a time: the position - /// manager doubles as the active flag, and a SYNC window keeps it populated, so this - /// also locks out starts smuggled into a window payload. No leftover order, - /// whitelisted position manager (its pair was checked at setPositionManager), + /// @dev Validates and stores the bound position manager, emitting {PositionManagerBound}. + /// Checks it is a contract and its assets match the bound pair, so every later + /// start-gate and cap read can trust the binding. Shared by {initialize} and + /// {setPositionManager}; the active-operation guard lives in the external setter. + function _setPositionManager(address positionManager) internal { + positionManager.checkContract(); + _checkPair(positionManager); + LibStorage.assetsStorage().positionManager = positionManager; + emit PositionManagerBound(positionManager); + } + + /// @dev Shared start gate of both entry points, returning the bound position manager. One + /// operation at a time: the operation's position manager doubles as the active flag, + /// and a SYNC window keeps it populated, so this also locks out starts smuggled into a + /// window payload. Requires a bound position manager (its pair was checked at bind), a /// whitelisted fund, and the requested amount within the live principal cap sized on - /// the operation's yield cap (fail-fast for ASYNC, where the binding check re-runs at - /// every consume; zero for SYNC, whose flash repayment carries no yield). - function _checkStart( - RetargetterOperation storage operation_, - address positionManager, - address fund, - uint256 amount, - uint16 yieldCapBps - ) internal view { + /// the operation's yield cap (fail-fast for ASYNC, where the cap re-runs at every + /// consume; zero for SYNC, whose flash repayment carries no yield). + function _checkStart(RetargetterOperation storage operation_, address fund, uint256 amount, uint16 yieldCapBps) + internal + view + returns (address positionManager) + { if (operation_.positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); if (operation_.orderLive) revert LibRetargetterErrors.OrderActive(); - RetargetterWhitelists storage whitelists_ = LibStorage.whitelistsStorage(); - if (!whitelists_.positionManagers[positionManager]) { - revert LibRetargetterErrors.PositionManagerNotWhitelisted(); - } - if (!whitelists_.funds[fund]) revert LibRetargetterErrors.FundNotWhitelisted(); + positionManager = LibStorage.assetsStorage().positionManager; + if (positionManager == address(0)) revert LibRetargetterErrors.PositionManagerNotBound(); + if (!LibStorage.whitelistsStorage().funds[fund]) revert LibRetargetterErrors.FundNotWhitelisted(); if (amount > _maxPrincipal(positionManager, yieldCapBps)) revert LibRetargetterErrors.PrincipalCapExceeded(); } diff --git a/src/manager/rebalancer/RetargetterFactory.sol b/src/manager/rebalancer/RetargetterFactory.sol index 0cb1ac22..964b9103 100644 --- a/src/manager/rebalancer/RetargetterFactory.sol +++ b/src/manager/rebalancer/RetargetterFactory.sol @@ -30,11 +30,12 @@ import {LibClone} from "lib/solady/src/utils/LibClone.sol"; /// 3. Deploy the MorphoFlashLoanAdapter once per chain (its constructor takes Morpho /// Blue's address). /// 4. The instance owner grants the Retargetter's own REBALANCER_ROLE (Solady _ROLE_0, -/// bit value 1) and CONSUMER_ROLE (Solady _ROLE_1, bit value 2), whitelists funds, -/// the adapter and the served PositionManagers, and sets the yield estimates. Grant -/// a PositionManager's REBALANCER_ROLE to at most one Retargetter: the -/// one-operation-at-a-time guard is per instance, so a second grantee could admit -/// the full principal cap on its own. +/// bit value 1) and CONSUMER_ROLE (Solady _ROLE_1, bit value 2), whitelists funds +/// and the adapter, binds the served PositionManager (at creation or via +/// setPositionManager), and sets the yield estimates. Each instance runs against one +/// bound PositionManager; grant a PositionManager's REBALANCER_ROLE to at most one +/// Retargetter, since the one-operation-at-a-time guard is per instance and a second +/// bound Retargetter could admit the full principal cap on its own. contract RetargetterFactory { using LibClone for address; @@ -84,23 +85,26 @@ contract RetargetterFactory { /* FACTORY METHODS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Creates a new Retargetter proxy bound to an asset pair. + /// @notice Creates a new Retargetter proxy bound to an asset pair and, optionally, a + /// position manager. /// @dev Deploys an ERC1967 beacon proxy and initializes it atomically. Emits a /// {RetargetterCreated} event. /// @param owner The address that will own the Retargetter /// @param collateralAsset The collateral asset of the bound pair /// @param debtAsset The debt asset of the bound pair + /// @param positionManager The position manager to bind, or the zero address to bind later /// @param config The initial configuration /// @return retargetter The address of the newly deployed Retargetter proxy function createRetargetter( address owner, address collateralAsset, address debtAsset, + address positionManager, RetargetterConfig calldata config ) external returns (address retargetter) { retargetter = RETARGETTER_BEACON.deployERC1967BeaconProxy(); - Retargetter(retargetter).initialize(owner, collateralAsset, debtAsset, config); + Retargetter(retargetter).initialize(owner, collateralAsset, debtAsset, positionManager, config); _isRetargetter[retargetter] = true; diff --git a/test/manager/rebalancer/Retargetter.t.sol b/test/manager/rebalancer/Retargetter.t.sol index f5b60786..c0f2e25d 100644 --- a/test/manager/rebalancer/Retargetter.t.sol +++ b/test/manager/rebalancer/Retargetter.t.sol @@ -44,8 +44,9 @@ contract RetargetterTest is RetargetterBaseTest { /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ function test_factory_createRetargetterDeploysInitializesAndRegisters() public { - address instance = - retargetterFactory.createRetargetter(owner, address(collateralToken), address(debtToken), _defaultConfig()); + address instance = retargetterFactory.createRetargetter( + owner, address(collateralToken), address(debtToken), address(0), _defaultConfig() + ); assertGt(instance.code.length, 0, "proxy deployed"); assertTrue(retargetterFactory.isRetargetter(instance), "registered"); @@ -56,20 +57,41 @@ contract RetargetterTest is RetargetterBaseTest { assertEq(collateralAsset, address(collateralToken), "collateral asset initialized"); assertEq(debtAsset, address(debtToken), "debt asset initialized"); assertEq(Retargetter(instance).owner(), owner, "owner initialized"); + assertEq(Retargetter(instance).boundPositionManager(), address(0), "unbound when created with the zero address"); + } + + function test_factory_createRetargetterBindsPositionManagerAtInit() public { + // Passing a position manager at creation binds it through the init path + address instance = retargetterFactory.createRetargetter( + owner, address(collateralToken), address(debtToken), address(positionManager), _defaultConfig() + ); + assertEq(Retargetter(instance).boundPositionManager(), address(positionManager), "bound at init"); + } + + function test_factory_createRetargetterRevertsOnPairMismatchAtInit() public { + // The init bind runs the same pair check as the setter + MockERC20 otherDebt = new MockERC20("Other Debt", "ODEBT", 18); + vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); + retargetterFactory.createRetargetter( + owner, address(collateralToken), address(otherDebt), address(positionManager), _defaultConfig() + ); } function test_factory_createRetargetterEmitsEvent() public { // The proxy address is not known ahead of the call, so topic 1 is unchecked vm.expectEmit(false, true, true, true, address(retargetterFactory)); emit RetargetterFactory.RetargetterCreated(address(0), owner, address(collateralToken), address(debtToken)); - retargetterFactory.createRetargetter(owner, address(collateralToken), address(debtToken), _defaultConfig()); + retargetterFactory.createRetargetter( + owner, address(collateralToken), address(debtToken), address(0), _defaultConfig() + ); } function test_factory_permissionlessCreation() public { address randomCreator = makeAddr("randomCreator"); vm.prank(randomCreator); - address instance = - retargetterFactory.createRetargetter(user, address(collateralToken), address(debtToken), _defaultConfig()); + address instance = retargetterFactory.createRetargetter( + user, address(collateralToken), address(debtToken), address(0), _defaultConfig() + ); assertTrue(retargetterFactory.isRetargetter(instance), "registered"); assertEq(Retargetter(instance).owner(), user, "owner is the passed owner, not the creator"); @@ -90,7 +112,7 @@ contract RetargetterTest is RetargetterBaseTest { Retargetter(UpgradeableBeacon(retargetterFactory.RETARGETTER_BEACON()).implementation()); vm.expectRevert(Initializable.InvalidInitialization.selector); - implementation.initialize(owner, address(collateralToken), address(debtToken), _defaultConfig()); + implementation.initialize(owner, address(collateralToken), address(debtToken), address(0), _defaultConfig()); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -136,27 +158,29 @@ contract RetargetterTest is RetargetterBaseTest { function test_initialize_revertZeroOwner() public { vm.expectRevert(LibCommonErrors.AddressZero.selector); - retargetterFactory.createRetargetter(address(0), address(collateralToken), address(debtToken), _defaultConfig()); + retargetterFactory.createRetargetter( + address(0), address(collateralToken), address(debtToken), address(0), _defaultConfig() + ); } function test_initialize_revertNonContractAssets() public { address nonContract = makeAddr("nonContract"); vm.expectRevert(abi.encodeWithSelector(LibCommonErrors.InvalidContract.selector, nonContract)); - retargetterFactory.createRetargetter(owner, nonContract, address(debtToken), _defaultConfig()); + retargetterFactory.createRetargetter(owner, nonContract, address(debtToken), address(0), _defaultConfig()); vm.expectRevert(abi.encodeWithSelector(LibCommonErrors.InvalidContract.selector, nonContract)); - retargetterFactory.createRetargetter(owner, address(collateralToken), nonContract, _defaultConfig()); + retargetterFactory.createRetargetter(owner, address(collateralToken), nonContract, address(0), _defaultConfig()); } function test_initialize_revertIdenticalAssets() public { vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); - retargetterFactory.createRetargetter(owner, address(debtToken), address(debtToken), _defaultConfig()); + retargetterFactory.createRetargetter(owner, address(debtToken), address(debtToken), address(0), _defaultConfig()); } function test_initialize_revertDoubleInitialize() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - retargetter.initialize(owner, address(collateralToken), address(debtToken), _defaultConfig()); + retargetter.initialize(owner, address(collateralToken), address(debtToken), address(0), _defaultConfig()); } function test_initialize_revertConfigBoundViolations() public { @@ -341,7 +365,7 @@ contract RetargetterTest is RetargetterBaseTest { // Zero-estimate cap: the one-trip bound (target * K - D) / (1 + yieldCap - target) // undercuts the buffered ideal (see test_maxPrincipal_upBranchZeroEstimates) - uint256 capBefore = retargetter.maxPrincipal(address(positionManager)); + uint256 capBefore = retargetter.maxPrincipal(); assertEq( capBefore, (POSITION_MANAGER_LTV * collateralQuoted / WAD - debt) * WAD / yieldCapDenominator, "zero-estimate cap" ); @@ -361,7 +385,7 @@ contract RetargetterTest is RetargetterBaseTest { retargetter.setEstimates(estimates); // The drifted debt D * 1.1 tightens both formulas; the one-trip bound still binds - uint256 capAfter = retargetter.maxPrincipal(address(positionManager)); + uint256 capAfter = retargetter.maxPrincipal(); uint256 driftedDebt = debt * (WAD + 0.1e18) / WAD; assertEq( capAfter, @@ -490,15 +514,15 @@ contract RetargetterTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(Ownable.Unauthorized.selector); - retargetter.setPositionManager(address(positionManager), false); + retargetter.setPositionManager(address(positionManager)); vm.prank(owner); vm.expectRevert(abi.encodeWithSelector(LibCommonErrors.InvalidContract.selector, stranger)); - retargetter.setPositionManager(stranger, true); + retargetter.setPositionManager(stranger); } function test_setPositionManager_revertAssetMismatch() public { - // A position manager on a different asset pair never gets whitelisted + // A position manager on a different asset pair cannot be bound MockERC20 otherCollateral = new MockERC20("Other Collateral", "OCOLL", 18); MockERC20 otherDebt = new MockERC20("Other Debt", "ODEBT", 18); PositionManager otherPositionManager = PositionManager(LibClone.clone(address(new PositionManager()))); @@ -518,13 +542,14 @@ contract RetargetterTest is RetargetterBaseTest { vm.prank(owner); vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); - retargetter.setPositionManager(address(otherPositionManager), true); + retargetter.setPositionManager(address(otherPositionManager)); } - function test_setPositionManager_emitsAndWhitelists() public { - assertTrue(retargetter.isPositionManager(address(positionManager)), "fixture manager whitelisted"); + function test_setPositionManager_rebindsWhileIdle() public { + // The fixture bound its manager at creation + assertEq(retargetter.boundPositionManager(), address(positionManager), "fixture manager bound"); - // A fresh manager on the bound pair whitelists cleanly + // A fresh manager on the bound pair rebinds cleanly while idle PositionManager second = PositionManager(LibClone.clone(address(new PositionManager()))); second.initialize( owner, @@ -539,35 +564,22 @@ contract RetargetterTest is RetargetterBaseTest { 0, 0 ); - assertFalse(retargetter.isPositionManager(address(second)), "not whitelisted before"); - - vm.prank(owner); - vm.expectEmit(address(retargetter)); - emit IRetargetter.PositionManagerSet(address(second), true); - retargetter.setPositionManager(address(second), true); - assertTrue(retargetter.isPositionManager(address(second)), "whitelisted after"); - // Removal skips the pair check and emits the same event, flag down vm.prank(owner); vm.expectEmit(address(retargetter)); - emit IRetargetter.PositionManagerSet(address(second), false); - retargetter.setPositionManager(address(second), false); - assertFalse(retargetter.isPositionManager(address(second)), "removed after"); + emit IRetargetter.PositionManagerBound(address(second)); + retargetter.setPositionManager(address(second)); + assertEq(retargetter.boundPositionManager(), address(second), "rebound to the new manager"); } - function test_setPositionManager_revertOperationActiveForBoundManager() public { + function test_setPositionManager_revertOperationActive() public { _seedPosition(10_000e18, 5_000e18); _startAsync(4_000e18, 100); - // The operation's manager cannot be removed while the operation is active + // The binding cannot move while an operation is active vm.prank(owner); vm.expectRevert(LibRetargetterErrors.OperationActive.selector); - retargetter.setPositionManager(address(positionManager), false); - - // Re-whitelisting the bound manager is a harmless no-op - vm.prank(owner); - retargetter.setPositionManager(address(positionManager), true); - assertTrue(retargetter.isPositionManager(address(positionManager)), "still whitelisted"); + retargetter.setPositionManager(address(positionManager)); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -672,7 +684,7 @@ contract RetargetterTest is RetargetterBaseTest { function test_maxPrincipal_revertEmptyPosition() public { vm.expectRevert(LibRetargetterErrors.EmptyPosition.selector); - retargetter.maxPrincipal(address(positionManager)); + retargetter.maxPrincipal(); } function test_maxPrincipal_revertBadDebtPosition() public { @@ -681,14 +693,14 @@ contract RetargetterTest is RetargetterBaseTest { oracle.setPrice(0); vm.expectRevert(abi.encodeWithSelector(LibRetargetterErrors.BadDebtPosition.selector, address(positionManager))); - retargetter.maxPrincipal(address(positionManager)); + retargetter.maxPrincipal(); } function test_maxPrincipal_zeroExactlyAtTarget() public { // LTV 7000/10000 == the 0.7 target: neither direction applies, the cap is zero _seedPosition(10_000e18, 7_000e18); assertEq(_currentLtv(), POSITION_MANAGER_LTV, "position exactly at target"); - assertEq(retargetter.maxPrincipal(address(positionManager)), 0, "cap is zero at target"); + assertEq(retargetter.maxPrincipal(), 0, "cap is zero at target"); } function test_maxPrincipal_upBranchZeroEstimates() public { @@ -704,7 +716,7 @@ contract RetargetterTest is RetargetterBaseTest { uint256 oneTrip = numerator * WAD / (WAD + uint256(DEFAULT_MAX_YIELD_BPS) * WAD / BPS - POSITION_MANAGER_LTV); assertLt(oneTrip, buffered, "the one-trip bound binds"); - assertEq(retargetter.maxPrincipal(address(positionManager)), oneTrip, "up cap"); + assertEq(retargetter.maxPrincipal(), oneTrip, "up cap"); } function test_maxPrincipal_downBranchZeroEstimates() public { @@ -719,7 +731,7 @@ contract RetargetterTest is RetargetterBaseTest { uint256 idealPrincipal = (debt - target * collateralQuoted / WAD) * WAD / (WAD - target); uint256 expectedCap = idealPrincipal * (BPS + DEFAULT_PRINCIPAL_BUFFER_BPS) / BPS; - assertEq(retargetter.maxPrincipal(address(positionManager)), expectedCap, "down cap"); + assertEq(retargetter.maxPrincipal(), expectedCap, "down cap"); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -732,7 +744,7 @@ contract RetargetterTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OperationActive.selector); - retargetter.startRetargetting(address(positionManager), 1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + retargetter.startRetargetting(1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); } function test_startRetargetting_revertFundNotWhitelisted() public { @@ -740,46 +752,35 @@ contract RetargetterTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.FundNotWhitelisted.selector); - retargetter.startRetargetting( - address(positionManager), 1e18, 100, address(strangerFund), REQUEST_NAME, REQUEST_SYMBOL - ); + retargetter.startRetargetting(1e18, 100, address(strangerFund), REQUEST_NAME, REQUEST_SYMBOL); } - function test_startRetargetting_revertPositionManagerNotWhitelisted() public { - // A position manager on the right pair that the owner never whitelisted - PositionManager strangerPositionManager = PositionManager(LibClone.clone(address(new PositionManager()))); - strangerPositionManager.initialize( - owner, - PositionManagerMetadata({ - name: "Stranger Position Manager", - symbol: "SPMS", - collateralAsset: address(collateralToken), - debtAsset: address(debtToken) - }), - POSITION_MANAGER_LTV, - address(0), - 0, - 0 + function test_startRetargetting_revertPositionManagerNotBound() public { + // A fresh instance created without a bound position manager (address(0) at init) + Retargetter unbound = Retargetter( + retargetterFactory.createRetargetter( + owner, address(collateralToken), address(debtToken), address(0), _defaultConfig() + ) ); + vm.prank(owner); + unbound.grantRoles(rebalancer, RETARGETTER_REBALANCER_ROLE); + vm.prank(owner); + unbound.setFund(address(fund), true); vm.prank(rebalancer); - vm.expectRevert(LibRetargetterErrors.PositionManagerNotWhitelisted.selector); - retargetter.startRetargetting( - address(strangerPositionManager), 1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL - ); + vm.expectRevert(LibRetargetterErrors.PositionManagerNotBound.selector); + unbound.startRetargetting(1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); } function test_startRetargetting_revertPrincipalCapExceeded() public { _seedPosition(10_000e18, 5_000e18); // The view sizes the cap on the config yield-cap ceiling; a start at that same ceiling // is gated by exactly this value - uint256 cap = retargetter.maxPrincipal(address(positionManager)); + uint256 cap = retargetter.maxPrincipal(); vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); - retargetter.startRetargetting( - address(positionManager), cap + 1, DEFAULT_MAX_YIELD_BPS, address(fund), REQUEST_NAME, REQUEST_SYMBOL - ); + retargetter.startRetargetting(cap + 1, DEFAULT_MAX_YIELD_BPS, address(fund), REQUEST_NAME, REQUEST_SYMBOL); } function test_startRetargetting_revertUnauthorized() public { @@ -787,7 +788,7 @@ contract RetargetterTest is RetargetterBaseTest { vm.prank(user); vm.expectRevert(Ownable.Unauthorized.selector); - retargetter.startRetargetting(address(positionManager), 6_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + retargetter.startRetargetting(6_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); } function test_startRetargetting_effectiveYieldCapCallerLower() public { @@ -797,7 +798,7 @@ contract RetargetterTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectEmit(true, false, true, true, address(retargetter)); emit IRetargetter.RetargettingStarted(address(positionManager), address(0), address(fund), 6_000e18, 100); - retargetter.startRetargetting(address(positionManager), 6_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + retargetter.startRetargetting(6_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); (,,,,, uint16 operationMaxYieldBps,,,,,) = retargetter.operation(); assertEq(operationMaxYieldBps, 100, "caller cap recorded"); @@ -813,9 +814,7 @@ contract RetargetterTest is RetargetterBaseTest { emit IRetargetter.RetargettingStarted( address(positionManager), address(0), address(fund), 4_000e18, DEFAULT_MAX_YIELD_BPS ); - retargetter.startRetargetting( - address(positionManager), 4_000e18, 2_000, address(fund), REQUEST_NAME, REQUEST_SYMBOL - ); + retargetter.startRetargetting(4_000e18, 2_000, address(fund), REQUEST_NAME, REQUEST_SYMBOL); (,,,,, uint16 operationMaxYieldBps,,,,,) = retargetter.operation(); assertEq(operationMaxYieldBps, DEFAULT_MAX_YIELD_BPS, "config cap recorded"); @@ -870,7 +869,7 @@ contract RetargetterTest is RetargetterBaseTest { function test_consume_revertPrincipalCapExceededCumulative() public { _seedPosition(10_000e18, 5_000e18); address request = _startAsync(6_000e18, 100); - uint256 cap = retargetter.maxPrincipal(address(positionManager)); + uint256 cap = retargetter.maxPrincipal(); assertLt(cap, 7_000e18, "cap sanity"); _consume(request, 6_000e18, 60e18, 6_000e18); @@ -1028,9 +1027,7 @@ contract RetargetterTest is RetargetterBaseTest { _seedPosition(10_000e18, 5_000e18); vm.startPrank(rebalancer); - retargetter.startRetargetting( - address(positionManager), 0, 100, address(reenteringFund), REQUEST_NAME, REQUEST_SYMBOL - ); + retargetter.startRetargetting(0, 100, address(reenteringFund), REQUEST_NAME, REQUEST_SYMBOL); retargetter.create(_order(Mode.DEPOSIT, 100e18, 100e18, bytes32(uint256(1)))); vm.expectRevert(LibRetargetterErrors.Reentrancy.selector); @@ -1126,6 +1123,11 @@ contract RetargetterTest is RetargetterBaseTest { uint256 slot1 = uint256(vm.load(address(retargetter), bytes32(uint256(ASSETS_STORAGE_SLOT) + 1))); assertEq(address(uint160(slot1)), address(debtToken), "debt asset in second slot"); assertEq(slot1 >> 160, 0, "assets slot 1 upper bits clean"); + + // The bound position manager occupies the third slot (bound at creation in the fixture) + uint256 slot2 = uint256(vm.load(address(retargetter), bytes32(uint256(ASSETS_STORAGE_SLOT) + 2))); + assertEq(address(uint160(slot2)), address(positionManager), "bound position manager in third slot"); + assertEq(slot2 >> 160, 0, "assets slot 2 upper bits clean"); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -1135,7 +1137,7 @@ contract RetargetterTest is RetargetterBaseTest { /// @dev Expects the next factory creation with the given config to revert InvalidParameters. function _expectCreateInvalidParameters(RetargetterConfig memory config_) internal { vm.expectRevert(LibRetargetterErrors.InvalidParameters.selector); - retargetterFactory.createRetargetter(owner, address(collateralToken), address(debtToken), config_); + retargetterFactory.createRetargetter(owner, address(collateralToken), address(debtToken), address(0), config_); } /// @dev Computes an ERC-7201 slot: keccak256(abi.encode(uint256(keccak256(namespace)) - 1)) & ~0xff. diff --git a/test/manager/rebalancer/RetargetterAsync.t.sol b/test/manager/rebalancer/RetargetterAsync.t.sol index 54331dd8..7836010d 100644 --- a/test/manager/rebalancer/RetargetterAsync.t.sol +++ b/test/manager/rebalancer/RetargetterAsync.t.sol @@ -172,7 +172,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { // Zero estimates: cap = (debt - target * collateral) / (1 - target), plus the 1% buffer uint256 expectedCap = (uint256(2_000e18) * WAD / 0.7e18) * 10_100 / 10_000; - assertEq(retargetter.maxPrincipal(address(positionManager)), expectedCap, "down principal cap"); + assertEq(retargetter.maxPrincipal(), expectedCap, "down principal cap"); address request = _startAsync(2_857e18, 100); _consume(request, 2_857e18, 28e18, 2_857e18); @@ -800,9 +800,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { retargetter.setFund(address(stickyFund), true); _seedPosition(10_000e18, 5_000e18); vm.prank(rebalancer); - retargetter.startRetargetting( - address(positionManager), 4_000e18, 100, address(stickyFund), REQUEST_NAME, REQUEST_SYMBOL - ); + retargetter.startRetargetting(4_000e18, 100, address(stickyFund), REQUEST_NAME, REQUEST_SYMBOL); vm.startPrank(rebalancer); retargetter.create(_order(Mode.DEPOSIT, 100e18, 100e18, bytes32(uint256(14)))); @@ -1230,7 +1228,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { // size. Consume is already shut by the pulled funding round (its window check runs // before the Request call), so the collapsed cap surfaces through the authorization gate (uint128 ptSupply,) = ITokenController(request).totalSupplies(); - assertLt(retargetter.maxPrincipal(address(positionManager)), uint256(ptSupply), "cap collapsed"); + assertLt(retargetter.maxPrincipal(), uint256(ptSupply), "cap collapsed"); Offer memory offer = _createOffer(100e18, 1e18); bytes memory signature = _signOffer(offer, request); @@ -1300,9 +1298,7 @@ contract RetargetterAsyncTest is RetargetterBaseTest { // The gate admits exactly the cap and rejects a single wei more vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); - retargetter.startRetargetting( - address(positionManager), cap + 1, DEFAULT_MAX_YIELD_BPS, address(fund), REQUEST_NAME, REQUEST_SYMBOL - ); + retargetter.startRetargetting(cap + 1, DEFAULT_MAX_YIELD_BPS, address(fund), REQUEST_NAME, REQUEST_SYMBOL); address request = _startAsync(cap, DEFAULT_MAX_YIELD_BPS); _consume(request, cap, cap / 10, cap); @@ -1367,11 +1363,11 @@ contract RetargetterAsyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OperationActive.selector); - retargetter.startRetargetting(address(positionManager), 1_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + retargetter.startRetargetting(1_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); vm.prank(owner); vm.expectRevert(LibRetargetterErrors.OperationActive.selector); - retargetter.startRetargetting(address(positionManager), 1_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + retargetter.startRetargetting(1_000e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); } /// @notice After resolve a fresh operation starts; the fund archives ended order ids, so diff --git a/test/manager/rebalancer/RetargetterBase.t.sol b/test/manager/rebalancer/RetargetterBase.t.sol index 0b999ebd..3163d30a 100644 --- a/test/manager/rebalancer/RetargetterBase.t.sol +++ b/test/manager/rebalancer/RetargetterBase.t.sol @@ -83,8 +83,11 @@ contract RetargetterBaseTest is PositionManagerBaseTest { requestFactory = new RequestFactory(owner); retargetterQuoter = new RetargetterQuoter(); retargetterFactory = new RetargetterFactory(owner, address(retargetterQuoter), address(requestFactory)); + // The instance is bound to its position manager at creation (the init bind path) retargetter = Retargetter( - retargetterFactory.createRetargetter(owner, address(collateralToken), address(debtToken), _defaultConfig()) + retargetterFactory.createRetargetter( + owner, address(collateralToken), address(debtToken), address(positionManager), _defaultConfig() + ) ); fund = new MockRetargetterFund(address(debtToken), address(collateralToken)); flashLoanAdapter = new MorphoFlashLoanAdapter(address(morpho)); @@ -94,7 +97,6 @@ contract RetargetterBaseTest is PositionManagerBaseTest { retargetter.grantRoles(consumer, RETARGETTER_CONSUMER_ROLE); retargetter.setFund(address(fund), true); retargetter.setFlashLoanModule(address(flashLoanAdapter), true); - retargetter.setPositionManager(address(positionManager), true); positionManager.grantRoles(address(retargetter), _ROLE_REBALANCER); // One basis point absorbs the Morpho rounding dust, no cooldown between steps positionManager.setRebalanceConfig(1, 0); @@ -156,9 +158,7 @@ contract RetargetterBaseTest is PositionManagerBaseTest { /// @dev Starts a default ASYNC operation as the rebalancer. function _startAsync(uint256 principal, uint16 maxYieldBps) internal returns (address request) { vm.prank(rebalancer); - request = retargetter.startRetargetting( - address(positionManager), principal, maxYieldBps, address(fund), REQUEST_NAME, REQUEST_SYMBOL - ); + request = retargetter.startRetargetting(principal, maxYieldBps, address(fund), REQUEST_NAME, REQUEST_SYMBOL); } /// @dev Builds a fresh offer from the maker wallet with a one-shot nonce. diff --git a/test/manager/rebalancer/RetargetterRebalance.t.sol b/test/manager/rebalancer/RetargetterRebalance.t.sol index 160eef26..0f3afbb7 100644 --- a/test/manager/rebalancer/RetargetterRebalance.t.sol +++ b/test/manager/rebalancer/RetargetterRebalance.t.sol @@ -774,7 +774,7 @@ contract RetargetterRebalanceTest is RetargetterBaseTest { ) ); - assertEq(retargetter.maxPrincipal(address(positionManager)), 0, "cap collapsed at target"); + assertEq(retargetter.maxPrincipal(), 0, "cap collapsed at target"); // The principal gate rejects any further consume once the Request call returns; the // offer is signed and approved so the revert comes from the cap re-check diff --git a/test/manager/rebalancer/RetargetterSmoke.t.sol b/test/manager/rebalancer/RetargetterSmoke.t.sol index cacf1425..e4a1437f 100644 --- a/test/manager/rebalancer/RetargetterSmoke.t.sol +++ b/test/manager/rebalancer/RetargetterSmoke.t.sol @@ -77,7 +77,7 @@ contract RetargetterSmokeTest is RetargetterBaseTest { ); vm.prank(rebalancer); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); assertFalse(retargetter.isActive(), "no persistent operation"); assertEq(debtToken.balanceOf(address(retargetter)), 0, "zero debt residual"); @@ -99,7 +99,7 @@ contract RetargetterSmokeTest is RetargetterBaseTest { _plantStrayOrder(); vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OrderActive.selector); - retargetter.startRetargetting(address(positionManager), 1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + retargetter.startRetargetting(1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); } function test_startSyncRetargetting_revertsOrderActiveOnStrayOrder() public { @@ -107,9 +107,7 @@ contract RetargetterSmokeTest is RetargetterBaseTest { _plantStrayOrder(); vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OrderActive.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), 1e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), 1e18, address(fund), new bytes[](0)); } function test_repay_zeroShortfallWhenRequestBalanceCovers() public { diff --git a/test/manager/rebalancer/RetargetterSync.t.sol b/test/manager/rebalancer/RetargetterSync.t.sol index 288988a1..58fb1e52 100644 --- a/test/manager/rebalancer/RetargetterSync.t.sol +++ b/test/manager/rebalancer/RetargetterSync.t.sol @@ -19,7 +19,7 @@ import {Ownable} from "lib/solady/src/auth/Ownable.sol"; import {SafeTransferLib} from "lib/solady/src/utils/SafeTransferLib.sol"; /// @dev Position manager stub returning a pair that does not match the Retargetter's bound -/// assets; startSyncRetargetting reads only assets() before reverting AssetMismatch. +/// assets; setPositionManager reads assets() and reverts AssetMismatch at bind time. contract MismatchedPairPositionManager { address internal immutable COLLATERAL_ASSET; address internal immutable DEBT_ASSET; @@ -156,7 +156,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { /// @dev Starts a SYNC operation as the rebalancer with the default stack. function _startSync(uint256 amount, bytes[] memory calls) internal { vm.prank(rebalancer); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); } /// @dev LTV-up payload: subscribe the loan into the fund, supply the settled shares with @@ -281,7 +281,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { // Exact-proceeds sizing: at share price 1e18 and a zero-fee flash loan, redeeming // exactly flashLoanAmount shares mints exactly the loan repayment uint256 amount = 2_857e18; - assertLe(amount, retargetter.maxPrincipal(address(positionManager)), "sized at or under the cap"); + assertLe(amount, retargetter.maxPrincipal(), "sized at or under the cap"); bytes[] memory calls = _syncDownPayload(amount, bytes32(uint256(1))); uint256 morphoDebtBefore = debtToken.balanceOf(address(morpho)); @@ -321,7 +321,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(SafeTransferLib.TransferFromFailed.selector); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); // The whole transaction reverted: no trace on the Retargetter, position untouched _assertNoTrace(); @@ -345,7 +345,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(abi.encodeWithSelector(LibFundsErrors.InvalidState.selector, State.PROCESSING)); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); _assertNoTrace(); _assertZeroResidual(); @@ -366,7 +366,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OrderPending.selector); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); _assertNoTrace(); assertEq(debtToken.balanceOf(address(retargetter)), amount, "donation restored by the revert"); @@ -387,7 +387,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(SafeTransferLib.TransferFromFailed.selector); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); _assertNoTrace(); _assertZeroResidual(); @@ -404,7 +404,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(abi.encodeWithSelector(LibRetargetterErrors.ResidualBalance.selector, address(collateralToken), 5)); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); _assertNoTrace(); } @@ -433,9 +433,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.ModuleNotWhitelisted.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(freshAdapter), 1_000e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(freshAdapter), 1_000e18, address(fund), new bytes[](0)); } function test_startSyncRetargetting_nonWhitelistedFundReverts() public { @@ -444,27 +442,17 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.FundNotWhitelisted.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), 1_000e18, address(freshFund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), 1_000e18, address(freshFund), new bytes[](0)); } - function test_startSyncRetargetting_assetMismatchReverts() public { - _seedPosition(10_000e18, 5_000e18); - // Reversed pair: whitelisting checks the pair, so the mismatched manager never gets in - // and the start gate rejects it as not whitelisted + function test_setPositionManager_assetMismatchReverts() public { + // Reversed pair: binding checks the pair, so a mismatched manager can never be bound MismatchedPairPositionManager mismatchedPm = new MismatchedPairPositionManager(address(debtToken), address(collateralToken)); vm.prank(owner); vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); - retargetter.setPositionManager(address(mismatchedPm), true); - - vm.prank(rebalancer); - vm.expectRevert(LibRetargetterErrors.PositionManagerNotWhitelisted.selector); - retargetter.startSyncRetargetting( - address(mismatchedPm), address(flashLoanAdapter), 1_000e18, address(fund), new bytes[](0) - ); + retargetter.setPositionManager(address(mismatchedPm)); } function test_startSyncRetargetting_principalCapExceededReverts() public { @@ -474,9 +462,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalCapExceeded.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), cap + 1, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), cap + 1, address(fund), new bytes[](0)); } function test_startSyncRetargetting_activeAsyncOperationReverts() public { @@ -485,9 +471,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OperationActive.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), 1_000e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), 1_000e18, address(fund), new bytes[](0)); assertTrue(retargetter.isActive(), "async operation still active"); } @@ -497,9 +481,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(user); vm.expectRevert(Ownable.Unauthorized.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), 1_000e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), 1_000e18, address(fund), new bytes[](0)); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -511,15 +493,11 @@ contract RetargetterSyncTest is RetargetterBaseTest { bytes[] memory inner = new bytes[](0); bytes[] memory calls = new bytes[](1); - calls[0] = abi.encodeCall( - retargetter.startSyncRetargetting, (address(positionManager), address(flashLoanAdapter), 0, address(fund), inner) - ); + calls[0] = abi.encodeCall(retargetter.startSyncRetargetting, (address(flashLoanAdapter), 0, address(fund), inner)); vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OperationActive.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), 1_000e18, address(fund), calls - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), 1_000e18, address(fund), calls); _assertNoTrace(); } @@ -528,15 +506,11 @@ contract RetargetterSyncTest is RetargetterBaseTest { _seedPosition(10_000e18, 5_000e18); bytes[] memory calls = new bytes[](1); - calls[0] = abi.encodeCall( - retargetter.startRetargetting, (address(positionManager), 0, 0, address(fund), REQUEST_NAME, REQUEST_SYMBOL) - ); + calls[0] = abi.encodeCall(retargetter.startRetargetting, (0, 0, address(fund), REQUEST_NAME, REQUEST_SYMBOL)); vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.OperationActive.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), 1_000e18, address(fund), calls - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), 1_000e18, address(fund), calls); _assertNoTrace(); } @@ -551,9 +525,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.NoActiveOperation.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), 1_000e18, address(fund), calls - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), 1_000e18, address(fund), calls); _assertNoTrace(); } @@ -574,9 +546,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { calls[1] = abi.encodeCall(retargetter.cancelOrder, ()); vm.prank(rebalancer); - retargetter.startSyncRetargetting( - address(positionManager), address(flashLoanAdapter), amount, address(probeFund), calls - ); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(probeFund), calls); assertTrue(probeFund.probed(), "probe executed inside the window"); assertTrue(probeFund.sawActive(), "window runs with an active operation"); @@ -614,7 +584,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.UnauthorizedFlashLoanCallback.selector); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); _assertNoTrace(); } @@ -627,9 +597,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.UnauthorizedFlashLoanCallback.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(wrongAmountModule), 1_000e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(wrongAmountModule), 1_000e18, address(fund), new bytes[](0)); _assertNoTrace(); } @@ -645,9 +613,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.UnauthorizedFlashLoanCallback.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(replayingModule), 1_000e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(replayingModule), 1_000e18, address(fund), new bytes[](0)); _assertNoTrace(); } @@ -667,9 +633,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.UnauthorizedFlashLoanCallback.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(substitutingModule), amount, address(fund), calls - ); + retargetter.startSyncRetargetting(address(substitutingModule), amount, address(fund), calls); _assertNoTrace(); } @@ -683,9 +647,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalNotDelivered.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(zeroDeliveryModule), amount, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(zeroDeliveryModule), amount, address(fund), new bytes[](0)); _assertNoTrace(); } @@ -700,9 +662,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalNotDelivered.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(partialDeliveryModule), amount, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(partialDeliveryModule), amount, address(fund), new bytes[](0)); _assertNoTrace(); } @@ -725,9 +685,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(Ownable.Unauthorized.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(selfFundingModule), 500e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(selfFundingModule), 500e18, address(fund), new bytes[](0)); _assertNoTrace(); _assertZeroResidual(); @@ -747,9 +705,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(LibRetargetterErrors.PrincipalNotDelivered.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(partialDeliveryModule), amount, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(partialDeliveryModule), amount, address(fund), new bytes[](0)); _assertNoTrace(); } @@ -778,9 +734,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { vm.prank(rebalancer); vm.expectRevert(Ownable.Unauthorized.selector); - retargetter.startSyncRetargetting( - address(positionManager), address(hostileModule), 1_000e18, address(fund), new bytes[](0) - ); + retargetter.startSyncRetargetting(address(hostileModule), 1_000e18, address(fund), new bytes[](0)); _assertNoTrace(); } @@ -811,7 +765,7 @@ contract RetargetterSyncTest is RetargetterBaseTest { // owner bypass never applies through a window vm.prank(owner); vm.expectRevert(abi.encodeWithSelector(LibRetargetterErrors.AboveTargetLtv.selector, ltvAfter, ltvBefore, 0.3e18)); - retargetter.startSyncRetargetting(address(positionManager), address(flashLoanAdapter), amount, address(fund), calls); + retargetter.startSyncRetargetting(address(flashLoanAdapter), amount, address(fund), calls); _assertNoTrace(); assertEq(positionManager.debtAmount(), debtBefore, "worsening borrow rolled back"); diff --git a/test/mock/manager/rebalancer/RetargetterHandler.sol b/test/mock/manager/rebalancer/RetargetterHandler.sol index e86ffa18..54dffbd6 100644 --- a/test/mock/manager/rebalancer/RetargetterHandler.sol +++ b/test/mock/manager/rebalancer/RetargetterHandler.sol @@ -226,7 +226,7 @@ contract RetargetterHandler is Test { function act_startAsync(uint256 principalSeed, uint256 yieldCapSeed) external trackStartedAt { if (retargetter.isActive()) return; uint256 cap; - try retargetter.maxPrincipal(address(positionManager)) returns (uint256 cap_) { + try retargetter.maxPrincipal() returns (uint256 cap_) { cap = cap_; } catch { return; @@ -235,9 +235,7 @@ contract RetargetterHandler is Test { // Above the 1000 bps config ceiling half the time so the min() clamp gets exercised uint16 yieldCap = uint16(_bound(yieldCapSeed, 0, 2000)); vm.prank(rebalancer); - try retargetter.startRetargetting( - address(positionManager), principal, yieldCap, address(fund), REQUEST_NAME, REQUEST_SYMBOL - ) returns ( + try retargetter.startRetargetting(principal, yieldCap, address(fund), REQUEST_NAME, REQUEST_SYMBOL) returns ( address request ) { lastRequest = request; @@ -544,7 +542,7 @@ contract RetargetterHandler is Test { if (retargetter.isActive()) return; uint256 shape = _bound(uint256(shapeSeed), 0, 2); uint256 cap; - try retargetter.maxPrincipal(address(positionManager)) returns (uint256 cap_) { + try retargetter.maxPrincipal() returns (uint256 cap_) { cap = cap_; } catch { return; @@ -575,7 +573,7 @@ contract RetargetterHandler is Test { } vm.prank(rebalancer); - try retargetter.startSyncRetargetting(address(positionManager), flashLoanAdapter, amount, address(fund), calls) { + try retargetter.startSyncRetargetting(flashLoanAdapter, amount, address(fund), calls) { syncSuccessCount++; } catch {} From cea2707a339497136e9740bc0f45fbb57ddba37d Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Mon, 10 Aug 2026 14:50:30 +0200 Subject: [PATCH 3/7] docs: state the IFund exact-transfer assumption per-transfer, not per single pull (3F-487) The assumption is a token property (every transfer/burn/wrap moves exactly the requested amount), independent of how many legs an adapter uses. Drop the Centrifuge-specific 'pull order.input and forward that same amount' wording, which misdescribes multi-leg adapters like the Midas redeem (bond + redemption legs summing to the input). --- src/interfaces/funds/IFund.sol | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/interfaces/funds/IFund.sol b/src/interfaces/funds/IFund.sol index 64467b87..0bf382fa 100644 --- a/src/interfaces/funds/IFund.sol +++ b/src/interfaces/funds/IFund.sol @@ -46,11 +46,13 @@ import {Order, State} from "../../libs/funds/Order.sol"; /// - Only unlock() and recover() finalize orders by reaching ENDED /// - Callers can batch state transitions when possible (e.g., create + commit if ACCEPTED) /// -/// Supported assets (binding on every adapter, current and future): asset and share -/// tokens must deliver exactly the requested amount on transfer. Adapters pull -/// `order.input` and then approve and forward that same amount to the venue, so any -/// shortfall breaks the venue request; fee-on-transfer, rebasing and callback tokens -/// are out of scope and must not be admitted. +/// Supported assets (binding on every adapter, current and future): the asset and share +/// tokens must be exact-transfer, delivering exactly the requested amount on every +/// transfer, burn, wrap or unwrap. An adapter may move an order's input in one transfer +/// or in several legs that sum to it (for example a Midas redeem's separate bond and +/// redemption legs), but each leg relies on that exactness, so a shortfall breaks the +/// venue request or the per-order accounting. Fee-on-transfer, rebasing and callback +/// tokens are out of scope and must not be admitted. interface IFund { /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* OPERATIONS */ From 970e37755c3db77a24b484e3d762d81cab2857e4 Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Mon, 10 Aug 2026 15:09:18 +0200 Subject: [PATCH 4/7] refactor: derive the Retargetter pair from its position manager (3F-483) The asset pair is the position manager's own pair, so read it from the manager at initialize instead of taking collateralAsset/debtAsset as params. The pair is stored (still read on every hot path) and immutable, so setFund's one-time validation and the start-time whitelist trust stay intact; rebinds must report the same pair. - initialize(owner, positionManager, config): position manager is required, the pair is derived from its assets(). createRetargetter drops the two asset params and reports the derived pair in its event. - setPositionManager(address) also accepts the zero address to unbind (abandon) the instance while idle; operations then revert PositionManagerNotBound until a same-pair manager is bound again. The pair is retained across an unbind. Full suite 2346 passing, 74 invariants passing; the not-bound guard is mutation-checked. Retargetter runtime 24,004 B on the ci profile, 572 B under EIP-170. --- .../manager/rebalancer/IRetargetter.sol | 19 ++-- .../rebalancer/LibRetargetterErrors.sol | 3 +- src/libs/manager/rebalancer/LibStorage.sol | 9 +- src/manager/rebalancer/Retargetter.sol | 79 ++++++------- src/manager/rebalancer/RetargetterFactory.sol | 37 +++--- test/manager/rebalancer/Retargetter.t.sol | 105 +++++++----------- test/manager/rebalancer/RetargetterBase.t.sol | 8 +- 7 files changed, 114 insertions(+), 146 deletions(-) diff --git a/src/interfaces/manager/rebalancer/IRetargetter.sol b/src/interfaces/manager/rebalancer/IRetargetter.sol index 10414b39..840b4620 100644 --- a/src/interfaces/manager/rebalancer/IRetargetter.sol +++ b/src/interfaces/manager/rebalancer/IRetargetter.sol @@ -348,20 +348,23 @@ interface IRetargetter { /// @param whitelisted Whether the module should be whitelisted function setFlashLoanModule(address module, bool whitelisted) external; - /// @notice Binds the single position manager every operation runs against. - /// @dev Operations never take a position manager as an argument: they run against this - /// binding, so every start-gate and principal-cap read trusts an owner-curated - /// address rather than a caller-supplied one. Checks the position manager is a - /// contract and its assets match the bound pair, and reverts while an operation is - /// active (the binding can only be rotated while idle). Also settable at initialize. - /// @param positionManager The position manager to bind + /// @notice Rotates (or clears) the single position manager every operation runs against. + /// @dev A position manager is bound at initialize (its assets define the instance's pair); + /// this rotates the binding to another position manager on the same pair, or unbinds + /// it with the zero address to abandon the instance (operations then revert until a + /// same-pair manager is bound again). Operations never take a position manager as an + /// argument, so every start-gate and principal-cap read trusts an owner-curated address + /// rather than a caller-supplied one. A nonzero manager is checked to be a contract on + /// the bound pair; both paths revert while an operation is active (the binding can only + /// change while idle). + /// @param positionManager The position manager to bind, or the zero address to unbind function setPositionManager(address positionManager) external; /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* VIEWS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Returns the bound asset pair, set once at initialization. + /// @notice Returns the bound asset pair, derived from the position manager at initialization. /// @return collateralAsset The collateral asset of the pair /// @return debtAsset The debt asset of the pair function assets() external view returns (address collateralAsset, address debtAsset); diff --git a/src/libs/manager/rebalancer/LibRetargetterErrors.sol b/src/libs/manager/rebalancer/LibRetargetterErrors.sol index d8d2aa21..46c8ebd4 100644 --- a/src/libs/manager/rebalancer/LibRetargetterErrors.sol +++ b/src/libs/manager/rebalancer/LibRetargetterErrors.sol @@ -36,7 +36,8 @@ library LibRetargetterErrors { /// @notice Thrown when binding a fund that is not owner-whitelisted. error FundNotWhitelisted(); - /// @notice Thrown when starting an operation before the owner has bound a position manager. + /// @notice Thrown when starting an operation while the position manager is unbound (the + /// instance was abandoned by binding the zero address). error PositionManagerNotBound(); /// @notice Thrown when using a flash-loan module that is not owner-whitelisted. diff --git a/src/libs/manager/rebalancer/LibStorage.sol b/src/libs/manager/rebalancer/LibStorage.sol index 68ef88bb..887eed65 100644 --- a/src/libs/manager/rebalancer/LibStorage.sol +++ b/src/libs/manager/rebalancer/LibStorage.sol @@ -18,12 +18,13 @@ import { } from "./LibRetargetterConstants.sol"; /// @notice The Retargetter's instance bindings (namespace "retargetter.assets"). -/// @dev The pair is written once at initialize and never mutated; the position manager is -/// owner-bound and rebindable while no operation is active. +/// @dev The pair is derived from the position manager at initialize and never mutated; the +/// position manager is owner-bound and rebindable to another same-pair manager while no +/// operation is active. /// @param collateralAsset The collateral asset every position manager and fund must match /// @param debtAsset The debt asset every position manager, fund and Request must match -/// @param positionManager The single position manager every operation runs against, bound by -/// the owner (its assets are checked against the pair at bind time) +/// @param positionManager The single position manager every operation runs against; its assets +/// define the pair at the first bind, and every rebind must match that pair struct RetargetterAssets { address collateralAsset; address debtAsset; diff --git a/src/manager/rebalancer/Retargetter.sol b/src/manager/rebalancer/Retargetter.sol index 024c8ead..8c76658e 100644 --- a/src/manager/rebalancer/Retargetter.sol +++ b/src/manager/rebalancer/Retargetter.sol @@ -161,34 +161,31 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /* INITIALIZATION */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Initializes the proxy instance, binds it to its asset pair, and optionally binds - /// its position manager. - /// @dev The pair is never mutable afterwards. A nonzero positionManager_ is bound here (its - /// assets are checked against the pair); a zero value leaves the instance unbound until - /// the owner calls {setPositionManager}. Whitelists start empty; zero estimates are - /// valid and degrade the principal cap to the zero-rate ideal formula. + /// @notice Initializes the proxy instance and binds it to a position manager, deriving the + /// asset pair from that manager. + /// @dev The pair is read from the position manager once and never mutated; every later + /// rebind through {setPositionManager} must report the same pair. Whitelists start + /// empty; zero estimates are valid and degrade the principal cap to the zero-rate + /// ideal formula. /// @param owner_ The address that will own the instance - /// @param collateralAsset_ The collateral asset of the bound pair - /// @param debtAsset_ The debt asset of the bound pair - /// @param positionManager_ The position manager to bind, or the zero address to bind later + /// @param positionManager_ The position manager to bind (the pair is derived from its assets) /// @param config_ The initial configuration, validated against the documented bounds - function initialize( - address owner_, - address collateralAsset_, - address debtAsset_, - address positionManager_, - RetargetterConfig calldata config_ - ) external initializer { + function initialize(address owner_, address positionManager_, RetargetterConfig calldata config_) + external + initializer + { owner_.checkNotZero(); - collateralAsset_.checkContract(); - debtAsset_.checkContract(); - if (collateralAsset_ == debtAsset_) revert LibRetargetterErrors.AssetMismatch(); + positionManager_.checkContract(); _setConfig(config_); + // Derive the bound pair from the position manager, the single source of truth. The pair + // is immutable afterwards, so setFund can validate a fund once and every rebind stays on + // the same pair (see {_checkPair}) RetargetterAssets storage assets_ = LibStorage.assetsStorage(); - assets_.collateralAsset = collateralAsset_; - assets_.debtAsset = debtAsset_; - // Optional bind at init; the owner can (re)bind later while no operation is active - if (positionManager_ != address(0)) _setPositionManager(positionManager_); + (address collateralAsset, address debtAsset) = IPositionManager(positionManager_).assets(); + assets_.collateralAsset = collateralAsset; + assets_.debtAsset = debtAsset; + assets_.positionManager = positionManager_; + emit PositionManagerBound(positionManager_); _initializeOwner(owner_); } @@ -734,13 +731,20 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali } /// @inheritdoc IRetargetter - /// @dev Binds the single position manager every operation runs against. Rebindable only - /// while no operation is active, so a live operation's binding never moves under it. - /// Every start-gate and principal-cap read trusts what the bound address reports about - /// itself, which is why the binding is owner-curated rather than caller-supplied. + /// @dev Rotates the bound position manager, only while no operation is active so a live + /// operation's binding never moves under it. A nonzero manager is checked to be a + /// contract on the immutable bound pair; the zero address unbinds the instance, + /// abandoning it until a same-pair manager is bound again (operations revert + /// PositionManagerNotBound meanwhile). Every start-gate and principal-cap read trusts + /// the bound address, which is why the binding is owner-curated, not caller-supplied. function setPositionManager(address positionManager) external onlyOwner nonReentrant { if (LibStorage.operationStorage().positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); - _setPositionManager(positionManager); + if (positionManager != address(0)) { + positionManager.checkContract(); + _checkPair(positionManager); + } + LibStorage.assetsStorage().positionManager = positionManager; + emit PositionManagerBound(positionManager); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -879,24 +883,13 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali emit ConfigSet(config_); } - /// @dev Validates and stores the bound position manager, emitting {PositionManagerBound}. - /// Checks it is a contract and its assets match the bound pair, so every later - /// start-gate and cap read can trust the binding. Shared by {initialize} and - /// {setPositionManager}; the active-operation guard lives in the external setter. - function _setPositionManager(address positionManager) internal { - positionManager.checkContract(); - _checkPair(positionManager); - LibStorage.assetsStorage().positionManager = positionManager; - emit PositionManagerBound(positionManager); - } - /// @dev Shared start gate of both entry points, returning the bound position manager. One /// operation at a time: the operation's position manager doubles as the active flag, /// and a SYNC window keeps it populated, so this also locks out starts smuggled into a - /// window payload. Requires a bound position manager (its pair was checked at bind), a - /// whitelisted fund, and the requested amount within the live principal cap sized on - /// the operation's yield cap (fail-fast for ASYNC, where the cap re-runs at every - /// consume; zero for SYNC, whose flash repayment carries no yield). + /// window payload. Requires a bound position manager (unbound means the instance was + /// abandoned), a whitelisted fund, and the requested amount within the live principal + /// cap sized on the operation's yield cap (fail-fast for ASYNC, where the cap re-runs + /// at every consume; zero for SYNC, whose flash repayment carries no yield). function _checkStart(RetargetterOperation storage operation_, address fund, uint256 amount, uint16 yieldCapBps) internal view diff --git a/src/manager/rebalancer/RetargetterFactory.sol b/src/manager/rebalancer/RetargetterFactory.sol index 964b9103..ff20cfc6 100644 --- a/src/manager/rebalancer/RetargetterFactory.sol +++ b/src/manager/rebalancer/RetargetterFactory.sol @@ -31,11 +31,12 @@ import {LibClone} from "lib/solady/src/utils/LibClone.sol"; /// Blue's address). /// 4. The instance owner grants the Retargetter's own REBALANCER_ROLE (Solady _ROLE_0, /// bit value 1) and CONSUMER_ROLE (Solady _ROLE_1, bit value 2), whitelists funds -/// and the adapter, binds the served PositionManager (at creation or via -/// setPositionManager), and sets the yield estimates. Each instance runs against one -/// bound PositionManager; grant a PositionManager's REBALANCER_ROLE to at most one -/// Retargetter, since the one-operation-at-a-time guard is per instance and a second -/// bound Retargetter could admit the full principal cap on its own. +/// and the adapter, and sets the yield estimates. The served PositionManager is bound +/// at creation (its assets define the instance's pair) and can be rotated to another +/// same-pair PositionManager via setPositionManager while idle. Each instance runs +/// against one bound PositionManager; grant a PositionManager's REBALANCER_ROLE to at +/// most one Retargetter, since the one-operation-at-a-time guard is per instance and a +/// second bound Retargetter could admit the full principal cap on its own. contract RetargetterFactory { using LibClone for address; @@ -85,29 +86,25 @@ contract RetargetterFactory { /* FACTORY METHODS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Creates a new Retargetter proxy bound to an asset pair and, optionally, a - /// position manager. - /// @dev Deploys an ERC1967 beacon proxy and initializes it atomically. Emits a - /// {RetargetterCreated} event. + /// @notice Creates a new Retargetter proxy bound to a position manager. + /// @dev Deploys an ERC1967 beacon proxy and initializes it atomically; the asset pair is + /// derived from the position manager. Emits a {RetargetterCreated} event. /// @param owner The address that will own the Retargetter - /// @param collateralAsset The collateral asset of the bound pair - /// @param debtAsset The debt asset of the bound pair - /// @param positionManager The position manager to bind, or the zero address to bind later + /// @param positionManager The position manager to bind (the pair is derived from its assets) /// @param config The initial configuration /// @return retargetter The address of the newly deployed Retargetter proxy - function createRetargetter( - address owner, - address collateralAsset, - address debtAsset, - address positionManager, - RetargetterConfig calldata config - ) external returns (address retargetter) { + function createRetargetter(address owner, address positionManager, RetargetterConfig calldata config) + external + returns (address retargetter) + { retargetter = RETARGETTER_BEACON.deployERC1967BeaconProxy(); - Retargetter(retargetter).initialize(owner, collateralAsset, debtAsset, positionManager, config); + Retargetter(retargetter).initialize(owner, positionManager, config); _isRetargetter[retargetter] = true; + // Read the pair the instance derived from the position manager, for the discovery event + (address collateralAsset, address debtAsset) = Retargetter(retargetter).assets(); emit RetargetterCreated(retargetter, owner, collateralAsset, debtAsset); } diff --git a/test/manager/rebalancer/Retargetter.t.sol b/test/manager/rebalancer/Retargetter.t.sol index c0f2e25d..ccdfa867 100644 --- a/test/manager/rebalancer/Retargetter.t.sol +++ b/test/manager/rebalancer/Retargetter.t.sol @@ -44,54 +44,33 @@ contract RetargetterTest is RetargetterBaseTest { /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ function test_factory_createRetargetterDeploysInitializesAndRegisters() public { - address instance = retargetterFactory.createRetargetter( - owner, address(collateralToken), address(debtToken), address(0), _defaultConfig() - ); + address instance = retargetterFactory.createRetargetter(owner, address(positionManager), _defaultConfig()); assertGt(instance.code.length, 0, "proxy deployed"); assertTrue(retargetterFactory.isRetargetter(instance), "registered"); assertTrue(retargetterFactory.isRetargetter(address(retargetter)), "fixture instance registered"); assertFalse(retargetterFactory.isRetargetter(makeAddr("notARetargetter")), "unknown address not registered"); + // The pair is derived from the bound position manager (address collateralAsset, address debtAsset) = Retargetter(instance).assets(); - assertEq(collateralAsset, address(collateralToken), "collateral asset initialized"); - assertEq(debtAsset, address(debtToken), "debt asset initialized"); + assertEq(collateralAsset, address(collateralToken), "collateral asset derived from the position manager"); + assertEq(debtAsset, address(debtToken), "debt asset derived from the position manager"); assertEq(Retargetter(instance).owner(), owner, "owner initialized"); - assertEq(Retargetter(instance).boundPositionManager(), address(0), "unbound when created with the zero address"); - } - - function test_factory_createRetargetterBindsPositionManagerAtInit() public { - // Passing a position manager at creation binds it through the init path - address instance = retargetterFactory.createRetargetter( - owner, address(collateralToken), address(debtToken), address(positionManager), _defaultConfig() - ); - assertEq(Retargetter(instance).boundPositionManager(), address(positionManager), "bound at init"); - } - - function test_factory_createRetargetterRevertsOnPairMismatchAtInit() public { - // The init bind runs the same pair check as the setter - MockERC20 otherDebt = new MockERC20("Other Debt", "ODEBT", 18); - vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); - retargetterFactory.createRetargetter( - owner, address(collateralToken), address(otherDebt), address(positionManager), _defaultConfig() - ); + assertEq(Retargetter(instance).boundPositionManager(), address(positionManager), "position manager bound at init"); } function test_factory_createRetargetterEmitsEvent() public { - // The proxy address is not known ahead of the call, so topic 1 is unchecked + // The proxy address is not known ahead of the call, so topic 1 is unchecked; the factory + // reports the pair it derived from the position manager vm.expectEmit(false, true, true, true, address(retargetterFactory)); emit RetargetterFactory.RetargetterCreated(address(0), owner, address(collateralToken), address(debtToken)); - retargetterFactory.createRetargetter( - owner, address(collateralToken), address(debtToken), address(0), _defaultConfig() - ); + retargetterFactory.createRetargetter(owner, address(positionManager), _defaultConfig()); } function test_factory_permissionlessCreation() public { address randomCreator = makeAddr("randomCreator"); vm.prank(randomCreator); - address instance = retargetterFactory.createRetargetter( - user, address(collateralToken), address(debtToken), address(0), _defaultConfig() - ); + address instance = retargetterFactory.createRetargetter(user, address(positionManager), _defaultConfig()); assertTrue(retargetterFactory.isRetargetter(instance), "registered"); assertEq(Retargetter(instance).owner(), user, "owner is the passed owner, not the creator"); @@ -112,7 +91,7 @@ contract RetargetterTest is RetargetterBaseTest { Retargetter(UpgradeableBeacon(retargetterFactory.RETARGETTER_BEACON()).implementation()); vm.expectRevert(Initializable.InvalidInitialization.selector); - implementation.initialize(owner, address(collateralToken), address(debtToken), address(0), _defaultConfig()); + implementation.initialize(owner, address(positionManager), _defaultConfig()); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -158,29 +137,18 @@ contract RetargetterTest is RetargetterBaseTest { function test_initialize_revertZeroOwner() public { vm.expectRevert(LibCommonErrors.AddressZero.selector); - retargetterFactory.createRetargetter( - address(0), address(collateralToken), address(debtToken), address(0), _defaultConfig() - ); + retargetterFactory.createRetargetter(address(0), address(positionManager), _defaultConfig()); } - function test_initialize_revertNonContractAssets() public { + function test_initialize_revertNonContractPositionManager() public { address nonContract = makeAddr("nonContract"); - vm.expectRevert(abi.encodeWithSelector(LibCommonErrors.InvalidContract.selector, nonContract)); - retargetterFactory.createRetargetter(owner, nonContract, address(debtToken), address(0), _defaultConfig()); - - vm.expectRevert(abi.encodeWithSelector(LibCommonErrors.InvalidContract.selector, nonContract)); - retargetterFactory.createRetargetter(owner, address(collateralToken), nonContract, address(0), _defaultConfig()); - } - - function test_initialize_revertIdenticalAssets() public { - vm.expectRevert(LibRetargetterErrors.AssetMismatch.selector); - retargetterFactory.createRetargetter(owner, address(debtToken), address(debtToken), address(0), _defaultConfig()); + retargetterFactory.createRetargetter(owner, nonContract, _defaultConfig()); } function test_initialize_revertDoubleInitialize() public { vm.expectRevert(Initializable.InvalidInitialization.selector); - retargetter.initialize(owner, address(collateralToken), address(debtToken), address(0), _defaultConfig()); + retargetter.initialize(owner, address(positionManager), _defaultConfig()); } function test_initialize_revertConfigBoundViolations() public { @@ -582,6 +550,32 @@ contract RetargetterTest is RetargetterBaseTest { retargetter.setPositionManager(address(positionManager)); } + function test_setPositionManager_unbindAbandonsThenRebindRevives() public { + _seedPosition(10_000e18, 5_000e18); + + // Unbind to the zero address: the instance is abandoned, its pair retained + vm.prank(owner); + vm.expectEmit(address(retargetter)); + emit IRetargetter.PositionManagerBound(address(0)); + retargetter.setPositionManager(address(0)); + assertEq(retargetter.boundPositionManager(), address(0), "unbound"); + (address collateralAsset, address debtAsset) = retargetter.assets(); + assertEq(collateralAsset, address(collateralToken), "pair retained after unbind"); + assertEq(debtAsset, address(debtToken), "pair retained after unbind"); + + // Operations revert while abandoned + vm.prank(rebalancer); + vm.expectRevert(LibRetargetterErrors.PositionManagerNotBound.selector); + retargetter.startRetargetting(1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); + + // Rebinding a same-pair manager revives it + vm.prank(owner); + retargetter.setPositionManager(address(positionManager)); + assertEq(retargetter.boundPositionManager(), address(positionManager), "rebound"); + _startAsync(4_000e18, 100); + assertTrue(retargetter.isActive(), "operations work again"); + } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* NO ESCAPE HATCH */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -755,23 +749,6 @@ contract RetargetterTest is RetargetterBaseTest { retargetter.startRetargetting(1e18, 100, address(strangerFund), REQUEST_NAME, REQUEST_SYMBOL); } - function test_startRetargetting_revertPositionManagerNotBound() public { - // A fresh instance created without a bound position manager (address(0) at init) - Retargetter unbound = Retargetter( - retargetterFactory.createRetargetter( - owner, address(collateralToken), address(debtToken), address(0), _defaultConfig() - ) - ); - vm.prank(owner); - unbound.grantRoles(rebalancer, RETARGETTER_REBALANCER_ROLE); - vm.prank(owner); - unbound.setFund(address(fund), true); - - vm.prank(rebalancer); - vm.expectRevert(LibRetargetterErrors.PositionManagerNotBound.selector); - unbound.startRetargetting(1e18, 100, address(fund), REQUEST_NAME, REQUEST_SYMBOL); - } - function test_startRetargetting_revertPrincipalCapExceeded() public { _seedPosition(10_000e18, 5_000e18); // The view sizes the cap on the config yield-cap ceiling; a start at that same ceiling @@ -1137,7 +1114,7 @@ contract RetargetterTest is RetargetterBaseTest { /// @dev Expects the next factory creation with the given config to revert InvalidParameters. function _expectCreateInvalidParameters(RetargetterConfig memory config_) internal { vm.expectRevert(LibRetargetterErrors.InvalidParameters.selector); - retargetterFactory.createRetargetter(owner, address(collateralToken), address(debtToken), address(0), config_); + retargetterFactory.createRetargetter(owner, address(positionManager), config_); } /// @dev Computes an ERC-7201 slot: keccak256(abi.encode(uint256(keccak256(namespace)) - 1)) & ~0xff. diff --git a/test/manager/rebalancer/RetargetterBase.t.sol b/test/manager/rebalancer/RetargetterBase.t.sol index 3163d30a..521c122d 100644 --- a/test/manager/rebalancer/RetargetterBase.t.sol +++ b/test/manager/rebalancer/RetargetterBase.t.sol @@ -83,12 +83,8 @@ contract RetargetterBaseTest is PositionManagerBaseTest { requestFactory = new RequestFactory(owner); retargetterQuoter = new RetargetterQuoter(); retargetterFactory = new RetargetterFactory(owner, address(retargetterQuoter), address(requestFactory)); - // The instance is bound to its position manager at creation (the init bind path) - retargetter = Retargetter( - retargetterFactory.createRetargetter( - owner, address(collateralToken), address(debtToken), address(positionManager), _defaultConfig() - ) - ); + // The instance is bound to its position manager at creation; the pair is derived from it + retargetter = Retargetter(retargetterFactory.createRetargetter(owner, address(positionManager), _defaultConfig())); fund = new MockRetargetterFund(address(debtToken), address(collateralToken)); flashLoanAdapter = new MorphoFlashLoanAdapter(address(morpho)); From 1ec46bd2b9a369a5f3c12a6f5b448da3ec26b5c2 Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Mon, 10 Aug 2026 15:25:28 +0200 Subject: [PATCH 5/7] refactor: share one bind function for init/setPositionManager, write-once pair (3F-483) Collapse the position-manager binding into a single internal _bindPositionManager used by both initialize and setPositionManager: for a nonzero manager it reads its assets and, while the stored pair is still zero, derives the immutable pair (else it must match); the zero address unbinds and leaves the pair untouched. The pair is therefore write-once regardless of whether the first bind happens at init or later, and initialize now accepts the zero address (create unbound, bind later). Removes the separate _checkPair and the duplicated init/setter logic. Smaller and simpler: Retargetter runtime 23,829 B on the ci profile, 747 B under EIP-170. Full suite 2347 passing, 74 invariants passing; the pair-match guard is mutation-checked. --- .../manager/rebalancer/IRetargetter.sol | 16 ++--- src/manager/rebalancer/Retargetter.sol | 59 +++++++++---------- src/manager/rebalancer/RetargetterFactory.sol | 21 ++++--- test/manager/rebalancer/Retargetter.t.sol | 17 ++++++ 4 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/interfaces/manager/rebalancer/IRetargetter.sol b/src/interfaces/manager/rebalancer/IRetargetter.sol index 840b4620..3e193a90 100644 --- a/src/interfaces/manager/rebalancer/IRetargetter.sol +++ b/src/interfaces/manager/rebalancer/IRetargetter.sol @@ -349,14 +349,14 @@ interface IRetargetter { function setFlashLoanModule(address module, bool whitelisted) external; /// @notice Rotates (or clears) the single position manager every operation runs against. - /// @dev A position manager is bound at initialize (its assets define the instance's pair); - /// this rotates the binding to another position manager on the same pair, or unbinds - /// it with the zero address to abandon the instance (operations then revert until a - /// same-pair manager is bound again). Operations never take a position manager as an - /// argument, so every start-gate and principal-cap read trusts an owner-curated address - /// rather than a caller-supplied one. A nonzero manager is checked to be a contract on - /// the bound pair; both paths revert while an operation is active (the binding can only - /// change while idle). + /// @dev The instance's asset pair is derived from the first position manager ever bound (at + /// initialize or here) and is immutable after; this rotates the binding to another + /// position manager on that pair, or unbinds it with the zero address to abandon the + /// instance (operations then revert until a same-pair manager is bound again). + /// Operations never take a position manager as an argument, so every start-gate and + /// principal-cap read trusts an owner-curated address rather than a caller-supplied + /// one. A nonzero manager is checked to be a contract on the bound pair; both paths + /// revert while an operation is active (the binding can only change while idle). /// @param positionManager The position manager to bind, or the zero address to unbind function setPositionManager(address positionManager) external; diff --git a/src/manager/rebalancer/Retargetter.sol b/src/manager/rebalancer/Retargetter.sol index 8c76658e..2ce787ce 100644 --- a/src/manager/rebalancer/Retargetter.sol +++ b/src/manager/rebalancer/Retargetter.sol @@ -163,29 +163,22 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// @notice Initializes the proxy instance and binds it to a position manager, deriving the /// asset pair from that manager. - /// @dev The pair is read from the position manager once and never mutated; every later - /// rebind through {setPositionManager} must report the same pair. Whitelists start - /// empty; zero estimates are valid and degrade the principal cap to the zero-rate - /// ideal formula. + /// @dev The pair is read from the position manager on the first bind and never mutated; + /// every later rebind through {setPositionManager} must report the same pair. Passing + /// the zero address leaves the instance unbound until the owner binds one. Whitelists + /// start empty; zero estimates are valid and degrade the principal cap to the + /// zero-rate ideal formula. /// @param owner_ The address that will own the instance - /// @param positionManager_ The position manager to bind (the pair is derived from its assets) + /// @param positionManager_ The position manager to bind (the pair is derived from its + /// assets), or the zero address to bind later /// @param config_ The initial configuration, validated against the documented bounds function initialize(address owner_, address positionManager_, RetargetterConfig calldata config_) external initializer { owner_.checkNotZero(); - positionManager_.checkContract(); _setConfig(config_); - // Derive the bound pair from the position manager, the single source of truth. The pair - // is immutable afterwards, so setFund can validate a fund once and every rebind stays on - // the same pair (see {_checkPair}) - RetargetterAssets storage assets_ = LibStorage.assetsStorage(); - (address collateralAsset, address debtAsset) = IPositionManager(positionManager_).assets(); - assets_.collateralAsset = collateralAsset; - assets_.debtAsset = debtAsset; - assets_.positionManager = positionManager_; - emit PositionManagerBound(positionManager_); + _bindPositionManager(positionManager_); _initializeOwner(owner_); } @@ -732,19 +725,13 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// @inheritdoc IRetargetter /// @dev Rotates the bound position manager, only while no operation is active so a live - /// operation's binding never moves under it. A nonzero manager is checked to be a - /// contract on the immutable bound pair; the zero address unbinds the instance, + /// operation's binding never moves under it. The zero address unbinds the instance, /// abandoning it until a same-pair manager is bound again (operations revert /// PositionManagerNotBound meanwhile). Every start-gate and principal-cap read trusts /// the bound address, which is why the binding is owner-curated, not caller-supplied. function setPositionManager(address positionManager) external onlyOwner nonReentrant { if (LibStorage.operationStorage().positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); - if (positionManager != address(0)) { - positionManager.checkContract(); - _checkPair(positionManager); - } - LibStorage.assetsStorage().positionManager = positionManager; - emit PositionManagerBound(positionManager); + _bindPositionManager(positionManager); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ @@ -918,16 +905,26 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali order_ = operation_.order(); } - /// @dev Reverts unless the position manager's assets equal the bound pair; run once at - /// setPositionManager. The fund's tokens were validated against the pair at setFund - /// and the Request is created with the debt asset, so a matching position manager - /// makes the whole operation match. - function _checkPair(address positionManager) internal view { + /// @dev Binds (or unbinds) the position manager, emitting {PositionManagerBound}. Shared by + /// {initialize} and {setPositionManager}. A nonzero manager must be a contract; its + /// assets define the instance's pair on the first bind (while the stored pair is still + /// zero) and must match that pair on every later bind, so the pair is write-once and + /// immutable. The zero address unbinds the instance and leaves the pair untouched, so a + /// later rebind still has to match it. + function _bindPositionManager(address positionManager) internal { RetargetterAssets storage assets_ = LibStorage.assetsStorage(); - (address collateralAsset, address debtAsset) = IPositionManager(positionManager).assets(); - if (collateralAsset != assets_.collateralAsset || debtAsset != assets_.debtAsset) { - revert LibRetargetterErrors.AssetMismatch(); + if (positionManager != address(0)) { + positionManager.checkContract(); + (address collateralAsset, address debtAsset) = IPositionManager(positionManager).assets(); + if (assets_.collateralAsset == address(0)) { + assets_.collateralAsset = collateralAsset; + assets_.debtAsset = debtAsset; + } else if (collateralAsset != assets_.collateralAsset || debtAsset != assets_.debtAsset) { + revert LibRetargetterErrors.AssetMismatch(); + } } + assets_.positionManager = positionManager; + emit PositionManagerBound(positionManager); } /// @dev The live principal cap for an operation carrying the given yield cap. Recomputed diff --git a/src/manager/rebalancer/RetargetterFactory.sol b/src/manager/rebalancer/RetargetterFactory.sol index ff20cfc6..7cc22a7e 100644 --- a/src/manager/rebalancer/RetargetterFactory.sol +++ b/src/manager/rebalancer/RetargetterFactory.sol @@ -32,11 +32,12 @@ import {LibClone} from "lib/solady/src/utils/LibClone.sol"; /// 4. The instance owner grants the Retargetter's own REBALANCER_ROLE (Solady _ROLE_0, /// bit value 1) and CONSUMER_ROLE (Solady _ROLE_1, bit value 2), whitelists funds /// and the adapter, and sets the yield estimates. The served PositionManager is bound -/// at creation (its assets define the instance's pair) and can be rotated to another -/// same-pair PositionManager via setPositionManager while idle. Each instance runs -/// against one bound PositionManager; grant a PositionManager's REBALANCER_ROLE to at -/// most one Retargetter, since the one-operation-at-a-time guard is per instance and a -/// second bound Retargetter could admit the full principal cap on its own. +/// at creation or later via setPositionManager (the first bind's assets define the +/// instance's immutable pair) and can be rotated to another same-pair PositionManager +/// while idle. Each instance runs against one bound PositionManager; grant a +/// PositionManager's REBALANCER_ROLE to at most one Retargetter, since the +/// one-operation-at-a-time guard is per instance and a second bound Retargetter could +/// admit the full principal cap on its own. contract RetargetterFactory { using LibClone for address; @@ -86,11 +87,13 @@ contract RetargetterFactory { /* FACTORY METHODS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Creates a new Retargetter proxy bound to a position manager. - /// @dev Deploys an ERC1967 beacon proxy and initializes it atomically; the asset pair is - /// derived from the position manager. Emits a {RetargetterCreated} event. + /// @notice Creates a new Retargetter proxy, optionally bound to a position manager. + /// @dev Deploys an ERC1967 beacon proxy and initializes it atomically; when a position + /// manager is passed the asset pair is derived from it, and the zero address leaves the + /// instance unbound until the owner binds one. Emits a {RetargetterCreated} event. /// @param owner The address that will own the Retargetter - /// @param positionManager The position manager to bind (the pair is derived from its assets) + /// @param positionManager The position manager to bind (the pair is derived from its + /// assets), or the zero address to bind later /// @param config The initial configuration /// @return retargetter The address of the newly deployed Retargetter proxy function createRetargetter(address owner, address positionManager, RetargetterConfig calldata config) diff --git a/test/manager/rebalancer/Retargetter.t.sol b/test/manager/rebalancer/Retargetter.t.sol index ccdfa867..9605a0a8 100644 --- a/test/manager/rebalancer/Retargetter.t.sol +++ b/test/manager/rebalancer/Retargetter.t.sol @@ -146,6 +146,23 @@ contract RetargetterTest is RetargetterBaseTest { retargetterFactory.createRetargetter(owner, nonContract, _defaultConfig()); } + function test_initialize_unboundWhenZeroPositionManager() public { + // Created with the zero address: no manager and no pair yet; the first bind sets both + Retargetter unbound = Retargetter(retargetterFactory.createRetargetter(owner, address(0), _defaultConfig())); + assertEq(unbound.boundPositionManager(), address(0), "created unbound"); + (address collateralAsset, address debtAsset) = unbound.assets(); + assertEq(collateralAsset, address(0), "pair unset before the first bind"); + assertEq(debtAsset, address(0), "pair unset before the first bind"); + + // The first bind derives and locks the pair from the position manager + vm.prank(owner); + unbound.setPositionManager(address(positionManager)); + assertEq(unbound.boundPositionManager(), address(positionManager), "bound"); + (collateralAsset, debtAsset) = unbound.assets(); + assertEq(collateralAsset, address(collateralToken), "pair derived on the first bind"); + assertEq(debtAsset, address(debtToken), "pair derived on the first bind"); + } + function test_initialize_revertDoubleInitialize() public { vm.expectRevert(Initializable.InvalidInitialization.selector); retargetter.initialize(owner, address(positionManager), _defaultConfig()); From 713b4e2a33d62b129d90a1ec5e9f26e04ede908e Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Mon, 10 Aug 2026 15:41:27 +0200 Subject: [PATCH 6/7] refactor: drop operation.positionManager, key active on fund + bound PM The operation always runs against the instance-bound position manager, which cannot change while active, so storing it on the operation record was pure duplication. Remove the field: - fund becomes the operation-active flag (both start paths set it, only clearOperation clears it, and no fund can be the zero address since setFund contract-checks it). isActive, _checkStart and setPositionManager's active guard key on fund; checkActive keys on fund and returns the bound PM. - consume/authorizeMinting/resolve read the bound PM from assets storage; the operation() view derives its positionManager (bound PM when active, else zero) instead of returning a stored field, keeping the same ABI and semantics. RetargetterOperation repacks to 7 slots (request now shares slot 0, fund + the order mode share slot 1); the storage-layout and stray-order tests are updated. Full suite 2347 passing, 74 invariants passing; the fund active flag is mutation-checked. Retargetter runtime 23,953 B on the ci profile, 623 B under EIP-170. --- src/libs/manager/rebalancer/LibStorage.sol | 27 +++++----- src/manager/rebalancer/Retargetter.sol | 39 +++++++------- test/manager/rebalancer/Retargetter.t.sol | 51 +++++++++---------- .../manager/rebalancer/RetargetterSmoke.t.sol | 10 ++-- 4 files changed, 66 insertions(+), 61 deletions(-) diff --git a/src/libs/manager/rebalancer/LibStorage.sol b/src/libs/manager/rebalancer/LibStorage.sol index 887eed65..8f278a9b 100644 --- a/src/libs/manager/rebalancer/LibStorage.sol +++ b/src/libs/manager/rebalancer/LibStorage.sol @@ -41,13 +41,15 @@ struct RetargetterWhitelists { } /// @notice The one in-flight operation (namespace "retargetter.operation"). -/// @dev `positionManager != address(0)` is the operation-active flag. An ASYNC operation uses -/// every field and is zeroed at resolve; a SYNC flash-loan window populates only the -/// addresses the steps need (`positionManager`, `fund`) and zeroes them at window close, -/// so `request == address(0)` inside a window is what gates the ASYNC-only steps. The -/// stored order is partial: `owner` and `receiver` are always the Retargetter (enforced -/// at create) so {LibStorage.order} rebuilds the full Order in memory. -/// @param positionManager The operation's position manager +/// @dev `fund != address(0)` is the operation-active flag: both start paths set it and only +/// {clearOperation} clears it, and no fund can be the zero address (setFund contract- +/// checks it). The position manager an operation runs against is the instance-bound one +/// ({RetargetterAssets.positionManager}), which cannot change while active, so it is not +/// duplicated here. An ASYNC operation uses every field and is zeroed at resolve; a SYNC +/// flash-loan window populates only `fund` and zeroes it at window close, so +/// `request == address(0)` inside a window is what gates the ASYNC-only steps. The stored +/// order is partial: `owner` and `receiver` are always the Retargetter (enforced at +/// create) so {LibStorage.order} rebuilds the full Order in memory. /// @param startedAt The loan clock origin, set at the first consume or nonzero mint /// authorization (0 until then); rewound to 0 by a revocation that leaves the /// operation with no PT, no YT and no pending authorization @@ -74,7 +76,6 @@ struct RetargetterWhitelists { /// eagerly, a completed mint is pruned lazily by the next write-path computation, and /// resolve empties the set struct RetargetterOperation { - address positionManager; uint40 startedAt; uint16 operationMaxYieldBps; bool consumptionClosed; @@ -135,12 +136,13 @@ library LibStorage { /* OPERATION GATES */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @dev Reverts unless an operation is active (ASYNC or SYNC window); returns its position - /// manager. + /// @dev Reverts unless an operation is active (ASYNC or SYNC window), keyed on the fund flag; + /// returns the instance-bound position manager the operation runs against (always set + /// while active, since it was required at start and cannot be unbound mid-operation). /// @param self The storage pointer to the operation function checkActive(RetargetterOperation storage self) internal view returns (address positionManager) { - positionManager = self.positionManager; - if (positionManager == address(0)) revert LibRetargetterErrors.NoActiveOperation(); + if (self.fund == address(0)) revert LibRetargetterErrors.NoActiveOperation(); + positionManager = assetsStorage().positionManager; } /// @dev Reverts unless an ASYNC operation is active (SYNC windows carry no Request); @@ -305,7 +307,6 @@ library LibStorage { /// each removal a plain pop); the order fields are cleared separately as orders end. /// @param self The storage pointer to the operation function clearOperation(RetargetterOperation storage self) internal { - self.positionManager = address(0); self.startedAt = 0; self.operationMaxYieldBps = 0; self.consumptionClosed = false; diff --git a/src/manager/rebalancer/Retargetter.sol b/src/manager/rebalancer/Retargetter.sol index 2ce787ce..0868b2f9 100644 --- a/src/manager/rebalancer/Retargetter.sol +++ b/src/manager/rebalancer/Retargetter.sol @@ -234,7 +234,8 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali 0 ); - operation_.positionManager = positionManager; + // fund is the operation-active flag (see {RetargetterOperation}); the position manager it + // runs against is the instance-bound one, not duplicated here operation_.operationMaxYieldBps = effectiveYieldCap; operation_.request = request; operation_.repaymentDeadline = repaymentDeadline; @@ -275,7 +276,9 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali operation_.checkYield(offer.amount, offer.expectedReturn); operation_.checkConsumptionWindow(); ytAmount = IRequest(request).consume(offer, signature, ptAmount); - operation_.checkPrincipalCap(request, 0, _maxPrincipal(operation_.positionManager, operation_.operationMaxYieldBps)); + operation_.checkPrincipalCap( + request, 0, _maxPrincipal(LibStorage.assetsStorage().positionManager, operation_.operationMaxYieldBps) + ); emit OfferConsumed(request, offer.maker, ptAmount, ytAmount); } @@ -306,7 +309,7 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali ptAmount, ytAmount, ptAmount, - _maxPrincipal(operation_.positionManager, operation_.operationMaxYieldBps) + _maxPrincipal(LibStorage.assetsStorage().positionManager, operation_.operationMaxYieldBps) ); // The capacity bound keeps every loop over the set within gas reach; see the constant accounts.add(to, MAX_AUTHORIZED_ACCOUNTS); @@ -388,7 +391,7 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali function resolve() external onlyOwnerOrRebalancer nonReentrant { RetargetterOperation storage operation_ = LibStorage.operationStorage(); address request = operation_.checkRequest(); - address positionManager = operation_.positionManager; + address positionManager = LibStorage.assetsStorage().positionManager; if (!IRequest(request).syncRepaidStatus()) revert LibRetargetterErrors.RequestNotRepaid(); operation_.checkNoPendingOrder(operation_.fund); _checkResidual(); @@ -595,15 +598,15 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali revert LibRetargetterErrors.ModuleNotWhitelisted(); } - // Open the window: the operation storage carries the addresses the steps read (and locks - // out nested starts); the module slot authenticates the callback and is zeroed on its - // entry (single-shot). The payload digest and the pre-loan debt balance bind the callback - // to this exact payload and to the full delivery of the principal, so a whitelisted - // module can neither substitute its own step calls nor collect the repayment approval - // for funds it never delivered. Step authority is not handed out here: the window slot - // is set inside the callback around the payload alone, so the module holds no authority - // in its own frame and cannot source the delivery from the position manager itself - operation_.positionManager = positionManager; + // Open the window: the operation's fund both records the venue the steps read and flags + // the operation active (locking out nested starts). The module slot authenticates the + // callback and is zeroed on its entry (single-shot). The payload digest and the pre-loan + // debt balance bind the callback to this exact payload and to the full delivery of the + // principal, so a whitelisted module can neither substitute its own step calls nor collect + // the repayment approval for funds it never delivered. Step authority is not handed out + // here: the window slot is set inside the callback around the payload alone, so the module + // holds no authority in its own frame and cannot source the delivery from the position + // manager itself operation_.fund = fund; address debtAsset = LibStorage.assetsStorage().debtAsset; bytes memory payload = abi.encode(data); @@ -730,7 +733,7 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// PositionManagerNotBound meanwhile). Every start-gate and principal-cap read trusts /// the bound address, which is why the binding is owner-curated, not caller-supplied. function setPositionManager(address positionManager) external onlyOwner nonReentrant { - if (LibStorage.operationStorage().positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); + if (LibStorage.operationStorage().fund != address(0)) revert LibRetargetterErrors.OperationActive(); _bindPositionManager(positionManager); } @@ -763,8 +766,10 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali ) { RetargetterOperation storage operation_ = LibStorage.operationStorage(); + // The operation runs against the instance-bound position manager while active (fund set), + // and zero when idle: derived rather than stored on the operation return ( - operation_.positionManager, + operation_.fund != address(0) ? LibStorage.assetsStorage().positionManager : address(0), operation_.request, operation_.fund, operation_.startedAt, @@ -780,7 +785,7 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// @inheritdoc IRetargetter function isActive() external view returns (bool) { - return LibStorage.operationStorage().positionManager != address(0); + return LibStorage.operationStorage().fund != address(0); } /// @inheritdoc IRetargetter @@ -882,7 +887,7 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali view returns (address positionManager) { - if (operation_.positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); + if (operation_.fund != address(0)) revert LibRetargetterErrors.OperationActive(); if (operation_.orderLive) revert LibRetargetterErrors.OrderActive(); positionManager = LibStorage.assetsStorage().positionManager; if (positionManager == address(0)) revert LibRetargetterErrors.PositionManagerNotBound(); diff --git a/test/manager/rebalancer/Retargetter.t.sol b/test/manager/rebalancer/Retargetter.t.sol index 9605a0a8..04ddd632 100644 --- a/test/manager/rebalancer/Retargetter.t.sol +++ b/test/manager/rebalancer/Retargetter.t.sol @@ -1062,30 +1062,29 @@ contract RetargetterTest is RetargetterBaseTest { assertEq(accounts.length, 1, "one registered account"); assertEq(accounts[0], broker, "broker registered"); - // Slot A: positionManager (bits 0..159) | startedAt (160..199) | operationMaxYieldBps - // (200..215) | consumptionClosed (216..223) | horizon (224..255): full, no wasted bits + // Slot A: startedAt (bits 0..39) | operationMaxYieldBps (40..55) | consumptionClosed + // (56..63) | horizon (64..95) | request (96..255): full, no wasted bits (no stored + // position manager: it is the instance-bound one, and fund is the active flag) uint256 slotA = uint256(vm.load(address(retargetter), OPERATION_STORAGE_SLOT)); - assertEq(address(uint160(slotA)), address(positionManager), "position manager bits"); - assertEq(uint40(slotA >> 160), uint40(startTime + 1 days), "startedAt bits"); - assertEq(uint16(slotA >> 200), 100, "operation max yield bits"); - assertEq(uint8(slotA >> 216), 0, "consumption closed bit clear"); - assertEq(slotA >> 224, DEFAULT_HORIZON, "horizon bits"); - - // Slot B: request (bits 0..159) | repaymentDeadline (160..199) | tickDuration (200..223) - // | tickThreshold (224..247) + assertEq(uint40(slotA), uint40(startTime + 1 days), "startedAt bits"); + assertEq(uint16(slotA >> 40), 100, "operation max yield bits"); + assertEq(uint8(slotA >> 56), 0, "consumption closed bit clear"); + 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 uint256 slotB = uint256(vm.load(address(retargetter), bytes32(uint256(OPERATION_STORAGE_SLOT) + 1))); - assertEq(address(uint160(slotB)), request, "request bits"); - assertEq(uint40(slotB >> 160), uint40(startTime + 90 days), "repayment deadline bits"); - assertEq(uint24(slotB >> 200), DEFAULT_TICK_DURATION, "tick duration bits"); - assertEq(uint24(slotB >> 224), DEFAULT_TICK_THRESHOLD, "tick threshold bits"); - assertEq(slotB >> 248, 0, "slot B upper bits clean"); + 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"); - // Slot C: fund (bits 0..159) | orderMode (160..167) | orderLive (168..175) + // Slot C: orderLive (bits 0..7) uint256 slotC = uint256(vm.load(address(retargetter), bytes32(uint256(OPERATION_STORAGE_SLOT) + 2))); - assertEq(address(uint160(slotC)), address(fund), "fund bits"); - assertEq(uint8(slotC >> 160), uint8(Mode.REDEEM), "order mode bits"); - assertEq(uint8(slotC >> 168), 1, "order live bits"); - assertEq(slotC >> 176, 0, "slot C upper bits clean"); + assertEq(uint8(slotC), 1, "order live bits"); + assertEq(slotC >> 8, 0, "slot C upper bits clean"); // Slots D to F: the stored order's input, output and salt assertEq( @@ -1098,15 +1097,15 @@ contract RetargetterTest is RetargetterBaseTest { vm.load(address(retargetter), bytes32(uint256(OPERATION_STORAGE_SLOT) + 5)), bytes32(uint256(42)), "order salt" ); - // Pulling funds flips the consumption-closed bit (216..223) and nothing else in slot A + // Pulling funds flips the consumption-closed bit (56..63) and nothing else in slot A vm.prank(rebalancer); retargetter.pullRequestFunds(1); slotA = uint256(vm.load(address(retargetter), OPERATION_STORAGE_SLOT)); - assertEq(address(uint160(slotA)), address(positionManager), "position manager bits unchanged"); - assertEq(uint40(slotA >> 160), uint40(startTime + 1 days), "startedAt bits unchanged"); - assertEq(uint16(slotA >> 200), 100, "operation max yield bits unchanged"); - assertEq(uint8(slotA >> 216), 1, "consumption closed bit set"); - assertEq(slotA >> 224, DEFAULT_HORIZON, "horizon bits unchanged by the pull"); + assertEq(uint40(slotA), uint40(startTime + 1 days), "startedAt bits unchanged"); + assertEq(uint16(slotA >> 40), 100, "operation max yield bits unchanged"); + assertEq(uint8(slotA >> 56), 1, "consumption closed bit set"); + assertEq(uint32(slotA >> 64), DEFAULT_HORIZON, "horizon bits unchanged by the pull"); + assertEq(address(uint160(slotA >> 96)), request, "request bits unchanged by the pull"); } function test_storageLayout_assetsPacking() public view { diff --git a/test/manager/rebalancer/RetargetterSmoke.t.sol b/test/manager/rebalancer/RetargetterSmoke.t.sol index e4a1437f..b3cbeaeb 100644 --- a/test/manager/rebalancer/RetargetterSmoke.t.sol +++ b/test/manager/rebalancer/RetargetterSmoke.t.sol @@ -85,13 +85,13 @@ contract RetargetterSmokeTest is RetargetterBaseTest { assertApproxEqAbs(_currentLtv(), 6875e14, 1e14, "post LTV (11000/16000)"); } - /// @dev Plants a stray live order directly in storage. The orderLive flag packs into the - /// operation namespace's third slot at bit 168 (after the fund address and the mode); - /// no reachable flow leaves it set without an active operation, so the defensive - /// OrderActive guards at both start entry points need this to be exercised. + /// @dev Plants a stray live order directly in storage. The orderLive flag sits alone at bit + /// 0 of the operation namespace's third slot (the fund address and the order mode moved + /// into the second slot); no reachable flow leaves it set without an active operation, + /// so the defensive OrderActive guards at both start entry points need this exercised. function _plantStrayOrder() internal { bytes32 slot = bytes32(uint256(OPERATION_STORAGE_SLOT) + 2); - vm.store(address(retargetter), slot, bytes32(uint256(1) << 168)); + vm.store(address(retargetter), slot, bytes32(uint256(1))); } function test_startRetargetting_revertsOrderActiveOnStrayOrder() public { From 444381fc6b527bc2b5f5ee88f7d0a463cec6b03c Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Tue, 11 Aug 2026 11:26:29 +0200 Subject: [PATCH 7/7] fix: settle the one-trip bound's rounding order and state the cap's minter trust Review follow-ups on the Retargetter batch: - RetargetterQuoter: the one-trip repayment bound now grows the collateral before applying the target (the order settlement itself uses) and rounds the drifted debt up, so the worst permitted repayment of a bound-sized principal is borrowable at target in integers, not just in the reals. The reversed order could oversize the bound by a few units and trip the settlement direction check by one unit of LTV. - Retargetter/IRetargetter: the post-call principal cap NatSpec no longer claims only the position manager's owner or rebalancer can move collateral after consume returns; it names the minter-role trust boundary the read relies on and the required role separation. - Tests: regression fixture with the reviewer's six-decimal counterexample plus a fuzz invariant asserting the worst repayment stays borrowable at target under worst-case realized integer state. --- .../manager/rebalancer/IRetargetter.sol | 4 +- .../manager/rebalancer/IRetargetterQuoter.sol | 6 +- src/manager/rebalancer/Retargetter.sol | 9 ++- src/manager/rebalancer/RetargetterQuoter.sol | 26 ++++++-- .../rebalancer/RetargetterQuoter.t.sol | 61 +++++++++++++++++-- 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/src/interfaces/manager/rebalancer/IRetargetter.sol b/src/interfaces/manager/rebalancer/IRetargetter.sol index 3e193a90..ad946c74 100644 --- a/src/interfaces/manager/rebalancer/IRetargetter.sol +++ b/src/interfaces/manager/rebalancer/IRetargetter.sol @@ -197,7 +197,9 @@ interface IRetargetter { /// be overpaid for time it never covered and, on a default, would dilute the earlier /// lenders' recovery. The principal cap is checked after the Request call, once the /// minted amount sits in the PT supply, so a maker callback moving the cap mid-call - /// cannot leave the operation committed above it. + /// cannot leave the operation committed above it. The read trusts the position + /// manager's minters, whose role must therefore never overlap makers or consumers + /// (see the implementation notes). /// @param offer The signed offer /// @param signature The maker's EIP-712 signature /// @param ptAmount The principal amount to consume diff --git a/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol b/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol index 93e20bf4..f11c9d62 100644 --- a/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol +++ b/src/interfaces/manager/rebalancer/IRetargetterQuoter.sol @@ -94,8 +94,12 @@ interface IRetargetterQuoter { /// @dev The largest principal whose worst permitted repayment (the principal plus the full /// flat yield cap) can still be borrowed at target once the subscription has landed as /// collateral: `P * (1 + yieldCap) <= targetLtv * (K * (1 + Yc) + P) - D * (1 + Rb)`. - /// Same accrual conventions as `ltvUpPrincipal`; the bridge yield estimate is replaced + /// Same accrual terms as `ltvUpPrincipal`, with the bridge yield estimate replaced /// by the flat cap because repayment prices actual YT supply, not the estimate. + /// The bound is exact in integers: the collateral growth floors before the target + /// applies (matching the settlement LTV check's own arithmetic) and the drifted debt + /// rounds up, so the worst repayment of a bound-sized principal is always borrowable + /// at target in one settlement trip. /// Returns 0 when the position is at or above target once the accruals are applied. /// Reverts with `InvalidParameters` only at a full target LTV combined with a zero /// yield cap. diff --git a/src/manager/rebalancer/Retargetter.sol b/src/manager/rebalancer/Retargetter.sol index 0868b2f9..9d55c4b8 100644 --- a/src/manager/rebalancer/Retargetter.sol +++ b/src/manager/rebalancer/Retargetter.sol @@ -262,9 +262,12 @@ contract Retargetter is IRetargetter, IFlashLoanReceiver, OwnableRoles, Initiali /// the Request call, when the minted amount already sits in the PT supply: the maker /// callback inside Request.consume can move the live cap (a liquidation shrinks it), /// and only a post-call read cannot be invalidated. The call is atomic, so failing - /// late rolls back the callback, the transfer and the mint alike, and a maker cannot - /// inflate capacity just for the check: once the call returns, only the position - /// manager's owner or rebalancer can move collateral back out. + /// late rolls back the callback, the transfer and the mint alike. The read still + /// trusts the position manager's minters: they deposit and withdraw at will, so a + /// maker or consumer that also held that role could inflate the position around the + /// call and unwind afterwards, stranding the supply above the live cap. No read + /// timing defends against that; the position manager's owner must keep its minter + /// role off makers and consumers. function consume(Offer calldata offer, bytes calldata signature, uint256 ptAmount) external onlyOwnerOrRoles(CONSUMER_ROLE) diff --git a/src/manager/rebalancer/RetargetterQuoter.sol b/src/manager/rebalancer/RetargetterQuoter.sol index 0760a063..a05b92cd 100644 --- a/src/manager/rebalancer/RetargetterQuoter.sol +++ b/src/manager/rebalancer/RetargetterQuoter.sol @@ -63,6 +63,13 @@ import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// /// x = (t * K * (1 + Yc) - D * (1 + Rb)) / (1 + y - t) /// +/// Unlike the sizing estimates, this bound must hold in integers and not just in the +/// reals: settlement quotes the grown collateral before its LTV check divides by it, +/// so the bound grows the collateral first and applies the target second (flooring +/// each step), rounds the drifted debt up and floors the final division. Any other +/// rounding order can size a principal whose worst repayment overshoots the settled +/// borrow capacity by a unit and trips the settlement direction check. +/// /// LTV-down sizing (`ltvDownPrincipal`), for over-leveraged positions (D / K > t): borrow /// x, repay x of position debt, withdraw collateral of quoted value w and redeem it, and /// at settlement repay the bridge `x * (1 + Yr)` from the redemption proceeds. The freed @@ -111,8 +118,9 @@ import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol"; /// owed = P + ceil(Y * min(paidDuration, H) / H) <= P + Y /// /// Rounding: principals round down, while everything the protocol must pay or set aside -/// rounds up (the owed yield, the collateral to free, the shortfall side of the mismatch), -/// so lenders are never underpaid and remediations never undershoot. +/// rounds up (the owed yield, the collateral to free, the shortfall side of the mismatch, +/// the drifted debt inside the one-trip bound), so lenders are never underpaid, +/// remediations never undershoot and the one-trip bound never oversizes. contract RetargetterQuoter is IRetargetterQuoter { using FixedPointMathLib for uint256; @@ -179,7 +187,9 @@ contract RetargetterQuoter is IRetargetterQuoter { uint256 duration ) public pure returns (uint256 principal) { uint256 grownTarget = _grownTarget(collateralQuoted, targetLtv, collateralYieldRate, duration); - uint256 driftedDebt = _driftedDebt(debt, borrowRate, duration); + // The settled venue debt is a liability the borrow capacity must cover before the + // repayment, so the bound rounds it up where the sizing estimates round down + uint256 driftedDebt = debt.fullMulDivUp(WAD + _scaledRate(borrowRate, duration), WAD); if (grownTarget <= driftedDebt) return 0; // Denominator 1 + y - targetLtv; zero only at targetLtv == WAD with a zero yield cap uint256 denominator = WAD + maxYieldBps * WAD / BPS - targetLtv; @@ -282,16 +292,20 @@ contract RetargetterQuoter is IRetargetterQuoter { } /// @dev Target collateral value grown by the collateral yield over settlement: - /// `targetLtv * K * (1 + Yc)`. + /// `targetLtv * K * (1 + Yc)`. Grows the collateral first and applies the target + /// second, mirroring the settlement LTV check (which quotes the grown collateral + /// before dividing by it), so a bound built on this term never exceeds the settled + /// position's borrow capacity at target. function _grownTarget(uint256 collateralQuoted, uint256 targetLtv, uint256 collateralYieldRate, uint256 duration) internal pure returns (uint256) { - return collateralQuoted.fullMulDiv(targetLtv, WAD).fullMulDiv(WAD + _scaledRate(collateralYieldRate, duration), WAD); + return collateralQuoted.fullMulDiv(WAD + _scaledRate(collateralYieldRate, duration), WAD).fullMulDiv(targetLtv, WAD); } - /// @dev Existing debt drifted by the borrow rate over settlement: `D * (1 + Rb)`. + /// @dev Existing debt drifted by the borrow rate over settlement: `D * (1 + Rb)`, rounded + /// down (the one-trip bound recomputes this term rounded up instead). function _driftedDebt(uint256 debt, uint256 borrowRate, uint256 duration) internal pure returns (uint256) { return debt.fullMulDiv(WAD + _scaledRate(borrowRate, duration), WAD); } diff --git a/test/manager/rebalancer/RetargetterQuoter.t.sol b/test/manager/rebalancer/RetargetterQuoter.t.sol index d8dbdb04..4d85594e 100644 --- a/test/manager/rebalancer/RetargetterQuoter.t.sol +++ b/test/manager/rebalancer/RetargetterQuoter.t.sol @@ -36,13 +36,14 @@ contract RetargetterQuoterTest is Test { return rate.fullMulDiv(duration, RATE_YEAR); } - /// @dev Mirrors the quoter's grown target term: `targetLtv * K * (1 + Yc)` (floor at each step). + /// @dev Mirrors the quoter's grown target term: `targetLtv * K * (1 + Yc)`, growing the + /// collateral before applying the target (floor at each step). function _grownTarget(uint256 collateralQuoted, uint256 targetLtv, uint256 collateralYieldRate, uint256 duration) internal pure returns (uint256) { - return collateralQuoted.fullMulDiv(targetLtv, WAD).fullMulDiv(WAD + _scaled(collateralYieldRate, duration), WAD); + return collateralQuoted.fullMulDiv(WAD + _scaled(collateralYieldRate, duration), WAD).fullMulDiv(targetLtv, WAD); } /// @dev Mirrors the quoter's drifted debt term: `D * (1 + Rb)` (floor). @@ -142,8 +143,9 @@ contract RetargetterQuoterTest is Test { uint256 principal = quoter.ltvUpOneTripPrincipal(k, d, tau, 1000, rb, yc, dt); - // Exact recomputation: the accrual terms match ltvUpPrincipal, only the divisor differs - uint256 numerator = _grownTarget(k, tau, yc, dt) - _driftedDebt(d, rb, dt); + // Exact recomputation: the collateral term matches ltvUpPrincipal, the debt drift + // rounds up instead of down and the divisor carries the flat cap + uint256 numerator = _grownTarget(k, tau, yc, dt) - d.fullMulDivUp(WAD + _scaled(rb, dt), WAD); assertEq(principal, numerator.fullMulDiv(WAD, WAD + 0.1e18 - tau), "extended formula"); // The flat cap always undercuts the same-numerator ideal whose scaled Yr is below it assertLt(principal, quoter.ltvUpPrincipal(k, d, tau, 0.04e18, rb, yc, dt), "below the estimated ideal"); @@ -160,6 +162,57 @@ contract RetargetterQuoterTest is Test { quoter.ltvUpOneTripPrincipal(10_000e18, 5_000e18, WAD, 0, 0, 0, 0); } + function test_ltvUpOneTripPrincipal_settlementRoundingOrder() public view { + // Six-decimal fixture (target 0.7, flat cap 10%, collateral growth 0.001 over a + // rate-year window so the scaled rate is the literal) where applying the target before + // the collateral growth rounds the bound three units too high: the worst repayment of + // that reversed-order principal exceeds the settled borrow capacity at target by one + // unit, so the settlement direction check would revert. The shipped order (grow first, + // then target) stays borrowable exactly. + uint256 k = 1_000_002.412953e6; + uint256 d = 500_007.6209e6; + + uint256 principal = quoter.ltvUpOneTripPrincipal(k, d, 0.7e18, 1000, 0, 0.001e18, RATE_YEAR); + assertEq(principal, 501_735.174637e6, "literal anchor"); + + // Settled state under the exact forecast: the collateral quote grows, the subscription + // lands at face value, and the worst repayment carries the full flat cap + uint256 grownCollateral = k.fullMulDiv(WAD + 0.001e18, WAD); + assertLe((d + principal + principal * 1000 / 10_000).divWad(grownCollateral + principal), 0.7e18, "borrowable"); + + // The reversed rounding order oversizes and its worst repayment trips the check + uint256 reversed = + (k.fullMulDiv(0.7e18, WAD).fullMulDiv(WAD + 0.001e18, WAD) - d).fullMulDiv(WAD, WAD + 0.1e18 - 0.7e18); + assertEq(reversed, principal + 3, "reversed order oversizes"); + assertGt((d + reversed + reversed * 1000 / 10_000).divWad(grownCollateral + reversed), 0.7e18, "over target"); + } + + function testFuzz_ltvUpOneTripPrincipal_worstRepaymentBorrowableAtTarget( + uint256 collateral, + uint256 debt, + uint256 targetLtv, + uint256 yieldCapBps, + uint256 collateralYield, + uint256 borrowDrift + ) public view { + collateral = bound(collateral, 1e6, 1e30); + targetLtv = bound(targetLtv, 1e15, WAD - 1); + debt = bound(debt, 0, collateral.fullMulDiv(targetLtv, WAD) * 2); + yieldCapBps = bound(yieldCapBps, 0, 5000); + collateralYield = bound(collateralYield, 0, 0.25e18); // already scaled over the window + borrowDrift = bound(borrowDrift, 0, 0.25e18); + + uint256 principal = + quoter.ltvUpOneTripPrincipal(collateral, debt, targetLtv, yieldCapBps, borrowDrift, collateralYield, RATE_YEAR); + if (principal == 0) return; + + // Worst-case realized integers consistent with the forecast: the collateral quote + // floors its growth, the venue rounds its debt accrual up, repayment carries the cap + uint256 settledCollateral = collateral.fullMulDiv(WAD + collateralYield, WAD) + principal; + uint256 settledDebt = debt.fullMulDivUp(WAD + borrowDrift, WAD) + principal + principal * yieldCapBps / 10_000; + assertLe(settledDebt.divWad(settledCollateral), targetLtv, "worst repayment borrowable at target"); + } + function testFuzz_ltvUpPrincipal_monotoneInDebt( uint256 collateral, uint256 targetLtv,