xanoutas/solidity-security-findings huggingface dataset:
burst() not return eth when action fails
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tapioca-periph/blob/032396f701be935b04a7e5cf3cb40a0136259dbc/contracts/Magnetar/Magnetar.sol#L72


# Vulnerability details

## Vulnerability details

when execute `Magnetar.burst()`
The user can specify `allowFailure = true`, and if it fails, ignore the current `_action` and execute another `_action`.

`burst()` -> `_executeCall(_allowFailure)`
```solidity
    function _executeCall(address _target, bytes calldata _actionCalldata, uint256 _actionValue, bool _allowFailure)
        private
    {
        bool success;
        bytes memory returnData;

        if (_actionValue > 0) {
            (success, returnData) = _target.call{value: _actionValue}(_actionCalldata);
        } else {
            (success, returnData) = _target.call(_actionCalldata);
        }

@>      if (!success && !_allowFailure) {
            _getRevertMsg(returnData);
        }
    }
```

But with the current implementation, if the user also passes `_action.value> 0` && `_action.allowFailure = true` 

If the action fails, the `_action.value` is not returned to the user, it stays in the contract.

Although the owner can retrieve the `eth` left in the contract via `rescueEth()`, the `eth` is not returned to the user until the owner executes `rescueEth()`.

However, before the owner executes `rescueEth()`, a malicious user can use the contract's `eth` directly to execute an `action` .

For example, by using
`MagnetarAssetModule.depositRepayAndRemoveCollateralFromMarket()` -> `_withdrawToChain()` -> `_lzWithdraw()` ->
`sendPacket{value:??} ()`
to use up `eth`.

## Impact

If the `action` fails, the `eth` left in the contract can be stolen by malicious people.

## Recommended Mitigation

Recommends the final return of all `eth`

```diff
    function burst(MagnetarCall[] calldata calls) external payable {
        uint256 valAccumulator;

        uint256 length = calls.length;

        for (uint256 i; i < length; i++) {
.....

        if (msg.value != valAccumulator) revert Magnetar_ValueMismatch(msg.value, valAccumulator);
+       if(address(this).balance>0){
+         msg.sender.call{value:address(this).balance}(""); // return all eth
+       }
      }
```


## Assessed type

Context
after unregisterSingularity() , position has not been unlock will be locked in the contract.
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tap-token/blob/20a83b1d2d5577653610a6c3879dff9df4968345/contracts/options/TapiocaOptionLiquidityProvision.sol#L334


# Vulnerability details

## Vulnerability details

when `singularity.rescue==true`, owner can execute `unregisterSingularity()`
this method will delete `activeSingularities[]/sglAssetIDToAddress[]`
```solidity
    function unregisterSingularity(IERC20 singularity) external onlyOwner updateTotalSGLPoolWeights {
        uint256 sglAssetID = activeSingularities[singularity].sglAssetID;
        if (sglAssetID == 0) revert NotRegistered();
        if (!activeSingularities[singularity].rescue) revert NotInRescueMode();

        unchecked {
            uint256[] memory _singularities = singularities;
            uint256 sglLength = _singularities.length;
            uint256 sglLastIndex = sglLength - 1;

            for (uint256 i; i < sglLength; i++) {
                if (_singularities[i] == sglAssetID) {
                    // If in the middle, copy last element on deleted element, then pop
@>                  delete activeSingularities[singularity];
@>                  delete sglAssetIDToAddress[sglAssetID];

                    if (i != sglLastIndex) {
                        singularities[i] = _singularities[sglLastIndex];
                    }
                    singularities.pop();
                    emit UnregisterSingularity(address(singularity), sglAssetID);
                    break;
                }
            }
        }

        emit UnregisterSingularity(address(singularity), sglAssetID);
    }
```

However, this method does not determine whether there are still has locked positions
In this way, if the user wants to `unlock()`, it will not be executed, and the `token` will be locked in the contract.


`activeSingularities[_singularity]` has been deleted.

```solidity
    function unlock(uint256 _tokenId, IERC20 _singularity, address _to) external {
...
@>      SingularityPool memory sgl = activeSingularities[_singularity];

        yieldBox.transfer(address(this), _to, lockPosition.sglAssetID, lockPosition.ybShares);
@>      activeSingularities[_singularity].totalDeposited -= lockPosition.ybShares;

        emit Burn(_to, lockPosition.sglAssetID, _tokenId);
    }
```

## Impact
after `unregisterSingularity()` , the position has not been unlock will be locked in the contract.

## Recommended Mitigation

