├── test └── StakingPool.ts ├── .gitignore ├── hardhat.config.ts ├── tsconfig.json ├── scripts └── deploy.ts ├── package.json ├── README.md └── contracts └── StakingPool.sol /test/StakingPool.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | describe("Staking Pool", function () { 5 | 6 | }); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | typechain-types 7 | 8 | # Hardhat files 9 | cache 10 | artifacts 11 | 12 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { HardhatUserConfig } from "hardhat/config"; 2 | import "@nomicfoundation/hardhat-toolbox"; 3 | 4 | const config: HardhatUserConfig = { 5 | solidity: "0.8.17", 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "hardhat"; 2 | 3 | async function main() { 4 | const currentTimestampInSeconds = Math.round(Date.now() / 1000); 5 | const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60; 6 | const unlockTime = currentTimestampInSeconds + ONE_YEAR_IN_SECS; 7 | 8 | const lockedAmount = ethers.utils.parseEther("1"); 9 | 10 | const Lock = await ethers.getContractFactory("Lock"); 11 | const lock = await Lock.deploy(unlockTime, { value: lockedAmount }); 12 | 13 | await lock.deployed(); 14 | 15 | console.log(`Lock with 1 ETH and unlock timestamp ${unlockTime} deployed to ${lock.address}`); 16 | } 17 | 18 | // We recommend this pattern to be able to use async/await everywhere 19 | // and properly handle errors. 20 | main().catch((error) => { 21 | console.error(error); 22 | process.exitCode = 1; 23 | }); 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "staking-pool", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "@ethersproject/abi": "^5.4.7", 8 | "@ethersproject/providers": "^5.4.7", 9 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.0", 10 | "@nomicfoundation/hardhat-network-helpers": "^1.0.0", 11 | "@nomicfoundation/hardhat-toolbox": "^2.0.0", 12 | "@nomiclabs/hardhat-ethers": "^2.0.0", 13 | "@nomiclabs/hardhat-etherscan": "^3.0.0", 14 | "@typechain/ethers-v5": "^10.1.0", 15 | "@typechain/hardhat": "^6.1.2", 16 | "@types/chai": "^4.2.0", 17 | "@types/mocha": "^9.1.0", 18 | "@types/node": ">=12.0.0", 19 | "chai": "^4.2.0", 20 | "ethers": "^5.4.7", 21 | "hardhat": "^2.12.4", 22 | "hardhat-gas-reporter": "^1.0.8", 23 | "solidity-coverage": "^0.8.0", 24 | "ts-node": ">=8.0.0", 25 | "typechain": "^8.1.0", 26 | "typescript": ">=4.5.0" 27 | }, 28 | "dependencies": { 29 | "@openzeppelin/contracts": "^4.8.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Staking Pool 2 | 3 | ## Approach 4 | 5 | Users can stake their ETH into this pool to potentially receive rewards (currently not available). When users stake their ETH, the contract internally tracks the amount of ETH they staked and the shares they have in the pool. As ETH staking rewards are not available yet, users will basically get a 1:1 ratio of shares and staked ETH. However, if the contract were to receive ETH from elsewhere (rewards), the amount of shares received from any new staker will depend on how much ETH is in the contract. On the flip side, when users want to withdraw their staked ETH plus any rewards (not available), the amount of ETH they receive back will depend on their shares. 6 | 7 | ## Functions 8 | 9 | The `stake` and `withdrawStake` functions only work when the contract is not paused and the pool is not full (< 32 ETH). The `stake` function allows the user to stake their ETH within the contract. The `withdrawStake` function allows the user to withdraw their staked ETH before the pool becomes full if they wish. The `unstake` function allows the user to unstake their ETH plus any rewards. However, this is currently turned off. 10 | -------------------------------------------------------------------------------- /contracts/StakingPool.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.9; 3 | 4 | import "@openzeppelin/contracts/security/Pausable.sol"; 5 | import "@openzeppelin/contracts/access/Ownable.sol"; 6 | 7 | 8 | interface IDepositContract { 9 | /// @notice A processed deposit event. 10 | // https://github.com/ethereum/consensus-specs/blob/dev/solidity_deposit_contract/deposit_contract.sol 11 | event DepositEvent( 12 | bytes pubkey, 13 | bytes withdrawal_credentials, 14 | bytes amount, 15 | bytes signature, 16 | bytes index 17 | ); 18 | 19 | /// @notice Submit a Phase 0 DepositData object. 20 | /// @param pubkey A BLS12-381 public key. 21 | /// @param withdrawal_credentials Commitment to a public key for withdrawals. 22 | /// @param signature A BLS12-381 signature. 23 | /// @param deposit_data_root The SHA-256 hash of the SSZ-encoded DepositData object. 24 | /// Used as a protection against malformed input. 25 | function deposit( 26 | bytes calldata pubkey, 27 | bytes calldata withdrawal_credentials, 28 | bytes calldata signature, 29 | bytes32 deposit_data_root 30 | ) external payable; 31 | 32 | /// @notice Query the current deposit root hash. 33 | /// @return The deposit root hash. 34 | function get_deposit_root() external view returns (bytes32); 35 | 36 | /// @notice Query the current deposit count. 37 | /// @return The deposit count encoded as a little endian 64-bit number. 38 | function get_deposit_count() external view returns (bytes memory); 39 | } 40 | 41 | contract StakingPool is Pausable, Ownable { 42 | // Total amount of ETH staked 43 | uint256 public totalEthStaked; 44 | 45 | // Total amount of rewards received by the contract 46 | uint256 public totalRewardsReceived; 47 | 48 | // To check if pool is full or not 49 | bool public poolFull; 50 | 51 | // To check if rewards are turned on 52 | bool public rewardsOn; 53 | 54 | // Address of mainnet staking contract 55 | address public mainnetStakingAddress; 56 | 57 | // Address of goerli staking contract 58 | address public goerliStakingAddress; 59 | 60 | // User's staked amount 61 | mapping(address => uint256) private stakedAmount; 62 | 63 | // --- Events --- 64 | event Stake(address indexed staker, uint256 stakedAmount); 65 | 66 | event WithdrawStake(address indexed staker, uint256 amountWithdrawn); 67 | 68 | event ClaimRewards( 69 | address indexed staker, 70 | uint256 amountClaimed, 71 | uint256 rewardsClaimed 72 | ); 73 | 74 | // Allow contract to receive ETH 75 | receive() external payable { 76 | totalRewardsReceived += msg.value; 77 | } 78 | 79 | constructor() { 80 | poolFull = false; 81 | rewardsOn = false; 82 | mainnetStakingAddress = 0x00000000219ab540356cBB839Cbe05303d7705Fa; 83 | goerliStakingAddress = 0xff50ed3d0ec03aC01D4C79aAd74928BFF48a7b2b; 84 | } 85 | 86 | // Check if pool is full 87 | modifier isPoolFull() { 88 | require(!poolFull, "Pool is full"); 89 | _; 90 | } 91 | 92 | // --- Admin functions --- 93 | function pause() public onlyOwner { 94 | _pause(); 95 | } 96 | 97 | function unpause() public onlyOwner { 98 | _unpause(); 99 | } 100 | 101 | function turnOnRewards() external onlyOwner { 102 | rewardsOn = true; 103 | } 104 | 105 | function turnOffPoolFull() external onlyOwner { 106 | poolFull = false; 107 | } 108 | 109 | 110 | // Need to complete function with required parameters 111 | function depositStake( 112 | bytes calldata pubkey, 113 | bytes calldata withdrawal_credentials, 114 | bytes calldata signature, 115 | bytes32 deposit_data_root 116 | ) external onlyOwner { 117 | poolFull = true; 118 | 119 | // Deposit 32 ETH into deposit contract 120 | require(address(this).balance >= 32 ether, "depositStake: insufficient pool balance"); 121 | // we need to figure out how to hash the contract address properly to match the withdrawal credentials 122 | // should be able to reverse engineer from staking deposit cli 123 | //require(keccak256(abi.encodePacked(withdrawal_credentials)) == keccak256(abi.encodePacked(address(this))), "depositStake: withdrawal_credentials address must match this contract address"); 124 | 125 | IDepositContract(goerliStakingAddress).deposit{value: 32 ether}( 126 | pubkey, 127 | abi.encodePacked(withdrawal_credentials), 128 | signature, 129 | deposit_data_root 130 | ); 131 | } 132 | 133 | // Allow users to stake ETH if pool is not full 134 | function stakeIntoPool() external payable whenNotPaused isPoolFull { 135 | // ETH sent from user needs to be greater than 0 136 | require(msg.value > 0, "Invalid amount"); 137 | 138 | // Check if staked ETH balance is over 32 ETH after user deposits stake 139 | require( 140 | totalEthStaked + msg.value <= 32.01 ether, 141 | "Pool max capacitiy" 142 | ); 143 | 144 | // Update state 145 | stakedAmount[msg.sender] += msg.value; 146 | totalEthStaked += msg.value; 147 | 148 | emit Stake(msg.sender, msg.value); 149 | } 150 | 151 | // Allow users to withdraw their stake if pool is not full 152 | function withdrawStakeFromPool( 153 | uint256 amount 154 | ) external whenNotPaused isPoolFull { 155 | // Check if user has enough to withdraw 156 | require(stakedAmount[msg.sender] >= amount, "Insufficient amount"); 157 | 158 | // Update state 159 | stakedAmount[msg.sender] -= amount; 160 | totalEthStaked -= amount; 161 | 162 | // Send user withdrawal amount 163 | (bool withdrawal, ) = payable(msg.sender).call{value: amount}(""); 164 | require(withdrawal, "Failed to withdraw"); 165 | 166 | emit WithdrawStake(msg.sender, amount); 167 | } 168 | 169 | // Allow users to unstake a certain amount of ETH + rewards (currently off) 170 | function unstakeFromPool(uint256 amount) external whenNotPaused { 171 | require(rewardsOn, "Currently cannot claim rewards"); 172 | 173 | uint256 userStakedAmount = stakedAmount[msg.sender]; 174 | 175 | // Check if user has enough to claim 176 | require(userStakedAmount >= amount, "Insufficient amount"); 177 | 178 | // Calculate rewards 179 | uint256 totalStakePortion = (userStakedAmount * 10 ** 18) / 180 | totalEthStaked; 181 | uint256 totalUserRewards = (totalStakePortion * totalRewardsReceived) / 182 | 10 ** 18; 183 | uint256 rewards = (totalUserRewards * 184 | ((amount * 10 ** 18) / userStakedAmount)) / 10 ** 18; 185 | 186 | // Update state 187 | stakedAmount[msg.sender] -= amount; 188 | 189 | // Send user amount staked + rewards 190 | (bool claim, ) = payable(msg.sender).call{value: amount + rewards}(""); 191 | require(claim, "Failed to unstake"); 192 | 193 | emit ClaimRewards(msg.sender, amount, rewards); 194 | } 195 | 196 | // Retrieve user's amount of staked ETH 197 | function stakeOf(address staker) public view returns (uint256) { 198 | return stakedAmount[staker]; 199 | } 200 | 201 | // Calculate total staker's rewards 202 | function rewardOf(address staker) public view returns (uint256) { 203 | uint256 userStakedAmount = stakedAmount[staker]; 204 | 205 | if (userStakedAmount == 0) { 206 | return 0; 207 | } 208 | 209 | // Calculate rewards 210 | uint256 totalStakePortion = (userStakedAmount * 10 ** 18) / 211 | totalEthStaked; 212 | uint256 totalUserRewards = (totalStakePortion * totalRewardsReceived) / 213 | 10 ** 18; 214 | 215 | return totalUserRewards; 216 | } 217 | } 218 | --------------------------------------------------------------------------------