Vulnerability: Withdrawing to other chain when exercising options won't work as expected, leading to DoS
Severity: medium

Description:
Withdrawing to another chain when exercising options will always fail because the implemented functionality does not bridge the tokens exercised in the option, and tries to perform a regular cross-chain call instead.\\nTapioca incorporates a DAO Share Options (DSO) program where users can lock USDO in order to obtain TAP tokens at a discounted price.\\nIn order to exercise their options, users need to execute a compose call with a message type of `MSG_TAP_EXERCISE`, which will trigger the UsdoOptionReceiverModule's `exerciseOptionsReceiver()` function.\\nWhen exercising their options, users can decide to bridge the obtained TAP tokens into another chain by setting the `msg_.withdrawOnOtherChain` to true:\\n```\\n// UsdoOptionReceiverModule.sol\\n\\nfunction exerciseOptionsReceiver(address srcChainSender, bytes memory _data) public payable {\\n        \\n        // rest of code \\n        \\n        ITapiocaOptionBroker(_options.target).exerciseOption(\\n            _options.oTAPTokenID,\\n            address(this), //payment token \\n            _options.tapAmount \\n        ); \\n        \\n        // rest of code\\n \\n        address tapOft = ITapiocaOptionBroker(_options.target).tapOFT();\\n        if (msg_.withdrawOnOtherChain) {\\n          // rest of code \\n\\n            // Sends to source and preserve source `msg.sender` (`from` in this case).\\n            _sendPacket(msg_.lzSendParams, msg_.composeMsg, _options.from); \\n\\n            // Refund extra amounts\\n            if (_options.tapAmount - amountToSend > 0) {\\n                IERC20(tapOft).safeTransfer(_options.from, _options.tapAmount - amountToSend);\\n            }\\n        } else {\\n            //send on this chain\\n            IERC20(tapOft).safeTransfer(_options.from, _options.tapAmount);\\n        }\\n        }\\n    } \\n```\\n\\nAs the code snippet shows, `exerciseOptionsReceiver()` will perform mainly 2 steps:\\nExercise the option by calling `_options.target.exerciseOption()` . This will make USDO tokens serving as a payment for the `tapOft` tokens be transferred from the user, and in exchange the corresponding option `tapOft` tokens will be transferred to the USDO contract so that they can later be transferred to the user.\\nTAP tokens will be sent to the user. This can be done in two ways:\\nIf the user doesn't decide to bridge them (by leaving `msg_.withdrawOnOtherChain` as false), the `tapOft` tokens will simply be transferred to the `_options.from` address, succesfully exercising the option\\nOn the other hand, if the user decides to bridge the exercised option, the internal `_sendPacket()` function will be triggered, which will perform a call via LayerZero to the destination chain:\\n`// UsdoOptionReceiverModule.sol\\n\\nfunction _sendPacket(LZSendParam memory _lzSendParam, bytes memory _composeMsg, address _srcChainSender)\\n        private\\n        returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt)\\n    {\\n        /// @dev Applies the token transfers regarding this send() operation.\\n        // - amountDebitedLD is the amount in local decimals that was ACTUALLY debited from the sender.\\n        // - amountToCreditLD is the amount in local decimals that will be credited to the recipient on the remote OFT instance.\\n        (uint256 amountDebitedLD, uint256 amountToCreditLD) =\\n            _debit(_lzSendParam.sendParam.amountLD, _lzSendParam.sendParam.minAmountLD, _lzSendParam.sendParam.dstEid);\\n   \\n        /// @dev Builds the options and OFT message to quote in the endpoint.\\n        (bytes memory message, bytes memory options) = _buildOFTMsgAndOptionsMemory(\\n            _lzSendParam.sendParam, _lzSendParam.extraOptions, _composeMsg, amountToCreditLD, _srcChainSender\\n        );\\n  \\n        /// @dev Sends the message to the LayerZero endpoint and returns the LayerZero msg receipt.\\n        msgReceipt =\\n            _lzSend(_lzSendParam.sendParam.dstEid, message, options, _lzSendParam.fee, _lzSendParam.refundAddress);\\n        /// @dev Formulate the OFT receipt.\\n        oftReceipt = OFTReceipt(amountDebitedLD, amountToCreditLD);\\n\\n        emit OFTSent(msgReceipt.guid, _lzSendParam.sendParam.dstEid, msg.sender, amountDebitedLD);\\n    }`\\nThe problem with the approach followed when users want to bridge the exercised options is that the contract will not actually bridge the exercised `tapOft` tokens by calling the tapOft's `sendPacket()` function (which is the actual way by which the token can be transferred cross-chain). Instead, the contract calls `_sendPacket()` , a function that will try to perform a USDO cross-chain call (instead of a `tapOft` cross-chain call). This will make the `_debit()` function inside `_sendPacket()` be executed, which will try to burn USDO tokens from the msg.sender:\\n```\\n// OFT.sol \\n\\nfunction _debit(\\n        uint256 _amountLD, \\n        uint256 _minAmountLD,\\n        uint32 _dstEid\\n    ) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) {\\n        (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid);\\n \\n        // @dev In NON-default OFT, amountSentLD could be 100, with a 10% fee, the amountReceivedLD amount is 90,\\n        // therefore amountSentLD CAN differ from amountReceivedLD.\\n   \\n        // @dev Default OFT burns on src.\\n        _burn(msg.sender, amountSentLD);\\n    }\\n```\\n\\nThis leads to two possible outcomes:\\n`msg.sender` (the LayerZero endpoint) has enough `amountSentLD` of USDO tokens to be burnt. In this situation, USDO tokens will be incorrectly burnt from the user, leading to a loss of balance for him. After this, the burnt USDO tokens will be bridged. This outcome greatly affect the user in two ways:\\nUSDO tokens are incorrectly burnt from his balance\\nThe exercised `tapOft` tokens remain stuck forever in the USDO contract because they are never actually bridged\\nThe most probable: `msg.sender` (LayerZero endpoint) does not have enough `amountSentLD` of USDO tokens to be burnt. In this case, an error will be thrown and the whole call will revert, leading to a DoS\\nProof of Concept\\nThe following poc shows how the function will be DoS'ed due to the sender not having enough USDO to be burnt. In order to execute the Poc, perform the following steps:\\nRemove the `_checkWhitelistStatus(OFTMsgCodec.bytes32ToAddress(msg_.lzSendParams.sendParam.to));` line in UsdoOptionReceiverModule.sol's `exerciseOptionsReceiver()` function (it is wrong and related to another vulnerability)\\nPaste the following code in Tapioca-bar/test/Usdo.t.sol:\\n`// Usdo.t.sol\\n\\nfunction testVuln_exercise_option() public {\\n        uint256 erc20Amount_ = 1 ether;\\n\\n        //setup\\n        {\\n            deal(address(aUsdo), address(this), erc20Amount_);\\n\\n            // @dev send TAP to tOB\\n            deal(address(tapOFT), address(tOB), erc20Amount_);\\n\\n            // @dev set `paymentTokenAmount` on `tOB`\\n            tOB.setPaymentTokenAmount(erc20Amount_);\\n        }\\n \\n        //useful in case of withdraw after borrow\\n        LZSendParam memory withdrawLzSendParam_;\\n        MessagingFee memory withdrawMsgFee_; // Will be used as value for the composed msg\\n\\n        {\\n            // @dev `withdrawMsgFee_` is to be airdropped on dst to pay for the send to source operation (B->A).\\n            PrepareLzCallReturn memory prepareLzCallReturn1_ = usdoHelper.prepareLzCall( // B->A data\\n                IUsdo(address(bUsdo)),\\n                PrepareLzCallData({\\n                    dstEid: aEid,\\n                    recipient: OFTMsgCodec.addressToBytes32(address(this)),\\n                    amountToSendLD: erc20Amount_,\\n                    minAmountToCreditLD: erc20Amount_,\\n                    msgType: SEND,\\n                    composeMsgData: ComposeMsgData({\\n                        index: 0,\\n                        gas: 0,\\n                        value: 0,\\n                        data: bytes(""),\\n                        prevData: bytes(""),\\n                        prevOptionsData: bytes("")\\n                    }),\\n                    lzReceiveGas: 500_000,\\n                    lzReceiveValue: 0\\n                })\\n            );\\n            withdrawLzSendParam_ = prepareLzCallReturn1_.lzSendParam;\\n            withdrawMsgFee_ = prepareLzCallReturn1_.msgFee;\\n        }\\n\\n        /**\\n         * Actions\\n         */\\n        uint256 tokenAmountSD = usdoHelper.toSD(erc20Amount_, aUsdo.decimalConversionRate());\\n\\n        //approve magnetar\\n        ExerciseOptionsMsg memory exerciseMsg = ExerciseOptionsMsg({\\n            optionsData: IExerciseOptionsData({\\n                from: address(this),\\n                target: address(tOB), \\n                paymentTokenAmount: tokenAmountSD,\\n                oTAPTokenID: 0, // @dev ignored in TapiocaOptionsBrokerMock\\n                tapAmount: tokenAmountSD\\n            }),\\n            withdrawOnOtherChain: true,\\n            lzSendParams: LZSendParam({\\n                sendParam: SendParam({\\n                    dstEid: 0,\\n                    to: "0x",\\n                    amountLD: erc20Amount_,\\n                    minAmountLD: erc20Amount_,\\n                    extraOptions: "0x",\\n                    composeMsg: "0x",\\n                    oftCmd: "0x"\\n                }),\\n                fee: MessagingFee({nativeFee: 0, lzTokenFee: 0}),\\n                extraOptions: "0x",\\n                refundAddress: address(this)\\n            }),\\n            composeMsg: "0x"\\n        });\\n        bytes memory sendMsg_ = usdoHelper.buildExerciseOptionMsg(exerciseMsg);\\n\\n        PrepareLzCallReturn memory prepareLzCallReturn2_ = usdoHelper.prepareLzCall(\\n            IUsdo(address(aUsdo)),\\n            PrepareLzCallData({\\n                dstEid: bEid,\\n                recipient: OFTMsgCodec.addressToBytes32(address(this)),\\n                amountToSendLD: erc20Amount_,\\n                minAmountToCreditLD: erc20Amount_,\\n                msgType: PT_TAP_EXERCISE,\\n                composeMsgData: ComposeMsgData({\\n                    index: 0,\\n                    gas: 500_000,\\n                    value: uint128(withdrawMsgFee_.nativeFee),\\n                    data: sendMsg_,\\n                    prevData: bytes(""),\\n                    prevOptionsData: bytes("")\\n                }),\\n                lzReceiveGas: 500_000,\\n                lzReceiveValue: 0\\n            })\\n        );\\n        bytes memory composeMsg_ = prepareLzCallReturn2_.composeMsg;\\n        bytes memory oftMsgOptions_ = prepareLzCallReturn2_.oftMsgOptions;\\n        MessagingFee memory msgFee_ = prepareLzCallReturn2_.msgFee;\\n        LZSendParam memory lzSendParam_ = prepareLzCallReturn2_.lzSendParam;\\n\\n        (MessagingReceipt memory msgReceipt_,) = aUsdo.sendPacket{value: msgFee_.nativeFee}(lzSendParam_, composeMsg_);\\n\\n        {\\n            verifyPackets(uint32(bEid), address(bUsdo));\\n\\n            vm.expectRevert("ERC20: burn amount exceeds balance");\\n            this.lzCompose(\\n                bEid,\\n                address(bUsdo),\\n                oftMsgOptions_,\\n                msgReceipt_.guid,\\n                address(bUsdo),\\n                abi.encodePacked(\\n                    OFTMsgCodec.addressToBytes32(address(this)), composeMsg_\\n                )\\n        ); \\n\\n        }\\n\\n    }`\\nRun the poc with the following command, inside the Tapioca-bar repo: `forge test --mt testVuln_exercise_option`\\nWe can see how the "ERC20: burn amount exceeds balance" error is thrown due to the issue mentioned in the report.

Impact:
High. As demonstrated, two critical outcomes might affect the user:\\n`tapOft` funds will remain stuck forever in the USDO contract and USDO will be incorrectly burnt from `msg.sender`\\nThe core functionality of exercising and bridging options always reverts and effectively causes a DoS.

Recommended mitigation:
If users decide to bridge their exercised tapOft, the sendPacket() function incorporated in the tapOft contract should be used instead of UsdoOptionReceiverModule's internal _sendPacket() function, so that the actual bridged asset is the tapOft and not the USDO.