Skip to content

Commit 7ea90db

Browse files
authored
Merge pull request #210 from lista-dao/feature/v3-rebalance-loss-cap
feat(provider + adaptor): bound rebalance loss on V3 LP collateral
2 parents 0f5c382 + b607f2b commit 7ea90db

19 files changed

Lines changed: 1772 additions & 215 deletions

src/liquidator/V3Liquidator.sol

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
5454
/// @dev Whitelisted V3Provider contracts (collateral token = the provider itself).
5555
mapping(address => bool) public v3Providers;
5656

57+
/// @dev Pass-back of the leg amounts redeemed inside onMoolahLiquidate, so flashLiquidate can report
58+
/// the actual redeemed amount0 / amount1 in its V3Liquidation event rather than 0. Appended at
59+
/// the end of the storage layout (upgrade-safe). flashLiquidate zeroes them before each liquidate
60+
/// so a non-redeeming flash liquidation — or a stale value from an earlier call — reports 0.
61+
uint256 private _redeemedAmount0;
62+
uint256 private _redeemedAmount1;
63+
5764
/* ──────────────────────────── events ────────────────────────────── */
5865

5966
event TokenWhitelistChanged(address indexed token, bool status);
@@ -276,6 +283,11 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
276283
address effectiveToken0Spender = params.token0Spender == address(0) ? params.token0Pair : params.token0Spender;
277284
address effectiveToken1Spender = params.token1Spender == address(0) ? params.token1Pair : params.token1Spender;
278285

286+
// Clear any stale pass-back (e.g. from an earlier flashLiquidate in the same tx). The callback
287+
// fills these in only when it redeems; a non-redeeming flash liquidation reports 0 / 0.
288+
_redeemedAmount0 = 0;
289+
_redeemedAmount1 = 0;
290+
279291
(uint256 _seized, uint256 _repaid) = IMoolah(MOOLAH).liquidate(
280292
mp,
281293
borrower,
@@ -301,7 +313,7 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
301313
)
302314
);
303315

304-
emit V3Liquidation(id, params.v3Provider, borrower, _seized, _repaid, 0, 0);
316+
emit V3Liquidation(id, params.v3Provider, borrower, _seized, _repaid, _redeemedAmount0, _redeemedAmount1);
305317
}
306318

307319
/**
@@ -401,6 +413,10 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
401413
// leg is an ERC-20 sold via approve + call. Read it from the provider — never hardcode a chain.
402414
address wrappedNative = IV3Provider(d.v3Provider).WRAPPED_NATIVE();
403415

416+
// Snapshot before redeeming/swapping so profitability is judged on THIS liquidation's
417+
// delta, not masked by loanToken balance already sitting idle from prior liquidations.
418+
uint256 before = d.loanToken.balanceOf(address(this));
419+
404420
// Redeem V3 shares → TOKEN0 + TOKEN1; the wrapped-native leg arrives as the native coin.
405421
(uint256 amount0, uint256 amount1) = IV3Provider(d.v3Provider).redeemShares(
406422
d.seized,
@@ -409,6 +425,10 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
409425
address(this)
410426
);
411427

428+
// Pass the actual redeemed amounts back to flashLiquidate for the V3Liquidation event.
429+
_redeemedAmount0 = amount0;
430+
_redeemedAmount1 = amount1;
431+
412432
// Swap TOKEN0 → loanToken (skip if already loanToken or no swap requested).
413433
if (d.swapToken0 && amount0 > 0 && token0 != d.loanToken) {
414434
_swapRedeemedLeg(token0 == wrappedNative, d.token0Pair, d.token0Spender, token0, amount0, d.swapToken0Data);
@@ -427,7 +447,8 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
427447
if (nativeBalance > 0) IWBNB(wrappedNative).deposit{ value: nativeBalance }();
428448
}
429449

430-
if (d.loanToken.balanceOf(address(this)) < repaidAssets) revert NoProfit();
450+
uint256 out = d.loanToken.balanceOf(address(this)) - before;
451+
if (out < repaidAssets) revert NoProfit();
431452
}
432453

433454
// Approve Moolah to pull the repayment (always done, flash or pre-funded).

src/provider/interfaces/IV3DexAdapter.sol

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ interface IV3DexAdapter {
111111
/// @notice Collect accrued fees and re-add them plus idle inventory as liquidity (compound).
112112
function collectAndCompound() external;
113113

114+
/// @notice Record tokens already transferred to the adapter as idle inventory, without minting into
115+
/// the pool. Used by the vault deposit path so new deposits enter as idle at the current fair
116+
/// composition (off-pool priced); a later spot-gated compound() deploys them.
117+
function creditIdle(uint256 amount0, uint256 amount1) external;
118+
114119
/* ─────────────────────── rebalance / rate config ────────────────── */
115120

