Dopex - Evo's results

A rebate system for option writers in the Dopex Protocol.

General Information

Platform: Code4rena

Start Date: 21/08/2023

Pot Size: $125,000 USDC

Total HM: 26

Participants: 189

Period: 16 days

Judge: GalloDaSballo

Total Solo HM: 3

Id: 278

League: ETH

Dopex

Findings Distribution

Researcher Performance

Rank: 6/189

Findings: 8

Award: $2,065.20

QA:
grade-a

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L764-L774 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L315-L369

Vulnerability details

Impact

Anyone can send 1 WEI to PerpetualAtlanticVault contract and cause DoS for settle() in RdpxV2Core contract.

Proof of Concept

When DEFAULT_ADMIN_ROLE Settles the options by calling settle() in RdpxV2Core contract, settle() in PerpetualAtlanticVault contract will be called as well for the passed optionIds moving forward to call subtractLoss() in PerpetualAtlanticVaultLP L764-L774

function settle(
    uint256[] memory optionIds
  )
    external
    onlyRole(DEFAULT_ADMIN_ROLE)
    returns (uint256 amountOfWeth, uint256 rdpxAmount)
  {
    _whenNotPaused();
    (amountOfWeth, rdpxAmount) = IPerpetualAtlanticVault(
      addresses.perpetualAtlanticVault
    ).settle(optionIds);

settle() in PerpetualAtlanticVaultLP contract L315-L369

function settle(
    uint256[] memory optionIds
  )
    external
    nonReentrant
    onlyRole(RDPXV2CORE_ROLE)
    returns (uint256 ethAmount, uint256 rdpxAmount)
  {
    _whenNotPaused();
    _isEligibleSender();

    updateFunding();

    for (uint256 i = 0; i < optionIds.length; i++) {
      uint256 strike = optionPositions[optionIds[i]].strike;
      uint256 amount = optionPositions[optionIds[i]].amount;

      // check if strike is ITM
      _validate(strike >= getUnderlyingPrice(), 7);

      ethAmount += (amount * strike) / 1e8;
      rdpxAmount += amount;
      optionsPerStrike[strike] -= amount;
      totalActiveOptions -= amount;

      // Burn option tokens from user
      _burn(optionIds[i]);

      optionPositions[optionIds[i]].strike = 0;
    }

    // Transfer collateral token from perpetual vault to rdpx rdpxV2Core
    collateralToken.safeTransferFrom(
      addresses.perpetualAtlanticVaultLP,
      addresses.rdpxV2Core,
      ethAmount
    );
        //must call sync to update the balance of weth on core contract
    // Transfer rdpx from rdpx rdpxV2Core to perpetual vault
    IERC20WithBurn(addresses.rdpx).safeTransferFrom(
      addresses.rdpxV2Core,
      addresses.perpetualAtlanticVaultLP,
      rdpxAmount
    );

    IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss(
      ethAmount
    );
    IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP)
      .unlockLiquidity(ethAmount);
    IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addRdpx(
      rdpxAmount
    );



    emit Settle(ethAmount, rdpxAmount, optionIds);
  }

Now the issue comes within subtractLoss()

IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss(
     ethAmount
   );

Inside subtractLoss() function this condition below mostly will not be valid because it requires balanceOf collateral to be equal to specific value, Anyone can send 1 WEI to the contract and cause DoS attack L200-L203

require(
      collateral.balanceOf(address(this)) == _totalCollateral - loss,
      "Not enough collateral was sent out"
    );

The condition basically is requiring the collateral balance of PerpetualAtlanticVault address to be equal to _totalCollateral - loss which it will fail mostly because the balance of the collateral will not be equal exactly to _totalCollateral - loss when an attacker sends 1 WEI to the contract.

The condition should check equal or greater than _totalCollateral - loss

Tools Used

Manual Review

Change the require condition to:

require(
      collateral.balanceOf(address(this)) >= _totalCollateral - loss,
      "Not enough collateral was sent out"
    );

Assessed type

Other

#0 - c4-pre-sort

2023-09-09T09:53:46Z

bytes032 marked the issue as duplicate of #619

#1 - c4-pre-sort

2023-09-11T16:14:15Z

bytes032 marked the issue as sufficient quality report

#2 - c4-judge

2023-10-21T07:14:56Z

GalloDaSballo marked the issue as satisfactory

Awards

17.313 USDC - $17.31

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
duplicate-867

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L118-L135

Vulnerability details

Impact

At the deposit() in PerpetualAtlanticVaultLP updateFunding() should be called before previewDeposit(), because updateFunding causing _totalCollateral to increase. because of this, the user gets share higher than what they should get.

Proof of Concept

The issue occur in function deposit() L118-L135 previewDeposit() is called before updateFunding() while it should be the other way.

previewDeposit. require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); perpetualAtlanticVault.updateFunding();
function deposit(
    uint256 assets,
    address receiver
  ) public virtual returns (uint256 shares) {
    // Check for rounding error since we round down in previewDeposit.
    require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES");


    perpetualAtlanticVault.updateFunding();


    // Need to transfer before minting or ERC777s could reenter.
    collateral.transferFrom(msg.sender, address(this), assets);


    _mint(receiver, shares);


    _totalCollateral += assets;


    emit Deposit(msg.sender, receiver, assets, shares);
  }

Please run this foundry POC code, import the file with project source inside tests folder.

