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);