116121
/// @notice Exchange rate at the last successful center/init (rate-implied pairs; 0 for TWAP pairs).
@@ -129,12 +134,25 @@ interface IV3DexAdapter {
129134
/// MANAGER). A venue may never be TOKEN0 / TOKEN1 / POOL / POSITION_MANAGER.
130135
function setSwapPairWhitelist(address swapPair, bool status) external;
131136

137+
/// @notice Per-swap rate-anchored loss cap for the rebalance conversion swap (ppm, 1e6 = 100%).
138+
function maxSwapLossBp() external view returns (uint256);
139+
140+
/// @notice Set the per-swap rate-anchored loss cap (onlyRole MANAGER; ppm, 1e6 = 100%).
141+
function setMaxSwapLossBp(uint256 maxSwapLossBp) external;
142+
143+
/// @notice Max |pool spot − fair| price deviation (bps) tolerated when adding liquidity at pool spot.
144+
function maxSpotDeviationBps() external view returns (uint256);
145+
146+
/// @notice Set the spot-vs-fair deviation gate (onlyRole MANAGER; bps of price, 0 disables).
147+
function setMaxSpotDeviationBps(uint256 maxSpotDeviationBps) external;
148+
132149
/// @notice Recenter the position to its range and convert inventory to the optimal ratio.
133150
/// onlyProvider — the provider gates the caller with the BOT role.
134151
function rebalance(
135152
uint256 minAmount0,
136153
uint256 minAmount1,
137154
uint256 minLiquidity,
155+
uint160 targetSqrtPriceX96,
138156
uint256 deadline,
139157
bytes calldata swapData
140158
) external;

src/provider/interfaces/IV3PoolMinimal.sol

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,20 @@ pragma solidity 0.8.34;
1313
*/
1414
interface IV3PoolMinimal {
1515
function slot0() external view returns (uint160 sqrtPriceX96, int24 tick);
16+
17+
/// @notice Global fee growth per unit of liquidity, Q128.
18+
function feeGrowthGlobal0X128() external view returns (uint256);
19+
20+
function feeGrowthGlobal1X128() external view returns (uint256);
21+
22+
/// @notice Tick state. Decodes only the first four fields (liquidityGross, liquidityNet,
23+
/// feeGrowthOutside0X128, feeGrowthOutside1X128) — the two fee-growth values are all the
24+
/// pending-fee simulation needs, and stopping the decode there keeps the read width-agnostic
25+
/// across V3 flavors (see the slot0 note above).
26+
function ticks(
27+
int24 tick
28+
)
29+
external
30+
view
31+
returns (uint128 liquidityGross, int128 liquidityNet, uint256 feeGrowthOutside0X128, uint256 feeGrowthOutside1X128);
1632
}

src/provider/interfaces/IV3Provider.sol

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,20 @@ interface IV3Provider is IProvider {
2626
/// @notice Total token0/token1 backing the vault at the current pool spot (display/bots).
2727
function getTotalAmounts() external view returns (uint256 total0, uint256 total1);
2828

29+
/// @notice Total token0/token1 backing the vault at the FAIR price (idle + fees included). This is the
30+
/// ratio a subsequent deposit binds to; size deposit legs in this ratio to minimise the refund.
31+
function getFairComposition() external view returns (uint256 total0, uint256 total1);
32+
2933
/// @notice Deposit token0/token1 into the V3 position and supply resulting shares as Moolah
30-
/// collateral on behalf of `onBehalf`.
34+
/// collateral on behalf of `onBehalf`. Reverts if the minted shares are below `minShares`
35+
/// (share-slippage floor; pass 0 to disable).
3136
function deposit(
3237
MarketParams calldata marketParams,
3338
uint256 amount0Desired,
3439
uint256 amount1Desired,
3540
uint256 amount0Min,
3641
uint256 amount1Min,
42+
uint256 minShares,
3743
address onBehalf
3844
) external payable returns (uint256 shares, uint256 amount0Used, uint256 amount1Used);
3945

src/provider/libraries/SwapInventoryLib.sol

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ library SwapInventoryLib {
3636
error ExceedAmountIn();
3737
error InsufficientOutput();
3838
error UnexpectedNative();
39+
error InsufficientInventory();
3940

4041
/// @notice Execute one backend-built swap. `sellToken0` ⇒ sell token0 for token1, else token1 for
4142
/// token0. `nativeIn` ⇒ the venue takes the native coin for the wrapped-native input leg
@@ -60,8 +61,11 @@ library SwapInventoryLib {
6061
address tokenOut = sellToken0 ? token1 : token0;
6162

6263
uint256 avail = sellToken0 ? total0 : total1;
63-
if (amountIn > avail) amountIn = avail; // never spend more than the position holds
64-
if (amountIn == 0) return (total0, total1);
64+
if (avail == 0) return (total0, total1); // nothing to sell
65+
66+
// Stale swapData (requested > held): fail fast. Capping would desync the venue allowance from
67+
// swapData and could underflow the totals. Now amountIn <= avail, so the allowance matches the pull.
68+
if (amountIn > avail) revert InsufficientInventory();
6569

6670
uint256 beforeIn = IERC20(tokenIn).balanceOf(address(this));
6771
uint256 beforeOut = IERC20(tokenOut).balanceOf(address(this));
@@ -83,10 +87,13 @@ library SwapInventoryLib {
8387

8488
// Wrap any native this call delivered — a native-out venue's proceeds (instantWithdraw → BNB) or a
8589
// native-in venue's unspent refund — back into the wrapped-native ERC-20, so it is booked into the
86-
// totals via the spent/received deltas and never stranded. Native can only belong to the
87-
// wrapped-native leg; if neither leg is it, there is nowhere to book the native ⇒ revert.
88-
if (address(this).balance > beforeNative) {
89-
if (tokenIn != wrappedNative && tokenOut != wrappedNative) revert UnexpectedNative();
90+
// totals via the spent/received deltas and never stranded. Only a wrapped-native leg can legitimately
91+
// deliver native; wrap the delta ONLY when either leg is the wrapped-native token. If NEITHER leg is
92+
// (an ERC-20↔ERC-20 swap on a future non-native pair), any native that appears is an unsolicited
93+
// donation the venue forwarded (e.g. a router that sweeps its native balance; an attacker can seed it
94+
// with 1 wei) — it is not part of this swap's accounting, so leave it untouched rather than reverting
95+
// and bricking the rebalance.
96+
if ((tokenIn == wrappedNative || tokenOut == wrappedNative) && address(this).balance > beforeNative) {
9097
IWBNB(wrappedNative).deposit{ value: address(this).balance - beforeNative }();
9198
}
9299

src/provider/v3/SlisBNBV3DexAdapter.sol

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,10 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter {
5858

5959
/* ───────────────────────── hook overrides ───────────────────────── */
6060

61-
/// @dev slisBNB↔BNB rate from the StakeManager (1e18). 0 for any non-slisBNB/WBNB pair → base TWAP.
61+
/// @dev slisBNB↔BNB rate from the StakeManager (1e18). The constructor pins TOKEN0 == slisBNB and
62+
/// TOKEN1 == WBNB, so the pair/order is fixed at deploy and the rate is always the direct
63+
/// slisBNB→BNB conversion (no runtime pair/order branch needed).
6264
function _lstNativeRate() internal view override returns (uint256) {
63-
return _isSlisBnbWbnbPool() ? _poolPriceRate() : 0;
64-
}
65-
66-
/* ─────────────────────────── internals ──────────────────────────── */
67-
68-
function _isSlisBnbWbnbPool() internal view returns (bool) {
69-
return (TOKEN0 == SLISBNB && TOKEN1 == WBNB) || (TOKEN0 == WBNB && TOKEN1 == SLISBNB);
70-
}
71-
72-
function _poolPriceRate() internal view returns (uint256) {
73-
return TOKEN0 == SLISBNB ? STAKE_MANAGER.convertSnBnbToBnb(1e18) : STAKE_MANAGER.convertBnbToSnBnb(1e18);
65+
return STAKE_MANAGER.convertSnBnbToBnb(1e18);
7466
}
7567
}

src/provider/v3/SlisBNBV3Provider.sol

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,11 @@ contract SlisBNBV3Provider is V3Provider {
5959
uint256 minAmount0,
6060
uint256 minAmount1,
6161
uint256 minLiquidity,
62+
uint160 targetSqrtPriceX96,
6263
uint256 deadline,
6364
bytes calldata swapData
6465
) external onlyRole(BOT) nonReentrant {
65-
ISlisBNBV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline, swapData);
66+
_guardedRebalance(minAmount0, minAmount1, minLiquidity, targetSqrtPriceX96, deadline, swapData);
6667
}
6768

6869
/* ─────────────────── slisBNBx: sync / view ──────────────────────── */
@@ -85,6 +86,9 @@ contract SlisBNBV3Provider is V3Provider {
8586
uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8-decimal USD
8687
uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8-decimal USD
8788
uint256 bnbPrice = IOracle(resilientOracle).peek(BNB_ADDRESS); // 8-decimal USD
89+
// Fail closed on a broken feed: a transiently-zero price would understate (possibly zero) the BNB
90+
// value and make the slisBNBx minter burn the user's reward balance toward 0. Revert instead.
91+
if (price0 == 0 || price1 == 0 || bnbPrice == 0) revert OracleZero();
8892

8993
uint256 value0 = (user0 * price0 * 1e18) / (10 ** DECIMALS0);
9094
uint256 value1 = (user1 * price1 * 1e18) / (10 ** DECIMALS1);

0 commit comments

Comments
 (0)