run this command: forge test --match-path ./tests/depositUpdateFunding.t.sol -vv

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/FixedPointMathLib.sol)
library FixedPointMathLib {
    /*//////////////////////////////////////////////////////////////
                    SIMPLIFIED FIXED POINT OPERATIONS
    //////////////////////////////////////////////////////////////*/

    uint256 internal constant MAX_UINT256 = 2 ** 256 - 1;

    uint256 internal constant WAD = 1e18; // The scalar of ETH and most ERC20s.

    function mulWadDown(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivDown(x, y, WAD); // Equivalent to (x * y) / WAD rounded down.
    }

    function mulWadUp(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivUp(x, y, WAD); // Equivalent to (x * y) / WAD rounded up.
    }

    function divWadDown(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivDown(x, WAD, y); // Equivalent to (x * WAD) / y rounded down.
    }

    function divWadUp(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivUp(x, WAD, y); // Equivalent to (x * WAD) / y rounded up.
    }

    /*//////////////////////////////////////////////////////////////
                    LOW LEVEL FIXED POINT OPERATIONS
    //////////////////////////////////////////////////////////////*/

    function mulDivDown(
        uint256 x,
        uint256 y,
        uint256 denominator
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y))
            if iszero(
                mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y)))))
            ) {
                revert(0, 0)
            }

            // Divide x * y by the denominator.
            z := div(mul(x, y), denominator)
        }
    }

    function mulDivUp(
        uint256 x,
        uint256 y,
        uint256 denominator
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y))
            if iszero(
                mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y)))))
            ) {
                revert(0, 0)
            }

            // If x * y modulo the denominator is strictly greater than 0,
            // 1 is added to round up the division of x * y by the denominator.
            z := add(
                gt(mod(mul(x, y), denominator), 0),
                div(mul(x, y), denominator)
            )
        }
    }

    function rpow(
        uint256 x,
        uint256 n,
        uint256 scalar
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            switch x
            case 0 {
                switch n
                case 0 {
                    // 0 ** 0 = 1
                    z := scalar
                }
                default {
                    // 0 ** n = 0
                    z := 0
                }
            }
            default {
                switch mod(n, 2)
                case 0 {
                    // If n is even, store scalar in z for now.
                    z := scalar
                }
                default {
                    // If n is odd, store x in z for now.
                    z := x
                }

                // Shifting right by 1 is like dividing by 2.
                let half := shr(1, scalar)

                for {
                    // Shift n right by 1 before looping to halve it.
                    n := shr(1, n)
                } n {
                    // Shift n right by 1 each iteration to halve it.
                    n := shr(1, n)
                } {
                    // Revert immediately if x ** 2 would overflow.
                    // Equivalent to iszero(eq(div(xx, x), x)) here.
                    if shr(128, x) {
                        revert(0, 0)
                    }

                    // Store x squared.
                    let xx := mul(x, x)

                    // Round to the nearest number.
                    let xxRound := add(xx, half)

                    // Revert if xx + half overflowed.
                    if lt(xxRound, xx) {
                        revert(0, 0)
                    }

                    // Set x to scaled xxRound.
                    x := div(xxRound, scalar)

                    // If n is even:
                    if mod(n, 2) {
                        // Compute z * x.
                        let zx := mul(z, x)

                        // If z * x overflowed:
                        if iszero(eq(div(zx, x), z)) {
                            // Revert if x is non-zero.
                            if iszero(iszero(x)) {
                                revert(0, 0)
                            }
                        }

                        // Round to the nearest number.
                        let zxRound := add(zx, half)

                        // Revert if zx + half overflowed.
                        if lt(zxRound, zx) {
                            revert(0, 0)
                        }

                        // Return properly scaled zxRound.
                        z := div(zxRound, scalar)
                    }
                }
            }
        }
    }

    /*//////////////////////////////////////////////////////////////
                        GENERAL NUMBER UTILITIES
    //////////////////////////////////////////////////////////////*/

    function sqrt(uint256 x) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            let y := x // We start y at x, which will help us make our initial estimate.

            z := 181 // The "correct" value is 1, but this saves a multiplication later.

            // This segment is to get a reasonable initial estimate for the Babylonian method. With a bad
            // start, the correct # of bits increases ~linearly each iteration instead of ~quadratically.

            // We check y >= 2^(k + 8) but shift right by k bits
            // each branch to ensure that if x >= 256, then y >= 256.
            if iszero(lt(y, 0x10000000000000000000000000000000000)) {
                y := shr(128, y)
                z := shl(64, z)
            }
            if iszero(lt(y, 0x1000000000000000000)) {
                y := shr(64, y)
                z := shl(32, z)
            }
            if iszero(lt(y, 0x10000000000)) {
                y := shr(32, y)
                z := shl(16, z)
            }
            if iszero(lt(y, 0x1000000)) {
                y := shr(16, y)
                z := shl(8, z)
            }

            // Goal was to get z*z*y within a small factor of x. More iterations could
            // get y in a tighter range. Currently, we will have y in [256, 256*2^16).
            // We ensured y >= 256 so that the relative difference between y and y+1 is small.
            // That's not possible if x < 256 but we can just verify those cases exhaustively.

            // Now, z*z*y <= x < z*z*(y+1), and y <= 2^(16+8), and either y >= 256, or x < 256.
            // Correctness can be checked exhaustively for x < 256, so we assume y >= 256.
            // Then z*sqrt(y) is within sqrt(257)/sqrt(256) of sqrt(x), or about 20bps.

            // For s in the range [1/256, 256], the estimate f(s) = (181/1024) * (s+1) is in the range
            // (1/2.84 * sqrt(s), 2.84 * sqrt(s)), with largest error when s = 1 and when s = 256 or 1/256.

            // Since y is in [256, 256*2^16), let a = y/65536, so that a is in [1/256, 256). Then we can estimate
            // sqrt(y) using sqrt(65536) * 181/1024 * (a + 1) = 181/4 * (y + 65536)/65536 = 181 * (y + 65536)/2^18.

            // There is no overflow risk here since y < 2^136 after the first branch above.
            z := shr(18, mul(z, add(y, 65536))) // A mul() is saved from starting z at 181.

            // Given the worst case multiplicative error of 2.84 above, 7 iterations should be enough.
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))

            // If x+1 is a perfect square, the Babylonian method cycles between
            // floor(sqrt(x)) and ceil(sqrt(x)). This statement ensures we return floor.
            // See: https://en.wikipedia.org/wiki/Integer_square_root#Using_only_integer_division
            // Since the ceil is rare, we save gas on the assignment and repeat division in the rare case.
            // If you don't care whether the floor or ceil square root is returned, you can remove this statement.
            z := sub(z, lt(div(x, z), z))
        }
    }

    function unsafeMod(uint256 x, uint256 y) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Mod x by y. Note this will return
            // 0 instead of reverting if y is zero.
            z := mod(x, y)
        }
    }

    function unsafeDiv(uint256 x, uint256 y) internal pure returns (uint256 r) {
        /// @solidity memory-safe-assembly
        assembly {
            // Divide x by y. Note this will return
            // 0 instead of reverting if y is zero.
            r := div(x, y)
        }
    }

    function unsafeDivUp(
        uint256 x,
        uint256 y
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Add 1 to x * y if x % y > 0. Note this will
            // return 0 instead of reverting if y is zero.
            z := add(gt(mod(x, y), 0), div(x, y))
        }
    }
}

/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC20.sol)
abstract contract ERC20 {
    /*//////////////////////////////////////////////////////////////
                                 EVENTS
    //////////////////////////////////////////////////////////////*/

    event Transfer(address indexed from, address indexed to, uint256 amount);

    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 amount
    );

    /*//////////////////////////////////////////////////////////////
                            METADATA STORAGE
    //////////////////////////////////////////////////////////////*/

    string public name;

    string public symbol;

    uint8 public immutable decimals;

    /*//////////////////////////////////////////////////////////////
                              ERC20 STORAGE
    //////////////////////////////////////////////////////////////*/

    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;

    mapping(address => mapping(address => uint256)) public allowance;

    /*//////////////////////////////////////////////////////////////
                            EIP-2612 STORAGE
    //////////////////////////////////////////////////////////////*/

    uint256 internal immutable INITIAL_CHAIN_ID;

    bytes32 internal immutable INITIAL_DOMAIN_SEPARATOR;

    mapping(address => uint256) public nonces;

    /*//////////////////////////////////////////////////////////////
                               CONSTRUCTOR
    //////////////////////////////////////////////////////////////*/

    constructor(string memory _name, string memory _symbol, uint8 _decimals) {
        name = _name;
        symbol = _symbol;
        decimals = _decimals;

        INITIAL_CHAIN_ID = block.chainid;
        INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator();
    }

    /*//////////////////////////////////////////////////////////////
                               ERC20 LOGIC
    //////////////////////////////////////////////////////////////*/

    function approve(
        address spender,
        uint256 amount
    ) public virtual returns (bool) {
        allowance[msg.sender][spender] = amount;

        emit Approval(msg.sender, spender, amount);

        return true;
    }

    function transfer(
        address to,
        uint256 amount
    ) public virtual returns (bool) {
        balanceOf[msg.sender] -= amount;

        // Cannot overflow because the sum of all user
        // balances can't exceed the max uint256 value.
        unchecked {
            balanceOf[to] += amount;
        }

        emit Transfer(msg.sender, to, amount);

        return true;
    }

    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) public virtual returns (bool) {
        uint256 allowed = allowance[from][msg.sender]; // Saves gas for limited approvals.

        if (allowed != type(uint256).max)
            allowance[from][msg.sender] = allowed - amount;

        balanceOf[from] -= amount;

        // Cannot overflow because the sum of all user
        // balances can't exceed the max uint256 value.
        unchecked {
            balanceOf[to] += amount;
        }

        emit Transfer(from, to, amount);

        return true;
    }

    /*//////////////////////////////////////////////////////////////
                             EIP-2612 LOGIC
    //////////////////////////////////////////////////////////////*/

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");

        // Unchecked because the only math done is incrementing
        // the owner's nonce which cannot realistically overflow.
        unchecked {
            address recoveredAddress = ecrecover(
                keccak256(
                    abi.encodePacked(
                        "\x19\x01",
                        DOMAIN_SEPARATOR(),
                        keccak256(
                            abi.encode(
                                keccak256(
                                    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                                ),
                                owner,
                                spender,
                                value,
                                nonces[owner]++,
                                deadline
                            )
                        )
                    )
                ),
                v,
                r,
                s
            );

            require(
                recoveredAddress != address(0) && recoveredAddress == owner,
                "INVALID_SIGNER"
            );

            allowance[recoveredAddress][spender] = value;
        }

        emit Approval(owner, spender, value);
    }

    function DOMAIN_SEPARATOR() public view virtual returns (bytes32) {
        return
            block.chainid == INITIAL_CHAIN_ID
                ? INITIAL_DOMAIN_SEPARATOR
                : computeDomainSeparator();
    }

    function computeDomainSeparator() internal view virtual returns (bytes32) {
        return
            keccak256(
                abi.encode(
                    keccak256(
                        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
                    ),
                    keccak256(bytes(name)),
                    keccak256("1"),
                    block.chainid,
                    address(this)
                )
            );
    }

    /*//////////////////////////////////////////////////////////////
                        INTERNAL MINT/BURN LOGIC
    //////////////////////////////////////////////////////////////*/

    function _mint(address to, uint256 amount) internal virtual {
        totalSupply += amount;

        // Cannot overflow because the sum of all user
        // balances can't exceed the max uint256 value.
        unchecked {
            balanceOf[to] += amount;
        }

        emit Transfer(address(0), to, amount);
    }

    function _burn(address from, uint256 amount) internal virtual {
        balanceOf[from] -= amount;

        // Cannot underflow because a user's balance
        // will never be larger than the total supply.
        unchecked {
            totalSupply -= amount;
        }

        emit Transfer(from, address(0), amount);
    }
}

