From 38c87aebd5d7e2f95cf96789973747e050cedfcc Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 20 Aug 2025 10:42:37 -0400 Subject: [PATCH 1/3] build --- .../LenderCommitmentGroup_Pool_V2.sol | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/contracts/contracts/LenderCommitmentForwarder/extensions/LenderCommitmentGroup/LenderCommitmentGroup_Pool_V2.sol b/packages/contracts/contracts/LenderCommitmentForwarder/extensions/LenderCommitmentGroup/LenderCommitmentGroup_Pool_V2.sol index 0c10833ca..5f28993cd 100644 --- a/packages/contracts/contracts/LenderCommitmentForwarder/extensions/LenderCommitmentGroup/LenderCommitmentGroup_Pool_V2.sol +++ b/packages/contracts/contracts/LenderCommitmentForwarder/extensions/LenderCommitmentGroup/LenderCommitmentGroup_Pool_V2.sol @@ -141,8 +141,8 @@ contract LenderCommitmentGroup_Pool_V2 is - uint256 immutable public DEFAULT_WITHDRAW_DELAY_TIME_SECONDS = 300; - uint256 immutable public MAX_WITHDRAW_DELAY_TIME = 86400; + // uint256 immutable public DEFAULT_WITHDRAW_DELAY_TIME_SECONDS = 300; + // uint256 immutable public MAX_WITHDRAW_DELAY_TIME = 86400; mapping(uint256 => bool) public activeBids; mapping(uint256 => uint256) public activeBidsAmountDueRemaining; @@ -150,7 +150,7 @@ contract LenderCommitmentGroup_Pool_V2 is int256 tokenDifferenceFromLiquidations; bool private firstDepositMade_deprecated; // no longer used - uint256 public withdrawDelayTimeSeconds; + uint256 public withdrawDelayTimeSeconds; // immutable for now - use withdrawDelayBypassForAccount IUniswapPricingLibrary.PoolRouteConfig[] public poolOracleRoutes; @@ -162,7 +162,8 @@ contract LenderCommitmentGroup_Pool_V2 is bool public paused; bool public borrowingPaused; bool public liquidationAuctionPaused; - + + mapping(address => bool) public withdrawDelayBypassForAccount; event PoolInitialized( address indexed principalTokenAddress, @@ -302,7 +303,7 @@ contract LenderCommitmentGroup_Pool_V2 is marketId = _commitmentGroupConfig.marketId; - withdrawDelayTimeSeconds = DEFAULT_WITHDRAW_DELAY_TIME_SECONDS; + withdrawDelayTimeSeconds = 300; //in order for this to succeed, first, the SmartCommitmentForwarder needs to be a trusted forwarder for the market ITellerV2Context(TELLER_V2).approveMarketForwarder( @@ -1136,17 +1137,19 @@ contract LenderCommitmentGroup_Pool_V2 is /** - * @notice Sets the delay time for withdrawing shares. Only Protocol Owner. - * @param _seconds Delay time in seconds. + * @notice Allows accounts such as Yearn Vaults to bypass withdraw delay. + * @dev This should ONLY be enabled for smart contracts that separately implement MEV/spam protection. + * @param _addr The account that will have the bypass. + * @param _bypass Whether or not bypass is enabled */ - function setWithdrawDelayTime(uint256 _seconds) + function setWithdrawDelayBypassForAccount(address _addr, bool _bypass ) external onlyProtocolOwner { - require( _seconds < MAX_WITHDRAW_DELAY_TIME , "WD"); - - withdrawDelayTimeSeconds = _seconds; + + withdrawDelayBypassForAccount[_addr] = _bypass; + } - + // ------------------------ Pausing functions ------------ @@ -1391,7 +1394,12 @@ contract LenderCommitmentGroup_Pool_V2 is // Check withdrawal delay uint256 sharesLastTransferredAt = getSharesLastTransferredAt(owner); - require(block.timestamp >= sharesLastTransferredAt + withdrawDelayTimeSeconds, "SW"); + + + require( + withdrawDelayBypassForAccount[msg.sender] || + block.timestamp >= sharesLastTransferredAt + withdrawDelayTimeSeconds, "SW" + ); require(msg.sender == owner, "UA"); @@ -1436,7 +1444,10 @@ contract LenderCommitmentGroup_Pool_V2 is // Check withdrawal delay uint256 sharesLastTransferredAt = getSharesLastTransferredAt(owner); - require(block.timestamp >= sharesLastTransferredAt + withdrawDelayTimeSeconds, "SR"); + require( + withdrawDelayBypassForAccount[msg.sender] || + block.timestamp >= sharesLastTransferredAt + withdrawDelayTimeSeconds, "SR" + ); // Burn shares from owner burnShares(owner, shares); @@ -1557,7 +1568,7 @@ contract LenderCommitmentGroup_Pool_V2 is uint256 availableShares = balanceOf(owner); uint256 sharesLastTransferredAt = getSharesLastTransferredAt(owner); - if (block.timestamp <= sharesLastTransferredAt + withdrawDelayTimeSeconds) { + if ( !withdrawDelayBypassForAccount[msg.sender] && block.timestamp <= sharesLastTransferredAt + withdrawDelayTimeSeconds) { return 0; } From 011a55e415739f186532411cc3c9008e316ff57f Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 20 Aug 2025 11:03:00 -0400 Subject: [PATCH 2/3] add tests for bypass --- .../LenderCommitmentGroup_Pool_V2_Test.sol | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) diff --git a/packages/contracts/tests/SmartCommitmentForwarder/LenderCommitmentGroup_Pool_V2_Test.sol b/packages/contracts/tests/SmartCommitmentForwarder/LenderCommitmentGroup_Pool_V2_Test.sol index 9d812c788..778f83796 100644 --- a/packages/contracts/tests/SmartCommitmentForwarder/LenderCommitmentGroup_Pool_V2_Test.sol +++ b/packages/contracts/tests/SmartCommitmentForwarder/LenderCommitmentGroup_Pool_V2_Test.sol @@ -962,6 +962,307 @@ contract LenderCommitmentGroup_Pool_V2_Test is Testable { assertEq(actualRedeemAssets, expectedRedeemAssets, "Actual redeem assets should match preview"); } + + // Tests for withdrawDelayBypassForAccount functionality + function test_setWithdrawDelayBypassForAccount_success() public { + initialize_group_contract(); + + address testAccount = address(0x123); + + // Get the protocol owner (TellerV2 owner) + address protocolOwner = lenderCommitmentGroupSmartV2.owner(); + + // Mock TellerV2 owner + vm.mockCall( + address(_tellerV2), + abi.encodeWithSignature("owner()"), + abi.encode(protocolOwner) + ); + + // Set bypass to true + vm.prank(protocolOwner); + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(testAccount, true); + + bool isBypassed = lenderCommitmentGroupSmartV2.withdrawDelayBypassForAccount(testAccount); + assertTrue(isBypassed, "Account should have withdraw delay bypass enabled"); + + // Set bypass to false + vm.prank(protocolOwner); + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(testAccount, false); + + isBypassed = lenderCommitmentGroupSmartV2.withdrawDelayBypassForAccount(testAccount); + assertFalse(isBypassed, "Account should have withdraw delay bypass disabled"); + } + + function test_setWithdrawDelayBypassForAccount_onlyProtocolOwner() public { + initialize_group_contract(); + + address testAccount = address(0x123); + address notOwner = address(0x456); + + // Mock TellerV2 owner to be different from notOwner + address protocolOwner = address(0x789); + vm.mockCall( + address(_tellerV2), + abi.encodeWithSignature("owner()"), + abi.encode(protocolOwner) + ); + + // Should revert when called by non-owner + vm.prank(notOwner); + vm.expectRevert(bytes("OO")); // OnlyOwner error + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(testAccount, true); + } + + function test_withdraw_with_bypass_success() public { + initialize_group_contract(); + lenderCommitmentGroupSmartV2.set_mockSharesExchangeRate(1e36); + lenderCommitmentGroupSmartV2.set_totalPrincipalTokensCommitted(1000000); + + // Fund the contract + principalToken.transfer(address(lenderCommitmentGroupSmartV2), 1e18); + + // Mint shares to lender at specific timestamp + uint256 sharesAmount = 1000000; + vm.warp(1e6); + vm.prank(address(lenderCommitmentGroupSmartV2)); + lenderCommitmentGroupSmartV2.force_mint_shares(address(lender), sharesAmount); + + // Set withdraw delay to a high value + lenderCommitmentGroupSmartV2.force_set_withdraw_delay(9000); + + // Enable bypass for the lender + address protocolOwner = lenderCommitmentGroupSmartV2.owner(); + vm.mockCall( + address(_tellerV2), + abi.encodeWithSignature("owner()"), + abi.encode(protocolOwner) + ); + vm.prank(protocolOwner); + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(address(lender), true); + + // Should be able to withdraw immediately without waiting + vm.prank(address(lender)); + uint256 sharesRedeemed = lenderCommitmentGroupSmartV2.withdraw( + 500000, + address(lender), + address(lender) + ); + + assertGt(sharesRedeemed, 0, "Should successfully withdraw with bypass"); + } + + function test_withdraw_without_bypass_fails_immediately() public { + initialize_group_contract(); + lenderCommitmentGroupSmartV2.set_mockSharesExchangeRate(1e36); + lenderCommitmentGroupSmartV2.set_totalPrincipalTokensCommitted(1000000); + + // Fund the contract + principalToken.transfer(address(lenderCommitmentGroupSmartV2), 1e18); + + // Mint shares to lender at specific timestamp + uint256 sharesAmount = 1000000; + vm.warp(1e6); + vm.prank(address(lenderCommitmentGroupSmartV2)); + lenderCommitmentGroupSmartV2.force_mint_shares(address(lender), sharesAmount); + + // Set withdraw delay to a high value + lenderCommitmentGroupSmartV2.force_set_withdraw_delay(9000); + + // Do NOT enable bypass - should fail immediately + vm.prank(address(lender)); + vm.expectRevert(bytes("SW")); // Should revert with "SW" (withdrawal delay error) + lenderCommitmentGroupSmartV2.withdraw( + 500000, + address(lender), + address(lender) + ); + } + + function test_redeem_with_bypass_success() public { + initialize_group_contract(); + lenderCommitmentGroupSmartV2.set_mockSharesExchangeRate(1e36); + lenderCommitmentGroupSmartV2.set_totalPrincipalTokensCommitted(1000000); + + // Fund the contract + principalToken.transfer(address(lenderCommitmentGroupSmartV2), 1e18); + + // Mint shares to lender at specific timestamp + uint256 sharesAmount = 1000000; + vm.warp(1e6); + vm.prank(address(lenderCommitmentGroupSmartV2)); + lenderCommitmentGroupSmartV2.force_mint_shares(address(lender), sharesAmount); + + // Set withdraw delay to a high value + lenderCommitmentGroupSmartV2.force_set_withdraw_delay(9000); + + // Enable bypass for the lender + address protocolOwner = lenderCommitmentGroupSmartV2.owner(); + vm.mockCall( + address(_tellerV2), + abi.encodeWithSignature("owner()"), + abi.encode(protocolOwner) + ); + vm.prank(protocolOwner); + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(address(lender), true); + + // Should be able to redeem immediately without waiting + vm.prank(address(lender)); + uint256 assetsReceived = lenderCommitmentGroupSmartV2.redeem( + 500000, + address(lender), + address(lender) + ); + + assertGt(assetsReceived, 0, "Should successfully redeem with bypass"); + } + + function test_redeem_without_bypass_fails_immediately() public { + initialize_group_contract(); + lenderCommitmentGroupSmartV2.set_mockSharesExchangeRate(1e36); + lenderCommitmentGroupSmartV2.set_totalPrincipalTokensCommitted(1000000); + + // Fund the contract + principalToken.transfer(address(lenderCommitmentGroupSmartV2), 1e18); + + // Mint shares to lender at specific timestamp + uint256 sharesAmount = 1000000; + vm.warp(1e6); + vm.prank(address(lenderCommitmentGroupSmartV2)); + lenderCommitmentGroupSmartV2.force_mint_shares(address(lender), sharesAmount); + + // Set withdraw delay to a high value + lenderCommitmentGroupSmartV2.force_set_withdraw_delay(9000); + + // Do NOT enable bypass - should fail immediately + vm.prank(address(lender)); + vm.expectRevert(bytes("SR")); // Should revert with "SR" (redeem delay error) + lenderCommitmentGroupSmartV2.redeem( + 500000, + address(lender), + address(lender) + ); + } + + function test_maxRedeem_with_bypass() public { + initialize_group_contract(); + lenderCommitmentGroupSmartV2.set_mockSharesExchangeRate(1e36); + lenderCommitmentGroupSmartV2.set_totalPrincipalTokensCommitted(1000000); + + // Fund the contract with liquidity + principalToken.transfer(address(lenderCommitmentGroupSmartV2), 1e18); + + // Mint shares to lender + uint256 sharesAmount = 1000000; + vm.warp(1e6); + vm.prank(address(lenderCommitmentGroupSmartV2)); + lenderCommitmentGroupSmartV2.force_mint_shares(address(lender), sharesAmount); + + // Set withdraw delay + lenderCommitmentGroupSmartV2.force_set_withdraw_delay(9000); + + // Without bypass - should return 0 due to delay (call from lender's perspective) + vm.prank(address(lender)); + uint256 maxWithoutBypass = lenderCommitmentGroupSmartV2.maxRedeem(address(lender)); + assertEq(maxWithoutBypass, 0, "maxRedeem should return 0 without bypass during delay period"); + + // Enable bypass for the lender + address protocolOwner = lenderCommitmentGroupSmartV2.owner(); + vm.mockCall( + address(_tellerV2), + abi.encodeWithSignature("owner()"), + abi.encode(protocolOwner) + ); + vm.prank(protocolOwner); + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(address(lender), true); + + // With bypass - should return full balance (call from lender's perspective) + vm.prank(address(lender)); + uint256 maxWithBypass = lenderCommitmentGroupSmartV2.maxRedeem(address(lender)); + assertGt(maxWithBypass, 0, "maxRedeem should return > 0 with bypass"); + } + + function test_maxRedeem_bypass_limited_by_liquidity() public { + initialize_group_contract(); + lenderCommitmentGroupSmartV2.set_mockSharesExchangeRate(1e36); + lenderCommitmentGroupSmartV2.set_totalPrincipalTokensCommitted(1000000); + + // Fund the contract with limited liquidity (less than user balance) + uint256 limitedLiquidity = 100000; + principalToken.transfer(address(lenderCommitmentGroupSmartV2), limitedLiquidity); + + // Mint more shares than available liquidity + uint256 sharesAmount = 1000000; + vm.warp(1e6); + vm.prank(address(lenderCommitmentGroupSmartV2)); + lenderCommitmentGroupSmartV2.force_mint_shares(address(lender), sharesAmount); + + // Enable bypass + address protocolOwner = lenderCommitmentGroupSmartV2.owner(); + vm.mockCall( + address(_tellerV2), + abi.encodeWithSignature("owner()"), + abi.encode(protocolOwner) + ); + vm.prank(protocolOwner); + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(address(lender), true); + + // maxRedeem should be limited by available liquidity even with bypass (call from lender's perspective) + vm.prank(address(lender)); + uint256 maxRedeemable = lenderCommitmentGroupSmartV2.maxRedeem(address(lender)); + uint256 expectedMaxShares = lenderCommitmentGroupSmartV2.convertToShares(limitedLiquidity); + + assertEq(maxRedeemable, expectedMaxShares, "maxRedeem should be limited by available liquidity"); + } + + function test_bypass_flag_defaults_to_false() public { + initialize_group_contract(); + + address randomAccount = address(0x999); + + bool isBypassed = lenderCommitmentGroupSmartV2.withdrawDelayBypassForAccount(randomAccount); + assertFalse(isBypassed, "Bypass flag should default to false for new accounts"); + } + + function test_bypass_works_for_different_msg_sender() public { + initialize_group_contract(); + lenderCommitmentGroupSmartV2.set_mockSharesExchangeRate(1e36); + lenderCommitmentGroupSmartV2.set_totalPrincipalTokensCommitted(1000000); + + // Fund the contract + principalToken.transfer(address(lenderCommitmentGroupSmartV2), 1e18); + + address shareOwner = address(lender); + + // Mint shares to shareOwner + uint256 sharesAmount = 1000000; + vm.warp(1e6); + vm.prank(address(lenderCommitmentGroupSmartV2)); + lenderCommitmentGroupSmartV2.force_mint_shares(shareOwner, sharesAmount); + + // Set high withdrawal delay + lenderCommitmentGroupSmartV2.force_set_withdraw_delay(9000); + + // Enable bypass for the withdrawCaller (msg.sender), not shareOwner + address protocolOwner = lenderCommitmentGroupSmartV2.owner(); + vm.mockCall( + address(_tellerV2), + abi.encodeWithSignature("owner()"), + abi.encode(protocolOwner) + ); + vm.prank(protocolOwner); + lenderCommitmentGroupSmartV2.setWithdrawDelayBypassForAccount(shareOwner, true); + + // withdrawCaller should be able to withdraw from shareOwner's account with bypass + vm.prank(shareOwner); + uint256 sharesRedeemed = lenderCommitmentGroupSmartV2.withdraw( + 500000, + shareOwner, // receiver + shareOwner // owner + ); + + assertGt(sharesRedeemed, 0, "Should successfully withdraw with msg.sender bypass"); + } } \ No newline at end of file From 09866db3e82236ca4c651d2540b28653b5e76da4 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 22 Aug 2025 23:15:12 -0400 Subject: [PATCH 3/3] verify --- ...pgrade_lender_groups_withdraw_allowlist.ts | 91 +++++++++++++++++++ .../deployments/polygon/.migrations.json | 3 +- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/deploy/upgrades/33_upgrade_lender_groups_withdraw_allowlist.ts diff --git a/packages/contracts/deploy/upgrades/33_upgrade_lender_groups_withdraw_allowlist.ts b/packages/contracts/deploy/upgrades/33_upgrade_lender_groups_withdraw_allowlist.ts new file mode 100644 index 000000000..a35278189 --- /dev/null +++ b/packages/contracts/deploy/upgrades/33_upgrade_lender_groups_withdraw_allowlist.ts @@ -0,0 +1,91 @@ +import { DeployFunction } from 'hardhat-deploy/dist/types' + +import { get_ecosystem_contract_address } from "../../helpers/ecosystem-contracts-lookup" + + +const deployFn: DeployFunction = async (hre) => { + hre.log('----------') + hre.log('') + hre.log('Lender pools V2: Proposing upgrade...') + + + + const lenderCommitmentGroupV2BeaconProxy = await hre.contracts.get('LenderCommitmentGroupBeaconV2') + + const tellerV2 = await hre.contracts.get('TellerV2') + const SmartCommitmentForwarder = await hre.contracts.get( + 'SmartCommitmentForwarder' + ) + const tellerV2Address = await tellerV2.getAddress() + + + const smartCommitmentForwarderAddress = + await SmartCommitmentForwarder.getAddress() + + +let uniswapV3FactoryAddress: string = get_ecosystem_contract_address( hre.network.name, "uniswapV3Factory" ) ; + + + const uniswapPricingHelper = await hre.contracts.get('UniswapPricingHelper') + const uniswapPricingHelperAddress = await uniswapPricingHelper.getAddress() + + + + +//this is why the owner of the beacon should be timelock controller ! +// so we can upgrade it like this . Using a proposal. This actually goes AROUND the proxy admin, interestingly. + await hre.upgrades.proposeBatchTimelock({ + title: 'Lender Pools V2: Upgrade Rollover Fns', + description: ` +# Lender Pools V2 + +* A patch to use withdraw allowlist. +`, + _steps: [ + { + beacon: lenderCommitmentGroupV2BeaconProxy, + implFactory: await hre.ethers.getContractFactory('LenderCommitmentGroup_Pool_V2', { + + }), + + opts: { + unsafeSkipStorageCheck: true, + unsafeAllow: [ + 'constructor', + 'state-variable-immutable', + 'external-library-linking', + ], + constructorArgs: [ + tellerV2Address, + smartCommitmentForwarderAddress, + uniswapV3FactoryAddress, + uniswapPricingHelperAddress + ], + }, + }, + ], + }) + + hre.log('done.') + hre.log('') + hre.log('----------') + + return true +} + +// tags and deployment +deployFn.id = 'lender-commitment-group-beacon-v2:withdraw-allowlist' +deployFn.tags = ['lender-commitment-group-beacon-v2'] +deployFn.dependencies = [ + 'teller-v2:deploy', + 'smart-commitment-forwarder:deploy', + 'teller-v2:uniswap-pricing-library-v2', + + 'lender-commitment-group-beacon-v2:deploy', + 'uniswap-pricing-helper:deploy' +] + +deployFn.skip = async (hre) => { + return !hre.network.live || !['sepolia','polygon','base','mainnet','arbitrum','katana','optimism'].includes(hre.network.name) +} +export default deployFn diff --git a/packages/contracts/deployments/polygon/.migrations.json b/packages/contracts/deployments/polygon/.migrations.json index 57e737b16..bdeef4d59 100644 --- a/packages/contracts/deployments/polygon/.migrations.json +++ b/packages/contracts/deployments/polygon/.migrations.json @@ -58,5 +58,6 @@ "smart-commitment-forwarder:upgrade-oracle": 1753816748, "lender-commitment-group-beacon-v2:upgrade-first-deposit": 1754323999, "lender-commitment-group-beacon-v2:pricing-helper": 1755271511, - "lender-commitment-forwarder:extensions:flash-swap-rollover:g2-upgrade": 1755275678 + "lender-commitment-forwarder:extensions:flash-swap-rollover:g2-upgrade": 1755275678, + "lender-commitment-group-beacon-v2:withdraw-allowlist": 1755275678, } \ No newline at end of file