Section 5 of 5

Learn
+100 Shields

Skills Assessment

Skills Assessment: Proxy Upgrade Patterns

This is an unguided assessment worth 100 Shields. No hints, no templates. You need to demonstrate that you can safely upgrade a proxy contract without breaking storage or losing access.

The Challenge

Safely upgrade a proxy contract from V1 to V2.

You are given a V1 contract that has been deployed behind a UUPS proxy. The contract has a critical bug: the withdraw function does not check for reentrancy, allowing an attacker to drain the contract.

V1 Contract (Deployed and Live)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract LendingVaultV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 public totalDeposits;
    mapping(address => uint256) public balances;
    mapping(address => uint256) public borrowBalances;
    uint256 public interestRateBps;
    bool public paused;

    uint256[45] private __gap;

    function initialize(address _owner, uint256 _rateBps) public initializer {
        __Ownable_init(_owner);
        __UUPSUpgradeable_init();
        interestRateBps = _rateBps;
    }

    function deposit() external payable {
        require(!paused, "Paused");
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
    }

    // BUG: No reentrancy protection
    function withdraw(uint256 amount) external {
        require(!paused, "Paused");
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;
        totalDeposits -= amount;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }

    function borrow(uint256 amount) external {
        require(!paused, "Paused");
        require(balances[msg.sender] * 75 / 100 >= borrowBalances[msg.sender] + amount, "Undercollateralized");
        borrowBalances[msg.sender] += amount;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

The contract has been live for 3 months. Users have deposited 500 ETH. There are active borrows.

Your Task

Write a complete LendingVaultV2 contract that:

  1. Fixes the reentrancy bug in both withdraw and borrow
  2. Preserves the exact storage layout of V1 (no variable reordering, no insertions before existing variables)
  3. Includes _authorizeUpgrade with proper access control
  4. Uses _disableInitializers() in the constructor
  5. Adds a reinitializer function for any V2-specific setup (if needed)
  6. Maintains the storage gap (adjusted if you add new variables)

Additionally, provide:

  1. A Hardhat deployment script (upgrade-to-v2.js) that upgrades the proxy from V1 to V2
  2. A brief explanation (100-200 words) of what would go wrong if you:
    • Moved paused before interestRateBps
    • Forgot to inherit UUPSUpgradeable in V2
    • Removed the __gap array

Evaluation Criteria

Storage Safety: V2 must not break any existing storage slots. All user balances, borrow positions, and configuration values must be preserved exactly. A single slot collision means total failure.

Reentrancy Fix: The fix must actually prevent reentrancy. Simply adding a nonReentrant modifier from OpenZeppelin is acceptable, but you must adjust the storage layout correctly (ReentrancyGuardUpgradeable uses a storage slot).

Upgrade Continuity: V2 must be upgradeable to V3 in the future. If V2 breaks the upgrade path, it is a critical failure.

Deliverable

Submit your complete LendingVaultV2.sol contract, the deployment script, and the explanation.

Verification

This is a skills assessment. Submit your completed code and explanation for review.