contract PerpetualAtlanticVaultLP is ERC20 {
    //   using SafeERC20 for IERC20WithBurn;
    //   using SafeTransferLib for ERC20;
    using FixedPointMathLib for uint256;

    // ================================ EVENTS ================================ //

    event Deposit(
        address indexed caller,
        address indexed owner,
        uint256 assets,
        uint256 shares
    );

    event Withdraw(
        address indexed caller,
        address indexed receiver,
        address indexed owner,
        uint256 assets,
        uint256 shares
    );

    // ================================ STATE VARIABLES ================================ //

    /// @dev The address of the perpetual Atlantic Vault contract creating the lp token
    //   IPerpetualAtlanticVault public perpetualAtlanticVault;

    /// @dev The collateral token
    ERC20 public collateral;

    /// @dev The symbol reperesenting the underlying asset of the perpetualatlanticvault lp
    string public underlyingSymbol;

    /// @dev The symbol representing the collateral token of the perpetualatlanticvault lp
    string public collateralSymbol;

    /// @dev Total collateral available
    uint256 private _totalCollateral;

    /// @dev Active collateral
    uint256 private _activeCollateral;

    /// @dev Total rdpx available
    uint256 private _rdpxCollateral;

    /// @dev address of rdpx token
    address public rdpx;

    /// @dev address of the rdpx rdpxV2Core contract
    address public rdpxRdpxV2Core;

    // ================================ CONSTRUCTOR ================================ //

    constructor()
        ERC20("PerpetualAtlanticVault LP Token", "_collateralSymbol", 18)
    {
        // require(
        //   _perpetualAtlanticVault != address(0) || _rdpx != address(0),
        //   "ZERO_ADDRESS"
        // );
        // perpetualAtlanticVault = IPerpetualAtlanticVault(_perpetualAtlanticVault);
        // rdpxRdpxV2Core = _rdpxRdpxV2Core;
        // collateralSymbol = _collateralSymbol;
        // rdpx = _rdpx;
        // collateral = ERC20(_collateral);
        // symbol = string.concat(_collateralSymbol, "-LP");
        // collateral.approve(_perpetualAtlanticVault, type(uint256).max);
        // ERC20(rdpx).approve(_perpetualAtlanticVault, type(uint256).max);
    }

    // ================================ PUBLIC FUNCTIONS ================================ //

    /**
     * @notice deposit into ERC4626 token
     * @param assets assets
     * @param receiver receiver
     * @return shares shares of LP tokens minted
     */
    function deposit(
        uint256 assets,
        address receiver
    ) public virtual returns (uint256 shares) {
        // Check for rounding error since we round down in previewDeposit.
        require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES");

        // simulation to update funding for demo purpose
        updateFunding();

        // Need to transfer before minting or ERC777s could reenter.
        // collateral.transferFrom(msg.sender, address(this), assets);

        _mint(receiver, shares);

        _totalCollateral += assets;

        emit Deposit(msg.sender, receiver, assets, shares);
    }

    function depositWithoutBug(
        uint256 assets,
        address receiver
    ) public virtual returns (uint256 shares) {
        // simulation to update funding for demo purpose
        updateFunding(); // called before previewDeposit

        // Check for rounding error since we round down in previewDeposit.
        require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES");

        // Need to transfer before minting or ERC777s could reenter.
        // collateral.transferFrom(msg.sender, address(this), assets);

        _mint(receiver, shares);

        _totalCollateral += assets;

        emit Deposit(msg.sender, receiver, assets, shares);
    }

    // simulation to update funding
    function updateFunding() public {
        addProceeds(10e18);
    }

    /**
     * @notice redeem ERC4626 token
     * @param shares shares
     * @param receiver receiver
     * @param owner owner
     * @return assets native tokens to be received
     * @return rdpxAmount rdpx tokens to be received
     */
    function redeem(
        uint256 shares,
        address receiver,
        address owner
    ) public returns (uint256 assets, uint256 rdpxAmount) {
        // perpetualAtlanticVault.updateFunding();

        if (msg.sender != owner) {
            uint256 allowed = allowance[owner][msg.sender]; // Saves gas for limited approvals.

            if (allowed != type(uint256).max) {
                allowance[owner][msg.sender] = allowed - shares;
            }
        }
        (assets, rdpxAmount) = redeemPreview(shares);

        // Check for rounding error since we round down in previewRedeem.
        require(assets != 0, "ZERO_ASSETS");

        _rdpxCollateral -= rdpxAmount;

        beforeWithdraw(assets, shares);

        _burn(owner, shares);

        // collateral.transfer(receiver, assets);

        // IERC20WithBurn(rdpx).safeTransfer(receiver, rdpxAmount);

        emit Withdraw(msg.sender, receiver, owner, assets, shares);
    }

    function addRdpx(uint256 amount) public {
        // require(
        //   IERC20WithBurn(rdpx).balanceOf(address(this)) >= _rdpxCollateral + amount,
        //   "Not enough rdpx token was sent"
        // );
        _rdpxCollateral += amount;
    }

    function addProceeds(uint256 proceeds) public {
        // require(
        //   collateral.balanceOf(address(this)) >= _totalCollateral + proceeds,
        //   "Not enough collateral token was sent"
        // );
        _totalCollateral += proceeds;
    }

    // ================================ INTERNAL FUNCTUONS ================================ //

    function _convertToAssets(
        uint256 shares
    ) internal view virtual returns (uint256 assets, uint256 rdpxAmount) {
        uint256 supply = totalSupply;
        return
            (supply == 0)
                ? (shares, 0)
                : (
                    shares.mulDivDown(totalCollateral(), supply),
                    shares.mulDivDown(_rdpxCollateral, supply)
                );
    }

    function _beforeTokenTransfer(
        address from,
        address,
        uint256
    ) internal virtual {}

    // ================================ VIEWS ================================ //

    /// @notice Returns the total active collateral
    function activeCollateral() public view returns (uint256) {
        return _activeCollateral;
    }

    /// @notice Returns the total collateral
    function totalCollateral() public view returns (uint256) {
        return _totalCollateral;
    }

    /// @notice Returns the total rdpx collateral
    function rdpxCollateral() public view returns (uint256) {
        return _rdpxCollateral;
    }

    /// @notice Returns the total available collateral
    function totalAvailableCollateral() public view returns (uint256) {
        return _totalCollateral - _activeCollateral;
    }

    // ================================ PUBLIC VIEW FUNCTIONS ================================ //

    /// @notice Returns the amount of collateral and rdpx per share
    function redeemPreview(
        uint256 shares
    ) public view returns (uint256, uint256) {
        return _convertToAssets(shares);
    }

    /// @notice Returns the amount of shares recieved for a given amount of assets
    function previewDeposit(uint256 assets) public view returns (uint256) {
        return convertToShares(assets);
    }

    /// @notice Returns the amount of shares recieved for a given amount of assets
    function convertToShares(
        uint256 assets
    ) public view returns (uint256 shares) {
        uint256 supply = totalSupply;
        uint256 rdpxPriceInAlphaToken = 1e6; // fixed price for demo purpose  perpetualAtlanticVault.getUnderlyingPrice();

        uint256 totalVaultCollateral = totalCollateral() +
            ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8);
        return
            supply == 0
                ? assets
                : assets.mulDivDown(supply, totalVaultCollateral);
    }

    function beforeWithdraw(uint256 assets, uint256 /*shares*/) internal {
        require(
            assets <= totalAvailableCollateral(),
            "Not enough available assets to satisfy withdrawal"
        );
        _totalCollateral -= assets;
    }
}

