From cabfcbced95d26137f24e0f7e813d0f43a93d734 Mon Sep 17 00:00:00 2001 From: Masaud Date: Fri, 18 Jun 2021 11:06:14 +0200 Subject: [PATCH 1/3] feat: wrapper contract to pay tip and harvest vault tip amount in ETH and swapping exact tokens for tokens supporting fee on transfer tokens modified IUniswapRouter to include said swap function see #12 --- contracts/harvester/ITipJar.sol | 12 +++ contracts/harvester/IUniswapRouter.sol | 18 ++-- contracts/harvester/TipHarvester.sol | 110 +++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 contracts/harvester/ITipJar.sol create mode 100644 contracts/harvester/TipHarvester.sol diff --git a/contracts/harvester/ITipJar.sol b/contracts/harvester/ITipJar.sol new file mode 100644 index 0000000..a2090e4 --- /dev/null +++ b/contracts/harvester/ITipJar.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.7.3; + +interface ITipJar { + function tip() external payable; + function updateMinerSplit(address minerAddress, address splitTo, uint32 splitPct) external; + function setFeeCollector(address newCollector) external; + function setFee(uint32 newFee) external; + function changeAdmin(address newAdmin) external; + function upgradeTo(address newImplementation) external; + function upgradeToAndCall(address newImplementation, bytes calldata data) external payable; +} \ No newline at end of file diff --git a/contracts/harvester/IUniswapRouter.sol b/contracts/harvester/IUniswapRouter.sol index 95c6cb5..6646a51 100644 --- a/contracts/harvester/IUniswapRouter.sol +++ b/contracts/harvester/IUniswapRouter.sol @@ -3,11 +3,17 @@ pragma solidity 0.7.3; interface IUniswapRouter { function swapExactTokensForTokens( - uint amountIn, - uint amountOutMin, - address[] calldata path, - address to, - uint deadline + uint amountIn, + uint amountOutMin, + address[] calldata path, + address to, + uint deadline ) external returns (uint[] memory amounts); - + function swapExactTokensForTokensSupportingFeeOnTransferTokens( + uint amountIn, + uint amountOutMin, + address[] calldata path, + address to, + uint deadline + ) external; } \ No newline at end of file diff --git a/contracts/harvester/TipHarvester.sol b/contracts/harvester/TipHarvester.sol new file mode 100644 index 0000000..4331401 --- /dev/null +++ b/contracts/harvester/TipHarvester.sol @@ -0,0 +1,110 @@ +pragma solidity 0.7.3; + +import "@openzeppelin/contracts/math/SafeMath.sol"; +import "../vault/IVault.sol"; +import "./IUniswapRouter.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; +import "./ITipJar.sol"; + +contract TipHarvester is Ownable { + using SafeMath for uint256; + using SafeERC20 for IERC20; + using Address for address payable; + + IUniswapRouter public router; + ITipJar public immutable tipJar; + mapping(IVault => uint256) public ratePerToken; + + + constructor(IUniswapRouter router_, ITipJar tipJar_) { + router = router_; + tipJar = tipJar_; + } + + fallback() external payable {} + receive() external payable {} + + /*** + * @notice retrieve tokens sent to contract by mistake + * + */ + function collect(address token_) public onlyOwner { + if (token_ == address(0)) { + payable(owner()).sendValue(address(this).balance); + } else { + uint256 balance = IERC20(token_).balanceOf(address(this)); + IERC20(token_).safeTransfer(owner(), balance); + } + } + + /** + * @notice approve to spend given amount of given token + * + */ + function _approve(IERC20 token_, address spender_, uint256 amount_) internal { + if (token_.allowance(address(this), spender_) < amount_) { + token_.safeApprove(spender_, amount_); + } + } + + /** + * @notice swap tokens for tokens + * @param amountIn Amount to swap + * @param amountOutMin Minimum amount out + * @param path Path for swap + * @param deadline Block timestamp deadline for trade + */ + function _swapExactTokensForTokens( + uint amountIn, + uint amountOutMin, + address[] calldata path, + address to, + uint deadline + ) internal { + router.swapExactTokensForTokensSupportingFeeOnTransferTokens(amountIn, amountOutMin, path, to, deadline); + } + + /** + * @notice Tip specific amount of ETH + * @param tipAmount Amount to tip + */ + function _tipAmountETH(uint256 tipAmount) internal { + tipJar.tip{value: tipAmount}(); + } + + /** + * @notice set router to IUniswapRouter compatible router + * @dev ensure router is IUniswapRouter compatible + * + */ + function setRouter(IUniswapRouter router_) public onlyOwner { + router = router_; + } + + /** + * @notice harvest vault while tipping miner + * _swapExactTokensForTokens returns no amounts and therefore received amount has to be calculated + */ + function harvestVault(IVault vault, uint256 amount, uint256 outMin, address[] calldata path, uint256 deadline) public payable onlyOwner { + require(msg.value > 0, "Tip Harvester: tip must be > 0"); + _tipAmountETH(msg.value); + + uint256 afterFee = vault.harvest(amount); + uint256 durationSinceLastHarvest = block.timestamp.sub(vault.lastDistribution()); + IERC20Detailed from = vault.underlying(); + ratePerToken[vault] = afterFee.mul(10**(36-from.decimals())).div(vault.totalSupply()).div(durationSinceLastHarvest); + IERC20 to = vault.target(); + + _approve(from, address(router), afterFee); + uint256 toBalanceBefore = IERC20(path[path.length - 1]).balanceOf(address(this)); + _swapExactTokensForTokens(afterFee, outMin, path, address(this), deadline); + uint256 toBalanceAfter = IERC20(path[path.length - 1]).balanceOf(address(this)); + + uint256 received = toBalanceAfter.sub(toBalanceBefore); + _approve(to, address(vault), received); + vault.distribute(received); + } +} From c6445a6a5668111a4150465cfde1e6461f036ce0 Mon Sep 17 00:00:00 2001 From: Masaud Date: Sun, 20 Jun 2021 14:13:35 +0200 Subject: [PATCH 2/3] feat: vault harvesting script update utils to query and submit transactions to Archer relay signing transaction with Wallet subclass see #12 --- contracts/harvester/TipHarvester.sol | 25 +++++++---- hardhat.config.js | 39 +++++++++++++--- src/utils.js | 67 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 src/utils.js diff --git a/contracts/harvester/TipHarvester.sol b/contracts/harvester/TipHarvester.sol index 4331401..a5c674a 100644 --- a/contracts/harvester/TipHarvester.sol +++ b/contracts/harvester/TipHarvester.sol @@ -29,24 +29,26 @@ contract TipHarvester is Ownable { /*** * @notice retrieve tokens sent to contract by mistake - * + * @param token_ Token to retrieve */ function collect(address token_) public onlyOwner { if (token_ == address(0)) { - payable(owner()).sendValue(address(this).balance); + payable(msg.sender).sendValue(address(this).balance); } else { uint256 balance = IERC20(token_).balanceOf(address(this)); - IERC20(token_).safeTransfer(owner(), balance); + IERC20(token_).safeTransfer(msg.sender, balance); } } /** * @notice approve to spend given amount of given token - * + * @param token Approval token + * @param spender Address to spend token + * @param amount Allowance of token amount to spend */ - function _approve(IERC20 token_, address spender_, uint256 amount_) internal { - if (token_.allowance(address(this), spender_) < amount_) { - token_.safeApprove(spender_, amount_); + function _approve(IERC20 token, address spender, uint256 amount) internal { + if (token.allowance(address(this), spender) < amount) { + token.safeApprove(spender, amount); } } @@ -78,7 +80,7 @@ contract TipHarvester is Ownable { /** * @notice set router to IUniswapRouter compatible router * @dev ensure router is IUniswapRouter compatible - * + * @param router_ IUniswapRouter compatible router */ function setRouter(IUniswapRouter router_) public onlyOwner { router = router_; @@ -86,7 +88,12 @@ contract TipHarvester is Ownable { /** * @notice harvest vault while tipping miner - * _swapExactTokensForTokens returns no amounts and therefore received amount has to be calculated + * _swapExactTokensForTokens returns no amounts and therefore received amount has to be calculated + * @param vault Vault from which to harvest + * @param amount Amount to harvest + * @param outMin Minimum amount of tokens out + * @param path Token swap path + * @param deadline Block timestamp deadline for trade */ function harvestVault(IVault vault, uint256 amount, uint256 outMin, address[] calldata path, uint256 deadline) public payable onlyOwner { require(msg.value > 0, "Tip Harvester: tip must be > 0"); diff --git a/hardhat.config.js b/hardhat.config.js index df2e6b9..2f66f44 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -1,4 +1,5 @@ const { task } = require('hardhat/config') +const { submitArcherTransaction, getTip } = require('./src/utils') require('@nomiclabs/hardhat-etherscan') require('@nomiclabs/hardhat-waffle') @@ -63,7 +64,7 @@ task('harvest', 'Harvest a vault') const underlyingAddress = await vault.underlying() const targetAddress = await vault.target() const decimals = await vault.decimals() - const harvester = await ethers.getContractAt('UniswapHarvester', await vault.harvester()) + const tipHarvester = await ethers.getContractAt('TipHarvester', await vault.harvester()) if (!args.amount) { args.amount = await vault.callStatic.underlyingYield() } else { @@ -72,17 +73,34 @@ task('harvest', 'Harvest a vault') if(args.amount.gt(0)) { console.log("Harvesting", ethers.utils.formatUnits(args.amount, decimals)) const deadline = Math.ceil(Date.now()/1000) + 3600 // 1 hour from now - let path = [underlyingAddress, targetAddress]; + let path = [underlyingAddress, targetAddress] // TODO: Find best path dynamically const weth = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" if(targetAddress.toLowerCase() !== weth.toLowerCase()) { path = [underlyingAddress, weth, targetAddress] } - const estimate = await harvester.estimateGas.harvestVault(args.vault, args.amount, 0, path, deadline) - const tx = await harvester.harvestVault(args.vault, args.amount, 0, path, deadline, { - gasLimit: Math.max(estimate, 1000000) + const provider = ethers.getDefaultProvider() + const estimate = await provider.estimateGas({ + to: tipHarvester.address, + data: (await tipHarvester.populateTransaction.harvestVault(args.vault, args.amount, 0, path, deadline))['data'] }) - console.log(tx.hash) + + const tip = await getTip('standard') + // creating wallet as etherssigner.signTransaction not supported + const privateKey = (await provider.getNetwork()).chainId == 1 ? process.env.MAINNET_PRIVKEY : process.env.RINKEBY_PRIVKEY + const deployer = new ethers.Wallet(privateKey, provider) + const nonce = await deployer.getTransactionCount() + const unsignedTx = await tipHarvester.populateTransaction.harvestVault(args.vault, args.amount, 0, path, deadline, { + gasLimit: Math.max(estimate, 1000000), + nonce: nonce, + value: tip + }) + + // sign transaction and send to Archer DAO relay server + const signedTx = await deployer.signTransaction(unsignedTx) + const response = await submitArcherTransaction(signedTx, deadline) + const data = response.data; + console.log(`Response from Archer DAO relay, status: ${response.status}, data: ${JSON.stringify(data)}`) } else { console.log("Nothing to Harvest. Skipping.") } @@ -132,5 +150,14 @@ module.exports = { version: '0.6.11' } ] + }, + namedAccounts: { + tipJar: { + 1: '0x5312B0d160E16feeeec13437a0053009e7564287', + 4: '0x914528335B5d031c93Ac86e6F6A6C67052Eb44f0' + }, + deployer: { + defualt: 0 + } } } diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..129833f --- /dev/null +++ b/src/utils.js @@ -0,0 +1,67 @@ +require('dotenv').config(); +const axios = require('axios'); +const BASE_URL = 'https://api.archerdao.io'; +const transactionPath = 'v1/transaction'; +const gasPath = 'v1/gas'; +let initBody = { + "jsonrpc": "2.0", + "method": "archer_submitTx" +} + +async function submitArcherTransaction(tx, deadline) { + const url = `${BASE_URL}/${transactionPath}`; + const id = (new Date()).getTime(); + const body = Object.assign({ tx, deadline, id }, initBody); + const response = await axios({ + method: 'post', + url: url, + headers: { + 'Authorization': process.env.ARCHER_DAO_API_KEY, + 'Content-Type': 'application/json' + }, + data: body + }); + if (!response) { + console.log('Error sending transaction to Archer relay'); + process.exit(1); + } + + return response; +} + +async function getArcherMinerTips() { + const url = `${BASE_URL}/${gasPath}`; + const response = await axios({ + method: 'get', + url: url, + headers: { + 'Content-Type': 'application/json', + 'Referrer-Policy': 'no-referrer' + } + }); + + return response.data.data; +} + +async function getTip(speed= 'standard') { + const tips = await getArcherMinerTips(); + if (!tips) { + console.log(`Couldn't get Archer gas`); + process.exit(1); + } + switch (speed) { + case 'immediate': return tips['immediate']; + case 'rapid': return tips['rapid']; + case 'fast': return tips['fast'] + case 'standard': return tips['standard']; + case 'slow': return tips['slow']; + case 'slower': return tips['slower']; + case 'slowest': return tips['slowest']; + default: return tips['standard']; + } +} + +module.exports = { + submitArcherTransaction, + getTip +} \ No newline at end of file From 4b2dba73fceee85967f8782a41cb12db0a6c659c Mon Sep 17 00:00:00 2001 From: Masaud Date: Sun, 20 Jun 2021 14:16:18 +0200 Subject: [PATCH 3/3] adding axios lib requirement --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0293669..fc185b3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@openzeppelin/contracts": "^3.3.0", "@uniswap/merkle-distributor": "git://github.com/Uniswap/merkle-distributor.git#c3255bfa2b684594ecd562cacd7664b0f18330bf", "async-prompt": "^1.0.1", + "axios": "^0.21.1", "dotenv": "^8.2.0", "openzeppelin-solidity-2.3.0": "npm:openzeppelin-solidity@2.3.0" }