diff --git a/mainnet-contracts/script/AccessManagerMigrations/05_GenerateValidatorTicketCalldata.s.sol b/mainnet-contracts/script/AccessManagerMigrations/05_GenerateValidatorTicketCalldata.s.sol new file mode 100644 index 00000000..eccbc293 --- /dev/null +++ b/mainnet-contracts/script/AccessManagerMigrations/05_GenerateValidatorTicketCalldata.s.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import { Script } from "forge-std/Script.sol"; +import { AccessManager } from "@openzeppelin/contracts/access/manager/AccessManager.sol"; +import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; +import { PUBLIC_ROLE, ROLE_ID_DAO, ROLE_ID_PUFETH_BURNER } from "../../script/Roles.sol"; +import { ValidatorTicket } from "../../src/ValidatorTicket.sol"; + +/** + * @title GenerateValidatorTicketCalldata + * @author Puffer Finance + * @notice Generates the call data to setup the ValidatorTicket contract access + * The returned calldata is queued and executed by the Operations Multisig + * 1. timelock.queueTransaction(address(accessManager), encodedMulticall, 1) + * 2. ... 7 days later ... + * 3. timelock.executeTransaction(address(accessManager), encodedMulticall, 1) + */ +contract GenerateValidatorTicketCalldata is Script { + function run(address validatorTicketProxy) public pure returns (bytes memory) { + bytes[] memory calldatas = new bytes[](3); + + // Public functions + bytes4[] memory vtPublicSelectors = new bytes4[](4); + vtPublicSelectors[0] = ValidatorTicket.burn.selector; + vtPublicSelectors[1] = ValidatorTicket.purchaseValidatorTicket.selector; + vtPublicSelectors[2] = ValidatorTicket.purchaseValidatorTicketWithPufETH.selector; + vtPublicSelectors[3] = ValidatorTicket.purchaseValidatorTicketWithPufETHAndPermit.selector; + calldatas[0] = abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, validatorTicketProxy, vtPublicSelectors, PUBLIC_ROLE + ); + + // DAO-restricted functions + bytes4[] memory vtDaoSelectors = new bytes4[](2); + vtDaoSelectors[0] = ValidatorTicket.setProtocolFeeRate.selector; + vtDaoSelectors[1] = ValidatorTicket.setGuardiansFeeRate.selector; + calldatas[1] = abi.encodeWithSelector( + AccessManager.setTargetFunctionRole.selector, validatorTicketProxy, vtDaoSelectors, ROLE_ID_DAO + ); + + // Grant PUFETH_BURNER role to ValidatorTicket + calldatas[2] = + abi.encodeWithSelector(AccessManager.grantRole.selector, ROLE_ID_PUFETH_BURNER, validatorTicketProxy, 0); + + bytes memory encodedMulticall = abi.encodeCall(Multicall.multicall, (calldatas)); + return encodedMulticall; + } +} diff --git a/mainnet-contracts/script/UpgradeValidatorTicket.s.sol b/mainnet-contracts/script/UpgradeValidatorTicket.s.sol new file mode 100644 index 00000000..2729a907 --- /dev/null +++ b/mainnet-contracts/script/UpgradeValidatorTicket.s.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import "forge-std/Script.sol"; +import { DeployerHelper } from "./DeployerHelper.s.sol"; +import { ValidatorTicket } from "../src/ValidatorTicket.sol"; +import { GenerateValidatorTicketCalldata } from "./AccessManagerMigrations/05_GenerateValidatorTicketCalldata.s.sol"; +import { IPufferOracle } from "../src/interface/IPufferOracle.sol"; +import { AccessManager } from "@openzeppelin/contracts/access/manager/AccessManager.sol"; + +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; + +/** + * forge script script/UpgradeValidatorTicket.s.sol:UpgradeValidatorTicket --rpc-url=$RPC_URL --private-key $PK + * add --slow if deploying to a mainnet fork like tenderly + */ +contract UpgradeValidatorTicket is DeployerHelper { + ValidatorTicket public validatorTicket; + bytes public upgradeCallData; + bytes public accessManagerCallData; + + function run() public { + GenerateValidatorTicketCalldata calldataGenerator = new GenerateValidatorTicketCalldata(); + + vm.startBroadcast(); + + ValidatorTicket validatorTicketImpl = new ValidatorTicket({ + guardianModule: payable(address(_getGuardianModule())), + treasury: payable(_getTreasury()), + pufferVault: payable(_getPufferVault()), + pufferOracle: IPufferOracle(address(_getPufferOracle())) + }); + + validatorTicket = ValidatorTicket(payable(_getValidatorTicket())); + + vm.label(address(validatorTicket), "ValidatorTicketProxy"); + vm.label(address(validatorTicketImpl), "ValidatorTicketImplementation"); + + // Upgrade on mainnet + upgradeCallData = abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (address(validatorTicketImpl), "")); + console.log("Queue TX From Timelock to -> ValidatorTicketProxy", _getValidatorTicket()); + console.logBytes(upgradeCallData); + console.log("================================================"); + accessManagerCallData = calldataGenerator.run(address(validatorTicket)); + + console.log("Queue from Timelock -> AccessManager", _getAccessManager()); + console.logBytes(accessManagerCallData); + + // If on testnet, upgrade and execute access control changes directly + if (block.chainid == holesky) { + // upgrade to implementation + AccessManager(_getAccessManager()).execute( + address(validatorTicket), + abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (address(validatorTicketImpl), "")) + ); + + // execute access control changes + (bool success,) = address(_getAccessManager()).call(accessManagerCallData); + console.log("AccessManager.call success", success); + require(success, "AccessManager.call failed"); + } + vm.stopBroadcast(); + } +} diff --git a/mainnet-contracts/src/ValidatorTicket.sol b/mainnet-contracts/src/ValidatorTicket.sol index f6c42a32..ae3faefd 100644 --- a/mainnet-contracts/src/ValidatorTicket.sol +++ b/mainnet-contracts/src/ValidatorTicket.sol @@ -10,9 +10,13 @@ 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 { Permit } from "./structs/Permit.sol"; /** * @title ValidatorTicket @@ -126,6 +130,42 @@ contract ValidatorTicket is emit DispersedETH({ treasury: treasuryAmount, guardians: guardiansAmount, vault: vaultAmount }); } + /** + * @inheritdoc IValidatorTicket + * @dev Restricted in this context is like the `whenNotPaused` modifier from Pausable.sol + */ + function purchaseValidatorTicketWithPufETH(address recipient, uint256 vtAmount) + external + virtual + restricted + returns (uint256) + { + return _processPurchaseValidatorTicketWithPufETH(recipient, vtAmount); + } + + /** + * @inheritdoc IValidatorTicket + * @dev Restricted in this context is like the `whenNotPaused` modifier from Pausable.sol + */ + function purchaseValidatorTicketWithPufETHAndPermit(address recipient, uint256 vtAmount, Permit calldata permitData) + external + virtual + restricted + returns (uint256) + { + try IERC20Permit(address(PUFFER_VAULT)).permit({ + owner: msg.sender, + spender: address(this), + value: permitData.amount, + deadline: permitData.deadline, + v: permitData.v, + r: permitData.r, + s: permitData.s + }) { } catch { } + + return _processPurchaseValidatorTicketWithPufETH(recipient, vtAmount); + } + /** * @notice Burns `amount` from the transaction sender * @dev Restricted to the PufferProtocol @@ -206,4 +246,61 @@ contract ValidatorTicket is } function _authorizeUpgrade(address newImplementation) internal virtual override restricted { } + + /** + * @dev Internal function to process the purchase of Validator Tickets with pufETH + * @param recipient The address to receive the minted VTs + * @param vtAmount The amount of Validator Tickets to purchase + * @return pufEthUsed The amount of pufETH used for the purchase + */ + function _processPurchaseValidatorTicketWithPufETH(address recipient, uint256 vtAmount) + internal + returns (uint256 pufEthUsed) + { + require(recipient != address(0), RecipientIsZeroAddress()); + + uint256 mintPrice = PUFFER_ORACLE.getValidatorTicketPrice(); + + uint256 requiredETH = vtAmount.mulDiv(mintPrice, 1 ether, Math.Rounding.Ceil); + + pufEthUsed = PufferVaultV3(PUFFER_VAULT).convertToSharesUp(requiredETH); + + IERC20(PUFFER_VAULT).transferFrom(msg.sender, address(this), pufEthUsed); + + _mint(recipient, vtAmount); + + // If we are over the burst threshold, send everything to the treasury + if (PUFFER_ORACLE.isOverBurstThreshold()) { + IERC20(PUFFER_VAULT).transfer(TREASURY, pufEthUsed); + emit DispersedPufETH({ treasury: pufEthUsed, guardians: 0, burned: 0 }); + return pufEthUsed; + } + + ValidatorTicket storage $ = _getValidatorTicketStorage(); + + uint256 treasuryAmount = _sendPufETH(TREASURY, pufEthUsed, $.protocolFeeRate); + uint256 guardiansAmount = _sendPufETH(GUARDIAN_MODULE, pufEthUsed, $.guardiansFeeRate); + uint256 burnAmount = pufEthUsed - (treasuryAmount + guardiansAmount); + + PufferVaultV3(PUFFER_VAULT).burn(burnAmount); + + emit DispersedPufETH({ treasury: treasuryAmount, guardians: guardiansAmount, burned: burnAmount }); + + return pufEthUsed; + } + + /** + * @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 c417b35c..e46f8e39 100644 --- a/mainnet-contracts/src/interface/IValidatorTicket.sol +++ b/mainnet-contracts/src/interface/IValidatorTicket.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.0 <0.9.0; import { IPufferOracle } from "../interface/IPufferOracle.sol"; +import { Permit } from "../structs/Permit.sol"; /** * @title IValidatorTicket @@ -20,6 +21,11 @@ interface IValidatorTicket { */ error InvalidData(); + /** + * @dev Thrown when the recipient address is zero + */ + error RecipientIsZeroAddress(); + /** * @notice Emitted when the ETH `amount` in wei is transferred to `to` address * @dev Signature "0xba7bb5aa419c34d8776b86cc0e9d41e72d74a893a511f361a11af6c05e920c3d" @@ -32,6 +38,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 +63,27 @@ 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 vtAmount The amount of Validator Tickets to purchase + * @return pufEthUsed The amount of pufETH used for the purchase + */ + function purchaseValidatorTicketWithPufETH(address recipient, uint256 vtAmount) + external + returns (uint256 pufEthUsed); + + /** + * @notice Purchases Validator Tickets with pufETH using permit + * @param recipient The address to receive the minted VTs + * @param vtAmount The amount of Validator Tickets to purchase + * @param permitData The permit data for the pufETH transfer + * @return pufEthUsed The amount of pufETH used for the purchase + */ + function purchaseValidatorTicketWithPufETHAndPermit(address recipient, uint256 vtAmount, Permit calldata permitData) + external + returns (uint256 pufEthUsed); + /** * @notice Retrieves the current guardians fee rate * @return The current guardians fee rate diff --git a/mainnet-contracts/test/fork-tests/ValidatorTicketMainnetTest.fork.t.sol b/mainnet-contracts/test/fork-tests/ValidatorTicketMainnetTest.fork.t.sol new file mode 100644 index 00000000..890ba51d --- /dev/null +++ b/mainnet-contracts/test/fork-tests/ValidatorTicketMainnetTest.fork.t.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import "forge-std/Test.sol"; +import { MainnetForkTestHelper } from "../MainnetForkTestHelper.sol"; +import { UpgradeValidatorTicket } from "../../script/UpgradeValidatorTicket.s.sol"; +import { ValidatorTicket } from "../../src/ValidatorTicket.sol"; +import { IValidatorTicket } from "../../src/interface/IValidatorTicket.sol"; +import { PufferVaultV3 } from "../../src/PufferVaultV3.sol"; +import { IPufferOracle } from "../../src/interface/IPufferOracle.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +contract ValidatorTicketMainnetTest is MainnetForkTestHelper { + using Math for uint256; + + address[] TOKEN_HOLDERS = [alice, bob, charlie, dave, eve]; + + ValidatorTicket public validatorTicket; + uint256 public constant INITIAL_PROTOCOL_FEE = 200; // 2% + uint256 public constant INITIAL_GUARDIANS_FEE = 50; // 0.5% + + function setUp() public override { + vm.createSelectFork(vm.rpcUrl("mainnet"), 21074115); + + // Label accounts for better trace output + for (uint256 i = 0; i < TOKEN_HOLDERS.length; i++) { + string memory name = i == 0 ? "alice" : i == 1 ? "bob" : i == 2 ? "charlie" : i == 3 ? "dave" : "eve"; + vm.label(TOKEN_HOLDERS[i], name); + } + + // Setup contracts that are deployed to mainnet + _setupLiveContracts(); + + // Deploy new implementation and get upgrade call data + UpgradeValidatorTicket upgradeScript = new UpgradeValidatorTicket(); + upgradeScript.run(); + validatorTicket = upgradeScript.validatorTicket(); + bytes memory upgradeCallData = upgradeScript.upgradeCallData(); + bytes memory accessManagerCallData = upgradeScript.accessManagerCallData(); + + // Upgrade validator ticket through timelock and execute access control changes + vm.startPrank(_getTimelock()); + (bool success,) = address(validatorTicket).call(upgradeCallData); + require(success, "Upgrade.call failed"); + (success,) = address(_getAccessManager()).call(accessManagerCallData); + require(success, "AccessManager.call failed"); + vm.stopPrank(); + } + + function test_initial_state() public { + assertEq(validatorTicket.name(), "Puffer Validator Ticket"); + assertEq(validatorTicket.symbol(), "VT"); + assertEq(validatorTicket.getProtocolFeeRate(), INITIAL_PROTOCOL_FEE); + assertEq(validatorTicket.getGuardiansFeeRate(), INITIAL_GUARDIANS_FEE); + assertTrue(address(validatorTicket.PUFFER_ORACLE()) != address(0)); + assertTrue(validatorTicket.GUARDIAN_MODULE() != address(0)); + assertTrue(validatorTicket.PUFFER_VAULT() != address(0)); + assertTrue(validatorTicket.TREASURY() != address(0)); + } + + function test_purchase_validator_ticket_with_pufeth() public { + uint256 vtAmount = 10 ether; + address recipient = alice; + + uint256 vtPrice = IPufferOracle(address(validatorTicket.PUFFER_ORACLE())).getValidatorTicketPrice(); + uint256 requiredETH = vtAmount.mulDiv(vtPrice, 1 ether, Math.Rounding.Ceil); + uint256 expectedPufEthUsed = + PufferVaultV3(payable(validatorTicket.PUFFER_VAULT())).convertToSharesUp(requiredETH); + + // Give whale some pufETH + deal(address(validatorTicket.PUFFER_VAULT()), recipient, expectedPufEthUsed * 2); + + vm.startPrank(recipient); + IERC20(validatorTicket.PUFFER_VAULT()).approve(address(validatorTicket), expectedPufEthUsed); + + uint256 pufEthUsed = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, vtAmount); + vm.stopPrank(); + + assertEq(pufEthUsed, expectedPufEthUsed, "PufETH used should match expected"); + assertEq(validatorTicket.balanceOf(recipient), vtAmount, "VT balance should match requested amount"); + } + + function test_funds_splitting_with_pufeth() public { + uint256 vtAmount = 2000 ether; + address recipient = dave; + address treasury = validatorTicket.TREASURY(); + address guardianModule = validatorTicket.GUARDIAN_MODULE(); + + uint256 vtPrice = IPufferOracle(address(validatorTicket.PUFFER_ORACLE())).getValidatorTicketPrice(); + uint256 requiredETH = vtAmount.mulDiv(vtPrice, 1 ether, Math.Rounding.Ceil); + uint256 pufEthAmount = PufferVaultV3(payable(validatorTicket.PUFFER_VAULT())).convertToSharesUp(requiredETH); + + deal(address(validatorTicket.PUFFER_VAULT()), recipient, pufEthAmount); + + uint256 initialTreasuryBalance = IERC20(validatorTicket.PUFFER_VAULT()).balanceOf(treasury); + uint256 initialGuardianBalance = IERC20(validatorTicket.PUFFER_VAULT()).balanceOf(guardianModule); + uint256 initialBurnedAmount = IERC20(validatorTicket.PUFFER_VAULT()).totalSupply(); + + vm.startPrank(recipient); + IERC20(validatorTicket.PUFFER_VAULT()).approve(address(validatorTicket), pufEthAmount); + uint256 pufEthUsed = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, vtAmount); + vm.stopPrank(); + + uint256 expectedTreasuryAmount = pufEthAmount.mulDiv(INITIAL_PROTOCOL_FEE, 10000, Math.Rounding.Ceil); + uint256 expectedGuardianAmount = pufEthAmount.mulDiv(INITIAL_GUARDIANS_FEE, 10000, Math.Rounding.Ceil); + uint256 expectedBurnAmount = pufEthAmount - expectedTreasuryAmount - expectedGuardianAmount; + + assertEq(pufEthUsed, pufEthAmount, "PufETH used should match expected"); + assertEq(validatorTicket.balanceOf(recipient), vtAmount, "Should mint requested VTs"); + assertEq( + IERC20(validatorTicket.PUFFER_VAULT()).balanceOf(treasury) - initialTreasuryBalance, + expectedTreasuryAmount, + "Treasury should receive 5% of pufETH" + ); + assertEq( + IERC20(validatorTicket.PUFFER_VAULT()).balanceOf(guardianModule) - initialGuardianBalance, + expectedGuardianAmount, + "Guardians should receive 0.5% of pufETH" + ); + assertEq( + initialBurnedAmount - IERC20(validatorTicket.PUFFER_VAULT()).totalSupply(), + expectedBurnAmount, + "Remaining pufETH should be burned" + ); + } + + function test_dao_fee_rate_changes() public { + vm.startPrank(_getDAO()); + + // Test protocol fee rate change + vm.expectEmit(true, true, true, true); + emit IValidatorTicket.ProtocolFeeChanged(INITIAL_PROTOCOL_FEE, 800); + validatorTicket.setProtocolFeeRate(800); // 8% + assertEq(validatorTicket.getProtocolFeeRate(), 800, "Protocol fee should be updated"); + + // Test guardians fee rate change + vm.expectEmit(true, true, true, true); + emit IValidatorTicket.GuardiansFeeChanged(INITIAL_GUARDIANS_FEE, 100); + validatorTicket.setGuardiansFeeRate(100); // 1% + assertEq(validatorTicket.getGuardiansFeeRate(), 100, "Guardians fee should be updated"); + + vm.stopPrank(); + } + + function test_purchase_validator_ticket_with_eth() public { + uint256 amount = 10 ether; + address recipient = alice; + + // Get initial balances + (uint256 initialTreasuryBalance, uint256 initialGuardianBalance, uint256 initialVaultBalance) = _getBalances(); + + // Purchase VTs + vm.deal(recipient, amount); + vm.prank(recipient); + uint256 mintedAmount = validatorTicket.purchaseValidatorTicket{ value: amount }(recipient); + + // Verify minted amount + uint256 expectedVTAmount = _calculateExpectedVTs(amount); + assertEq(mintedAmount, expectedVTAmount, "Minted VT amount should match expected"); + assertEq(validatorTicket.balanceOf(recipient), expectedVTAmount, "VT balance should match expected"); + + // Verify fee distributions + _verifyFeeDistribution(amount, initialTreasuryBalance, initialGuardianBalance, initialVaultBalance); + } + + function _getBalances() internal view returns (uint256, uint256, uint256) { + return ( + validatorTicket.TREASURY().balance, + validatorTicket.GUARDIAN_MODULE().balance, + validatorTicket.PUFFER_VAULT().balance + ); + } + + function _calculateExpectedVTs(uint256 amount) internal view returns (uint256) { + uint256 vtPrice = IPufferOracle(address(validatorTicket.PUFFER_ORACLE())).getValidatorTicketPrice(); + return (amount * 1 ether) / vtPrice; + } + + function _verifyFeeDistribution( + uint256 amount, + uint256 initialTreasuryBalance, + uint256 initialGuardianBalance, + uint256 initialVaultBalance + ) internal { + address treasury = validatorTicket.TREASURY(); + address guardianModule = validatorTicket.GUARDIAN_MODULE(); + address vault = validatorTicket.PUFFER_VAULT(); + + uint256 treasuryAmount = amount.mulDiv(INITIAL_PROTOCOL_FEE, 10000, Math.Rounding.Ceil); + uint256 guardianAmount = amount.mulDiv(INITIAL_GUARDIANS_FEE, 10000, Math.Rounding.Ceil); + uint256 vaultAmount = amount - treasuryAmount - guardianAmount; + + assertEq(treasury.balance - initialTreasuryBalance, treasuryAmount, "Treasury should receive correct fee"); + assertEq( + guardianModule.balance - initialGuardianBalance, guardianAmount, "Guardians should receive correct fee" + ); + assertEq(vault.balance - initialVaultBalance, vaultAmount, "Vault should receive remaining amount"); + } + + function test_purchase_validator_ticket_with_eth_over_burst_threshold() public { + uint256 amount = 10 ether; + address recipient = alice; + address treasury = validatorTicket.TREASURY(); + + // Mock oracle + vm.mockCall( + address(validatorTicket.PUFFER_ORACLE()), + abi.encodeWithSelector(IPufferOracle.isOverBurstThreshold.selector), + abi.encode(true) + ); + + uint256 initialTreasuryBalance = treasury.balance; + + // Purchase VTs + vm.deal(recipient, amount); + vm.prank(recipient); + uint256 mintedAmount = validatorTicket.purchaseValidatorTicket{ value: amount }(recipient); + + // Verify minted amount + uint256 expectedVTAmount = _calculateExpectedVTs(amount); + assertEq(mintedAmount, expectedVTAmount, "Minted VT amount should match expected"); + assertEq(validatorTicket.balanceOf(recipient), expectedVTAmount, "VT balance should match expected"); + + // Verify all ETH went to treasury + assertEq( + treasury.balance - initialTreasuryBalance, + amount, + "Treasury should receive all ETH when over burst threshold" + ); + } +} diff --git a/mainnet-contracts/test/unit/ValidatorTicket.t.sol b/mainnet-contracts/test/unit/ValidatorTicket.t.sol index aec62e1d..924bba49 100644 --- a/mainnet-contracts/test/unit/ValidatorTicket.t.sol +++ b/mainnet-contracts/test/unit/ValidatorTicket.t.sol @@ -8,14 +8,25 @@ 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"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.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; + using Math for uint256; + + address[] public actors; function setUp() public override { // Just call the parent setUp() @@ -27,6 +38,29 @@ 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[](3); + validatorTicketPublicSelectors[0] = IValidatorTicket.purchaseValidatorTicketWithPufETH.selector; + validatorTicketPublicSelectors[1] = IValidatorTicket.purchaseValidatorTicketWithPufETHAndPermit.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 +164,144 @@ contract ValidatorTicketTest is UnitTestHelper { assertEq(validatorTicket.getProtocolFeeRate(), newFeeRate, "updated"); } + + function test_purchaseValidatorTicketWithPufETH() public { + uint256 vtAmount = 10 ether; + address recipient = actors[0]; + + uint256 vtPrice = pufferOracle.getValidatorTicketPrice(); + uint256 requiredETH = vtAmount.mulDiv(vtPrice, 1 ether, Math.Rounding.Ceil); + + uint256 expectedPufEthUsed = pufferVault.convertToSharesUp(requiredETH); + + _givePufETH(expectedPufEthUsed, recipient); + + vm.startPrank(recipient); + pufferVault.approve(address(validatorTicket), expectedPufEthUsed); + + uint256 pufEthUsed = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, vtAmount); + vm.stopPrank(); + + assertEq(pufEthUsed, expectedPufEthUsed, "PufETH used should match expected"); + assertEq(validatorTicket.balanceOf(recipient), vtAmount, "VT balance should match requested amount"); + } + + function test_purchaseValidatorTicketWithPufETH_exchangeRateChange() public { + uint256 vtAmount = 10 ether; + address recipient = actors[2]; + + uint256 exchangeRate = pufferVault.convertToAssets(1 ether); + assertEq(exchangeRate, 1 ether, "1:1 exchange rate"); + + // 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 requiredETH = vtAmount.mulDiv(vtPrice, 1 ether, Math.Rounding.Ceil); + + uint256 pufEthAmount = pufferVault.convertToSharesUp(requiredETH); + + _givePufETH(pufEthAmount, recipient); + + vm.startPrank(recipient); + pufferVault.approve(address(validatorTicket), pufEthAmount); + uint256 pufEthUsed = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, vtAmount); + vm.stopPrank(); + + assertEq(pufEthUsed, pufEthAmount, "PufETH used should match expected"); + assertEq(validatorTicket.balanceOf(recipient), vtAmount, "VT balance should match requested amount"); + } + + function test_purchaseValidatorTicketWithPufETHAndPermit() public { + uint256 vtAmount = 10 ether; + address recipient = actors[2]; + + uint256 vtPrice = pufferOracle.getValidatorTicketPrice(); + uint256 requiredETH = vtAmount * vtPrice / 1 ether; + + uint256 pufETHToETHExchangeRate = pufferVault.convertToAssets(1 ether); + uint256 expectedPufEthUsed = (requiredETH * 1 ether) / pufETHToETHExchangeRate; + + _givePufETH(expectedPufEthUsed, recipient); + + // Create a permit + Permit memory permit = _signPermit( + _testTemps("charlie", address(validatorTicket), expectedPufEthUsed, block.timestamp), + pufferVault.DOMAIN_SEPARATOR() + ); + + vm.prank(recipient); + uint256 pufEthUsed = validatorTicket.purchaseValidatorTicketWithPufETHAndPermit(recipient, vtAmount, permit); + + assertEq(pufEthUsed, expectedPufEthUsed, "PufETH used should match expected"); + assertEq(validatorTicket.balanceOf(recipient), vtAmount, "VT balance should match requested amount"); + } + + function _givePufETH(uint256 pufEthAmount, address recipient) internal { + deal(address(pufferVault), recipient, 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 vtAmount = 2000 ether; // Want to mint 2000 VTs + address recipient = actors[0]; + address treasury = validatorTicket.TREASURY(); + + uint256 vtPrice = pufferOracle.getValidatorTicketPrice(); + uint256 requiredETH = vtAmount.mulDiv(vtPrice, 1 ether, Math.Rounding.Ceil); + + uint256 pufEthAmount = pufferVault.convertToSharesUp(requiredETH); + + _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 pufEthUsed = validatorTicket.purchaseValidatorTicketWithPufETH(recipient, vtAmount); + vm.stopPrank(); + + assertEq(pufEthUsed, pufEthAmount, "PufETH used should match expected"); + assertEq(validatorTicket.balanceOf(recipient), vtAmount, "Should mint requested VTs"); + + uint256 expectedTreasuryAmount = pufEthAmount.mulDiv(500, 10000, Math.Rounding.Ceil); // 5% to treasury + uint256 expectedGuardianAmount = pufEthAmount.mulDiv(50, 10000, Math.Rounding.Ceil); // 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" + ); + } + + function test_revert_zero_recipient() public { + uint256 vtAmount = 10 ether; + + vm.expectRevert(IValidatorTicket.RecipientIsZeroAddress.selector); + validatorTicket.purchaseValidatorTicketWithPufETH(address(0), vtAmount); + + Permit memory permit = _signPermit( + _testTemps("charlie", address(validatorTicket), vtAmount, block.timestamp), pufferVault.DOMAIN_SEPARATOR() + ); + + vm.expectRevert(IValidatorTicket.RecipientIsZeroAddress.selector); + validatorTicket.purchaseValidatorTicketWithPufETHAndPermit(address(0), vtAmount, permit); + } }