Skip to content

Commit 82e2c05

Browse files
razwwclaude
andcommitted
fix(v3-lp): liquidator profit/receiver guards, deposit preview rounding, range params
Liquidator: - onMoolahLiquidate counts native in the before-snapshot when the loan token is the wrapped-native, so stray native from earlier redemptions or receive() donations no longer reads as fresh profit and lets a shortfall clear NoProfit. - redeemV3Shares requires receiver == self when a fundSource is set, keeping vault-funded proceeds inside the reflow path. - setReflowBlacklist rejects a no-op status change, matching the other setters. Provider: - previewDepositAmounts rounds the leg amounts UP, matching _quoteDeposit, so the preview reports exactly what deposit() consumes. - Range half-width INITIAL_RANGE_BPS 100 -> 50 (+/-0.5%), which also tightens the maxSpotDeviationBps and maxTwapDeviationBps defaults; centerRateThresholdBps drops to 1bp so the BOT, not the contract, picks the rebalance cadence. - Correct two stale comments: the zero-liquidity guard covers compound only (the rebalance re-mint has none), and the spot-vs-fair gate does not apply to the rebalance re-mint. Drop the previewDepositForToken0 suggestion to deposit a single leg when fair leaves the range -- every shape reverts until a recenter. Tests: - Counter-tests for each liquidator fix, incl. a WBNB-loan market so the wrapped-native branch is exercised. - previewDepositAmounts exact-match test; regression test pinning that deposits are closed while fair sits past tickUpper and reopen after a BOT recenter. - _deposit helpers derive a non-zero minShares from previewDepositShares: min0/min1 floor the consumed amounts, not the entry price. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0e7a27b commit 82e2c05

11 files changed

Lines changed: 218 additions & 33 deletions

src/liquidator/V3Liquidator.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
3636
error SwapFailed();
3737
error NotAuthorized();
3838
error InvalidFundSource();
39+
error ReceiverNotSelf();
3940

4041
/* ──────────────────────────── constants ─────────────────────────── */
4142

