Skip to content

Commit 6b5936a

Browse files
razwwclaude
andcommitted
feat(v3-lp): gate deposits behind an optional whitelist
Adds an opt-in allowlist for a gated launch. Shares are already non-transferable except by Moolah, so closing the deposit entry point is enough to keep them off non-whitelisted addresses; nothing else needs a gate. Both msg.sender and onBehalf are checked — gating only the caller would let a whitelisted address open a position for anyone. Withdraw, redeemShares, withdrawShares, the Moolah liquidation callback and the BOT paths are deliberately left open. Gating an exit would strand a delisted holder's funds, and gating a seizure would socialise bad debt to lenders; two of the new tests pin that behaviour so a later change cannot quietly extend the gate to those paths. Disabled by default, so existing deployments and tests are unaffected until MANAGER turns it on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1a1de2e commit 6b5936a

3 files changed

Lines changed: 127 additions & 1 deletion

File tree

src/provider/interfaces/IV3Provider.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,12 @@ interface IV3Provider is IProvider {
103103
uint256 minAmount1,
104104
address receiver
105105
) external returns (uint256 amount0, uint256 amount1);
106+
107+
function depositWhitelistEnabled() external view returns (bool);
108+
109+
function depositWhitelist(address account) external view returns (bool);
110+
111+
function setDepositWhitelistEnabled(bool enabled) external;
112+
113+
function setDepositWhitelist(address[] calldata accounts, bool allowed) external;
106114
}

src/provider/v3/V3Provider.sol

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,12 @@ abstract contract V3Provider is
9090
/// @dev Cumulative rebalance loss (8-decimal USD) recorded so far for `dailyLossDay`.
9191
uint256 public dailyLossAccum;
9292

93+
/// @dev Gate on the deposit entry point only. Disabled by default; flip on for a gated launch.
94+
bool public depositWhitelistEnabled;
95+
mapping(address => bool) public depositWhitelist;
96+
9397
/// @dev Reserved storage for future base variables (keep subclass storage stable on upgrade).
94-
uint256[46] private __gap;
98+
uint256[44] private __gap;
9599

96100
/* ───────────────────────────── events ───────────────────────────── */
97101

@@ -115,6 +119,8 @@ abstract contract V3Provider is
115119
event SharesRedeemed(address indexed redeemer, uint256 shares, uint256 amount0, uint256 amount1, address receiver);
116120
event MaxRebalanceLossBpChanged(uint256 maxRebalanceLossBp);
117121
event MaxDailyLossUsdChanged(uint256 maxDailyLossUsd);
122+
event DepositWhitelistEnabledChanged(bool enabled);
123+
event DepositWhitelistChanged(address indexed account, bool allowed);
118124
event RebalanceDailyLossAccrued(uint256 indexed day, uint256 loss, uint256 accum);
119125

120126
/* ───────────────────────────── errors ───────────────────────────── */
@@ -127,6 +133,7 @@ abstract contract V3Provider is
127133
error Unauthorized();
128134
error InsufficientShares();
129135
error OnlyMoolah();
136+
error NotWhitelisted();
130137
error InvalidMarket();
131138
error StandardEntryDisabled();
132139
error BnbTransferFailed();
@@ -195,6 +202,23 @@ abstract contract V3Provider is
195202
emit MaxDailyLossUsdChanged(maxDailyLossUsd);
196203
}
197204

205+
/// @notice Turn the deposit whitelist on or off. onlyRole MANAGER.
206+
function setDepositWhitelistEnabled(bool enabled) external onlyRole(MANAGER) {
207+
depositWhitelistEnabled = enabled;
208+
emit DepositWhitelistEnabledChanged(enabled);
209+
}
210+
211+
/// @notice Allow or disallow accounts on the deposit whitelist. onlyRole MANAGER.
212+
/// @dev Removing an account only closes new deposits — it never blocks that account's withdraw,
213+
/// redeem or liquidation.
214+
function setDepositWhitelist(address[] calldata accounts, bool allowed) external onlyRole(MANAGER) {
215+
for (uint256 i; i < accounts.length; ++i) {
216+
if (accounts[i] == address(0)) revert ZeroAddress();
217+
depositWhitelist[accounts[i]] = allowed;
218+
emit DepositWhitelistChanged(accounts[i], allowed);
219+
}
220+
}
221+
198222
/* ──────────────────── ERC20 transfer restrictions ───────────────── */
199223

