diff --git a/contracts/modules/Exec.sol b/contracts/modules/Exec.sol index dbc3a40e..1adef7af 100644 --- a/contracts/modules/Exec.sol +++ b/contracts/modules/Exec.sol @@ -87,17 +87,39 @@ contract Exec is BaseLogic { /// @param account The account to defer liquidity for. Usually address(this), although not always /// @param data Passed through to the onDeferredLiquidityCheck() callback, so contracts don't need to store transient data in storage function deferLiquidityCheck(address account, bytes memory data) external reentrantOK { + address[] memory accounts = new address[](1); + accounts[0] = account; + + doDeferLiquidityCheckMulti(accounts, data); + } + + /// @notice Defer liquidity checking for an array of accounts, to perform rebalancing, flash loans, etc. msg.sender must implement IDeferredLiquidityCheck + /// @param accounts The array of accounts to defer liquidity for + /// @param data Passed through to the onDeferredLiquidityCheck() callback, so contracts don't need to store transient data in storage + function deferLiquidityCheckMulti(address[] memory accounts, bytes memory data) external reentrantOK { + doDeferLiquidityCheckMulti(accounts, data); + } + + function doDeferLiquidityCheckMulti(address[] memory accounts, bytes memory data) internal { address msgSender = unpackTrailingParamMsgSender(); - require(accountLookup[account].deferLiquidityStatus == DEFERLIQUIDITY__NONE, "e/defer/reentrancy"); - accountLookup[account].deferLiquidityStatus = DEFERLIQUIDITY__CLEAN; + for (uint i = 0; i < accounts.length; ++i) { + address account = accounts[i]; + + require(accountLookup[account].deferLiquidityStatus == DEFERLIQUIDITY__NONE, "e/defer/reentrancy"); + accountLookup[account].deferLiquidityStatus = DEFERLIQUIDITY__CLEAN; + } IDeferredLiquidityCheck(msgSender).onDeferredLiquidityCheck(data); - uint8 status = accountLookup[account].deferLiquidityStatus; - accountLookup[account].deferLiquidityStatus = DEFERLIQUIDITY__NONE; + for (uint i = 0; i < accounts.length; ++i) { + address account = accounts[i]; + + uint8 status = accountLookup[account].deferLiquidityStatus; + accountLookup[account].deferLiquidityStatus = DEFERLIQUIDITY__NONE; - if (status == DEFERLIQUIDITY__DIRTY) checkLiquidity(account); + if (status == DEFERLIQUIDITY__DIRTY) checkLiquidity(account); + } } /// @notice Execute several operations in a single transaction @@ -183,10 +205,14 @@ contract Exec is BaseLogic { /// @notice Transfer underlying tokens from sender's wallet into the pToken wrapper. Allowance should be set for the euler address. /// @param underlying Token address - /// @param amount The amount to wrap in underlying units + /// @param amount The amount to wrap in underlying units (use max uint256 for full underlying token balance) function pTokenWrap(address underlying, uint amount) external nonReentrant { address msgSender = unpackTrailingParamMsgSender(); + if (amount == type(uint).max) { + amount = IERC20(underlying).balanceOf(msgSender); + } + emit PTokenWrap(underlying, msgSender, amount); address pTokenAddr = reversePTokenLookup[underlying]; @@ -204,15 +230,19 @@ contract Exec is BaseLogic { /// @notice Transfer underlying tokens from the pToken wrapper to the sender's wallet. /// @param underlying Token address - /// @param amount The amount to unwrap in underlying units + /// @param amount The amount to unwrap in underlying units (use max uint256 for full underlying token balance) function pTokenUnWrap(address underlying, uint amount) external nonReentrant { address msgSender = unpackTrailingParamMsgSender(); - emit PTokenUnWrap(underlying, msgSender, amount); - address pTokenAddr = reversePTokenLookup[underlying]; require(pTokenAddr != address(0), "e/exec/ptoken-not-found"); + if (amount == type(uint).max) { + amount = PToken(pTokenAddr).balanceOf(msgSender); + } + + emit PTokenUnWrap(underlying, msgSender, amount); + PToken(pTokenAddr).forceUnwrap(msgSender, amount); } diff --git a/contracts/test/DeferredLiquidityCheckTest.sol b/contracts/test/DeferredLiquidityCheckTest.sol new file mode 100644 index 00000000..c47fc40b --- /dev/null +++ b/contracts/test/DeferredLiquidityCheckTest.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.0; + +import "../modules/Exec.sol"; +import "../modules/Markets.sol"; +import "../modules/DToken.sol"; + + +contract DeferredLiquidityCheckTest is IDeferredLiquidityCheck { + uint constant AMOUNT = 1 ether; + address euler; + address markets; + address exec; + + event onDeferredLiquidityCheckEvent(); + + constructor(address eulerAddr, address marketsAddr, address execAddr) { + euler = eulerAddr; + markets = marketsAddr; + exec = execAddr; + } + + function getSubAccount(uint subAccountId) internal view returns (address) { + require(subAccountId < 256, "sub-account-id-too-big"); + return address(uint160(address(this)) ^ uint160(subAccountId)); + } + + function onDeferredLiquidityCheck(bytes memory data) external override { + (address underlying, address[] memory accounts, uint scenario) = abi.decode(data, (address, address[], uint)); + + address dToken = Markets(markets).underlyingToDToken(underlying); + IERC20(underlying).approve(euler, type(uint).max); + emit onDeferredLiquidityCheckEvent(); + + if (scenario == 1) { + DToken(dToken).borrow(0, AMOUNT); + DToken(dToken).repay(0, AMOUNT); + } else if (scenario == 2) { + DToken(dToken).borrow(0, AMOUNT); + DToken(dToken).borrow(1, AMOUNT); + DToken(dToken).repay(0, AMOUNT); + DToken(dToken).repay(1, AMOUNT); + } else if (scenario == 3) { + Exec(exec).deferLiquidityCheckMulti(accounts, abi.encode(underlying, accounts, 1)); + } else if (scenario == 4) { + Exec(exec).deferLiquidityCheck(accounts[accounts.length - 1], abi.encode(underlying, accounts, 1)); + } else if (scenario == 5) { + address account = getSubAccount(1); + accounts[0] = account; + Exec(exec).deferLiquidityCheck(account, abi.encode(underlying, accounts, 1)); + Exec(exec).deferLiquidityCheck(account, abi.encode(underlying, accounts, 2)); + Exec(exec).deferLiquidityCheckMulti(accounts, abi.encode(underlying, accounts, 1)); + Exec(exec).deferLiquidityCheckMulti(accounts, abi.encode(underlying, accounts, 2)); + } else if (scenario == 6) { + Exec.EulerBatchItem[] memory items = new Exec.EulerBatchItem[](2); + items[0] = Exec.EulerBatchItem(false, dToken, abi.encodeWithSelector(DToken.borrow.selector, 0, AMOUNT)); + items[1] = Exec.EulerBatchItem(false, dToken, abi.encodeWithSelector(DToken.repay.selector, 0, AMOUNT)); + accounts[0] = getSubAccount(0); + Exec(exec).batchDispatch(items, accounts); + } else if (scenario == 7) { + Exec.EulerBatchItem[] memory items = new Exec.EulerBatchItem[](2); + items[0] = Exec.EulerBatchItem(false, dToken, abi.encodeWithSelector(DToken.borrow.selector, 0, AMOUNT)); + items[1] = Exec.EulerBatchItem(false, dToken, abi.encodeWithSelector(DToken.repay.selector, 0, AMOUNT)); + accounts[0] = getSubAccount(0); + accounts[1] = address(0); + Exec(exec).batchDispatch(items, accounts); + } else { + revert("onDeferredLiquidityCheck: wrong scenario"); + } + } + + function test(address underlying, address[] memory accounts, uint scenario) external { + if (scenario == 1) { + Exec(exec).deferLiquidityCheck(accounts[0], abi.encode(underlying, accounts, scenario)); + } else if (scenario == 2) { + Exec(exec).deferLiquidityCheckMulti(accounts, abi.encode(underlying, accounts, scenario)); + } else if (scenario == 3) { + Exec(exec).deferLiquidityCheck(accounts[0], abi.encode(underlying, accounts, scenario)); + } else if (scenario == 4) { + Exec(exec).deferLiquidityCheckMulti(accounts, abi.encode(underlying, accounts, scenario)); + } else if (scenario == 5) { + Exec(exec).deferLiquidityCheck(accounts[0], abi.encode(underlying, accounts, scenario)); + } else if (scenario == 6) { + Exec(exec).deferLiquidityCheck(accounts[0], abi.encode(underlying, accounts, scenario)); + } else if (scenario == 7) { + Exec(exec).deferLiquidityCheckMulti(accounts, abi.encode(underlying, accounts, scenario)); + } else if (scenario == 8) { + Exec.EulerBatchItem[] memory items = new Exec.EulerBatchItem[](1); + items[0] = Exec.EulerBatchItem( + false, + exec, + abi.encodeWithSelector( + Exec.deferLiquidityCheck.selector, + accounts[0], + abi.encode(underlying, accounts, 1) + ) + ); + Exec(exec).batchDispatch(items, accounts); + } else if (scenario == 9) { + Exec.EulerBatchItem[] memory items = new Exec.EulerBatchItem[](1); + items[0] = Exec.EulerBatchItem( + false, + exec, + abi.encodeWithSelector( + Exec.deferLiquidityCheckMulti.selector, + accounts, + abi.encode(underlying, accounts, 1) + ) + ); + Exec(exec).batchDispatch(items, accounts); + } else if (scenario == 10) { + Exec.EulerBatchItem[] memory items = new Exec.EulerBatchItem[](1); + items[0] = Exec.EulerBatchItem( + false, + exec, + abi.encodeWithSelector( + Exec.deferLiquidityCheck.selector, + getSubAccount(1), + abi.encode(underlying, accounts, 1) + ) + ); + Exec(exec).batchDispatch(items, accounts); + } else { + revert("test: wrong scenario"); + } + } +} diff --git a/test/batch.js b/test/batch.js index c371f645..8457ba2f 100644 --- a/test/batch.js +++ b/test/batch.js @@ -145,6 +145,21 @@ et.testSet({ }) +.test({ + desc: "defer extended reentrancy", + actions: ctx => [ + { action: 'sendBatch', deferLiquidityChecks: [et.getSubAccount(ctx.wallet.address, 1), et.getSubAccount(ctx.wallet.address, 2)], batch: [ + { send: 'eTokens.eTST.transfer', args: [et.getSubAccount(ctx.wallet.address, 2), et.eth(1)], }, + { send: 'exec.deferLiquidityCheckExtended', args: [ + [et.getSubAccount(ctx.wallet.address, 2)], + ctx.contracts.eTokens.eTST.interface.encodeFunctionData('transfer', [ctx.wallet.address, et.eth(1)]), + ]} + ], expectError: 'e/defer/reentrancy', + }, + ], +}) + + .test({ desc: "allow error", actions: ctx => [ diff --git a/test/deferLiquidityCheck.js b/test/deferLiquidityCheck.js new file mode 100644 index 00000000..d09ec604 --- /dev/null +++ b/test/deferLiquidityCheck.js @@ -0,0 +1,153 @@ +const et = require('./lib/eTestLib'); + +et.testSet({ + desc: "defer liquidity check", + + preActions: ctx => [ + { send: 'tokens.TST.mint', args: [ctx.wallet.address, et.eth(100)], }, + { send: 'tokens.TST.approve', args: [ctx.contracts.euler.address, et.MaxUint256,], }, + { send: 'eTokens.eTST.deposit', args: [0, et.MaxUint256], }, + { action: 'updateUniswapPrice', pair: 'TST/WETH', price: '.01', }, + { action: 'cb', cb: async () => { + ctx.contracts.deferredLiquidityCheckTest = await (await ctx.factories.DeferredLiquidityCheckTest.deploy( + ctx.contracts.euler.address, + ctx.contracts.markets.address, + ctx.contracts.exec.address, + )).deployed(); + }} + ], +}) + +.test({ + desc: "simple defer liquidity check", + actions: ctx => [ + // should revert as liquidity deferred for wrong address + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.AddressZero ], 1], + expectError: 'e/collateral-violation' + }, + + // should pass as liquidity deferred for correct address + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ ctx.contracts.deferredLiquidityCheckTest.address ], 1], + onLogs: logs => { + et.expect(logs.findIndex(log => log.name === "onDeferredLiquidityCheckEvent")).to.gt(-1); + }}, + ], +}) + +.test({ + desc: "extended defer liquidity check", + actions: ctx => [ + // should revert as liquidity deferred only for one address + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 0) ], 2], + expectError: 'e/collateral-violation' + }, + + // should pass as liquidity deferred for both addresses + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 0), et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 1) ], 2], + onLogs: logs => { + et.expect(logs.findIndex(log => log.name === "onDeferredLiquidityCheckEvent")).to.gt(-1); + }}, + ], +}) + +.test({ + desc: "defer liquidity check - reentrancies", + actions: ctx => [ + // should revert due to reentrancy enforced by scenario 3 + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ ctx.contracts.deferredLiquidityCheckTest.address ], 3], + expectError: 'e/defer/reentrancy' + }, + + // should revert due to reentrancy enforced by scenario 4 + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, + [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 0), + et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 1), + et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 2) + ], 4], + expectError: 'e/defer/reentrancy' + }, + + // should pass as scenario 5 re-enters, but defers liquidity for an address not deferred before + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ ctx.contracts.deferredLiquidityCheckTest.address ], 5], + onLogs: logs => { + for(const i = 0; i < 4; i++) { + if (i > 0) { + logs.splice(index, 1) + } + const index = logs.findIndex(log => log.name === "onDeferredLiquidityCheckEvent") + et.expect(index).to.gt(-1); + } + }}, + ], +}) + +.test({ + desc: "batch dispatch from defer liquidity check", + actions: ctx => [ + // should revert due to reentrancy enforced from defer liquidity check in scenario 6 + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ ctx.contracts.deferredLiquidityCheckTest.address ], 6], + expectError: 'e/batch/reentrancy' + }, + + // should pass as defer liquidity check defers liquidity for different account than batch dispatch called from defer liquidity check + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 1) ], 6], + onLogs: logs => { + et.expect(logs.findIndex(log => log.name === "onDeferredLiquidityCheckEvent")).to.gt(-1); + }}, + + // should revert due to reentrancy enforced from defer liquidity check in scenario 7 + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 0), et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 1) ], 7], + expectError: 'e/batch/reentrancy' + }, + + // should pass as defer liquidity check defers liquidity for different account than batch dispatch called from defer liquidity check + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 1), et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 2) ], 7], + onLogs: logs => { + et.expect(logs.findIndex(log => log.name === "onDeferredLiquidityCheckEvent")).to.gt(-1); + }}, + ], +}) + +.test({ + desc: "defer liquidity check from batch dispatch", + actions: ctx => [ + // should revert due to reentrancy enforced from batch dispatch in scenario 8 + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ ctx.contracts.deferredLiquidityCheckTest.address ], 8], + expectError: 'e/defer/reentrancy' + }, + + // should revert due to reentrancy enforced from batch dispatch in scenario 9 + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 0), et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 1) ], 9], + expectError: 'e/defer/reentrancy' + }, + + // should revert due to reentrancy enforced from batch dispatch in scenario 10 + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 0), et.getSubAccount(ctx.contracts.deferredLiquidityCheckTest.address, 1) ], 10], + expectError: 'e/defer/reentrancy' + }, + + // should pass as batch dispatch defers liquidity for different account than defer liquidity check called from batch dispatch + { call: 'deferredLiquidityCheckTest.test', + args: [ctx.contracts.tokens.TST.address, [ ctx.contracts.deferredLiquidityCheckTest.address ], 10], + onLogs: logs => { + et.expect(logs.findIndex(log => log.name === "onDeferredLiquidityCheckEvent")).to.gt(-1); + }}, + ], +}) + + +.run(); diff --git a/test/lib/eTestLib.js b/test/lib/eTestLib.js index 209cdbe5..2e259e4d 100644 --- a/test/lib/eTestLib.js +++ b/test/lib/eTestLib.js @@ -89,6 +89,7 @@ const contractNames = [ 'TestModule', 'MockAggregatorProxy', 'MockStETH', + 'DeferredLiquidityCheckTest', // Custom Oracles diff --git a/test/pToken.js b/test/pToken.js index 9a567071..634ee020 100644 --- a/test/pToken.js +++ b/test/pToken.js @@ -127,6 +127,34 @@ et.testSet({ ], }) +.test({ + desc: "batch wrapping amount max", + actions: ctx => [ + { send: 'tokens.TST.approve', args: [ctx.contracts.euler.address, et.MaxUint256,], }, + + { action: 'sendBatch', batch: [ + { send: 'exec.pTokenWrap', args: [ctx.contracts.tokens.TST.address, et.MaxUint256], }, + { send: 'eTokens.epTST.deposit', args: [0, et.eth(5)], }, + { send: 'markets.enterMarket', args: [0, ctx.contracts.pTokens.pTST.address], }, + ]}, + + { call: 'exec.detailedLiquidity', args: [ctx.wallet.address], onResult: r => { + et.equals(r[0].status.collateralValue, 3.75, 0.001); + }, }, + + { call: 'pTokens.pTST.balanceOf', args: [ctx.wallet.address], equals: 95, }, + { call: 'tokens.TST.balanceOf', args: [ctx.wallet.address], equals: 0, }, + + { action: 'sendBatch', batch: [ + { send: 'eTokens.epTST.withdraw', args: [0, et.eth(3)], }, + { send: 'exec.pTokenUnWrap', args: [ctx.contracts.tokens.TST.address, et.MaxUint256], }, + ]}, + + { call: 'pTokens.pTST.balanceOf', args: [ctx.wallet.address], equals: 0, }, + { call: 'tokens.TST.balanceOf', args: [ctx.wallet.address], equals: 98, }, + ], +}) + .test({ desc: "activate market for ptoken",