diff --git a/contracts/lib/xERC4626.sol b/contracts/lib/xERC4626.sol index f2ff33e..c1bda4a 100644 --- a/contracts/lib/xERC4626.sol +++ b/contracts/lib/xERC4626.sol @@ -12,9 +12,9 @@ import {IxERC4626} from "../interfaces/IxERC4626.sol"; // Rewards logic inspired by xERC20 (https://github.com/ZeframLou/playpen/blob/main/src/xERC20.sol) -/** +/** @title An xERC4626 Single Staking Contract -@notice This contract allows users to autocompound rewards denominated in an underlying reward token. +@notice This contract allows users to autocompound rewards denominated in an underlying reward token. It is fully compatible with [ERC4626](https://eips.ethereum.org/EIPS/eip-4626) allowing for DeFi composability. It maintains balances using internal accounting to prevent instantaneous changes in the exchange rate. NOTE: an exception is at contract creation, when a reward cycle begins before the first deposit. After the first deposit, exchange rate updates smoothly. @@ -22,85 +22,85 @@ import {IxERC4626} from "../interfaces/IxERC4626.sol"; Operates on "cycles" which distribute the rewards surplus over the internal balance to users linearly over the remainder of the cycle window. */ abstract contract xERC4626 is IxERC4626, ERC4626 { - using SafeCastLib for *; + using SafeCastLib for *; - /// @notice the maximum length of a rewards cycle - uint32 public immutable rewardsCycleLength; + /// @notice the maximum length of a rewards cycle + uint32 public immutable rewardsCycleLength; - /// @notice the effective start of the current cycle - uint32 public lastSync; + /// @notice the effective start of the current cycle + uint32 public lastSync; - /// @notice the end of the current cycle. Will always be evenly divisible by `rewardsCycleLength`. - uint32 public rewardsCycleEnd; + /// @notice the end of the current cycle. Will always be evenly divisible by `rewardsCycleLength`. + uint32 public rewardsCycleEnd; - /// @notice the amount of rewards distributed in a the most recent cycle. - uint192 public lastRewardAmount; + /// @notice the amount of rewards distributed in a the most recent cycle. + uint192 public lastRewardAmount; - uint256 internal storedTotalAssets; + uint256 internal storedTotalAssets; - constructor(uint32 _rewardsCycleLength) { - rewardsCycleLength = _rewardsCycleLength; - // seed initial rewardsCycleEnd - rewardsCycleEnd = (block.timestamp.safeCastTo32() / rewardsCycleLength) * rewardsCycleLength; - } + constructor(uint32 _rewardsCycleLength) { + rewardsCycleLength = _rewardsCycleLength; + // seed initial rewardsCycleEnd + rewardsCycleEnd = (block.timestamp.safeCastTo32() / rewardsCycleLength) * rewardsCycleLength; + } - /// @notice Compute the amount of tokens available to share holders. - /// Increases linearly during a reward distribution period from the sync call, not the cycle start. - function totalAssets() public view override returns (uint256) { - // cache global vars - uint256 storedTotalAssets_ = storedTotalAssets; - uint192 lastRewardAmount_ = lastRewardAmount; - uint32 rewardsCycleEnd_ = rewardsCycleEnd; - uint32 lastSync_ = lastSync; + /// @notice Compute the amount of tokens available to share holders. + /// Increases linearly during a reward distribution period from the sync call, not the cycle start. + function totalAssets() public view override returns (uint256) { + // cache global vars + uint256 storedTotalAssets_ = storedTotalAssets; + uint192 lastRewardAmount_ = lastRewardAmount; + uint32 rewardsCycleEnd_ = rewardsCycleEnd; + uint32 lastSync_ = lastSync; + + if (block.timestamp >= rewardsCycleEnd_) { + // no rewards or rewards fully unlocked + // entire reward amount is available + return storedTotalAssets_ + lastRewardAmount_; + } + + // rewards not fully unlocked + // add unlocked rewards to stored total + uint256 unlockedRewards = (lastRewardAmount_ * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_); + return storedTotalAssets_ + unlockedRewards; + } - if (block.timestamp >= rewardsCycleEnd_) { - // no rewards or rewards fully unlocked - // entire reward amount is available - return storedTotalAssets_ + lastRewardAmount_; + // Update storedTotalAssets on withdraw/redeem + function beforeWithdraw(uint256 amount, uint256 shares) internal virtual override { + super.beforeWithdraw(amount, shares); + storedTotalAssets -= amount; } - // rewards not fully unlocked - // add unlocked rewards to stored total - uint256 unlockedRewards = (lastRewardAmount_ * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_); - return storedTotalAssets_ + unlockedRewards; - } + // Update storedTotalAssets on deposit/mint + function afterDeposit(uint256 amount, uint256 shares) internal virtual override { + storedTotalAssets += amount; + super.afterDeposit(amount, shares); + } - // Update storedTotalAssets on withdraw/redeem - function beforeWithdraw(uint256 amount, uint256 shares) internal virtual override { - super.beforeWithdraw(amount, shares); - storedTotalAssets -= amount; - } + /// @notice Distributes rewards to xERC4626 holders. + /// All surplus `asset` balance of the contract over the internal balance becomes queued for the next cycle. + function syncRewards() public virtual { + uint192 lastRewardAmount_ = lastRewardAmount; + uint32 timestamp = block.timestamp.safeCastTo32(); - // Update storedTotalAssets on deposit/mint - function afterDeposit(uint256 amount, uint256 shares) internal virtual override { - storedTotalAssets += amount; - super.afterDeposit(amount, shares); - } + if (timestamp < rewardsCycleEnd) revert SyncError(); - /// @notice Distributes rewards to xERC4626 holders. - /// All surplus `asset` balance of the contract over the internal balance becomes queued for the next cycle. - function syncRewards() public virtual { - uint192 lastRewardAmount_ = lastRewardAmount; - uint32 timestamp = block.timestamp.safeCastTo32(); + uint256 storedTotalAssets_ = storedTotalAssets; + uint256 nextRewards = asset.balanceOf(address(this)) - storedTotalAssets_ - lastRewardAmount_; - if (timestamp < rewardsCycleEnd) revert SyncError(); + storedTotalAssets = storedTotalAssets_ + lastRewardAmount_; // SSTORE - uint256 storedTotalAssets_ = storedTotalAssets; - uint256 nextRewards = asset.balanceOf(address(this)) - storedTotalAssets_ - lastRewardAmount_; + uint32 end = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; - storedTotalAssets = storedTotalAssets_ + lastRewardAmount_; // SSTORE + if (end - timestamp < rewardsCycleLength / 20) { + end += rewardsCycleLength; + } - uint32 end = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; + // Combined single SSTORE + lastRewardAmount = nextRewards.safeCastTo192(); + lastSync = timestamp; + rewardsCycleEnd = end; - if (end - timestamp < rewardsCycleLength / 20) { - end += rewardsCycleLength; + emit NewRewardsCycle(end, nextRewards); } - - // Combined single SSTORE - lastRewardAmount = nextRewards.safeCastTo192(); - lastSync = timestamp; - rewardsCycleEnd = end; - - emit NewRewardsCycle(end, nextRewards); - } } diff --git a/contracts/v2/core/WSGEth.sol b/contracts/v2/core/WSGEth.sol index 2586787..115852d 100644 --- a/contracts/v2/core/WSGEth.sol +++ b/contracts/v2/core/WSGEth.sol @@ -38,7 +38,7 @@ contract WSGETH is xERC4626, ReentrancyGuard { uint8 v, bytes32 r, bytes32 s - ) external nonReentrant returns (uint256 shares) { + ) external returns (uint256 shares) { uint256 amount = approveMax ? type(uint256).max : assets; asset.permit(msg.sender, address(this), amount, deadline, v, r, s); return (deposit(assets, receiver)); diff --git a/package.json b/package.json index 6deced3..453ab11 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@fei-protocol/erc4626": "^0.0.0", + "@nomicfoundation/hardhat-network-helpers": "^1.0.11", "@openzeppelin/contracts": "^4.8.0", "@openzeppelin/contracts-upgradeable": "^4.3.1", "dotenv": "^16.1.4", diff --git a/test/v2/core/wsgETH.spec.js b/test/v2/core/wsgETH.spec.js new file mode 100644 index 0000000..a116b88 --- /dev/null +++ b/test/v2/core/wsgETH.spec.js @@ -0,0 +1,201 @@ +const {ethers} = require("hardhat"); +const {expect} = require("chai"); +const {parseEther, formatEther} = require("ethers/lib/utils"); +const {time} = require("@nomicfoundation/hardhat-network-helpers"); + +describe.only("WsgETH.sol", () => { + let sgEth, wsgEth, deployer, alice, multiSig; + let MINTER_ROLE; + + beforeEach(async () => { + const [owner, addr1, addr2] = await ethers.getSigners(); + + const SgETH = await ethers.getContractFactory("SgETH"); + sgEth = await SgETH.deploy([]); + await sgEth.deployed(); + + deployer = owner; + alice = addr1; + multiSig = addr2; + + MINTER_ROLE = await sgEth.MINTER(); + + // deploy wsgeth + const WSGETH = await ethers.getContractFactory("WSGETH"); + wsgEth = await WSGETH.deploy(sgEth.address, 24 * 60 * 60); + await wsgEth.deployed(); + + // mint tokens for test + await sgEth.addMinter(deployer.address); + await sgEth.mint(deployer.address, parseEther("1000")); + }); + + it("deposit", async () => { + await expect(wsgEth.connect(alice).deposit(parseEther("1"), alice.address)).to.be.revertedWith( + "TRANSFER_FROM_FAILED", + ); + await expect(wsgEth.deposit(parseEther("1"), alice.address)).to.be.revertedWith("TRANSFER_FROM_FAILED"); + + await sgEth.approve(wsgEth.address, parseEther("1")); + await expect(wsgEth.deposit(parseEther("1"), alice.address)) + .to.be.emit(wsgEth, "Transfer") + .withArgs(ethers.constants.AddressZero, alice.address, parseEther("1")); + }); + + it("mint", async () => { + await expect(wsgEth.connect(alice).mint(parseEther("1"), alice.address)).to.be.revertedWith("TRANSFER_FROM_FAILED"); + await expect(wsgEth.mint(parseEther("1"), alice.address)).to.be.revertedWith("TRANSFER_FROM_FAILED"); + + await sgEth.approve(wsgEth.address, parseEther("1")); + await expect(wsgEth.mint(parseEther("1"), alice.address)) + .to.be.emit(wsgEth, "Transfer") + .withArgs(ethers.constants.AddressZero, alice.address, parseEther("1")); + }); + + it("withdraw", async () => { + await expect(wsgEth.withdraw(parseEther("1"), alice.address, alice.address)).to.be.revertedWith(""); // panic revert by insufficient allowance + await wsgEth.connect(alice).approve(deployer.address, parseEther("1000")); + + await expect(wsgEth.withdraw(parseEther("1"), alice.address, alice.address)).to.be.revertedWith(""); // panic revert by insufficient balance + + // approve sgEth to alice + await sgEth.approve(wsgEth.address, parseEther("1")); + await wsgEth.deposit(parseEther("1"), alice.address); + + await expect(wsgEth.withdraw(parseEther("1"), alice.address, alice.address)) + .to.be.emit(wsgEth, "Withdraw") + .withArgs(deployer.address, alice.address, alice.address, parseEther("1"), parseEther("1")); + }); + + it("redeem", async () => { + await expect(wsgEth.redeem(parseEther("1"), alice.address, alice.address)).to.be.revertedWith(""); // panic revert by insufficient allowance + await wsgEth.connect(alice).approve(deployer.address, parseEther("1000")); + + await expect(wsgEth.redeem(parseEther("1"), alice.address, alice.address)).to.be.revertedWith(""); // panic revert by insufficient balance + + // approve sgEth to alice + await sgEth.approve(wsgEth.address, parseEther("1")); + await wsgEth.deposit(parseEther("1"), alice.address); + + await expect(wsgEth.redeem(parseEther("1"), alice.address, alice.address)) + .to.be.emit(wsgEth, "Withdraw") + .withArgs(deployer.address, alice.address, alice.address, parseEther("1"), parseEther("1")); + }); + + it("depositWithSignature", async () => { + await sgEth.transfer(alice.address, parseEther("1")); + const nonce = await sgEth.nonces(alice.address); + const deadline = Math.floor(Date.now() / 1000) + 1000; + const approveData = { + owner: alice.address, + spender: wsgEth.address, + value: parseEther("1"), + nonce, + deadline: deadline, + }; + + const domain = await sgEth.eip712Domain(); + + const signature = await alice._signTypedData( + { + name: domain.name, + version: domain.version, + chainId: domain.chainId, + verifyingContract: domain.verifyingContract, + }, + { + Permit: [ + {name: "owner", type: "address"}, + {name: "spender", type: "address"}, + {name: "value", type: "uint256"}, + {name: "nonce", type: "uint256"}, + {name: "deadline", type: "uint256"}, + ], + }, + approveData, + ); + + const {r, s, v} = ethers.utils.splitSignature(signature); + + await expect(wsgEth.connect(alice).depositWithSignature(parseEther("1"), alice.address, deadline, false, v, r, s)) + .to.be.emit(wsgEth, "Transfer") + .withArgs(ethers.constants.AddressZero, alice.address, parseEther("1")); + }); + + it("price per share", async () => { + const splitterAddresses = [deployer.address, multiSig.address, wsgEth.address]; + const splitterValues = [6, 3, 31]; + + const PaymentSplitter = await ethers.getContractFactory("PaymentSplitter"); + const paymentSplitter = await PaymentSplitter.deploy(splitterAddresses, splitterValues); + await paymentSplitter.deployed(); + + const rolloverVirtual = "1080000000000000000"; + const vETH2Addr = "0x898bad2774eb97cf6b94605677f43b41871410b1"; + + const Withdrawals = await ethers.getContractFactory("Withdrawals"); + const withdrawals = await Withdrawals.deploy(vETH2Addr, rolloverVirtual); + await withdrawals.deployed(); + + const numValidators = 1000; + const adminFee = 0; + + const addresses = [ + ethers.constants.AddressZero, // fee splitter + sgEth.address, // sgETH address + wsgEth.address, // wsgETH address + multiSig.address, // government address + ethers.constants.AddressZero, // deposit contract address - can't find deposit contract - using dummy address + ]; + + // add secondary minter contract / eoa + const Minter = await ethers.getContractFactory("SharedDepositMinterV2"); + const minter = await Minter.deploy(numValidators, adminFee, addresses); + await minter.deployed(); + + const RewardsReceiver = await ethers.getContractFactory("RewardsReceiver"); + const rewardsReceiver = await RewardsReceiver.deploy(withdrawals.address, [ + sgEth.address, + wsgEth.address, + paymentSplitter.address, + minter.address, + ]); + await rewardsReceiver.deployed(); + + await sgEth.addMinter(minter.address); + + // approve sgEth to alice + await sgEth.approve(wsgEth.address, parseEther("2")); + await wsgEth.deposit(parseEther("2"), alice.address); + + await expect(wsgEth.connect(alice).redeem(parseEther("0.5"), alice.address, alice.address)) + .to.be.emit(wsgEth, "Withdraw") + .withArgs(alice.address, alice.address, alice.address, parseEther("0.5"), parseEther("0.5")); + + // deposit to rewardReceiver to simulate reward + await deployer.sendTransaction({ + to: rewardsReceiver.address, + value: parseEther("1"), + }); + // sends 60% of sgEth to WSGEth contract - so current rate is 1.5/2.1 + await rewardsReceiver.work(); + + await expect(wsgEth.syncRewards()).to.be.revertedWith("SyncError()"); + // increase time by reward cycle + await time.increase(24 * 60 * 60); + // await wsgEth.syncRewards(); + // reward rate is not updated yet + // after call this functions, reward rate is increasing linearly from 1 to 1.0/(2.1-0.5) = 1.0/1.6 + await expect(wsgEth.connect(alice).redeem(parseEther("0.5"), alice.address, alice.address)) + .to.be.emit(wsgEth, "Withdraw") + .withArgs(alice.address, alice.address, alice.address, parseEther("0.5"), parseEther("0.5")); + + await time.increase(24 * 60 * 60); + + // after reward cycle, reward rate is updated with new rate + // redeem will get 0.8 = 1.6/1.0*0.5 sgEth + await expect(wsgEth.connect(alice).redeem(parseEther("0.5"), alice.address, alice.address)) + .to.be.emit(wsgEth, "Withdraw") + .withArgs(alice.address, alice.address, alice.address, parseEther("0.8"), parseEther("0.5")); + }); +}); diff --git a/yarn.lock b/yarn.lock index f6e164a..93ad908 100644 --- a/yarn.lock +++ b/yarn.lock @@ -726,6 +726,13 @@ "@nomicfoundation/ethereumjs-rlp" "5.0.4" ethereum-cryptography "0.1.3" +"@nomicfoundation/hardhat-network-helpers@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-network-helpers/-/hardhat-network-helpers-1.0.11.tgz#64096829661b960b88679bd5c4fbcb50654672d1" + integrity sha512-uGPL7QSKvxrHRU69dx8jzoBvuztlLCtyFsbgfXIwIjnO3dqZRz2GNMHJoO3C3dIiUNM6jdNF4AUnoQKDscdYrA== + dependencies: + ethereumjs-util "^7.1.4" + "@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-darwin-arm64/-/solidity-analyzer-darwin-arm64-0.1.1.tgz#4c858096b1c17fe58a474fe81b46815f93645c15" @@ -4310,7 +4317,7 @@ ethereumjs-util@^5.0.0, ethereumjs-util@^5.0.1, ethereumjs-util@^5.1.1, ethereum rlp "^2.0.0" safe-buffer "^5.1.1" -ethereumjs-util@^7.0.2, ethereumjs-util@^7.0.3: +ethereumjs-util@^7.0.2, ethereumjs-util@^7.0.3, ethereumjs-util@^7.1.4: version "7.1.5" resolved "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-7.1.5.tgz" integrity sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg==