Dopex - __141345__'s results

A rebate system for option writers in the Dopex Protocol.

General Information

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

Dopex

Findings Distribution

Researcher Performance

Rank: 4/189

Findings: 7

Award: $3,073.82

QA:
grade-b
Gas:
grade-a
Analysis:
grade-a

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: LokiThe5th

Also found by: Nikki, __141345__, mahdikarimi, peakbolt, rvierdiiev, wintermute

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
sufficient quality report
duplicate-1584

Awards

1263.2349 USDC - $1,263.23

External Links

Lines of code

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

Vulnerability details

Impact

deposit()/redeem() could take advantage of the totalVaultCollateral change before and after settlement, effectively dilute the shares of other LP.

Proof of Concept

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:

  • spot price of rdpx is 0.2 ETH/rdpx.
  • The ITM option of 100 rdpx at 0.25 ETH.
  • VaultLP has total 50 ETH + 250 rdpx before settlement
  • after settlement, it becomes 25 ETH + 350 rpdx

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.

Tools Used

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).

Assessed type

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)

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L201

Vulnerability details

Impact

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.

Proof of Concept

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).

Tools Used

Manual analysis.

  • Use ">=" like addRdpx().
  • Or add some function to absorb the extra balance for collateral.

Assessed type

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

Awards

15.9268 USDC - $15.93

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
duplicate-850

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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];

Tools Used

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.

Assessed type

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

Findings Information

Labels

bug
2 (Med Risk)
satisfactory
duplicate-761

Awards

158.2271 USDC - $158.23

External Links

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L283-L286

Vulnerability details

Impact

The option fee has 2 parts

  • initial premium paid by the bonder
  • funding paid by the Dopex Treasury

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.

Proof of Concept

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);

Tools Used

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.

Assessed type

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

Findings Information

Labels

bug
2 (Med Risk)
satisfactory
sponsor acknowledged
sufficient quality report
edited-by-warden
duplicate-761

Awards

158.2271 USDC - $158.23

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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:

expiryprice
71.83e-3
146.68e-3
2816.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);

Tools Used

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.

Assessed type

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

Awards

19.1724 USDC - $19.17

Labels

bug
grade-b
QA (Quality Assurance)
sufficient quality report
edited-by-warden
Q-15

External Links

L-01 approveforall NFT role can not redeem

It 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.

L-02 cannot settle in pause status

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();

L-03 calculateFunding() require manual trigger

if 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.

L-04 doc deviates from implementation

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;

L-05 time span should be future period

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.

L-06 underflow when latestFundingPaymentPointer == 0

When 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));

L-07 reserve out of funds could make decayBond useless

_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

L-01 approveforall NFT role can not redeem

L

L-02 cannot settle in pause status

I, intended

L-03 calculateFunding() require manual trigger

L

L-04 doc deviates from implementation

L

L-05 time span should be future period

L

L-06 underflow when latestFundingPaymentPointer == 0

I

L-07 reserve out of funds could make decayBond useless

L, maybe invalid

#2 - c4-judge

2023-10-20T10:21:39Z

GalloDaSballo marked the issue as grade-b

Findings Information

🌟 Selected for report: c3phas

Also found by: 0xgrbr, HHK, LeoS, QiuhaoLi, Sathish9098, __141345__, flacko, oakcobalt, pontifex

Labels

bug
G (Gas Optimization)
grade-a
sufficient quality report
G-06

Awards

784.396 USDC - $784.40

External Links

G-01 nextFundingPaymentTimestamp() can be cached

save 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) {

G-02 (currentFundingRate * (block.timestamp - startTime)) / 1e18 can be cached

File: 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:     );

G-03 latestFundingPaymentPointer can be cached

save 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:   }

G-04 amount can be summed to avoid multiple function calls

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:           );

G-05 premium double calculated

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));

G-06 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:     }

G-07 repeat call 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();

G-08 repeat call 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));

G-09 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:     );

G-10 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]);

G-11 latestFundingPaymentPointer can be cached

save 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:   }

G-12 premium can be added altogether , no need to sstore each time

Multiple strikes are looped to add premium. Save multiple sstore.

File: contracts\perp-vault\PerpetualAtlanticVault.sol
449:       totalFundingForEpoch[latestFundingPaymentPointer] += premium;

G-13

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

Findings Information

Labels

analysis-advanced
grade-a
sufficient quality report
edited-by-warden
A-07

Awards

832.8493 USDC - $832.85

External Links

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.

  1. HV derived IV and pricing
  2. BSM and perpetual
  3. incentive for perpetual writer
  4. depeg restore
  5. price bounce back after settlement
  6. bond DpxEth is one way process
  7. inflation/deflation of rDPX

HV derived IV and pricing

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:

  1. traders provide bid ask quotes
  2. based on the quoted price, IV is derived through BSM as a metric for human to get a quick feeling about the level of price

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 and perpetual

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.

incentive for perpetual writer

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:

  • higher APR
  • has portfolio margin, means much higher fund efficiency
  • more ways to hedge, and control over the strategy
  • much more flexible if want to exit

There could be 2 reasons for writers to lock collateral:

  1. expect the price of rDPX will go up in the long term, since redeemed funds are weth and rDPX
  2. some other rewards by the protocol, (currently is share in 3k $DPX/year for the first 3 years paid by the Dopex Treasury)

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.

depeg restore

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.

price bounce back after settlement

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.

bond DpxEth is one way process

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.

inflation/deflation of rDPX

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.

Time spent:

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter