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/e2e.spec.js b/test/v2/core/e2e.spec.js index 441f904..9da6d98 100644 --- a/test/v2/core/e2e.spec.js +++ b/test/v2/core/e2e.spec.js @@ -42,7 +42,7 @@ describe("e2e test", () => { const adminFee = 0; const addresses = [ - paymentSplitter.address, // fee splitter + ethers.constants.AddressZero, // fee splitter sgEth.address, // sgETH address wsgETH.address, // wsgETH address multiSig.address, // government address diff --git a/test/v2/core/minter.spec.js b/test/v2/core/minter.spec.js new file mode 100644 index 0000000..6c3016f --- /dev/null +++ b/test/v2/core/minter.spec.js @@ -0,0 +1,266 @@ +const {ethers} = require("hardhat"); +const {expect} = require("chai"); +const {parseEther} = require("ethers/lib/utils"); +const {time} = require("@nomicfoundation/hardhat-network-helpers"); + +describe("SharedDepositMinterV2", () => { + let sgEth, paymentSplitter, minter, withdrawals, wsgEth, rewardsReceiver, deployer, alice, bob, multiSig; + + beforeEach(async () => { + const [owner, addr1, addr2, addr3] = await ethers.getSigners(); + + const SgETH = await ethers.getContractFactory("SgETH"); + sgEth = await SgETH.deploy([]); + await sgEth.deployed(); + + deployer = owner; + alice = addr1; + bob = addr2; + multiSig = addr3; + + MINTER_ROLE = await sgEth.MINTER(); + + // deploy sgeth + const WSGETH = await ethers.getContractFactory("WSGETH"); + wsgEth = await WSGETH.deploy(sgEth.address, 24 * 60 * 60); + await wsgEth.deployed(); + + const splitterAddresses = [deployer.address, multiSig.address, wsgEth.address]; + const splitterValues = [6, 3, 31]; + + const PaymentSplitter = await ethers.getContractFactory("PaymentSplitter"); + paymentSplitter = await PaymentSplitter.deploy(splitterAddresses, splitterValues); + await paymentSplitter.deployed(); + + const rolloverVirtual = "1080000000000000000"; + const vETH2Addr = "0x898bad2774eb97cf6b94605677f43b41871410b1"; + + const Withdrawals = await ethers.getContractFactory("Withdrawals"); + withdrawals = await Withdrawals.deploy(vETH2Addr, rolloverVirtual); + await withdrawals.deployed(); + + const numValidators = 1000; + const adminFee = 0; + + // const FeeCalc = await ethers.getContractFactory("FeeCalc"); + // const feeCalc = await FeeCalc.deploy(parseEther("0"), parseEther("0")); + // await feeCalc.deployed(); + + const addresses = [ + ethers.constants.AddressZero, + //feeCalc.address, // 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"); + minter = await Minter.deploy(numValidators, adminFee, addresses); + await minter.deployed(); + + const RewardsReceiver = await ethers.getContractFactory("RewardsReceiver"); + rewardsReceiver = await RewardsReceiver.deploy(withdrawals.address, [ + sgEth.address, + wsgEth.address, + paymentSplitter.address, + minter.address, + ]); + await rewardsReceiver.deployed(); + + await sgEth.addMinter(minter.address); + }); + + describe("functionality", () => { + it("deposit", async () => { + const prevBalance = await sgEth.balanceOf(alice.address); + await minter.connect(alice).deposit({ + value: parseEther("1"), + }); + const afterBalance = await sgEth.balanceOf(alice.address); + expect(afterBalance).to.eq(prevBalance.add(parseEther("1"))); + }); + + it("depositFor", async () => { + // alice deposit for bob + const prevBalance = await sgEth.balanceOf(bob.address); + await minter.connect(alice).depositFor(bob.address, { + value: parseEther("1"), + }); + const afterBalance = await sgEth.balanceOf(bob.address); + expect(afterBalance).to.eq(prevBalance.add(parseEther("1"))); + }); + + it("depositAndStake", async () => { + const prevStake = await wsgEth.maxRedeem(deployer.address); + const prevBalance = await sgEth.balanceOf(wsgEth.address); + await minter.depositAndStake({ + value: parseEther("1"), + }); + const afterBalance = await sgEth.balanceOf(wsgEth.address); + expect(afterBalance).to.eq(prevBalance.add(parseEther("1"))); + + const afterStake = await wsgEth.maxRedeem(deployer.address); + expect(afterStake).to.eq(prevStake.add(parseEther("1"))); + }); + + it("depositAndStakeFor", async () => { + // alice deposit and stake for bob + const prevStake = await wsgEth.maxRedeem(bob.address); + const prevBalance = await sgEth.balanceOf(wsgEth.address); + await minter.connect(alice).depositAndStakeFor(bob.address, { + value: parseEther("1"), + }); + const afterBalance = await sgEth.balanceOf(wsgEth.address); + expect(afterBalance).to.eq(prevBalance.add(parseEther("1"))); + + const afterStake = await wsgEth.maxRedeem(bob.address); + expect(afterStake).to.eq(prevStake.add(parseEther("1"))); + }); + + it("withdraw, withdrawTo", async () => { + await minter.connect(alice).deposit({ + value: parseEther("1"), + }); + await expect(minter.connect(alice).withdraw(parseEther("1.1"))).to.be.revertedWith(""); + + let prevBalance = await sgEth.balanceOf(alice.address); + await minter.connect(alice).withdraw(parseEther("0.5")); + let afterBalance = await sgEth.balanceOf(alice.address); + + expect(afterBalance).to.eq(prevBalance.sub(parseEther("0.5"))); + + prevBalance = await sgEth.balanceOf(alice.address); + await minter.connect(alice).withdrawTo(parseEther("0.5"), bob.address); + afterBalance = await sgEth.balanceOf(alice.address); + + expect(afterBalance).to.eq(prevBalance.sub(parseEther("0.5"))); + }); + + it("unstakeAndWithdraw", async () => { + await minter.connect(alice).depositAndStake({ + value: parseEther("1"), + }); + await expect(minter.connect(alice).unstakeAndWithdraw(parseEther("1.1"), alice.address)).to.be.revertedWith(""); + await expect(minter.connect(alice).unstakeAndWithdraw(parseEther("0.5"), alice.address)).to.be.revertedWith(""); + + await wsgEth.connect(alice).approve(minter.address, ethers.constants.MaxUint256); + let prevBalance = await wsgEth.balanceOf(alice.address); + await minter.connect(alice).unstakeAndWithdraw(parseEther("0.5"), alice.address); + let afterBalance = await wsgEth.balanceOf(alice.address); + + expect(afterBalance).to.eq(prevBalance.sub(parseEther("0.5"))); + }); + + it("slash", async () => { + await minter.connect(alice).depositAndStake({ + value: parseEther("10"), + }); + expect(await wsgEth.maxWithdraw(alice.address)).to.eq(parseEther("10")); + + // deposit to rewardReceiver to simulate reward + await deployer.sendTransaction({ + to: rewardsReceiver.address, + value: parseEther("1"), + }); + // sends 60% of sgEth to WSGEth contract + await rewardsReceiver.work(); + + // slash 0.1 eth from reward amount, so currently reward is 0.5 + await expect(minter.connect(multiSig).slash(parseEther("0.1"))) + .to.be.emit(sgEth, "Transfer") + .withArgs(wsgEth.address, ethers.constants.AddressZero, parseEther("0.1")); + + // max withdrawal amount is not changed yet because didn't called syncReward of WsgEth + expect(await wsgEth.maxWithdraw(alice.address)).to.eq(parseEther("10")); + + await time.increase(24 * 60 * 60); + + // deposit more to call syncReward, rewards increases by linear from this moment + await minter.connect(alice).depositAndStake({ + value: parseEther("1"), + }); + + await time.increase(24 * 60 * 60); + + // deposit more to call syncReward. can test with full reward + await minter.connect(alice).depositAndStake({ + value: parseEther("1"), + }); + + // currently withdrawal amount is 10 + 1 + 1 + 0.6 - 0.1 = 12.5 + expect(await wsgEth.maxWithdraw(alice.address)).to.eq(parseEther("12.5")); + }); + }); + + describe("access control", async () => { + it("setWithdrawalCredential", async () => { + // only NOR Role can call this function + const NOR_ROLE = await minter.NOR(); + await expect(minter.connect(alice).setWithdrawalCredential("0x")).to.be.revertedWith( + `AccessControl: account ${alice.address.toLowerCase()} is missing role ${NOR_ROLE}`, + ); + + await minter.setWithdrawalCredential("0x"); + }); + + it("slash", async () => { + // only GOV Role can call this function + const GOV_ROLE = await minter.GOV(); + await expect(minter.connect(alice).slash(parseEther("0.1"))).to.be.revertedWith( + `AccessControl: account ${alice.address.toLowerCase()} is missing role ${GOV_ROLE}`, + ); + + await expect(minter.connect(multiSig).slash(parseEther("0.1"))).to.be.revertedWith("AmountTooHigh()"); + + await minter.connect(alice).depositAndStake({ + value: parseEther("10"), + }); + await expect(minter.connect(multiSig).slash(parseEther("0.1"))) + .to.be.emit(sgEth, "Transfer") + .withArgs(wsgEth.address, ethers.constants.AddressZero, parseEther("0.1")); + }); + + it("togglePause", async () => { + // only GOV Role can call this function + const GOV_ROLE = await minter.GOV(); + await expect(minter.connect(alice).togglePause()).to.be.revertedWith( + `AccessControl: account ${alice.address.toLowerCase()} is missing role ${GOV_ROLE}`, + ); + + await expect(minter.connect(multiSig).togglePause()).to.be.emit(minter, "Paused").withArgs(multiSig.address); + }); + + it("migrateShares", async () => { + // only GOV Role can call this function + const GOV_ROLE = await minter.GOV(); + await expect(minter.connect(alice).migrateShares(parseEther("0.1"))).to.be.revertedWith( + `AccessControl: account ${alice.address.toLowerCase()} is missing role ${GOV_ROLE}`, + ); + + await minter.connect(multiSig).migrateShares(parseEther("0.1")); + }); + + it("toggleWithdrawRefund", async () => { + // only GOV Role can call this function + const GOV_ROLE = await minter.GOV(); + await expect(minter.connect(alice).toggleWithdrawRefund()).to.be.revertedWith( + `AccessControl: account ${alice.address.toLowerCase()} is missing role ${GOV_ROLE}`, + ); + + await minter.connect(multiSig).toggleWithdrawRefund(); + }); + + it("setNumValidators", async () => { + // only GOV Role can call this function + const GOV_ROLE = await minter.GOV(); + await expect(minter.connect(alice).setNumValidators(1)).to.be.revertedWith( + `AccessControl: account ${alice.address.toLowerCase()} is missing role ${GOV_ROLE}`, + ); + await expect(minter.connect(multiSig).setNumValidators(0)).to.be.revertedWith("Minimum 1 validator"); + + await minter.connect(multiSig).setNumValidators(1); + }); + }); +}); diff --git a/test/v2/core/rewardsReceiver.spec.js b/test/v2/core/rewardsReceiver.spec.js new file mode 100644 index 0000000..8538343 --- /dev/null +++ b/test/v2/core/rewardsReceiver.spec.js @@ -0,0 +1,105 @@ +const {ethers} = require("hardhat"); +const {expect} = require("chai"); +const {parseEther} = require("ethers/lib/utils"); + +describe("RewardsReceiver", () => { + let rewardsReceiver, sgEth, paymentSplitter, minter, withdrawals, wsgEth, deployer, alice, multiSig; + + 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 sgeth + const WSGETH = await ethers.getContractFactory("WSGETH"); + wsgEth = await WSGETH.deploy(sgEth.address, 24 * 60 * 60); + await wsgEth.deployed(); + + const splitterAddresses = [deployer.address, multiSig.address, wsgEth.address]; + const splitterValues = [6, 3, 31]; + + const PaymentSplitter = await ethers.getContractFactory("PaymentSplitter"); + paymentSplitter = await PaymentSplitter.deploy(splitterAddresses, splitterValues); + await paymentSplitter.deployed(); + + const rolloverVirtual = "1080000000000000000"; + const vETH2Addr = "0x898bad2774eb97cf6b94605677f43b41871410b1"; + + const Withdrawals = await ethers.getContractFactory("Withdrawals"); + withdrawals = await Withdrawals.deploy(vETH2Addr, rolloverVirtual); + await withdrawals.deployed(); + + const numValidators = 1000; + const adminFee = 0; + + // const FeeCalc = await ethers.getContractFactory("FeeCalc"); + // const feeCalc = await FeeCalc.deploy(parseEther("0.1"), parseEther("0.1")); + // await feeCalc.deployed(); + + const addresses = [ + ethers.constants.AddressZero, + // feeCalc.address, // 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"); + minter = await Minter.deploy(numValidators, adminFee, addresses); + await minter.deployed(); + + const RewardsReceiver = await ethers.getContractFactory("RewardsReceiver"); + rewardsReceiver = await RewardsReceiver.deploy(withdrawals.address, [ + sgEth.address, + wsgEth.address, + paymentSplitter.address, + minter.address, + ]); + await rewardsReceiver.deployed(); + + await sgEth.addMinter(minter.address); + }); + + it("work", async () => { + // deposit eth for test + await deployer.sendTransaction({ + to: rewardsReceiver.address, + value: parseEther("1"), + }); + + let prevBalance = await deployer.provider.getBalance(rewardsReceiver.address); + console.log(prevBalance); + await rewardsReceiver.work(); + let afterBalance = await deployer.provider.getBalance(rewardsReceiver.address); + expect(afterBalance).to.eq(prevBalance.sub(parseEther("1"))); + + await deployer.sendTransaction({ + to: rewardsReceiver.address, + value: parseEther("1"), + }); + await rewardsReceiver.flipState(); + + prevBalance = await deployer.provider.getBalance(rewardsReceiver.address); + await rewardsReceiver.work(); + afterBalance = await deployer.provider.getBalance(rewardsReceiver.address); + expect(afterBalance).to.eq(prevBalance.sub(parseEther("1"))); + }); + + it("flipState", async () => { + await expect(rewardsReceiver.connect(alice).flipState()).to.be.revertedWith("Ownable: caller is not the owner"); + + expect(await rewardsReceiver.state()).to.eq(0); + await rewardsReceiver.flipState(); + expect(await rewardsReceiver.state()).to.eq(1); + }); +}); diff --git a/test/v2/core/wsgETH.spec.js b/test/v2/core/wsgETH.spec.js new file mode 100644 index 0000000..013d7a6 --- /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("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) + 1000000; + 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==