contract VaultLPTest is Test {
    PerpetualAtlanticVaultLP perpetualAtlanticVaultLP;
    address users = vm.addr(1);
    address user = vm.addr(2);

    function setUp() public {
        perpetualAtlanticVaultLP = new PerpetualAtlanticVaultLP();
    }

    function test_deposit() public {
        vm.startPrank(users);
        perpetualAtlanticVaultLP.deposit(50e18, users); // add some deposit
        vm.stopPrank();
        perpetualAtlanticVaultLP.addRdpx(20e18); // add some rdpx

        vm.startPrank(user);
        uint256 assets = 100e18;
        uint256 shares = perpetualAtlanticVaultLP.deposit(assets, user);
        console.log("updateFunding() called after previewDeposit");
        console.log("shares %d", shares);
        vm.stopPrank();
    }

      function test_deposit_without_bug() public {
        vm.startPrank(users);
        perpetualAtlanticVaultLP.deposit(50e18, users); // add some deposit
        vm.stopPrank();
        perpetualAtlanticVaultLP.addRdpx(20e18); // add some rdpx

        vm.startPrank(user);
        uint256 assets = 100e18;
        uint256 shares = perpetualAtlanticVaultLP.depositWithoutBug(assets, user);
        console.log("updateFunding() called before previewDeposit");
        console.log("shares %d", shares);
        vm.stopPrank();
    }
}

output:

[PASS] test_deposit() (gas: 141613) Logs: updateFunding() called after previewDeposit shares 83056478405315614617 [PASS] test_deposit_without_bug() (gas: 141700) Logs: updateFunding() called before previewDeposit shares 71225071225071225071

Tools Used

Manual Review

Call updateFunding() before previewDeposit()

Assessed type

Other

#0 - c4-pre-sort

2023-09-09T16:42:22Z

bytes032 marked the issue as duplicate of #867

#1 - c4-pre-sort

2023-09-11T09:08:22Z

bytes032 marked the issue as sufficient quality report

#2 - c4-judge

2023-10-20T19:23:33Z

GalloDaSballo marked the issue as satisfactory

Findings Information

Awards

96.3292 USDC - $96.33

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
sufficient quality report
duplicate-549

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L513-L516

Vulnerability details

Impact

a possible collateralToken transfer to perpetualAtlanticVaultLP with an unexpected and incorrect amount according to collateralToken type. This happen when collateralToken has less or more than 18 decimals

Proof of Concept

Since the collateralToken is ETH (i.e. weth) then things will work fine in the transfer at updateFunding(). However, the protocol could add different token other than ETH and that will cause different result of the transferred amount,

In updateFunding() function, collateralToken transferred amount should be normalized before sending funds to perpetualAtlanticVaultLP L513-L516

collateralToken.safeTransfer(
     addresses.perpetualAtlanticVaultLP,
     (currentFundingRate * (block.timestamp - startTime)) / 1e18
   );

Actually the issue starts with _purchaseOptions()which calls purchase() of the vault L481

(premium, optionId) = IPerpetualAtlanticVault(
      addresses.perpetualAtlanticVault
    ).purchase(_amount, address(this));

The _amount that passed above into purchase is 18 decimals of RDPX token, now inside purchase() function calculatePremium() is called on same amount and returning premium value, and since premium calculated on 18 decimals, _updateFundingRate(premium) is calculating funding rate relying on premium value.

Let's check transfer amount here which include currentFundingRate inside updateFunding() function :

(currentFundingRate * (block.timestamp - startTime)) / 1e18

Above is the amount that will be transferred to addresses.perpetualAtlanticVaultLP which will work fine since the default likely collateralToken is ETH (18 decimals), but since the protocol could have different collateralToken then we would have an issue with the amount being calculated relied on 18 decimals all the way till being passed to updateFunding() function.

Because other collateral Tokens possible to have less or more than 18 decimals and then you have incorrect amount being transferred from perpetualAtlanticVault to perpetualAtlanticVaultLP

Tools Used

Manual Review

Normalise the amount to 18 decimals to take into account other collateral tokens that have less or more than 18 decimals

Assessed type

Other

#0 - c4-pre-sort

2023-09-13T13:32:37Z

bytes032 marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-09-15T10:14:06Z

bytes032 marked the issue as duplicate of #549

#2 - c4-judge

2023-10-20T18:27:59Z

GalloDaSballo marked the issue as satisfactory

#3 - c4-judge

2023-10-20T18:28:21Z

GalloDaSballo changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: LokiThe5th

Also found by: 0xnev, Evo, volodya

Labels

bug
2 (Med Risk)
downgraded by judge
low quality report
satisfactory
duplicate-2130

Awards

909.7371 USDC - $909.74

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L127-L133

Vulnerability details

Impact

PerpetaulAtlanticVaultLP allows users to deposit() and redeem(). When you deposit, you only deposit assets (e.g. Weth) which is the main collateral token L127-L133

    // Need to transfer before minting or ERC777s could reenter.
    collateral.transferFrom(msg.sender, address(this), assets);

    _mint(receiver, shares);

    _totalCollateral += assets;

But when you redeem, you get a share from both the asset and rDPX L170-L173

    collateral.transfer(receiver, assets);

    IERC20WithBurn(rdpx).safeTransfer(receiver, rdpxAmount);

Assume PerpetaulAtlanticVaultLP = VaultLP, Initially rDPX balance in the vault is zero. However, when settling options, Weth is transferred from VaultLP to core contract and rDPX is transferred from core contract to VaultLP.

A malicious actor can buy all rDPX from VaultLP by repeating deposit() and redeem() over and over. Eventually, receiving all the rDPX amount from the vault. After that, the actor sells rDPX in the market dumping it which cause lowering the price of rDPX. Because of this, the admin might have to to reLP() to raise the price of rDPX. This means, losing liquidity (Weth) from uniswap pool (i.e. addresses.ammRouter).

From docs, to understand reLP process

reLP is the process by which part of the Uniswap V2 liquidity is removed, then the ETH received is swapped to rDPX. This essentially increases the price of rDPX and also increases the Backing Reserve of rDPX

There is no loss for the actor (except gas fees), since the actor is selling rDPX immediately. So, the actor is getting back their Weth. So, the cost of the attack is cheap.

Impact summary:

  1. VaultLP rDPX are bought and sold in the market.
  2. rDPX price goes lower.
  3. assuming reLP() process is active. reLP process is triggered.
  4. Core Contract Weth reserve balance decreases.
  5. Core Contract rDPX reserve balance increases.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/FixedPointMathLib.sol)
