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: 4/189
Findings: 7
Award: $3,073.82
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: LokiThe5th
Also found by: Nikki, __141345__, mahdikarimi, peakbolt, rvierdiiev, wintermute
1263.2349 USDC - $1,263.23
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L315-L369 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L218-L229 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L274-L284 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L199-L214
deposit()/redeem()
could take advantage of the totalVaultCollateral
change before and after settlement, effectively dilute the shares of other LP.
When deposit()
in VaultLP, the shares to get is valued by totalVaultCollateral
, or the sum of collateral and rpdx spot balance value. But before and after settlement, the total value will decrease, since the spot value is using the current price for rpdx, while in put option settlement, rpdx is valued at the strike price (higher than spot price). So for LP, more shares can be got if deposit()
after the settlement. On the contrary, when redeem()
, LP will do it before the settlement. redeem()
is a little different, the user will get both collateral and rdpx in proportion. This issue remains,
Here the "backrun/frontrun" is not in the MEV sense, it can be several blocks apart. The root cause is the strike price difference from spot oracle feed.
In settle()
of ITM options, collateral and rdpx will be exchanged in VaultLP.sol at the strike price, which is higher than the spot price.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 315: function settle() 328: for (uint256 i = 0; i < optionIds.length; i++) { 329: uint256 strike = optionPositions[optionIds[i]].strike; 330: uint256 amount = optionPositions[optionIds[i]].amount; 331: 332: // check if strike is ITM 333: _validate(strike >= getUnderlyingPrice(), 7); 346: // Transfer collateral token from perpetual vault to rdpx rdpxV2Core 347: collateralToken.safeTransferFrom( 348: addresses.perpetualAtlanticVaultLP, 349: addresses.rdpxV2Core, 350: ethAmount 351: ); 352: // Transfer rdpx from rdpx rdpxV2Core to perpetual vault 353: IERC20WithBurn(addresses.rdpx).safeTransferFrom( 354: addresses.rdpxV2Core, 355: addresses.perpetualAtlanticVaultLP, 356: rdpxAmount 357: ); 358: 359: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( 360: ethAmount 361: ); 362: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP) 363: .unlockLiquidity(ethAmount); 364: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addRdpx( 365: rdpxAmount 366: );
When deposit()/redeem()
, the totalVaultCollateral
or collateral/rdpx portion is calculated by the spot price.
File: contracts\perp-vault\PerpetualAtlanticVaultLP.sol 274: function convertToShares( 275: uint256 assets 276: ) public view returns (uint256 shares) { 277: uint256 supply = totalSupply; 278: uint256 rdpxPriceInAlphaToken = perpetualAtlanticVault.getUnderlyingPrice(); 279: 280: uint256 totalVaultCollateral = totalCollateral() + 281: ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8); 282: return 283: supply == 0 ? assets : assets.mulDivDown(supply, totalVaultCollateral); 284: } 218: function _convertToAssets( 219: uint256 shares 220: ) internal view virtual returns (uint256 assets, uint256 rdpxAmount) { 221: uint256 supply = totalSupply; 222: return 223: (supply == 0) 224: ? (shares, 0) 225: : ( 226: shares.mulDivDown(totalCollateral(), supply), 227: shares.mulDivDown(_rdpxCollateral, supply) 228: ); 229: }
Hence, before and after settle()
, the VaultLP will incur some "loss"
File: contracts\perp-vault\PerpetualAtlanticVault.sol 359: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( 360: ethAmount 361: ); 364: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addRdpx( 365: rdpxAmount 366: );
This is the timing for deposit()/redeem()
. deposit()
after settle can get more shares due to the decrease in
Number example:
If the user would like to deposit()
10 ETH.
Before settlement, totalVaultCollateral
is 100 ETH, after settlement, it becomes 95 ETH. So "backrun" settle()
can increase the share from 10% to 10.5%. redeem()
is the same (assuming the available balance is enough).
On the other hand, other LP's shares are diluted because of the timing of these actions, intentionally or not.
Manual analysis.
Delay the deposit()/redeem()
to 1 epoch later after settlement.
Or separate the available collateral/rdpx and the part locked for settlement (might need more sophisticated formula since it is perpetual option).
Timing
#0 - c4-pre-sort
2023-09-12T08:19:48Z
bytes032 marked the issue as low quality report
#1 - c4-pre-sort
2023-09-12T08:20:05Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-12T08:20:12Z
bytes032 marked the issue as remove high or low quality report
#3 - c4-pre-sort
2023-09-12T08:20:36Z
bytes032 marked the issue as primary issue
#4 - c4-pre-sort
2023-09-12T08:34:29Z
bytes032 marked the issue as sufficient quality report
#5 - bytes032
2023-09-15T13:07:54Z
#2130 is related
#6 - c4-sponsor
2023-09-25T17:28:33Z
psytama (sponsor) confirmed
#7 - c4-judge
2023-10-20T08:06:45Z
GalloDaSballo marked the issue as duplicate of #2130
#8 - c4-judge
2023-10-20T20:05:11Z
GalloDaSballo marked the issue as not a duplicate
#9 - c4-judge
2023-10-20T20:05:19Z
GalloDaSballo marked the issue as duplicate of #1584
#10 - c4-judge
2023-10-20T20:05:31Z
GalloDaSballo marked the issue as satisfactory
#11 - c4-judge
2023-10-21T07:50:55Z
GalloDaSballo changed the severity to 3 (High Risk)
🌟 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
subtractLoss()
is using strict equality in the require statement, which could easily be broke if malicious user donate 1 wei of collateral to the contract. subtractLoss()
will be forever inoperable, and all future settlement calling this function will DoS.
When settle()
options, PerpetualAtlanticVaultLP.sol#subtractLoss()
will be called. And there is a require statement checks that the collateral balance is exactly _totalCollateral - loss
.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 315: function settle( 316: uint256[] memory optionIds 317: ) 359: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( 360: ethAmount 361: ); File: contracts\perp-vault\PerpetualAtlanticVaultLP.sol 199: function subtractLoss(uint256 loss) public onlyPerpVault { 200: require( 201: collateral.balanceOf(address(this)) == _totalCollateral - loss, 202: "Not enough collateral was sent out" 203: ); 204: _totalCollateral -= loss; 205: }
The strict equality check is dangerous since malicious user can donate some amount of collateral to the contract to break the equality, even as little as 1 wei. Then subtractLoss()
will DoS forever, making settlement impossible.
And there is no other way to adjust the balance error, currently all the _totalCollateral
changes are tied to read balance change (deposit/withdraw/addProceeds, etc).
Manual analysis.
addRdpx()
.Token-Transfer
#0 - c4-pre-sort
2023-09-09T09:56:47Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:14:28Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-21T07:14: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
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L237-L241 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L563-L569
When admin change fundingDuration
, current funding epoch will be affected. Funding accounting will be inaccurate, future or past epochs could be mixed, and protocol could lose fund or lock fund. In some cases could temporarily DoS the functionality due to not enough fund.
Let fundingDuration
be 7 days, PaymentPointer
0 points to day 0-6, PaymentPointer
1 points to day 7-13, etc. Now is day 30, latestFundingPaymentPointer
updated to 5 (next epoch of day 28-34), lastUpdateTime
is day 27. And let the price stay the same level. Funding for each epoch is 21 weth. So the fundingRate is 3 weth/day. And fundingRates[5]
is set to 3 weth/day.
Then admin changes the fundingDuration
to 10 days.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 237: function updateFundingDuration( 238: uint256 _fundingDuration 239: ) external onlyRole(DEFAULT_ADMIN_ROLE) { 240: fundingDuration = _fundingDuration; 241: }
Now the latestFundingPaymentPointer
is still 5, lastUpdateTime
is still day 27. However, the current implementation will treat epoch 5 as day 40-49, nextFundingPaymentTimestamp()
will return day 49.
563: function nextFundingPaymentTimestamp() { 568: return genesis + (latestFundingPaymentPointer * fundingDuration); 569: }
From now to day 40, latestFundingPaymentPointer
won't be increased, since block.timestamp
is less than nextFundingPaymentTimestamp()
:
462: function updateFundingPaymentPointer() public { 463: while (block.timestamp >= nextFundingPaymentTimestamp()) { 493: latestFundingPaymentPointer += 1;
As a result, the funding rate is fixed until day 49. If the fixed rate is high level, the protocol will incur loss. Assume for the later epoch, the funding becomes 2 weth/day, but fundingRates[5]
will be used until day 49, (49 - 34) * (3 - 2) = 15 weth in loss. Or if the contract does not have enough weth to pay, the contract will DoS until new fund is refilled. This could happen because latestFundingPaymentPointer
does not increase for a long time, no more enough funding added. On the other hand, if the later epoch has lower premium than the fixed rate, those difference will be locked in the contract.
Another possibility, the fundingDuration
can decrease from 10 to 7, assuming now is day 48, next epoch 5 (day 50-59) premium is transferred. When fundingDuration
is decreased, updateFundingPaymentPointer()
will update epoch pointer to 8 (day 49-55), fundingRates[5]
is already past, it will not be used, so the transferred fund will be lost.
502: function updateFunding() public { 503: updateFundingPaymentPointer(); 504: uint256 currentFundingRate = fundingRates[latestFundingPaymentPointer];
Manual analysis.
The way to record past epochs, need refactor. For each epoch, the start/end time and funding rate need to be stored, rather than computed by genesis + (latestFundingPaymentPointer * fundingDuration)
.
Also need to make sure changed fundingDuration
only take effect for future epochs, not past epochs.
Other
#0 - c4-pre-sort
2023-09-08T06:23:32Z
bytes032 marked the issue as duplicate of #980
#1 - c4-pre-sort
2023-09-14T06:49:17Z
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:12:24Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: said
Also found by: 0xWaitress, Nyx, RED-LOTUS-REACH, Tendency, __141345__, carrotsmuggler, nirlin, peakbolt, qpzm, wallstreetvilkas
158.2271 USDC - $158.23
The option fee has 2 parts
One parameter to calculate the premium is the time to expiry for the next epoch. Initially the epoch(fundingDuration) is set to 7 days. Which means the initial premium time to expiry can range from 0 to 7 days. The bonder can call bond()
just before nextFundingPaymentTimestamp()
to pay dust premium and leave almost all the options fee for treasury as funding.
When purchase the option, the time to expiry is calculated by the difference to nextFundingPaymentTimestamp()
. When just 1 block before it, the premium is negligible compare to the full epoch premium (7 days). This is unfair for early bonders. Malicious bonders will always bond at the end of the epoch, paying dust premium, taking advantage that treasury will pay for the funding later on and forever.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 255: function purchase( 256: uint256 amount, 257: address to 258: ) 283: uint256 timeToExpiry = nextFundingPaymentTimestamp() - block.timestamp; 284: 285: // Get total premium for all options being purchased 286: premium = calculatePremium(strike, amount, timeToExpiry, 0);
Manual analysis.
Be consistent for all bonders in terms of premium. Some standard term and process can be applied, such as enforce the beginning of the bond to the next epoch, and charge for 1 full epoch premium uniformly.
Timing
#0 - c4-pre-sort
2023-09-13T07:19:00Z
bytes032 marked the issue as duplicate of #237
#1 - c4-pre-sort
2023-09-14T09:28:49Z
bytes032 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-09-14T09:35:24Z
bytes032 marked the issue as duplicate of #761
#3 - c4-judge
2023-10-20T11:57:07Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: said
Also found by: 0xWaitress, Nyx, RED-LOTUS-REACH, Tendency, __141345__, carrotsmuggler, nirlin, peakbolt, qpzm, wallstreetvilkas
158.2271 USDC - $158.23
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L237-L241 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L283-L286
BSM is used to calculate option premium, however the condition for BSM requires the option has some specific expiry date, which is not the case for perpetual. The time decay of BSM is not even, the time value decay at different speed when the expiry approaches. As a result, the current formula for premium is not accurate, protocol or the users could both lose or get undeserved fund.
In perpetual vault, the option is expected to last forever, BSM with expiry is used. To mimic perpetual, epoch by epoch options are connected one after another. But this approach is not accurate and not consistent.
Use BSM, 10% OTM put IV 50%, price is as below:
expiry | price |
---|---|
7 | 1.83e-3 |
14 | 6.68e-3 |
28 | 16.89e-3 |
Clearly the price is not proportional to the time to expiry. The decay speed is related to time left and moneyness (ref BSM). And the inconsistency can be seen if we change fundingDuration
. Using the above number, if change it from 7 days to 28 days, 7 days * 4 needs funding of 7.32e-3, but 28 days is 16.89e-3, more than double the premium.
Here we assume the price is at the same level all the time, so in logic, the option fee should be consistent no matter what fundingDuration
is used. The inconsistency indicates that the way to calculate fee has problem. Either the treasury or the users will have unfair gain/loss.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 237: function updateFundingDuration( 238: uint256 _fundingDuration 239: ) external onlyRole(DEFAULT_ADMIN_ROLE) { 240: fundingDuration = _fundingDuration; 241: } 255: function purchase( 256: uint256 amount, 257: address to 258: ) 283: uint256 timeToExpiry = nextFundingPaymentTimestamp() - block.timestamp; 284: 285: // Get total premium for all options being purchased 286: premium = calculatePremium(strike, amount, timeToExpiry, 0);
Manual analysis.
New formula can be used to calculate premium/funding, refer to opyn squeeth, similar idea implemented for everlasting-options. However, this approach is hard, and may need strict assumptions deviates from real world.
Empirical approach could be used and might be better, let the traders and market find the best price. Just like real option prices has IV smile (in BSM, IV smile does not exist, it should be flat). The smile is the dynamic equilibrium of market supply and demand. So some idea like AMM could be used to give users the freedom to discover the most appropriate price for perpetual options.
Math
#0 - c4-pre-sort
2023-09-09T12:17:48Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-11T06:42:43Z
bytes032 marked the issue as sufficient quality report
#2 - c4-sponsor
2023-09-25T19:22:49Z
psytama (sponsor) acknowledged
#3 - c4-judge
2023-10-13T12:07:13Z
GalloDaSballo marked the issue as duplicate of #761
#4 - GalloDaSballo
2023-10-13T12:07:23Z
IMO logically same as dup, the price can be attacked, this finding shows the math
#5 - c4-judge
2023-10-20T11:56:04Z
GalloDaSballo marked the issue as satisfactory
#6 - 141345
2023-10-22T12:02:22Z
I believe this is different from https://github.com/code-423n4/2023-08-dopex-findings/issues/761, which describes the timing of bond()
, and can be mitigated by fix the duration windown in the price formula. My other submission https://github.com/code-423n4/2023-08-dopex-findings/issues/1791 is a dup of it.
However, for this report, the issue arises from the fundamental formula used for pricing, it is baked into the math, no matter what duration window we use, the uneven decay of time value persists.
The reason is: BSM has assumption that options always has expiry date (evitably lead to the uneven time value ~$\sqrt{t}
$ when approaching expiry).
However, the context here is "perpetual options" with no expiry (require some constant time value decay, similar to funding rate in perpetual futures), the preassumption of BSM does not hold. As a result, the pricing formula will be inconsistant for various scenarios.
#7 - GalloDaSballo
2023-10-25T09:11:31Z
I would have a easier time making this a separate issue if the finding also included risk free arbitages that make the formula basically pay out free money to attackers
In lack of that I think it's best to keep it duplicate as the implementation of the formula causes a valid attack which you have succesfully identified
🌟 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
approveforall
NFT role can not redeemIt has to be the owner.
File: contracts\core\RdpxV2Core.sol 1016: function redeem( 1017: uint256 id, 1018: address to 1019: ) external returns (uint256 receiptTokenAmount) { 1025: _validate( 1026: msg.sender == RdpxV2Bond(addresses.receiptTokenBonds).ownerOf(id), 1027: 9 1028: );
Suggestion:
Allow approveforall
role to redeem.
File: contracts\core\RdpxV2Core.sol 764: function settle( 765: uint256[] memory optionIds 766: ) 767: external 768: onlyRole(DEFAULT_ADMIN_ROLE) 769: returns (uint256 amountOfWeth, uint256 rdpxAmount) 770: { 771: _whenNotPaused(); File: contracts\perp-vault\PerpetualAtlanticVault.sol 315: function settle( 316: uint256[] memory optionIds 317: ) 318: external 319: nonReentrant 320: onlyRole(RDPXV2CORE_ROLE) 321: returns (uint256 ethAmount, uint256 rdpxAmount) 322: { 323: _whenNotPaused();
calculateFunding()
require manual triggerif no one call calculateFunding()
, some epoch could be skipped and miss the funding.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 449: totalFundingForEpoch[latestFundingPaymentPointer] += premium;
Suggestion: add checks for skipped epochs, in case some epoch is missed.
The doc
After the bonding process is complete a bond (ERC721 token) will be minted to the user which can be redeemed after a variable maturity (initially to be set at 5 days) for the receipt tokens.
The code base shows it is 7 days.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 101: uint256 public fundingDuration = 7 days;
File: contracts\perp-vault\PerpetualAtlanticVault.sol 405: function calculateFunding( 406: uint256[] memory strikes 407: ) external nonReentrant returns (uint256 fundingAmount) { 426: uint256 timeToExpiry = nextFundingPaymentTimestamp() - 427: (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration));
Line 427 is misleading, it looks like counting current epoch for funding, actually it is calculating for the next epoch.
It might better be
-426: uint256 timeToExpiry = nextFundingPaymentTimestamp() - -427: (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration)); +426: uint256 timeToExpiry = (genesis + ((latestFundingPaymentPointer + 1) * fundingDuration)) +427: - nextFundingPaymentTimestamp();
Or just simply use fundingDuration
as timeToExpiry
.
latestFundingPaymentPointer
== 0When latestFundingPaymentPointer == 0
, calculateFunding()
will underflow in line 427.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 405: function calculateFunding( 406: uint256[] memory strikes 407: ) external nonReentrant returns (uint256 fundingAmount) { 426: uint256 timeToExpiry = nextFundingPaymentTimestamp() - 427: (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration));
_transfer()
is called in bonding process, however, if rdpxReserve
run out of rDPX, bond will fail. Those users with decayBond will lose, and decayBond will become useless.
624: function _transfer( 625: uint256 _rdpxAmount, 626: uint256 _wethAmount, 627: uint256 _bondAmount, 628: uint256 _bondId 629: ) internal { 648: IRdpxReserve(addresses.rdpxReserve).withdraw(_rdpxAmount);
#0 - c4-pre-sort
2023-09-10T11:42:47Z
bytes032 marked the issue as sufficient quality report
#1 - GalloDaSballo
2023-10-10T11:37:57Z
approveforall
NFT role can not redeemL
I, intended
calculateFunding()
require manual triggerL
L
L
latestFundingPaymentPointer
== 0I
L, maybe invalid
#2 - c4-judge
2023-10-20T10:21:39Z
GalloDaSballo marked the issue as grade-b
🌟 Selected for report: c3phas
Also found by: 0xgrbr, HHK, LeoS, QiuhaoLi, Sathish9098, __141345__, flacko, oakcobalt, pontifex
784.396 USDC - $784.40
nextFundingPaymentTimestamp()
can be cachedsave 1 function call, including sload for 100 cost.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 463: while (block.timestamp >= nextFundingPaymentTimestamp()) { 594: function _updateFundingRate(uint256 amount) private { 595: if (fundingRates[latestFundingPaymentPointer] == 0) {
(currentFundingRate * (block.timestamp - startTime)) / 1e18
can be cachedFile: contracts\perp-vault\PerpetualAtlanticVault.sol 510: collateralToken.safeTransfer( 511: addresses.perpetualAtlanticVaultLP, 512: (currentFundingRate * (block.timestamp - startTime)) / 1e18 513: ); 514: 515: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addProceeds( 516: (currentFundingRate * (block.timestamp - startTime)) / 1e18 517: ); 518: 519: emit FundingPaid( 520: msg.sender, 521: ((currentFundingRate * (block.timestamp - startTime)) / 1e18), 522: latestFundingPaymentPointer 523: );
latestFundingPaymentPointer
can be cachedsave 1 sload.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 502: function updateFunding() public { 504: uint256 currentFundingRate = fundingRates[latestFundingPaymentPointer]; 519: emit FundingPaid( 520: msg.sender, 521: ((currentFundingRate * (block.timestamp - startTime)) / 1e18), 522: latestFundingPaymentPointer 523: ); 524: }
For each epoch, addProceeds()
is called, with the funding for that epoch. However, all the funding amount can be added together, only need 1 call to addProceeds()
.
Save several sstore.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 463: while (block.timestamp >= nextFundingPaymentTimestamp()) { 464: if (lastUpdateTime < nextFundingPaymentTimestamp()) { 465: uint256 currentFundingRate = fundingRates[latestFundingPaymentPointer]; 466: 467: uint256 startTime = lastUpdateTime == 0 468: ? (nextFundingPaymentTimestamp() - fundingDuration) 469: : lastUpdateTime; 470: 471: lastUpdateTime = nextFundingPaymentTimestamp(); 472: 473: collateralToken.safeTransfer( 474: addresses.perpetualAtlanticVaultLP, 475: (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / 476: 1e18 477: ); 478: 479: IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP) 480: .addProceeds( 481: (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / 482: 1e18 483: );
File: contracts\core\RdpxV2Core.sol 894: function bond() 903: // Compute the bond cost 904: (uint256 rdpxRequired, uint256 wethRequired) = calculateBondCost( 905: _amount, 906: rdpxBondId 907: ); 920: if (putOptionsRequired) { 921: premium = _purchaseOptions(rdpxRequired); 922: }
premium is double calculated in these 2 functions.
1156: function calculateBondCost( 1157: uint256 _amount, 1158: uint256 _rdpxBondId 1159: ) public view returns (uint256 rdpxRequired, uint256 wethRequired) { 1195: if (putOptionsRequired) { 1196: wethRequired += IPerpetualAtlanticVault(addresses.perpetualAtlanticVault) 1197: .calculatePremium(strike, rdpxRequired, timeToExpiry, 0); 1198: } 471: function _purchaseOptions( 472: uint256 _amount 473: ) internal returns (uint256 premium) { 481: (premium, optionId) = IPerpetualAtlanticVault( 482: addresses.perpetualAtlanticVault 483: ).purchase(_amount, address(this));
timeToExpiry
timeToExpiry
only used if putOptionsRequired == true
, it should be moved into if block
File: contracts\core\RdpxV2Core.sol 1192: uint256 timeToExpiry = IPerpetualAtlanticVault( 1193: addresses.perpetualAtlanticVault 1194: ).nextFundingPaymentTimestamp() - block.timestamp; 1195: if (putOptionsRequired) { 1196: wethRequired += IPerpetualAtlanticVault(addresses.perpetualAtlanticVault) 1197: .calculatePremium(strike, rdpxRequired, timeToExpiry, 0); 1198: }
getRdpxPrice()
The result should be saved locally, avoid 1 additional external oracle call.
File: contracts\core\RdpxV2Core.sol 669: uint256 rdpxAmountInWeth = (_rdpxAmount * getRdpxPrice()) / 1e8; 670: uint256 discountReceivedInWeth = _bondAmount - 671: _wethAmount - 672: rdpxAmountInWeth; 673: uint256 extraRdpxToWithdraw = (discountReceivedInWeth * 1e8) / 674: getRdpxPrice();
getDpxEthPrice()
, getEthPrice()
The result should be saved locally, avoid 2 additional external oracle call.
File: contracts\core\RdpxV2Core.sol 545: uint256 minOut = _ethToDpxEth 546: ? (((_amount * getDpxEthPrice()) / 1e8) - 547: (((_amount * getDpxEthPrice()) * slippageTolerance) / 1e16)) 548: : (((_amount * getEthPrice()) / 1e8) - 549: (((_amount * getEthPrice()) * slippageTolerance) / 1e16));
minAmount > 0
line 545 could be skipped if minAmount > 0
, should move the calculation in to if block.
File: contracts\core\RdpxV2Core.sol 544: // calculate minimum amount out 545: uint256 minOut = _ethToDpxEth 546: ? (((_amount * getDpxEthPrice()) / 1e8) - 547: (((_amount * getDpxEthPrice()) * slippageTolerance) / 1e16)) 548: : (((_amount * getEthPrice()) / 1e8) - 549: (((_amount * getEthPrice()) * slippageTolerance) / 1e16)); 550: 551: // Swap the tokens 552: amountOut = dpxEthCurvePool.exchange( 553: _ethToDpxEth ? int128(int256(a)) : int128(int256(b)), 554: _ethToDpxEth ? int128(int256(b)) : int128(int256(a)), 555: _amount, 556: minAmount > 0 ? minAmount : minOut 557: );
totalFundingForEpoch[latestFundingPaymentPointer]
multiple sload access, it should be cached.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 382: collateralToken.safeTransferFrom( 383: addresses.rdpxV2Core, 384: address(this), 385: totalFundingForEpoch[latestFundingPaymentPointer] 386: ); 387: _updateFundingRate(totalFundingForEpoch[latestFundingPaymentPointer]); 388: 389: emit PayFunding( 390: msg.sender, 391: totalFundingForEpoch[latestFundingPaymentPointer], 392: latestFundingPaymentPointer 393: ); 394: 395: return (totalFundingForEpoch[latestFundingPaymentPointer]);
latestFundingPaymentPointer
can be cachedsave multiple sload.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 405: function calculateFunding( 406: uint256[] memory strikes 407: ) external nonReentrant returns (uint256 fundingAmount) { 408: _whenNotPaused(); 409: _isEligibleSender(); 410: 411: updateFundingPaymentPointer(); 412: 413: for (uint256 i = 0; i < strikes.length; i++) { 414: _validate(optionsPerStrike[strikes[i]] > 0, 4); 415: _validate( 416: latestFundingPerStrike[strikes[i]] != latestFundingPaymentPointer, 417: 5 418: ); 419: uint256 strike = strikes[i]; 420: 421: uint256 amount = optionsPerStrike[strike] - 422: fundingPaymentsAccountedForPerStrike[latestFundingPaymentPointer][ 423: strike 424: ]; 425: 426: uint256 timeToExpiry = nextFundingPaymentTimestamp() - 427: (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration)); 428: 429: uint256 premium = calculatePremium( 430: strike, 431: amount, 432: timeToExpiry, 433: getUnderlyingPrice() 434: ); 435: 436: latestFundingPerStrike[strike] = latestFundingPaymentPointer; 437: fundingAmount += premium; 438: 439: // Record number of options that funding payments were accounted for, for this epoch 440: fundingPaymentsAccountedFor[latestFundingPaymentPointer] += amount; 441: 442: // record the number of options funding has been accounted for the epoch and strike 443: fundingPaymentsAccountedForPerStrike[latestFundingPaymentPointer][ 444: strike 445: ] += amount; 446: 447: // Record total funding for this epoch 448: // This does not need to be done in purchase() since it's already accounted for using `addProceeds()` 449: totalFundingForEpoch[latestFundingPaymentPointer] += premium; 450: 451: emit CalculateFunding( 452: msg.sender, 453: amount, 454: strike, 455: premium, 456: latestFundingPaymentPointer 457: ); 458: } 459: }
premium
can be added altogether , no need to sstore each timeMultiple strikes are looped to add premium. Save multiple sstore.
File: contracts\perp-vault\PerpetualAtlanticVault.sol 449: totalFundingForEpoch[latestFundingPaymentPointer] += premium;
File: contracts\perp-vault\PerpetualAtlanticVault.sol 405: function calculateFunding( 406: uint256[] memory strikes 407: ) external nonReentrant returns (uint256 fundingAmount) { 426: uint256 timeToExpiry = nextFundingPaymentTimestamp() - 427: (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration));
Can simply use fundingDuration
as timeToExpiry
.
Save 1 function call, several sload for latestFundingPaymentPointer
.
#0 - c4-pre-sort
2023-09-10T11:34:44Z
bytes032 marked the issue as sufficient quality report
#1 - GalloDaSballo
2023-10-10T18:53:18Z
nextFundingPaymentTimestamp() can be cached L (currentFundingRate * (block.timestamp startTime)) 1e18 can be cached NC latestFundingPaymentPointer can be cached L amount can be summed to avoid multiple function calls R premium double calculated I timeToExpiry R repeat call getRdpxPrice() L repeat call getDpxEthPrice(), getEthPrice() L minAmount > L totalFundingForEpoch L premium can be added altogether , no need to sstore each time L
One of the best in terms of optimizations per line
#2 - c4-judge
2023-10-20T10:19:14Z
GalloDaSballo marked the issue as grade-a
#3 - c4-judge
2023-10-20T10:20:33Z
GalloDaSballo marked the issue as selected for report
#4 - c4-judge
2023-10-25T09:00:10Z
GalloDaSballo marked the issue as not selected for report
#5 - c4-judge
2023-10-25T09:00:43Z
GalloDaSballo marked the issue as selected for report
#6 - c4-judge
2023-10-25T09:01:01Z
GalloDaSballo marked the issue as not selected for report
🌟 Selected for report: catellatech
Also found by: 0xSmartContract, 0xnev, K42, LokiThe5th, RED-LOTUS-REACH, Sathish9098, __141345__, bitsurfer, hunter_w3b, mark0v, nirlin, rjs, rokinot
832.8493 USDC - $832.85
Here several aspects about mechanism of the protocol are discussed. Topic 1, 2, 3 are related to each other, and the final suggestion is give users some freedom in pricing. Topic 4 and 5 also have similar suggestion for over protection. The rest topics are independent.
IV is an important metric in option pricing. In this contract, traders's flexibility to quote is limited due to the way IV is get. Further, the option liquidity will be affected, as well as the DpxEth bonding availability.
According to the doc in option-pricing, IV is based on HV:
Volatility is based on the 30-day historical volatility of the underlying
The current way seems intended to provide some "fair" price for options. However when trade options, IV is the result, not the input.
Let's recall how IV is derived in option trading:
There is no guarantee that real volatility will converge to IV, or the other way. Traders provide their quotes based on their risk estimate, their pricing are changing all the time, as a result, the IV smile and term structure are constantly changing. It could has nothing to do with past price path.
The quote also includes slippage, for far OTM options, the slippage is larger, this is the seller's "risk premium". However if everything is based on HV, the IV surface becomes rigid, sometimes the price could be too low, writer's won't be willing to take the downside risk. Or the price could be too high, someone can take advantage of it. However, overall, traders can not adjust the price at will. This mechanism could hinder lots of traders to participate. As a result, the liquidity of the perpetual vault is harder to grow.
On the other hand, HV value also depends on the way to sample the data, with interval of 1 min, 5 min, 15 min, different HV values will be got. Using this as reference is also prone to error.
Suggestion: Give the users some freedom on the bid ask price, instead of fixing IV (price). Maybe something similar to AMM could be designed, which can timely reflect the supply and demand of options.
BSM is used to calculate option premium, however the condition for BSM requires the option has some specific expiry date, which is not the case for perpetual. The time decay of BSM is not even, the time value decay at different speed when the expiry approaches.
As a result, the current formula for premium is not accurate, and inconsistent for different fundingDuration
. Either the treasury or the users will have unfair gain/loss.
See the discussion in BSM has expiry with uneven time decay, it is not for perpetual.
Suggestion: Empirical approach could be used and might be better, let the traders and market find the best price. Just like real option prices has IV smile (in BSM, IV smile does not exist, it should be flat). The smile is the dynamic equilibrium of market supply and demand. So some idea like AMM could be used to give users the freedom to discover the most appropriate price for perpetual options.
Apart from the flexibility discussed above, the benefits for writers needs attention as well.
25% OTM option premium is negligible. As rDPX price drops towards strike, the premium will be higher, but at the end the seller will incur some loss in face value after settlement.
Then why will the users be willing to write the perpetual put options? The fund efficiency is low with of full collateral requirement. And since this is perpetual, each portion of locked collateral can only be released when settled, which means exercised at a price higher then the market. The writers as a whole group will incur loss at every moment of settlement. It is hard to say whether options fee income can cover the loss.
With the same amount of eth, if the writer sell puts in deribit:
There could be 2 reasons for writers to lock collateral:
No 1 is not guaranteed, it won't be the reason for many users. The controllable method is No 2 to add enough incentive for the writers. However this could be tricky, if the rewards is too large, far beyond market iv, some users could take advantage of the protocol, at the end it will be the protocol to take the downside risk, in some sense. If the rewards is too low, not enough writers. Downside protection can not be provided for future bonders.
Suggestion: Maybe also consider some high rewards with strict condition such as vesting period.
The logic to handle depeg might need more tweak. Currently, to prevent rDPX crash, 25% OTM put option is used as downside protection. However, when that happens, it is still using the crashed rDPX as the last resort to buy back weth to restore peg.
See the discussion in lower depeg could fail to restore if no enough rDPX.
Suggestion: One solution is over protection for crash risk.
Now we assume there is enough writer to take the downside risk, and settlement are done after some market crash. However the rDPX price soon bounce back. Then the loss is realized at the moment of settlement and becomes permanent. The 93.75% will gradually approaching. The core contract is equivalent to swap at 75% price. When the price bounce back, it's the perpetual LP's profit.
Although later the protocol has other income, and AMO can be used to restore the peg. I think some other way is to over protect the downside. When 25% rDPX is used, the put option can be more than that amount. As a result, if rDPX price drop below the strike, settlement can recover some loss by it's own.
Suggestion: Same as above topic, over protection for rDPX.
Current design is rDPX + weth -> DpxEth. The one way process could make many users concern whether to bond. After all, bond, waiting for the epoch, redeem is receiptToken, remove liquidity, and finally swap back to weth is a detour process. As such, the liquidity of DpxEth might be hard to scale.
Suggestion: Consider add method to convert back, the users could has less to worry when bond DpxEth.
If there is long term trend of appreciation/depreciation of rDPX, one of the backing asset of DpxEth, the bonding system may not be easy to maintain sustainability. If in the long term rDPX goes down, few people will be interested in writing perpetual puts. If rDPX always goes up, users may want to hold, rather than bond for DpxEth.
Although rDPX is not expected to be something like stablecoin, the totalSupply still needs attention, as well as the volatility. Since it is the backing asset, it should maintain some value and reliability.
Suggestion: Monitor and control the supply of rDPX from protocol's side.
30 hours
#0 - c4-pre-sort
2023-09-15T15:55:17Z
bytes032 marked the issue as sufficient quality report
#1 - c4-judge
2023-10-20T10:00:51Z
GalloDaSballo marked the issue as grade-a