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..a5c674a --- /dev/null +++ b/contracts/harvester/TipHarvester.sol @@ -0,0 +1,117 @@ +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 + * @param token_ Token to retrieve + */ + function collect(address token_) public onlyOwner { + if (token_ == address(0)) { + payable(msg.sender).sendValue(address(this).balance); + } else { + uint256 balance = IERC20(token_).balanceOf(address(this)); + 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); + } + } + + /** + * @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 + * @param router_ IUniswapRouter compatible router + */ + 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 + * @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"); + _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); + } +} 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/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" } 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