library FixedPointMathLib {
    /*//////////////////////////////////////////////////////////////
                    SIMPLIFIED FIXED POINT OPERATIONS
    //////////////////////////////////////////////////////////////*/

    uint256 internal constant MAX_UINT256 = 2 ** 256 - 1;

    uint256 internal constant WAD = 1e18; // The scalar of ETH and most ERC20s.

    function mulWadDown(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivDown(x, y, WAD); // Equivalent to (x * y) / WAD rounded down.
    }

    function mulWadUp(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivUp(x, y, WAD); // Equivalent to (x * y) / WAD rounded up.
    }

    function divWadDown(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivDown(x, WAD, y); // Equivalent to (x * WAD) / y rounded down.
    }

    function divWadUp(uint256 x, uint256 y) internal pure returns (uint256) {
        return mulDivUp(x, WAD, y); // Equivalent to (x * WAD) / y rounded up.
    }

    /*//////////////////////////////////////////////////////////////
                    LOW LEVEL FIXED POINT OPERATIONS
    //////////////////////////////////////////////////////////////*/

    function mulDivDown(
        uint256 x,
        uint256 y,
        uint256 denominator
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y))
            if iszero(
                mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y)))))
            ) {
                revert(0, 0)
            }

            // Divide x * y by the denominator.
            z := div(mul(x, y), denominator)
        }
    }

    function mulDivUp(
        uint256 x,
        uint256 y,
        uint256 denominator
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y))
            if iszero(
                mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y)))))
            ) {
                revert(0, 0)
            }

            // If x * y modulo the denominator is strictly greater than 0,
            // 1 is added to round up the division of x * y by the denominator.
            z := add(
                gt(mod(mul(x, y), denominator), 0),
                div(mul(x, y), denominator)
            )
        }
    }

    function rpow(
        uint256 x,
        uint256 n,
        uint256 scalar
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            switch x
            case 0 {
                switch n
                case 0 {
                    // 0 ** 0 = 1
                    z := scalar
                }
                default {
                    // 0 ** n = 0
                    z := 0
                }
            }
            default {
                switch mod(n, 2)
                case 0 {
                    // If n is even, store scalar in z for now.
                    z := scalar
                }
                default {
                    // If n is odd, store x in z for now.
                    z := x
                }

                // Shifting right by 1 is like dividing by 2.
                let half := shr(1, scalar)

                for {
                    // Shift n right by 1 before looping to halve it.
                    n := shr(1, n)
                } n {
                    // Shift n right by 1 each iteration to halve it.
                    n := shr(1, n)
                } {
                    // Revert immediately if x ** 2 would overflow.
                    // Equivalent to iszero(eq(div(xx, x), x)) here.
                    if shr(128, x) {
                        revert(0, 0)
                    }

                    // Store x squared.
                    let xx := mul(x, x)

                    // Round to the nearest number.
                    let xxRound := add(xx, half)

                    // Revert if xx + half overflowed.
                    if lt(xxRound, xx) {
                        revert(0, 0)
                    }

                    // Set x to scaled xxRound.
                    x := div(xxRound, scalar)

                    // If n is even:
                    if mod(n, 2) {
                        // Compute z * x.
                        let zx := mul(z, x)

                        // If z * x overflowed:
                        if iszero(eq(div(zx, x), z)) {
                            // Revert if x is non-zero.
                            if iszero(iszero(x)) {
                                revert(0, 0)
                            }
                        }

                        // Round to the nearest number.
                        let zxRound := add(zx, half)

                        // Revert if zx + half overflowed.
                        if lt(zxRound, zx) {
                            revert(0, 0)
                        }

                        // Return properly scaled zxRound.
                        z := div(zxRound, scalar)
                    }
                }
            }
        }
    }

    /*//////////////////////////////////////////////////////////////
                        GENERAL NUMBER UTILITIES
    //////////////////////////////////////////////////////////////*/

    function sqrt(uint256 x) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            let y := x // We start y at x, which will help us make our initial estimate.

            z := 181 // The "correct" value is 1, but this saves a multiplication later.

            // This segment is to get a reasonable initial estimate for the Babylonian method. With a bad
            // start, the correct # of bits increases ~linearly each iteration instead of ~quadratically.

            // We check y >= 2^(k + 8) but shift right by k bits
            // each branch to ensure that if x >= 256, then y >= 256.
            if iszero(lt(y, 0x10000000000000000000000000000000000)) {
                y := shr(128, y)
                z := shl(64, z)
            }
            if iszero(lt(y, 0x1000000000000000000)) {
                y := shr(64, y)
                z := shl(32, z)
            }
            if iszero(lt(y, 0x10000000000)) {
                y := shr(32, y)
                z := shl(16, z)
            }
            if iszero(lt(y, 0x1000000)) {
                y := shr(16, y)
                z := shl(8, z)
            }

            // Goal was to get z*z*y within a small factor of x. More iterations could
            // get y in a tighter range. Currently, we will have y in [256, 256*2^16).
            // We ensured y >= 256 so that the relative difference between y and y+1 is small.
            // That's not possible if x < 256 but we can just verify those cases exhaustively.

            // Now, z*z*y <= x < z*z*(y+1), and y <= 2^(16+8), and either y >= 256, or x < 256.
            // Correctness can be checked exhaustively for x < 256, so we assume y >= 256.
            // Then z*sqrt(y) is within sqrt(257)/sqrt(256) of sqrt(x), or about 20bps.

            // For s in the range [1/256, 256], the estimate f(s) = (181/1024) * (s+1) is in the range
            // (1/2.84 * sqrt(s), 2.84 * sqrt(s)), with largest error when s = 1 and when s = 256 or 1/256.

            // Since y is in [256, 256*2^16), let a = y/65536, so that a is in [1/256, 256). Then we can estimate
            // sqrt(y) using sqrt(65536) * 181/1024 * (a + 1) = 181/4 * (y + 65536)/65536 = 181 * (y + 65536)/2^18.

            // There is no overflow risk here since y < 2^136 after the first branch above.
            z := shr(18, mul(z, add(y, 65536))) // A mul() is saved from starting z at 181.

            // Given the worst case multiplicative error of 2.84 above, 7 iterations should be enough.
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))
            z := shr(1, add(z, div(x, z)))

            // If x+1 is a perfect square, the Babylonian method cycles between
            // floor(sqrt(x)) and ceil(sqrt(x)). This statement ensures we return floor.
            // See: https://en.wikipedia.org/wiki/Integer_square_root#Using_only_integer_division
            // Since the ceil is rare, we save gas on the assignment and repeat division in the rare case.
            // If you don't care whether the floor or ceil square root is returned, you can remove this statement.
            z := sub(z, lt(div(x, z), z))
        }
    }

    function unsafeMod(uint256 x, uint256 y) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Mod x by y. Note this will return
            // 0 instead of reverting if y is zero.
            z := mod(x, y)
        }
    }

    function unsafeDiv(uint256 x, uint256 y) internal pure returns (uint256 r) {
        /// @solidity memory-safe-assembly
        assembly {
            // Divide x by y. Note this will return
            // 0 instead of reverting if y is zero.
            r := div(x, y)
        }
    }

    function unsafeDivUp(
        uint256 x,
        uint256 y
    ) internal pure returns (uint256 z) {
        /// @solidity memory-safe-assembly
        assembly {
            // Add 1 to x * y if x % y > 0. Note this will
            // return 0 instead of reverting if y is zero.
            z := add(gt(mod(x, y), 0), div(x, y))
        }
    }
}

