From eab5313f9b1ede3a64b06e5669e49324c0429abd Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Thu, 14 Dec 2023 15:10:18 +0300 Subject: [PATCH 01/11] feat: add a contract to operate MoveDebtDelegate --- contracts/flash-swap/ExactOutputFlashSwap.sol | 163 ++++++++++++++++++ contracts/operators/MoveDebtOperator.sol | 91 ++++++++++ .../third-party/pancakeswap-v8/BytesLib.sol | 95 ++++++++++ .../pancakeswap-v8/ISmartRouter.sol | 26 +++ contracts/third-party/pancakeswap-v8/Path.sol | 61 +++++++ .../pancakeswap-v8/PoolAddress.sol | 47 +++++ .../third-party/pancakeswap-v8/constants.sol | 8 + contracts/util/approveOrRevert.sol | 28 +++ 8 files changed, 519 insertions(+) create mode 100644 contracts/flash-swap/ExactOutputFlashSwap.sol create mode 100644 contracts/operators/MoveDebtOperator.sol create mode 100644 contracts/third-party/pancakeswap-v8/BytesLib.sol create mode 100644 contracts/third-party/pancakeswap-v8/ISmartRouter.sol create mode 100644 contracts/third-party/pancakeswap-v8/Path.sol create mode 100644 contracts/third-party/pancakeswap-v8/PoolAddress.sol create mode 100644 contracts/third-party/pancakeswap-v8/constants.sol create mode 100644 contracts/util/approveOrRevert.sol diff --git a/contracts/flash-swap/ExactOutputFlashSwap.sol b/contracts/flash-swap/ExactOutputFlashSwap.sol new file mode 100644 index 00000000..8e75da32 --- /dev/null +++ b/contracts/flash-swap/ExactOutputFlashSwap.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.13; + +import { IPancakeV3SwapCallback } from "@pancakeswap/v3-core/contracts/interfaces/callback/IPancakeV3SwapCallback.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 ExactOutputFlashSwap +/// @notice A base contract for exact output flash swap operations. +/// +/// Upon calling _flashSwap, swaps tokenX to tokenY using a flash swap, 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 _onFlashSwapCompleted +/// +/// @dev This contract is abstract and should be inherited by a contract that implements +/// _onMoneyReceived and _onFlashSwapCompleted. 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 ExactOutputFlashSwap is IPancakeV3SwapCallback { + using SafeERC20 for IERC20; + using Path for bytes; + + /// @notice Flash swap parameters + struct FlashSwapParams { + /// @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 Envelope { + /// @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 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(); + + /// @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 an Envelope structure + function pancakeV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + Envelope memory envelope = abi.decode(data, (Envelope)); + _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); + } + + uint256 maxAmountIn = _onMoneyReceived(envelope.data); + + if (envelope.path.hasMultiplePools()) { + bytes memory remainingPath = envelope.path.skipToken(); + approveOrRevert(tokenToPay, address(SWAP_ROUTER), maxAmountIn); + SWAP_ROUTER.exactOutput( + ISmartRouter.ExactOutputParams({ + path: remainingPath, + recipient: msg.sender, // repaying to the pool + amountOut: amountToPay, + amountInMaximum: maxAmountIn + }) + ); + approveOrRevert(tokenToPay, 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); + } + + _onFlashSwapCompleted(envelope.data); + } + + /// @dev Initiates a flash swap + /// @param params Flash swap parameters + function _flashSwap(FlashSwapParams 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(Envelope(params.path, params.data, poolKey)) + ); + } + + /// @dev Called when token Y is received during a flash swap. 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 was invoked. + /// @param data Application-specific data + /// @return maxAmountIn Maximum amount of token X to be used to repay the flash swap + function _onMoneyReceived(bytes memory data) internal virtual returns (uint256 maxAmountIn); + + /// @dev Called when the flash swap 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 was invoked. + /// @param data Application-specific data + function _onFlashSwapCompleted(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..1ee103a3 --- /dev/null +++ b/contracts/operators/MoveDebtOperator.sol @@ -0,0 +1,91 @@ +// 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 { ISmartRouter } from "../third-party/pancakeswap-v8/ISmartRouter.sol"; +import { ExactOutputFlashSwap } from "../flash-swap/ExactOutputFlashSwap.sol"; + +contract MoveDebtOperator is ExactOutputFlashSwap { + using SafeERC20 for IERC20; + + struct MoveDebtParams { + uint256 maxExtraAmount; + address originalSender; + address originalBorrower; + uint256 repayAmount; + IVBep20 vTokenToBorrow; + } + + MoveDebtDelegate public immutable DELEGATE; + + constructor(ISmartRouter swapRouter_, MoveDebtDelegate delegate_) ExactOutputFlashSwap(swapRouter_) { + ensureNonzeroAddress(address(delegate_)); + DELEGATE = delegate_; + } + + function moveDebt( + address originalBorrower, + uint256 repayAmount, + IVBep20 vTokenToBorrow, + uint256 maxExtraAmount, + bytes memory path + ) external { + MoveDebtParams memory params = MoveDebtParams({ + maxExtraAmount: maxExtraAmount, + originalSender: msg.sender, + originalBorrower: originalBorrower, + repayAmount: repayAmount, + vTokenToBorrow: vTokenToBorrow + }); + + bytes memory data = abi.encode(params); + _flashSwap(FlashSwapParams({ amountOut: repayAmount, path: path, data: data })); + } + + function _onMoneyReceived(bytes memory data) internal override returns (uint256 maxAmountIn) { + MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); + IERC20 repayToken = _repayToken(); + IERC20 borrowToken = _borrowToken(params); + + uint256 balanceBefore = borrowToken.balanceOf(address(this)); + + approveOrRevert(repayToken, address(DELEGATE), params.repayAmount); + DELEGATE.moveDebt(params.originalBorrower, params.repayAmount, 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 balanceAfter - balanceBefore; + } + + function _onFlashSwapCompleted(bytes memory data) internal override { + MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); + + _transferAll(_borrowToken(params), params.originalSender); + _transferAll(_repayToken(), params.originalSender); + } + + function _transferAll(IERC20 token, address to) internal { + uint256 balance = token.balanceOf(address(this)); + if (balance > 0) { + token.safeTransfer(to, balance); + } + } + + function _repayToken() internal view returns (IERC20) { + return IERC20(DELEGATE.vTokenToRepay().underlying()); + } + + function _borrowToken(MoveDebtParams memory params) internal view returns (IERC20) { + return IERC20(params.vTokenToBorrow.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(); + } +} From cd842ff6754d944e68c27bdeb20136c5b3ad3c02 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Thu, 14 Dec 2023 15:11:23 +0300 Subject: [PATCH 02/11] test: add tentative fork testing utils --- tests/fork/utils/constants.ts | 13 ++++++++++ tests/fork/utils/index.ts | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/fork/utils/constants.ts create mode 100644 tests/fork/utils/index.ts diff --git a/tests/fork/utils/constants.ts b/tests/fork/utils/constants.ts new file mode 100644 index 00000000..eb971402 --- /dev/null +++ b/tests/fork/utils/constants.ts @@ -0,0 +1,13 @@ +export const ADDRESSES = { + bscmainnet: { + BUSD: "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", + USDT: "0x55d398326f99059fF775485246999027B3197955", + PancakeSwapRouter: "0x13f4EA83D0bd40E75C8222255bc855a974568Dd4", + MoveDebtDelegate: "0x89621C48EeC04A85AfadFD37d32077e65aFe2226", + Unitroller: "0xfD36E2c2a6789Db23113685031d7F16329158384", + NormalTimelock: "0x939bD8d64c0A9583A7Dcea9933f7b21697ab6396", + vUSDT: "0xfD5840Cd36d94D7229439859C0112a4185BC0255", + vBUSD: "0x95c78222B3D6e262426483D42CfA53685A67Ab9D", + }, + 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); +}; From 16bb1487bae253e6558661d666a7c0245e6e632a Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Thu, 14 Dec 2023 15:11:44 +0300 Subject: [PATCH 03/11] test: add a fork test for MoveDebtOperator --- tests/fork/MoveDebtOperator.ts | 101 +++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/fork/MoveDebtOperator.ts diff --git a/tests/fork/MoveDebtOperator.ts b/tests/fork/MoveDebtOperator.ts new file mode 100644 index 00000000..d7db0a78 --- /dev/null +++ b/tests/fork/MoveDebtOperator.ts @@ -0,0 +1,101 @@ +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; +import { parseEther, parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; +import { SignerWithAddress } from "hardhat-deploy-ethers/signers"; + +import { + IERC20, + IERC20__factory, + MoveDebtDelegate, + MoveDebtOperator, + MoveDebtOperator__factory, +} from "../../typechain"; +import { forking, initMainnetUser } from "./utils"; + +interface MoveDebtOperatorFixture { + moveDebtOperator: MoveDebtOperator; + usdt: IERC20; + busd: IERC20; +} + +forking({ bscmainnet: 34341800 } as const, addresses => { + const executeVip215 = async () => { + const timelock = await initMainnetUser(addresses.NormalTimelock, parseEther("1")); + const comptrollerAbi = [ + "function _setPendingImplementation(address) external", + "function _become(address) external", + "function setDelegateForBNBHacker(address) external", + "function comptrollerImplementation() external view returns (address)", + "function approvedDelegates(address, address) external view returns (bool)", + ]; + + const comptroller = await ethers.getContractAt(comptrollerAbi, "0xfD36E2c2a6789Db23113685031d7F16329158384"); + const intermediateImpl = await ethers.getContractAt(comptrollerAbi, "0xAE37464537fDa217258Bb2Cd70e4f8ffC7E95790"); + const currentImpl = await ethers.getContractAt(comptrollerAbi, "0xD93bFED40466c9A9c3E7381ab335a08807318a1b"); + + await comptroller.connect(timelock)._setPendingImplementation("0xAE37464537fDa217258Bb2Cd70e4f8ffC7E95790"); + await intermediateImpl.connect(timelock)._become("0xfD36E2c2a6789Db23113685031d7F16329158384"); + await comptroller.connect(timelock).setDelegateForBNBHacker("0x89621C48EeC04A85AfadFD37d32077e65aFe2226"); + await comptroller.connect(timelock)._setPendingImplementation("0xD93bFED40466c9A9c3E7381ab335a08807318a1b"); + await currentImpl.connect(timelock)._become("0xfD36E2c2a6789Db23113685031d7F16329158384"); + }; + + const moveDebtOperatorFixture = async (): Promise => { + const USDT_HOLDER = "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3"; + const [admin] = await ethers.getSigners(); + + await executeVip215(); + + const usdt = IERC20__factory.connect(addresses.USDT, admin); + const busd = IERC20__factory.connect(addresses.BUSD, admin); + + const usdtHolder = await initMainnetUser(USDT_HOLDER, parseEther("1")); + await usdt.connect(usdtHolder).transfer(admin.address, parseUnits("1000", 18)); + + const moveDebtOperatorFactory = await ethers.getContractFactory("MoveDebtOperator"); + const moveDebtOperator = await moveDebtOperatorFactory.deploy( + addresses.PancakeSwapRouter, + addresses.MoveDebtDelegate, + ); + return { moveDebtOperator, usdt, busd }; + }; + + describe("MoveDebtOperator", () => { + const BUSD_BORROWER = "0x1F6D66bA924EBF554883Cf84d482394013eD294B"; + const BNB_EXPLOITER = "0x489A8756C18C0b8B24EC2a2b9FF3D4d447F79BEc"; + + let admin: SignerWithAddress; + let busd: IERC20; + let usdt: IERC20; + let moveDebtOperator: MoveDebtOperator; + let moveDebtDelegate: MoveDebtDelegate; + + beforeEach(async () => { + [admin] = await ethers.getSigners(); + ({ moveDebtOperator, busd, usdt } = await loadFixture(moveDebtOperatorFixture)); + moveDebtDelegate = await ethers.getContractAt("MoveDebtDelegate", addresses.MoveDebtDelegate); + }); + + it("should work with a single-hop path from BUSD to USDT", async () => { + const path = ethers.utils.hexlify(ethers.utils.concat([addresses.BUSD, "0x000064", addresses.USDT])); + await usdt.connect(admin).approve(moveDebtOperator.address, parseUnits("100", 18)); + const repayAmount = parseUnits("30000", 18); + const maxUsdtToSpend = parseUnits("30", 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); + }); + }); +}); From f550211cc163b035661e61969fb10f6562f1fd05 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Thu, 14 Dec 2023 15:20:33 +0300 Subject: [PATCH 04/11] fixup! feat: add a contract to operate MoveDebtDelegate --- contracts/flash-swap/ExactOutputFlashSwap.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/flash-swap/ExactOutputFlashSwap.sol b/contracts/flash-swap/ExactOutputFlashSwap.sol index 8e75da32..885ce0d5 100644 --- a/contracts/flash-swap/ExactOutputFlashSwap.sol +++ b/contracts/flash-swap/ExactOutputFlashSwap.sol @@ -150,6 +150,7 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { /// Note that msg.sender is the pool that called the callback, not the original caller /// of the transaction where _flashSwap was invoked. /// @param data Application-specific data + // solhint-disable-next-line no-empty-blocks function _onFlashSwapCompleted(bytes memory data) internal virtual {} /// @dev Ensures that the caller of a callback is a legitimate PancakeSwap pool From 60b1252a545b47a81a79eaf504277dc09b1bc669 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Thu, 14 Dec 2023 23:45:19 +0300 Subject: [PATCH 05/11] fixup! feat: add a contract to operate MoveDebtDelegate --- contracts/flash-swap/ExactOutputFlashSwap.sol | 9 +++++---- contracts/operators/MoveDebtOperator.sol | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/contracts/flash-swap/ExactOutputFlashSwap.sol b/contracts/flash-swap/ExactOutputFlashSwap.sol index 885ce0d5..b23c1305 100644 --- a/contracts/flash-swap/ExactOutputFlashSwap.sol +++ b/contracts/flash-swap/ExactOutputFlashSwap.sol @@ -97,11 +97,11 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { amountToPay = uint256(amount1Delta); } - uint256 maxAmountIn = _onMoneyReceived(envelope.data); + (IERC20 tokenIn, uint256 maxAmountIn) = _onMoneyReceived(envelope.data); if (envelope.path.hasMultiplePools()) { bytes memory remainingPath = envelope.path.skipToken(); - approveOrRevert(tokenToPay, address(SWAP_ROUTER), maxAmountIn); + approveOrRevert(tokenIn, address(SWAP_ROUTER), maxAmountIn); SWAP_ROUTER.exactOutput( ISmartRouter.ExactOutputParams({ path: remainingPath, @@ -110,7 +110,7 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { amountInMaximum: maxAmountIn }) ); - approveOrRevert(tokenToPay, address(SWAP_ROUTER), 0); + 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); @@ -143,8 +143,9 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { /// Note that msg.sender is the pool that called the callback, not the original caller /// of the transaction where _flashSwap 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 (uint256 maxAmountIn); + function _onMoneyReceived(bytes memory data) internal virtual returns (IERC20 tokenIn, uint256 maxAmountIn); /// @dev Called when the flash swap 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 diff --git a/contracts/operators/MoveDebtOperator.sol b/contracts/operators/MoveDebtOperator.sol index 1ee103a3..e7ea742d 100644 --- a/contracts/operators/MoveDebtOperator.sol +++ b/contracts/operators/MoveDebtOperator.sol @@ -48,7 +48,7 @@ contract MoveDebtOperator is ExactOutputFlashSwap { _flashSwap(FlashSwapParams({ amountOut: repayAmount, path: path, data: data })); } - function _onMoneyReceived(bytes memory data) internal override returns (uint256 maxAmountIn) { + function _onMoneyReceived(bytes memory data) internal override returns (IERC20 tokenIn, uint256 maxAmountIn) { MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); IERC20 repayToken = _repayToken(); IERC20 borrowToken = _borrowToken(params); @@ -64,7 +64,7 @@ contract MoveDebtOperator is ExactOutputFlashSwap { } uint256 balanceAfter = borrowToken.balanceOf(address(this)); - return balanceAfter - balanceBefore; + return (borrowToken, balanceAfter - balanceBefore); } function _onFlashSwapCompleted(bytes memory data) internal override { From 760a9730bac5654b48f930cb9b3fae56e9d1ec25 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Thu, 14 Dec 2023 23:45:49 +0300 Subject: [PATCH 06/11] fixup! test: add a fork test for MoveDebtOperator --- tests/fork/MoveDebtOperator.ts | 23 +++++++++++++++++++++++ tests/fork/utils/constants.ts | 3 +++ 2 files changed, 26 insertions(+) diff --git a/tests/fork/MoveDebtOperator.ts b/tests/fork/MoveDebtOperator.ts index d7db0a78..04a3fb95 100644 --- a/tests/fork/MoveDebtOperator.ts +++ b/tests/fork/MoveDebtOperator.ts @@ -97,5 +97,28 @@ forking({ bscmainnet: 34341800 } as const, addresses => { ); await expect(tx).to.emit(busd, "Transfer").withArgs(moveDebtDelegate.address, addresses.vBUSD, repayAmount); }); + + 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 index eb971402..50ad997b 100644 --- a/tests/fork/utils/constants.ts +++ b/tests/fork/utils/constants.ts @@ -2,12 +2,15 @@ export const ADDRESSES = { bscmainnet: { BUSD: "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", USDT: "0x55d398326f99059fF775485246999027B3197955", + USDC: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + WBNB: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", PancakeSwapRouter: "0x13f4EA83D0bd40E75C8222255bc855a974568Dd4", MoveDebtDelegate: "0x89621C48EeC04A85AfadFD37d32077e65aFe2226", Unitroller: "0xfD36E2c2a6789Db23113685031d7F16329158384", NormalTimelock: "0x939bD8d64c0A9583A7Dcea9933f7b21697ab6396", vUSDT: "0xfD5840Cd36d94D7229439859C0112a4185BC0255", vBUSD: "0x95c78222B3D6e262426483D42CfA53685A67Ab9D", + vUSDC: "0xecA88125a5ADbe82614ffC12D0DB554E2e2867C8", }, bsctestnet: {}, } as const; From 54832799062a23a0edb0b0283cc8a2a52b4c955c Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Wed, 20 Dec 2023 16:28:50 +0300 Subject: [PATCH 07/11] refactor: move transferAll to a free function --- contracts/operators/MoveDebtOperator.sol | 12 +++--------- contracts/util/transferAll.sol | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 contracts/util/transferAll.sol diff --git a/contracts/operators/MoveDebtOperator.sol b/contracts/operators/MoveDebtOperator.sol index e7ea742d..eda7ee3d 100644 --- a/contracts/operators/MoveDebtOperator.sol +++ b/contracts/operators/MoveDebtOperator.sol @@ -8,6 +8,7 @@ import { MoveDebtDelegate } from "@venusprotocol/venus-protocol/contracts/Delega 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 { ExactOutputFlashSwap } from "../flash-swap/ExactOutputFlashSwap.sol"; @@ -70,15 +71,8 @@ contract MoveDebtOperator is ExactOutputFlashSwap { function _onFlashSwapCompleted(bytes memory data) internal override { MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); - _transferAll(_borrowToken(params), params.originalSender); - _transferAll(_repayToken(), params.originalSender); - } - - function _transferAll(IERC20 token, address to) internal { - uint256 balance = token.balanceOf(address(this)); - if (balance > 0) { - token.safeTransfer(to, balance); - } + transferAll(_borrowToken(params), address(this), params.originalSender); + transferAll(_repayToken(), address(this), params.originalSender); } function _repayToken() internal view returns (IERC20) { 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); + } +} From 8ecc567b900c0ef7de8ecff6140d0cab76c5d8e2 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Fri, 22 Dec 2023 14:36:35 +0300 Subject: [PATCH 08/11] feat: support batch repayments --- contracts/operators/MoveDebtOperator.sol | 54 +++++++++++++++--- tests/fork/MoveDebtOperator.ts | 71 +++++++++++++++++++++++- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/contracts/operators/MoveDebtOperator.sol b/contracts/operators/MoveDebtOperator.sol index eda7ee3d..d47053e8 100644 --- a/contracts/operators/MoveDebtOperator.sol +++ b/contracts/operators/MoveDebtOperator.sol @@ -18,8 +18,9 @@ contract MoveDebtOperator is ExactOutputFlashSwap { struct MoveDebtParams { uint256 maxExtraAmount; address originalSender; - address originalBorrower; - uint256 repayAmount; + address[] originalBorrowers; + uint256[] repayAmounts; + uint256 totalRepayAmount; IVBep20 vTokenToBorrow; } @@ -37,16 +38,50 @@ contract MoveDebtOperator is ExactOutputFlashSwap { 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(originalBorrowers, repayAmounts, repayAmount, vTokenToBorrow, maxExtraAmount, path); + } + + function moveAllDebts( + address[] memory originalBorrowers, + IVBep20 vTokenToBorrow, + uint256 maxExtraAmount, + bytes memory path + ) external { + uint256 borrowersCount = originalBorrowers.length; + IVBep20 vTokenToRepay = DELEGATE.vTokenToRepay(); + + 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(originalBorrowers, repayAmounts, totalRepayAmount, vTokenToBorrow, maxExtraAmount, path); + } + + function _moveDebts( + 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, - originalBorrower: originalBorrower, - repayAmount: repayAmount, + originalBorrowers: originalBorrowers, + repayAmounts: repayAmounts, + totalRepayAmount: totalRepayAmount, vTokenToBorrow: vTokenToBorrow }); - bytes memory data = abi.encode(params); - _flashSwap(FlashSwapParams({ amountOut: repayAmount, path: path, data: data })); + _flashSwap(FlashSwapParams({ amountOut: totalRepayAmount, path: path, data: data })); } function _onMoneyReceived(bytes memory data) internal override returns (IERC20 tokenIn, uint256 maxAmountIn) { @@ -56,8 +91,11 @@ contract MoveDebtOperator is ExactOutputFlashSwap { uint256 balanceBefore = borrowToken.balanceOf(address(this)); - approveOrRevert(repayToken, address(DELEGATE), params.repayAmount); - DELEGATE.moveDebt(params.originalBorrower, params.repayAmount, params.vTokenToBorrow); + approveOrRevert(repayToken, address(DELEGATE), params.totalRepayAmount); + uint256 borrowersCount = params.originalBorrowers.length; + for (uint256 i = 0; i < borrowersCount; ++i) { + DELEGATE.moveDebt(params.originalBorrowers[i], params.repayAmounts[i], params.vTokenToBorrow); + } approveOrRevert(repayToken, address(DELEGATE), 0); if (params.maxExtraAmount > 0) { diff --git a/tests/fork/MoveDebtOperator.ts b/tests/fork/MoveDebtOperator.ts index 04a3fb95..1a6e2bfc 100644 --- a/tests/fork/MoveDebtOperator.ts +++ b/tests/fork/MoveDebtOperator.ts @@ -1,12 +1,14 @@ +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; import { expect } from "chai"; -import { parseEther, parseUnits } from "ethers/lib/utils"; +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, @@ -68,6 +70,7 @@ forking({ bscmainnet: 34341800 } as const, addresses => { let admin: SignerWithAddress; let busd: IERC20; let usdt: IERC20; + let vBUSD: IVBep20; let moveDebtOperator: MoveDebtOperator; let moveDebtDelegate: MoveDebtDelegate; @@ -75,6 +78,7 @@ forking({ bscmainnet: 34341800 } as const, addresses => { [admin] = await ethers.getSigners(); ({ moveDebtOperator, busd, usdt } = await loadFixture(moveDebtOperatorFixture)); moveDebtDelegate = await ethers.getContractAt("MoveDebtDelegate", addresses.MoveDebtDelegate); + vBUSD = await ethers.getContractAt("IVBep20", addresses.vBUSD); }); it("should work with a single-hop path from BUSD to USDT", async () => { @@ -98,6 +102,71 @@ forking({ bscmainnet: 34341800 } as const, addresses => { await expect(tx).to.emit(busd, "Transfer").withArgs(moveDebtDelegate.address, addresses.vBUSD, repayAmount); }); + 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]), From df29b71ea9002ddb57b061ce7e05452dd458a092 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Mon, 1 Jan 2024 18:52:57 +0300 Subject: [PATCH 09/11] feat: adjust operator to VIP-225 --- contracts/operators/MoveDebtOperator.sol | 41 ++++++++----- package.json | 2 +- tests/fork/MoveDebtOperator.ts | 76 +++++++++++------------- tests/fork/utils/constants.ts | 2 + yarn.lock | 19 ++++-- 5 files changed, 79 insertions(+), 61 deletions(-) diff --git a/contracts/operators/MoveDebtOperator.sol b/contracts/operators/MoveDebtOperator.sol index d47053e8..c9650925 100644 --- a/contracts/operators/MoveDebtOperator.sol +++ b/contracts/operators/MoveDebtOperator.sol @@ -18,6 +18,7 @@ contract MoveDebtOperator is ExactOutputFlashSwap { struct MoveDebtParams { uint256 maxExtraAmount; address originalSender; + IVBep20 vTokenToRepay; address[] originalBorrowers; uint256[] repayAmounts; uint256 totalRepayAmount; @@ -32,6 +33,7 @@ contract MoveDebtOperator is ExactOutputFlashSwap { } function moveDebt( + IVBep20 vTokenToRepay, address originalBorrower, uint256 repayAmount, IVBep20 vTokenToBorrow, @@ -42,17 +44,17 @@ contract MoveDebtOperator is ExactOutputFlashSwap { uint256[] memory repayAmounts = new uint256[](1); originalBorrowers[0] = originalBorrower; repayAmounts[0] = repayAmount; - _moveDebts(originalBorrowers, repayAmounts, repayAmount, vTokenToBorrow, maxExtraAmount, path); + _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; - IVBep20 vTokenToRepay = DELEGATE.vTokenToRepay(); uint256[] memory repayAmounts = new uint256[](borrowersCount); uint256 totalRepayAmount = 0; @@ -61,10 +63,19 @@ contract MoveDebtOperator is ExactOutputFlashSwap { repayAmounts[i] = amount; totalRepayAmount += amount; } - _moveDebts(originalBorrowers, repayAmounts, totalRepayAmount, vTokenToBorrow, maxExtraAmount, path); + _moveDebts( + vTokenToRepay, + originalBorrowers, + repayAmounts, + totalRepayAmount, + vTokenToBorrow, + maxExtraAmount, + path + ); } function _moveDebts( + IVBep20 vTokenToRepay, address[] memory originalBorrowers, uint256[] memory repayAmounts, uint256 totalRepayAmount, @@ -75,6 +86,7 @@ contract MoveDebtOperator is ExactOutputFlashSwap { MoveDebtParams memory params = MoveDebtParams({ maxExtraAmount: maxExtraAmount, originalSender: msg.sender, + vTokenToRepay: vTokenToRepay, originalBorrowers: originalBorrowers, repayAmounts: repayAmounts, totalRepayAmount: totalRepayAmount, @@ -86,15 +98,20 @@ contract MoveDebtOperator is ExactOutputFlashSwap { function _onMoneyReceived(bytes memory data) internal override returns (IERC20 tokenIn, uint256 maxAmountIn) { MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); - IERC20 repayToken = _repayToken(); - IERC20 borrowToken = _borrowToken(params); + 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.originalBorrowers[i], params.repayAmounts[i], params.vTokenToBorrow); + DELEGATE.moveDebt( + params.vTokenToRepay, + params.originalBorrowers[i], + params.repayAmounts[i], + params.vTokenToBorrow + ); } approveOrRevert(repayToken, address(DELEGATE), 0); @@ -109,15 +126,11 @@ contract MoveDebtOperator is ExactOutputFlashSwap { function _onFlashSwapCompleted(bytes memory data) internal override { MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); - transferAll(_borrowToken(params), address(this), params.originalSender); - transferAll(_repayToken(), address(this), params.originalSender); + transferAll(_underlying(params.vTokenToBorrow), address(this), params.originalSender); + transferAll(_underlying(params.vTokenToRepay), address(this), params.originalSender); } - function _repayToken() internal view returns (IERC20) { - return IERC20(DELEGATE.vTokenToRepay().underlying()); - } - - function _borrowToken(MoveDebtParams memory params) internal view returns (IERC20) { - return IERC20(params.vTokenToBorrow.underlying()); + function _underlying(IVBep20 vToken) internal view returns (IERC20) { + return IERC20(vToken.underlying()); } } 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 index 1a6e2bfc..fc8d39bf 100644 --- a/tests/fork/MoveDebtOperator.ts +++ b/tests/fork/MoveDebtOperator.ts @@ -18,39 +18,33 @@ import { forking, initMainnetUser } from "./utils"; interface MoveDebtOperatorFixture { moveDebtOperator: MoveDebtOperator; usdt: IERC20; - busd: IERC20; + btc: IERC20; } -forking({ bscmainnet: 34341800 } as const, addresses => { - const executeVip215 = async () => { +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 comptrollerAbi = [ - "function _setPendingImplementation(address) external", - "function _become(address) external", - "function setDelegateForBNBHacker(address) external", - "function comptrollerImplementation() external view returns (address)", - "function approvedDelegates(address, address) external view returns (bool)", - ]; - - const comptroller = await ethers.getContractAt(comptrollerAbi, "0xfD36E2c2a6789Db23113685031d7F16329158384"); - const intermediateImpl = await ethers.getContractAt(comptrollerAbi, "0xAE37464537fDa217258Bb2Cd70e4f8ffC7E95790"); - const currentImpl = await ethers.getContractAt(comptrollerAbi, "0xD93bFED40466c9A9c3E7381ab335a08807318a1b"); - - await comptroller.connect(timelock)._setPendingImplementation("0xAE37464537fDa217258Bb2Cd70e4f8ffC7E95790"); - await intermediateImpl.connect(timelock)._become("0xfD36E2c2a6789Db23113685031d7F16329158384"); - await comptroller.connect(timelock).setDelegateForBNBHacker("0x89621C48EeC04A85AfadFD37d32077e65aFe2226"); - await comptroller.connect(timelock)._setPendingImplementation("0xD93bFED40466c9A9c3E7381ab335a08807318a1b"); - await currentImpl.connect(timelock)._become("0xfD36E2c2a6789Db23113685031d7F16329158384"); + 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 [admin] = await ethers.getSigners(); - await executeVip215(); + await executeVip225(); const usdt = IERC20__factory.connect(addresses.USDT, admin); - const busd = IERC20__factory.connect(addresses.BUSD, 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)); @@ -60,49 +54,51 @@ forking({ bscmainnet: 34341800 } as const, addresses => { addresses.PancakeSwapRouter, addresses.MoveDebtDelegate, ); - return { moveDebtOperator, usdt, busd }; + return { moveDebtOperator, usdt, btc }; }; describe("MoveDebtOperator", () => { - const BUSD_BORROWER = "0x1F6D66bA924EBF554883Cf84d482394013eD294B"; const BNB_EXPLOITER = "0x489A8756C18C0b8B24EC2a2b9FF3D4d447F79BEc"; let admin: SignerWithAddress; - let busd: IERC20; + let btc: IERC20; let usdt: IERC20; - let vBUSD: IVBep20; + let vBTC: IVBep20; let moveDebtOperator: MoveDebtOperator; let moveDebtDelegate: MoveDebtDelegate; beforeEach(async () => { [admin] = await ethers.getSigners(); - ({ moveDebtOperator, busd, usdt } = await loadFixture(moveDebtOperatorFixture)); + ({ moveDebtOperator, btc, usdt } = await loadFixture(moveDebtOperatorFixture)); moveDebtDelegate = await ethers.getContractAt("MoveDebtDelegate", addresses.MoveDebtDelegate); - vBUSD = await ethers.getContractAt("IVBep20", addresses.vBUSD); + vBTC = await ethers.getContractAt("IVBep20", addresses.vBTC); }); - it("should work with a single-hop path from BUSD to USDT", async () => { - const path = ethers.utils.hexlify(ethers.utils.concat([addresses.BUSD, "0x000064", addresses.USDT])); - await usdt.connect(admin).approve(moveDebtOperator.address, parseUnits("100", 18)); - const repayAmount = parseUnits("30000", 18); - const maxUsdtToSpend = parseUnits("30", 18); + 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(BUSD_BORROWER, repayAmount, addresses.vUSDT, maxUsdtToSpend, path); + .moveDebt(addresses.vBTC, SHORTFALL_BORROWER, repayAmount, addresses.vUSDT, maxUsdtToSpend, path); await expect(tx) .to.emit(moveDebtDelegate, "DebtMoved") .withArgs( - BUSD_BORROWER, - addresses.vBUSD, + SHORTFALL_BORROWER, + addresses.vBTC, repayAmount, BNB_EXPLOITER, addresses.vUSDT, - parseUnits("30020.438638269452250292", 18), + parseUnits("42.582032813125250100", 18), ); - await expect(tx).to.emit(busd, "Transfer").withArgs(moveDebtDelegate.address, addresses.vBUSD, repayAmount); + await expect(tx).to.emit(btc, "Transfer").withArgs(moveDebtDelegate.address, addresses.vBTC, repayAmount); }); - it("should repay all debts of a list of borrowers", async () => { + // + // 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", @@ -188,6 +184,6 @@ forking({ bscmainnet: 34341800 } as const, addresses => { 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 index 50ad997b..dac7c913 100644 --- a/tests/fork/utils/constants.ts +++ b/tests/fork/utils/constants.ts @@ -1,5 +1,6 @@ export const ADDRESSES = { bscmainnet: { + BTCB: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", BUSD: "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", USDT: "0x55d398326f99059fF775485246999027B3197955", USDC: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", @@ -8,6 +9,7 @@ export const ADDRESSES = { MoveDebtDelegate: "0x89621C48EeC04A85AfadFD37d32077e65aFe2226", Unitroller: "0xfD36E2c2a6789Db23113685031d7F16329158384", NormalTimelock: "0x939bD8d64c0A9583A7Dcea9933f7b21697ab6396", + vBTC: "0x882C173bC7Ff3b7786CA16dfeD3DFFfb9Ee7847B", vUSDT: "0xfD5840Cd36d94D7229439859C0112a4185BC0255", vBUSD: "0x95c78222B3D6e262426483D42CfA53685A67Ab9D", vUSDC: "0xecA88125a5ADbe82614ffC12D0DB554E2e2867C8", 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 From 585386cf4f96f2118798012f2010992c744a1792 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Tue, 2 Jan 2024 01:37:24 +0300 Subject: [PATCH 10/11] feat: support in-kind debt moves using a flash loan --- ...tputFlashSwap.sol => ExactOutputFlash.sol} | 98 +++++++++++++++---- contracts/operators/MoveDebtOperator.sol | 15 ++- tests/fork/MoveDebtOperator.ts | 25 +++++ 3 files changed, 114 insertions(+), 24 deletions(-) rename contracts/flash-swap/{ExactOutputFlashSwap.sol => ExactOutputFlash.sol} (62%) diff --git a/contracts/flash-swap/ExactOutputFlashSwap.sol b/contracts/flash-swap/ExactOutputFlash.sol similarity index 62% rename from contracts/flash-swap/ExactOutputFlashSwap.sol rename to contracts/flash-swap/ExactOutputFlash.sol index b23c1305..56f0ae3f 100644 --- a/contracts/flash-swap/ExactOutputFlashSwap.sol +++ b/contracts/flash-swap/ExactOutputFlash.sol @@ -2,6 +2,7 @@ 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"; @@ -13,29 +14,30 @@ 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 ExactOutputFlashSwap +/// @title ExactOutputFlash /// @notice A base contract for exact output flash swap operations. /// -/// Upon calling _flashSwap, swaps tokenX to tokenY using a flash swap, i.e. the contract: +/// 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 _onFlashSwapCompleted +/// 5. Calls _onFlashCompleted /// /// @dev This contract is abstract and should be inherited by a contract that implements -/// _onMoneyReceived and _onFlashSwapCompleted. Note that in the callbacks transaction +/// _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 ExactOutputFlashSwap is IPancakeV3SwapCallback { +abstract contract ExactOutputFlash is IPancakeV3SwapCallback, IPancakeV3FlashCallback { using SafeERC20 for IERC20; using Path for bytes; /// @notice Flash swap parameters - struct FlashSwapParams { + 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 @@ -45,7 +47,7 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { } /// @notice Callback data passed to the swap callback - struct Envelope { + struct FlashSwapEnvelope { /// @notice Exact-output (reversed) swap path, starting with tokenY and ending with tokenX bytes path; /// @notice Application-specific data @@ -54,6 +56,18 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { 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; @@ -68,6 +82,12 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { /// @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_)); @@ -79,9 +99,9 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { /// @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 an Envelope structure + /// @param data Callback data containing a FlashSwapEnvelope structure function pancakeV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { - Envelope memory envelope = abi.decode(data, (Envelope)); + FlashSwapEnvelope memory envelope = abi.decode(data, (FlashSwapEnvelope)); _verifyCallback(envelope.poolKey); if (amount0Delta <= 0 && amount1Delta <= 0) { revert EmptySwap(); @@ -116,12 +136,37 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { tokenToPay.safeTransfer(msg.sender, amountToPay); } - _onFlashSwapCompleted(envelope.data); + _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); } /// @dev Initiates a flash swap /// @param params Flash swap parameters - function _flashSwap(FlashSwapParams memory params) internal { + 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)); @@ -133,26 +178,41 @@ abstract contract ExactOutputFlashSwap is IPancakeV3SwapCallback { swapZeroForOne, -int256(params.amountOut), sqrtPriceLimitX96, - abi.encode(Envelope(params.path, params.data, poolKey)) + 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. This function has to ensure - /// that at the end of the execution the contract has enough token X to repay the flash - /// swap. + /// @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 was invoked. + /// 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 is completed and was paid for. By default, does nothing. + /// @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 was invoked. + /// of the transaction where _flashSwap or _flashLoan was invoked. /// @param data Application-specific data // solhint-disable-next-line no-empty-blocks - function _onFlashSwapCompleted(bytes memory data) internal virtual {} + 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 diff --git a/contracts/operators/MoveDebtOperator.sol b/contracts/operators/MoveDebtOperator.sol index c9650925..3f553b37 100644 --- a/contracts/operators/MoveDebtOperator.sol +++ b/contracts/operators/MoveDebtOperator.sol @@ -10,9 +10,9 @@ import { ensureNonzeroAddress } from "@venusprotocol/solidity-utilities/contract import { approveOrRevert } from "../util/approveOrRevert.sol"; import { transferAll } from "../util/transferAll.sol"; import { ISmartRouter } from "../third-party/pancakeswap-v8/ISmartRouter.sol"; -import { ExactOutputFlashSwap } from "../flash-swap/ExactOutputFlashSwap.sol"; +import { ExactOutputFlash } from "../flash-swap/ExactOutputFlash.sol"; -contract MoveDebtOperator is ExactOutputFlashSwap { +contract MoveDebtOperator is ExactOutputFlash { using SafeERC20 for IERC20; struct MoveDebtParams { @@ -27,7 +27,7 @@ contract MoveDebtOperator is ExactOutputFlashSwap { MoveDebtDelegate public immutable DELEGATE; - constructor(ISmartRouter swapRouter_, MoveDebtDelegate delegate_) ExactOutputFlashSwap(swapRouter_) { + constructor(ISmartRouter swapRouter_, MoveDebtDelegate delegate_) ExactOutputFlash(swapRouter_) { ensureNonzeroAddress(address(delegate_)); DELEGATE = delegate_; } @@ -93,7 +93,12 @@ contract MoveDebtOperator is ExactOutputFlashSwap { vTokenToBorrow: vTokenToBorrow }); bytes memory data = abi.encode(params); - _flashSwap(FlashSwapParams({ amountOut: totalRepayAmount, path: path, data: data })); + 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) { @@ -123,7 +128,7 @@ contract MoveDebtOperator is ExactOutputFlashSwap { return (borrowToken, balanceAfter - balanceBefore); } - function _onFlashSwapCompleted(bytes memory data) internal override { + function _onFlashCompleted(bytes memory data) internal override { MoveDebtParams memory params = abi.decode(data, (MoveDebtParams)); transferAll(_underlying(params.vTokenToBorrow), address(this), params.originalSender); diff --git a/tests/fork/MoveDebtOperator.ts b/tests/fork/MoveDebtOperator.ts index fc8d39bf..24a6fcd3 100644 --- a/tests/fork/MoveDebtOperator.ts +++ b/tests/fork/MoveDebtOperator.ts @@ -39,6 +39,7 @@ forking({ bscmainnet: 34841800 } as const, addresses => { const moveDebtOperatorFixture = async (): Promise => { const USDT_HOLDER = "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3"; + const BTC_HOLDER = "0xF977814e90dA44bFA03b6295A0616a897441aceC"; const [admin] = await ethers.getSigners(); await executeVip225(); @@ -49,6 +50,9 @@ forking({ bscmainnet: 34841800 } as const, addresses => { 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, @@ -95,6 +99,27 @@ forking({ bscmainnet: 34841800 } as const, addresses => { 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 // From 909ff3a9b7c7556a50928a0c7514d1199f0d1987 Mon Sep 17 00:00:00 2001 From: Kirill Kuvshinov Date: Tue, 2 Jan 2024 02:00:16 +0300 Subject: [PATCH 11/11] fixup! feat: support in-kind debt moves using a flash loan --- contracts/flash-swap/ExactOutputFlash.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/flash-swap/ExactOutputFlash.sol b/contracts/flash-swap/ExactOutputFlash.sol index 56f0ae3f..f111ea31 100644 --- a/contracts/flash-swap/ExactOutputFlash.sol +++ b/contracts/flash-swap/ExactOutputFlash.sol @@ -162,6 +162,8 @@ abstract contract ExactOutputFlash is IPancakeV3SwapCallback, IPancakeV3FlashCal } envelope.token.safeTransfer(msg.sender, envelope.amountOut + fee); + + _onFlashCompleted(envelope.data); } /// @dev Initiates a flash swap