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: 23/189
Findings: 6
Award: $893.77
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L359 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L201
AtlanticVault.sol#settle can be DOSed when admin want to settle with loss
In function settle PerpetualAtlanticVault
the line of code is called below
IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss(ethAmount);
which calls subractLoss in VaultLp contract
function subtractLoss(uint256 loss) public onlyPerpVault { require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss; }
noteo the line of code:
require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" );
this check is too strict, before the AtlanticVault.sol#settle called, as long as user transfer 1 wei of collateral to the AtlanticVaultLP contract
the check above revert and break, and then the admin cannot settle the option id on time
Manual Review
do not use strict comparision
collateral.balanceOf(address(this)) == _totalCollateral - loss
can change to >= instead of ==
DoS
#0 - c4-pre-sort
2023-09-09T10:02:31Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:15:16Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T20:01:55Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-10-21T07:26:28Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-10-21T07:26:28Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-10-21T07:26:28Z
GalloDaSballo changed the severity to 3 (High Risk)
🌟 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.0734 USDC - $0.07
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/amo/UniV3LiquidityAmo.sol#L361 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L873 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L964
Bond / BondWithDelegate / amo / relp can get consistently DOSed because totalWethDelegated is not updated when delegate withdraw WETH
the bond / bondWithDelegate / amo / relp are all core operation
In UniswapV3LiquidityAmo when admin swap / add liquidity and mint position NFT / decrease liquidity
the function _sendTokensToRdpxV2Core is called
function _sendTokensToRdpxV2Core(address tokenA, address tokenB) internal { uint256 tokenABalance = IERC20WithBurn(tokenA).balanceOf(address(this)); uint256 tokenBBalance = IERC20WithBurn(tokenB).balanceOf(address(this)); // transfer token A and B from this contract to the rdpxV2Core IERC20WithBurn(tokenA).safeTransfer(rdpxV2Core, tokenABalance); IERC20WithBurn(tokenB).safeTransfer(rdpxV2Core, tokenBBalance); // sync token balances IRdpxV2Core(rdpxV2Core).sync(); emit LogAssetsTransfered(tokenABalance, tokenBBalance, tokenA, tokenB); }
and the function call sync()
IRdpxV2Core(rdpxV2Core).sync();
when bond / bondWithDelegate, and the relp flag turns on,
// reLP if (isReLPActive) IReLP(addresses.reLPContract).reLP(totalBondAmount);
according to the docs:
Finally a reLP process is applied on the Uniswap V2 liquidity added by the Uniswap V2 AMO. This is toggleable and hence may or may not be used on every bond execution depending on the market conditions of rDPX.
In the end of the relp function, sync() is called as well to sync the reserve balance of WETH and RDPX
However, the cost of DOS the functon sync is cheap
function sync() external { for (uint256 i = 1; i < reserveAsset.length; i++) { uint256 balance = IERC20WithBurn(reserveAsset[i].tokenAddress).balanceOf( address(this) ); if (weth == reserveAsset[i].tokenAddress) { balance = balance - totalWethDelegated; } reserveAsset[i].tokenBalance = balance; } emit LogSync(); }
how to make this function consistently revert?
note this line of code:
if (weth == reserveAsset[i].tokenAddress) { balance = balance - totalWethDelegated; }
the totalWethDelegated is updated in two other places,
when user call addToDelegate, totalWethDelegated is increased
// add amount to total weth delegated totalWethDelegated += _amount;
when user bondWithDelegate, the totalWethDelegated is decreased
_validate(delegate.amount - delegate.activeCollateral >= wethRequired, 5); delegate.activeCollateral += wethRequired; // update total weth delegated totalWethDelegated -= wethRequired;
the user can addToDelegate and set a fee and delegate WETH, so user with only rdpx can pair with the delegated WETH to complete the bond operation
the user can withdraw the not-used WETH by calling RdpxV2Core.sol#withdraw
However, when the delegator withdraw not-used WETH in this way, the totalWethDelegated is not decreased and therefore the totalWethDelegated is inflated
then it is very obvious that
if (weth == reserveAsset[i].tokenAddress) { balance = balance - totalWethDelegated; }
can revert in underflow
suppose WETH balance is 0 ETH
user addToDelegate with 0.5 WETH, now the contract hold 0.5 WETH,
the totalWethDelegated is 0.5 WETH
then user can withdraw, the contract balance hold 0 WETh
but totalWethDelegated is still 0.5 WETh
then balance - totalWethDelegated -> 0 WETH - 0.5 WETH -> underflow
basically all a malicious actor needs to do is call addToDelegate and then withdraw to inflate the totalWethDelegated state
then sync would revert in underflow, and relp revert in underflow when calling sync, and if the relp flag turns on, the bond operation revert as well
the POC below shows that addToDelegate then withdraw can make sync revert in underflow
In tests\rdpxV2-core\Unit.t.sol,
add
function testDelegate_POC() public { uint256 userBalance = weth.balanceOf(address(this)); uint256 delegateId = rdpxV2Core.addToDelegate(50 * 1e18, 10 * 1e8); rdpxV2Core.withdraw(delegateId); rdpxV2Core.sync(); }
then run the test
and we can see that transaction revert in underflow when calling sync
PS D:\2023Security\2023-08-dopex> forge test --match testDelegate_POC [â ”] Compiling... [â †] Compiling 1 files with 0.8.19 [â ’] Solc 0.8.19 finished in 11.85s Compiler run successful (with warnings) warning[2072]: Warning: Unused local variable. --> tests/rdpxV2-core/Unit.t.sol:103:6: | 103 | uint256 userBalance = weth.balanceOf(address(this)); | ^^^^^^^^^^^^^^^^^^^ Running 1 test for tests/rdpxV2-core/Unit.t.sol:Unit [FAIL. Reason: Arithmetic over/underflow] testDelegate_POC() (gas: 192797) Test result: FAILED. 0 passed; 1 failed; finished in 149.54ms Failing tests: Encountered 1 failing test in tests/rdpxV2-core/Unit.t.sol:Unit [FAIL. Reason: Arithmetic over/underflow] testDelegate_POC() (gas: 192797) Encountered a total of 1 failing tests, 0 tests succeeded
Manual Review,
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); amountWithdrawn = delegate.amount - delegate.activeCollateral; _validate(amountWithdrawn > 0, 15); delegate.amount = delegate.activeCollateral; // update totalWethDelegated state here totalWethDelegated -= amountWithdrawn; IERC20WithBurn(weth).safeTransfer(msg.sender, amountWithdrawn); emit LogDelegateWithdraw(delegateId, amountWithdrawn); }
Under/Overflow
#0 - c4-pre-sort
2023-09-08T13:27:04Z
bytes032 marked the issue as high quality report
#1 - c4-pre-sort
2023-09-08T13:27:11Z
bytes032 marked the issue as duplicate of #2186
#2 - c4-judge
2023-10-20T17:53:49Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-10-20T17:55:32Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-10-21T07:38:55Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-10-21T07:43:42Z
GalloDaSballo marked the issue as partial-50
🌟 Selected for report: juancito
Also found by: 0x3b, 0xmuxyz, 0xnev, ArmedGoose, Bauchibred, KrisApostolov, RED-LOTUS-REACH, Viktor_Cortess, ciphermarco, ginlee, ladboy233, mitko1111, nemveer
90.6302 USDC - $90.63
AddLiquidity during Relp missing slippage protection
According to the docs:
https://dopex.notion.site/rDPX-V2-RI-b45b5b402af54bcab758d62fb7c69cb4
reLP is the process by which part of the Uniswap V2 liquidity is removed, then the ETH received is swapped to rDPX.
when adding liquidity, the min token out of A and B are set to 0
(, , uint256 lp) = IUniswapV2Router(addresses.ammRouter).addLiquidity( addresses.tokenA, addresses.tokenB, tokenAAmountOut, amountB / 2, 0, 0, address(this), block.timestamp + 10 );
the protocol intended to deploy on arbitrum, the sequencer is in charge of ordering transaction
even it is difficult to frontrun and sandwich the addLiquidity like other network,
the user cannot make sure there is no other transaction that change the liquidity of Uniswap V2 pool landed before the relp transaction in the same block
so a very suboptimal amount of Lp can be minted
Manual Review
does hardcode amountAMin and amountBMin to 0
Uniswap
#0 - c4-pre-sort
2023-09-07T12:42:33Z
bytes032 marked the issue as duplicate of #1259
#1 - c4-pre-sort
2023-09-11T07:51:04Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-11T07:53:21Z
bytes032 marked the issue as duplicate of #1032
#3 - c4-judge
2023-10-15T19:21:19Z
GalloDaSballo marked the issue as satisfactory
655.0107 USDC - $655.01
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L904 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L912 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L653 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L1180
Bond missing slippage control and can cost too much WETH of caller
When bond, user pays WETH and RDPX togther
/** * @notice The primary bonding function * @param _amount The amount of DpxEth to bond for * @param rdpxBondId The bond id * @param _to The address to send the bond to * @return receiptTokenAmount the amount of receipt tokens received **/ function bond( uint256 _amount, uint256 rdpxBondId, address _to ) public returns (uint256 receiptTokenAmount) {
the exact amount of the charged WETH and RDPX is calculated by the function
// Compute the bond cost (uint256 rdpxRequired, uint256 wethRequired) = calculateBondCost( _amount, rdpxBondId );
If we take a look at the logic inside calculateBondCost, the amout of rdpxRequired
depends on
// if rdpx decaying bonds are being used there is no discount rdpxRequired = (RDPX_RATIO_PERCENTAGE * _amount * DEFAULT_PRECISION) / (DEFAULT_PRECISION * rdpxPrice * 1e2); wethRequired = (ETH_RATIO_PERCENTAGE * _amount) / (DEFAULT_PRECISION * 1e2);
In case there is a price strike for rdpxPrice, the user can be charged too much rdpx when bonding
the amount of the WETH required depends on
uint256 strike = IPerpetualAtlanticVault(addresses.perpetualAtlanticVault) .roundUp(rdpxPrice - (rdpxPrice / 4)); // 25% below the current price uint256 timeToExpiry = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).nextFundingPaymentTimestamp() - block.timestamp; // @audit if (putOptionsRequired) { wethRequired += IPerpetualAtlanticVault(addresses.perpetualAtlanticVault) .calculatePremium(strike, rdpxRequired, timeToExpiry, 0); }
function calculatePremium( uint256 _strike, uint256 _amount, uint256 timeToExpiry, uint256 _price ) public view returns (uint256 premium) { premium = ((IOptionPricing(addresses.optionPricing).getOptionPrice( _strike, _price > 0 ? _price : getUnderlyingPrice(), getVolatility(_strike), timeToExpiry ) * _amount) / 1e8); }
in the case when there is a volatile period or there is a price strike of rdpx price, too much premium can be charged in the form of WETH from caller
then the amount of too much WETH is transfered from user in this line of code
the amount of too much RDPX is transfered from user in this line of code
suppose the admin submit a transaction to update the oracle price first, while the sequencer is finalizing the transaction
a user can call bond, but again as mentioned above, combining with the fact that the bond function lacks slippage control, suddenly price spike of the rdpx price can cause user get charged too much WETH and RDPX when bonding
Manual Review
consider implement slippage control when user bonding and limit the max WETH used and max RDPX uesd
Timing
#0 - c4-pre-sort
2023-09-13T12:48:18Z
bytes032 marked the issue as duplicate of #598
#1 - c4-judge
2023-10-20T09:33:41Z
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/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L995 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/amo/UniV2LiquidityAmo.sol#L160 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L486 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L1135
UniV2LiquidityAmo.sol does not sync core contract balance correctly and break the Core contract accounting
In RdpxV2Core.sol contract
the code use reserveAssets to track WETH token and RDPX token balance
which is
reserveAsset[reservesIndex["WETH"]].tokenBalance
and
reserveAsset[reservesIndex["RDPX"]].tokenBalance
this is crucial to the Core contract accounting
In UniV3LiquidityAmo.sol,
whenever calling addLiquidity or removeLiqudity or swap,
the function _sendTokensToRdpxV2Core is called, this is the code
function _sendTokensToRdpxV2Core(address tokenA, address tokenB) internal { uint256 tokenABalance = IERC20WithBurn(tokenA).balanceOf(address(this)); uint256 tokenBBalance = IERC20WithBurn(tokenB).balanceOf(address(this)); // transfer token A and B from this contract to the rdpxV2Core IERC20WithBurn(tokenA).safeTransfer(rdpxV2Core, tokenABalance); IERC20WithBurn(tokenB).safeTransfer(rdpxV2Core, tokenBBalance); // sync token balances IRdpxV2Core(rdpxV2Core).sync(); emit LogAssetsTransfered(tokenABalance, tokenBBalance, tokenA, tokenB); }
note the function call
IRdpxV2Core(rdpxV2Core).sync();
and the function sync call sync the balance of reserved token properly
this function update the reservesAsset token balance correctly in core contract
However, in UniV2LiquidityAmo.sol#_sendTokensToRdpxV2Core, the line of code
IRdpxV2Core(rdpxV2Core).sync();
is never called
but the tokenA and tokenB are sent to Core contract, so without calling sync in Core contract, the
reserveAsset[reservesIndex["WETH"]].tokenBalance
and
reserveAsset[reservesIndex["RDPX"]].tokenBalance
under-estimate the asset balance
this becomes a issue when the the core contract tries to update the token balance, especially when the contract tries to substract from the balance
reserveAsset[reservesIndex["RDPX"]].tokenBalance -= _rdpxAmount;
and
reserveAsset[reservesIndex["WETH"]].tokenBalance -= _wethAmount;
one of the example is when the code tries to purchase the option
// update weth reserve reserveAsset[reservesIndex["WETH"]].tokenBalance += wethRequired; // purchase options uint256 premium; if (putOptionsRequired) { premium = _purchaseOptions(rdpxRequired); }
and
/** * @notice Purchase rdpx put options * @param _amount amount of rdpx to purchase * @return premium amount of premium paid */ function _purchaseOptions( uint256 _amount ) internal returns (uint256 premium) { /** * Purchase options and store ERC721 option id * Note that the amount of options purchased is the amount of rDPX received * from the user to sufficiently collateralize the underlying DpxEth stored in the bond **/ uint256 optionId; (premium, optionId) = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).purchase(_amount, address(this)); optionsOwned[optionId] = true; reserveAsset[reservesIndex["WETH"]].tokenBalance -= premium; }
the code tries to purchase put option and the premium is subtract from the WETH balance
suppose reserveAsset[reservesIndex["WETH"]].tokenBalance is 1 WETH
then UniV2LiquidityAmo.sol#addLiquidity is triggered and _sendTokensToRdpxV2Core add 0.5 WETH to the core contract
the real balance of WETH 1.5 WETH, while the contract think it only has 1 WETH
suppose user tries to bond and transfer 1 WETH in this line of code,
// update weth reserve reserveAsset[reservesIndex["WETH"]].tokenBalance += wethRequired;
now reserveAsset[reservesIndex["WETH"]].tokenBalance is 2 WETH (in reality the contract 2.5 WETH)
and then premium that needs to pay is 2.1 WETH
2 WETH - 2.1 WETH in this line of code can revert in underflow
reserveAsset[reservesIndex["WETH"]].tokenBalance -= premium;
while in reality the contract hold 2.5 WETH (2 WETH original WETH + 0.5 WETH from UniV2LiquidityAmo.sol#addLiquidity) and user should be able to purchase the option
but because the sync is never called, the update of balance of WETH and RDPX is delayed in the core contract
further more, the core contract exposes a view function, without sync the core contract balance properly, the external integration that replies on the api getReserveTokenInfo can retrieve less token balance than it should be
and the function getReserveTokenInfo is no longer a reliable source for external integration to retrieve the token balance state.
function getReserveTokenInfo( string memory _token ) public view returns (address, uint256, string memory) { ReserveAsset memory asset = reserveAsset[reservesIndex[_token]]; _validate( keccak256(abi.encodePacked(asset.tokenSymbol)) == keccak256(abi.encodePacked(_token)), 19 ); return (asset.tokenAddress, asset.tokenBalance, asset.tokenSymbol); }
Manual Review
Sync core contract balane properly after changing the core contract token balance
Token-Transfer
#0 - c4-pre-sort
2023-09-09T03:47:07Z
bytes032 marked the issue as duplicate of #798
#1 - c4-pre-sort
2023-09-09T04:09:26Z
bytes032 marked the issue as duplicate of #269
#2 - c4-pre-sort
2023-09-11T11:58:36Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-15T18:12:00Z
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/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L995 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/amo/UniV3LiquidityAmo.sol#L119
UniV3LiquidityAmo.sol does not sync core contract balance correctly when collecting fee and break the Core contract accounting
In RdpxV2Core.sol contract
the code use reserveAssets to track WETH token and RDPX token balance
which is
reserveAsset[reservesIndex["WETH"]].tokenBalance
and
reserveAsset[reservesIndex["RDPX"]].tokenBalance
this is crucial to the Core contract accounting
In UniV3LiquidityAmo.sol,
whenever calling addLiquidity or removeLiqudity or swap,
the function _sendTokensToRdpxV2Core is called, this is the code
function _sendTokensToRdpxV2Core(address tokenA, address tokenB) internal { uint256 tokenABalance = IERC20WithBurn(tokenA).balanceOf(address(this)); uint256 tokenBBalance = IERC20WithBurn(tokenB).balanceOf(address(this)); // transfer token A and B from this contract to the rdpxV2Core IERC20WithBurn(tokenA).safeTransfer(rdpxV2Core, tokenABalance); IERC20WithBurn(tokenB).safeTransfer(rdpxV2Core, tokenBBalance); // sync token balances IRdpxV2Core(rdpxV2Core).sync(); emit LogAssetsTransfered(tokenABalance, tokenBBalance, tokenA, tokenB); }
note the function call
IRdpxV2Core(rdpxV2Core).sync();
and the function sync call sync the balance of reserved token properly
this function update the reservesAsset token balance correctly in core contract
However, in UniV3LiquidityAmo.sol#collectFees, the line of code
IRdpxV2Core(rdpxV2Core).sync();
is never called
function collectFees() external onlyRole(DEFAULT_ADMIN_ROLE) { for (uint i = 0; i < positions_array.length; i++) { Position memory current_position = positions_array[i]; INonfungiblePositionManager.CollectParams memory collect_params = INonfungiblePositionManager.CollectParams( current_position.token_id, rdpxV2Core, type(uint128).max, type(uint128).max ); // Send to custodian address univ3_positions.collect(collect_params); } }
but the tokenA and tokenB from the LP fee on Uniswap V3 are sent to Core contract, so without calling sync in Core contract, the
reserveAsset[reservesIndex["WETH"]].tokenBalance
and
reserveAsset[reservesIndex["RDPX"]].tokenBalance
under-estimate the asset balance
this becomes a issue when the the core contract tries to update the token balance, especially when the contract tries to substract from the balance
reserveAsset[reservesIndex["RDPX"]].tokenBalance -= _rdpxAmount;
and
reserveAsset[reservesIndex["WETH"]].tokenBalance -= _wethAmount;
one of the example is when the code tries to purchase the option
// update weth reserve reserveAsset[reservesIndex["WETH"]].tokenBalance += wethRequired; // purchase options uint256 premium; if (putOptionsRequired) { premium = _purchaseOptions(rdpxRequired); }
and
/** * @notice Purchase rdpx put options * @param _amount amount of rdpx to purchase * @return premium amount of premium paid */ function _purchaseOptions( uint256 _amount ) internal returns (uint256 premium) { /** * Purchase options and store ERC721 option id * Note that the amount of options purchased is the amount of rDPX received * from the user to sufficiently collateralize the underlying DpxEth stored in the bond **/ uint256 optionId; (premium, optionId) = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).purchase(_amount, address(this)); optionsOwned[optionId] = true; reserveAsset[reservesIndex["WETH"]].tokenBalance -= premium; }
the code tries to purchase put option and the premium is subtract from the WETH balance
suppose reserveAsset[reservesIndex["WETH"]].tokenBalance is 1 WETH
then UniV2LiquidityAmo.sol#addLiquidity is triggered and _sendTokensToRdpxV2Core add 0.5 WETH to the core contract
the real balance of WETH 1.5 WETH, while the contract think it only has 1 WETH
suppose user tries to bond and transfer 1 WETH in this line of code,
// update weth reserve reserveAsset[reservesIndex["WETH"]].tokenBalance += wethRequired;
now reserveAsset[reservesIndex["WETH"]].tokenBalance is 2 WETH (in reality the contract 2.5 WETH)
and then premium that needs to pay is 2.1 WETH
2 WETH - 2.1 WETH in this line of code can revert in underflow
reserveAsset[reservesIndex["WETH"]].tokenBalance -= premium;
while in reality the contract hold 2.5 WETH (2 WETH original WETH + 0.5 WETH from UniV2LiquidityAmo.sol#addLiquidity) and user should be able to purchase the option
but because the sync is never called, the update of balance of WETH and RDPX is delayed in the core contract
further more, the core contract exposes a view function, without sync the core contract balance properly, the external integration that replies on the api getReserveTokenInfo can retrieve less token balance than it should be
and the function getReserveTokenInfo is no longer a reliable source for external integration to retrieve the token balance state.
function getReserveTokenInfo( string memory _token ) public view returns (address, uint256, string memory) { ReserveAsset memory asset = reserveAsset[reservesIndex[_token]]; _validate( keccak256(abi.encodePacked(asset.tokenSymbol)) == keccak256(abi.encodePacked(_token)), 19 ); return (asset.tokenAddress, asset.tokenBalance, asset.tokenSymbol); }
Manual Review
Sync core contract balane properly after changing the core contract token balance
Token-Transfer
#0 - c4-pre-sort
2023-09-09T04:00:12Z
bytes032 marked the issue as primary issue
#1 - bytes032
2023-09-09T04:00:42Z
Similar to #798, but different function.
#2 - c4-pre-sort
2023-09-09T04:06:04Z
bytes032 marked the issue as duplicate of #798
#3 - c4-pre-sort
2023-09-09T04:09:27Z
bytes032 marked the issue as duplicate of #269
#4 - c4-pre-sort
2023-09-11T11:58:36Z
bytes032 marked the issue as sufficient quality report
#5 - c4-judge
2023-10-15T18:11:59Z
GalloDaSballo marked the issue as satisfactory
#6 - JeffCX
2023-10-21T16:53:34Z
this is marked as duplicate of #250
but the issue 250 only talk about does not calling sync on UniswapV2AMO
this issue talk about sync is not properly called in UniswapV3AMO
so I politely think this issue as well as https://github.com/code-423n4/2023-08-dopex-findings/issues/269 that mentions UniswapV3AMO missing sync issue can be duplicated seperately as a medium
because fixing issue 250 does not fix the issue highlighted in this report
#7 - T1MOH593
2023-10-24T05:02:25Z
In case judge will separate issues, more complex reduplication should be made, not only 2 above reports describe issue in UniswapV3Amo
#8 - JeffCX
2023-10-24T11:40:02Z
In case judge will separate issues, more complex reduplication should be made, not only 2 above reports describe issue in UniswapV3Amo
which report describe UniswapV3Amo sync issue as well?
#9 - GalloDaSballo
2023-10-24T15:44:32Z
Will change Best Submission to one that hits at both AMOs
🌟 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
RDPX reserve balance can be inflated to apply too much discount when bond or cause too much LP to remove when relp
while the reserve logic is out of scope, the flaw clearly impact the in-scope code
when Relp, the logic of calculating how much discount depends on the spot reserve balance of rdpx
function calculateBondCost( uint256 _amount, uint256 _rdpxBondId ) public view returns (uint256 rdpxRequired, uint256 wethRequired) { uint256 rdpxPrice = getRdpxPrice(); if (_rdpxBondId == 0) { // @audit uint256 bondDiscount = (bondDiscountFactor * Math.sqrt(IRdpxReserve(addresses.rdpxReserve).rdpxReserve()) * 1e2) / (Math.sqrt(1e18)); // 1e8 precision _validate(bondDiscount < 100e8, 14); rdpxRequired = ((RDPX_RATIO_PERCENTAGE - (bondDiscount / 2)) * _amount * DEFAULT_PRECISION) / (DEFAULT_PRECISION * rdpxPrice * 1e2); wethRequired = ((ETH_RATIO_PERCENTAGE - (bondDiscount / 2)) * _amount) / (DEFAULT_PRECISION * 1e2); }
a user can inflate the rdpxReserve by sending the rdpx directly to reserve balance
then the bondDiscount applied is larger and user can pay less rdpx and weth when bonding
the POC is here in tests\rdpxV2-core\Unit.t.sol
function testTooMuchDiscount_Bond_POC() public { uint256 userwethBalance = weth.balanceOf(address(this)); uint256 userRdpxBalance = rdpx.balanceOf(address(this)); (uint256 rdpxRequired, uint256 wethRequired) = rdpxV2Core.calculateBondCost( 1 * 1e18, 0 ); console.log("rdpx required", rdpxRequired); console.log("weth required", wethRequired); address reserve = address(rdpxReserveContract); rdpx.transfer(reserve, 10000 ether); console.log("amount of WETH and RDPX to bond after applying discount"); (uint256 rdpxRequiredAfter, uint256 wethRequiredAfter) = rdpxV2Core.calculateBondCost( 1 * 1e18, 0 ); console.log("rdpx required", rdpxRequiredAfter); console.log("weth required", wethRequiredAfter); }
we run the POC and we get:
rdpx required 1225000000000000000 weth required 806250000000000000 amount of WETH and RDPX to bond after applying discount rdpx required 998753109500000000 weth required 749688277375000000
after we inflate the reserved balance of the reserve contract,
the amount of rdpx and weth to bond is much smaller,
note, wether this is profitable for attack depends on whether the attack can cash out the minted bond in external NFT market
and also depending on the price of rdpx and weth, in the case when rdpx is very cheap while ETH is expensive, the attacker can buy more rdpx to inflate the reserve balance and pay less WETH to get higher discount
Manual Review
do not use the consume the spot balance of the rdpx reserve balance when calcuting bonding cost
Other
#0 - c4-pre-sort
2023-09-10T07:59:15Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-11T06:37:21Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-11T06:37:27Z
bytes032 marked the issue as high quality report
#3 - c4-sponsor
2023-09-26T14:28:50Z
psytama (sponsor) disputed
#4 - psytama
2023-09-26T14:29:42Z
THe attack is not profitable at all and is donating rdpx, and the discount will be monitored and params will change to maintain the target discount.
#5 - GalloDaSballo
2023-10-12T11:19:46Z
Will need to dig deeper
Theoretically if a X donation reduces the cost by more than X then the attack is valid
More specifically, the attack has to be profitable, not just possible
#6 - c4-judge
2023-10-18T15:17:02Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#7 - GalloDaSballo
2023-10-18T15:17:18Z
Donations can be used to change the price of the vault lp
But the finding was unable to leak value, downgrading to QA
#8 - c4-judge
2023-10-20T18:15:49Z
GalloDaSballo marked the issue as grade-a