diff --git a/contracts/flash-swap/ExactOutputFlash.sol b/contracts/flash-swap/ExactOutputFlash.sol new file mode 100644 index 00000000..f111ea31 --- /dev/null +++ b/contracts/flash-swap/ExactOutputFlash.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.13; + +import { IPancakeV3SwapCallback } from "@pancakeswap/v3-core/contracts/interfaces/callback/IPancakeV3SwapCallback.sol"; +import { IPancakeV3FlashCallback } from "@pancakeswap/v3-core/contracts/interfaces/callback/IPancakeV3FlashCallback.sol"; +import { IPancakeV3Pool } from "@pancakeswap/v3-core/contracts/interfaces/IPancakeV3Pool.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ensureNonzeroAddress } from "@venusprotocol/solidity-utilities/contracts/validators.sol"; + +import { approveOrRevert } from "../util/approveOrRevert.sol"; +import { ISmartRouter } from "../third-party/pancakeswap-v8/ISmartRouter.sol"; +import { Path } from "../third-party/pancakeswap-v8/Path.sol"; +import { PoolAddress } from "../third-party/pancakeswap-v8/PoolAddress.sol"; +import { MIN_SQRT_RATIO, MAX_SQRT_RATIO } from "../third-party/pancakeswap-v8/constants.sol"; + +/// @title ExactOutputFlash +/// @notice A base contract for exact output flash swap operations. +/// +/// Upon calling _flash, swaps tokenX to tokenY using a flash swap or a flash loan, +/// i.e. the contract: +/// +/// 1. Invokes the flash swap on the first pool from the path +/// 2. Receives tokenY from the pool +/// 3. Calls _onMoneyReceived, which should ensure that the contract has enough tokenX +/// to repay the flash swap +/// 4. Repays the flash swap with tokenX (doing the conversion if necessary) +/// 5. Calls _onFlashCompleted +/// +/// @dev This contract is abstract and should be inherited by a contract that implements +/// _onMoneyReceived and _onFlashCompleted. Note that in the callbacks transaction +/// context (sender and value) is different from the original context. The inheriting +/// contracts should save the original context in the application-specific data bytes +/// passed to the callbacks. +abstract contract ExactOutputFlash is IPancakeV3SwapCallback, IPancakeV3FlashCallback { + using SafeERC20 for IERC20; + using Path for bytes; + + /// @notice Flash swap parameters + struct FlashParams { + /// @notice Amount of tokenY to receive during the flash swap + uint256 amountOut; + /// @notice Exact-output (reversed) swap path, starting with tokenY and ending with tokenX + bytes path; + /// @notice Application-specific data + bytes data; + } + + /// @notice Callback data passed to the swap callback + struct FlashSwapEnvelope { + /// @notice Exact-output (reversed) swap path, starting with tokenY and ending with tokenX + bytes path; + /// @notice Application-specific data + bytes data; + /// @notice Pool key of the pool that should have called the callback + PoolAddress.PoolKey poolKey; + } + + /// @notice Callback data passed to the flash loan callback + struct FlashLoanEnvelope { + /// @notice Token (the same as tokenX and tokenY) + IERC20 token; + /// @notice Amount of tokenY to receive during the flash loan + uint256 amountOut; + /// @notice Application-specific data + bytes data; + /// @notice Pool key of the pool that should have called the callback + PoolAddress.PoolKey poolKey; + } + + /// @notice The PancakeSwap SmartRouter contract + ISmartRouter public immutable SWAP_ROUTER; + + /// @notice The PancakeSwap deployer contract + address public immutable DEPLOYER; + + /// @notice Thrown if swap callback is called by a non-PancakeSwap contract + /// @param expected Expected callback sender (pool address computed based on the pool key) + /// @param actual Actual callback sender + error InvalidCallbackSender(address expected, address actual); + + /// @notice Thrown if the swap callback is called with unexpected or zero amount of tokens + error EmptySwap(); + + /// @notice Thrown if the application tries a flash loan with tokenX != tokenY + error UnexpectedFlashLoan(address tokenX, address tokenY); + + /// @notice Thrown if maxAmountIn threshold is violated while repaying a flash loan + error MaxAmountInViolated(uint256 amountToPay, uint256 maxAmountIn); + + /// @param swapRouter_ PancakeSwap SmartRouter contract + constructor(ISmartRouter swapRouter_) { + ensureNonzeroAddress(address(swapRouter_)); + + SWAP_ROUTER = swapRouter_; + DEPLOYER = swapRouter_.deployer(); + } + + /// @notice Callback called by PancakeSwap pool during flash swap conversion + /// @param amount0Delta Amount of pool's token0 to repay for the flash swap (negative if no need to repay this token) + /// @param amount1Delta Amount of pool's token1 to repay for the flash swap (negative if no need to repay this token) + /// @param data Callback data containing a FlashSwapEnvelope structure + function pancakeV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + FlashSwapEnvelope memory envelope = abi.decode(data, (FlashSwapEnvelope)); + _verifyCallback(envelope.poolKey); + if (amount0Delta <= 0 && amount1Delta <= 0) { + revert EmptySwap(); + } + + uint256 amountToPay; + IERC20 tokenToPay; + if (amount0Delta > 0) { + tokenToPay = IERC20(envelope.poolKey.token0); + amountToPay = uint256(amount0Delta); + } else if (amount1Delta > 0) { + tokenToPay = IERC20(envelope.poolKey.token1); + amountToPay = uint256(amount1Delta); + } + + (IERC20 tokenIn, uint256 maxAmountIn) = _onMoneyReceived(envelope.data); + + if (envelope.path.hasMultiplePools()) { + bytes memory remainingPath = envelope.path.skipToken(); + approveOrRevert(tokenIn, address(SWAP_ROUTER), maxAmountIn); + SWAP_ROUTER.exactOutput( + ISmartRouter.ExactOutputParams({ + path: remainingPath, + recipient: msg.sender, // repaying to the pool + amountOut: amountToPay, + amountInMaximum: maxAmountIn + }) + ); + approveOrRevert(tokenIn, address(SWAP_ROUTER), 0); + } else { + // If the path had just one pool, tokenToPay should be tokenX, so we can just repay the debt. + tokenToPay.safeTransfer(msg.sender, amountToPay); + } + + _onFlashCompleted(envelope.data); + } + + /// @notice Callback called by PancakeSwap pool during in-kind liquidation. Liquidates the + /// borrow, seizing vTokens with the same underlying as the borrowed asset, redeems these + /// vTokens and repays the flash swap. + /// @param fee0 Fee amount in pool's token0 + /// @param fee1 Fee amount in pool's token1 + /// @param data Callback data, passed during _flashLiquidateInKind + function pancakeV3FlashCallback(uint256 fee0, uint256 fee1, bytes memory data) external { + FlashLoanEnvelope memory envelope = abi.decode(data, (FlashLoanEnvelope)); + _verifyCallback(envelope.poolKey); + + (IERC20 tokenIn, uint256 maxAmountIn) = _onMoneyReceived(envelope.data); + + if (tokenIn != envelope.token) { + revert UnexpectedFlashLoan(address(tokenIn), address(envelope.token)); + } + + uint256 fee = (fee0 == 0 ? fee1 : fee0); + uint256 amountToPay = envelope.amountOut + fee; + if (maxAmountIn > amountToPay) { + revert MaxAmountInViolated(amountToPay, maxAmountIn); + } + + envelope.token.safeTransfer(msg.sender, envelope.amountOut + fee); + + _onFlashCompleted(envelope.data); + } + + /// @dev Initiates a flash swap + /// @param params Flash swap parameters + function _flashSwap(FlashParams memory params) internal { + (address tokenY, address tokenB, uint24 fee) = params.path.decodeFirstPool(); + PoolAddress.PoolKey memory poolKey = PoolAddress.getPoolKey(tokenY, tokenB, fee); + IPancakeV3Pool pool = IPancakeV3Pool(PoolAddress.computeAddress(DEPLOYER, poolKey)); + + bool swapZeroForOne = poolKey.token1 == tokenY; + uint160 sqrtPriceLimitX96 = (swapZeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1); + pool.swap( + address(this), + swapZeroForOne, + -int256(params.amountOut), + sqrtPriceLimitX96, + abi.encode(FlashSwapEnvelope(params.path, params.data, poolKey)) + ); + } + + /// @dev Initiates a flash swap + /// @param params Flash loan parameters + function _flashLoan(FlashParams memory params) internal { + (address tokenY, address tokenB, uint24 fee) = params.path.decodeFirstPool(); + PoolAddress.PoolKey memory poolKey = PoolAddress.getPoolKey(tokenY, tokenB, fee); + IPancakeV3Pool pool = IPancakeV3Pool(PoolAddress.computeAddress(DEPLOYER, poolKey)); + pool.flash( + address(this), + poolKey.token0 == tokenY ? params.amountOut : 0, + poolKey.token1 == tokenY ? params.amountOut : 0, + abi.encode(FlashLoanEnvelope(IERC20(tokenY), params.amountOut, params.data, poolKey)) + ); + } + + /// @dev Called when token Y is received during a flash swap or a flash loan. This function + /// has to ensure that at the end of the execution the contract has enough token X to repay + /// the flash swap. + /// Note that msg.sender is the pool that called the callback, not the original caller + /// of the transaction where _flashSwap or _flashLoan was invoked. + /// @param data Application-specific data + /// @return tokenIn Token X + /// @return maxAmountIn Maximum amount of token X to be used to repay the flash swap + function _onMoneyReceived(bytes memory data) internal virtual returns (IERC20 tokenIn, uint256 maxAmountIn); + + /// @dev Called when the flash swap or flash loan is completed and was paid for. By default, + /// does nothing. + /// Note that msg.sender is the pool that called the callback, not the original caller + /// of the transaction where _flashSwap or _flashLoan was invoked. + /// @param data Application-specific data + // solhint-disable-next-line no-empty-blocks + function _onFlashCompleted(bytes memory data) internal virtual {} + + /// @dev Ensures that the caller of a callback is a legitimate PancakeSwap pool + /// @param poolKey The pool key of the pool to verify + function _verifyCallback(PoolAddress.PoolKey memory poolKey) internal view { + address pool = PoolAddress.computeAddress(DEPLOYER, poolKey); + if (msg.sender != pool) { + revert InvalidCallbackSender(pool, msg.sender); + } + } +} diff --git a/contracts/operators/MoveDebtOperator.sol b/contracts/operators/MoveDebtOperator.sol new file mode 100644 index 00000000..3f553b37 --- /dev/null +++ b/contracts/operators/MoveDebtOperator.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.13; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IVBep20 } from "@venusprotocol/venus-protocol/contracts/InterfacesV8.sol"; +import { MoveDebtDelegate } from "@venusprotocol/venus-protocol/contracts/DelegateBorrowers/MoveDebtDelegate.sol"; +import { ensureNonzeroAddress } from "@venusprotocol/solidity-utilities/contracts/validators.sol"; + +import { approveOrRevert } from "../util/approveOrRevert.sol"; +import { transferAll } from "../util/transferAll.sol"; +import { ISmartRouter } from "../third-party/pancakeswap-v8/ISmartRouter.sol"; +import { ExactOutputFlash } from "../flash-swap/ExactOutputFlash.sol"; + +contract MoveDebtOperator is ExactOutputFlash { + using SafeERC20 for IERC20; + + struct MoveDebtParams { + uint256 maxExtraAmount; + address originalSender; + IVBep20 vTokenToRepay; + address[] originalBorrowers; + uint256[] repayAmounts; + uint256 totalRepayAmount; + IVBep20 vTokenToBorrow; + } + + MoveDebtDelegate public immutable DELEGATE; + + constructor(ISmartRouter swapRouter_, MoveDebtDelegate delegate_) ExactOutputFlash(swapRouter_) { + ensureNonzeroAddress(address(delegate_)); + DELEGATE = delegate_; + } + + function moveDebt( + IVBep20 vTokenToRepay, + address originalBorrower, + uint256 repayAmount, + IVBep20 vTokenToBorrow, + uint256 maxExtraAmount, + bytes memory path + ) external { + address[] memory originalBorrowers = new address[](1); + uint256[] memory repayAmounts = new uint256[](1); + originalBorrowers[0] = originalBorrower; + repayAmounts[0] = repayAmount; + _moveDebts(vTokenToRepay, originalBorrowers, repayAmounts, repayAmount, vTokenToBorrow, maxExtraAmount, path); + } + + function moveAllDebts( + IVBep20 vTokenToRepay, + address[] memory originalBorrowers, + IVBep20 vTokenToBorrow, + uint256 maxExtraAmount, + bytes memory path + ) external { + uint256 borrowersCount = originalBorrowers.length; + + uint256[] memory repayAmounts = new uint256[](borrowersCount); + uint256 totalRepayAmount = 0; + for (uint256 i = 0; i < borrowersCount; ++i) { + uint256 amount = vTokenToRepay.borrowBalanceCurrent(originalBorrowers[i]); + repayAmounts[i] = amount; + totalRepayAmount += amount; + } + _moveDebts( + vTokenToRepay, + originalBorrowers, + repayAmounts, + totalRepayAmount, + vTokenToBorrow, + maxExtraAmount, + path + ); + } + + function _moveDebts( + IVBep20 vTokenToRepay, + address[] memory originalBorrowers, + uint256[] memory repayAmounts, + uint256 totalRepayAmount, + IVBep20 vTokenToBorrow, + uint256 maxExtraAmount, + bytes memory path + ) internal { + MoveDebtParams memory params = MoveDebtParams({ + maxExtraAmount: maxExtraAmount, + originalSender: msg.sender, + vTokenToRepay: vTokenToRepay, + originalBorrowers: originalBorrowers, + repayAmounts: repayAmounts, + totalRepayAmount: totalRepayAmount, + vTokenToBorrow: vTokenToBorrow + }); + bytes memory data = abi.encode(params); + FlashParams memory flashParams = FlashParams({ amountOut: totalRepayAmount, path: path, data: data }); + if (_underlying(vTokenToRepay) == _underlying(vTokenToBorrow)) { + _flashLoan(flashParams); + } else { + _flashSwap(flashParams); + } + } + + function _onMoneyReceived(bytes memory data) internal override returns (IERC20 tokenIn, uint256 maxAmountIn) { + MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); + IERC20 repayToken = _underlying(params.vTokenToRepay); + IERC20 borrowToken = _underlying(params.vTokenToBorrow); + + uint256 balanceBefore = borrowToken.balanceOf(address(this)); + + approveOrRevert(repayToken, address(DELEGATE), params.totalRepayAmount); + uint256 borrowersCount = params.originalBorrowers.length; + for (uint256 i = 0; i < borrowersCount; ++i) { + DELEGATE.moveDebt( + params.vTokenToRepay, + params.originalBorrowers[i], + params.repayAmounts[i], + params.vTokenToBorrow + ); + } + approveOrRevert(repayToken, address(DELEGATE), 0); + + if (params.maxExtraAmount > 0) { + borrowToken.safeTransferFrom(params.originalSender, address(this), params.maxExtraAmount); + } + + uint256 balanceAfter = borrowToken.balanceOf(address(this)); + return (borrowToken, balanceAfter - balanceBefore); + } + + function _onFlashCompleted(bytes memory data) internal override { + MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); + + transferAll(_underlying(params.vTokenToBorrow), address(this), params.originalSender); + transferAll(_underlying(params.vTokenToRepay), address(this), params.originalSender); + } + + function _underlying(IVBep20 vToken) internal view returns (IERC20) { + return IERC20(vToken.underlying()); + } +} diff --git a/contracts/third-party/pancakeswap-v8/BytesLib.sol b/contracts/third-party/pancakeswap-v8/BytesLib.sol new file mode 100644 index 00000000..899e20dd --- /dev/null +++ b/contracts/third-party/pancakeswap-v8/BytesLib.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * @title Solidity Bytes Arrays Utils + * @author Gonçalo Sá + * + * @dev Bytes tightly packed arrays utility library for ethereum contracts written in Solidity. + * The library lets you concatenate, slice and type cast bytes arrays both in memory and storage. + */ +pragma solidity >=0.8.0 <0.9.0; + +library BytesLib { + function slice(bytes memory _bytes, uint256 _start, uint256 _length) internal pure returns (bytes memory) { + require(_length + 31 >= _length, "slice_overflow"); + require(_bytes.length >= _start + _length, "slice_outOfBounds"); + + bytes memory tempBytes; + + assembly { + switch iszero(_length) + case 0 { + // Get a location of some free memory and store it in tempBytes as + // Solidity does for memory variables. + tempBytes := mload(0x40) + + // The first word of the slice result is potentially a partial + // word read from the original array. To read it, we calculate + // the length of that partial word and start copying that many + // bytes into the array. The first word we copy will start with + // data we don't care about, but the last `lengthmod` bytes will + // land at the beginning of the contents of the new array. When + // we're done copying, we overwrite the full first word with + // the actual length of the slice. + let lengthmod := and(_length, 31) + + // The multiplication in the next line is necessary + // because when slicing multiples of 32 bytes (lengthmod == 0) + // the following copy loop was copying the origin's length + // and then ending prematurely not copying everything it should. + let mc := add(add(tempBytes, lengthmod), mul(0x20, iszero(lengthmod))) + let end := add(mc, _length) + + for { + // The multiplication in the next line has the same exact purpose + // as the one above. + let cc := add(add(add(_bytes, lengthmod), mul(0x20, iszero(lengthmod))), _start) + } lt(mc, end) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { + mstore(mc, mload(cc)) + } + + mstore(tempBytes, _length) + + //update free-memory pointer + //allocating the array padded to 32 bytes like the compiler does now + mstore(0x40, and(add(mc, 31), not(31))) + } + //if we want a zero-length slice let's just return a zero-length array + default { + tempBytes := mload(0x40) + //zero out the 32 bytes slice we are about to return + //we need to do it because Solidity does not garbage collect + mstore(tempBytes, 0) + + mstore(0x40, add(tempBytes, 0x20)) + } + } + + return tempBytes; + } + + function toAddress(bytes memory _bytes, uint256 _start) internal pure returns (address) { + require(_bytes.length >= _start + 20, "toAddress_outOfBounds"); + address tempAddress; + + assembly { + tempAddress := div(mload(add(add(_bytes, 0x20), _start)), 0x1000000000000000000000000) + } + + return tempAddress; + } + + function toUint24(bytes memory _bytes, uint256 _start) internal pure returns (uint24) { + require(_start + 3 >= _start, "toUint24_overflow"); + require(_bytes.length >= _start + 3, "toUint24_outOfBounds"); + uint24 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x3), _start)) + } + + return tempUint; + } +} diff --git a/contracts/third-party/pancakeswap-v8/ISmartRouter.sol b/contracts/third-party/pancakeswap-v8/ISmartRouter.sol new file mode 100644 index 00000000..a41ef89a --- /dev/null +++ b/contracts/third-party/pancakeswap-v8/ISmartRouter.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.0; + +interface ISmartRouter { + struct ExactInputParams { + bytes path; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + } + + struct ExactOutputParams { + bytes path; + address recipient; + uint256 amountOut; + uint256 amountInMaximum; + } + + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); + + function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn); + + function deployer() external view returns (address); + + function WETH9() external view returns (address); +} diff --git a/contracts/third-party/pancakeswap-v8/Path.sol b/contracts/third-party/pancakeswap-v8/Path.sol new file mode 100644 index 00000000..5e58e8f4 --- /dev/null +++ b/contracts/third-party/pancakeswap-v8/Path.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.6.0; + +import "./BytesLib.sol"; + +/// @title Functions for manipulating path data for multihop swaps +library Path { + using BytesLib for bytes; + + /// @dev The length of the bytes encoded address + uint256 private constant ADDR_SIZE = 20; + /// @dev The length of the bytes encoded fee + uint256 private constant FEE_SIZE = 3; + + /// @dev The offset of a single token address and pool fee + uint256 private constant NEXT_OFFSET = ADDR_SIZE + FEE_SIZE; + /// @dev The offset of an encoded pool key + uint256 private constant POP_OFFSET = NEXT_OFFSET + ADDR_SIZE; + /// @dev The minimum length of an encoding that contains 2 or more pools + uint256 private constant MULTIPLE_POOLS_MIN_LENGTH = POP_OFFSET + NEXT_OFFSET; + + /// @notice Returns true iff the path contains two or more pools + /// @param path The encoded swap path + /// @return True if path contains two or more pools, otherwise false + function hasMultiplePools(bytes memory path) internal pure returns (bool) { + return path.length >= MULTIPLE_POOLS_MIN_LENGTH; + } + + /// @notice Returns the number of pools in the path + /// @param path The encoded swap path + /// @return The number of pools in the path + function numPools(bytes memory path) internal pure returns (uint256) { + // Ignore the first token address. From then on every fee and token offset indicates a pool. + return ((path.length - ADDR_SIZE) / NEXT_OFFSET); + } + + /// @notice Decodes the first pool in path + /// @param path The bytes encoded swap path + /// @return tokenA The first token of the given pool + /// @return tokenB The second token of the given pool + /// @return fee The fee level of the pool + function decodeFirstPool(bytes memory path) internal pure returns (address tokenA, address tokenB, uint24 fee) { + tokenA = path.toAddress(0); + fee = path.toUint24(ADDR_SIZE); + tokenB = path.toAddress(NEXT_OFFSET); + } + + /// @notice Gets the segment corresponding to the first pool in the path + /// @param path The bytes encoded swap path + /// @return The segment containing all data necessary to target the first pool in the path + function getFirstPool(bytes memory path) internal pure returns (bytes memory) { + return path.slice(0, POP_OFFSET); + } + + /// @notice Skips a token + fee element from the buffer and returns the remainder + /// @param path The swap path + /// @return The remaining token + fee elements in the path + function skipToken(bytes memory path) internal pure returns (bytes memory) { + return path.slice(NEXT_OFFSET, path.length - NEXT_OFFSET); + } +} diff --git a/contracts/third-party/pancakeswap-v8/PoolAddress.sol b/contracts/third-party/pancakeswap-v8/PoolAddress.sol new file mode 100644 index 00000000..16097f98 --- /dev/null +++ b/contracts/third-party/pancakeswap-v8/PoolAddress.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Provides functions for deriving a pool address from the factory, tokens, and the fee +library PoolAddress { + // The following line was modified by Venus so that the init hash corresponds to the one of PancakeSwap + bytes32 internal constant POOL_INIT_CODE_HASH = 0x6ce8eb472fa82df5469c6ab6d485f17c3ad13c8cd7af59b3d4a8026c5ce0f7e2; + + /// @notice The identifying key of the pool + struct PoolKey { + address token0; + address token1; + uint24 fee; + } + + /// @notice Returns PoolKey: the ordered tokens with the matched fee levels + /// @param tokenA The first token of a pool, unsorted + /// @param tokenB The second token of a pool, unsorted + /// @param fee The fee level of the pool + /// @return Poolkey The pool details with ordered token0 and token1 assignments + function getPoolKey(address tokenA, address tokenB, uint24 fee) internal pure returns (PoolKey memory) { + if (tokenA > tokenB) (tokenA, tokenB) = (tokenB, tokenA); + return PoolKey({ token0: tokenA, token1: tokenB, fee: fee }); + } + + /// @notice Deterministically computes the pool address given the factory and PoolKey + /// @param factory The Uniswap V3 factory contract address + /// @param key The PoolKey + /// @return pool The contract address of the V3 pool + function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) { + require(key.token0 < key.token1); + pool = address( + uint160( + uint256( + keccak256( + abi.encodePacked( + hex"ff", + factory, + keccak256(abi.encode(key.token0, key.token1, key.fee)), + POOL_INIT_CODE_HASH + ) + ) + ) + ) + ); + } +} diff --git a/contracts/third-party/pancakeswap-v8/constants.sol b/contracts/third-party/pancakeswap-v8/constants.sol new file mode 100644 index 00000000..c0980958 --- /dev/null +++ b/contracts/third-party/pancakeswap-v8/constants.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity >=0.5.0; + +/// @dev The minimum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MIN_TICK) +uint160 constant MIN_SQRT_RATIO = 4295128739; +/// @dev The maximum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MAX_TICK) +uint160 constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; diff --git a/contracts/util/approveOrRevert.sol b/contracts/util/approveOrRevert.sol new file mode 100644 index 00000000..02963a23 --- /dev/null +++ b/contracts/util/approveOrRevert.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.13; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Thrown if a contract is unable to approve a transfer +error ApproveFailed(); + +/// @notice Approves a transfer, ensuring that it is successful. This function supports non-compliant +/// tokens like the ones that don't return a boolean value on success. Thus, such approve call supports +/// three different kinds of tokens: +/// * Compliant tokens that revert on failure +/// * Compliant tokens that return false on failure +/// * Non-compliant tokens that don't return a value +/// @param token The contract address of the token which will be transferred +/// @param spender The spender contract address +/// @param amount The value of the transfer +function approveOrRevert(IERC20 token, address spender, uint256 amount) { + bytes memory callData = abi.encodeCall(token.approve, (spender, amount)); + + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory result) = address(token).call(callData); + + if (!success || (result.length != 0 && !abi.decode(result, (bool)))) { + revert ApproveFailed(); + } +} diff --git a/contracts/util/transferAll.sol b/contracts/util/transferAll.sol new file mode 100644 index 00000000..29a87b90 --- /dev/null +++ b/contracts/util/transferAll.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.13; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +using SafeERC20 for IERC20; + +function transferAll(IERC20 token, address this_, address to) { + uint256 balance = token.balanceOf(this_); + if (balance > 0) { + token.safeTransfer(to, balance); + } +} diff --git a/package.json b/package.json index e5aed9b3..736114cb 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,6 @@ "dependencies": { "@openzeppelin/contracts-upgradeable": "4.9.3", "@venusprotocol/protocol-reserve": "1.0.0-converters.1", - "@venusprotocol/venus-protocol": "6.1.0-dev.8" + "@venusprotocol/venus-protocol": "7.0.0-move-debt.1" } } diff --git a/tests/fork/MoveDebtOperator.ts b/tests/fork/MoveDebtOperator.ts new file mode 100644 index 00000000..24a6fcd3 --- /dev/null +++ b/tests/fork/MoveDebtOperator.ts @@ -0,0 +1,214 @@ +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; +import { formatUnits, parseEther, parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; +import { SignerWithAddress } from "hardhat-deploy-ethers/signers"; + +import { + IERC20, + IERC20__factory, + IVBep20, + MoveDebtDelegate, + MoveDebtOperator, + MoveDebtOperator__factory, +} from "../../typechain"; +import { forking, initMainnetUser } from "./utils"; + +interface MoveDebtOperatorFixture { + moveDebtOperator: MoveDebtOperator; + usdt: IERC20; + btc: IERC20; +} + +forking({ bscmainnet: 34841800 } as const, addresses => { + const SHORTFALL_BORROWER = "0xEF044206Db68E40520BfA82D45419d498b4bc7Bf"; + const MOVE_DEBT_DELEGATE = "0x89621C48EeC04A85AfadFD37d32077e65aFe2226"; + + const executeVip225 = async () => { + const timelock = await initMainnetUser(addresses.NormalTimelock, parseEther("1")); + const proxyAdminAbi = ["function upgrade(address proxy, address newImplementation) external"]; + + const proxyAdmin = await ethers.getContractAt(proxyAdminAbi, "0x1BB765b741A5f3C2A338369DAb539385534E3343"); + const moveDebtDelegate = await ethers.getContractAt("MoveDebtDelegate", MOVE_DEBT_DELEGATE); + + await proxyAdmin.connect(timelock).upgrade(MOVE_DEBT_DELEGATE, "0x8439932C45e646FcC1009690417A65BF48f68Ce7"); + await moveDebtDelegate.connect(timelock).setBorrowAllowed(addresses.vBTC, true); + await moveDebtDelegate.connect(timelock).setRepaymentAllowed(addresses.vBTC, SHORTFALL_BORROWER, true); + }; + + const moveDebtOperatorFixture = async (): Promise => { + const USDT_HOLDER = "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3"; + const BTC_HOLDER = "0xF977814e90dA44bFA03b6295A0616a897441aceC"; + const [admin] = await ethers.getSigners(); + + await executeVip225(); + + const usdt = IERC20__factory.connect(addresses.USDT, admin); + const btc = IERC20__factory.connect(addresses.BTCB, admin); + + const usdtHolder = await initMainnetUser(USDT_HOLDER, parseEther("1")); + await usdt.connect(usdtHolder).transfer(admin.address, parseUnits("1000", 18)); + + const btcHolder = await initMainnetUser(BTC_HOLDER, parseEther("1")); + await btc.connect(btcHolder).transfer(admin.address, parseUnits("100", 18)); + + const moveDebtOperatorFactory = await ethers.getContractFactory("MoveDebtOperator"); + const moveDebtOperator = await moveDebtOperatorFactory.deploy( + addresses.PancakeSwapRouter, + addresses.MoveDebtDelegate, + ); + return { moveDebtOperator, usdt, btc }; + }; + + describe("MoveDebtOperator", () => { + const BNB_EXPLOITER = "0x489A8756C18C0b8B24EC2a2b9FF3D4d447F79BEc"; + + let admin: SignerWithAddress; + let btc: IERC20; + let usdt: IERC20; + let vBTC: IVBep20; + let moveDebtOperator: MoveDebtOperator; + let moveDebtDelegate: MoveDebtDelegate; + + beforeEach(async () => { + [admin] = await ethers.getSigners(); + ({ moveDebtOperator, btc, usdt } = await loadFixture(moveDebtOperatorFixture)); + moveDebtDelegate = await ethers.getContractAt("MoveDebtDelegate", addresses.MoveDebtDelegate); + vBTC = await ethers.getContractAt("IVBep20", addresses.vBTC); + }); + + it("should work with a single-hop path from BTC to USDT", async () => { + const path = ethers.utils.hexlify(ethers.utils.concat([addresses.BTCB, "0x0001f4", addresses.USDT])); + await usdt.connect(admin).approve(moveDebtOperator.address, parseUnits("1000", 18)); + const repayAmount = parseUnits("0.001", 18); + const maxUsdtToSpend = parseUnits("1000", 18); + const tx = await moveDebtOperator + .connect(admin) + .moveDebt(addresses.vBTC, SHORTFALL_BORROWER, repayAmount, addresses.vUSDT, maxUsdtToSpend, path); + await expect(tx) + .to.emit(moveDebtDelegate, "DebtMoved") + .withArgs( + SHORTFALL_BORROWER, + addresses.vBTC, + repayAmount, + BNB_EXPLOITER, + addresses.vUSDT, + parseUnits("42.582032813125250100", 18), + ); + await expect(tx).to.emit(btc, "Transfer").withArgs(moveDebtDelegate.address, addresses.vBTC, repayAmount); + }); + + it("should work with in-kind debt moves with flash-loan", async () => { + const path = ethers.utils.hexlify(ethers.utils.concat([addresses.BTCB, "0x0001f4", addresses.USDT])); + await btc.connect(admin).approve(moveDebtOperator.address, parseUnits("0.1", 18)); + const repayAmount = parseUnits("1", 18); + const maxBtcToSpend = parseUnits("0.1", 18); + const tx = await moveDebtOperator + .connect(admin) + .moveDebt(addresses.vBTC, SHORTFALL_BORROWER, repayAmount, addresses.vBTC, maxBtcToSpend, path); + await expect(tx) + .to.emit(moveDebtDelegate, "DebtMoved") + .withArgs( + SHORTFALL_BORROWER, + addresses.vBTC, + repayAmount, + BNB_EXPLOITER, + addresses.vBTC, + parseUnits("1.0", 18), + ); + await expect(tx).to.emit(btc, "Transfer").withArgs(moveDebtDelegate.address, addresses.vBTC, repayAmount); + }); + + // + // TODO: Make these compatible with VIP-225 version of MoveDebtDelegate + // + /*it("should repay all debts of a list of borrowers", async () => { + const path = ethers.utils.hexlify(ethers.utils.concat([addresses.BUSD, "0x000064", addresses.USDT])); + const borrowers = [ + "0x6b69d62616633d71d36cf253021ae717db2e09c7", + "0xc870b1b5a7787ef5877190fe10851fdceb402d47", + "0xca925c8900f6f27e89b9bebc4987a655cb43911f", + "0xdc9af4213dee2321c0461a59e761f76e4677fdb9", + "0xdf99f63bc8f1ce2fd7aa0c0140a0340f91fb4680", + "0xac0c96c50bb4080bcb920a7c84dc23f0decddbd6", + "0xd3a5eb04d919d17b846d81b9875c652720b7be97", + "0xd53f6bae74603aaf74145e8150d11dbdb6fc805d", + "0xc4050213225baf4e2e9fb53f877caa727f05faf5", + "0x60f2563424db41fbfe308f46034f155f1c1e5d29", + "0x1e803cf10460bcd7235a87527105d1e2a3c6319b", + "0xf51c2e6a7e838923f30367b3d2f6d22db85a83b5", + "0x37e1e4b215c2a1d026444899b90f0bf29ef12576", + "0x3b052f0a3cfe703b63ac6fe688bbf16a67ecf738", + "0xcbc090c20d32b4415edacfc70665d4101cd940f6", + "0xccb5c516c5c3dddf1e46db9b3c399b0f5e542251", + "0xe9caddbc9f620098171c67385f5237b829a55db6", + "0x981e41c791f9cf5b5b0470df0637b95a5b4e00a2", + "0x8915cf710645794dad5eaee96b3e56675bf62651", + "0x0794e8235fbdfaf2061a6721ade61126d8493b35", + "0x7d6936e3dbeb9ff00e9d77ecc40eb59bf96c82b3", + "0xe7404e8c8c5607aea3ddfc6820c143bd21e750a1", + "0x82ec5d22cff969aa01afa7d082891b2cf5714769", + "0xabb3ed61b1928dc0a57839d6c8a38446762bdbfe", + "0x0e8c66bbfec32aa3fab86bba641f4f20fe457ca9", + "0xc7d785ffb0b5f92beb21382d44539048a5b5df62", + "0xd4e982b86590428588934fbd5510a61342ef7214", + "0x7261c1a6a47e596dfcf0e2f21140217acc5eded8", + "0xe33fca60a281431d82ac9ad55e37be9b587ead63", + "0xb75a7d3049654242617015dcedf2c49e1ca7ea65", + "0x88511937ddb0c65cf2b925200e185f590a43a267", + "0xdf38cd49bb821a3984277a252b4ecdda4ab7631c", + "0x614146018042d47dcde01a9400a8d14343047b67", + "0xd071f60ea1e2d4855cf34a6022372c33d046e34b", + "0x6dc3353dc6ba9d0cdc97af9d7eb5a4475ded4a3f", + "0x53d4bc5ccb0fa8ce8b853eb90086df93d1217026", + "0x58ceb7d9abbe3039e4aec356446f7c59aadae376", + "0x9edb330cac62e21f616d113754c7f7451179b5b2", + "0x22d1eca04a0ab99ebb57b9feeed45dee3df4e444", + "0x1956b3aae9c3e583584143d566612e7e721de141", + "0x0db95671310f0ba4cc0f29106593f8090037027a", + "0x00e1dfbb710d0d854f0d6736170b874b3690d0d7", + "0x4d390a71b61bdbee803960a00961de839d7c09d0", + "0xa139f8b283199b61efbe9c934c3a74656ed82618", + "0x8764c54e16304a26cd9356635431ce9a709d634c", + "0x6bc65848bfa839005c65d4d49989fcca9925f4ce", + "0x19132c5d648e0706bda8afa9e8ef3c57e86f50a9", + "0xe2a6d1c95672211a7d86fe394cc7eb1969e6258d", + "0x604c235ec0ab0b303090449a70550bab044e6be3", + "0xce1f6f19faec15e0806c8ad6378a3e9a7f74994e", + ].map(ethers.utils.getAddress); + await usdt.connect(admin).approve(moveDebtOperator.address, parseUnits("100", 18)); + const maxUsdtToSpend = parseUnits("30", 18); + const tx = await moveDebtOperator.connect(admin).moveAllDebts(borrowers, addresses.vUSDT, maxUsdtToSpend, path); + for (const borrower of borrowers) { + await expect(tx) + .to.emit(moveDebtDelegate, "DebtMoved") + .withArgs(borrower, addresses.vBUSD, anyValue, BNB_EXPLOITER, addresses.vUSDT, anyValue); + expect(await vBUSD.callStatic.borrowBalanceCurrent(borrower)).to.equal(0); + } + }); + + it("should work with a multi-hop path from BUSD to USDT", async () => { + const path = ethers.utils.hexlify( + ethers.utils.concat([addresses.BUSD, "0x0001f4", addresses.WBNB, "0x0001f4", addresses.USDT]), + ); + await usdt.connect(admin).approve(moveDebtOperator.address, parseUnits("300", 18)); + const repayAmount = parseUnits("30000", 18); + const maxUsdtToSpend = parseUnits("300", 18); + const tx = await moveDebtOperator + .connect(admin) + .moveDebt(BUSD_BORROWER, repayAmount, addresses.vUSDT, maxUsdtToSpend, path); + await expect(tx) + .to.emit(moveDebtDelegate, "DebtMoved") + .withArgs( + BUSD_BORROWER, + addresses.vBUSD, + repayAmount, + BNB_EXPLOITER, + addresses.vUSDT, + parseUnits("30020.438638269452250292", 18), + ); + await expect(tx).to.emit(busd, "Transfer").withArgs(moveDebtDelegate.address, addresses.vBUSD, repayAmount); + });*/ + }); +}); diff --git a/tests/fork/utils/constants.ts b/tests/fork/utils/constants.ts new file mode 100644 index 00000000..dac7c913 --- /dev/null +++ b/tests/fork/utils/constants.ts @@ -0,0 +1,18 @@ +export const ADDRESSES = { + bscmainnet: { + BTCB: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", + BUSD: "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", + USDT: "0x55d398326f99059fF775485246999027B3197955", + USDC: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + WBNB: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + PancakeSwapRouter: "0x13f4EA83D0bd40E75C8222255bc855a974568Dd4", + MoveDebtDelegate: "0x89621C48EeC04A85AfadFD37d32077e65aFe2226", + Unitroller: "0xfD36E2c2a6789Db23113685031d7F16329158384", + NormalTimelock: "0x939bD8d64c0A9583A7Dcea9933f7b21697ab6396", + vBTC: "0x882C173bC7Ff3b7786CA16dfeD3DFFfb9Ee7847B", + vUSDT: "0xfD5840Cd36d94D7229439859C0112a4185BC0255", + vBUSD: "0x95c78222B3D6e262426483D42CfA53685A67Ab9D", + vUSDC: "0xecA88125a5ADbe82614ffC12D0DB554E2e2867C8", + }, + bsctestnet: {}, +} as const; diff --git a/tests/fork/utils/index.ts b/tests/fork/utils/index.ts new file mode 100644 index 00000000..983b4891 --- /dev/null +++ b/tests/fork/utils/index.ts @@ -0,0 +1,47 @@ +import { impersonateAccount, setBalance } from "@nomicfoundation/hardhat-network-helpers"; +import { NumberLike } from "@nomicfoundation/hardhat-network-helpers/dist/src/types"; +import { ethers } from "hardhat"; +import hre from "hardhat"; + +import { ADDRESSES } from "./constants"; + +export type Network = keyof typeof ADDRESSES; +export type ForkConfig = { [T in N]: number }; +export type Addresses = typeof ADDRESSES[N]; + +export const resetFork = async (network: Network, blockNumber: number) => { + await hre.network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: process.env[`ARCHIVE_NODE_${network}`], + blockNumber, + }, + }, + ], + }); +}; + +export const forking = (config: ForkConfig, fn: (addresses: Addresses) => void) => { + if (!process.env.FORK || process.env.FORK === "false") { + return; + } + const config_ = Object.entries(config) as [N, number][]; + config_.forEach(([network, blockNumber]) => { + describe(`Forking ${network} at block #${blockNumber}`, () => { + before(async () => { + await resetFork(network, blockNumber); + }); + fn(ADDRESSES[network]); + }); + }); +}; + +export const initMainnetUser = async (user: string, balance?: NumberLike) => { + await impersonateAccount(user); + if (balance !== undefined) { + await setBalance(user, balance); + } + return ethers.getSigner(user); +}; diff --git a/yarn.lock b/yarn.lock index 071f977d..fc44f354 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3125,7 +3125,7 @@ __metadata: "@venusprotocol/oracle": ^1.7.3 "@venusprotocol/protocol-reserve": 1.0.0-converters.1 "@venusprotocol/solidity-utilities": ^1.1.0 - "@venusprotocol/venus-protocol": 6.1.0-dev.8 + "@venusprotocol/venus-protocol": 7.0.0-move-debt.1 chai: ^4.3.10 dotenv: ^16.3.1 eslint: ^7.32.0 @@ -3214,6 +3214,13 @@ __metadata: languageName: node linkType: hard +"@venusprotocol/solidity-utilities@npm:^1.2.0-dev.1": + version: 1.2.0 + resolution: "@venusprotocol/solidity-utilities@npm:1.2.0" + checksum: b3d17a6747330da2e24a27dc0741e26a5def95307136abbcba5231fa7d9104d47458e4f0e436846aa83f6f968f67251fb6e0a8f71f4257b4151dffb6cf19810a + languageName: node + linkType: hard + "@venusprotocol/venus-protocol@npm:0.7.0": version: 0.7.0 resolution: "@venusprotocol/venus-protocol@npm:0.7.0" @@ -3226,19 +3233,19 @@ __metadata: languageName: node linkType: hard -"@venusprotocol/venus-protocol@npm:6.1.0-dev.8": - version: 6.1.0-dev.8 - resolution: "@venusprotocol/venus-protocol@npm:6.1.0-dev.8" +"@venusprotocol/venus-protocol@npm:7.0.0-move-debt.1": + version: 7.0.0-move-debt.1 + resolution: "@venusprotocol/venus-protocol@npm:7.0.0-move-debt.1" dependencies: "@openzeppelin/contracts": 4.9.3 "@openzeppelin/contracts-upgradeable": ^4.8.0 "@venusprotocol/governance-contracts": ^1.4.0-dev.2 "@venusprotocol/protocol-reserve": 1.2.0-dev.2 - "@venusprotocol/solidity-utilities": ^1.1.0 + "@venusprotocol/solidity-utilities": ^1.2.0-dev.1 bignumber.js: ^9.1.2 dotenv: ^16.0.1 module-alias: ^2.2.2 - checksum: 61e9d61128ea36aed3781d7373ec874233e9c2b2267ca1774f39139fb6a7c0844836be447084b36404fead74dc1efcae71330c081a4cca7563a061a73cb803c2 + checksum: 913235664d8fa5d511b8e38d639e5203d5ac3deb205fb80e4c314cde6f3a4e70b1f0a9f33f9e059cf7bdbcf7aff5040caa72d4ecbc204c81e65df92dbdd12f58 languageName: node linkType: hard