From bd0a06fb6e4bee65367b8faa4c214e3e93730223 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:54:26 -0700 Subject: [PATCH 1/4] fix: correct handling of non-18 decimal reward tokens (#35) * Feat/mapping comment update (#18) * feat: update mapping comments * feat: clean comment * Update contracts/PointTokenVault.sol Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> --------- Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> * fix: ensure reward fees are only transferred if the reward token is set, sherlock 185 * fix: correct handling of non-18 decimal reward tokens * fix: revert rewards collection change from different branch * fix: revert rewards collection test change from different branch * fix: correct reversion commit --------- Co-authored-by: Steven Valeri --- contracts/PointTokenVault.sol | 28 +++++++----- contracts/test/PointTokenVault.t.sol | 65 ++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/contracts/PointTokenVault.sol b/contracts/PointTokenVault.sol index 991b462..da162e9 100644 --- a/contracts/PointTokenVault.sol +++ b/contracts/PointTokenVault.sol @@ -32,8 +32,8 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall // Merkle root distribution. bytes32 public currRoot; bytes32 public prevRoot; - mapping(address => mapping(bytes32 => uint256)) public claimedPTokens; // user => pointsId => claimed - mapping(address => mapping(bytes32 => uint256)) public claimedRedemptionRights; // user => pointsId => claimed + mapping(address => mapping(bytes32 => uint256)) public claimedPTokens; // user => pointsId => PTokens claimed + mapping(address => mapping(bytes32 => uint256)) public claimedRedemptionRights; // user => pointsId => Rewards redeemed mapping(bytes32 => PToken) public pTokens; // pointsId => pTokens @@ -188,7 +188,9 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall _verifyClaimAndUpdateClaimed(_claim, claimHash, msg.sender, claimedRedemptionRights); } - uint256 pTokensToBurn = FixedPointMathLib.divWadUp(amountToClaim, rewardsPerPToken); + uint256 scalingFactor = 10 ** (18 - rewardToken.decimals()); + uint256 pTokensToBurn = FixedPointMathLib.divWadUp(amountToClaim * scalingFactor, rewardsPerPToken); + pTokens[pointsId].burn(msg.sender, pTokensToBurn); uint256 claimed = claimedPTokens[msg.sender][pointsId]; @@ -205,12 +207,17 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall rewardsToTransfer = amountToClaim; feelesslyRedeemedPTokens[msg.sender][pointsId] += pTokensToBurn; } else { - // If some or all of the pTokens need to be charged a fee. - uint256 redeemableWithFee = pTokensToBurn - feelesslyRedeemable; - // fee = amount of pTokens that are not feeless * rewardsPerPToken * redemptionFee - fee = FixedPointMathLib.mulWadUp( - FixedPointMathLib.mulWadUp(redeemableWithFee, rewardsPerPToken), redemptionFee - ); + // Calculate the fee. Scope avoids stack too deep errors. + { + // If some or all of the pTokens need to be charged a fee. + uint256 redeemableWithFee = pTokensToBurn - feelesslyRedeemable; + // fee = amount of pTokens that are not feeless * rewardsPerPToken * redemptionFee + fee = FixedPointMathLib.mulWadUp( + FixedPointMathLib.mulWadUp(redeemableWithFee, rewardsPerPToken), redemptionFee + ); + + fee = fee / scalingFactor; // Downscale to reward token decimals. + } rewardTokenFeeAcc[pointsId] += fee; rewardsToTransfer = amountToClaim - fee; @@ -241,7 +248,8 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall rewardToken.safeTransferFrom(msg.sender, address(this), _amountToConvert); - uint256 pTokensToMint = FixedPointMathLib.divWadDown(_amountToConvert, rewardsPerPToken); // Round down for mint. + uint256 scalingFactor = 10 ** (18 - rewardToken.decimals()); + uint256 pTokensToMint = FixedPointMathLib.divWadDown(_amountToConvert * scalingFactor, rewardsPerPToken); // Dust guard. if (pTokensToMint == 0) { diff --git a/contracts/test/PointTokenVault.t.sol b/contracts/test/PointTokenVault.t.sol index f870d6f..f8dfba6 100644 --- a/contracts/test/PointTokenVault.t.sol +++ b/contracts/test/PointTokenVault.t.sol @@ -5,17 +5,13 @@ import {Test, console} from "forge-std/Test.sol"; import {PointTokenVault} from "../PointTokenVault.sol"; import {PToken} from "../PToken.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {ERC1967Utils} from "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Utils.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {MockERC20, ERC20} from "solmate/test/utils/mocks/MockERC20.sol"; import {LibString} from "solady/utils/LibString.sol"; -import {OwnableUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; - import {PointTokenVaultScripts} from "../script/PointTokenVault.s.sol"; contract PointTokenVaultTest is Test { @@ -409,6 +405,31 @@ contract PointTokenVaultTest is Test { assertEq(pointTokenVault.pTokens(eigenPointsId).balanceOf(vitalik), 1e18 - 1); } + function test_RedeemRewardsWith6DecimalToken() public { + // Setup a mock 6-decimal token (like USDC) + MockERC20 usdcReward = new MockERC20("USDC Reward", "USDC", 6); + + // Mint 1,000,000 USDC to the vault + usdcReward.mint(address(pointTokenVault), 1_000_000 * 1e6); + + // Set redemption parameters (1 pToken = 1 USDC) + vm.prank(operator); + pointTokenVault.setRedemption(eigenPointsId, usdcReward, 1e18, false); + + // Mint 1 pToken to vitalik + vm.startPrank(address(pointTokenVault)); + pointTokenVault.pTokens(eigenPointsId).mint(vitalik, 1e18); + vm.stopPrank(); + + // Vitalik redeems 1 pToken for 1 USDC + vm.prank(vitalik); + pointTokenVault.redeemRewards(PointTokenVault.Claim(eigenPointsId, 1e6, 1e6, new bytes32[](0)), vitalik); + + // Check balances + assertEq(usdcReward.balanceOf(vitalik), 1e6, "Vitalik should receive 1 USDC"); + assertEq(pointTokenVault.pTokens(eigenPointsId).balanceOf(vitalik), 0, "Vitalik should have 0 pTokens left"); + } + event RewardsClaimed( address indexed owner, address indexed receiver, bytes32 indexed pointsId, uint256 amount, uint256 tax ); @@ -519,7 +540,7 @@ contract PointTokenVaultTest is Test { event RewardsConverted(address indexed owner, address indexed receiver, bytes32 indexed pointsId, uint256 amount); - function test_MintPTokensForRewards() public { + function test_ConvertRewardsToPTokens() public { bytes32 root = 0x4e40a10ce33f33a4786960a8bb843fe0e170b651acd83da27abc97176c4bed3c; bytes32[] memory proof = new bytes32[](1); @@ -568,17 +589,43 @@ contract PointTokenVaultTest is Test { assertEq(pointTokenVault.pTokens(eigenPointsId).balanceOf(vitalik), 0); } + function test_ConvertRewardsToPTokensWith6DecimalToken() public { + // Setup a mock 6-decimal token (like USDC) + MockERC20 usdcReward = new MockERC20("USDC Reward", "USDC", 6); + + // Mint 1,000,000 USDC to vitalik + usdcReward.mint(vitalik, 1_000_000 * 1e6); + + // Set redemption parameters (1 pToken = 1 USDC) + vm.prank(operator); + pointTokenVault.setRedemption(eigenPointsId, usdcReward, 1e18, false); + + // Approve USDC spend + vm.prank(vitalik); + usdcReward.approve(address(pointTokenVault), type(uint256).max); + + // Vitalik converts 1 USDC to 1 pToken + vm.prank(vitalik); + pointTokenVault.convertRewardsToPTokens(vitalik, eigenPointsId, 1e6); + + // Check balances + assertEq(usdcReward.balanceOf(vitalik), 999_999 * 1e6, "Vitalik should have 999,999 USDC left"); + assertEq(pointTokenVault.pTokens(eigenPointsId).balanceOf(vitalik), 1e18, "Vitalik should receive 1 pToken"); + } + event FeeCollectorSet(address feeCollector); - + function test_setFeeCollector() public { vm.prank(admin); - vm.expectEmit(true,true,true,true); + vm.expectEmit(true, true, true, true); emit FeeCollectorSet(toly); pointTokenVault.setFeeCollector(toly); vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, address(vitalik), pointTokenVault.DEFAULT_ADMIN_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(vitalik), + pointTokenVault.DEFAULT_ADMIN_ROLE() ) ); vm.prank(vitalik); From 64158736c8f0ac2b47489949b2bd2badb733066d Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:54:40 -0700 Subject: [PATCH 2/4] fix: ensure reward fees are only transferred if the reward token is set (#36) * Feat/mapping comment update (#18) * feat: update mapping comments * feat: clean comment * Update contracts/PointTokenVault.sol Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> --------- Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> * fix: ensure reward fees are only transferred if the reward token is set * refactor: don't revert on collect fees if the reward token is unset, just skip the transfer --------- Co-authored-by: Steven Valeri --- contracts/PointTokenVault.sol | 16 ++++++++++------ contracts/test/PointTokenVault.t.sol | 20 +++++++++++++++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/contracts/PointTokenVault.sol b/contracts/PointTokenVault.sol index da162e9..bc37858 100644 --- a/contracts/PointTokenVault.sol +++ b/contracts/PointTokenVault.sol @@ -89,7 +89,7 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall error ProofInvalidOrExpired(); error ClaimTooLarge(); - error RewardsNotReleased(); + error RewardsNotLive(); error CantConvertMerkleRedemption(); error PTokenAlreadyDeployed(); error DepositExceedsCap(); @@ -177,7 +177,7 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall (params.rewardToken, params.rewardsPerPToken, params.isMerkleBased); if (address(rewardToken) == address(0)) { - revert RewardsNotReleased(); + revert RewardsNotLive(); } if (isMerkleBased) { @@ -239,7 +239,7 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall (params.rewardToken, params.rewardsPerPToken, params.isMerkleBased); if (address(rewardToken) == address(0)) { - revert RewardsNotReleased(); + revert RewardsNotLive(); } if (isMerkleBased) { @@ -357,9 +357,13 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall } if (rewardTokenFee > 0) { - // There will only be a positive rewardTokenFee if there are reward tokens in this contract available for transfer. - redemptions[_pointsId].rewardToken.safeTransfer(feeCollector, rewardTokenFee); - rewardTokenFeeAcc[_pointsId] = 0; + ERC20 rewardToken = redemptions[_pointsId].rewardToken; + if (address(rewardToken) != address(0)) { + rewardToken.safeTransfer(feeCollector, rewardTokenFee); + rewardTokenFeeAcc[_pointsId] = 0; + } else { + rewardTokenFee = 0; // Do not collect reward token fees if the reward token is unset. + } } emit FeesCollected(_pointsId, feeCollector, pTokenFee, rewardTokenFee); diff --git a/contracts/test/PointTokenVault.t.sol b/contracts/test/PointTokenVault.t.sol index f8dfba6..0e4f58d 100644 --- a/contracts/test/PointTokenVault.t.sol +++ b/contracts/test/PointTokenVault.t.sol @@ -556,9 +556,9 @@ contract PointTokenVaultTest is Test { // Cannot redeem pTokens or convert rewards before redemption data is set bytes32[] memory empty = new bytes32[](0); - vm.expectRevert(PointTokenVault.RewardsNotReleased.selector); + vm.expectRevert(PointTokenVault.RewardsNotLive.selector); pointTokenVault.redeemRewards(PointTokenVault.Claim(eigenPointsId, 2e18, 2e18, empty), vitalik); - vm.expectRevert(PointTokenVault.RewardsNotReleased.selector); + vm.expectRevert(PointTokenVault.RewardsNotLive.selector); pointTokenVault.convertRewardsToPTokens(vitalik, eigenPointsId, 1e18); vm.prank(operator); @@ -722,9 +722,23 @@ contract PointTokenVaultTest is Test { vm.prank(toly); pointTokenVault.redeemRewards(PointTokenVault.Claim(eigenPointsId, 1.8e18, 1.8e18, empty), toly); + // Unset redemption + vm.prank(operator); + pointTokenVault.setRedemption(eigenPointsId, ERC20(address(0)), 0, false); + + // No reward token fees are collected. + vm.expectEmit(true, true, true, true); + emit FeesCollected(eigenPointsId, pointTokenVault.feeCollector(), 0.1e18, 0); + pointTokenVault.collectFees(eigenPointsId); + assertEq(rewardToken.balanceOf(pointTokenVault.feeCollector()), 0); + + // Set redemption again + vm.prank(operator); + pointTokenVault.setRedemption(eigenPointsId, rewardToken, 2e18, false); + // Collect fees vm.expectEmit(true, true, true, true); - emit FeesCollected(eigenPointsId, pointTokenVault.feeCollector(), 0.1e18, 0.09e18); + emit FeesCollected(eigenPointsId, pointTokenVault.feeCollector(), 0, 0.09e18); pointTokenVault.collectFees(eigenPointsId); // Check balances after fee collection From e2852448a423f2e35c288d28410bcc107bb3b972 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:54:57 -0700 Subject: [PATCH 3/4] chore: trusted claimer -> trusted receiver (#37) * Feat/mapping comment update (#18) * feat: update mapping comments * feat: clean comment * Update contracts/PointTokenVault.sol Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> --------- Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> * chore: trusted claimer -> trusted receiver --------- Co-authored-by: Steven Valeri --- contracts/PointTokenVault.sol | 16 ++++++++-------- contracts/test/PointTokenVault.t.sol | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/contracts/PointTokenVault.sol b/contracts/PointTokenVault.sol index bc37858..6f65b9a 100644 --- a/contracts/PointTokenVault.sol +++ b/contracts/PointTokenVault.sol @@ -41,7 +41,7 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall mapping(address => uint256) public caps; // asset => deposit cap - mapping(address => mapping(address => bool)) public trustedClaimers; // owner => delegate => trustedClaimers + mapping(address => mapping(address => bool)) public trustedReceivers; // owner => delegate => trustedReceivers // Fees uint256 public mintFee; @@ -66,7 +66,7 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall event Deposit(address indexed depositor, address indexed receiver, address indexed token, uint256 amount); event Withdraw(address indexed withdrawer, address indexed receiver, address indexed token, uint256 amount); - event TrustClaimer(address indexed owner, address indexed delegate, bool isTrusted); + event TrustReceiver(address indexed owner, address indexed delegate, bool isTrusted); event RootUpdated(bytes32 prevRoot, bytes32 newRoot); event PTokensClaimed( address indexed account, address indexed receiver, bytes32 indexed pointsId, uint256 amount, uint256 fee @@ -95,7 +95,7 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall error DepositExceedsCap(); error PTokenNotDeployed(); error AmountTooSmall(); - error NotTrustedClaimer(); + error NotTrustedReceiver(); /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -149,8 +149,8 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall revert PTokenNotDeployed(); } - if (_account != _receiver && !trustedClaimers[_account][_receiver]) { - revert NotTrustedClaimer(); + if (_account != _receiver && !trustedReceivers[_account][_receiver]) { + revert NotTrustedReceiver(); } uint256 pTokenFee = FixedPointMathLib.mulWadUp(_claim.amountToClaim, mintFee); @@ -161,9 +161,9 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall emit PTokensClaimed(_account, _receiver, pointsId, _claim.amountToClaim, pTokenFee); } - function trustClaimer(address _account, bool _isTrusted) public { - trustedClaimers[msg.sender][_account] = _isTrusted; - emit TrustClaimer(msg.sender, _account, _isTrusted); + function trustReceiver(address _account, bool _isTrusted) public { + trustedReceivers[msg.sender][_account] = _isTrusted; + emit TrustReceiver(msg.sender, _account, _isTrusted); } /// @notice Redeems point tokens for rewards diff --git a/contracts/test/PointTokenVault.t.sol b/contracts/test/PointTokenVault.t.sol index 0e4f58d..51191dd 100644 --- a/contracts/test/PointTokenVault.t.sol +++ b/contracts/test/PointTokenVault.t.sol @@ -503,7 +503,7 @@ contract PointTokenVaultTest is Test { assertEq(pointTokenVault.pTokens(eigenPointsId).balanceOf(vitalik), 1e18); } - function test_TrustedClaimer() public { + function test_TrustedReceiver() public { bytes32 root = 0x4e40a10ce33f33a4786960a8bb843fe0e170b651acd83da27abc97176c4bed3c; bytes32[] memory proof = new bytes32[](1); @@ -514,12 +514,12 @@ contract PointTokenVaultTest is Test { // Toly tries to claim vitalik's pTokens (should fail) vm.prank(toly); - vm.expectRevert(PointTokenVault.NotTrustedClaimer.selector); + vm.expectRevert(PointTokenVault.NotTrustedReceiver.selector); pointTokenVault.claimPTokens(PointTokenVault.Claim(eigenPointsId, 1e18, 0.6e18, proof), vitalik, toly); // Vitalik delegates claiming rights to Toly vm.prank(vitalik); - pointTokenVault.trustClaimer(toly, true); + pointTokenVault.trustReceiver(toly, true); // Toly claims the half of Vitalik's pTokens vm.prank(toly); From 31f96c57deb2265cfc8a2beab609039af55b9ab6 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:55:12 -0700 Subject: [PATCH 4/4] fix: separate deposit cap tracking from contract balance changes (#38) * Feat/mapping comment update (#18) * feat: update mapping comments * feat: clean comment * Update contracts/PointTokenVault.sol Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> --------- Co-authored-by: Josh Levine <24902242+jparklev@users.noreply.github.com> * fix: separate deposit cap tracking from contract balance changes * chore: move interaction below effects for deposit --------- Co-authored-by: Steven Valeri --- contracts/PointTokenVault.sol | 12 +++++--- contracts/test/PointTokenVault.t.sol | 41 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/contracts/PointTokenVault.sol b/contracts/PointTokenVault.sol index 6f65b9a..ffa948e 100644 --- a/contracts/PointTokenVault.sol +++ b/contracts/PointTokenVault.sol @@ -43,6 +43,8 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall mapping(address => mapping(address => bool)) public trustedReceivers; // owner => delegate => trustedReceivers + mapping(address => uint256) public totalDeposited; // token => total deposited amount + // Fees uint256 public mintFee; uint256 public redemptionFee; @@ -110,25 +112,27 @@ contract PointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, Multicall _setFeeCollector(_feeCollector); } - // Rebasing and fee-on-transfer tokens must be wrapped before depositing. + // Rebasing and fee-on-transfer tokens must be wrapped before depositing. ie, they are not supported natively. function deposit(ERC20 _token, uint256 _amount, address _receiver) public { uint256 cap = caps[address(_token)]; if (cap != type(uint256).max) { - if (_amount + _token.balanceOf(address(this)) > cap) { + if (totalDeposited[address(_token)] + _amount > cap) { revert DepositExceedsCap(); } } - _token.safeTransferFrom(msg.sender, address(this), _amount); - balances[_receiver][_token] += _amount; + totalDeposited[address(_token)] += _amount; + + _token.safeTransferFrom(msg.sender, address(this), _amount); emit Deposit(msg.sender, _receiver, address(_token), _amount); } function withdraw(ERC20 _token, uint256 _amount, address _receiver) public { balances[msg.sender][_token] -= _amount; + totalDeposited[address(_token)] -= _amount; _token.safeTransfer(_receiver, _amount); diff --git a/contracts/test/PointTokenVault.t.sol b/contracts/test/PointTokenVault.t.sol index 51191dd..f5c74cd 100644 --- a/contracts/test/PointTokenVault.t.sol +++ b/contracts/test/PointTokenVault.t.sol @@ -146,6 +146,47 @@ contract PointTokenVaultTest is Test { assertEq(pointTokenVault.balances(vitalik, newMockToken), 2e18); // Total 2 tokens deposited } + function test_DepositCapRewardSameAsDeposit() public { + // Set up an 18 decimal token as both deposit and reward token + MockERC20 token = new MockERC20("Example Token", "EX", 18); + + vm.startPrank(operator); + // Set deposit cap for token to 5000 + pointTokenVault.setCap(address(token), 5000e18); + + // Set token as reward token with 1:1 ratio + pointTokenVault.setRedemption(eigenPointsId, token, 1e18, false); + vm.stopPrank(); + + // Mint tokens to users + token.mint(vitalik, 5000e18); + token.mint(toly, 2000e18); + + // Vitalik deposits 4000 tokens + vm.startPrank(vitalik); + token.approve(address(pointTokenVault), 4000e18); + pointTokenVault.deposit(token, 4000e18, vitalik); + vm.stopPrank(); + + // Toly converts 2000 tokens to pTokens + vm.startPrank(toly); + token.approve(address(pointTokenVault), 2000e18); + pointTokenVault.convertRewardsToPTokens(toly, eigenPointsId, 2000e18); + vm.stopPrank(); + + // Assert current token balance in vault + assertEq(token.balanceOf(address(pointTokenVault)), 6000e18); + + // Try to deposit 1000 tokens, which should succeed + vm.startPrank(vitalik); + token.approve(address(pointTokenVault), 1000e18); + pointTokenVault.deposit(token, 1000e18, vitalik); + vm.stopPrank(); + + // Assert that 5000 tokens have been deposited + assertEq(pointTokenVault.balances(vitalik, token), 5000e18); + } + function test_DeployPToken() public { // Can't deploy the same token twice vm.expectRevert(PointTokenVault.PTokenAlreadyDeployed.selector);