```diff
    function unregisterSingularity(IERC20 singularity) external onlyOwner updateTotalSGLPoolWeights {
...
            for (uint256 i; i < sglLength; i++) {
                if (_singularities[i] == sglAssetID) {
+                   //check totalDeposited
+                   require(activeSingularities[singularity].totalDeposited == 0 , "invalid");

                    // If in the middle, copy last element on deleted element, then pop
                    delete activeSingularities[singularity];
                    delete sglAssetIDToAddress[sglAssetID];
TapiocaOptionBroker.participate() with the approve authorization, it still cannot be executed.
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tap-token/blob/20a83b1d2d5577653610a6c3879dff9df4968345/contracts/options/TapiocaOptionBroker.sol#L253


# Vulnerability details

## Vulnerability details
If the user `isApproved()`, the user can call `TapiocaOptionBroker.participate()`.
The code is as follows:
```solidity
    function participate(uint256 _tOLPTokenID) external whenNotPaused nonReentrant returns (uint256 oTAPTokenID) {
...

        TWAMLPool memory pool = twAML[lock.sglAssetID];
        if (pool.cumulative == 0) {
            pool.cumulative = EPOCH_DURATION;
        }

@>      if (!tOLP.isApprovedOrOwner(msg.sender, _tOLPTokenID)) {
            revert NotAuthorized();
        }

       {
@>        bool isErr = pearlmit.transferFromERC721(msg.sender, address(this), address(tOLP), _tOLPTokenID);
            if (isErr) revert TransferFailed();
        }
```

However, in the current implementation, even though `msg.sender` has obtained authorization (`isApprovedOrOwner(msg.sender) == true`), it is still unable to execute due to the incorrect usage of: `pearlmit.transferFromERC721(msg.sender, address(this), address(tOLP), _tOLPTokenID);`
Passing `msg.sender` as the `owner` will result in failure to execute.


`pearlmit.transferFromERC721(msg.sener,to)`->`IERC721(token).transferFrom(owner, to)`->`_transfer(owner,to)` ->`require(ERC721.ownerOf(tokenId) == owner`

it will check the first parameter is the owner of NFT.

it should use: `pearlmit.transferFromERC721(tOLP.ownerOf(_tOLPTokenID), address(this), address(tOLP), _tOLPTokenID)`

## Impact

Although `msg.sender` has been granted authorization, it is still unable to execute `participate()`.

## Recommended Mitigation
```diff
    function participate(uint256 _tOLPTokenID) external whenNotPaused nonReentrant returns (uint256 oTAPTokenID) {
...

        if (!tOLP.isApprovedOrOwner(msg.sender, _tOLPTokenID)) {
            revert NotAuthorized();
        }

        // Transfer tOLP position to this contract
        // tOLP.transferFrom(msg.sender, address(this), _tOLPTokenID);
        {
-           bool isErr = pearlmit.transferFromERC721(msg.sender, address(this), address(tOLP), _tOLPTokenID);
+           bool isErr = pearlmit.transferFromERC721(tOLP.ownerOf(_tOLPTokenID), address(this), address(tOLP), _tOLPTokenID); 
            if (isErr) revert TransferFailed();
        }


```


## Assessed type

Context
AirdropBroker.exerciseOption() can exercise twice.
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tap-token/blob/20a83b1d2d5577653610a6c3879dff9df4968345/contracts/option-airdrop/AirdropBroker.sol#L233


# Vulnerability details

## Vulnerability details
The `AirdropBroker.exerciseOption()` function has two crucial limitations:
1. The `aoTAP`  cannot be expired.
2. The number of already exercised options: `aoTAPCalls[_aoTAPTokenID][epoch]`, must not exceed `aoTapOption.amount`.

the code as follows:
```solidity
    function exerciseOption(uint256 _aoTAPTokenID, ERC20 _paymentToken, uint256 _tapAmount)
        external
        whenNotPaused
        tapExists
    {
...
    function exerciseOption(uint256 _aoTAPTokenID, ERC20 _paymentToken, uint256 _tapAmount)
        external
        whenNotPaused
        tapExists
    {
        // Load data
        (, AirdropTapOption memory aoTapOption) = aoTAP.attributes(_aoTAPTokenID);
@>      if (aoTapOption.expiry < block.timestamp) revert OptionExpired();
...
        uint256 cachedEpoch = epoch;

        uint256 eligibleTapAmount = aoTapOption.amount;
@>      eligibleTapAmount -= aoTAPCalls[_aoTAPTokenID][cachedEpoch]; // Subtract already exercised amount
        if (eligibleTapAmount < _tapAmount) revert TooHigh();

```
Currently, expiration is checked using: `if (aoTapOption.expiry < block.timestamp) revert OptionExpired();`
It should be:  `aoTapOption.expiry <= block.timestamp`.

This leads to a problem: users can exercise twice when block.timestamp ==aoTapOption.expiry.

For instance:  epoch = 1  , EPOCH_DURATION = 2 days = [ 2024-03-18 00:00:12  ~ 2024-03-20 00:00:12] 
and alice has _aoTAPTokenID = 1 ,amount = 100 , expiry = 2024-03-20 00:00:12

1. alice call exerciseOption(1,100) at  `2024-03-19`
    - pass expire check    =>  block.timestamp < 2024-03-20 00:00:12
    - set aoTAPCalls[1][1]=   100

2. when `2024-03-20 00:00:12`  alice call `AirdropBroker.newEpoch()`
  - pass `newEpoch()` time check ，`newEpoch()` check time logic, see the following code 
  - epoch++ ,  epoch = 2     (so in step 3 will pass amount check)
3.  in same block `2024-03-20 00:00:12`，alice call exerciseOption(1,100)  again
    - pass expire check    (Because the code above block.timestamp == expiry == `2024-03-20 00:00:12` is ok)
    - pass amount check,  because aoTAPCalls[1][2] ==0, it will set to 100
 
so alice exercise amount:  100 + 100 = 200
 
when `block.timestamp ==expire` `newEpoch()`  can new epoch
```solidity
    function newEpoch() external tapExists {
@>      if (block.timestamp < lastEpochUpdate + EPOCH_DURATION) {
            revert TooSoon();
        }

        // Update epoch info
        lastEpochUpdate = uint64(block.timestamp);
        epoch++;
...
```

## Impact

Users can illegally buy tap at a low discount again.

## Recommended Mitigation
```diff
    function exerciseOption(uint256 _aoTAPTokenID, ERC20 _paymentToken, uint256 _tapAmount)
        external
        whenNotPaused
        tapExists
    {
        // Load data
        (, AirdropTapOption memory
TapiocaOmnichainReceiver users can use multi-signature wallets that do not belong to them under different chains
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tapioca-periph/blob/032396f701be935b04a7e5cf3cb40a0136259dbc/contracts/tapiocaOmnichainEngine/TapiocaOmnichainReceiver.sol#L286


# Vulnerability details

## Vulnerability details

Users can execute remote token transfers via `lzCompose` with `msgType_ == MSG_REMOTE_TRANSFER`. This process primarily involves two actions:

1. `_internalTransferWithAllowance()`: This function validates allowances.
2. `_internalRemoteTransferSendPacket()`: It performs cross-chain token transfers.

Below is the code snippet for `_internalTransferWithAllowance()`:
```solidity
    function _internalTransferWithAllowance(address _owner, address srcChainSender, uint256 _amount) internal {
@>      if (_owner != srcChainSender) {
            _spendAllowance(_owner, srcChainSender, _amount);
        }

        _transfer(_owner, address(this), _amount);
    }
```
The variable `srcChainSender` represents the user who initially called `lzSend` on Chain A.

If _owner == srcChainSender, this method can be directly used. don't check the allowance.

Currently, many users possess multi-signature wallets.
Due to the mechanism for generating multi-signature addresses,
the same address might not be controlled by the same user on different chains.

Let's consider a scenario where the same multi-signed address 0x123 belongs to Alice on Chain A but is belongs to Bob on Chain B.

In this case, Alice can execute lzCompose(_dstEid=B,owner==srcChainSender==0x123) to bypass the allowance security check and manipulate Bob's balance on Chain B.

Here are examples from other protocol audits:

https://solodit.xyz/issues/h-02-if-the-virtual-accounts-owner-is-a-contract-account-multisig-wallet-attackers-can-gain-control-of-the-virtual-accounts-by-gaining-control-of-the-same-owners-address-in-a-different-chain-code4rena-maia-dao-maia-dao-git


## Impact

Users can use the multi-signed wallet balance that does not belong to them under different chains.

## Recommended Mitigation

It is recommended that if _owner is not EOA (code.length > 0), the same allowance check should be performed to ensure security.

```diff
    function _internalTransferWithAllowance(address _owner, address srcChainSender, uint256 _amount) internal {
-       if (_owner != srcChainSender) {
+       // check code for ensure the safety of multi-signature wallet cross-chain.
+       if (_owner != srcChainSender || _owner.code.length !=0) {
            _spendAllowance(_owner, srcChainSender, _amount);
        }

        _transfer(_owner, address(this), _amount);
    }
```


## Assessed type

Context
TapiocaOmnichainReceiver DOS attack
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tapioca-periph/blob/032396f701be935b04a7e5cf3cb40a0136259dbc/contracts/tapiocaOmnichainEngine/TapiocaOmnichainReceiver.sol#L300


# Vulnerability details

## Vulnerability details

`TapiocaOmnichainReceiver`, as the base class for OFT (Omnichain Functionality Token), uniformly receives and processes `lzCompose` messages. 

Currently, most cross-chain operations carry multiple pieces of information, for example:

`[tapComposeMsg1 + tapComposeMsg2 + tapComposeMsg3]`

The first message typically involves authorization, followed by corresponding actions. 
For instance:

- `tapComposeMsg1 = token.permit(v, r, s)`
- `tapComposeMsg2 = market...`

It is well-known that `permit(v,r,s)` is susceptible to front-running attacks, where it can be executed prematurely. 
Presently, the implementation may revert at the first `msg1`, rendering subsequent steps unexecutable.

recommend wrapping all `permit` calls in a `try/catch` block. 
If the permit has already been executed, it can be safely ignored to prevent DOS.

```solidity
    function _lzCompose(address srcChainSender_, bytes32 _guid, bytes memory oftComposeMsg_) internal {
..
    function _lzCompose(address srcChainSender_, bytes32 _guid, bytes memory oftComposeMsg_) internal {
        // Decode OFT compose message.
        (uint16 msgType_,,, bytes memory tapComposeMsg_, bytes memory nextMsg_) =
            TapiocaOmnichainEngineCodec.decodeToeComposeMsg(oftComposeMsg_);

        // Call Permits/approvals if the msg type is a permit/approval.
        // If the msg type is not a permit/approval, it will call the other receivers.
        if (msgType_ == MSG_REMOTE_TRANSFER) {
            _remoteTransferReceiver(srcChainSender_, tapComposeMsg_);
@>      } else if (!_extExec(msgType_, tapComposeMsg_)) {
....
        if (nextMsg_.length > 0) {
@>          _lzCompose(srcChainSender_, _guid, nextMsg_);
        }
    }
...

    function _extExec(uint16 _msgType, bytes memory _data) internal returns (bool) {
        if (_msgType == MSG_APPROVALS) {
            toeExtExec.erc20PermitApproval(_data);
        } else if (_msgType == MSG_NFT_APPROVALS) {
            toeExtExec.erc721PermitApproval(_data);
        } else if (_msgType == MSG_PEARLMIT_APPROVAL) {
            toeExtExec.pearlmitApproval(_data);
        } else if (_msgType == MSG_YB_APPROVE_ALL) {
            toeExtExec.yieldBoxPermitAll(_data);
        } else if (_msgType == MSG_YB_APPROVE_ASSET) {
            toeExtExec.yieldBoxPermitAsset(_data);
        } else if (_msgType == MSG_MARKET_PERMIT) {
            toeExtExec.marketPermit(_data);
        } else {
            return false;
        }
        return true;
    }
```

## Impact

DOS `lzCompose` with `permit(v,r,s)`, causing it to fail to execute

## Recommended Mitigation

Try/catch all permits  in `TapiocaOmnichainExtExec`


```diff
contract TapiocaOmnichainExtExec is Ownable {
...
    function erc20PermitApproval(bytes memory _data) public {
AirdropBroker does not handle the case where payment token decimals are greater than 18
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tap-token/blob/20a83b1d2d5577653610a6c3879dff9df4968345/contracts/option-airdrop/AirdropBroker.sol#L536


# Vulnerability details

AirdropBroker's _getDiscountedPaymentAmount is used to calculate the number of payment tokens corresponding to the USD value required for exercising options(airdrop options).

```solidity
paymentAmount = paymentAmount / (10 ** (18 - _paymentTokenDecimals));
```

But it doesn't consider the case where the payment token decimals are greater than 18. So these types of payment tokens can't exercise options, due to the overflow revert.

## Recommendation
We can find the correct implementation in the TapiocaOptionBroker:
https://github.com/Tapioca-DAO/tap-token/blob/20a83b1d2d5577653610a6c3879dff9df4968345/contracts/options/TapiocaOptionBroker.sol#L592-L596

```solidity
        if (_paymentTokenDecimals <= 18) {
            paymentAmount = paymentAmount / (10 ** (18 - _paymentTokenDecimals));
        } else {
            paymentAmount = paymentAmount * (10 ** (_paymentTokenDecimals - 18));
        }
```


## Assessed type

Math
Adding reward tokens to twTap could cause pending cross-chain claim rewards msg to be stuck
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tap-token/blob/20a83b1d2d5577653610a6c3879dff9df4968345/contracts/tokens/TapTokenReceiver.sol#L158-L164


# Vulnerability details

There is a check in the `TapTokenReceiver._claimTwpTapRewardsReceiver` to ensure the claimed types amount is equal to the token types amount of sendParam.
```solidity
        if (
            (claimedAmount_.length - 1) // Remove 1 because the first index doesn't count.
                != claimTwTapRewardsMsg_.sendParam.length
        ) {
            revert
```
The process of cross-chain claim is as follows:
The B chain is the main chain, which the twTap is deployed. And the user is on the chain A.

1. The user calls TapOFT.sendPacket with MSG_CLAIM_REWARDS message on the chain A. 
2. The LZ replays the message on the chain B, which will call the `TapTokenReceiver._claimTwpTapRewardsReceiver` function. 
3. `TapTokenReceiver._claimTwpTapRewardsReceiver` function will send every reward token to the chain A by LZ `TapTokenSender(rewardToken_).sendPacket`.

## Proof of Concept 
The issue is that, if after step 1 the LZ message sent, a new reward token is added to the twTAP on the chain B, the step2 will be revert in the `TapTokenReceiver._claimTwpTapRewardsReceiver` function. 

So this message will stuck in the LZ indefinitely.

## Impact
Every LZ message sent by TapToken.sendPacket can carry cross-chain TAP tokens, which means the `amountToSendLD` of the message is not zero. It will burn the TAP token of the source chain and mint them on the target chain. If the message is stuck, these TAP token will be lost forever.



## Assessed type

Context
Magnetar.burst can steal eth in the Magnetar by LZ sendPacket
Audit: 2024-02-tapioca-findings
Severity: MEDIUM

# Lines of code

https://github.com/Tapioca-DAO/tapioca-periph/blob/032396f701be935b04a7e5cf3cb40a0136259dbc/contracts/Magnetar/modules/MagnetarBaseModule.sol#L117-L129
https://github.com/Tapioca-DAO/tapioca-periph/blob/032396f701be935b04a7e5cf3cb40a0136259dbc/contracts/Magnetar/modules/MagnetarBaseModule.sol#L164-L170


# Vulnerability details

The MagnetarBaseModule class is the base class for all Magnetar modules. The `MagnetarBaseModule._withdrawToChain` function is called in several modules to cross-chain withdraw OFT. 

To prevent attackers from stealing ETH from the contract, both the _lzCustomWithdraw and _lzWithdraw methods perform a minimum value check on msg.value:
```solidity
        if (msg.value < prepareLzCallReturn.msgFee.nativeFee) {
            revert Magnetar_GasMismatch(prepareLzCallReturn.msgFee.nativeFee, msg.value);
        }
```
The issue is that, the Magnetar modules are called by delegatecall from the Magnetar contract, and the entry method `Magnetar.burst` allows for multiple calls to delegatecall within a loop. The delegatecall doesn't have its own msg.value, the original msg.value of the `Magnetar.burst` function input is exactly the same in each module delegatecall.

## Impact 
Therefore, an attacker can package multiple module call requests with withdrawToChain calls into a single burst call. When the modules execute the `IOftSender(_asset).sendPacket{value: prepareLzCallReturn.msgFee.nativeFee}` sequentially, the ETH in the contract will be stolen and sent to the attacker's address on the target chain.

And the same issue is also in the `TapiocaOmnichainReceiver`. Attacker can steal ETH from TapiocaOmnichainReceiver contract when its decoding multiple composed calls by the same reason.
## Proof of Concept 
Here is the simplest module interface without any complex checks that can implement the above exploitation: https://github.com/Tapioca-DAO/tapioca-periph/blob/032396f701be935b04a7e5cf3cb40a0136259dbc/contracts/Magnetar/modules/MagnetarYieldBoxModule.sol#L81-L83
```
    function withdrawToChain(MagnetarWithdrawData memory data) public payable {
        _withdrawToChain(data);
    }
```
And the LZ send accepts a `adapterParams` param to receive airdropped native gas from the relayer on destination. It can be used to steal all the eth in the Magnetar contract.

## Recommendation
Input the value as a param, instead of getting it from msg.value in a delegatecall.


## Assessed type

Context