@@ -225,6 +226,7 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
225226
/// tokens are never pushed to the vault; they stay here to be redeemed via redeemV3Shares.
226227
function setReflowBlacklist(address token, bool status) external onlyRole(MANAGER) {
227228
require(token != address(0), "zero address");
229+
require(reflowBlacklist[token] != status, WhitelistSameStatus());
228230
reflowBlacklist[token] = status;
229231
emit ReflowBlacklistChanged(token, status);
230232
}
@@ -421,6 +423,7 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
421423
address receiver
422424
) external nonReentrant onlyRole(BOT) returns (uint256 amount0, uint256 amount1) {
423425
require(v3Providers[v3Provider], NotWhitelisted());
426+
require(fundSource == address(0) || receiver == address(this), ReceiverNotSelf());
424427
(amount0, amount1) = IV3Provider(v3Provider).redeemShares(shares, minAmt0, minAmt1, receiver);
425428
// When redeemed into this contract, reflow the legs to the vault (no-op when fundSource == 0).
426429
if (receiver == address(this)) {
@@ -510,6 +513,7 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont
510513
// Snapshot before redeeming/swapping so profitability is judged on THIS liquidation's
511514
// delta, not masked by loanToken balance already sitting idle from prior liquidations.
512515
uint256 before = d.loanToken.balanceOf(address(this));
516+
if (d.loanToken == wrappedNative) before += address(this).balance;
513517

514518
// Redeem V3 shares → TOKEN0 + TOKEN1; the wrapped-native leg arrives as the native coin.
515519
(uint256 amount0, uint256 amount1) = IV3Provider(d.v3Provider).redeemShares(

src/provider/v3/SlisBNBV3DexAdapter.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter {
5353
(int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate);
5454
__V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper);
5555
lastCenterRate = initialCenterRate;
56-
centerRateThresholdBps = INITIAL_RANGE_BPS;
56+
// Minimal anti-churn floor only: 1 BPS
57+
centerRateThresholdBps = 1;
5758
}
5859

5960
/* ───────────────────────── hook overrides ───────────────────────── */

src/provider/v3/V3DexAdapter.sol

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ abstract contract V3DexAdapter is
6969
uint256 internal constant BPS = 10_000;
7070
/// @dev Denominator for `maxSwapLossBp` — parts-per-million (ppm).
7171
uint256 internal constant LOSS_DENOM = 1e6;
72-
/// @dev Half-width of the rate-centered range for rate-implied pairs (±1%).
73-
uint256 internal constant INITIAL_RANGE_BPS = 100;
72+
/// @dev Half-width of the rate-centered range for rate-implied pairs (±0.5%).
73+
uint256 internal constant INITIAL_RANGE_BPS = 50;
7474
/// @dev Fallback half-range (ticks) around spot for non-rate (TWAP) pairs.
7575
int24 internal constant FALLBACK_HALF_RANGE_TICKS = 500;
7676
/// @dev Fixed-point 2^128, the denominator of Uniswap V3 fee-growth (feeGrowthInside/Global) values.
@@ -110,12 +110,11 @@ abstract contract V3DexAdapter is
110110
/// this slippage on the converted leg. Default 50_000 = 5%.
111111
uint256 public maxSwapLossBp;
112112

113-
/// @dev Max allowed |pool spot − fair| price deviation (bps of price) for adding liquidity at the pool
114-
/// spot: the (BOT-gated) compound and the rebalance re-mint both call increaseLiquidity/mint at the
115-
/// pool spot, so a flash-loan that skews spot could make the vault add liquidity at a manipulated
116-
/// price and get sandwiched on the attacker's back-swap. fairSqrtPriceX96
117-
/// is rate-anchored (flash-loan-immune for the LST pairs), so gating spot against it neutralises
118-
/// that vector. 0 disables the gate. Default INITIAL_RANGE_BPS (1%).
113+
/// @dev Max allowed |pool spot − fair| price deviation (bps) for adding liquidity at the pool spot.
114+
/// Gates the compound path: fairSqrtPriceX96 is rate-anchored, so a flash-loan skew cannot make
115+
/// compound add at a manipulated price and get sandwiched on the back-swap. The rebalance re-mint
116+
/// has no fair gate — only the BOT's targetSqrtPriceX96 assertion (same tolerance), skipped at
117+
/// target 0, so pass a non-zero target in production. 0 disables. Default INITIAL_RANGE_BPS (0.5%).
119118
uint256 public maxSpotDeviationBps;
120119

121120
/// @dev Max |live center rate − BOT expectedCenterRate| deviation on rebalance (BPS; 0 = off). Guards
@@ -230,7 +229,7 @@ abstract contract V3DexAdapter is
230229
maxSwapLossBp = 50_000; // 5% (ppm) — per-swap rate-anchored loss cap
231230
emit MaxSwapLossBpChanged(maxSwapLossBp);
232231

233-
maxSpotDeviationBps = INITIAL_RANGE_BPS; // 1% — spot-vs-fair gate for adding liquidity at pool spot
232+
maxSpotDeviationBps = INITIAL_RANGE_BPS; // 0.5% — spot-vs-fair gate for adding liquidity at pool spot
234233
emit MaxSpotDeviationBpsChanged(maxSpotDeviationBps);
235234
}
236235

@@ -746,11 +745,10 @@ abstract contract V3DexAdapter is
746745
return;
747746
}
748747

749-
// One-sided inventory relative to the active range yields zero addable liquidity, which would
750-
// revert inside pool.mint (require(amount > 0)). Hold everything as idle and skip this round
751-
// instead of reverting, so a BOT compound / rebalance is never bricked by a single-sided balance.
752-
// The idle is deployed on a later compound once the opposite leg arrives or a rebalance recenters
753-
// the range.
748+
// One-sided inventory yields zero addable liquidity, which would revert inside pool.mint
749+
// (require(amount > 0)). Park it as idle instead; a later compound or rebalance deploys it.
750+
// Compound only — the rebalance re-mint has no such guard, so an empty-swapData recenter on
751+
// one-sided inventory reverts bare out of pool.mint; pass swapData or a non-zero minLiquidity.
754752
uint128 addable = LiquidityAmounts.getLiquidityForAmounts(
755753
spotSqrtPriceX96(),
756754
TickMath.getSqrtRatioAtTick(tickLower),

src/provider/v3/V3Provider.sol

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -488,8 +488,9 @@ abstract contract V3Provider is
488488
uint256 f1 = (amount1Desired * WAD) / t1;
489489
frac = f0 < f1 ? f0 : f1;
490490
}
491-
amount0 = (t0 * frac) / WAD;
492-
amount1 = (t1 * frac) / WAD;
491+
// Round UP, matching _quoteDeposit: the preview must report exactly what deposit() will consume.
492+
amount0 = (t0 * frac + WAD - 1) / WAD;
493+
amount1 = (t1 * frac + WAD - 1) / WAD;
493494
}
494495

