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: 37/189
Findings: 4
Award: $526.37
🌟 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
A malicious actor can block all settle operations by directly transferring collateral to the LP vault (PerpetualAtlanticVaultLP
), making all future settle operations revert. This happens because PerpetualAtlanticVaultLP
incorrectly presumes that only deposited collateral and procedures increment contract collateral, without taking into account that anyone can directly send collateral to the LP vault.
In the above scenario all settle operations fail and users lose their earned rewards.
A settle operation execution flow is as follows:
RdpxV2Core::settle
PerpetualAtlanticVault::settle
PerpetualAtlanticVaultLP::[several call]
When a settle call reaches PerpetualAtlanticVault::settle
, if the options to be settled are correct, the calculate wETH amount to be moved from the Atlantic LP vault is first sent from the vault and the internal accounting for the LP vault is called to update and confirm the loss:
function settle( uint256[] memory optionIds ) external nonReentrant onlyRole(RDPXV2CORE_ROLE) returns (uint256 ethAmount, uint256 rdpxAmount) { // Transfer collateral token from perpetual vault to rdpx rdpxV2Core collateralToken.safeTransferFrom( addresses.perpetualAtlanticVaultLP, addresses.rdpxV2Core, ethAmount ); // code ... IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( ethAmount ); // code ... }
The IPerpetualAtlanticVaultLP::subtractLoss
verifies that the collateral amount has indeed been transferred out of the vault and notes it internally.
function subtractLoss(uint256 loss) public onlyPerpVault { require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss; }
The issues is with the require condition that collateral.balanceOf(address(this))
must be equal with total accounted collateral minus the now incurred loss since the contract balance can be updated by anyone but _totalCollateral
is only increased in case of a deposit or when adding proceeds gains, meaning it does not take into consideration any directly send balance.
Because of this, directly sending collateral to the vault will block all settles.
Follows is a coded POC test that shows the above scenario. Add it to tests\perp-vault\Unit.t.sol
and it can be run with forge test --match-test testSettleIsBlocked -vv
function testSettleIsBlocked() public { // initial vault setup weth.mint(address(1), 1 ether); deposit(1 ether, address(1)); vault.purchase(1 ether, address(this)); uint256[] memory ids = new uint256[](1); ids[0] = 0; // malicious user bob setup address bob = makeAddr("bob"); weth.mint(bob, 1 ether); // oracle update price so that it is in the money priceOracle.updateRdpxPrice(0.010 gwei); // ITM // before a valid "settle" call, bob transfers 1 WEI to the LP vault vm.prank(bob); weth.transfer(address(vaultLp), 1); // settle will revert vm.expectRevert("Not enough collateral was sent out"); vault.settle(ids); }
A malicious actor can block all settle operations by directly transferring collateral to the Atlantic perpetual LP vault, making all future settle operations revert.
Manual review.
The entire vault LP should hold an internal accounting of exactly what balance it is tracking, relying on collateral.balanceOf(address(this))
is not a recommended practice and may lead to other issues in the future.
DoS
#0 - c4-pre-sort
2023-09-09T06:21:14Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:14:04Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:37:34Z
GalloDaSballo marked the issue as satisfactory
491.258 USDC - $491.26
Atlantic Perpetual PUT Options are currently are implemented without any expiration date. This is a high level design issue that needs to be resolved, as theoretically options that are set near ATL (all time lows) will last forever and those that are near local price lows can remain in the system for weeks to months.
// Mint the option tokens tokenId = _mintOptionToken(); optionPositions[tokenId] = OptionPosition({ strike: strike, amount: amount, positionId: tokenId });
During this time funding is paid for all of them. From an economical point of view this system of options without expiry does not make any sense and can accumulate options indefinitely.
Options can only be closed if they are ITM (in the money) which is a risk free economical instrument for investors, one that should not exist:
// check if strike is ITM _validate(strike >= getUnderlyingPrice(), 7);
Options not settled remain in the forever, system pays funding theoretically forever for them without any value added in the long term.
Manual review.
Implement an expiry period for options and check in the settle function that it either ITM or expired and remove it from the system.
Other
#0 - c4-pre-sort
2023-09-13T15:25:32Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-13T15:25:54Z
bytes032 marked the issue as duplicate of #1956
#2 - c4-judge
2023-10-20T09:37:35Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-20T09:37:51Z
GalloDaSballo marked the issue as satisfactory
🌟 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
Distributing funding for Atlantic Vault PUT option writers needs to be done in 2 steps, without incrementing the epoch pointer, otherwise that funding is lost. Steps:
PerpetualAtlanticVault::calculateFunding
RdpxV2Core::provideFunding
calculateFunding
call and before an call that updates the funding pointer
protocol documentation indicates it is done in the next epoch (chronologically but before the pointer is incremented)
Note that the funding has to be paid at the start of each epoch.
Another important observation is that APP (Atlantic Perpetual PUTS Options vault) owner can modify the epoch duration at will via PerpetualAtlanticVault::updateFundingDuration
to any value.
/** * @notice Updates the funding duration * @dev Can only be called by the owner **/ function updateFundingDuration( uint256 _fundingDuration ) external onlyRole(DEFAULT_ADMIN_ROLE) { fundingDuration = _fundingDuration; }
A major issue occurs when updating the funding duration to any value that would set the protocol into the next epoch without funding being collected up to this point. Since distributing funding (2)
depends on its calculation (1)
which increment the pointer if it is the case, by setting a lower funding duration we are basically "thrusted into the future" from internal protocol accounting point of view and missed collecting and distributing funding amount to option writers.
A theoretical POC:
updateFundingDuration
to 5 dayscalculateFunding
to be called but this updates the current funding pointerupdateFundingPaymentPointer
or updateFunding
(because calculateFunding
can be called only once per strike price per epoch and doing so to update the pointer will result in funding being lost this way)A coded POC follows, with the added observations:
tests\perp-vault\Unit.t.sol
testDecreasingFundingDuration_CorrectFundingBehavior
test) and if an incorrect behavior, the currently existing implementation, is applied (testDecreasingFundingDuration_SlashFunding
)import "forge-std/console.sol";
to file import headerforge test --match-test testDecreasingFundingDuration -vvv
will run both of them at the same time and you can see the difference side by sidefunction doFundingSetup() public returns (uint256[] memory) { // setup tests vault.setLpAllowance(true); uint256 lpBalanceBefore = weth.balanceOf(address(vaultLp)); assertEq(lpBalanceBefore, 0); deposit(100 ether, address(this)); uint256 lpBalanceAfter = weth.balanceOf(address(vaultLp)); assertEq(lpBalanceAfter, 100 ether); // initial options purchase vault.purchase(1 ether, address(this)); uint256[] memory strikes = new uint256[](1); strikes[0] = 0.015 gwei; // start with a fresh pointer skip(86500); vault.updateFundingPaymentPointer(); return strikes; } function testDecreasingFundingDuration_CorrectFundingBehavior() public { uint256[] memory strikes = doFundingSetup(); uint256 cumulatedFundingAmount = 0; skip(75600); // after 21/24 h have passed cumulatedFundingAmount += vault.calculateFunding(strikes); console.log("Correctly paying funding up until before the update in duration; funding:", cumulatedFundingAmount); vault.updateFundingDuration(72000); // change to 20 from 24h cumulatedFundingAmount += vault.calculateFunding(strikes); console.log("Total funding while taking into consideration fragmented epoch:", cumulatedFundingAmount); } function testDecreasingFundingDuration_SlashFunding() public { uint256[] memory strikes = doFundingSetup(); skip(75600); // after 21/24 h have passed // here the call to calculate funding is missing vault.updateFundingDuration(72000); // change to 20 from 24h uint256 fundingAccrued = vault.calculateFunding(strikes); console.log("Total funding without taking into consideration fragmented epoch:", fundingAccrued); }
Output shows how in the correct case we get 0.1 ETH funding paid and in the incorrect one we get only 0.05 ETH paid, a 50% loss in funding:
Running 2 tests for tests/perp-vault/Unit.t.sol:Unit [PASS] testDecreasingFundingDuration_CorrectFundingBehavior() (gas: 810384) Logs: Correctly paying funding up until before the update in duration; funding: 50000000000000000 Total funding while taking into consideration fragmented epoch: 100000000000000000 [PASS] testDecreasingFundingDuration_SlashFunding() (gas: 731499) Logs: Total funding without taking into consideration fragmented epoch: 50000000000000000 Test result: ok. 2 passed; 0 failed; finished in 653.01ms
Updating the funding duration to any value that would set the protocol into the next epoch without funding being collected up to this point will result in the funding being lost.
Manual analysis
Modify updateFundingDuration
so that:
RdpxV2Core
contract and make a RdpxV2Core::updateFundingDuration
version as entry pointPerpetualAtlanticVault::calculateFunding
RdpxV2Core::provideFunding
PerpetualAtlanticVault::updateFundingPaymentPointer
Doing it like so would resolve any funding issue with this scenario.
Timing
#0 - c4-pre-sort
2023-09-08T06:25:49Z
bytes032 marked the issue as duplicate of #980
#1 - c4-pre-sort
2023-09-11T08:20:21Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-11T08:20:39Z
bytes032 marked the issue as high quality report
#3 - c4-judge
2023-10-20T11:12:30Z
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
19.1724 USDC - $19.17
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L624-L685
Decaying bonds are minted to users they face a loss using one of the other Dopex ecosystem products. As a rebate for their loss, a decaying bondable rDPX bond is minted for the users with a expiry after which it is no longer valid and a amount based on the loss.
If a decaying bond is used, as a users, you don't have to send the rDPX from your balance as it will use the rDPX balance from the decaying bonds. Since the user still needs to add WETH the protocol overall still receives value, just less.
The decaying bond consumption logic has a design flaw that significantly reduces the usage of decaying bonds, ultimately leading to less liquidity coming into the protocol itself:
DPXETH
to be bonded to cannot be more then the amount offered from the decaying bondConsider the following situations:
DPXETH
in decaying bondsDPXETH
in decaying bondsDPXETH
in decaying bondsDPXETH
, logically he will use either B because it is the happy path and the least effort to use (normal effort/reward theory)DPXETH
DPXETH
, which uses user A's bondsDPXETH
in decaying bondsDPXETH
in decaying bondsDPXETH
in decaying bondsDPXETH
in decaying bondsDPXETH
in decaying bondsDPXETH
will not chose to combine A+B+C, why would he do that when F or G exists?The design logic issue is that, if users want to bond with amounts that are not within existing decaying bonds then they cannot do this without splitting their buys into several smaller ones. This will lead to users simply waiting for decaying bods within their limits to appear and chose those (efficiency). This further leaves all uncommon values (or small values) decaying bonds not used, why combine N when you can get the value from just 1 bond (it's even more cheap from a gas point of view). These small value bonds expire instead of driving revenue to the protocol.
Small value bonds will expire instead of driving revenue to the protocol.
Manual review
Modify bonding function to
Other
#0 - c4-pre-sort
2023-09-15T08:16:32Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-15T08:16:39Z
bytes032 marked the issue as sufficient quality report
#2 - c4-sponsor
2023-09-25T16:06:50Z
witherblock (sponsor) disputed
#3 - witherblock
2023-09-25T16:11:31Z
This is a design choice and splitting buys into smaller ones is not a problem as these contracts will be deployed on arbitrum which has very low gas fees.
#4 - c4-judge
2023-10-12T08:47:42Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#5 - GalloDaSballo
2023-10-12T08:48:15Z
I believe that this finding is valid, but it ultimately falls down to an implementation Gotcha