200224
/// @dev Only Moolah may transfer shares (prevents orphaning the position by moving collateral out).
@@ -229,6 +253,9 @@ abstract contract V3Provider is
229253
) external payable nonReentrant returns (uint256 shares, uint256 amount0Used, uint256 amount1Used) {
230254
if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken();
231255
if (onBehalf == address(0)) revert ZeroAddress();
256+
// Check both: gating only msg.sender would let a whitelisted caller open a position for anyone.
257+
if (depositWhitelistEnabled && (!depositWhitelist[msg.sender] || !depositWhitelist[onBehalf]))
258+
revert NotWhitelisted();
232259

233260
uint256 _amount0Desired = amount0Desired;
234261
uint256 _amount1Desired = amount1Desired;

test/provider/SlisBNBV3Provider.t.sol

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,97 @@ contract SlisBNBV3ProviderTest is Test {
317317
vm.stopPrank();
318318
}
319319

320+
/* ─────────────────────── deposit whitelist ─────────────────────── */
321+
322+
function _allow(address who, bool ok) internal {
323+
address[] memory a = new address[](1);
324+
a[0] = who;
325+
vm.prank(manager);
326+
provider.setDepositWhitelist(a, ok);
327+
}
328+
329+
function _enableWhitelist() internal {
330+
vm.prank(manager);
331+
provider.setDepositWhitelistEnabled(true);
332+
}
333+
334+
function test_depositWhitelist_offByDefault() public {
335+
assertFalse(provider.depositWhitelistEnabled(), "off by default");
336+
_deposit(user, 1 ether, 1 ether); // unlisted user still gets in
337+
}
338+
339+
function test_depositWhitelist_blocksUnlistedDepositor() public {
340+
_enableWhitelist();
341+
deal(SLISBNB, user, 1 ether);
342+
deal(WBNB, user, 1 ether);
343+
vm.startPrank(user);
344+
IERC20(SLISBNB).approve(address(provider), 1 ether);
345+
IERC20(WBNB).approve(address(provider), 1 ether);
346+
vm.expectRevert(V3Provider.NotWhitelisted.selector);
347+
provider.deposit(marketParams, 1 ether, 1 ether, 0, 0, 0, user);
348+
vm.stopPrank();
349+
350+
_allow(user, true);
351+
(uint256 shares, , ) = _deposit(user, 1 ether, 1 ether);
352+
assertGt(shares, 0, "listed depositor gets in");
353+
}
354+
355+
/// @dev Gating only msg.sender would let a listed caller open a position for an unlisted address.
356+
function test_depositWhitelist_blocksUnlistedOnBehalf() public {
357+
_enableWhitelist();
358+
_allow(user, true);
359+
deal(SLISBNB, user, 1 ether);
360+
deal(WBNB, user, 1 ether);
361+
vm.startPrank(user);
362+
IERC20(SLISBNB).approve(address(provider), 1 ether);
363+
IERC20(WBNB).approve(address(provider), 1 ether);
364+
vm.expectRevert(V3Provider.NotWhitelisted.selector);
365+
provider.deposit(marketParams, 1 ether, 1 ether, 0, 0, 0, user2);
366+
vm.stopPrank();
367+
}
368+
369+
/// @dev A delisted holder must never be trapped: exits stay open after the gate closes on them.
370+
function test_depositWhitelist_neverBlocksExit() public {
371+
(uint256 shares, , ) = _deposit(user, 5 ether, 5 ether);
372+
_enableWhitelist(); // user is NOT listed
373+
374+
vm.prank(user);
375+
provider.withdraw(marketParams, shares / 2, 0, 0, user, user);
376+
377+
vm.prank(user);
378+
provider.withdrawShares(marketParams, shares / 4, user, user);
379+
vm.prank(user);
380+
provider.redeemShares(shares / 4, 0, 0, user);
381+
382+
// All three exit paths ran under the gate: collateral shrank and the shares pulled to the wallet
383+
// were redeemed rather than stranded there.
384+
assertEq(_collateral(user), shares - shares / 2 - shares / 4, "collateral drawn down while gated");
385+
assertEq(provider.balanceOf(user), 0, "wallet shares redeemed while gated");
386+
}
387+
388+
/// @dev Liquidation must keep working under the gate, or bad debt is socialised to lenders.
389+
function test_depositWhitelist_neverBlocksLiquidation() public {
390+
(uint256 shares, , ) = _deposit(user, 10 ether, 10 ether);
391+
_borrowAgainstCollateral(user);
392+
_enableWhitelist(); // nobody is listed
393+
_makeUnhealthy();
394+
395+
uint256 seize = _collateral(user) / 2;
396+
deal(LISUSD, address(this), 1_000_000 ether);
397+
IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max);
398+
moolah.liquidate(marketParams, user, seize, 0, "");
399+
assertLt(_collateral(user), shares, "liquidation still seizes under the gate");
400+
}
401+
402+
function test_setDepositWhitelist_onlyManager() public {
403+
address[] memory a = new address[](1);
404+
a[0] = user;
405+
vm.expectRevert();
406+
provider.setDepositWhitelist(a, true);
407+
vm.expectRevert();
408+
provider.setDepositWhitelistEnabled(true);
409+
}
410+
320411
function _collateral(address _user) internal view returns (uint256) {
321412
(, , uint256 col) = moolah.position(marketId, _user);
322413
return col;

0 commit comments

Comments
 (0)