From 34ac49623a06904ca8e31a2571fbca1cf33c010e Mon Sep 17 00:00:00 2001 From: ksatyarth2 Date: Fri, 25 Oct 2024 19:48:16 +0530 Subject: [PATCH] feat: add vt purchase with pufETH functions and basic tests --- mainnet-contracts/src/ValidatorTicket.sol | 91 ++++++++++ .../src/interface/IValidatorTicket.sol | 13 ++ .../test/unit/ValidatorTicket.t.sol | 159 +++++++++++++++++- 3 files changed, 262 insertions(+), 1 deletion(-) diff --git a/mainnet-contracts/src/ValidatorTicket.sol b/mainnet-contracts/src/ValidatorTicket.sol index f6c42a3..4315536 100644 --- a/mainnet-contracts/src/ValidatorTicket.sol +++ b/mainnet-contracts/src/ValidatorTicket.sol @@ -10,9 +10,14 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { ValidatorTicketStorage } from "./ValidatorTicketStorage.sol"; import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { PufferVaultV3 } from "./PufferVaultV3.sol"; import { IPufferOracle } from "./interface/IPufferOracle.sol"; import { IValidatorTicket } from "./interface/IValidatorTicket.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import { IPufferVault } from "./interface/IPufferVault.sol"; +import { Permit } from "./structs/Permit.sol"; /** * @title ValidatorTicket @@ -206,4 +211,90 @@ contract ValidatorTicket is } function _authorizeUpgrade(address newImplementation) internal virtual override restricted { } + + /** + * @notice Purchases Validator Tickets with pufETH + * @param recipient The address to receive the minted VTs + * @param pufEthAmount The amount of pufETH to spend + * @return mintedAmount The amount of VTs minted + */ + function purchaseValidatorTicketWithPufETH(address recipient, uint256 pufEthAmount) + external + virtual + restricted + returns (uint256 mintedAmount) + { + return _processPurchaseValidatorTicketWithPufETH(recipient, pufEthAmount); + } + + /** + * @notice Purchases Validator Tickets with pufETH using permit + * @param recipient The address to receive the minted VTs + * @param permitData The permit data for the pufETH transfer + * @return mintedAmount The amount of VTs minted + */ + function purchaseValidatorTicketWithPufETHAndPermit(address recipient, Permit calldata permitData) + external + virtual + restricted + returns (uint256 mintedAmount) + { + try IERC20Permit(PUFFER_VAULT).permit( + msg.sender, address(this), permitData.amount, permitData.deadline, permitData.v, permitData.r, permitData.s + ) { } catch { } + + return _processPurchaseValidatorTicketWithPufETH(recipient, permitData.amount); + } + + /** + * @dev Processes the purchase of Validator Tickets with pufETH + * @param recipient The address to receive the minted VTs + * @param pufEthAmount The amount of pufETH to spend + * @return mintedAmount The amount of VTs minted + */ + function _processPurchaseValidatorTicketWithPufETH(address recipient, uint256 pufEthAmount) + private + returns (uint256 mintedAmount) + { + IERC20(PUFFER_VAULT).transferFrom(msg.sender, address(this), pufEthAmount); + ValidatorTicket storage $ = _getValidatorTicketStorage(); + + uint256 pufETHToETHExchangeRate = PufferVaultV3(PUFFER_VAULT).convertToAssets(1 ether); + uint256 expectedETHAmount = pufEthAmount * pufETHToETHExchangeRate / 1 ether; + + uint256 mintPrice = PUFFER_ORACLE.getValidatorTicketPrice(); + mintedAmount = (expectedETHAmount * 1 ether) / mintPrice; // * 1 ether is to upscale amount to 18 decimals + + _mint(recipient, mintedAmount); + + // If we are over the burst threshold, send everything to the treasury + if (PUFFER_ORACLE.isOverBurstThreshold()) { + IERC20(PUFFER_VAULT).transfer(TREASURY, pufEthAmount); + emit DispersedPufETH({ treasury: pufEthAmount, guardians: 0, burned: 0 }); + return mintedAmount; + } + + uint256 treasuryAmount = _sendPufETH(TREASURY, pufEthAmount, $.protocolFeeRate); + uint256 guardiansAmount = _sendPufETH(GUARDIAN_MODULE, pufEthAmount, $.guardiansFeeRate); + uint256 burnAmount = pufEthAmount - (treasuryAmount + guardiansAmount); + + PufferVaultV3(PUFFER_VAULT).burn(burnAmount); + + emit DispersedPufETH({ treasury: treasuryAmount, guardians: guardiansAmount, burned: burnAmount }); + } + + /** + * @dev Calculates the amount of pufETH to send and sends it to the recipient + * @param to The recipient address + * @param amount The total amount of pufETH + * @param rate The fee rate in basis points + * @return toSend The amount of pufETH sent + */ + function _sendPufETH(address to, uint256 amount, uint256 rate) internal virtual returns (uint256 toSend) { + toSend = amount.mulDiv(rate, _BASIS_POINT_SCALE, Math.Rounding.Ceil); + + if (toSend != 0) { + IERC20(PUFFER_VAULT).transfer(to, toSend); + } + } } diff --git a/mainnet-contracts/src/interface/IValidatorTicket.sol b/mainnet-contracts/src/interface/IValidatorTicket.sol index c417b35..efa4d81 100644 --- a/mainnet-contracts/src/interface/IValidatorTicket.sol +++ b/mainnet-contracts/src/interface/IValidatorTicket.sol @@ -32,6 +32,11 @@ interface IValidatorTicket { */ event DispersedETH(uint256 treasury, uint256 guardians, uint256 vault); + /** + * @notice Emitted when the pufETH is split between treasury, guardians and the amount burned + */ + event DispersedPufETH(uint256 treasury, uint256 guardians, uint256 burned); + /** * @notice Emitted when the protocol fee rate is changed * @dev Signature "0xb51bef650ff5ad43303dbe2e500a74d4fd1bdc9ae05f046bece330e82ae0ba87" @@ -52,6 +57,14 @@ interface IValidatorTicket { */ function purchaseValidatorTicket(address recipient) external payable returns (uint256); + /** + * @notice Purchases Validator Tickets with pufETH + * @param recipient The address to receive the minted VTs + * @param pufEthAmount The amount of pufETH to spend + * @return mintedAmount The amount of VTs minted + */ + function purchaseValidatorTicketWithPufETH(address recipient, uint256 pufEthAmount) external returns (uint256); + /** * @notice Retrieves the current guardians fee rate * @return The current guardians fee rate diff --git a/mainnet-contracts/test/unit/ValidatorTicket.t.sol b/mainnet-contracts/test/unit/ValidatorTicket.t.sol index aec62e1..f76ccbc 100644 --- a/mainnet-contracts/test/unit/ValidatorTicket.t.sol +++ b/mainnet-contracts/test/unit/ValidatorTicket.t.sol @@ -8,15 +8,24 @@ import { ValidatorTicket } from "../../src/ValidatorTicket.sol"; import { IValidatorTicket } from "../../src/interface/IValidatorTicket.sol"; import { PufferOracle } from "../../src/PufferOracle.sol"; import { PufferOracleV2 } from "../../src/PufferOracleV2.sol"; - +import { IPufferVault } from "../../src/interface/IPufferVault.sol"; +import { PufferVaultV2 } from "../../src/PufferVaultV2.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import { PUBLIC_ROLE, ROLE_ID_PUFETH_BURNER, ROLE_ID_VAULT_WITHDRAWER } from "../../script/Roles.sol"; +import { Permit } from "../../src/structs/Permit.sol"; +import "forge-std/console.sol"; /** * @dev This test is for the ValidatorTicket smart contract with `src/PufferOracle.sol` */ + contract ValidatorTicketTest is UnitTestHelper { using ECDSA for bytes32; using Address for address; using Address for address payable; + address[] public actors; + function setUp() public override { // Just call the parent setUp() super.setUp(); @@ -27,6 +36,28 @@ contract ValidatorTicketTest is UnitTestHelper { // In the initial deployment, the PufferOracle will supply that information pufferOracle = PufferOracleV2(address(new PufferOracle(address(accessManager)))); _skipDefaultFuzzAddresses(); + // Grant the ValidatorTicket contract the ROLE_ID_PUFETH_BURNER role + vm.startPrank(_broadcaster); + vm.label(address(validatorTicket), "ValidatorTicket"); + console.log("validatorTicket", address(validatorTicket)); + + bytes4[] memory burnerSelectors = new bytes4[](1); + burnerSelectors[0] = PufferVaultV2.burn.selector; + accessManager.setTargetFunctionRole(address(pufferVault), burnerSelectors, ROLE_ID_PUFETH_BURNER); + + bytes4[] memory validatorTicketPublicSelectors = new bytes4[](1); + validatorTicketPublicSelectors[0] = IValidatorTicket.purchaseValidatorTicketWithPufETH.selector; + + accessManager.setTargetFunctionRole(address(validatorTicket), validatorTicketPublicSelectors, PUBLIC_ROLE); + accessManager.grantRole(ROLE_ID_PUFETH_BURNER, address(validatorTicket), 0); + vm.stopPrank(); + + // Initialize actors + actors.push(alice); + actors.push(bob); + actors.push(charlie); + actors.push(dianna); + actors.push(ema); } function test_setup() public view { @@ -130,4 +161,130 @@ contract ValidatorTicketTest is UnitTestHelper { assertEq(validatorTicket.getProtocolFeeRate(), newFeeRate, "updated"); } + + function test_purchaseValidatorTicketWithPufETH() public { + uint256 pufEthAmount = 10 ether; + address recipient = actors[0]; + + uint256 exchangeRate = pufferVault.convertToAssets(1 ether); + + uint256 vtPrice = pufferOracle.getValidatorTicketPrice(); + uint256 expectedMintedAmount = (pufEthAmount * exchangeRate) / vtPrice; + + _givePufETH(pufEthAmount, recipient); + + vm.startPrank(recipient); + + pufferVault.approve(address(validatorTicket), pufEthAmount); + + uint256 mintedAmount = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, pufEthAmount); + vm.stopPrank(); + + assertEq(mintedAmount, expectedMintedAmount, "Minted amount should match expected"); + } + + function test_purchaseValidatorTicketWithPufETH_exchangeRateChange() public { + uint256 pufEthAmount = 10 ether; + address recipient = actors[1]; + + uint256 exchangeRate = pufferVault.convertToAssets(1 ether); + assertEq(exchangeRate, 1 ether, "1:1 exchange rate"); + + _givePufETH(pufEthAmount, recipient); + + // Simulate + 10% increase in ETH + deal(address(pufferVault), 1110 ether); + exchangeRate = pufferVault.convertToAssets(1 ether); + assertGt(exchangeRate, 1 ether, "Now exchange rate should be greater than 1"); + + uint256 vtPrice = pufferOracle.getValidatorTicketPrice(); + uint256 expectedMintedAmount = (pufEthAmount * exchangeRate) / vtPrice; + + vm.startPrank(recipient); + + pufferVault.approve(address(validatorTicket), pufEthAmount); + + uint256 mintedAmount = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, pufEthAmount); + vm.stopPrank(); + + assertEq(mintedAmount, expectedMintedAmount, "Minted amount should match expected"); + } + + function test_purchaseValidatorTicketWithPufETHAndPermit() public { + uint256 pufEthAmount = 10 ether; + address recipient = actors[2]; + + uint256 exchangeRate = pufferVault.convertToAssets(1 ether); + uint256 vtPrice = pufferOracle.getValidatorTicketPrice(); + uint256 expectedMintedAmount = (pufEthAmount * exchangeRate) / vtPrice; + + _givePufETH(pufEthAmount, recipient); + + // Create a permit + Permit memory permit = _signPermit( + _testTemps("charlie", address(validatorTicket), pufEthAmount, block.timestamp), + pufferVault.DOMAIN_SEPARATOR() + ); + + vm.prank(recipient); + uint256 mintedAmount = validatorTicket.purchaseValidatorTicketWithPufETHAndPermit(recipient, permit); + + assertEq(mintedAmount, expectedMintedAmount, "Minted amount should match expected"); + } + + function _givePufETH(uint256 ethAmount, address recipient) internal returns (uint256) { + vm.deal(address(this), ethAmount); + + vm.startPrank(address(this)); + uint256 pufETHAmount = pufferVault.depositETH{ value: ethAmount }(recipient); + vm.stopPrank(); + + return pufETHAmount; + } + + function _signPermit(bytes32 structHash, bytes32 domainSeparator) internal view returns (Permit memory permit) { + // TODO: Implement signing logic here + permit = Permit({ amount: 10 ether, deadline: block.timestamp + 1 hours, v: 27, r: bytes32(0), s: bytes32(0) }); + } + + function test_funds_splitting_with_pufETH() public { + uint256 vtPrice = pufferOracle.getValidatorTicketPrice(); + uint256 pufEthAmount = vtPrice * 2000; // 2000 VTs worth of pufETH + + address recipient = actors[0]; + address treasury = validatorTicket.TREASURY(); + + _givePufETH(pufEthAmount, recipient); + + uint256 initialTreasuryBalance = pufferVault.balanceOf(treasury); + uint256 initialGuardianBalance = pufferVault.balanceOf(address(guardianModule)); + uint256 initialBurnedAmount = pufferVault.totalSupply(); + + vm.startPrank(recipient); + + pufferVault.approve(address(validatorTicket), pufEthAmount); + + uint256 mintedAmount = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, pufEthAmount); + vm.stopPrank(); + + assertEq(mintedAmount, 2000 ether, "Should mint 2000 VTs"); + + uint256 expectedTreasuryAmount = (pufEthAmount * 500) / 10000; // 5% to treasury + uint256 expectedGuardianAmount = (pufEthAmount * 50) / 10000; // 0.5% to guardians + uint256 expectedBurnAmount = pufEthAmount - expectedTreasuryAmount - expectedGuardianAmount; + + assertEq( + pufferVault.balanceOf(treasury) - initialTreasuryBalance, + expectedTreasuryAmount, + "Treasury should receive 5% of pufETH" + ); + assertEq( + pufferVault.balanceOf(address(guardianModule)) - initialGuardianBalance, + expectedGuardianAmount, + "Guardians should receive 0.5% of pufETH" + ); + assertEq( + initialBurnedAmount - pufferVault.totalSupply(), expectedBurnAmount, "Remaining pufETH should be burned" + ); + } }