495496
/// @notice Preview the shares a deposit would mint — the exact min(fair, spot) credit deposit() uses.
@@ -516,9 +517,9 @@ abstract contract V3Provider is
516517

517518
/// @notice Given a desired token0 amount, the token1 amount that pairs with it at the current fair
518519
/// composition ratio, so a subsequent deposit consumes both legs fully (minimal refund).
519-
/// @dev amount1 = amount0 * T1 / T0, where (T0, T1) = getFairComposition(). Reverts if the fair
520-
/// composition has no token0 leg (in that one-sided case deposit token1 only). For the first
521-
/// deposit (no position yet) use previewDepositAmounts, which previews the spot mint instead.
520+
/// @dev amount1 = amount0 * T1 / T0, where (T0, T1) = getFairComposition(). Reverts once fair has
521+
/// drifted past tickUpper (no token0 leg); deposits are then closed in every shape until the
522+
/// BOT recenters. Symmetric below tickLower. First deposit: use previewDepositAmounts.
522523
function previewDepositForToken0(uint256 amount0) external view returns (uint256 amount1) {
523524
(uint256 t0, uint256 t1) = getFairComposition();
524525
if (t0 == 0) revert ZeroAmounts();

src/provider/v3/WbETHV3DexAdapter.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ contract WbETHV3DexAdapter is V3DexAdapter {
6464
__V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper);
6565

6666
lastCenterRate = initialCenterRate;
67-
centerRateThresholdBps = INITIAL_RANGE_BPS;
68-
maxTwapDeviationBps = INITIAL_RANGE_BPS; // default valuation clamp band = ±range width (±1%)
67+
centerRateThresholdBps = 1;
68+
maxTwapDeviationBps = INITIAL_RANGE_BPS; // default valuation clamp band = ±range width (±0.5%)
6969
emit MaxTwapDeviationChanged(INITIAL_RANGE_BPS);
7070
}
7171

src/provider/v3/WstETHV3DexAdapter.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ contract WstETHV3DexAdapter is V3DexAdapter {
6464
__V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper);
6565

6666
lastCenterRate = initialCenterRate;
67-
centerRateThresholdBps = INITIAL_RANGE_BPS;
68-
maxTwapDeviationBps = INITIAL_RANGE_BPS; // default valuation clamp band = ±range width (±1%)
67+
centerRateThresholdBps = 1;
68+
maxTwapDeviationBps = INITIAL_RANGE_BPS; // default valuation clamp band = ±range width (±0.5%)
6969
emit MaxTwapDeviationChanged(INITIAL_RANGE_BPS);
7070
}
7171

test/liquidator/V3Liquidator.t.sol

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,73 @@ contract V3LiquidatorTest is Test {
638638
liquidator.flashLiquidate(Id.unwrap(marketId), user, shares, params);
639639
}
640640

