feat: Liquity V1 Stability Pool integration for LUSD yield#1011
feat: Liquity V1 Stability Pool integration for LUSD yield#1011CelebrityPunks wants to merge 2 commits intoubiquity:developmentfrom
Conversation
Integrates Liquity V1 Stability Pool for yield generation on LUSD collateral held by the Ubiquity protocol. Deposits LUSD to earn ETH liquidation gains and LQTY rewards, with harvested yields forwarded to the protocol treasury for buybacks/compounding. Closes ubiquity#997 Files added: - ILiquityStabilityPool.sol: Liquity SP interface (provideToSP, withdrawFromSP, gain queries) - IStabilityPoolFacet.sol: Facet interface with deposit/withdraw/harvest + admin setters - LibStabilityPool.sol: Diamond storage library with all business logic - StabilityPoolFacet.sol: Diamond facet with access control and reentrancy guards - MockLiquityStabilityPool.sol: Full mock with configurable gains and loss simulation - StabilityPoolFacet.t.sol: Foundry test suite (20+ tests covering all paths) - Deploy003_StabilityPool.s.sol: Deployment scripts for mainnet and development
📝 WalkthroughWalkthroughAdds Liquity V1 Stability Pool integration: new StabilityPoolFacet, LibStabilityPool library, ILiquityStabilityPool and IStabilityPoolFacet interfaces, mocks, tests, and deployment scripts (development and mainnet). The facet exposes deposit, withdraw, harvest, view getters, and admin setters; the library implements diamond-storage state, deposit/withdraw flows, gain forwarding to a protocol treasury, and configuration. Tests cover configuration, deposit/withdraw/harvest flows, edge cases, and reentrancy. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
packages/contracts/test/diamond/facets/StabilityPoolFacet.t.sol (1)
465-472: This is a placeholder, not a reentrancy test.
assertTrue(true)only proves the test function executed. Use a malicious treasury or stability-pool mock that reenters during gain forwarding and assert the guard reverts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0046f6e0-6f41-4d1e-983b-f16441bcd073
📒 Files selected for processing (8)
packages/contracts/migrations/development/Deploy003_StabilityPool.s.solpackages/contracts/migrations/mainnet/Deploy003_StabilityPool.s.solpackages/contracts/src/dollar/facets/StabilityPoolFacet.solpackages/contracts/src/dollar/interfaces/ILiquityStabilityPool.solpackages/contracts/src/dollar/interfaces/IStabilityPoolFacet.solpackages/contracts/src/dollar/libraries/LibStabilityPool.solpackages/contracts/src/dollar/mocks/MockLiquityStabilityPool.solpackages/contracts/test/diamond/facets/StabilityPoolFacet.t.sol
| function depositToPool(uint256 amount) internal { | ||
| StabilityPoolStorage storage ss = stabilityPoolStorage(); | ||
|
|
||
| require(amount > 0, "StabilityPool: zero deposit"); | ||
| require( | ||
| ss.stabilityPool != address(0), | ||
| "StabilityPool: pool not set" | ||
| ); | ||
| require(ss.lusdToken != address(0), "StabilityPool: LUSD not set"); | ||
|
|
||
| IERC20 lusd = IERC20(ss.lusdToken); | ||
|
|
||
| // Verify the diamond holds sufficient LUSD | ||
| uint256 balance = lusd.balanceOf(address(this)); | ||
| require(balance >= amount, "StabilityPool: insufficient LUSD"); | ||
|
|
||
| // Approve the Stability Pool to pull LUSD | ||
| lusd.safeApprove(ss.stabilityPool, 0); | ||
| lusd.safeApprove(ss.stabilityPool, amount); | ||
|
|
||
| // Deposit to Liquity Stability Pool | ||
| ILiquityStabilityPool(ss.stabilityPool).provideToSP( | ||
| amount, | ||
| ss.frontEndTag | ||
| ); | ||
|
|
||
| // Track principal | ||
| ss.totalPrincipalInPool += amount; | ||
|
|
||
| emit DepositedToStabilityPool(amount); |
There was a problem hiding this comment.
❓ Verification inconclusive
Script executed:
cd packages/contracts && find . -name "LibStabilityPool.sol" -type fRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
./src/dollar/libraries/LibStabilityPool.sol
Script executed:
cd packages/contracts && wc -l src/dollar/libraries/LibStabilityPool.solRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
349 src/dollar/libraries/LibStabilityPool.sol
Script executed:
cd packages/contracts && cat -n src/dollar/libraries/LibStabilityPool.solRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
1 // SPDX-License-Identifier: GPL-2.0-or-later
2 pragma solidity 0.8.19;
3
4 import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5 import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6 import {ILiquityStabilityPool} from "../interfaces/ILiquityStabilityPool.sol";
7
8 /**
9 * `@notice` Library for managing LUSD deposits into the Liquity V1 Stability Pool
10 * `@notice` Follows the diamond storage pattern used across the Ubiquity protocol.
11 * Handles deposit, withdrawal, and gain harvesting operations.
12 */
13 library LibStabilityPool {
14 using SafeERC20 for IERC20;
15
16 /// `@notice` Storage slot used to store data for this library
17 bytes32 constant STABILITY_POOL_STORAGE_POSITION =
18 bytes32(
19 uint256(
20 keccak256("ubiquity.contracts.stability.pool.storage")
21 ) - 1
22 ) & ~bytes32(uint256(0xff));
23
24 /// `@notice` Struct used as a storage for this library
25 struct StabilityPoolStorage {
26 /// `@notice` Address of the Liquity V1 Stability Pool contract
27 address stabilityPool;
28 /// `@notice` Address of the LUSD token
29 address lusdToken;
30 /// `@notice` Address of the LQTY token
31 address lqtyToken;
32 /// `@notice` Protocol treasury that receives harvested ETH/LQTY gains
33 address protocolTreasury;
34 /// `@notice` Frontend tag address for Liquity frontend kickback rate
35 address frontEndTag;
36 /// `@notice` Total LUSD principal deposited (before any liquidation losses)
37 uint256 totalPrincipalInPool;
38 }
39
40 /// `@notice` Emitted when LUSD is deposited to the Liquity Stability Pool
41 event DepositedToStabilityPool(uint256 amount);
42
43 /// `@notice` Emitted when LUSD is withdrawn from the Liquity Stability Pool
44 event WithdrawnFromStabilityPool(uint256 amount);
45
46 /// `@notice` Emitted when ETH/LQTY gains are harvested
47 event GainsHarvested(
48 uint256 ethGain,
49 uint256 lqtyGain,
50 address treasury
51 );
52
53 /// `@notice` Emitted when the Stability Pool address is configured
54 event StabilityPoolAddressSet(address stabilityPool);
55
56 /// `@notice` Emitted when the LUSD token address is configured
57 event LusdTokenAddressSet(address lusdToken);
58
59 /// `@notice` Emitted when the LQTY token address is configured
60 event LqtyTokenAddressSet(address lqtyToken);
61
62 /// `@notice` Emitted when the protocol treasury address is configured
63 event ProtocolTreasurySet(address treasury);
64
65 /// `@notice` Emitted when the frontend tag is updated
66 event FrontEndTagSet(address frontEndTag);
67
68 /**
69 * `@notice` Returns struct used as a storage for this library
70 * `@return` ss Struct used as a storage
71 */
72 function stabilityPoolStorage()
73 internal
74 pure
75 returns (StabilityPoolStorage storage ss)
76 {
77 bytes32 position = STABILITY_POOL_STORAGE_POSITION;
78 assembly {
79 ss.slot := position
80 }
81 }
82
83 /**
84 * `@notice` Deposits LUSD into the Liquity Stability Pool
85 * `@dev` LUSD must already be held by this contract (the diamond proxy).
86 * The caller is responsible for transferring LUSD to the diamond before calling.
87 * `@param` amount Amount of LUSD to deposit
88 */
89 function depositToPool(uint256 amount) internal {
90 StabilityPoolStorage storage ss = stabilityPoolStorage();
91
92 require(amount > 0, "StabilityPool: zero deposit");
93 require(
94 ss.stabilityPool != address(0),
95 "StabilityPool: pool not set"
96 );
97 require(ss.lusdToken != address(0), "StabilityPool: LUSD not set");
98
99 IERC20 lusd = IERC20(ss.lusdToken);
100
101 // Verify the diamond holds sufficient LUSD
102 uint256 balance = lusd.balanceOf(address(this));
103 require(balance >= amount, "StabilityPool: insufficient LUSD");
104
105 // Approve the Stability Pool to pull LUSD
106 lusd.safeApprove(ss.stabilityPool, 0);
107 lusd.safeApprove(ss.stabilityPool, amount);
108
109 // Deposit to Liquity Stability Pool
110 ILiquityStabilityPool(ss.stabilityPool).provideToSP(
111 amount,
112 ss.frontEndTag
113 );
114
115 // Track principal
116 ss.totalPrincipalInPool += amount;
117
118 emit DepositedToStabilityPool(amount);
119 }
120
121 /**
122 * `@notice` Withdraws LUSD from the Liquity Stability Pool
123 * `@dev` Also triggers collection of any pending ETH/LQTY gains.
124 * The withdrawn LUSD is sent back to the diamond.
125 * `@param` amount Amount of LUSD to withdraw
126 */
127 function withdrawFromPool(uint256 amount) internal {
128 StabilityPoolStorage storage ss = stabilityPoolStorage();
129
130 require(amount > 0, "StabilityPool: zero withdrawal");
131 require(
132 ss.stabilityPool != address(0),
133 "StabilityPool: pool not set"
134 );
135
136 // Get current compounded deposit to validate withdrawal amount
137 uint256 compoundedDeposit = ILiquityStabilityPool(ss.stabilityPool)
138 .getCompoundedLUSDDeposit(address(this));
139 require(
140 amount <= compoundedDeposit,
141 "StabilityPool: amount exceeds deposit"
142 );
143
144 // Capture pre-withdrawal balances for gain calculation
145 uint256 ethBefore = address(this).balance;
146 uint256 lqtyBefore = ss.lqtyToken != address(0)
147 ? IERC20(ss.lqtyToken).balanceOf(address(this))
148 : 0;
149
150 // Withdraw from Liquity Stability Pool (also collects gains)
151 ILiquityStabilityPool(ss.stabilityPool).withdrawFromSP(amount);
152
153 // Update principal tracking (cap at actual withdrawal to handle liquidation losses)
154 if (amount >= ss.totalPrincipalInPool) {
155 ss.totalPrincipalInPool = 0;
156 } else {
157 ss.totalPrincipalInPool -= amount;
158 }
159
160 // Forward harvested gains to treasury
161 _forwardGains(ss, ethBefore, lqtyBefore);
162
163 emit WithdrawnFromStabilityPool(amount);
164 }
165
166 /**
167 * `@notice` Harvests ETH and LQTY gains without changing the LUSD deposit
168 * `@dev` Calls withdrawFromSP(0) to trigger gain collection without reducing deposit.
169 * Gains are forwarded to the protocol treasury.
170 */
171 function harvestGains() internal {
172 StabilityPoolStorage storage ss = stabilityPoolStorage();
173
174 require(
175 ss.stabilityPool != address(0),
176 "StabilityPool: pool not set"
177 );
178 require(
179 ss.protocolTreasury != address(0),
180 "StabilityPool: treasury not set"
181 );
182
183 // Capture pre-harvest balances
184 uint256 ethBefore = address(this).balance;
185 uint256 lqtyBefore = ss.lqtyToken != address(0)
186 ? IERC20(ss.lqtyToken).balanceOf(address(this))
187 : 0;
188
189 // Withdraw 0 to trigger gain collection only
190 ILiquityStabilityPool(ss.stabilityPool).withdrawFromSP(0);
191
192 // Forward gains to treasury
193 _forwardGains(ss, ethBefore, lqtyBefore);
194 }
195
196 /**
197 * `@notice` Returns the current compounded LUSD deposit in the Stability Pool
198 * `@return` Compounded LUSD balance (principal minus liquidation losses)
199 */
200 function getPoolBalance() internal view returns (uint256) {
201 StabilityPoolStorage storage ss = stabilityPoolStorage();
202
203 if (ss.stabilityPool == address(0)) {
204 return 0;
205 }
206
207 return
208 ILiquityStabilityPool(ss.stabilityPool)
209 .getCompoundedLUSDDeposit(address(this));
210 }
211
212 /**
213 * `@notice` Returns the total LUSD principal deposited
214 * `@return` Total principal in pool
215 */
216 function getTotalPrincipal() internal view returns (uint256) {
217 StabilityPoolStorage storage ss = stabilityPoolStorage();
218 return ss.totalPrincipalInPool;
219 }
220
221 /**
222 * `@notice` Returns the pending ETH gain from liquidations
223 * `@return` ETH gain accrued
224 */
225 function getETHGain() internal view returns (uint256) {
226 StabilityPoolStorage storage ss = stabilityPoolStorage();
227
228 if (ss.stabilityPool == address(0)) {
229 return 0;
230 }
231
232 return
233 ILiquityStabilityPool(ss.stabilityPool).getDepositorETHGain(
234 address(this)
235 );
236 }
237
238 /**
239 * `@notice` Returns the pending LQTY reward gain
240 * `@return` LQTY gain accrued
241 */
242 function getLQTYGain() internal view returns (uint256) {
243 StabilityPoolStorage storage ss = stabilityPoolStorage();
244
245 if (ss.stabilityPool == address(0)) {
246 return 0;
247 }
248
249 return
250 ILiquityStabilityPool(ss.stabilityPool).getDepositorLQTYGain(
251 address(this)
252 );
253 }
254
255 /**
256 * `@notice` Sets the Liquity Stability Pool contract address
257 * `@param` _stabilityPool Address of the Liquity Stability Pool
258 */
259 function setStabilityPoolAddress(address _stabilityPool) internal {
260 require(
261 _stabilityPool != address(0),
262 "StabilityPool: zero address"
263 );
264 StabilityPoolStorage storage ss = stabilityPoolStorage();
265 ss.stabilityPool = _stabilityPool;
266 emit StabilityPoolAddressSet(_stabilityPool);
267 }
268
269 /**
270 * `@notice` Sets the LUSD token address
271 * `@param` _lusdToken Address of the LUSD ERC20 token
272 */
273 function setLusdTokenAddress(address _lusdToken) internal {
274 require(_lusdToken != address(0), "StabilityPool: zero address");
275 StabilityPoolStorage storage ss = stabilityPoolStorage();
276 ss.lusdToken = _lusdToken;
277 emit LusdTokenAddressSet(_lusdToken);
278 }
279
280 /**
281 * `@notice` Sets the LQTY token address
282 * `@param` _lqtyToken Address of the LQTY ERC20 token
283 */
284 function setLqtyTokenAddress(address _lqtyToken) internal {
285 require(_lqtyToken != address(0), "StabilityPool: zero address");
286 StabilityPoolStorage storage ss = stabilityPoolStorage();
287 ss.lqtyToken = _lqtyToken;
288 emit LqtyTokenAddressSet(_lqtyToken);
289 }
290
291 /**
292 * `@notice` Sets the protocol treasury address
293 * `@param` _treasury Address of the protocol treasury
294 */
295 function setProtocolTreasury(address _treasury) internal {
296 require(_treasury != address(0), "StabilityPool: zero address");
297 StabilityPoolStorage storage ss = stabilityPoolStorage();
298 ss.protocolTreasury = _treasury;
299 emit ProtocolTreasurySet(_treasury);
300 }
301
302 /**
303 * `@notice` Sets the frontend tag for Liquity frontend kickback rewards
304 * `@param` _frontEndTag Address of the frontend operator
305 */
306 function setFrontEndTag(address _frontEndTag) internal {
307 StabilityPoolStorage storage ss = stabilityPoolStorage();
308 ss.frontEndTag = _frontEndTag;
309 emit FrontEndTagSet(_frontEndTag);
310 }
311
312 /**
313 * `@notice` Forwards ETH and LQTY gains to the protocol treasury
314 * `@param` ss Storage reference
315 * `@param` ethBefore ETH balance before the operation
316 * `@param` lqtyBefore LQTY balance before the operation
317 */
318 function _forwardGains(
319 StabilityPoolStorage storage ss,
320 uint256 ethBefore,
321 uint256 lqtyBefore
322 ) private {
323 uint256 ethGain = address(this).balance - ethBefore;
324 uint256 lqtyGain = 0;
325
326 if (ss.lqtyToken != address(0)) {
327 lqtyGain =
328 IERC20(ss.lqtyToken).balanceOf(address(this)) -
329 lqtyBefore;
330 }
331
332 address treasury = ss.protocolTreasury;
333
334 // Forward ETH gains
335 if (ethGain > 0 && treasury != address(0)) {
336 (bool success, ) = treasury.call{value: ethGain}("");
337 require(success, "StabilityPool: ETH transfer failed");
338 }
339
340 // Forward LQTY gains
341 if (lqtyGain > 0 && treasury != address(0)) {
342 IERC20(ss.lqtyToken).safeTransfer(treasury, lqtyGain);
343 }
344
345 if (ethGain > 0 || lqtyGain > 0) {
346 emit GainsHarvested(ethGain, lqtyGain, treasury);
347 }
348 }
349 }
Script executed:
cd packages/contracts && find . -name "ILiquityStabilityPool.sol" -o -name "*Liquity*" | head -20Repository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
./src/dollar/interfaces/ILiquityStabilityPool.sol
./src/dollar/mocks/MockLiquityStabilityPool.sol
Script executed:
cd packages/contracts && grep -r "provideToSP\|withdrawFromSP" --include="*.sol" -B 2 -A 2 | head -80Repository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
src/dollar/facets/StabilityPoolFacet.sol- * 1. User mints Ubiquity Dollars depositing LUSD collateral
src/dollar/facets/StabilityPoolFacet.sol- * 2. Diamond calls depositToStabilityPool() with the received LUSD
src/dollar/facets/StabilityPoolFacet.sol: * 3. LUSD is forwarded to Liquity Stability Pool via provideToSP()
src/dollar/facets/StabilityPoolFacet.sol- *
src/dollar/facets/StabilityPoolFacet.sol- * `@dev` Withdraw flow (on redeem):
src/dollar/facets/StabilityPoolFacet.sol- * 1. User redeems Ubiquity Dollars for LUSD collateral
src/dollar/facets/StabilityPoolFacet.sol- * 2. Diamond calls withdrawFromStabilityPool() for the required LUSD
src/dollar/facets/StabilityPoolFacet.sol: * 3. LUSD is pulled from Liquity Stability Pool via withdrawFromSP()
src/dollar/facets/StabilityPoolFacet.sol- * 4. ETH/LQTY gains are automatically harvested and sent to treasury
src/dollar/facets/StabilityPoolFacet.sol- *
src/dollar/facets/StabilityPoolFacet.sol- * `@dev` Harvest flow (standalone or piggybacked):
src/dollar/facets/StabilityPoolFacet.sol: * 1. Calls withdrawFromSP(0) to trigger gain collection
src/dollar/facets/StabilityPoolFacet.sol- * 2. ETH gains from liquidation absorptions are forwarded to treasury
src/dollar/facets/StabilityPoolFacet.sol- * 3. LQTY rewards are forwarded to treasury for buyback/compounding
--
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@param` _frontEndTag Frontend operator address for kickback rate (use address(0) for no frontend)
src/dollar/interfaces/ILiquityStabilityPool.sol- */
src/dollar/interfaces/ILiquityStabilityPool.sol: function provideToSP(uint256 _amount, address _frontEndTag) external;
src/dollar/interfaces/ILiquityStabilityPool.sol-
src/dollar/interfaces/ILiquityStabilityPool.sol- /**
--
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@param` _amount Amount of LUSD to withdraw. Use type(uint256).max to withdraw entire deposit.
src/dollar/interfaces/ILiquityStabilityPool.sol- */
src/dollar/interfaces/ILiquityStabilityPool.sol: function withdrawFromSP(uint256 _amount) external;
src/dollar/interfaces/ILiquityStabilityPool.sol-
src/dollar/interfaces/ILiquityStabilityPool.sol- /**
--
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- // Deposit to Liquity Stability Pool
src/dollar/libraries/LibStabilityPool.sol: ILiquityStabilityPool(ss.stabilityPool).provideToSP(
src/dollar/libraries/LibStabilityPool.sol- amount,
src/dollar/libraries/LibStabilityPool.sol- ss.frontEndTag
--
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- // Withdraw from Liquity Stability Pool (also collects gains)
src/dollar/libraries/LibStabilityPool.sol: ILiquityStabilityPool(ss.stabilityPool).withdrawFromSP(amount);
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- // Update principal tracking (cap at actual withdrawal to handle liquidation losses)
--
src/dollar/libraries/LibStabilityPool.sol- /**
src/dollar/libraries/LibStabilityPool.sol- * `@notice` Harvests ETH and LQTY gains without changing the LUSD deposit
src/dollar/libraries/LibStabilityPool.sol: * `@dev` Calls withdrawFromSP(0) to trigger gain collection without reducing deposit.
src/dollar/libraries/LibStabilityPool.sol- * Gains are forwarded to the protocol treasury.
src/dollar/libraries/LibStabilityPool.sol- */
--
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- // Withdraw 0 to trigger gain collection only
src/dollar/libraries/LibStabilityPool.sol: ILiquityStabilityPool(ss.stabilityPool).withdrawFromSP(0);
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- // Forward gains to treasury
--
src/dollar/mocks/MockLiquityStabilityPool.sol- }
src/dollar/mocks/MockLiquityStabilityPool.sol-
src/dollar/mocks/MockLiquityStabilityPool.sol: function provideToSP(uint256 _amount, address) external override {
src/dollar/mocks/MockLiquityStabilityPool.sol- lusdToken.transferFrom(msg.sender, address(this), _amount);
src/dollar/mocks/MockLiquityStabilityPool.sol- deposits[msg.sender] += _amount;
src/dollar/mocks/MockLiquityStabilityPool.sol- }
src/dollar/mocks/MockLiquityStabilityPool.sol-
src/dollar/mocks/MockLiquityStabilityPool.sol: function withdrawFromSP(uint256 _amount) external override {
src/dollar/mocks/MockLiquityStabilityPool.sol- uint256 compounded = getCompoundedLUSDDeposit(msg.sender);
src/dollar/mocks/MockLiquityStabilityPool.sol- uint256 toWithdraw = _amount > compounded ? compounded : _amount;
Script executed:
cd packages/contracts && grep -r "getDepositorETHGain\|getDepositorLQTYGain" --include="*.sol" -B 2 -A 2Repository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@return` ETH gain accrued by the depositor
src/dollar/interfaces/ILiquityStabilityPool.sol- */
src/dollar/interfaces/ILiquityStabilityPool.sol: function getDepositorETHGain(
src/dollar/interfaces/ILiquityStabilityPool.sol- address _depositor
src/dollar/interfaces/ILiquityStabilityPool.sol- ) external view returns (uint256);
--
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@return` LQTY gain accrued by the depositor
src/dollar/interfaces/ILiquityStabilityPool.sol- */
src/dollar/interfaces/ILiquityStabilityPool.sol: function getDepositorLQTYGain(
src/dollar/interfaces/ILiquityStabilityPool.sol- address _depositor
src/dollar/interfaces/ILiquityStabilityPool.sol- ) external view returns (uint256);
--
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- return
src/dollar/libraries/LibStabilityPool.sol: ILiquityStabilityPool(ss.stabilityPool).getDepositorETHGain(
src/dollar/libraries/LibStabilityPool.sol- address(this)
src/dollar/libraries/LibStabilityPool.sol- );
--
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- return
src/dollar/libraries/LibStabilityPool.sol: ILiquityStabilityPool(ss.stabilityPool).getDepositorLQTYGain(
src/dollar/libraries/LibStabilityPool.sol- address(this)
src/dollar/libraries/LibStabilityPool.sol- );
--
src/dollar/mocks/MockLiquityStabilityPool.sol- }
src/dollar/mocks/MockLiquityStabilityPool.sol-
src/dollar/mocks/MockLiquityStabilityPool.sol: function getDepositorETHGain(
src/dollar/mocks/MockLiquityStabilityPool.sol- address _depositor
src/dollar/mocks/MockLiquityStabilityPool.sol- ) external view override returns (uint256) {
--
src/dollar/mocks/MockLiquityStabilityPool.sol- }
src/dollar/mocks/MockLiquityStabilityPool.sol-
src/dollar/mocks/MockLiquityStabilityPool.sol: function getDepositorLQTYGain(
src/dollar/mocks/MockLiquityStabilityPool.sol- address _depositor
src/dollar/mocks/MockLiquityStabilityPool.sol- ) external view override returns (uint256) {
Script executed:
cd packages/contracts && cat -n src/dollar/mocks/MockLiquityStabilityPool.solRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
1 // SPDX-License-Identifier: MIT
2 pragma solidity 0.8.19;
3
4 import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5 import {ILiquityStabilityPool} from "../interfaces/ILiquityStabilityPool.sol";
6
7 /**
8 * `@notice` Mock Liquity Stability Pool for testing
9 * `@dev` Simulates deposit/withdrawal behavior, ETH/LQTY gain accrual,
10 * and liquidation-based deposit reduction (compounding).
11 */
12 contract MockLiquityStabilityPool is ILiquityStabilityPool {
13 IERC20 public lusdToken;
14 IERC20 public lqtyToken;
15
16 mapping(address => uint256) public deposits;
17 mapping(address => uint256) public ethGains;
18 mapping(address => uint256) public lqtyGains;
19
20 /// `@notice` Simulated loss ratio (basis points). 0 = no loss. 500 = 5% loss.
21 uint256 public lossRatioBps;
22
23 constructor(address _lusdToken, address _lqtyToken) {
24 lusdToken = IERC20(_lusdToken);
25 lqtyToken = IERC20(_lqtyToken);
26 }
27
28 function provideToSP(uint256 _amount, address) external override {
29 lusdToken.transferFrom(msg.sender, address(this), _amount);
30 deposits[msg.sender] += _amount;
31 }
32
33 function withdrawFromSP(uint256 _amount) external override {
34 uint256 compounded = getCompoundedLUSDDeposit(msg.sender);
35 uint256 toWithdraw = _amount > compounded ? compounded : _amount;
36
37 // Reduce deposit
38 if (toWithdraw >= deposits[msg.sender]) {
39 deposits[msg.sender] = 0;
40 } else {
41 deposits[msg.sender] -= toWithdraw;
42 }
43
44 // Transfer LUSD back
45 if (toWithdraw > 0) {
46 lusdToken.transfer(msg.sender, toWithdraw);
47 }
48
49 // Transfer ETH gains
50 uint256 ethGain = ethGains[msg.sender];
51 if (ethGain > 0) {
52 ethGains[msg.sender] = 0;
53 (bool success, ) = msg.sender.call{value: ethGain}("");
54 require(success, "ETH transfer failed");
55 }
56
57 // Transfer LQTY gains
58 uint256 lqtyGain = lqtyGains[msg.sender];
59 if (lqtyGain > 0) {
60 lqtyGains[msg.sender] = 0;
61 lqtyToken.transfer(msg.sender, lqtyGain);
62 }
63 }
64
65 function getDepositorETHGain(
66 address _depositor
67 ) external view override returns (uint256) {
68 return ethGains[_depositor];
69 }
70
71 function getDepositorLQTYGain(
72 address _depositor
73 ) external view override returns (uint256) {
74 return lqtyGains[_depositor];
75 }
76
77 function getCompoundedLUSDDeposit(
78 address _depositor
79 ) public view override returns (uint256) {
80 uint256 deposit = deposits[_depositor];
81 if (lossRatioBps > 0) {
82 uint256 loss = (deposit * lossRatioBps) / 10000;
83 return deposit - loss;
84 }
85 return deposit;
86 }
87
88 // ---- Test helpers ----
89
90 /// `@notice` Simulate ETH gains from liquidations
91 function setETHGain(address _depositor, uint256 _amount) external {
92 ethGains[_depositor] = _amount;
93 }
94
95 /// `@notice` Simulate LQTY reward gains
96 function setLQTYGain(address _depositor, uint256 _amount) external {
97 lqtyGains[_depositor] = _amount;
98 }
99
100 /// `@notice` Simulate liquidation losses (basis points)
101 function setLossRatio(uint256 _bps) external {
102 lossRatioBps = _bps;
103 }
104
105 /// `@notice` Fund the mock with ETH for gain distribution
106 receive() external payable {}
107 }
Script executed:
cd packages/contracts && cat -n src/dollar/interfaces/ILiquityStabilityPool.solRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
1 // SPDX-License-Identifier: GPL-2.0-or-later
2 pragma solidity 0.8.19;
3
4 /**
5 * `@notice` Liquity V1 Stability Pool interface
6 * `@dev` Mainnet deployment: 0x66017D22b0f8556afDd19e1e5b5f1cbD89a6C337
7 */
8 interface ILiquityStabilityPool {
9 /**
10 * `@notice` Deposits LUSD to the Stability Pool
11 * `@param` _amount Amount of LUSD to deposit
12 * `@param` _frontEndTag Frontend operator address for kickback rate (use address(0) for no frontend)
13 */
14 function provideToSP(uint256 _amount, address _frontEndTag) external;
15
16 /**
17 * `@notice` Withdraws LUSD from the Stability Pool
18 * `@param` _amount Amount of LUSD to withdraw. Use type(uint256).max to withdraw entire deposit.
19 */
20 function withdrawFromSP(uint256 _amount) external;
21
22 /**
23 * `@notice` Returns the ETH gain for a given depositor
24 * `@param` _depositor Address of the depositor
25 * `@return` ETH gain accrued by the depositor
26 */
27 function getDepositorETHGain(
28 address _depositor
29 ) external view returns (uint256);
30
31 /**
32 * `@notice` Returns the LQTY gain for a given depositor
33 * `@param` _depositor Address of the depositor
34 * `@return` LQTY gain accrued by the depositor
35 */
36 function getDepositorLQTYGain(
37 address _depositor
38 ) external view returns (uint256);
39
40 /**
41 * `@notice` Returns the compounded LUSD deposit for a given depositor.
42 * The compounded deposit reflects the principal minus any losses from
43 * liquidation absorptions.
44 * `@param` _depositor Address of the depositor
45 * `@return` Compounded LUSD deposit remaining
46 */
47 function getCompoundedLUSDDeposit(
48 address _depositor
49 ) external view returns (uint256);
50 }
Script executed:
cd packages/contracts && grep -r "depositToStabilityPool\|withdrawFromStabilityPool\|harvestGains" --include="*.sol" -A 5 src/dollar/facets/StabilityPoolFacet.sol | head -100Repository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
* 2. Diamond calls depositToStabilityPool() with the received LUSD
* 3. LUSD is forwarded to Liquity Stability Pool via provideToSP()
*
* `@dev` Withdraw flow (on redeem):
* 1. User redeems Ubiquity Dollars for LUSD collateral
* 2. Diamond calls withdrawFromStabilityPool() for the required LUSD
* 3. LUSD is pulled from Liquity Stability Pool via withdrawFromSP()
* 4. ETH/LQTY gains are automatically harvested and sent to treasury
*
* `@dev` Harvest flow (standalone or piggybacked):
* 1. Calls withdrawFromSP(0) to trigger gain collection
--
function depositToStabilityPool(
uint256 amount
) external nonReentrant onlyAdmin {
LibStabilityPool.depositToPool(amount);
}
--
function withdrawFromStabilityPool(
uint256 amount
) external nonReentrant onlyAdmin {
LibStabilityPool.withdrawFromPool(amount);
}
--
function harvestGains() external nonReentrant onlyAdmin {
LibStabilityPool.harvestGains();
}
//========================
// Restricted functions
//========================
Script executed:
cd packages/contracts && grep -r "provideToSP" --include="*.sol" -B 5 -A 10 | grep -A 10 -B 5 "gain\|reward" | head -60Repository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
src/dollar/facets/StabilityPoolFacet.sol- *
src/dollar/facets/StabilityPoolFacet.sol- * `@dev` Withdraw flow (on redeem):
src/dollar/facets/StabilityPoolFacet.sol- * 1. User redeems Ubiquity Dollars for LUSD collateral
src/dollar/facets/StabilityPoolFacet.sol- * 2. Diamond calls withdrawFromStabilityPool() for the required LUSD
src/dollar/facets/StabilityPoolFacet.sol- * 3. LUSD is pulled from Liquity Stability Pool via withdrawFromSP()
src/dollar/facets/StabilityPoolFacet.sol- * 4. ETH/LQTY gains are automatically harvested and sent to treasury
src/dollar/facets/StabilityPoolFacet.sol- *
src/dollar/facets/StabilityPoolFacet.sol- * `@dev` Harvest flow (standalone or piggybacked):
src/dollar/facets/StabilityPoolFacet.sol- * 1. Calls withdrawFromSP(0) to trigger gain collection
src/dollar/facets/StabilityPoolFacet.sol- * 2. ETH gains from liquidation absorptions are forwarded to treasury
--
src/dollar/interfaces/ILiquityStabilityPool.sol- /**
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@notice` Deposits LUSD to the Stability Pool
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@param` _amount Amount of LUSD to deposit
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@param` _frontEndTag Frontend operator address for kickback rate (use address(0) for no frontend)
src/dollar/interfaces/ILiquityStabilityPool.sol- */
src/dollar/interfaces/ILiquityStabilityPool.sol: function provideToSP(uint256 _amount, address _frontEndTag) external;
src/dollar/interfaces/ILiquityStabilityPool.sol-
src/dollar/interfaces/ILiquityStabilityPool.sol- /**
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@notice` Withdraws LUSD from the Stability Pool
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@param` _amount Amount of LUSD to withdraw. Use type(uint256).max to withdraw entire deposit.
src/dollar/interfaces/ILiquityStabilityPool.sol- */
src/dollar/interfaces/ILiquityStabilityPool.sol- function withdrawFromSP(uint256 _amount) external;
src/dollar/interfaces/ILiquityStabilityPool.sol-
src/dollar/interfaces/ILiquityStabilityPool.sol- /**
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@notice` Returns the ETH gain for a given depositor
src/dollar/interfaces/ILiquityStabilityPool.sol- * `@param` _depositor Address of the depositor
--
src/dollar/libraries/LibStabilityPool.sol- // Approve the Stability Pool to pull LUSD
src/dollar/libraries/LibStabilityPool.sol- lusd.safeApprove(ss.stabilityPool, 0);
src/dollar/libraries/LibStabilityPool.sol- lusd.safeApprove(ss.stabilityPool, amount);
src/dollar/libraries/LibStabilityPool.sol-
src/dollar/libraries/LibStabilityPool.sol- // Deposit to Liquity Stability Pool
src/dollar/libraries/LibStabilityPool.sol: ILiquityStabilityPool(ss.stabilityPool).provideToSP(
src/dollar/libraries/LibStabilityPool.sol- amount,
src/dollar/libraries/LibStabilityPool.sol- ss.frontEndTag
Script executed:
web_search
What does Liquity V1 StabilityPool provideToSP do when depositing to existing positionRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 127
stderr:
/bin/bash: line 1: web_search: command not found
/bin/bash: line 2: What: command not found
Liquity V1 StabilityPool provideToSP deposit existing position gains
In Liquity V1, StabilityPool.provideToSP(uint _amount, address _frontEndTag) is the deposit / top-up entrypoint. When you call it while you already have a Stability Pool deposit:
-
Your existing deposit is first “compounded” to account for prior liquidations (
compoundedLUSDDeposit = getCompoundedLUSDDeposit(msg.sender)), i.e., your effective LUSD deposit may be lower than what you originally put in. Then the contract sets your new recorded deposit to:
newDeposit = compoundedLUSDDeposit + _amount(and overwrites the old deposit + refreshes snapshots). [1] -
Your accumulated gains are paid out on the top-up:
- It computes your current ETH gain (
depositorETHGain = getDepositorETHGain(msg.sender)) and then sends it to you at the end ofprovideToSP(_sendETHGainToDepositor(depositorETHGain)). [1] - It also pays out any LQTY gains before updating your deposit (
_payOutLQTYGains(...)). [1]
- It computes your current ETH gain (
-
_frontEndTagis only applied on the first deposit (wheninitialDeposit == 0). If you already have a deposit, callingprovideToSPagain does not change your front end tag. [1]
So: yes, provideToSP can add to an existing position; and yes, it will claim/payout your pending ETH + LQTY gains as part of that same transaction—it does not auto-compound the ETH gain back into the LUSD deposit (it transfers the ETH to you). [1]
Sources
[1] Liquity V1 StabilityPool verified source (Etherscan) – provideToSP implementation and notes on compounding + payout behavior.
Script executed:
cd packages/contracts && find . -name "*.test.ts" -o -name "*.spec.ts" | xargs grep -l "depositToPool\|depositToStabilityPool" 2>/dev/null | head -5Repository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
Script executed:
cd packages/contracts && find . -path ./node_modules -prune -o -name "*.test.ts" -type f -print | head -10Repository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
Script executed:
cd packages/contracts && grep -r "harvestGains\|depositToPool\|depositToStabilityPool" --include="*.sol" -B 3 -A 3 src/dollar/facets/StabilityPoolFacet.solRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 0
stdout:
*
* `@dev` Deposit flow (on mint):
* 1. User mints Ubiquity Dollars depositing LUSD collateral
* 2. Diamond calls depositToStabilityPool() with the received LUSD
* 3. LUSD is forwarded to Liquity Stability Pool via provideToSP()
*
* `@dev` Withdraw flow (on redeem):
--
//====================
/// `@inheritdoc` IStabilityPoolFacet
function depositToStabilityPool(
uint256 amount
) external nonReentrant onlyAdmin {
LibStabilityPool.depositToPool(amount);
}
/// `@inheritdoc` IStabilityPoolFacet
--
}
/// `@inheritdoc` IStabilityPoolFacet
function harvestGains() external nonReentrant onlyAdmin {
LibStabilityPool.harvestGains();
}
//========================
Script executed:
cd packages/contracts && grep -B 10 -A 10 "Only ETH" src/dollar/libraries/LibStabilityPool.solRepository: ubiquity/ubiquity-dollar
Repository: ubiquity/ubiquity-dollar
Exit code: 1
Call harvestGains() after topping up existing positions, or snapshot and forward gains within depositToPool().
When Liquity's provideToSP() is called on an existing Stability Pool position, it automatically pays out accumulated ETH and LQTY gains. The current depositToPool() implementation calls provideToSP() but never snapshots balances or forwards these gains to the treasury via _forwardGains(). This causes rewards to strand on the diamond contract. Either trigger the harvest flow after deposit operations that top up existing positions, or refactor depositToPool() to snapshot balances pre-call and forward gains post-call, similar to how withdrawFromPool() and harvestGains() handle gain collection.
- Use pro-rata principal tracking in withdrawFromPool to correctly handle liquidation losses (prevents stale totalPrincipalInPool residual) - Fix mock withdrawFromSP to reduce raw deposits proportionally, matching real Liquity pool behavior after partial withdrawals with losses - Add test for harvestGains revert when treasury is not set - Replace placeholder reentrancy test with real malicious contract test - Add test verifying principal tracking after liquidation losses
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/contracts/test/diamond/facets/StabilityPoolFacet.t.sol (1)
466-502: Reentrancy test design is correct but error message expectation deserves a comment.The test expects
"StabilityPool: ETH transfer failed"because the reentrancy guard reverts insidereceive(), causing the ETH transfer itself to fail. Consider adding a brief inline comment explaining this causality for future readers.📝 Optional: Add clarifying comment
// Harvest should revert: attacker's receive() tries to re-enter, // which triggers ReentrancyGuard. The inner revert causes the ETH // transfer to fail, surfacing as "StabilityPool: ETH transfer failed". + // Note: We don't see "ReentrancyGuard: reentrant call" because + // that revert happens inside the attacker's receive(), causing + // the low-level call to return false. vm.prank(admin);
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 588ddc4f-3ae3-4f01-ad86-0b34394620d7
📒 Files selected for processing (3)
packages/contracts/src/dollar/libraries/LibStabilityPool.solpackages/contracts/src/dollar/mocks/MockLiquityStabilityPool.solpackages/contracts/test/diamond/facets/StabilityPoolFacet.t.sol
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/contracts/src/dollar/mocks/MockLiquityStabilityPool.sol
Summary
Implements the Liquity V1 Stability Pool integration as specified in #997. This enables the Ubiquity protocol to deposit LUSD collateral into Liquity's Stability Pool to earn ~6.28% APR from ETH liquidation gains and LQTY rewards.
Architecture
Following the existing diamond proxy pattern, this PR adds:
ILiquityStabilityPool.sol— Interface for Liquity V1 SP at0x66017D22b0f8556afDd19e1e5b5f1cbD89a6C337IStabilityPoolFacet.sol— Facet interface with deposit/withdraw/harvest operations and admin configuration settersLibStabilityPool.sol— Diamond storage library containing all business logic, usingkeccak256("ubiquity.contracts.stability.pool.storage")slotStabilityPoolFacet.sol— Diamond facet withonlyAdminaccess control andnonReentrantguards on all state-changing operationsMockLiquityStabilityPool.sol— Mock with configurable ETH/LQTY gains and liquidation loss simulation for testingStabilityPoolFacet.t.sol— Foundry test suite with 20+ test cases covering all pathsDeploy003_StabilityPool.s.sol— Deployment scripts for both mainnet and development environmentsKey Design Decisions
depositToStabilityPool()called after mint,withdrawFromStabilityPool()called before redeem — ETH/LQTY gains auto-harvested on every withdrawalwithdrawFromSP(0): Triggers gain collection without reducing the LUSD depositcall{value}, LQTY viasafeTransfer— treasury can then buyback/compoundtotalPrincipalInPooltracks deposits independent of liquidation-loss-adjusted compounded balancesafeApprove(0)thensafeApprove(amount)for USDT-compatible approval handlingStorage
Test Plan
Closes #997