diff --git a/brownie-config.yaml b/brownie-config.yaml index fdf8e648..1ddaacd8 100644 --- a/brownie-config.yaml +++ b/brownie-config.yaml @@ -81,10 +81,11 @@ dependencies: - openzeppelin/openzeppelin-contracts@4.8.0 - openzeppelin/openzeppelin-contracts-upgradeable@4.8.0 - openzeppelin/openzeppelin-contracts@4.3.2 - - openzeppelin/openzeppelin-contracts@3.4.0 - - openzeppelin/openzeppelin-contracts@2.5.0 + - openzeppelin/openzeppelin-contracts@3.4.2 + - openzeppelin/openzeppelin-contracts@2.5.1 - paulrberg/prb-math@2.4.1 - - uniswap/uniswap-v2-core@1.0.1 - - uniswap/uniswap-v3-core@1.0.0 - - uniswap/uniswap-v3-periphery@1.3.0 + - uniswap/v2-core@1.0.1 + - uniswap/v3-core@1.0.0 + - uniswap/v3-periphery@1.3.0 + - celer-network/sgn-v2-contracts@0.2.0 dev_deployment_artifacts: false diff --git a/contracts/staking-vault/StakingVault.sol b/contracts/staking-vault/StakingVault.sol new file mode 100644 index 00000000..87408673 --- /dev/null +++ b/contracts/staking-vault/StakingVault.sol @@ -0,0 +1,267 @@ +pragma solidity ^0.8.0; + +import "@openzeppelin-4.8.0/token/ERC1155/ERC1155.sol"; +import "@openzeppelin-4.8.0/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin-4.8.0/token/ERC20/extensions/IERC20Metadata.sol"; + +import "../../interfaces/IPriceFeeds.sol"; +import "../proxies/0_8/Upgradeable_0_8.sol"; +import "../../interfaces/IStakingVault.sol"; + +contract StakingVault is IStakingVault, Upgradeable_0_8, ERC1155 { + using SafeERC20 for IERC20; + struct TokenComposition { + address depositToken; + address tokenToBack; + } + address public immutable PROTOCOL; + IPriceFeeds public immutable PRICE_FEED; + address public immutable VALUATION_TOKEN; + address public immutable REWARD_TOKEN; + + + address[] private _stakingTokens; + mapping(address => bool) public tokenSupported; + mapping(uint256 => uint256) private _sTokenPrice; + mapping(uint256 => TokenComposition) public identifierToTokens; + mapping(uint256 => uint256) public balanceStakedPerID; + mapping(uint256 => uint256) public supplyPerID; + + mapping(address => uint256) public undistributedRewards; + mapping(uint256 => uint256) public rewardsPerID; + + mapping(address => mapping(uint256 => uint256)) public lastClaimRewardAccrual; + mapping(uint256 => bool) public initialized; + mapping(address => uint8) public tokenDecimalCache; + + modifier onlyProtocol() { + require(msg.sender == PROTOCOL, "not protocol"); + _; + } + + constructor(string memory uri_, IPriceFeeds pFeed, address rToken, address p, address vToken) ERC1155(uri_) { + PRICE_FEED = pFeed; + REWARD_TOKEN = rToken; + PROTOCOL = p; + VALUATION_TOKEN = vToken; + } + + function stakingTokens() external view returns (address[] memory) { + return _stakingTokens; + } + + function updateTokenSupport( + address[] memory tokens, + bool[] memory support, + address[] memory listOfSupportedTokens, + uint8[] memory tokenDecimals + ) external onlyOwner { + _stakingTokens = listOfSupportedTokens; + require(tokens.length == tokenDecimals.length, "mismatch"); + for (uint256 i; i < tokens.length; ) { + tokenSupported[tokens[i]] = support[i]; + if (support[i]) { + tokenDecimalCache[tokens[i]] = tokenDecimals[i]; + } + unchecked { + ++i; + } + } + + emit TokensSupported(listOfSupportedTokens); + } + + function deposit( + address depositToken, + address tokenToBack, + uint256 amount + ) external { + require(tokenSupported[depositToken], "unsupported deposit token"); + uint256 tokenID = convertToID(depositToken, tokenToBack); + if (identifierToTokens[tokenID].depositToken == address(0)) { + _sTokenPrice[tokenID] = 1e18; + } + identifierToTokens[tokenID] = TokenComposition({depositToken: depositToken, tokenToBack: tokenToBack}); + IERC20(depositToken).safeTransferFrom(msg.sender, address(this), amount); + uint256 mintAmount = _amountToMint(tokenID, amount); + _claimReward(tokenID); + _mint(msg.sender, tokenID, mintAmount, ""); + balanceStakedPerID[tokenID] += amount; + supplyPerID[tokenID] += mintAmount; + + emit Deposit(msg.sender, tokenToBack, depositToken, amount); + } + + function withdraw( + address depositToken, + address tokenToBack, + uint256 amount + ) external { + uint256 tokenID = convertToID(depositToken, tokenToBack); + _claimReward(tokenID); + _burn(msg.sender, tokenID, amount); + supplyPerID[tokenID] -= amount; + uint256 sendAmount = _amountToSend(tokenID, amount); + balanceStakedPerID[tokenID] -= sendAmount; + IERC20(depositToken).safeTransfer(msg.sender, sendAmount); + + emit Withdraw(msg.sender, tokenToBack, depositToken, amount); + } + + function drawOnPool( + address tokenBacked, + address tokenToCover, + uint256 amountToCover + ) external onlyProtocol returns (uint256[] memory) { + address[] memory tokensStaked = _stakingTokens; + uint256[] memory valuesPerToken = new uint256[](tokensStaked.length); + uint256 totalValue; + for (uint256 i = 0; i < tokensStaked.length; ) { + valuesPerToken[i] = PRICE_FEED.queryReturn(tokensStaked[i], VALUATION_TOKEN, balanceStakedPerID[convertToID(tokensStaked[i], tokenBacked)]); + totalValue += valuesPerToken[i]; + unchecked { + ++i; + } + } + uint256[] memory amountDrawnPerToken = new uint256[](tokensStaked.length); + uint256 valueOfCoverage = PRICE_FEED.queryReturn(tokenToCover, VALUATION_TOKEN, amountToCover); + for (uint256 i = 0; i < tokensStaked.length; ) { + amountDrawnPerToken[i] = PRICE_FEED.queryReturn(VALUATION_TOKEN, tokensStaked[i], (valueOfCoverage * valuesPerToken[i]) / totalValue); + IERC20(tokensStaked[i]).safeTransfer(msg.sender, amountDrawnPerToken[i]); + unchecked { + ++i; + } + } + + _updatePrice(tokenBacked, amountDrawnPerToken); + + emit PoolDraw(tokenBacked, tokenToCover, amountToCover); + return amountDrawnPerToken; + } + + function getStoredTokenPrice(uint256 ID) external view returns (uint256) { + return _sTokenPrice[ID]; + } + + function getBackingAmount(address token) external view returns (uint256) { + address[] memory tokensStaked = _stakingTokens; + uint256 value; + for (uint256 i; i < tokensStaked.length; ) { + value += PRICE_FEED.queryReturn(tokensStaked[i], VALUATION_TOKEN, balanceStakedPerID[convertToID(token, tokensStaked[i])]); + } + return value; + } + + function getBackingAmountInNativeTokens(address token) external view returns (uint256[] memory balances) { + address[] memory tokensStaked = _stakingTokens; + balances = new uint256[](tokensStaked.length); + for (uint256 i; i < tokensStaked.length; ) { + balances[i] = balanceStakedPerID[convertToID(token, tokensStaked[i])]; + unchecked { + ++i; + } + } + } + + function convertToID(address depositToken, address tokenToBack) public pure returns (uint256) { + return uint256(keccak256(abi.encode(depositToken, tokenToBack))); + } + + function addRewards(address tokenBacked, uint256 rewardAmount) external { + _addRewards(rewardAmount, tokenBacked); + IERC20(REWARD_TOKEN).safeTransferFrom(msg.sender, address(this), rewardAmount); + + emit AddRewards(tokenBacked, rewardAmount); + } + + function accumulateRewards(address tokenBacked, uint256 rewardAmount) external { + undistributedRewards[tokenBacked] += rewardAmount; + IERC20(REWARD_TOKEN).safeTransferFrom(msg.sender, address(this), rewardAmount); + + emit AccumulateRewards(tokenBacked, rewardAmount); + } + + function distributeRewards(address tokenBacked) external { + uint256 rewards = undistributedRewards[tokenBacked]; + _addRewards(rewards, tokenBacked); + undistributedRewards[tokenBacked] = 0; + + emit DistributeRewards(tokenBacked, rewards); + } + + function claimRewards(uint256[] memory tokenIDs) external { + for (uint256 i; i < tokenIDs.length; ) { + _claimReward(tokenIDs[i]); + unchecked { + ++i; + } + } + } + + function _updatePrice(address tokenBacked, uint256[] memory amountsDrawn) internal { + address[] memory tokensStaked = _stakingTokens; + uint256 previousBalance; + for (uint256 i = 0; i < tokensStaked.length; ) { + uint256 tokenID = convertToID(tokensStaked[i], tokenBacked); + previousBalance = balanceStakedPerID[tokenID]; + balanceStakedPerID[tokenID] = previousBalance - amountsDrawn[i]; + _sTokenPrice[tokenID] = (_sTokenPrice[tokenID] * (previousBalance - balanceStakedPerID[tokenID])) / previousBalance; + unchecked { + ++i; + } + } + } + + function _amountToMint(uint256 tokenID, uint256 amount) internal view returns (uint256) { + uint8 decimals = tokenDecimalCache[identifierToTokens[tokenID].depositToken]; + + return ((amount * 1e18) / _sTokenPrice[tokenID]) * 10**(18 - decimals); + } + + function _amountToSend(uint256 tokenID, uint256 amountBurnt) internal view returns (uint256) { + uint8 decimals = IERC20Metadata(identifierToTokens[tokenID].depositToken).decimals(); + + return ((_sTokenPrice[tokenID] * amountBurnt) / 1e18) / 10**(18 - decimals); + } + + function _addRewards(uint256 rewardAmount, address tokenBacked) internal { + address[] memory tokensStaked = _stakingTokens; + uint256 totalValue; + uint256[] memory values = new uint256[](tokensStaked.length); + uint256 tokenID; + for (uint256 i; i < tokensStaked.length; ) { + tokenID = convertToID(tokensStaked[i], tokenBacked); + values[i] = PRICE_FEED.queryReturn(tokensStaked[i], VALUATION_TOKEN, balanceStakedPerID[tokenID]); + totalValue += values[i]; + unchecked { + ++i; + } + } + for (uint256 i; i < tokensStaked.length; ) { + tokenID = convertToID(tokensStaked[i], tokenBacked); + values[i] = (values[i] * 1e18) / totalValue; + rewardsPerID[tokenID] += (rewardAmount * values[i]) / supplyPerID[tokenID]; + unchecked { + ++i; + } + } + } + + function _claimReward(uint256 tokenID) internal { + uint256 previousAmount = lastClaimRewardAccrual[msg.sender][tokenID]; + bool tokenIDInitialized = initialized[tokenID]; + if (previousAmount == 0 && tokenIDInitialized) { + lastClaimRewardAccrual[msg.sender][tokenID] = rewardsPerID[tokenID]; + return; + } + uint256 newAmount = rewardsPerID[tokenID]; + if (newAmount - previousAmount == 0) { + return; + } + IERC20(REWARD_TOKEN).safeTransfer(msg.sender, ((newAmount - previousAmount) * balanceOf(msg.sender, tokenID)) / 1e18); + if (!tokenIDInitialized && newAmount > 0) { + initialized[tokenID] = true; + } + lastClaimRewardAccrual[msg.sender][tokenID] = newAmount; + } +} diff --git a/interfaces/IStakingVault.sol b/interfaces/IStakingVault.sol new file mode 100644 index 00000000..78f8cafd --- /dev/null +++ b/interfaces/IStakingVault.sol @@ -0,0 +1,32 @@ +pragma solidity ^0.8.0; + +import "./IPriceFeeds.sol"; + +interface IStakingVault { + event TokensSupported(address[] supportedTokens); + event Deposit(address depositor, address tokenBacked, address depositToken, uint256 amount); + event Withdraw(address withdrawer, address tokenBacked, address receivedToken, uint256 amount); + event PoolDraw(address tokenBacked, address tokenCovered, uint256 amountCovered); + event AddRewards(address tokenBacked, uint256 amount); + event AccumulateRewards(address tokenBacked, uint256 amount); + event DistributeRewards(address tokenBacked, uint256 amount); + + function updateTokenSupport(address[] memory tokens, bool[] memory support, address[] memory listOfSupportedTokens, uint8[] memory decimals) external; + function deposit(address depositToken, address tokenToBack, uint256 amount) external; + function withdraw(address depositToken, address tokenToBack, uint256 amount) external; + function getStoredTokenPrice(uint256 ID) external view returns (uint256); + function getBackingAmount(address token) external view returns (uint256); + function getBackingAmountInNativeTokens(address token) external view returns (uint256[] memory balances); + function convertToID(address depositToken, address tokenToBack) external pure returns (uint256); + function addRewards(address tokenBacked, uint256 rewardAmount) external; + function accumulateRewards(address tokenBacked, uint256 rewardAmount) external; + function distributeRewards(address tokenBacked) external; + function claimRewards(uint256[] memory tokenIDs) external; + // function priceFeed() external view returns (IPriceFeeds); + // function protocol() external view returns (address); + // function valuationToken() external view returns (address); + function stakingTokens() external view returns (address[] memory); + // function rewardToken() external view returns (address); + function drawOnPool(address tokenBacked, address tokenToCover, uint256 amountToCover) external returns (uint256[] memory); + +} diff --git a/testsmainnet/staking-vault.py b/testsmainnet/staking-vault.py new file mode 100644 index 00000000..92265038 --- /dev/null +++ b/testsmainnet/staking-vault.py @@ -0,0 +1,94 @@ +from brownie import * +import pytest + +@pytest.fixture(scope="module") +def PriceFeedContract(): + return "0x5AbC9e082Bf6e4F930Bbc79742DA3f6259c4aD1d" + +@pytest.fixture(scope="module") +def ProtocolContract(accounts): + return accounts[1] + +@pytest.fixture(scope="module") +def RewardTokenContract(): + return "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + +@pytest.fixture(scope="module") +def ValuationTokenContract(): + return "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + +@pytest.fixture(scope="module") +def StakeVault(accounts, Contract, StakingVault, Proxy_0_8, PriceFeedContract, ProtocolContract, RewardTokenContract, ValuationTokenContract): + #deploy and configure vault + s = StakingVault.deploy("", {"from":accounts[0]}) + s_proxy = Proxy_0_8.deploy(s, {"from":accounts[0]}) + s = Contract.from_abi("StakingVault",s_proxy.address, StakingVault.abi) + s.setPriceFeed(PriceFeedContract, {"from":accounts[0]}) + s.setProtocol(ProtocolContract, {"from":accounts[0]}) + s.setRewardToken(RewardTokenContract, {"from":accounts[0]}) + s.setValuationToken(ValuationTokenContract, {"from":accounts[0]}) + #add backstop tokens + tokens = ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"] + update = [True] + s.updateTokenSupport(tokens, update, tokens, {"from":accounts[0]}) + return s + +#deposit into staking vault for a specific token and withdraw. +def test_case1(accounts, Contract, interface, StakeVault, TestToken): + token_to_deposit = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + token_to_backstop = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + source_of_token = "0x0a59649758aa4d66e25f08dd01271e891fe52199" + t_deposit = Contract.from_abi("Deposit",token_to_deposit,TestToken.abi) + t_deposit.transfer(accounts[0], 1e6, {"from":source_of_token}) + starting_balance = interface.IERC20(token_to_deposit).balanceOf(accounts[0]) + t_deposit.approve(StakeVault, 1e6, {"from":accounts[0]}) + StakeVault.deposit(token_to_deposit, token_to_backstop, 1e6, {"from":accounts[0]}) + assert StakeVault.balanceOf(accounts[0], StakeVault.convertToID(token_to_deposit, token_to_backstop)) == 1e18 + print(StakeVault.rewardsPerToken(StakeVault.convertToID(token_to_deposit, token_to_backstop))) + StakeVault.withdraw(token_to_deposit, token_to_backstop, 1e18, {"from":accounts[0]}) + assert t_deposit.balanceOf(accounts[0]) == starting_balance +#deposit into staking vault, add reward to vault, and withdraw. Check to see if rewards were sent on the withdrawal +def test_case2(accounts, Contract, interface, StakeVault, TestToken): + token_to_deposit = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + token_to_backstop = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + source_of_token = "0x0a59649758aa4d66e25f08dd01271e891fe52199" + t_deposit = Contract.from_abi("Deposit",token_to_deposit,TestToken.abi) + t_deposit.transfer(accounts[0], 1e6, {"from":source_of_token}) + starting_balance = interface.IERC20(token_to_deposit).balanceOf(accounts[0]) + t_deposit.approve(StakeVault, 1e6, {"from":accounts[0]}) + StakeVault.deposit(token_to_deposit, token_to_backstop, 1e6, {"from":accounts[0]}) + t_deposit.approve(StakeVault, 1e6, {"from":source_of_token}) + StakeVault.addRewards(token_to_backstop, 1e6, {"from":source_of_token}) + assert StakeVault.balanceOf(accounts[0], StakeVault.convertToID(token_to_deposit, token_to_backstop)) == 1e18 + print(StakeVault.rewardsPerToken(StakeVault.convertToID(token_to_deposit, token_to_backstop))) + StakeVault.withdraw(token_to_deposit, token_to_backstop, 1e18, {"from":accounts[0]}) + assert t_deposit.balanceOf(accounts[0]) - starting_balance == 1e6 +#deposit into vault, add rewards, manual claim reward, draw on the pool as if being used to backstop a loan, check balance left, and withdraw +def test_case3(accounts, Contract, interface, StakeVault, TestToken, ProtocolContract): + token_to_deposit = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + token_to_backstop = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + source_of_token = "0x0a59649758aa4d66e25f08dd01271e891fe52199" + t_deposit = Contract.from_abi("Deposit",token_to_deposit,TestToken.abi) + t_deposit.transfer(accounts[0], 1e6, {"from":source_of_token}) + starting_balance = interface.IERC20(token_to_deposit).balanceOf(accounts[0]) + t_deposit.approve(StakeVault, 1e6, {"from":accounts[0]}) + StakeVault.deposit(token_to_deposit, token_to_backstop, 1e6, {"from":accounts[0]}) + t_deposit.approve(StakeVault, 1e6, {"from":source_of_token}) + StakeVault.addRewards(token_to_backstop, 1e6, {"from":source_of_token}) + bal = interface.IERC20(token_to_deposit).balanceOf(accounts[0]) + assert StakeVault.balanceOf(accounts[0], StakeVault.convertToID(token_to_deposit, token_to_backstop)) == 1e18 + print(StakeVault.rewardsPerToken(StakeVault.convertToID(token_to_deposit, token_to_backstop))) + StakeVault.claimRewards([StakeVault.convertToID(token_to_deposit, token_to_backstop)], {"from":accounts[0]}) + assert interface.IERC20(token_to_deposit).balanceOf(accounts[0]) - bal == 1e6 + bal = interface.IERC20(token_to_deposit).balanceOf(ProtocolContract) + StakeVault.drawOnPool(token_to_backstop, token_to_backstop, 5e5, {"from":ProtocolContract}) + added_amount = interface.IERC20(token_to_deposit).balanceOf(ProtocolContract) - bal + assert added_amount > 4.95e5 + assert added_amount < 5.05e5 + assert StakeVault.balanceStakedPerID(StakeVault.convertToID(token_to_deposit, token_to_backstop)) < 1e6 + assert StakeVault.getStoredTokenPrice(StakeVault.convertToID(token_to_deposit, token_to_backstop)) < 1e18 + bal = t_deposit.balanceOf(accounts[0]) + print(StakeVault.getStoredTokenPrice(StakeVault.convertToID(token_to_deposit, token_to_backstop))) + StakeVault.withdraw(token_to_deposit, token_to_backstop, 1e18, {"from":accounts[0]}) + assert t_deposit.balanceOf(accounts[0]) > bal + assert t_deposit.balanceOf(accounts[0]) - starting_balance == 1e6 - added_amount