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: 2/189
Findings: 15
Award: $10,393.30
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 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
RdpxV2Core
will purchase OTM puts that are 25% below current price during rDPX bonding. The option strike price to be purchased at is determined by the PerpetualAtlanticVault.roundUp()
, which will round up the strike price to the nearest 1e6 dp.
However, an issue will occur when rDPX price is < 1e6, as roundUp()
will return 1e6 even when currentPrice is < 1e6. This will cause strike price to be above current price (ITM puts), instead of below the current price (OTM puts).
function purchase( uint256 amount, address to ) external nonReentrant onlyRole(RDPXV2CORE_ROLE) returns (uint256 premium, uint256 tokenId) { _whenNotPaused(); _validate(amount > 0, 2); updateFunding(); uint256 currentPrice = getUnderlyingPrice(); // price of underlying wrt collateralToken //@audit - roundUp() will return 1e6 even when currentPrice is < 1e6, causing strike to be above current price uint256 strike = roundUp(currentPrice - (currentPrice / 4)); // 25% below the current price IPerpetualAtlanticVaultLP perpetualAtlanticVaultLp = IPerpetualAtlanticVaultLP( addresses.perpetualAtlanticVaultLP );
Due to the rounding up, rDPXV2Core
will purchase ITM puts instead of OTM puts, paying a much higher premium than required.
Add the following test case test/rdpxV2-core/Unit.t.sol
function testPeakboltBond() public { rdpxPriceOracle.updateRdpxPrice(1e5); uint256 receiptTokenAmount = rdpxV2Core.bond(1 * 1e10, 0, address(this)); (uint256 strike1, uint256 amount1, ) = vault.optionPositions(0); //strike price should be 0.75e5 (75% of 1e5) // However this will fail as it will round up to 1e6 assertEq(strike1, 75000); }
Verify that strike > rdpxPrice
during purchase()
to ensure the puts is OTM.
Math
#0 - c4-pre-sort
2023-09-13T11:27:59Z
bytes032 marked the issue as duplicate of #2083
#1 - c4-pre-sort
2023-09-14T05:33:00Z
bytes032 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-09-14T05:33:06Z
bytes032 marked the issue as sufficient quality report
#3 - c4-pre-sort
2023-09-14T05:54:22Z
bytes032 marked the issue as duplicate of #2083
#4 - c4-judge
2023-10-20T14:13:22Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: LokiThe5th
Also found by: Nikki, __141345__, mahdikarimi, peakbolt, rvierdiiev, wintermute
1263.2349 USDC - $1,263.23
PerpetualAtlanticVaultLP
holders receives premium/funding for their deposited WETH, which are used to write PerpetualAtlanticVault
put options.
However, they are able to redeem their WETH anytime as long as there are available WETH in PerpetualAtlanticVaultLP
. As the losses from the options are deducted from PerpetualAtlanticVaultLP
via settle()
, PerpetualAtlanticVaultLP
holders can evade the loss and redeem their WETH by frontrunning settle()
operation. They can then backrun settle()
to re-deposit the redeemed WETH at a lower share price.
Even though, Arbitrum does not have a mempool at the moment, there could possibly be MEV opportunities in the future when they decentralize the sequencer. And this issue is also applicable if the project intends to expand to other chains or make it omnichain.
The issue can allow PerpetualAtlanticVaultLP
holders to arbitrage by evading options while receiving premium/funding, causing the other holders to inccur higher losses.
Imagine the following scenario,
PerpetualAtlanticVaultLP
holder has deposited WETH in PerpetualAtlanticVaultLP
and receives premium/funding for them.settle()
transaction and proceed to frontrun them by calling redeem()
to evade the option loss and withdraws her WETH & rDPX.settle()
transactions with deposit()
, to deposit the WETH & rDPX at a lower share price and continue to receive premium/funding for them.Add in a time lock on PerpetualAtlanticVaultLP
redemption, to allow holders to request redemption and only allow them to retrieve the WETH & rDPX after a certain period of time.
Note that holders should not receive fundings upon redemption request.
MEV
#0 - c4-pre-sort
2023-09-12T08:13:32Z
bytes032 marked the issue as low quality report
#1 - bytes032
2023-09-12T08:13:38Z
LQ because of front-running on Arb
#2 - c4-judge
2023-10-20T08:50:09Z
GalloDaSballo marked the issue as unsatisfactory: Insufficient proof
#3 - GalloDaSballo
2023-10-20T08:50:15Z
OTM -> Cannot redeem ITM -> Redeem is on purpose
#4 - c4-judge
2023-10-20T19:07:22Z
GalloDaSballo marked the issue as duplicate of #1584
#5 - c4-judge
2023-10-20T19:07:56Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: klau5
Also found by: 0x3b, 0xCiphky, 0xDING99YA, 0xWaitress, 0xbranded, 0xc0ffEE, 0xklh, 0xsurena, 0xvj, ABA, AkshaySrivastav, Anirruth, Aymen0909, Baki, Blockian, BugzyVonBuggernaut, DanielArmstrong, Evo, GangsOfBrahmin, HChang26, Inspex, Jiamin, Juntao, Kow, Krace, KrisApostolov, LFGSecurity, LokiThe5th, Mike_Bello90, Norah, Nyx, QiuhaoLi, RED-LOTUS-REACH, SBSecurity, Snow24, SpicyMeatball, T1MOH, Tendency, Toshii, Udsen, Yanchuan, __141345__, ak1, asui, auditsea, ayden, bart1e, bin2chen, blutorque, carrotsmuggler, chaduke, chainsnake, circlelooper, clash, codegpt, crunch, degensec, dirk_y, ge6a, gjaldon, grearlake, jasonxiale, juancito, ke1caM, kodyvim, kutugu, ladboy233, lanrebayode77, mahdikarimi, max10afternoon, mert_eren, nirlin, nobody2018, oakcobalt, parsely, peakbolt, pks_, pontifex, ravikiranweb3, rokinot, rvierdiiev, said, savi0ur, sces60107, sh1v, sl1, spidy730, tapir, tnquanghuy0512, ubermensch, visualbits, volodya, wintermute
0.0098 USDC - $0.01
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L201 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L359 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L774
PerpetualAtlanticVaultLP.subtractLoss()
is used to reduce _totalCollateral
in PerpetualAtlanticVaultLP
when RdpxV2Core.settle()
is called to settle the put options.
However, it uses a strict equality for the balanceOf()
check, which means anyone can manipulate it and cause it to fail by sending 1 wei WETH to de-sync the collateral balance and _totalCollateral
. When that happens, settle()
will fail, preventing RdpxV2Core
from realizing profits from ITM put options.
function subtractLoss(uint256 loss) public onlyPerpVault { //@audit - this will fail when _totalCollateral is not in sync with balanceOf() require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss; }
Depositors of PerpetualAtlanticVaultLP
can prevent RdpxV2Core
from settling any ITM put options to evade all losses. That means RdpxV2Core
will suffer losses as it will not be able to realize the profits when the put options are ITM.
PerpetualAtlanticVaultLP
sends 1 wei of WETH to PerpetualAtlanticVaultLP
. This causes PerpetualAtlanticVaultLP's collateral
balance of WETH to be out of sync with _totalCollateral
.rdpxV2Core
are now ITM (current price below strike price) and are in profits.rdpxV2Core
admin tries to close the options using settle()
. However, it fails, preventing it from realizing the profits.PerpetualAtlanticVaultLP
continue to gain funding payments and evade the losses despite that the options are ITM.Use >=
instead of ==
for the balanceOf()
check in subtractLoss()
.
Invalid Validation
#0 - c4-pre-sort
2023-09-09T10:02:55Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:15:19Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:34:54Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: bart1e
Also found by: 0x3b, 0xCiphky, Aymen0909, HHK, Inspex, bin2chen, chaduke, gkrastenov, jasonxiale, josephdara, kodyvim, peakbolt, pep7siup, rokinot, rvierdiiev, tapir
181.367 USDC - $181.37
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L161-L173
RdpxV2Core.emergencyWithdraw()
is meant for use during an emergency (when contract is paused) to allow admin to withdraw assets from RdpxV2Core
and possibly migrate to a new contract later on.
However, it only transfers out ERC20 tokens and does not withdraw the PerpetualAtlanticVault
put options as those are NFTs.
Furthermore, RdpxV2Core
is not able to close the OTM put options, causing the associated locked WETH to remain locked in PerpetualAtlanticVaultLP
.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L161-L173
function emergencyWithdraw( address[] calldata tokens ) external onlyRole(DEFAULT_ADMIN_ROLE) { _whenPaused(); IERC20WithBurn token; for (uint256 i = 0; i < tokens.length; i++) { token = IERC20WithBurn(tokens[i]); token.safeTransfer(msg.sender, token.balanceOf(address(this))); } emit LogEmergencyWithdraw(msg.sender, tokens); }
The issue will cause PerpetualAtlanticVault
put options to be left in RdpxV2Core
without a way to retrieve them or close them during the emergency.
Add in withdrawals of PerpetualAtlanticVault
put options NFTs in RdpxV2Core.emergencyWithdraw()
.
Other
#0 - c4-pre-sort
2023-09-13T11:22:32Z
bytes032 marked the issue as duplicate of #935
#1 - c4-pre-sort
2023-09-14T08:34:43Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T18:05:28Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-10-20T18:05:45Z
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
PerpetualAtlanticVaultLP
allows users to deposit WETH in exchange for shares. During deposit, perpetualAtlanticVault.updateFunding()
is called to distribute the funding to PerpetualAtlanticVaultLP
.
However, the issue is that previewDeposit(assets)
, which determines the deposit share count, is called before updateFunding()
. The implication is that new depositors will receive more shares than required as the share price is lower before the funding is distributed to PerpetualAtlanticVaultLP
. updateFunding()
should be called before previewDeposit(assets)
to update the share price before computing the share count for new deposits.
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"); //@audit this should be called before previewDeposit() 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); }
The issue will cause existing PerpetualAtlanticVaultLP
holders to be shortchanged as new deposit receives more share than what is deposited into PerpetualAtlanticVaultLP
.
When User A calls PerpetualAtlanticVaultLP.deposit()
, the following will occur,
previewDeposit(assets)
has determined X amount of shares to mint for the provided assets
(WETH) by User A.perpetualAtlanticVault.updateFunding()
is called, which transfers the funding to PerpetualAtlanticVaultLP
, increasing the WETH balance.updateFunding()
and User A should get less than X share.PerpetualAtlanticVaultLP
holders will be shortchanged as User A takes a higher amount of shares than required.In PerpetualAtlanticVaultLP.deposit()
, call updateFunding()
before previewDeposit()
.
Other
#0 - c4-pre-sort
2023-09-07T13:34:54Z
bytes032 marked the issue as duplicate of #1948
#1 - c4-pre-sort
2023-09-07T13:42:24Z
bytes032 marked the issue as duplicate of #867
#2 - c4-pre-sort
2023-09-11T09:04:20Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-20T19:26:15Z
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/core/RdpxV2Core.sol#L1192-L1194
RdpxV2Core.bond()
relies on calculateBondCost()
to determine the premium cost for the PerpetualAtlanticVault
put option, which is purchased during bonding.
However, calculateBondCost()
does not update the latestFundingPaymentPointer
using updateFundingPaymentPointer()
before computing timeToExpiry
for calculatePremium()
.
When timeToExpiry == 0
at the end of the epoch, the premium calculation in calculateBondCost()
will be lower than the premium calculated in PerpetualAtlanticVault.purchase()
. That is because PerpetualAtlanticVault.purchase()
will trigger the transition to next epoch, and the premium will be for the entire duration of the next epoch.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1192-L1194
function calculateBondCost( .. //@audit missing updateFundingPaymentPointer() here uint256 timeToExpiry = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).nextFundingPaymentTimestamp() - block.timestamp;
A user can abuse the issue to pay less premium for the put option and make RdpxV2Core
pay most of the remaining amount.
bond()
at the end of epoch. In calculateBondCost()
, the timeToExpiry
will be equal to 0
as the epoch has not transition due to missing 1updateFundingPaymentPointer()`.calculateBondCost()
will compute premium based on timeToExpiry == 0
to derive wethRequired
, which will be paid by Alice.PerpetualAtlanticVault.purchase()
will be called to purchase the put option.PerpetualAtlanticVault.purchase()
will call updateFundingPaymentPointer()
, it will transition to the next epoch.purchase()
, the calculated premium will be higher as it based on entire duration of the next epoch (due to the epoch transition).purchase()
will actually transfer a higher premium amount from RdpxV2Core
. So effectively Alice made RdpxV2Core
pay for most of the premium.in calculateBondCost()
, call updateFundingPaymentPointer()
to set to latest epoch before computing timeToExpiry
.
Other
#0 - c4-pre-sort
2023-09-10T12:07:14Z
bytes032 marked the issue as duplicate of #237
#1 - c4-pre-sort
2023-09-12T06:07:04Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-14T09:35:03Z
bytes032 marked the issue as duplicate of #761
#3 - c4-judge
2023-10-20T11:59:20Z
GalloDaSballo marked the issue as satisfactory
#4 - GalloDaSballo
2023-10-20T12:01:42Z
TODO: Funding Pointer Update Dups
#5 - c4-judge
2023-10-20T12:01:48Z
GalloDaSballo marked the issue as not a duplicate
#6 - c4-judge
2023-10-21T16:56:59Z
GalloDaSballo marked the issue as duplicate of #867
#7 - c4-judge
2023-10-21T16:57:07Z
GalloDaSballo marked the issue as partial-50
🌟 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
PerpetualAtlanticVaultLP
allows one to deposit WETH for LP shares to receive funding payments from PerpetualAtlanticVault
.
However, the issue is that there is no timelock on PerpetualAtlanticVaultLP.redeem()
. This will allow an attacker to steal from PerpetualAtlanticVaultLP
as it is possible to increase the share price through bond()
after depositing so that one can redeem at a higher share price with flash loan.
//@audit lack of timelock on redeem allow one to deposit and redeem within same transaction function redeem( uint256 shares, address receiver, address owner ) public returns (uint256 assets, uint256 rdpxAmount) {
The issue allows an attacker to steal from PerpetualAtlanticVaultLP
holders.
PerpetualAtlanticVaultLP
to obtain large amount of LP shares.bond()
, which triggers PerpetualAtlanticVault.purchase()
to buy put options. Alice can specify a bond amount such that it does not lock up all the WETH in PerpetualAtlanticVaultLP
, allowing Alice to withdraw after this.updateFunding()
to transfer the premium into PerpetualAtlanticVaultLP
, which increases the share price.redeem()
the LP shares within the same transaction at a higher share price and repays the flash loan. The result is that Alice managed to arbitrage and profit from the redemption at the expense of the PerpetualAtlanticVaultLP
holders.To increase the profitability,
bond()
can be triggered near the end of the epoch so that the funding rate increase will be higher, as the premium will be divided by a smaller duration.Impose a timelock on PerpetualAtlanticVaultLP.redeem()
so that it cannot be called within the same transaction as deposit()
.
Other
#0 - c4-pre-sort
2023-09-12T08:57:48Z
bytes032 marked the issue as duplicate of #1781
#1 - c4-pre-sort
2023-09-12T08:57:54Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T08:06:43Z
GalloDaSballo marked the issue as duplicate of #2130
#3 - c4-judge
2023-10-20T19:06:53Z
GalloDaSballo marked the issue as not a duplicate
#4 - c4-judge
2023-10-20T19:07:02Z
GalloDaSballo marked the issue as duplicate of #867
#5 - c4-judge
2023-10-20T19:42:34Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xrafaelnicolau
Also found by: 0x111, 0xCiphky, 0xMosh, 0xWaitress, 0xc0ffEE, 0xkazim, 0xnev, 0xvj, ABAIKUNANBAEV, Aymen0909, Baki, ElCid, HChang26, HHK, Inspex, Jorgect, Kow, Krace, KrisApostolov, LFGSecurity, MiniGlome, Nyx, QiuhaoLi, RED-LOTUS-REACH, Talfao, Toshii, Vagner, Viktor_Cortess, Yanchuan, _eperezok, asui, atrixs6, bart1e, bin2chen, carrotsmuggler, chaduke, chainsnake, deadrxsezzz, degensec, dethera, dimulski, dirk_y, ether_sky, gizzy, glcanvas, grearlake, gumgumzum, halden, hals, kodyvim, koo, ladboy233, lanrebayode77, max10afternoon, minhtrng, mussucal, nobody2018, peakbolt, pontifex, qbs, ravikiranweb3, rvierdiiev, said, tapir, ubermensch, volodya, wintermute, yashar, zaevlad, zzebra83
0.1468 USDC - $0.15
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L975-L990 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1110
RdpxV2Core.withdraw()
allows the delegate user to withdraw un-used WETH that was delegated.
However, withdraw()
fails to reduce the totalWethDelegated
by the amountWithdrawn
value, causing totalWethDelegated
to be incorrect.
An attacker can exploit the issue and use sync()
to manipulate reserveAsset[reservesIndex["WETH"]].tokenBalance
and make it zero. That will cause lowerDepeg()
to fail as it requires a positive amount of reserveAsset[reservesIndex["WETH"]].tokenBalance
.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L975-L990
function withdraw( uint256 delegateId ) external returns (uint256 amountWithdrawn) { _whenNotPaused(); _validate(delegateId < delegates.length, 14); Delegate storage delegate = delegates[delegateId]; _validate(delegate.owner == msg.sender, 9); //@audit - totalWethDelegated should be reduced by amountWithdrawn after this amountWithdrawn = delegate.amount - delegate.activeCollateral; _validate(amountWithdrawn > 0, 15); delegate.amount = delegate.activeCollateral; IERC20WithBurn(weth).safeTransfer(msg.sender, amountWithdrawn); emit LogDelegateWithdraw(delegateId, amountWithdrawn); }
An attacker can exploit this issue to prevent RdpxV2Core
from maintaining the peg of DpxEth.
reserveAsset[reservesIndex["WETH"]].tokenBalance - totalWethDelegated
.RdpxV2Core
using addToDelegate()
. This will increase totalWethDelegated
to be equal to reserveAsset[reservesIndex["WETH"]].tokenBalance
.sync()
to sync asset reserves, which will then reduce reserveAsset[reservesIndex["WETH"]].tokenBalance
by totalWethDelegated
to zero.lowerDepeg()
will fail as it requires a positive amount of reserveAsset[reservesIndex["WETH"]].tokenBalance
.Reduce totalWethDelegated
by the amountWithdrawn
during withdraw()
.
Other
#0 - c4-pre-sort
2023-09-07T07:51:23Z
bytes032 marked the issue as duplicate of #2186
#1 - c4-judge
2023-10-20T17:54:37Z
GalloDaSballo marked the issue as satisfactory
#2 - c4-judge
2023-10-20T17:55:32Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-21T07:38:54Z
GalloDaSballo changed the severity to 3 (High Risk)
491.258 USDC - $491.26
settle()
is used by RdpxV2Core
admin to close PerpetualAtlanticVault
put options and realize the profits when they are ITM.
However, it does not allow forfeiting of put options, which means there is no mechanism to unlock the associated locked WETH unless they are ITM.
There are scenarios where such a function is required, such as during an emergency or migration (transferring to a new vault version and deprecating the existing vault). In these cases, there is no mechanism to unlock the WETH associated with the OTM put options. Furthermore, in these situation, funding payment for the OTM options will likely be paused as well.
Without a mechanism to forfeit put options, the associated locked WETH will be stuck in PerpetualAtlanticVaultLP
without any mechanism to release them.
calculateFunding()
and provideFunding()
for epoch N+1. That means there are no funding payment for Epoch N+1.settle()
and realize the profits from the active options.Add the ability for settle()
to forfeit put options.
Other
#0 - c4-pre-sort
2023-09-12T09:02:34Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-14T08:36:57Z
bytes032 marked the issue as primary issue
#2 - c4-sponsor
2023-09-25T15:50:35Z
psytama marked the issue as disagree with severity
#3 - psytama
2023-09-25T15:50:47Z
There is no exploit or loss of funds.
#4 - c4-judge
2023-10-15T17:49:39Z
GalloDaSballo marked the issue as unsatisfactory: Insufficient proof
#5 - c4-judge
2023-10-15T17:50:03Z
GalloDaSballo removed the grade
#6 - c4-judge
2023-10-15T17:50:09Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#7 - GalloDaSballo
2023-10-15T17:50:20Z
Downgrading to QA for Documentation
#8 - peakbolt
2023-10-22T20:48:24Z
Hi @GalloDaSballo ,
this issue has the same root cause as #1956, as it is referring to the inability to settle OTM options. I use the term "forfeit options" as it is stated the docs under Atlantic Perpetual PUTS Options https://dopex.notion.site/rDPX-V2-RI-b45b5b402af54bcab758d62fb7c69cb4.
#9 - c4-judge
2023-10-24T07:31:52Z
This previously downgraded issue has been upgraded by GalloDaSballo
#10 - GalloDaSballo
2023-10-24T07:32:10Z
Agree
#11 - c4-judge
2023-10-24T07:32:18Z
GalloDaSballo marked the issue as duplicate of #1956
#12 - c4-judge
2023-10-24T07:32:27Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: peakbolt
6489.2083 USDC - $6,489.21
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L873 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L930
RdpxV2Core.bond()
calls reLPContract.reLP()
at the end of the bonding to adjust the LP for the rDPX/ETH pool and swap ETH for rDPX.
Within reLPContract.reLP()
, it first calls removeLiquidity()
to remove liquidity (rDPX and ETH), followed by swapExactTokensForTokens()
and addLiquidity()
.
However, the issue is that an attacker has control over RdpxV2Core.bond()
and can use it to indirectly call reLPContract.reLP()
. That means the attacker can basically frontrunned/backrunned reLP()
without a mempool. It also reduces the attack cost as attacker does not pay high gas to frontrun it.
One may argue that the slippage protection is in place, but that only makes it less profitable and would not help in this case because the attacker is able to increase the trade size of the UniswapV2 transactions. That is because the attacker can adjust the bonding amount to increase the amount of liquidity removed by reLP()
(and also swap/add liquidity), allowing the attacker to craft a profitable sandwich attack.
It is not recommended to allow users to have indirect control over the UniswapV2 transaction in reLPContract
.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L873
function bond( uint256 _amount, uint256 rdpxBondId, address _to ) public returns (uint256 receiptTokenAmount) { ... //@audit an attacker can indirectly trigger this to perform a large trade and sandwich attack it // reLP if (isReLPActive) IReLP(addresses.reLPContract).reLP(_amount);
An attacker can steal from rdpxV2Core
by crafting an profitable sandwich attack on bond()
.
An attacker can perform a sandwich attack as follows:
reLPContract.reLP()
.rdpxV2Core.bond()
to indirectly trigger reLPContract.reLP()
, which will removeLiquidity()
, causing it receive lesser rDPX due to the price manipulation.reLPContract.reLP()
will then swapExactTokensForTokens()
to swap ETH-to-rDPX using ETH from removed liquidity. This further increase price of rDPX.addLiquidity()
is then called by reLPContract.reLP()
to return part of the liquidity into the pool.Remove reLPContract.ReLP()
from bond()
and trigger separately to prevent user access to it.
Uniswap
#0 - c4-pre-sort
2023-09-10T10:20:55Z
bytes032 marked the issue as primary issue
#1 - bytes032
2023-09-11T15:09:09Z
Related to #598
#2 - c4-pre-sort
2023-09-11T15:09:13Z
bytes032 marked the issue as sufficient quality report
#3 - psytama
2023-09-25T14:05:53Z
The re-LP contract and process will be modified.
#4 - c4-sponsor
2023-09-25T14:05:55Z
psytama (sponsor) confirmed
#5 - GalloDaSballo
2023-10-12T11:34:55Z
I would have liked to see a Coded POC
Removing Liquidity is not as simple to exploit as swap
#6 - c4-judge
2023-10-16T08:33:59Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - GalloDaSballo
2023-10-16T08:36:54Z
UniV2 Code is as follows https://github.com/Uniswap/v2-core/blob/ee547b17853e71ed4e0101ccfd52e70d5acded58/contracts/UniswapV2Pair.sol#L144-L155
Offering Pro Rata Distribution
In lack of a coded POC
The maximum skimmable is the delta excess value in the UniV2 Reserves vs the value that the caller has to pay to imbalance them
The cost of imbalancing and the nature of it + the fact that other people would have ownership of the LP token means that profitability is dependent on those factors, which IMO have not been properly factored in.
In the presence of a Coded POC as part of the original submission, I would have considered a High Severity
In lack of it, Medium Severity for Skimming the excess value seems more appropriate
#8 - c4-judge
2023-10-16T08:37:01Z
GalloDaSballo marked the issue as selected for report
#9 - peakbolt
2023-10-22T20:41:30Z
Hi @GalloDaSballo,
I understand your judgement as you have raised valid points, though the circumstances here are different (explained below), which make it more profitable as compared to a typical sandwich attack on removeLiquidity().
1. Coded POC
First, to clarify, as requested on 12 Oct, I have submitted the coded POC as a text file to PaperParachute on 19 Oct, before judging QA started. My apologies that it took a while as I travelling and this POC was complicated and required internet access to code due to the forking. Below is the printout from the POC, which shows it is more than just skimming of excess value (profit of 42.79 ETH using 1000 ETH flash loan vs minimal cost in point 2 below).
For your reference, i have uploaded it here now https://gist.github.com/peakbolt/f2d9a44714b1a2a693debea737128696
-------------- setup copied from testReLpContract() ------------ -------------- 1. attacker perform first swap to get rDPX with flashloaned WETH ------------ attacker weth balance (initial) : 10000000000000000000000 [from flashloan] attacker rdpx balance (initial) : 0 attacker weth balance (after 1st swap): 0 attacker rdpx balance (after 1st swap): 33799781371440793668463 rDPX price in WETH (after 1st swap): 435429977934145959 -------------- 2. Attacker triggers bond() that indirectly perform removeLiquidity() in reLPContract.reLP() ------------ rDPX price in WETH (after bond): 440827346696573406 -------------- 3. Attacker performs 2nd swap rDPX back to WETH and profit ------------ attacker weth balance (after 2nd swap): 10042795347724107931994 attacker rdpx balance (after 2nd swap): 0 attacker profit in WETH (after repaying flashloan): 42795347724107931994
2. Cost of imbalancing There are 3 main attack cost implicitly mentioned in the original submission. Based on the POC, they are ~11 ETH, minimal as compared to the 42.79 ETH profit.
3. Control over amount of LP tokens to remove As mentioned in my submission, in this case, the attacker has control of the trade size of removeLiquidity() via bond() amount. By increasing the bond amount, which increase amount of LP tokens to remove, the attacker can increase the profitability of the attack.
4. During the bootstrap phase, majority of LP holders are dopex funds/partners The docs stated that during bootstrap phase, Dopex Treasury funds and Partners will partake in the dpxETH bonding first to ensure sufficient dpxETH in circulation. In addition, the backing reserve will be used to seed the liquidity for the Uniswap V2 Pool. So during that period, there are little other LP holders. I did not state this in submission as it is already in the docs. https://dopex.notion.site/rDPX-V2-RI-b45b5b402af54bcab758d62fb7c69cb4 (see Launch Details)
#10 - GalloDaSballo
2023-10-25T07:47:58Z
@peakbolt -What's the value in Pool?
#11 - peakbolt
2023-10-25T18:48:01Z
Hi @GalloDaSballo,
I have adjusted the POC to a liquidity pool value of ~800 ETH with flashloan attack amount of 15e20 and ran it to provide the answers below. Used 800ETH TVL as the assumed amount to bootstrap, judging by the lower range of TVL in the top uniswap pools. Note that the initial TVL value in the test suite is actually 4000 ETH. See link for updated poc: https://gist.github.com/peakbolt/a8fdbe18759ac2e25d15716af6faa76b.
bond()
iterations.bond()
will hit a revert as there will be insufficient WETH collateral in PerpetualAltanticVault
for purchase of put options (during bonding).Total Liquidity (in WETH) for RDPX/WETH UniswapV2 Pool (before attack): 804803971980485176292 attacker weth balance (initial) : 1500000000000000000000 [from flashloan] attacker rdpx balance (initial) : 0 bond() Iteration 1 bond() Iteration 2 bond() Iteration 3 bond() Iteration 4 bond() Iteration 5 attacker profit in WETH (after repaying flashloan): 22067892940781745930
For 800 ETH TVL, the flash loan cost for attack is lower at 1.35 ETH (0.09% of 15e20 ETH)
After flash loan cost profit will be 22-1.35 = 20.65 ETH
That works out to be 20.65/800 ETH = ~2.58% profit/TVL.
Per iteration will be 0.516% profit/TVL.
For bonding, it will require upfront capital of 1e20 WETH/iteration to bond the dpxETH, which can be recouped by selling the dpxETH later on. To keep it simple, I have omitted the loan cost for this and assume attacker has the capital. Otherwise, attacker can get a loan and reduces attack profit.
#12 - c4-judge
2023-10-26T14:56:20Z
GalloDaSballo changed the severity to 3 (High Risk)
#13 - c4-judge
2023-10-26T15:00:49Z
GalloDaSballo changed the severity to 2 (Med Risk)
#14 - c4-judge
2023-10-26T15:11:06Z
GalloDaSballo changed the severity to 3 (High Risk)
#15 - GalloDaSballo
2023-10-26T15:15:15Z
The finding shows that the attacker has access to the "button", this allows them to cause the contract to auto-rebalance
The auto-rebalancing is part of the "button" that attack can trigger
Initially I believed that the attacker only had control over the amounts from remove liquidity, which by definition will give pro-rata of the tokens
The pro-rata can be manipulated based on attacking the X * Y = k uniswap invariant to have the contract take a loss that is relative to the new spot balance, vs the fair LP pricing.
That was Medium Severity imo and most Judges would agree with me, however, after talking with 2 more judges, we agreed that the code also impacts the rebalancing, done via swapExactTokensForTokens
meaning that the attacker is not only causing the "skimmin" of the LP token value, they are able to influence the swap via swapExactTokensForTokens
However, unpon further inspection, that is protected by the Oracle Pricing, which could be argued to protect against it
That said, the default slippage protection is 50 BPS, which is above the 30BPS avg cost of a swap on UnIV2, the oracles also can have drift which in general should make the cost of backrunning sustainable
The profit of the attack would come due to: -> Swap taking a Loss -> generally seen as Med but since it's repeatable High is acceptable -> Ability to trigger the Withdrawal at any time, which causes the Pro-Rata received X * Y being less valuable than the fair valuation of the LP Token
This means that under most circumnstances, a risk free trade is available for attacker at the detriment of the reLP
contract, leading me to believe High Severity to be appropriate.
However, given the circumnstances, and given the original submissions not containing the POC, also considering that the POC shows an impact of 2% of funds, I'm maintaining Medium Severity due to the POC not being present in the original submission
#16 - c4-judge
2023-10-26T17:36:17Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 Selected for report: 0xTheC0der
Also found by: 0Kage, 0xDING99YA, 0xHelium, 0xbranded, 836541, ABA, Kow, QiuhaoLi, SpicyMeatball, T1MOH, __141345__, alexfilippov314, ayden, bart1e, bin2chen, chaduke, degensec, jasonxiale, joaovwfreire, nirlin, peakbolt, pep7siup, rvierdiiev, tnquanghuy0512
15.9268 USDC - $15.93
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L237-L241 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L426-L427
fundingDuration
is used to determine the time period between funding payments for the active put options.
It is also used within PerpetualAtlanticVault.calculateFunding()
to determine the start time of the epoch and compute the funding payment for the next epoch.
However, the calculateFunding()
will be affected if updateFundingDuration()
is used by the admin to change fundingDuration
. The result is that the funding payment will be higher/lower causing an incorrect payment to be paid to PerpetualAtlanticVaultLP
when fundingDuration is changed.
function calculateFunding( ... //@audit fundingDuration is used to compute the start time of the epoch and derive timeToExpiry. uint256 timeToExpiry = nextFundingPaymentTimestamp() - (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration)); //@audit timeToExpiry is a parameter for premium calculation uint256 premium = calculatePremium( strike, amount, timeToExpiry, getUnderlyingPrice() );
Changing fundingDuration
will affect the funding payment computation, causing PerpetualAtlanticVaultLP
holders to be underpaid or overpaid.
In calculateFunding()
, the premium calculation is determined by timeToExpiry
below.
uint256 timeToExpiry = nextFundingPaymentTimestamp() - (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration));
timeToExpiry
is derived from the time difference between nextFundingPaymentTimestamp()
and the start time.
Note that start time computation is derived by this formula: (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration))
.
Now lets suppose fundingDuration
is changed from 7 to 5 days for the current epoch.
The start time computation will then be incorrect as it uses the new 5 days fundingDuration
instead of 7 days to determine the start time.
That will cause start time to be much earlier, leading to a longer timeToExpiry
, which causes premium to be much higher.
The result is that PerpetualAtlanticVaultLP
holders will be overpaid with a higher premium during that time period.
Make fundingDuration
immutable and remove updateFundingDuration()
.
Other
#0 - c4-pre-sort
2023-09-08T06:25:09Z
bytes032 marked the issue as duplicate of #980
#1 - c4-pre-sort
2023-09-11T08:20:05Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T11:09:32Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-20T11:11:42Z
GalloDaSballo marked the issue as satisfactory
1347.7586 USDC - $1,347.76
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L904-L924
During rDPX bonding, rdpxV2Core
will purchase put options for the rDPX that are used for minting DPXETH. This is for hedging against rDPX price drop to protect the DPXETH peg. The amount of put options to purchase is equivalent to the amount of rDPX that are used for minting DPXETH, which is rdxRequired
as given by calculateBondCost()
.
The issue is that for regular bonding, rdxRequired
is reduced by the discount provided by Treasury Reserve and the amount of rDPX used for minting is actually rdxRequired
+ rDPX discount given by Treasury Reserve. We can see the _transfer()
code below actually increment the rDPX Backing Reserve by _rdpxAmount + extraRdpxToWithdraw
, which is the actual amount of rDPX use for minting DPXETH.
So users that perform regular/delegate bonding will pay less premium due to the reduced amount of put options. With a reduced amount of put options, the hedging in place to protect DPXETH peg will be less effective.
Users who performs bonding via rDPX Decaying Bond will be shortchanged as they are paying the full premium while users who perform regular/delegate bonding will pay less premium than required.
Also, with a reduced amount of put options, it will reduce effectiveness of the hedging against rDPX price drop, thereby leading to higher likelihood of DPX ETH depeg.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L904-L924
function bond( uint256 _amount, uint256 rdpxBondId, address _to ) public returns (uint256 receiptTokenAmount) { _whenNotPaused(); // Validate amount _validate(_amount > 0, 4); //@audit rdpxRequired is reduced by discount for regular bonding // Compute the bond cost (uint256 rdpxRequired, uint256 wethRequired) = calculateBondCost( _amount, rdpxBondId ); //@audit for regular bonding, it should purchase rdpxRequired + rdpx discount // purchase options uint256 premium; if (putOptionsRequired) { premium = _purchaseOptions(rdpxRequired); } //@audit we can see the _transfer() code below actually uses _rdpxAmount + extraRdpxToWithdraw _transfer(rdpxRequired, wethRequired - premium, _amount, rdpxBondId);
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1162-L1178
function calculateBondCost( uint256 _amount, uint256 _rdpxBondId ) public view returns (uint256 rdpxRequired, uint256 wethRequired) { uint256 rdpxPrice = getRdpxPrice(); if (_rdpxBondId == 0) { uint256 bondDiscount = (bondDiscountFactor * Math.sqrt(IRdpxReserve(addresses.rdpxReserve).rdpxReserve()) * 1e2) / (Math.sqrt(1e18)); // 1e8 precision _validate(bondDiscount < 100e8, 14); //@audit for regular bonding rdpxRequired is reduced by discount rdpxRequired = ((RDPX_RATIO_PERCENTAGE - (bondDiscount / 2)) * _amount * DEFAULT_PRECISION) / (DEFAULT_PRECISION * rdpxPrice * 1e2);
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L681-L684 function _transfer( uint256 _rdpxAmount, uint256 _wethAmount, uint256 _bondAmount, uint256 _bondId ) internal { //@audit amount of rDPX used for minting is _rdpxAmount + extraRdpxToWithdraw due to discount reserveAsset[reservesIndex["RDPX"]].tokenBalance += _rdpxAmount + extraRdpxToWithdraw; }
Update _purchaseOptions(rdpxRequired)
to include the discount provided by Treasury Reserve.
Other
#0 - c4-pre-sort
2023-09-09T10:47:17Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-12T04:20:29Z
bytes032 marked the issue as sufficient quality report
#2 - bytes032
2023-09-12T04:20:57Z
Related to #1921
#3 - c4-sponsor
2023-09-25T16:42:32Z
psytama (sponsor) disputed
#4 - psytama
2023-09-25T16:42:48Z
This is a design choice and the code works as intended.
#5 - GalloDaSballo
2023-10-12T17:39:26Z
POC from the Warden:
The following POC provides the details to shows the validity of the issue and the code is not working correctly as mentioned above.
RdpxV2Core.sol#L922
console.log("premium paid = %d", premium);
tests\rdpxV2-core\Integration.t.sol
function testPeakboltIncorrectOptionPurchase() public { console.log("------------- bonding with decaying bond (1 dpxETH)-------------"); // test with rdpx decaying bonds rdpxDecayingBonds.grantRole(rdpxDecayingBonds.MINTER_ROLE(), address(this)); rdpxDecayingBonds.mint(address(this), block.timestamp + 10, 125 * 1e16); assertEq(rdpxDecayingBonds.ownerOf(1), address(this)); rdpxDecayingBonds.approve(address(rdpxV2Core), 1); (uint256 rdpxRequiredToBond, ) = rdpxV2Core.calculateBondCost(1 * 1e18, 1); console.log("rdpxRequiredToBond: %d", rdpxRequiredToBond); // bond using decaying rdpxBondId 1 uint256 wethBalanceBefore = weth.balanceOf(address(this)); (, uint256 rdpxReserveBefore, ) = rdpxV2Core.getReserveTokenInfo("RDPX"); rdpxV2Core.bond(1 * 1e18, 1, address(this)); uint256 wethBalanceAfter = weth.balanceOf(address(this)); uint256 wethPaidForDecayingBonding = wethBalanceBefore - wethBalanceAfter; (, uint256 rdpxReserveAfter, ) = rdpxV2Core.getReserveTokenInfo("RDPX"); uint256 rdpxReserveChange = rdpxReserveAfter - rdpxReserveBefore; console.log("rDPX reserve increase: %d", rdpxReserveChange); uint256 optionsIndex = vault.balanceOf(address(rdpxV2Core)) - 1; (uint256 strike, uint256 optionsAmount, ) = vault.optionPositions(optionsIndex); console.log("option purchased for rdpxRequired: %d", optionsAmount); // this will pass as amount of option purchase is equal to rdpx amount contributed to reserve assertEq(optionsAmount, rdpxReserveChange); console.log("------------- regular bonding (1 dpxETH) -------------"); (rdpxRequiredToBond, ) = rdpxV2Core.calculateBondCost(1 * 1e18, 0); console.log("rdpxRequiredToBond: %d ", rdpxRequiredToBond); // regular bonding wethBalanceBefore = weth.balanceOf(address(this)); (, rdpxReserveBefore, ) = rdpxV2Core.getReserveTokenInfo("RDPX"); rdpxV2Core.bond(1 * 1e18, 0, address(this)); wethBalanceAfter = weth.balanceOf(address(this)); uint256 wethPaidForRegularBonding = wethBalanceBefore - wethBalanceAfter; (, rdpxReserveAfter, ) = rdpxV2Core.getReserveTokenInfo("RDPX"); rdpxReserveChange = rdpxReserveAfter - rdpxReserveBefore; console.log("rDPX reserve increase: %d", rdpxReserveChange); optionsIndex = vault.balanceOf(address(rdpxV2Core)) - 1; (strike, optionsAmount, ) = vault.optionPositions(optionsIndex); console.log("option purchased for rdpxRequired: %d", optionsAmount); // this will fail as amount of option purchased < rdpx amount contributed to reserve (incorrect) // that means rdpxV2Core has a smaller than required hedge against rDPX price drop, which is wrong assertEq(optionsAmount, rdpxReserveChange); // this will fail as users performing regular bonding is paying less weth than users bonding with decaying bond // this is due to a lower premium (incorrect) paid for regular bonding, because of incorrect option purchased assertEq(wethPaidForDecayingBonding, wethPaidForRegularBonding); }
#6 - c4-judge
2023-10-15T17:56:21Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - GalloDaSballo
2023-10-20T08:03:33Z
I've adjusted the POC to revert after the first operations as to not change the balance of reserves which has been demonstrated to change pricing
The output
------------- bonding with decaying bond (1 dpxETH)------------- rdpxRequiredToBond: 1250000000000000000 rDPX reserve increase: 1250000000000000000 option purchased for rdpxRequired: 1250000000000000000 ------------- regular bonding (1 dpxETH) ------------- rdpxRequiredToBond: 1225000000000000000 rDPX reserve increase: 1275000000000000000 option purchased for rdpxRequired: 1225000000000000000 Error: a == b not satisfied [uint] Left: 1225000000000000000 Right: 1275000000000000000 Error: a == b not satisfied [uint] Left: 812500000000000000 Right: 806250000000000000
Unless I'm missing something, the Warden has shown how, by purchasing effectively the same thing, we can get two difference prices
#8 - c4-judge
2023-10-20T19:50:44Z
GalloDaSballo marked the issue as satisfactory
#9 - c4-judge
2023-10-20T19:50:56Z
GalloDaSballo marked the issue as selected for report
#10 - c4-judge
2023-10-20T19:51:27Z
GalloDaSballo marked issue #780 as primary and marked this issue as a duplicate of 780
🌟 Selected for report: said
Also found by: 0xWaitress, Nyx, RED-LOTUS-REACH, Tendency, __141345__, carrotsmuggler, nirlin, peakbolt, qpzm, wallstreetvilkas
79.1135 USDC - $79.11
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L286-L289 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1196-L1197
rDPX bonding requires users to provide rDPX tokens and WETH to mint DPXETH. In addition, users have to pay premium for RdpxV2Core
to purchase PerpetualAtlanticVault
put options.
The issue is that the option premium cost is dependent on timeToExpiry
, so users will pay more when the timeToExpiry
is higher.
This is an unfair cost computation as users who perform bonding at the start of the epoch are charged more premium than users who performed bonding for the same quantity of bonds near the end of the epoch. This is true even when rDPX price remains the same.
function purchase( ... // Get total premium for all options being purchased premium = calculatePremium(strike, amount, timeToExpiry, 0); //@audit during the start of epoch (higher timeToExpiry), users pay more premium // Transfer premium from msg.sender to PerpetualAtlantics vault collateralToken.safeTransferFrom(msg.sender, address(this), premium);
The issue will cause users that bond near the start of epoch to be shortchanged as they will pay more than users that bond near the end of the epoch.
Imagine the following scenario, First we suppose rDPX price remains the same throughput, to keep it simple.
bond()
for N amount of bond at the start of the option epoch. User A is charged X amount of premium.bond()
for same N amount of bond at the end the option epoch. However, User B pay less than X premium for the bonding. So User A is charged more despite receiving the same amount of bond and providing the same rDPX amount.Consider using a fixed fee approach for bond()
to cover the premium payment so that users pay a consistent amount at different part of the epoch.
Other
#0 - c4-pre-sort
2023-09-13T11:25:19Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-14T16:28:39Z
bytes032 marked the issue as duplicate of #761
#2 - c4-judge
2023-10-20T12:00:49Z
GalloDaSballo marked the issue as nullified
#3 - GalloDaSballo
2023-10-20T12:01:01Z
Says over-paying is unfair but that's not true
#4 - c4-judge
2023-10-20T15:47:01Z
GalloDaSballo marked the issue as partial-50
#5 - c4-judge
2023-10-21T07:54:54Z
GalloDaSballo changed the severity to 2 (Med Risk)
238.7514 USDC - $238.75
PerpetualAtlanticVaultLP.redeem()
allow LP holders to redeem their LP shares in exchange for an equivalent amount of WETH and rDPX tokens.
However, the issue is that redeem()
does not allow redemption when WETH balance in PerpetualAtlanticVaultLP
is zero, even when rDPX balance is positive.
This could occur when all the WETH in PerpetualAtlanticVaultLP
are used up to settle the ITM options and buy the rDPX tokens from RdpxV2Core
.
In that situation, rDPX balance in PerpetualAtlanticVaultLP
will be positive as rDPX is purchased from the option settlement while WETH balance is zero.
function redeem( uint256 shares, address receiver, address owner ) public returns (uint256 assets, uint256 rdpxAmount) { ... (assets, rdpxAmount) = redeemPreview(shares); //@audit in the event that all the WETH are used up, this will prevent LP holders from redeeming the remaining rDPX. // Check for rounding error since we round down in previewRedeem. require(assets != 0, "ZERO_ASSETS");
LP holders of PerpetualAtlanticVaultLP
will not be able to redeem their shares when the issue occur.
Suppose the following situation,
PerpetualAtlanticVaultLP
are fully locked as they are used to purchase put options and any un-used WETH are redeemed by users. So no there no unlocked WETH at this moment.RdpxV2Core
admin proceed to settle all the options.PerpetualAtlanticVaultLP
are then transferred to RdpxV2Core
in exchange for rDPX tokens.PerpetualAtlanticVaultLP
. However, due to the require(assets != 0)
check, PerpetualAtlanticVaultLP
holders are not able to redeem their shares.Change to require(assets != 0 || rdpxAmount != 0, "ZERO_ASSETS");
Invalid Validation
#0 - c4-pre-sort
2023-09-13T11:26:27Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-15T06:14:35Z
bytes032 marked the issue as duplicate of #750
#2 - c4-pre-sort
2023-09-15T06:14:51Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-12T11:29:22Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-10-15T18:03:04Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: Vagner
Also found by: 0Kage, 0xCiphky, 0xnev, ABAIKUNANBAEV, Aymen0909, Evo, KmanOfficial, MohammedRizwan, T1MOH, Viktor_Cortess, Yanchuan, ak1, alexzoid, bin2chen, codegpt, hals, ladboy233, mrudenko, nemveer, oakcobalt, peakbolt, pep7siup, qbs, said, savi0ur, tapir, wintermute, zaevlad, zzebra83
7.8372 USDC - $7.84
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV2LiquidityAmo.sol#L160-L178
UniV2LiquidityAmo._sendTokensToRdpxV2Core()
is called to send all ETH and rDPX tokens in UniV2LiquidityAmo
to RdpxV2Core
after liquidity/swap operations.
However, it is missing a call to RdpxV2Core.sync()
to synchronize the tokenBalance for the ETH and rDPX reserves. This will cause a de-sync in the tokenBalance and affect the operations in RdpxV2Core
.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV2LiquidityAmo.sol#L160-L178
function _sendTokensToRdpxV2Core() internal { uint256 tokenABalance = IERC20WithBurn(addresses.tokenA).balanceOf( address(this) ); uint256 tokenBBalance = IERC20WithBurn(addresses.tokenB).balanceOf( address(this) ); // transfer token A and B from this contract to the rdpxV2Core IERC20WithBurn(addresses.tokenA).safeTransfer( addresses.rdpxV2Core, tokenABalance ); IERC20WithBurn(addresses.tokenB).safeTransfer( addresses.rdpxV2Core, tokenBBalance ); emit LogAssetsTransfered(msg.sender, tokenABalance, tokenBBalance); }
The issue will cause a de-sync of ETH and rDPX token balance in RdpxV2Core, leading to DoS of certain operations that adjust token balance such as settle()
, bond()
, provideFunding()
.
Add a call to RdpxV2Core.sync()
in UniV2LiquidityAmo._sendTokensToRdpxV2Core()
.
DoS
#0 - c4-pre-sort
2023-09-09T03:43:07Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-09T04:11:41Z
bytes032 marked the issue as duplicate of #269
#2 - c4-pre-sort
2023-09-11T11:58:49Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-15T18:12:07Z
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-L295
ReLPContract.reLP()
is used to adjust the LP allocation in the UniswapV2 rDPX/ETH pool by removing part of liquidity, swapping received ETH to rDPX and re-adding liquidity. It essentially increase price of rDPX and increase value of rDPX backing reserve.
However, when re-adding liquidity back to the pool, it does not handle the case where there could be un-used ETH balance after addLiquidity()
is called. It is possible for ETH to be partially utilized for addLiquidity()
due to the 0.3% trading fee for swap.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L286-L295
function reLP(uint256 _amount) external onlyRole(RDPXV2CORE_ROLE) { ... //@audit tokenAAmountOut has 0.03% fee deducted uint256 tokenAAmountOut = IUniswapV2Router(addresses.ammRouter) .swapExactTokensForTokens( amountB / 2, mintokenAAmount, path, address(this), block.timestamp + 10 )[path.length - 1]; (, , uint256 lp) = IUniswapV2Router(addresses.ammRouter).addLiquidity( addresses.tokenA, addresses.tokenB, tokenAAmountOut, amountB / 2, //@audit this will be partially utilized as actual amt is derived from tokenAAmountOut 0, 0, address(this), block.timestamp + 10 ); ... //@audit missing transfer of balance ETH in reLPContract // transfer rdpx to rdpxV2Core IERC20WithBurn(addresses.tokenA).safeTransfer( addresses.rdpxV2Core, IERC20WithBurn(addresses.tokenA).balanceOf(address(this)) ); IRdpxV2Core(addresses.rdpxV2Core).sync();
If we see UniswapV2Router._addLiquidity()
, it will calculate the amountB
based on amountADesired
. So the amount of ETH used for addLiquidity()
will be lower. See POC for more explanation.
https://github.com/Uniswap/v2-periphery/blob/master/contracts/UniswapV2Router02.sol#L49-L53
function _addLiquidity( address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin ) internal virtual returns (uint amountA, uint amountB) { // create the pair if it doesn't exist yet if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) { IUniswapV2Factory(factory).createPair(tokenA, tokenB); } (uint reserveA, uint reserveB) = UniswapV2Library.getReserves(factory, tokenA, tokenB); if (reserveA == 0 && reserveB == 0) { (amountA, amountB) = (amountADesired, amountBDesired); } else { uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB); if (amountBOptimal <= amountBDesired) { require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT'); (amountA, amountB) = (amountADesired, amountBOptimal); } else {
Un-used balance of ETH is stuck within the ReLPContract.
Here is a simple scenario to explain a possible issue,
ReLP()
calls swapExactTokensForTokens()
to swap amountB / 2
ETH and obtain tokenAAmountOut
of rDPX tokens. As we are providing exactly amountB / 2
of ETH for swapExactTokensForTokens()
, the received tokenAAmountOut
will be deducted of the fee.
To keep it simple, we assume an exchange rate of 1 ETH to 1 rDPX.
Suppose for the swapExactTokensForTokens()
, we swap amountB/2
= 100 ETH and we will receive tokenAAmountOut
= 99.7 rDPX (fee paid is 0.3% of 100 rDPX = 0.03 rDPX)
Now if we provide 99.7 rDPX (amountADesired
) and 100 ETH (amountBDesired
) for addLiquidity()
, only 99.7 ETH will be used as _addLiquidity()
will calculate the amountB
based on amountADesired
. Based on 1:1 exchange rate that will be 99.7ETH (See code above).
So after addLiquidity()
, 0.03 ETH will be left within the RELPContract.
For more details, run the following test case in tests/rdpxV2-core/Periphery.t.sol
function testPeakboltReLpContract() public { testV2Amo(); // set address in reLP contract and grant role reLpContract.setAddresses( address(rdpx), address(weth), address(pair), address(rdpxV2Core), address(rdpxReserveContract), address(uniV2LiquidityAMO), address(rdpxPriceOracle), address(factory), address(router) ); reLpContract.grantRole(reLpContract.RDPXV2CORE_ROLE(), address(rdpxV2Core)); reLpContract.setreLpFactor(9e4); // add liquidity uniV2LiquidityAMO.addLiquidity(5e18, 1e18, 0, 0); uniV2LiquidityAMO.approveContractToSpend( address(pair), address(reLpContract), type(uint256).max ); rdpxV2Core.setIsreLP(true); uint256 reLpwethBalanceBefore = weth.balanceOf(address(reLpContract)); rdpxV2Core.bond(1 * 1e18, 0, address(this)); uint256 reLpwethBalanceRemaining = weth.balanceOf(address(reLpContract)) - reLpwethBalanceBefore; console.log("Left over WETH stuck in reLPContract = %d", reLpwethBalanceRemaining); }
Transfer any remaining balance of ETH back to rdpxV2Core
and call sync()
.
Uniswap
#0 - c4-pre-sort
2023-09-07T12:54:43Z
bytes032 marked the issue as duplicate of #1286
#1 - c4-pre-sort
2023-09-11T15:38:13Z
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:14:00Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: juancito
Also found by: 0xDING99YA, 0xTiwa, 0xkazim, 0xnev, ABA, ArmedGoose, Aymen0909, Bauchibred, Evo, IceBear, KrisApostolov, MohammedRizwan, Nikki, QiuhaoLi, T1MOH, Toshii, WoolCentaur, Yanchuan, __141345__, asui, bart1e, carrotsmuggler, catellatech, chaduke, codegpt, deadrxsezzz, degensec, dethera, dirk_y, erebus, ether_sky, gjaldon, glcanvas, jasonxiale, josephdara, klau5, kodyvim, ladboy233, lsaudit, minhquanym, parsely, peakbolt, pep7siup, rvierdiiev, said, savi0ur, sces60107, tapir, ubermensch, volodya, zzebra83
140.2087 USDC - $140.21
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L978
Users can delegate their WETH and earn fees when its utilized by others to bond with their rDPX tokens.
However, the delegates are not able to withdraw un-used WETH when paused. This is not fair to the delegates as bondWithDelegate()
is disabled when paused so there is no opportunity for delegates to earn fees on their delegated WETH.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L978
function withdraw( uint256 delegateId ) external returns (uint256 amountWithdrawn) { //@audit this prevent withdraw of delegated WETH when paused _whenNotPaused();
Do not disable withdraw()
when paused to allow WETH delegate withdraw their un-used WETH.
Other
#0 - c4-pre-sort
2023-09-10T07:31:00Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-10T07:31:05Z
bytes032 marked the issue as primary issue
#2 - c4-sponsor
2023-09-25T16:03:59Z
psytama (sponsor) disputed
#3 - psytama
2023-09-25T16:06:26Z
If a contract is paused due to an emergency situation where there can be an exploit we would not want any of the funds to be transferred out and the admin would emergency withdraw the funds and distribute it accordingly.
#4 - c4-judge
2023-10-06T12:06:38Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-10-20T18:16:02Z
GalloDaSballo marked the issue as grade-a