641+
/// @dev Native analogue of the pre-funded-reserves test. When loanToken IS the wrapped-native, the
642+
/// profit check wraps the WHOLE native balance, so stray native held beforehand (an earlier
643+
/// redemption's unsold surplus, an un-reflowed redeemV3Shares, or a donation via receive()) would
644+
/// read as fresh profit and let a real shortfall pass. The snapshot must count it.
645+
function test_flashLiquidate_strayNativeNotCountedAsProfit() public {
646+
// WBNB-loan market so the wrapped-native branch of the profit check is taken.
647+
MarketParams memory wbnbMp = MarketParams({
648+
loanToken: WBNB,
649+
collateralToken: address(provider),
650+
oracle: address(providerOracle),
651+
irm: IRM,
652+
lltv: LLTV
653+
});
654+
Id wbnbId = wbnbMp.id();
655+
vm.prank(OPERATOR);
656+
moolah.createMarket(wbnbMp);
657+
vm.prank(MANAGER_ADDR);
658+
moolah.setProvider(wbnbId, address(provider), true);
659+
vm.prank(manager);
660+
liquidator.setMarketWhitelist(Id.unwrap(wbnbId), true);
661+
vm.prank(manager);
662+
liquidator.setTokenWhitelist(WBNB, true);
663+
664+
// Lender liquidity.
665+
address lender = makeAddr("wbnbLender");
666+
deal(WBNB, lender, 1_000 ether);
667+
vm.startPrank(lender);
668+
IERC20(WBNB).approve(address(moolah), type(uint256).max);
669+
moolah.supply(wbnbMp, 500 ether, 0, lender, "");
670+
vm.stopPrank();
671+
672+
// Collateral + borrow, then make it liquidatable with repaidAssets > 0.
673+
(uint256 shares, , ) = _deposit(user, 10 ether, 10 ether);
674+
vm.prank(user);
675+
provider.withdrawShares(marketParams, shares, user, user);
676+
vm.prank(user);
677+
provider.supplyShares(wbnbMp, shares, user);
678+
(, , uint128 col) = moolah.position(wbnbId, user);
679+
uint256 borrowed = (uint256(col) * providerOracle.peek(address(provider)) * 60) / (providerOracle.peek(WBNB) * 100);
680+
vm.prank(user);
681+
moolah.borrow(wbnbMp, borrowed, 0, user, user);
682+
_makeUnhealthyPartial(5_000);
683+
684+
// Stray native from prior activity — on its own it would cover the repayment.
685+
vm.deal(address(liquidator), 100 ether);
686+
687+
// Both legs yield zero WBNB: THIS liquidation produces nothing.
688+
bytes memory swap0Data = abi.encodeWithSelector(mockSwap.swap.selector, SLISBNB, WBNB, uint256(0), uint256(0));
689+
690+
V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({
691+
v3Provider: address(provider),
692+
minToken0Amt: 0,
693+
minToken1Amt: 0,
694+
redeemShares: true,
695+
token0Pair: address(mockSwap),
696+
token0Spender: address(0),
697+
token1Pair: address(0), // token1 IS the loan token (WBNB) — no swap
698+
token1Spender: address(0),
699+
swapToken0Data: swap0Data,
700+
swapToken1Data: ""
701+
});
702+
703+
vm.prank(bot);
704+
vm.expectRevert(V3Liquidator.NoProfit.selector);
705+
liquidator.flashLiquidate(Id.unwrap(wbnbId), user, shares, params);
706+
}
707+
641708
function test_flashLiquidate_revertsIfMarketNotWhitelisted() public {
642709
vm.prank(manager);
643710
liquidator.setMarketWhitelist(Id.unwrap(marketId), false);
@@ -716,6 +783,55 @@ contract V3LiquidatorTest is Test {
716783
liquidator.redeemV3Shares(address(provider), 1, 0, 0, address(liquidator));
717784
}
718785

786+
/// @dev With a shared pool configured, an external receiver would bypass the reflow accounting, so it
787+
/// is rejected — vault-funded redemptions must land here and flow back through _reflow.
788+
function test_redeemV3Shares_revertsOnExternalReceiverWithFundSource() public {
789+
// Hold real seized shares, so without the guard this redeem would SUCCEED and pay the external
790+
// address — the guard is what stops it, not a lack of balance.
791+
(uint256 shares, , ) = _deposit(user, 10 ether, 10 ether);
792+
_borrowAgainstCollateral(user);
793+
_makeUnhealthy();
794+
deal(LISUSD, address(liquidator), 1_000 ether);
795+
vm.prank(bot);
796+
liquidator.liquidate(Id.unwrap(marketId), user, shares, 0);
797+
uint256 held = provider.balanceOf(address(liquidator));
798+
assertGt(held, 0, "setup: liquidator holds shares");
799+
800+
LiquidationVault vault = _deployVault(true);
801+
vm.prank(manager);
802+
liquidator.setFundSource(address(vault));
803+
804+
vm.prank(bot);
805+
vm.expectRevert(V3Liquidator.ReceiverNotSelf.selector);
806+
liquidator.redeemV3Shares(address(provider), held, 0, 0, makeAddr("externalReceiver"));
807+
808+
// Same call into this contract still works.
809+
vm.prank(bot);
810+
liquidator.redeemV3Shares(address(provider), held, 0, 0, address(liquidator));
811+
assertEq(provider.balanceOf(address(liquidator)), 0, "shares redeemed to self");
812+
}
813+
814+
/// @dev Legacy (no shared pool): an external receiver stays allowed — the guard is scoped to fundSource.
815+
function test_redeemV3Shares_externalReceiverAllowedWithoutFundSource() public {
816+
assertEq(liquidator.fundSource(), address(0), "no fund source by default");
817+
818+
(uint256 shares, , ) = _deposit(user, 10 ether, 10 ether);
819+
_borrowAgainstCollateral(user);
820+
_makeUnhealthy();
821+
deal(LISUSD, address(liquidator), 1_000 ether);
822+
vm.prank(bot);
823+
liquidator.liquidate(Id.unwrap(marketId), user, shares, 0);
824+
825+
uint256 held = provider.balanceOf(address(liquidator));
826+
address receiver = makeAddr("externalReceiver");
827+
uint256 before = IERC20(SLISBNB).balanceOf(receiver);
828+
829+
vm.prank(bot);
830+
(uint256 out0, ) = liquidator.redeemV3Shares(address(provider), held, 0, 0, receiver);
831+
832+
assertEq(IERC20(SLISBNB).balanceOf(receiver) - before, out0, "external receiver paid");
833+
}
834+
719835
/* ─────────────────── sell token ─────────────────────────────────── */
720836

721837
function test_sellToken_erc20_swapsAndClearsAllowance() public {

test/provider/SlisBNBV3Provider.t.sol

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,13 @@ contract SlisBNBV3ProviderTest is Test {
300300
(, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1);
301301
uint256 min0 = (exp0 * 999) / 1000;
302302
uint256 min1 = (exp1 * 999) / 1000;
303+
// min0/min1 floor the CONSUMED amounts, not the entry price; minShares is the only guard against a
304+
// bad price, so mirror production and never pass 0.
305+
uint256 minShares = (provider.previewDepositShares(amount0, amount1) * 999) / 1000;
303306
vm.startPrank(_user);
304307
IERC20(SLISBNB).approve(address(provider), amount0);
305308
IERC20(WBNB).approve(address(provider), amount1);
306-
(shares, used0, used1) = provider.deposit(marketParams, amount0, amount1, min0, min1, 0, _user);
309+
(shares, used0, used1) = provider.deposit(marketParams, amount0, amount1, min0, min1, minShares, _user);
307310
vm.stopPrank();
308311
}
309312

@@ -792,6 +795,19 @@ contract SlisBNBV3ProviderTest is Test {
792795
assertApproxEqAbs(used1, exp1, 1, "used1 should match preview within 1 wei");
793796
}
794797

798+
/// @dev Subsequent-deposit branch (supply > 0): the preview must equal what deposit() consumes to the
799+
/// wei. Both round the fair composition UP, so a floor-rounded preview would under-report by 1 wei.
800+
function test_previewDeposit_amountsMatchActual_subsequentDeposit() public {
801+
_deposit(user, 100 ether, 100 ether); // seed so the frac branch is taken
802+
803+
(uint128 liquidity, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(10 ether, 10 ether);
804+
assertEq(liquidity, 0, "subsequent deposit parks to idle, mints no liquidity");
805+
806+
(, uint256 used0, uint256 used1) = _depositWithMin(user2, 10 ether, 10 ether, 0, 0);
807+
assertEq(used0, exp0, "used0 == preview exactly");
808+
assertEq(used1, exp1, "used1 == preview exactly");
809+
}
810+
795811
function test_previewDeposit_derivedMinAmounts_succeed() public {
796812
uint256 amount0 = 10 ether;
797813
uint256 amount1 = 10 ether;
@@ -2455,7 +2471,7 @@ contract SlisBNBV3ProviderTest is Test {
24552471
/* ───────────── Spot-vs-fair gate when adding liquidity at pool spot ───────────── */
24562472

24572473
function test_spotDeviationGate_defaults() public view {
2458-
assertEq(adapter.maxSpotDeviationBps(), 100, "default spot-deviation gate = 1%");
2474+
assertEq(adapter.maxSpotDeviationBps(), 50, "default spot-deviation gate = 0.5%");
24592475
}
24602476

24612477
function test_setMaxSpotDeviationBps_accessAndCaps() public {

0 commit comments

Comments
 (0)