diff --git a/contracts/test/PointTokenVault.t.sol b/contracts/test/PointTokenVault.t.sol
index f304d66..306e436 100644
--- a/contracts/test/PointTokenVault.t.sol
+++ b/contracts/test/PointTokenVault.t.sol
@@ -485,10 +485,16 @@ contract PointTokenVaultTest is Test {
 
         rewardToken.mint(address(pointTokenVault), 3e18);
 
+        // Cannot redeem pTokens or convert rewards before redemption data is set
+        bytes32[] memory empty = new bytes32[](0);
+        vm.expectRevert(PointTokenVault.RewardsNotReleased.selector);
+        pointTokenVault.redeemRewards(PointTokenVault.Claim(eigenPointsId, 2e18, 2e18, empty), vitalik);
+        vm.expectRevert(PointTokenVault.RewardsNotReleased.selector);
+        pointTokenVault.convertRewardsToPTokens(vitalik, eigenPointsId, 1e18);
+
         vm.prank(operator);
         pointTokenVault.setRedemption(eigenPointsId, rewardToken, 2e18, false);
 
-        bytes32[] memory empty = new bytes32[](0);
         vm.prank(vitalik);
         pointTokenVault.redeemRewards(PointTokenVault.Claim(eigenPointsId, 2e18, 2e18, empty), vitalik);
 
@@ -603,6 +609,35 @@ contract PointTokenVaultTest is Test {
         uint256 newBalance = address(pointTokenVault).balance;
         assertEq(newBalance, initialBalance + amountToSend);
     }
+
+    function test_PTokenNotDeployed() public {
+        // Deploy new instance of vault (without pToken deployed)
+        PointTokenVault mockVault = _deployAdditionalVault();
+
+        bytes32 root = 0x4e40a10ce33f33a4786960a8bb843fe0e170b651acd83da27abc97176c4bed3c;
+        bytes32[] memory proof = new bytes32[](1);
+        proof[0] = 0x6d0fcb8de12b1f57f81e49fa18b641487b932cdba4f064409fde3b05d3824ca2;
+
+        vm.prank(merkleUpdater);
+        mockVault.updateRoot(root);
+
+        // Cannot claim if pToken hasn't been deployed yet
+        vm.prank(vitalik);
+        vm.expectRevert(PointTokenVault.PTokenNotDeployed.selector);
+        mockVault.claimPTokens(PointTokenVault.Claim(eigenPointsId, 1e18, 1e18, proof), vitalik);
+    } 
+
+    // Internal
+    function _deployAdditionalVault() internal returns (PointTokenVault mockVault) {
+        PointTokenVaultScripts scripts = new PointTokenVaultScripts();
+
+        mockVault = scripts.run("0.0.1");
+
+        mockVault.grantRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), admin);
+        mockVault.grantRole(pointTokenVault.MERKLE_UPDATER_ROLE(), merkleUpdater);
+        mockVault.grantRole(pointTokenVault.OPERATOR_ROLE(), operator);
+        mockVault.revokeRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), address(this));
+    }
 }
 
 contract Echo {
diff --git a/contracts/test/invariant/PointTokenVaultInvariants.t.sol b/contracts/test/invariant/PointTokenVaultInvariants.t.sol
new file mode 100644
index 0000000..5118a6b
--- /dev/null
+++ b/contracts/test/invariant/PointTokenVaultInvariants.t.sol
@@ -0,0 +1,68 @@
+pragma solidity ^0.8.13;
+
+import {Test, console, console2} from "forge-std/Test.sol";
+
+import {MockPointTokenVault} from "../mock/MockPointTokenVault.sol";
+import {MockPointTokenVaultScripts} from "../mock/script/MockPointTokenVault.s.sol";
+
+import {PointTokenVaultHandler} from "./handlers/PointTokenVaultHandler.sol";
+
+import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol";
+
+contract PointTokenVaultInvariants is Test {
+    PointTokenVaultHandler handler;
+    
+    MockPointTokenVault pointTokenVault;
+
+    function setUp() public {
+        // Mock vault bypasses merkle validation to allow for fuzzing.
+        // Merkle validation is tested in PointTokenVault.t.sol
+        MockPointTokenVaultScripts scripts = new MockPointTokenVaultScripts();
+
+        // Deploy the PointTokenVault
+        pointTokenVault = scripts.run("0.0.1");
+        address[3] memory admins = [
+            makeAddr("admin"),
+            makeAddr("operator"),
+            makeAddr("merkleUpdater")
+        ];
+
+        pointTokenVault.grantRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), admins[0]);
+        pointTokenVault.grantRole(pointTokenVault.MERKLE_UPDATER_ROLE(), admins[2]);
+        pointTokenVault.grantRole(pointTokenVault.OPERATOR_ROLE(), admins[1]);
+        pointTokenVault.revokeRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), address(this));
+
+        handler = new PointTokenVaultHandler(
+            pointTokenVault,
+            admins
+        );
+
+        bytes4[] memory selectors = new bytes4[](5);
+        selectors[0] = handler.deposit.selector;
+        selectors[1] = handler.withdraw.selector;
+        selectors[2] = handler.claimPTokens.selector;
+        selectors[3] = handler.redeem.selector;
+        selectors[4] = handler.convertRewardsToPTokens.selector;
+
+        targetSelector(
+            FuzzSelector({
+                addr: address(handler),
+                selectors: selectors
+            })
+        );
+
+        targetContract(address(handler));
+    }
+
+    function invariant_point_earning_token_balances_remain_accurate_over_time() public view {
+        require(handler.checkPointEarningTokenGhosts(), "local pointsEarningTokens balances do not match balances stored in contract");
+    }
+
+    function invariant_claimed_ptoken_balances_remain_accurate_over_time() public view {
+        require(handler.checkClaimedPTokensGhosts(), "local claimed pTokens balances do not match balances stored in contract");
+    }
+
+    function invariant_ptoken_total_supplies_equal_sum_of_balances() public view {
+        require(handler.checkSumOfPTokenBalances(), "sum of a pToken's balances does not equal its total supply");
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/invariant/handlers/PointTokenVaultHandler.sol b/contracts/test/invariant/handlers/PointTokenVaultHandler.sol
new file mode 100644
index 0000000..81c3f3c
--- /dev/null
+++ b/contracts/test/invariant/handlers/PointTokenVaultHandler.sol
@@ -0,0 +1,402 @@
+pragma solidity ^0.8.13;
+
+import {Test, console, console2} from "forge-std/Test.sol";
+
+import {PToken} from "../../../PToken.sol";
+
+import {MockPointTokenVault} from "../../mock/MockPointTokenVault.sol";
+
+import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol";
+import {LibString} from "solady/utils/LibString.sol";
+import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol";
+
+contract PointTokenVaultHandler is Test {
+    MockPointTokenVault public pointTokenVault;
+
+    MockERC20[10] pointEarningTokens = [
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18),
+        new MockERC20("Test Token", "TST", 18)
+    ];
+
+    bytes32[10] pointsIds = [
+        LibString.packTwo("First", "test"),
+        LibString.packTwo("Second", "test"),
+        LibString.packTwo("Third", "test"),
+        LibString.packTwo("Fourth", "test"),
+        LibString.packTwo("Fifth", "test"),
+        LibString.packTwo("Sixth", "test"),
+        LibString.packTwo("Seventh", "test"),
+        LibString.packTwo("Eighth", "test"),
+        LibString.packTwo("Ninth", "test"),
+        LibString.packTwo("Tenth", "test")
+    ];
+
+    MockERC20[10] rewardTokens = [
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18),
+        new MockERC20("Reward Token", "RWT", 18)
+    ];
+
+    address public currentActor;
+
+    struct Actor {
+        address addr;
+        uint256 key;
+    }
+
+    Actor[] public actors;
+    Actor[] public dsts;
+    Actor internal actor;
+
+    bytes32[] internal expectedErrors;
+
+    mapping(address => mapping(address => uint256)) pointEarningTokenGhosts;
+    mapping(address => mapping(bytes32 => uint256)) claimedPTokensGhosts;
+    mapping(address => mapping(bytes32 => uint256)) pTokenBalanceGhosts;
+
+    modifier useRandomActor(uint256 _actorIndex) {
+        actor = _selectActor(_actorIndex);
+        vm.stopPrank();
+        vm.startPrank(actor.addr);
+        _;
+        delete actor;
+        vm.stopPrank();
+    }
+
+    modifier resetErrors() {
+        _;
+        delete expectedErrors;
+    }
+
+    constructor(
+        MockPointTokenVault pointTokenVault_,
+        address[3] memory admins
+    ) {
+        pointTokenVault = pointTokenVault_;
+
+        vm.prank(admins[1]);
+        pointTokenVault.setIsCapped(false);
+
+        for (uint256 i = 0; i < admins.length; i++) {
+            Actor memory _actor;
+            _actor.addr = admins[i];
+            _actor.key = 0;
+            actors.push(_actor);
+            dsts.push(_actor);
+        }
+
+        for (uint256 j = 0; j < 10; j++) {
+            Actor memory _actor;
+            (_actor.addr, _actor.key) = makeAddrAndKey(string(abi.encodePacked("Actor", vm.toString(j))));
+            actors.push(_actor);
+            dsts.push(_actor);
+        }
+
+        Actor memory zero;
+        (zero.addr, zero.key) = makeAddrAndKey(string(abi.encodePacked("Zero")));
+        zero.addr = address(0);
+        dsts.push(zero);
+
+        for (uint256 k = 0; k < pointsIds.length; k++) {
+            pointTokenVault.deployPToken(pointsIds[k]);
+        }
+    }
+
+    function deposit(
+        uint256 actorIndex,
+        uint256 dstIndex,
+        uint256 amount,
+        uint256 tokenIndex
+    ) public useRandomActor(actorIndex) {
+        actorIndex = bound(actorIndex, 0, 12);
+        dstIndex = bound(dstIndex, 0, 12);
+        amount = bound(amount, 0, 1000000000000 * 1e18);
+        tokenIndex = bound(tokenIndex, 0, 9);
+
+        MockERC20 token = pointEarningTokens[tokenIndex];
+
+        token.mint(currentActor, amount);
+        token.approve(address(pointTokenVault), amount);
+
+        uint256 depositorBalanceBefore = token.balanceOf(currentActor);
+        uint256 receiverBalanceBefore = pointTokenVault.balances(actors[dstIndex].addr, token);
+
+        try pointTokenVault.deposit(token, amount, actors[dstIndex].addr) {
+            uint256 depositorBalanceAfter = token.balanceOf(currentActor);
+            uint256 receiverBalanceAfter = pointTokenVault.balances(actors[dstIndex].addr, token);
+
+            pointEarningTokenGhosts[actors[dstIndex].addr][address(token)] += amount;
+
+            assertEq(depositorBalanceBefore - depositorBalanceAfter, amount);
+            assertEq(receiverBalanceAfter - receiverBalanceBefore, amount);
+        } catch (bytes memory reason) {
+            console.log("Unexpected revert: deposit failed!");
+            console.logBytes(reason);
+        }
+    }
+
+    function withdraw(
+        uint256 actorIndex,
+        uint256 dstIndex,
+        uint256 amount,
+        uint256 tokenIndex
+    ) public useRandomActor(actorIndex) {
+        actorIndex = bound(actorIndex, 0, 12);
+        dstIndex = bound(dstIndex, 0, 12);
+        amount = bound(amount, 0, 1000000000000 * 1e18);
+        tokenIndex = bound(tokenIndex, 0, 9);
+
+        MockERC20 token = pointEarningTokens[tokenIndex];
+
+        token.mint(currentActor, amount);
+        token.approve(address(pointTokenVault), amount);
+        pointTokenVault.deposit(token, amount, currentActor);
+        pointEarningTokenGhosts[currentActor][address(token)] += amount;
+
+        uint256 actorBalanceBefore = pointTokenVault.balances(currentActor, token);
+        uint256 receiverBalanceBefore = token.balanceOf(actors[dstIndex].addr);
+
+        try pointTokenVault.withdraw(token, amount, actors[dstIndex].addr) {
+            uint256 actorBalanceAfter = pointTokenVault.balances(currentActor, token);
+            uint256 receiverBalanceAfter = token.balanceOf(actors[dstIndex].addr);
+
+            pointEarningTokenGhosts[currentActor][address(token)] -= amount;
+
+            assertEq(actorBalanceBefore - actorBalanceAfter, amount);
+            assertEq(receiverBalanceAfter - receiverBalanceBefore, amount);
+        } catch (bytes memory reason) {
+            console.log("Unexpected revert: withdraw failed!");
+            console.logBytes(reason);
+        }
+    }
+
+    function claimPTokens(
+        uint256 actorIndex,
+        uint256 dstIndex,
+        uint256 pointsIdIndex,
+        uint256 totalClaimable,
+        uint256 amount
+    ) public useRandomActor(actorIndex) {
+        actorIndex = bound(actorIndex, 0, 12);
+        dstIndex = bound(dstIndex, 0, 12);
+        totalClaimable = bound(totalClaimable, 0, 100000 * 1e18);
+        amount = bound(amount, 0, 1000000000000 * 1e18);
+        pointsIdIndex = bound(pointsIdIndex, 0, 9);
+
+        bytes32[] memory emptyProof = new bytes32[](0);
+
+        MockPointTokenVault.Claim memory claim = MockPointTokenVault.Claim(
+            pointsIds[pointsIdIndex],
+            totalClaimable,
+            amount,
+            emptyProof
+        );
+
+        uint256 pTokenBalanceBefore = pointTokenVault.pTokens(pointsIds[pointsIdIndex]).balanceOf(actors[dstIndex].addr);
+        uint256 claimedBalanceBefore = pointTokenVault.claimedPTokens(actors[dstIndex].addr, pointsIds[pointsIdIndex]);
+
+        try pointTokenVault.claimPTokens(claim, actors[dstIndex].addr) {
+            uint256 pTokenBalanceAfter = pointTokenVault.pTokens(pointsIds[pointsIdIndex]).balanceOf(actors[dstIndex].addr);
+            uint256 claimedBalanceAfter = pointTokenVault.claimedPTokens(actors[dstIndex].addr, pointsIds[pointsIdIndex]);
+
+            claimedPTokensGhosts[actors[dstIndex].addr][pointsIds[pointsIdIndex]] += amount;
+            pTokenBalanceGhosts[actors[dstIndex].addr][pointsIds[pointsIdIndex]] += amount;
+
+            assertEq(pTokenBalanceAfter - pTokenBalanceBefore, amount);
+            assertEq(claimedBalanceAfter - claimedBalanceBefore, amount);
+        } catch (bytes memory reason) {
+            if (totalClaimable < claimedBalanceBefore + amount) {
+                console.log("Expected revert: totalClaimable < claimedBalance + amount");
+                assertEq(bytes4(reason), MockPointTokenVault.ClaimTooLarge.selector);
+            } else {
+                console.log("Unexpected revert: claim failed!");
+                console.logBytes(reason);
+            }
+        }
+    }
+
+    function redeem(
+        uint256 actorIndex,
+        uint256 rewardTokenIndex,
+        uint256 rewardTokenAmount,
+        uint256 rewardPerPToken,
+        uint256 pointsIdIndex
+    ) public useRandomActor(actorIndex) {
+        actorIndex = bound(actorIndex, 0, 12);
+        rewardTokenAmount = bound(rewardTokenAmount, 1e18, 1000000000000 * 1e18);
+        rewardTokenIndex = bound(rewardTokenIndex, 0, 9);
+        rewardPerPToken = bound(rewardPerPToken, 1e18, 1000000 * 1e18);
+        pointsIdIndex = bound(pointsIdIndex, 0, 9);
+
+        uint256 pTokenAmount = FixedPointMathLib.divWadUp(rewardTokenAmount, rewardPerPToken);
+
+        _simpleClaim(pointsIdIndex, pTokenAmount, actorIndex);
+
+        MockERC20 rewardToken = rewardTokens[rewardTokenIndex];
+        rewardToken.mint(address(pointTokenVault), rewardTokenAmount);
+
+        vm.startPrank(actors[1].addr);
+        pointTokenVault.setRedemption(pointsIds[pointsIdIndex], rewardToken, rewardPerPToken, false);
+        vm.startPrank(currentActor);
+
+        MockPointTokenVault.Claim memory redemptionClaim = MockPointTokenVault.Claim(
+            pointsIds[pointsIdIndex],
+            rewardTokenAmount,
+            rewardTokenAmount,
+            new bytes32[](0)
+        );
+
+        uint256 rewardBalanceBefore = rewardToken.balanceOf(currentActor);
+
+        try pointTokenVault.redeemRewards(redemptionClaim, currentActor) {
+            pTokenBalanceGhosts[currentActor][pointsIds[pointsIdIndex]] -= FixedPointMathLib.divWadUp(rewardTokenAmount, rewardPerPToken);
+            assertEq(rewardToken.balanceOf(currentActor) - rewardBalanceBefore, rewardTokenAmount);
+        } catch (bytes memory reason) {
+            console.log("Unexpected revert: redeem failed!");
+            console.logBytes(reason);
+        }
+    }
+
+    function convertRewardsToPTokens(
+        uint256 actorIndex,
+        uint256 dstIndex,
+        uint256 pointsIdIndex,
+        uint256 rewardTokenIndex,
+        uint256 rewardPerPToken,
+        uint256 amount
+    ) public useRandomActor(actorIndex) {
+        actorIndex = bound(actorIndex, 0, 12);
+        dstIndex = bound(dstIndex, 0, 12);
+        amount = bound(amount, 0, 1000000000000 * 1e18);
+        pointsIdIndex = bound(pointsIdIndex, 0, 9);
+        rewardTokenIndex = bound(rewardTokenIndex, 0, 9);
+        rewardPerPToken = bound(rewardPerPToken, 1e18, 1000000 * 1e18);
+
+        PToken pToken = pointTokenVault.pTokens(pointsIds[pointsIdIndex]);
+        MockERC20 rewardToken = rewardTokens[rewardTokenIndex];
+
+        vm.startPrank(actors[1].addr);
+        pointTokenVault.setRedemption(pointsIds[pointsIdIndex], rewardToken, rewardPerPToken, false);
+        vm.startPrank(currentActor);
+        rewardToken.mint(currentActor, amount);
+        rewardToken.approve(address(pointTokenVault), amount);
+
+        uint256 senderRewardTokenBalanceBefore = rewardToken.balanceOf(currentActor);
+        uint256 receiverPTokenBalanceBefore = pToken.balanceOf(actors[dstIndex].addr);
+
+        try pointTokenVault.convertRewardsToPTokens(actors[dstIndex].addr, pointsIds[pointsIdIndex], amount) {
+            uint256 pTokenAmount = FixedPointMathLib.divWadDown(amount, rewardPerPToken);
+            
+            pTokenBalanceGhosts[actors[dstIndex].addr][pointsIds[pointsIdIndex]] += pTokenAmount;
+
+            assertEq(senderRewardTokenBalanceBefore - rewardToken.balanceOf(currentActor), amount);
+            assertEq(pToken.balanceOf(actors[dstIndex].addr) - receiverPTokenBalanceBefore, pTokenAmount);
+
+            // This can be re-added when issue #13 is resolved.
+            // Tests that pTokenAmount is never 0 if rewardAmount > 0
+            // if (amount > 0) {
+                // assertGt(pToken.balanceOf(actors[dstIndex].addr) - receiverPTokenBalanceBefore, 0);
+            // }
+        } catch (bytes memory reason) {
+            console.log("Unexpected error: conversion failed!");
+            console.logBytes(reason);
+        }
+    }
+
+    // Helper functions ---
+
+    function _simpleClaim(
+        uint256 pointsIdIndex,
+        uint256 pTokenAmount,
+        uint256 actorIndex
+    ) internal {
+        bytes32[] memory emptyProof = new bytes32[](0);
+
+        MockPointTokenVault.Claim memory pTokenClaim = MockPointTokenVault.Claim(
+            pointsIds[pointsIdIndex],
+            type(uint256).max,
+            pTokenAmount,
+            emptyProof
+        );
+
+        pointTokenVault.claimPTokens(pTokenClaim, actors[actorIndex].addr);
+        claimedPTokensGhosts[actors[actorIndex].addr][pointsIds[pointsIdIndex]] += pTokenAmount;
+        pTokenBalanceGhosts[actors[actorIndex].addr][pointsIds[pointsIdIndex]] += pTokenAmount;
+    }
+
+    function checkPointEarningTokenGhosts() public view returns (bool) {
+        for (uint256 i; i < actors.length; i++) {
+            for (uint256 j; j < pointEarningTokens.length; j++) {
+                if (
+                    pointEarningTokenGhosts[actors[i].addr][address(pointEarningTokens[j])]
+                        != pointTokenVault.balances(actors[i].addr, pointEarningTokens[j])
+                ) {
+                    console.log("Ghost balance:", pointEarningTokenGhosts[actors[i].addr][address(pointEarningTokens[j])]);
+                    console.log("Balance according to contract:", pointTokenVault.balances(actors[i].addr, pointEarningTokens[j]));
+
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    function checkClaimedPTokensGhosts() public view returns (bool) {
+        for (uint i; i < actors.length; i++) {
+            for (uint256 j; j < pointsIds.length; j++) {
+                if (
+                    claimedPTokensGhosts[actors[i].addr][pointsIds[j]]
+                        != pointTokenVault.claimedPTokens(actors[i].addr, pointsIds[j])
+                ) {
+                    console.log("Ghost balance:", claimedPTokensGhosts[actors[i].addr][pointsIds[j]]);
+                    console.log("Balance according to contract:", pointTokenVault.claimedPTokens(actors[i].addr, pointsIds[j]));
+
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    function checkSumOfPTokenBalances() public view returns (bool) {
+        uint256 sumOfBalances;    
+        for (uint256 i; i < pointsIds.length; i++) {
+            sumOfBalances = 0;
+            for (uint256 j; j < actors.length; j++) {
+                sumOfBalances += pTokenBalanceGhosts[actors[j].addr][pointsIds[i]];
+            }
+
+            if (sumOfBalances != pointTokenVault.pTokens(pointsIds[i]).totalSupply()) {
+                console.log("PToken index:", i);
+                console.log("Sum of balances:", sumOfBalances);
+
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    function _selectActor(uint256 _actorIndex) internal returns (Actor memory actor_) {
+        uint256 index = bound(_actorIndex, 0, actors.length - 1);
+        currentActor = actors[index].addr;
+        actor_ = actors[index];
+    }
+}
\ No newline at end of file
diff --git a/contracts/test/mock/MockPointTokenVault.sol b/contracts/test/mock/MockPointTokenVault.sol
new file mode 100644
index 0000000..44a6806
--- /dev/null
+++ b/contracts/test/mock/MockPointTokenVault.sol
@@ -0,0 +1,252 @@
+// SPDX-License-Identifier: UNLICENSED
+pragma solidity ^0.8.13;
+
+import {console} from "forge-std/Test.sol";
+
+import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
+
+import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol";
+import {AccessControlUpgradeable} from
+    "openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol";
+import {MulticallUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/utils/MulticallUpgradeable.sol";
+
+import {ERC20} from "solmate/tokens/ERC20.sol";
+import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol";
+
+import {LibString} from "solady/utils/LibString.sol";
+import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol";
+
+import {PToken} from "../../PToken.sol";
+
+/// @title Point Token Vault
+/// @notice Manages deposits and withdrawals for points-earning assets, point token claims, and reward redemptions.
+contract MockPointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, MulticallUpgradeable {
+    using SafeTransferLib for ERC20;
+    using MerkleProof for bytes32[];
+
+    bytes32 public constant REDEMPTION_RIGHTS_PREFIX = keccak256("REDEMPTION_RIGHTS");
+    bytes32 public constant MERKLE_UPDATER_ROLE = keccak256("MERKLE_UPDATER_ROLE");
+    bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
+
+    // Deposit asset balancess.
+    mapping(address => mapping(ERC20 => uint256)) public balances; // user => point-earning token => balance
+
+    // 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(bytes32 => PToken) public pTokens; // pointsId => pTokens
+
+    mapping(bytes32 => RedemptionParams) public redemptions; // pointsId => redemptionParams
+
+    mapping(address => uint256) public caps; // asset => deposit cap
+    bool public isCapped;
+
+    struct Claim {
+        bytes32 pointsId;
+        uint256 totalClaimable;
+        uint256 amountToClaim;
+        bytes32[] proof;
+    }
+
+    struct RedemptionParams {
+        ERC20 rewardToken;
+        uint256 rewardsPerPToken; // Assume 18 decimals.
+        bool isMerkleBased;
+    }
+
+    event Deposit(address indexed receiver, address indexed token, uint256 amount);
+    event Withdraw(address indexed user, address indexed token, uint256 amount);
+    event RootUpdated(bytes32 prevRoot, bytes32 newRoot);
+    event PTokensClaimed(address indexed account, bytes32 indexed pointsId, uint256 amount);
+    event RewardsClaimed(address indexed owner, address indexed receiver, bytes32 indexed pointsId, uint256 amount);
+    event RewardRedemptionSet(
+        bytes32 indexed pointsId, ERC20 rewardToken, uint256 rewardsPerPToken, bool isMerkleBased
+    );
+    event PTokenDeployed(bytes32 indexed pointsId, address indexed pToken);
+    event CapSet(address indexed token, uint256 cap);
+
+    error ProofInvalidOrExpired();
+    error ClaimTooLarge();
+    error RewardsNotReleased();
+    error PTokenAlreadyDeployed();
+    error DepositExceedsCap();
+    error PTokenNotDeployed();
+
+    constructor() {
+        _disableInitializers();
+    }
+
+    function initialize(address _admin) public initializer {
+        __UUPSUpgradeable_init();
+        __AccessControl_init();
+        __Multicall_init();
+        _grantRole(DEFAULT_ADMIN_ROLE, _admin);
+        isCapped = true;
+    }
+
+    // Rebasing and fee-on-transfer tokens must be wrapped before depositing.
+    function deposit(ERC20 _token, uint256 _amount, address _receiver) public {
+        if (isCapped && (_amount + _token.balanceOf(address(this)) > caps[address(_token)])) {
+            revert DepositExceedsCap();
+        }
+
+        _token.safeTransferFrom(msg.sender, address(this), _amount);
+
+        balances[_receiver][_token] += _amount;
+
+        emit Deposit(_receiver, address(_token), _amount);
+    }
+
+    function withdraw(ERC20 _token, uint256 _amount, address _receiver) public {
+        balances[msg.sender][_token] -= _amount;
+
+        _token.safeTransfer(_receiver, _amount);
+
+        emit Withdraw(_receiver, address(_token), _amount);
+    }
+
+    /// @notice Claims point tokens after verifying the merkle proof
+    /// @param _claim The claim details including the merkle proof
+    /// @param _account The account to claim for
+    // Adapted from Morpho's RewardsDistributor.sol (https://github.com/morpho-org/morpho-optimizers/blob/ffd702f045d24b911d6c8c6c2194dd15cf9387ff/src/common/rewards-distribution/RewardsDistributor.sol)
+    function claimPTokens(Claim calldata _claim, address _account) public {
+        bytes32 pointsId = _claim.pointsId;
+
+        bytes32 claimHash = keccak256(abi.encodePacked(_account, pointsId, _claim.totalClaimable));
+        _verifyClaimAndUpdateClaimed(_claim, claimHash, _account, claimedPTokens);
+
+        if (address(pTokens[pointsId]) == address(0)) {
+            revert PTokenNotDeployed();
+        }
+
+        pTokens[pointsId].mint(_account, _claim.amountToClaim);
+
+        emit PTokensClaimed(_account, pointsId, _claim.amountToClaim);
+    }
+
+    /// @notice Redeems rewards for point tokens
+    /// @param _claim Details of the claim including the amount and merkle proof
+    /// @param _receiver The account that will receive the msg.sender redeemed rewards
+    function redeemRewards(Claim calldata _claim, address _receiver) public {
+        (bytes32 pointsId, uint256 amountToClaim) = (_claim.pointsId, _claim.amountToClaim);
+
+        RedemptionParams memory params = redemptions[pointsId];
+        (ERC20 rewardToken, uint256 rewardsPerPToken, bool isMerkleBased) =
+            (params.rewardToken, params.rewardsPerPToken, params.isMerkleBased);
+
+        if (address(rewardToken) == address(0)) {
+            revert RewardsNotReleased();
+        }
+
+        if (isMerkleBased) {
+            // If it's merkle-based, only those callers with redemption rights can redeem their point token for rewards.
+
+            bytes32 claimHash =
+                keccak256(abi.encodePacked(REDEMPTION_RIGHTS_PREFIX, msg.sender, pointsId, _claim.totalClaimable));
+            _verifyClaimAndUpdateClaimed(_claim, claimHash, msg.sender, claimedRedemptionRights);
+        }
+
+        // Will fail if the user doesn't also have enough point tokens. Assume rewardsPerPToken is 18 decimals.
+        pTokens[pointsId].burn(msg.sender, FixedPointMathLib.divWadUp(amountToClaim, rewardsPerPToken)); // Round up for burn.
+        rewardToken.safeTransfer(_receiver, amountToClaim);
+        emit RewardsClaimed(msg.sender, _receiver, pointsId, amountToClaim);
+    }
+
+    /// @notice Mints point tokens for rewards after redemption has been enabled
+    function convertRewardsToPTokens(address _receiver, bytes32 _pointsId, uint256 _amountToConvert) public {
+        RedemptionParams memory params = redemptions[_pointsId];
+        (ERC20 rewardToken, uint256 rewardsPerPToken) = (params.rewardToken, params.rewardsPerPToken);
+
+        if (address(rewardToken) == address(0)) {
+            revert RewardsNotReleased();
+        }
+
+        rewardToken.safeTransferFrom(msg.sender, address(this), _amountToConvert);
+        pTokens[_pointsId].mint(_receiver, FixedPointMathLib.divWadDown(_amountToConvert, rewardsPerPToken)); // Round down for mint.
+    }
+
+    function deployPToken(bytes32 _pointsId) public {
+        if (address(pTokens[_pointsId]) != address(0)) {
+            revert PTokenAlreadyDeployed();
+        }
+
+        (string memory name, string memory symbol) = LibString.unpackTwo(_pointsId); // Assume the points id was created using LibString.packTwo.
+        pTokens[_pointsId] = new PToken{salt: _pointsId}(name, symbol, 18);
+        emit PTokenDeployed(_pointsId, address(pTokens[_pointsId]));
+    }
+
+    // Internal ---
+
+    function _verifyClaimAndUpdateClaimed(
+        Claim calldata _claim,
+        bytes32 _claimHash,
+        address _account,
+        mapping(address => mapping(bytes32 => uint256)) storage _claimed
+    ) internal {
+        // bytes32 candidateRoot = _claim.proof.processProof(_claimHash);
+        // The following line exists only here in the mock, just to silence the warning about _claimHash being unused
+        _claimHash;
+        bytes32 pointsId = _claim.pointsId;
+        uint256 amountToClaim = _claim.amountToClaim;
+
+        // Check if the root is valid.
+        // if (candidateRoot != currRoot && candidateRoot != prevRoot) {
+        //     revert ProofInvalidOrExpired();
+        // }
+
+        uint256 alreadyClaimed = _claimed[_account][pointsId];
+
+        // Can claim up to the total claimable amount from the hash.
+        // IMPORTANT: totalClaimable must be in the claim hash passed into this function.
+        if (_claim.totalClaimable < alreadyClaimed + amountToClaim) revert ClaimTooLarge();
+
+        // Update the total claimed amount.
+        unchecked {
+            _claimed[_account][pointsId] = alreadyClaimed + amountToClaim;
+        }
+    }
+
+    // Admin ---
+
+    function updateRoot(bytes32 _newRoot) external onlyRole(MERKLE_UPDATER_ROLE) {
+        prevRoot = currRoot;
+        currRoot = _newRoot;
+        emit RootUpdated(prevRoot, currRoot);
+    }
+
+    function setCap(address _token, uint256 _cap) external onlyRole(OPERATOR_ROLE) {
+        caps[_token] = _cap;
+        emit CapSet(_token, _cap);
+    }
+
+    function setIsCapped(bool _isCapped) external onlyRole(OPERATOR_ROLE) {
+        isCapped = _isCapped;
+    }
+
+    // Can be used to unlock reward token redemption (can also modify a live redemption, so use with care).
+    function setRedemption(bytes32 _pointsId, ERC20 _rewardToken, uint256 _rewardsPerPToken, bool _isMerkleBased)
+        external
+        onlyRole(OPERATOR_ROLE)
+    {
+        redemptions[_pointsId] = RedemptionParams(_rewardToken, _rewardsPerPToken, _isMerkleBased);
+        emit RewardRedemptionSet(_pointsId, _rewardToken, _rewardsPerPToken, _isMerkleBased);
+    }
+
+    // To handle arbitrary reward claiming logic.
+    function execute(address _to, bytes memory _data, uint256 _txGas)
+        external
+        onlyRole(DEFAULT_ADMIN_ROLE)
+        returns (bool success)
+    {
+        assembly {
+            success := delegatecall(_txGas, _to, add(_data, 0x20), mload(_data), 0, 0)
+        }
+    }
+
+    function _authorizeUpgrade(address _newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {}
+
+    receive() external payable {}
+}
diff --git a/contracts/test/mock/script/MockPointTokenVault.s.sol b/contracts/test/mock/script/MockPointTokenVault.s.sol
new file mode 100644
index 0000000..ad6e344
--- /dev/null
+++ b/contracts/test/mock/script/MockPointTokenVault.s.sol
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: UNLICENSED
+pragma solidity ^0.8.13;
+
+import {BatchScript} from "forge-safe/src/BatchScript.sol";
+
+import {MockPointTokenVault} from "../MockPointTokenVault.sol";
+import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol";
+import {ERC20} from "solmate/tokens/ERC20.sol";
+import {CREATE3} from "solmate/utils/CREATE3.sol";
+import {LibString} from "solady/utils/LibString.sol";
+
+import {console} from "forge-std/console.sol";
+
+contract MockPointTokenVaultScripts is BatchScript {
+    // Sepolia Test Accounts
+    address payable public JIM = payable(0xD6633c1382896079D3576eC43519d844a8C80B56);
+    address payable public SAM = payable(0xeeD5B3026060218Dc270AE672be6468053e65E39);
+    address payable public AVA = payable(0xb30C79546800EF35Ea1fAae56A5faA5C03332D9F);
+
+    uint256 JIM_PRIVATE_KEY = 0x70be68eaa723b433c6b8806f3851d3e04f51a1beed15146dc9fba0873f3b7772;
+    uint256 SAM_PRIVATE_KEY = 0x8563bad6b0b906b890cd3272ee8748b7d0e20d6e49917e769af364598e96b466;
+    uint256 AVA_PRIVATE_KEY = 0x7617580e9556785c7f9bb93e652df98b6acd0de459300711afbcf53e40ce0358;
+
+    address public SEOPLIA_MERKLE_BOT_SAFE = 0xec48011b60be299A2684F36Bdb3B498a61A6CbF3;
+    address public SEPOLIA_OPERATOR_SAFE = 0xec48011b60be299A2684F36Bdb3B498a61A6CbF3;
+    address public SEOPLIA_ADMIN_SAFE = 0xec48011b60be299A2684F36Bdb3B498a61A6CbF3;
+
+    address public MAINNET_MERKLE_UPDATER = 0xfDE9f367c933A7D7E7348D4a3e6e096d814F5828;
+    address public MAINNET_OPERATOR = 0x0c0264Ba7799dA7aF0fd141ba5Ba976E6DcC6C17;
+    address public MAINNET_ADMIN = 0x9D89745fD63Af482ce93a9AdB8B0BbDbb98D3e06;
+
+    string public VERSION = "0.0.1";
+
+    function run() public returns (address) {
+        uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY");
+
+        vm.startBroadcast(deployerPrivateKey);
+
+        MockPointTokenVault pointTokenVault = run(VERSION);
+
+        // Set roles
+        pointTokenVault.grantRole(pointTokenVault.MERKLE_UPDATER_ROLE(), SEOPLIA_MERKLE_BOT_SAFE);
+        pointTokenVault.grantRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), SEOPLIA_ADMIN_SAFE);
+        pointTokenVault.grantRole(pointTokenVault.OPERATOR_ROLE(), SEPOLIA_OPERATOR_SAFE);
+
+        // Remove self
+        pointTokenVault.revokeRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), msg.sender);
+
+        require(!pointTokenVault.hasRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), msg.sender), "Self role not removed");
+
+        vm.stopBroadcast();
+
+        return address(pointTokenVault);
+    }
+
+    function run(string memory version) public returns (MockPointTokenVault) {
+        MockPointTokenVault pointTokenVaultImplementation = new MockPointTokenVault{salt: keccak256(abi.encode(version))}();
+
+        MockPointTokenVault pointTokenVault = MockPointTokenVault(payable(
+            address(
+                new ERC1967Proxy{salt: keccak256(abi.encode(version))}(
+                    address(pointTokenVaultImplementation),
+                    abi.encodeCall(MockPointTokenVault.initialize, (msg.sender)) // msg.sender is admin
+                )
+            ))
+        );
+
+        return pointTokenVault;
+    }
+
+    function deposit() public returns (uint256) {
+        vm.startBroadcast(JIM_PRIVATE_KEY);
+
+        ERC20 token = ERC20(0x791a051631c9c4cDf4E03Fb7Aec3163AE164A34B);
+        MockPointTokenVault pointTokenVault = MockPointTokenVault(payable(0xbff7Fb79efC49504afc97e74F83EE618768e63E9));
+        token.symbol();
+
+        token.approve(address(pointTokenVault), 2.5e18);
+        pointTokenVault.deposit(token, 2.5e18, JIM);
+
+        vm.stopBroadcast();
+
+        return token.balanceOf(JIM);
+    }
+
+    function upgrade() public {
+        vm.startBroadcast();
+
+        MockPointTokenVault currentPointTokenVault = MockPointTokenVault(payable(0xbff7Fb79efC49504afc97e74F83EE618768e63E9));
+
+        MockPointTokenVault PointTokenVaultImplementation = new MockPointTokenVault();
+
+        currentPointTokenVault.upgradeToAndCall(address(PointTokenVaultImplementation), bytes(""));
+
+        vm.stopBroadcast();
+    }
+
+    function deployPToken() public {
+        vm.startBroadcast(JIM_PRIVATE_KEY);
+
+        MockPointTokenVault pointTokenVault = MockPointTokenVault(payable(0xbff7Fb79efC49504afc97e74F83EE618768e63E9));
+
+        pointTokenVault.deployPToken(LibString.packTwo("ETHERFI Points", "pEF"));
+
+        vm.stopBroadcast();
+    }
+
+    function deployMockERC20() public {
+        vm.startBroadcast(JIM_PRIVATE_KEY);
+
+        MockERC20 token = new MockERC20("ETHFI", "eETH", 18);
+
+        token.mint(JIM, 100e18);
+        token.mint(SAM, 100e18);
+        token.mint(AVA, 100e18);
+
+        vm.stopBroadcast();
+    }
+
+    function setCap() public {
+        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
+        vm.startBroadcast(deployerPrivateKey);
+
+        address pointTokenVault = 0xbff7Fb79efC49504afc97e74F83EE618768e63E9;
+
+        bytes memory txn =
+            abi.encodeWithSelector(MockPointTokenVault.setCap.selector, 0x791a051631c9c4cDf4E03Fb7Aec3163AE164A34B, 10e18);
+        addToBatch(pointTokenVault, 0, txn);
+
+        executeBatch(SEOPLIA_ADMIN_SAFE, true);
+        vm.stopBroadcast();
+    }
+}
diff --git a/foundry.toml b/foundry.toml
index 7ec75d2..6d69766 100644
--- a/foundry.toml
+++ b/foundry.toml
@@ -5,6 +5,7 @@ test = 'contracts/test'
 out = "out"
 libs = ["lib"]
 evm_version = "cancun"
+gas_reports = ["PointTokenVault"]
 ffi = true
 ast = true
 build_info = true
@@ -16,4 +17,12 @@ sepolia = "${SEPOLIA_RPC_URL}"
 [etherscan]
 sepolia = {key = "${ETHERSCAN_API_KEY}"}
 
+[fuzz]
+runs = 256
+
+[invariant]
+depth = 20
+fail_on_revert = true
+dictionary_weight = 60
+
 # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options