/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC20.sol)
abstract contract ERC20 {
    /*//////////////////////////////////////////////////////////////
                                 EVENTS
    //////////////////////////////////////////////////////////////*/

    event Transfer(address indexed from, address indexed to, uint256 amount);

    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 amount
    );

    /*//////////////////////////////////////////////////////////////
                            METADATA STORAGE
    //////////////////////////////////////////////////////////////*/

    string public name;

    string public symbol;

    uint8 public immutable decimals;

    /*//////////////////////////////////////////////////////////////
                              ERC20 STORAGE
    //////////////////////////////////////////////////////////////*/

    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;

    mapping(address => mapping(address => uint256)) public allowance;

    /*//////////////////////////////////////////////////////////////
                            EIP-2612 STORAGE
    //////////////////////////////////////////////////////////////*/

    uint256 internal immutable INITIAL_CHAIN_ID;

    bytes32 internal immutable INITIAL_DOMAIN_SEPARATOR;

    mapping(address => uint256) public nonces;

    /*//////////////////////////////////////////////////////////////
                               CONSTRUCTOR
    //////////////////////////////////////////////////////////////*/

    constructor(string memory _name, string memory _symbol, uint8 _decimals) {
        name = _name;
        symbol = _symbol;
        decimals = _decimals;

        INITIAL_CHAIN_ID = block.chainid;
        INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator();
    }

    /*//////////////////////////////////////////////////////////////
                               ERC20 LOGIC
    //////////////////////////////////////////////////////////////*/

    function approve(
        address spender,
        uint256 amount
    ) public virtual returns (bool) {
        allowance[msg.sender][spender] = amount;

        emit Approval(msg.sender, spender, amount);

        return true;
    }

    function transfer(
        address to,
        uint256 amount
    ) public virtual returns (bool) {
        balanceOf[msg.sender] -= amount;

        // Cannot overflow because the sum of all user
        // balances can't exceed the max uint256 value.
        unchecked {
            balanceOf[to] += amount;
        }

        emit Transfer(msg.sender, to, amount);

        return true;
    }

    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) public virtual returns (bool) {
        uint256 allowed = allowance[from][msg.sender]; // Saves gas for limited approvals.

        if (allowed != type(uint256).max)
            allowance[from][msg.sender] = allowed - amount;

        balanceOf[from] -= amount;

        // Cannot overflow because the sum of all user
        // balances can't exceed the max uint256 value.
        unchecked {
            balanceOf[to] += amount;
        }

        emit Transfer(from, to, amount);

        return true;
    }

    /*//////////////////////////////////////////////////////////////
                             EIP-2612 LOGIC
    //////////////////////////////////////////////////////////////*/

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");

        // Unchecked because the only math done is incrementing
        // the owner's nonce which cannot realistically overflow.
        unchecked {
            address recoveredAddress = ecrecover(
                keccak256(
                    abi.encodePacked(
                        "\x19\x01",
                        DOMAIN_SEPARATOR(),
                        keccak256(
                            abi.encode(
                                keccak256(
                                    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                                ),
                                owner,
                                spender,
                                value,
                                nonces[owner]++,
                                deadline
                            )
                        )
                    )
                ),
                v,
                r,
                s
            );

            require(
                recoveredAddress != address(0) && recoveredAddress == owner,
                "INVALID_SIGNER"
            );

            allowance[recoveredAddress][spender] = value;
        }

        emit Approval(owner, spender, value);
    }

    function DOMAIN_SEPARATOR() public view virtual returns (bytes32) {
        return
            block.chainid == INITIAL_CHAIN_ID
                ? INITIAL_DOMAIN_SEPARATOR
                : computeDomainSeparator();
    }

    function computeDomainSeparator() internal view virtual returns (bytes32) {
        return
            keccak256(
                abi.encode(
                    keccak256(
                        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
                    ),
                    keccak256(bytes(name)),
                    keccak256("1"),
                    block.chainid,
                    address(this)
                )
            );
    }

    /*//////////////////////////////////////////////////////////////
                        INTERNAL MINT/BURN LOGIC
    //////////////////////////////////////////////////////////////*/

    function _mint(address to, uint256 amount) internal virtual {
        totalSupply += amount;

        // Cannot overflow because the sum of all user
        // balances can't exceed the max uint256 value.
        unchecked {
            balanceOf[to] += amount;
        }

        emit Transfer(address(0), to, amount);
    }

    function _burn(address from, uint256 amount) internal virtual {
        balanceOf[from] -= amount;

        // Cannot underflow because a user's balance
        // will never be larger than the total supply.
        unchecked {
            totalSupply -= amount;
        }

        emit Transfer(from, address(0), amount);
    }
}

contract PerpetualAtlanticVaultLP is ERC20 {
    //   using SafeERC20 for IERC20WithBurn;
    //   using SafeTransferLib for ERC20;
    using FixedPointMathLib for uint256;

    // ================================ EVENTS ================================ //

    event Deposit(
        address indexed caller,
        address indexed owner,
        uint256 assets,
        uint256 shares
    );

    event Withdraw(
        address indexed caller,
        address indexed receiver,
        address indexed owner,
        uint256 assets,
        uint256 shares
    );

    // ================================ STATE VARIABLES ================================ //

    /// @dev The address of the perpetual Atlantic Vault contract creating the lp token
    //   IPerpetualAtlanticVault public perpetualAtlanticVault;

    /// @dev The collateral token
    ERC20 public collateral;

    /// @dev The symbol reperesenting the underlying asset of the perpetualatlanticvault lp
    string public underlyingSymbol;

    /// @dev The symbol representing the collateral token of the perpetualatlanticvault lp
    string public collateralSymbol;

    /// @dev Total collateral available
    uint256 private _totalCollateral;

    /// @dev Active collateral
    uint256 private _activeCollateral;

    /// @dev Total rdpx available
    uint256 private _rdpxCollateral;

    /// @dev address of rdpx token
    address public rdpx;

    /// @dev address of the rdpx rdpxV2Core contract
    address public rdpxRdpxV2Core;

    // ================================ CONSTRUCTOR ================================ //

    constructor()
        ERC20("PerpetualAtlanticVault LP Token", "_collateralSymbol", 18)
    {
        // require(
        //   _perpetualAtlanticVault != address(0) || _rdpx != address(0),
        //   "ZERO_ADDRESS"
        // );
        // perpetualAtlanticVault = IPerpetualAtlanticVault(_perpetualAtlanticVault);
        // rdpxRdpxV2Core = _rdpxRdpxV2Core;
        // collateralSymbol = _collateralSymbol;
        // rdpx = _rdpx;
        // collateral = ERC20(_collateral);
        // symbol = string.concat(_collateralSymbol, "-LP");
        // collateral.approve(_perpetualAtlanticVault, type(uint256).max);
        // ERC20(rdpx).approve(_perpetualAtlanticVault, type(uint256).max);
    }

    // ================================ PUBLIC FUNCTIONS ================================ //

    /**
     * @notice deposit into ERC4626 token
     * @param assets assets
     * @param receiver receiver
     * @return shares shares of LP tokens minted
     */
    function deposit(
        uint256 assets,
        address receiver
    ) public virtual returns (uint256 shares) {
        // Check for rounding error since we round down in previewDeposit.
        require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES");

        // perpetualAtlanticVault.updateFunding();

        // Need to transfer before minting or ERC777s could reenter.
        // collateral.transferFrom(msg.sender, address(this), assets);

        _mint(receiver, shares);

        _totalCollateral += assets;

        emit Deposit(msg.sender, receiver, assets, shares);
    }

    /**
     * @notice redeem ERC4626 token
     * @param shares shares
     * @param receiver receiver
     * @param owner owner
     * @return assets native tokens to be received
     * @return rdpxAmount rdpx tokens to be received
     */
    function redeem(
        uint256 shares,
        address receiver,
        address owner
    ) public returns (uint256 assets, uint256 rdpxAmount) {
        // perpetualAtlanticVault.updateFunding();

        if (msg.sender != owner) {
            uint256 allowed = allowance[owner][msg.sender]; // Saves gas for limited approvals.

            if (allowed != type(uint256).max) {
                allowance[owner][msg.sender] = allowed - shares;
            }
        }
        (assets, rdpxAmount) = redeemPreview(shares);

        // Check for rounding error since we round down in previewRedeem.
        require(assets != 0, "ZERO_ASSETS");

        _rdpxCollateral -= rdpxAmount;

        beforeWithdraw(assets, shares);

        _burn(owner, shares);

        // collateral.transfer(receiver, assets);

        // IERC20WithBurn(rdpx).safeTransfer(receiver, rdpxAmount);

        emit Withdraw(msg.sender, receiver, owner, assets, shares);
    }

    function addRdpx(uint256 amount) public {
        // require(
        //   IERC20WithBurn(rdpx).balanceOf(address(this)) >= _rdpxCollateral + amount,
        //   "Not enough rdpx token was sent"
        // );
        _rdpxCollateral += amount;
    }

    // ================================ INTERNAL FUNCTUONS ================================ //

    function _convertToAssets(
        uint256 shares
    ) internal view virtual returns (uint256 assets, uint256 rdpxAmount) {
        uint256 supply = totalSupply;
        return
            (supply == 0)
                ? (shares, 0)
                : (
                    shares.mulDivDown(totalCollateral(), supply),
                    shares.mulDivDown(_rdpxCollateral, supply)
                );
    }

    function _beforeTokenTransfer(
        address from,
        address,
        uint256
    ) internal virtual {}

    // ================================ VIEWS ================================ //

    /// @notice Returns the total active collateral
    function activeCollateral() public view returns (uint256) {
        return _activeCollateral;
    }

    /// @notice Returns the total collateral
    function totalCollateral() public view returns (uint256) {
        return _totalCollateral;
    }

    /// @notice Returns the total rdpx collateral
    function rdpxCollateral() public view returns (uint256) {
        return _rdpxCollateral;
    }

    /// @notice Returns the total available collateral
    function totalAvailableCollateral() public view returns (uint256) {
        return _totalCollateral - _activeCollateral;
    }

    // ================================ PUBLIC VIEW FUNCTIONS ================================ //

    /// @notice Returns the amount of collateral and rdpx per share
    function redeemPreview(
        uint256 shares
    ) public view returns (uint256, uint256) {
        return _convertToAssets(shares);
    }

    /// @notice Returns the amount of shares recieved for a given amount of assets
    function previewDeposit(uint256 assets) public view returns (uint256) {
        return convertToShares(assets);
    }

    /// @notice Returns the amount of shares recieved for a given amount of assets
    function convertToShares(
        uint256 assets
    ) public view returns (uint256 shares) {
        uint256 supply = totalSupply;
        uint256 rdpxPriceInAlphaToken = 1e6; // fixed price for demo purpose  perpetualAtlanticVault.getUnderlyingPrice();

        uint256 totalVaultCollateral = totalCollateral() +
            ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8);
        return
            supply == 0
                ? assets
                : assets.mulDivDown(supply, totalVaultCollateral);
    }

    function beforeWithdraw(uint256 assets, uint256 /*shares*/) internal {
        require(
            assets <= totalAvailableCollateral(),
            "Not enough available assets to satisfy withdrawal"
        );
        _totalCollateral -= assets;
    }
}

