Skip to content

Commit d456bb4

Browse files
solofberlinclaude
andcommitted
feat: add OpenLiquidityStrategy for direct rebalancing
Add a new liquidity strategy where the caller of rebalance() acts as the liquidity source, providing tokens via ERC20 transfers instead of minting/burning through a reserve. Uses EIP-1153 transient storage to track the rebalancer address within a transaction. Includes full test suite (40 tests): admin, rebalance, and hook tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cc47d64 commit d456bb4

File tree

7 files changed

+1493
-0
lines changed

7 files changed

+1493
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
pragma solidity ^0.8.0;
3+
4+
import { ILiquidityStrategy } from "./ILiquidityStrategy.sol";
5+
6+
/**
7+
* @title IOpenLiquidityStrategy
8+
* @notice Interface for liquidity strategy where the rebalancer (caller) provides liquidity directly
9+
*/
10+
interface IOpenLiquidityStrategy is ILiquidityStrategy {
11+
/* ============================================================ */
12+
/* ======================== Errors ============================ */
13+
/* ============================================================ */
14+
15+
/// @notice Thrown when the rebalancer has no collateral available for contraction
16+
error OLS_OUT_OF_COLLATERAL();
17+
/// @notice Thrown when the rebalancer has no debt tokens available for expansion
18+
error OLS_OUT_OF_DEBT();
19+
20+
/* ============================================================ */
21+
/* ==================== Mutative Functions ==================== */
22+
/* ============================================================ */
23+
24+
/**
25+
* @notice Initializes the OpenLiquidityStrategy contract
26+
* @param _initialOwner The initial owner of the contract
27+
*/
28+
function initialize(address _initialOwner) external;
29+
30+
/**
31+
* @notice Adds a new liquidity pool to be managed by the strategy
32+
* @param params The parameters for adding a pool
33+
*/
34+
function addPool(AddPoolParams calldata params) external;
35+
36+
/**
37+
* @notice Removes a pool from the strategy
38+
* @param pool The address of the pool to remove
39+
*/
40+
function removePool(address pool) external;
41+
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity 0.8.24;
3+
4+
import { IERC20Upgradeable as IERC20 } from "openzeppelin-contracts-upgradeable/contracts/token/ERC20/IERC20Upgradeable.sol";
5+
// solhint-disable-next-line max-line-length
6+
import { SafeERC20Upgradeable as SafeERC20 } from "openzeppelin-contracts-upgradeable/contracts/token/ERC20/utils/SafeERC20Upgradeable.sol";
7+
8+
import { LiquidityStrategy } from "./LiquidityStrategy.sol";
9+
import { ILiquidityStrategy } from "../interfaces/ILiquidityStrategy.sol";
10+
import { IOpenLiquidityStrategy } from "../interfaces/IOpenLiquidityStrategy.sol";
11+
import { IFPMM } from "../interfaces/IFPMM.sol";
12+
import { LiquidityStrategyTypes as LQ } from "../libraries/LiquidityStrategyTypes.sol";
13+
14+
/**
15+
* @title OpenLiquidityStrategy
16+
* @notice Liquidity strategy where the caller of rebalance() acts as the liquidity source.
17+
* @dev The rebalancer provides tokens to the pool and receives tokens from the pool
18+
* via standard ERC20 transfers. The rebalancer must approve this contract for
19+
* the tokens owed to the pool before calling rebalance().
20+
*/
21+
contract OpenLiquidityStrategy is IOpenLiquidityStrategy, LiquidityStrategy {
22+
using LQ for LQ.Context;
23+
using SafeERC20 for IERC20;
24+
25+
/// @dev Transient storage slot for the rebalancer address
26+
bytes32 private constant REBALANCER_TSLOT = keccak256("OpenLiquidityStrategy.rebalancer");
27+
28+
/* ============================================================ */
29+
/* ======================= Constructor ======================== */
30+
/* ============================================================ */
31+
32+
/**
33+
* @notice Disables initializers on implementation contracts.
34+
* @param disable Set to true to disable initializers (for proxy pattern).
35+
*/
36+
constructor(bool disable) LiquidityStrategy(disable) {}
37+
38+
/// @inheritdoc IOpenLiquidityStrategy
39+
function initialize(address _initialOwner) public initializer {
40+
__LiquidityStrategy_init(_initialOwner);
41+
}
42+
43+
/* ============================================================ */
44+
/* ==================== External Functions ==================== */
45+
/* ============================================================ */
46+
47+
/// @inheritdoc IOpenLiquidityStrategy
48+
function addPool(AddPoolParams calldata params) external onlyOwner {
49+
LiquidityStrategy._addPool(params);
50+
}
51+
52+
/// @inheritdoc IOpenLiquidityStrategy
53+
function removePool(address pool) external onlyOwner {
54+
LiquidityStrategy._removePool(pool);
55+
}
56+
57+
function rebalance(address pool) external override(ILiquidityStrategy, LiquidityStrategy) nonReentrant {
58+
_setRebalancer(_msgSender());
59+
60+
_ensurePool(pool);
61+
if (_isHookCalled(pool)) {
62+
revert LS_CAN_ONLY_REBALANCE_ONCE(pool);
63+
}
64+
65+
PoolConfig memory config = poolConfigs[pool];
66+
// Skip cooldown check for first rebalance (lastRebalance == 0)
67+
if (config.lastRebalance > 0 && block.timestamp < config.lastRebalance + config.rebalanceCooldown) {
68+
revert LS_COOLDOWN_ACTIVE();
69+
}
70+
71+
LQ.Context memory ctx = LQ.newRebalanceContext(pool, config);
72+
LQ.Action memory action = _determineAction(ctx);
73+
74+
(address debtToken, address collToken) = ctx.tokens();
75+
76+
bytes memory hookData = abi.encode(
77+
LQ.CallbackData({
78+
amountOwedToPool: action.amountOwedToPool,
79+
dir: action.dir,
80+
isToken0Debt: ctx.isToken0Debt,
81+
debtToken: debtToken,
82+
collToken: collToken
83+
})
84+
);
85+
86+
poolConfigs[pool].lastRebalance = uint32(block.timestamp);
87+
IFPMM(pool).rebalance(action.amount0Out, action.amount1Out, hookData);
88+
if (!_isHookCalled(pool)) {
89+
revert LS_HOOK_NOT_CALLED();
90+
}
91+
92+
// slither-disable-start incorrect-equality
93+
emit LiquidityMoved({
94+
pool: pool,
95+
direction: action.dir,
96+
tokenGivenToPool: action.dir == LQ.Direction.Expand ? debtToken : collToken,
97+
amountGivenToPool: action.amountOwedToPool,
98+
tokenTakenFromPool: action.dir == LQ.Direction.Expand ? collToken : debtToken,
99+
amountTakenFromPool: action.amount0Out + action.amount1Out // only one is positive
100+
});
101+
// slither-disable-end incorrect-equality
102+
}
103+
104+
/* =========================================================== */
105+
/* ==================== Virtual Functions ==================== */
106+
/* =========================================================== */
107+
108+
/**
109+
* @notice Clamps expansion amounts based on the rebalancer's debt token balance
110+
* @dev For expansions, checks the rebalancer's debt token balance and adjusts if insufficient
111+
* @param ctx The liquidity context containing pool state and configuration
112+
* @param idealDebtToExpand The calculated ideal amount of debt tokens to add to pool
113+
* @param idealCollateralToPay The calculated ideal amount of collateral to receive from pool
114+
* @return debtToExpand The actual debt amount to expand (may be less than ideal)
115+
* @return collateralToPay The actual collateral amount to receive (adjusted if balance insufficient)
116+
*/
117+
function _clampExpansion(
118+
LQ.Context memory ctx,
119+
uint256 idealDebtToExpand,
120+
uint256 idealCollateralToPay
121+
) internal view override returns (uint256 debtToExpand, uint256 collateralToPay) {
122+
address debtToken = ctx.debtToken();
123+
uint256 debtBalance = IERC20(debtToken).balanceOf(_getRebalancer());
124+
125+
// slither-disable-next-line incorrect-equality
126+
if (debtBalance == 0) revert OLS_OUT_OF_DEBT();
127+
128+
if (debtBalance < idealDebtToExpand) {
129+
uint256 combinedFeeMultiplier = LQ.combineFees(
130+
ctx.incentives.protocolIncentiveExpansion,
131+
ctx.incentives.liquiditySourceIncentiveExpansion
132+
);
133+
debtToExpand = debtBalance;
134+
collateralToPay = ctx.convertToCollateralWithFee(debtBalance, LQ.FEE_DENOMINATOR, combinedFeeMultiplier);
135+
} else {
136+
debtToExpand = idealDebtToExpand;
137+
collateralToPay = idealCollateralToPay;
138+
}
139+
140+
return (debtToExpand, collateralToPay);
141+
}
142+
143+
/**
144+
* @notice Clamps contraction amounts based on the rebalancer's collateral balance
145+
* @dev For contractions, checks the rebalancer's collateral balance and adjusts if insufficient
146+
* @param ctx The liquidity context containing pool state and configuration
147+
* @param idealDebtToContract The calculated ideal amount of debt tokens to receive from pool
148+
* @param idealCollateralToReceive The calculated ideal amount of collateral to add to pool
149+
* @return debtToContract The actual debt amount to contract (may be less than ideal)
150+
* @return collateralToReceive The actual collateral amount to send (adjusted if balance insufficient)
151+
*/
152+
function _clampContraction(
153+
LQ.Context memory ctx,
154+
uint256 idealDebtToContract,
155+
uint256 idealCollateralToReceive
156+
) internal view override returns (uint256 debtToContract, uint256 collateralToReceive) {
157+
address collateralToken = ctx.collateralToken();
158+
uint256 collateralBalance = IERC20(collateralToken).balanceOf(_getRebalancer());
159+
160+
// slither-disable-next-line incorrect-equality
161+
if (collateralBalance == 0) revert OLS_OUT_OF_COLLATERAL();
162+
163+
if (collateralBalance < idealCollateralToReceive) {
164+
uint256 combinedFeeMultiplier = LQ.combineFees(
165+
ctx.incentives.protocolIncentiveContraction,
166+
ctx.incentives.liquiditySourceIncentiveContraction
167+
);
168+
collateralToReceive = collateralBalance;
169+
debtToContract = ctx.convertToDebtWithFee(collateralBalance, LQ.FEE_DENOMINATOR, combinedFeeMultiplier);
170+
} else {
171+
collateralToReceive = idealCollateralToReceive;
172+
debtToContract = idealDebtToContract;
173+
}
174+
175+
return (debtToContract, collateralToReceive);
176+
}
177+
178+
/* ============================================================ */
179+
/* ================= Callback Implementation ================== */
180+
/* ============================================================ */
181+
182+
/**
183+
* @notice Handles the rebalance callback by transferring tokens to/from the rebalancer
184+
* @dev Tokens received from the pool (minus protocol incentive) go to the rebalancer.
185+
* Tokens owed to the pool are pulled from the rebalancer via transferFrom.
186+
* @param pool The address of the FPMM pool
187+
* @param amount0Out The amount of token0 sent by the pool
188+
* @param amount1Out The amount of token1 sent by the pool
189+
* @param cb The callback data containing rebalance parameters
190+
*/
191+
function _handleCallback(
192+
address pool,
193+
uint256 amount0Out,
194+
uint256 amount1Out,
195+
LQ.CallbackData memory cb
196+
) internal override {
197+
PoolConfig memory config = poolConfigs[pool];
198+
address rebalancer = _getRebalancer();
199+
200+
(address tokenFromPool, address tokenToPool, uint256 protocolIncentive) = cb.dir == LQ.Direction.Expand
201+
? (cb.collToken, cb.debtToken, uint256(config.protocolIncentiveExpansion))
202+
: (cb.debtToken, cb.collToken, uint256(config.protocolIncentiveContraction));
203+
204+
uint256 amountFromPool = amount0Out > 0 ? amount0Out : amount1Out;
205+
uint256 protocolIncentiveAmount = (amountFromPool * protocolIncentive) / LQ.FEE_DENOMINATOR;
206+
207+
// Transfer protocol incentive to protocol fee recipient
208+
_transferRebalanceIncentive(tokenFromPool, protocolIncentiveAmount, config.protocolFeeRecipient);
209+
// Transfer remaining tokens to rebalancer (includes liquidity source incentive)
210+
IERC20(tokenFromPool).safeTransfer(rebalancer, amountFromPool - protocolIncentiveAmount);
211+
// Pull tokens from rebalancer and send to pool
212+
IERC20(tokenToPool).safeTransferFrom(rebalancer, pool, cb.amountOwedToPool);
213+
}
214+
215+
/* ============================================================ */
216+
/* ==================== Private Functions ===================== */
217+
/* ============================================================ */
218+
219+
/**
220+
* @notice Stores the rebalancer address in transient storage
221+
* @param rebalancer The address of the rebalancer (msg.sender of rebalance())
222+
*/
223+
function _setRebalancer(address rebalancer) private {
224+
bytes32 slot = REBALANCER_TSLOT;
225+
// solhint-disable-next-line no-inline-assembly
226+
assembly {
227+
tstore(slot, rebalancer)
228+
}
229+
}
230+
231+
/**
232+
* @notice Reads the rebalancer address from transient storage
233+
* @return rebalancer The stored rebalancer address
234+
*/
235+
function _getRebalancer() private view returns (address rebalancer) {
236+
bytes32 slot = REBALANCER_TSLOT;
237+
// solhint-disable-next-line no-inline-assembly
238+
assembly {
239+
rebalancer := tload(slot)
240+
}
241+
}
242+
243+
/**
244+
* @notice Checks if the hook was called for a pool in the current transaction
245+
* @dev Mirrors LiquidityStrategy._getHookCalled (which is private) using the same key derivation
246+
* @param pool The address of the pool being checked
247+
* @return called True if the hook was called for this pool
248+
*/
249+
function _isHookCalled(address pool) private view returns (bool called) {
250+
bytes32 key = bytes32(uint256(uint160(pool)));
251+
// solhint-disable-next-line no-inline-assembly
252+
assembly {
253+
called := tload(key)
254+
}
255+
}
256+
}

0 commit comments

Comments
 (0)