contract VaultLPTest is Test {
    PerpetualAtlanticVaultLP perpetualAtlanticVaultLP;
    address users = vm.addr(1);
    address maliciousActor = vm.addr(2);

    function setUp() public {
        perpetualAtlanticVaultLP = new PerpetualAtlanticVaultLP();
    }

    function test_deposit_redeem() public {
        vm.startPrank(users);
        perpetualAtlanticVaultLP.deposit(50e18, users); // add some deposit
        vm.stopPrank();
        perpetualAtlanticVaultLP.addRdpx(20e18); // add some rdpx

        console.log(
            "totalCollateral Before: %d ",
            perpetualAtlanticVaultLP.totalCollateral()
        );
        console.log(
            "rdpxCollateral Before:  %d ",
            perpetualAtlanticVaultLP.rdpxCollateral()
        );

        uint256 maliciousActorRDPXBalance;

        console.log("==Attack starts here==");

        console.log("One TX, deposit and redeem, repeat 3 times");
        vm.startPrank(maliciousActor);
        uint256 assets = 100e18; // start with 100
        uint256 rdpxAmount;
        uint256 shares = perpetualAtlanticVaultLP.deposit(
            assets,
            maliciousActor
        );
        (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem(
            shares,
            maliciousActor,
            maliciousActor
        );
        maliciousActorRDPXBalance += rdpxAmount;

        // repeat #1
        shares = perpetualAtlanticVaultLP.deposit(assets, maliciousActor);
        (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem(
            shares,
            maliciousActor,
            maliciousActor
        );
        maliciousActorRDPXBalance += rdpxAmount;

        // repeat #2
        shares = perpetualAtlanticVaultLP.deposit(assets, maliciousActor);
        (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem(
            shares,
            maliciousActor,
            maliciousActor
        );
        maliciousActorRDPXBalance += rdpxAmount;

        // repeat  #3
        shares = perpetualAtlanticVaultLP.deposit(assets, maliciousActor);
        (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem(
            shares,
            maliciousActor,
            maliciousActor
        );
        maliciousActorRDPXBalance += rdpxAmount;



        console.log("==Attack ends here==");



        console.log(
            "totalCollateral After: %d ",
            perpetualAtlanticVaultLP.totalCollateral()
        );
        console.log(
            "rdpxCollateral After:  %d ",
            perpetualAtlanticVaultLP.rdpxCollateral()
        );

        // Attacker now has 19749607529375632688 => approx. 19.7 rDPX

        console.log(
            "maliciousActor rDPX balance: %d ",
            maliciousActorRDPXBalance
        );
    }
}

output:

[PASS] test_deposit_redeem() (gas: 205808) Logs: totalCollateral Before: 50000000000000000000 rdpxCollateral Before: 20000000000000000000 ==Attack starts here== One TX, deposit and redeem, repeat 3 times ==Attack ends here== totalCollateral After: 50197496075293756328 rdpxCollateral After: 250392470624367312 maliciousActor rDPX balance: 19749607529375632688

Tools Used

Manual analysis

So the Mitigation here depends on how the sponsor would like the protocol to function. One possibility is to avoid activating reLP if a sudden change in rDPX amount occurs in VaultLP. This can be done by tracking rdpxBalance of the VaultLP in reLP contract over a period of time (for example the balance 1 minute ago, 5 minute ago ..etc.) and take the average of it. If the average is too big, then skip reLP because we know the price drops unnaturally from selling this big amount or at least give the admin the option to confirm reLP regardless the reason of price drop.

Another possibility is not to allow deposit, redeem in the same TX multiple times, this is will allow the fetched price of rdpx to be updated. Therefore, making this attack not realsitic/reliable and more expensive.

Assessed type

Other

#0 - c4-pre-sort

2023-09-10T07:18:03Z

bytes032 marked the issue as low quality report

#1 - GalloDaSballo

2023-10-03T07:12:18Z

This seems legitimate

It would ultimately depend on the rate of exchange in the vault vs the market

#2 - c4-judge

2023-10-20T08:43:05Z

GalloDaSballo marked the issue as duplicate of #2130

#3 - c4-judge

2023-10-20T19:41:52Z

GalloDaSballo changed the severity to 2 (Med Risk)

#4 - c4-judge

2023-10-20T19:41:58Z

GalloDaSballo marked the issue as satisfactory

Findings Information

🌟 Selected for report: gjaldon

Also found by: Evo, HChang26, QiuhaoLi, Toshii, Yanchuan, peakbolt, rvierdiiev, tapir

Labels

bug
2 (Med Risk)
satisfactory
duplicate-750

Awards

238.7514 USDC - $238.75

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L159-L160

Vulnerability details

Impact

In PerpetaulAtlanticVaultLP, users can redeem() LP token to receive collateral token (e.g. WETH) and rdpx. To calculate how many the user should receive from each, redeemPreview is called L159-L160

    (assets, rdpxAmount) = redeemPreview(shares);

If assets are zero, then revert L161-L163

    // Check for rounding error since we round down in previewRedeem.
    require(assets != 0, "ZERO_ASSETS");

In this case, the user won't be able to redeem their rdpx. This becomes an issue if Weth balance is going so low and rdpx is going high due to losses where options are settled L346-L358

    // Transfer collateral token from perpetual vault to rdpx rdpxV2Core
    collateralToken.safeTransferFrom(
      addresses.perpetualAtlanticVaultLP,
      addresses.rdpxV2Core,
      ethAmount
    );
    // Transfer rdpx from rdpx rdpxV2Core to perpetual vault
    IERC20WithBurn(addresses.rdpx).safeTransferFrom(
      addresses.rdpxV2Core,
      addresses.perpetualAtlanticVaultLP,
      rdpxAmount
    );

So users with big shares will have no problem. However, once they redeem they are contributing to lowering the asset balance leaving users with small shares at risk of not being able to redeem at least rdpx.

Proof of Concept

If assets are zero, it reverts regardless of rdpx L161-L163

    // Check for rounding error since we round down in previewRedeem.
    require(assets != 0, "ZERO_ASSETS");

Tools Used

Manual analysis

Consider giving an option to the user to redeem their share to rdpx in case the returned assets (eth) for the share is zero.

Assessed type

Other

#0 - c4-pre-sort

2023-09-15T06:14:59Z

bytes032 marked the issue as duplicate of #750

#1 - c4-judge

2023-10-15T18:03:15Z

GalloDaSballo marked the issue as satisfactory

Findings Information

🌟 Selected for report: dirk_y

Also found by: Brenzee, Evo, T1MOH, ladboy233

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-598

Awards

655.0107 USDC - $655.01

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L894-L898 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L903-L907

Vulnerability details

Impact

bond transaction could cost the user higher than expected.

Proof of Concept

bond function doesn't have protection for the user. if the bond cost goes high before the TX is processed. more ETH and rDPX from the user will be taken.

In case a price change in rdpx or options suddenly according to the market volatility or other reasons, rdpxRequired and wethRequired that come out of calculateBondCost() will be higher. The user could pay more than expected and cost a higher value, A protection for the user as a max price is mandatory so he pays till his max that he can afford, otherwise the transaction should revert.

As you see below in bond() there is no max price param, the function only rely on the _amount L894-L898

function bond(
   uint256 _amount,
   uint256 rdpxBondId,
   address _to
 ) public returns (uint256 receiptTokenAmount) {

The issue occur here at Compute the bond cost at L903-L907

(uint256 rdpxRequired, uint256 wethRequired) = calculateBondCost(
      _amount,
      rdpxBondId
    );

If the price of rDPX changed for any reason the user will pay more and that could be unfair to the user since he is expecting a specific rdpxRequired & wethRequired to be taken according to the current price.

Tools Used

Manual Review

  • Add MaxPrice as param being passed by user in bond()
  • Consider involving MaxPrice in calculateBondCost()

Assessed type

Other

#0 - c4-pre-sort

2023-09-13T10:31:37Z

bytes032 marked the issue as duplicate of #598

#1 - c4-pre-sort

2023-09-13T10:31:43Z

bytes032 marked the issue as sufficient quality report

#2 - c4-judge

2023-10-20T09:33:43Z

GalloDaSballo marked the issue as satisfactory

Awards

7.8372 USDC - $7.84

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-269

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L779-L781

Vulnerability details

Impact

In core contract, settle is called to settles the options. It calls settle function of perpetualAtlanticVault. This function returns amountOfWeth and rdpxAmount. After that, reserve balances are updated in core contract L779-L781

	reserveAsset[reservesIndex["WETH"]].tokenBalance += amountOfWeth;
	reserveAsset[reservesIndex["RDPX"]].tokenBalance -= rdpxAmount;

Core contract assumes that the collateral token is WETH. based on that it deducts from WETH tokenBalance manually. However, according to the sponsor, the collateral token in perpetual vault could be any other token. In this case, this update will be wrong. Instead of this, the sync() should be called to make sure that the collateral token balance is updated regardless of what it is (i.e. WETH or any other token).

Proof of Concept

In core contract, settle function updating colleteral token reserve manually L779-L781

	reserveAsset[reservesIndex["WETH"]].tokenBalance += amountOfWeth;
	reserveAsset[reservesIndex["RDPX"]].tokenBalance -= rdpxAmount;

In perpetual atlantic vault, _collateralToken is passed in the constructor. It doesn't assume it is WETH since it is reading the symbol and decimals.

    collateralToken = IERC20WithBurn(_collateralToken);
    underlyingSymbol = collateralToken.symbol();
    collateralPrecision = 10 ** collateralToken.decimals();

In perpetual atlantic vault LP, also there is no assumption that the collateral is WETH in the whole contract. in fact, there is a comment above the constructor saying "@param _collateral The address of the collateral asset in the perpetualatlanticvault contract" . So, this address is the same what is in perpetual atlantic vault.

The same applies to _purchaseOptions and provideFunding where premium and fundingAmount are deducted from WETH tokenBalance manually too

Tools Used

Manual analysis

Call sync() instead. It syncs asset reserves with contract balances regardless of what the collateral token is. L995-L1009

  function sync() external {
    for (uint256 i = 1; i < reserveAsset.length; i++) {
      uint256 balance = IERC20WithBurn(reserveAsset[i].tokenAddress).balanceOf(
        address(this)
      );

      if (weth == reserveAsset[i].tokenAddress) {
        balance = balance - totalWethDelegated;
      }
      reserveAsset[i].tokenBalance = balance;
    }

    emit LogSync();
  }

Assessed type

Other

#0 - c4-pre-sort

2023-09-13T13:32:18Z

bytes032 marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-09-15T09:47:41Z

bytes032 marked the issue as duplicate of #269

#2 - c4-judge

2023-10-15T18:11:38Z

GalloDaSballo marked the issue as satisfactory

Awards

140.2087 USDC - $140.21

Labels

bug
downgraded by judge
grade-a
QA (Quality Assurance)
sponsor disputed
sufficient quality report
Q-19

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L285-L287 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L545-L551

Vulnerability details

Impact

In purchase() function, calculatePremium() is called to get total premium for options being purchased L285-L287

    // Get total premium for all options being purchased
    premium = calculatePremium(strike, amount, timeToExpiry, 0);

Calculated like this OptionPrice * amount / 1e8 L545-L551

    premium = ((IOptionPricing(addresses.optionPricing).getOptionPrice(
      _strike,
      _price > 0 ? _price : getUnderlyingPrice(),
      getVolatility(_strike),
      timeToExpiry
    ) * _amount) / 1e8);

If optionPrice and amount are both too low, the product of both could be less than 1e8 then premium will equal zero. If this happens, the user won't pay for the premium since it is zero.

The same applies to requiredCollateral L275-L277

// Check if vault has enough collateral to write the options uint256 requiredCollateral = (amount * strike) / 1e8;

This is even more likely to occur because strike is 25% below the price. In this case, zero required collateral will be locked.

Proof of Concept

Check above code blocks with link provided.

Tools Used

Manual analysis

Check if premium is zero then revert. The same for requiredCollateral.

Assessed type

Other

#0 - bytes032

2023-09-15T12:33:04Z

The price part is related to #1012

#1 - c4-pre-sort

2023-09-15T12:33:10Z

bytes032 marked the issue as sufficient quality report

#2 - c4-sponsor

2023-09-25T18:54:56Z

psytama (sponsor) disputed

#3 - psytama

2023-09-25T18:54:57Z

min premium check is there in the option pricing contract.

#4 - GalloDaSballo

2023-10-11T11:50:53Z

@psytama I cannot verify your statement, could you please link to the line that performs the validation?

#5 - c4-judge

2023-10-20T19:46:03Z

GalloDaSballo changed the severity to QA (Quality Assurance)

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter