Multiliquid

Multiliquid

Cantina Security Report

Organization

@multiliquid

Engagement Type

Cantina Reviews

Period

-


Findings

Medium Risk

2 findings

2 fixed

0 acknowledged

Low Risk

1 findings

1 fixed

0 acknowledged

Informational

3 findings

3 fixed

0 acknowledged

Gas Optimizations

2 findings

2 fixed

0 acknowledged


Medium Risk2 findings

  1. Issuer admin can transfer user funds from StablecoinDelegate approvals

    Severity

    Severity: Medium

    ≈

    Likelihood: Low

    ×

    Impact: High

    Submitted by

    rvierdiiev


    Description

    To interact with StablecoinDelegate, a user must provide an allowance for the stablecoin, which can be set to an infinite amount. The depositStablecoin() function allows the issuer admin to transfer stablecoins from any user who has approved the delegate contract. Additionally, the withdrawStablecoin() function enables withdrawing those funds.

    This design introduces unnecessary risk, as it grants the issuer admin broad control over user funds once approval is given.

    Recommendation

    Project comments indicate that this functionality is not required.
    It is recommended to remove depositStablecoin() function to reduce risk.

  2. RWA redemption fees are swapped to RWA and sent to user

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: Medium

    Submitted by

    HickupHH3


    Description

    When swapping from a stablecoin to RWA, stablecoinToSwap will include the redemption fee, which shouldn't be the case, since fees are expected to be collected in stablecoin. It is then swapped to the RWA and sent to the user, which means the redemption fee isn't actually taken.

    Proof of Concept

    function test_redemptionFeeNotRetained() public {        // Fast forward time by 7 days to avoid arithmetic underflow in ULTRADelegate        vm.warp(block.timestamp + 7 days);        setupAgoraPools();                        // set redemption fee to 1%        vm.prank(stablecoinIssuerAdmin3);        ausdDelegate.setRWARedemptionFee(ULTRA_ID, 1e16);                        //Mint AUSD to user        ausd.mint(regularUser, 150_000e6); //150k AUSD                        vm.startPrank(regularUser);        //Approval to the ausd delegate        ausd.approve(address(ausdDelegate), type(uint256).max);                        //Swap from AUSD to Ultra        multiliquidSwap.swapIntoRWA(ULTRA_ID, AUSD_ID, 50_000e6, "", "");                // assert that delegate has not kept redemption fee in either RWA or AUSD        assertEq(ausd.balanceOf(address(ausdDelegate)), 0);        assertEq(ultra.balanceOf(address(ausdDelegate)), 0);}

    Recommendation

    Pass the redemption fee to this function and subtract it from stablecoinToSwap.

Low Risk1 finding

  1. Repeated scaling in RWA and stablecoin amount conversion view functions

    Severity

    Severity: Low

    ≈

    Likelihood: High

    ×

    Impact: Low

    Submitted by

    HickupHH3


    Description

    The external view functions calculateRWAAmt() and calculateStableAmt() are performing redundant decimal scaling operations that are already handled internally by their respective helper functions _calculateRWAAmtAndFeeAmt() and _calculateStableAndFeeAmt().

    Proof of Concept

    function test_calculateStableAmt_RepeatedRounding() public {    address mockStablecoin = makeAddr("mockStablecoin");    swap.setStablecoinAcceptance(        stablecoinID, address(stablecoinDelegate), mockStablecoin, 6, true, false    );    uint256 rwaValue = 3e18; // 3 USD    uint256 rwaAmount = 100e18; // 100 RWA    uint256 adjustmentBps = 20e14; // 20 bps    uint256 feeBps = 10e14; // 10 bps    uint256 redemptionFeeBps = 0; // No redemption fee for this test        _setupScenario(rwaValue, adjustmentBps, feeBps, redemptionFeeBps);        (uint256 customerAmount, uint256 feeAmount) =        swap.calculateStableAmt(stablecoinID, rwaID, rwaAmount, "");    assertEq(customerAmount, 0);    assertEq(feeAmount, 0);}

    Recommendation

    Remove the scaling operations in the view functions.

Informational3 findings

  1. Comment Clarification

    Severity

    Severity: Informational

    Submitted by

    HickupHH3


    Description

    0 to disable may give the impression that it disables the volume limit, but it means disable swaps entirely. Recommend clarifying the comment.

    Recommendation

    - 0 to disable+ 0 to disable swaps
  2. Absent checks in calculation methods

    Severity

    Severity: Informational

    Submitted by

    HickupHH3


    Description

    The checks that exist in he swap functions are absent in these calculation methods.

    Recommendation

    Consider adding the checks into these functions. In addition, since there is significant overlap between the calculation and swap functions, they can be refactored and abstracted into internal functions to avoid code duplication.

  3. Add slippage protection to swaps

    Severity

    Severity: Informational

    Submitted by

    HickupHH3


    Description & Recommendation

    Consider adding a minStablecoinAmt to prevent the swaps to be executed at undesirable rates, and from protection against unexpected changes to redemption fees and discounts by issuer admins right before the swap transaction.

Gas Optimizations2 findings

  1. stablecoinAddress can be cached in memory

    Severity

    Severity: Gas optimization

    Submitted by

    HickupHH3


    Description

    stablecoinAddress can be cached and casted in memory to avoid repeated storage reads.

    test_maliciousRWA_RevertTransfer() (gas: 10 (0.001%)) test_deployStablecoin_AgoraSwapReverts() (gas: 10 (0.006%)) test_deployStablecoin_InsufficientAUSDOutput() (gas: 10 (0.008%)) test_maliciousStablecoinToRwa_ReducedRwaOutput() (gas: -395 (-0.013%)) test_maliciousAgoraContract_ReducedOutput() (gas: -395 (-0.013%)) test_maliciousAgoraContract_ZeroOutput() (gas: -395 (-0.014%)) test_MegaE2E() (gas: -1056 (-0.058%)) test_fuzz_normal_swap_into_rwa_happy_path_ausd(uint256) (gas: -393 (-0.071%)) test_normal_swap_into_rwa_happy_path_ausd() (gas: -393 (-0.072%)) test_normal_swap_into_stablecoin_happy_path_ausd() (gas: -663 (-0.124%)) test_receiveStablecoin_minimal() (gas: -393 (-0.124%)) test_normal_swap_into_stablecoin_happy_path_fuzz(uint256) (gas: -663 (-0.130%)) test_receiveStablecoin_Success() (gas: -393 (-0.145%)) test_receiveStablecoin_InsufficientRWAAmount() (gas: -260 (-0.154%)) test_deployStablecoin_Success_RWAAsToken0() (gas: -663 (-0.237%)) test_deployStablecoin_ReentrancyProtection() (gas: -663 (-0.256%)) test_receiveStablecoin_ZeroAmounts() (gas: -393 (-0.307%)) test_maliciousRWA_TransferFee_RwaToStablecoin() (gas: -50943 (-0.601%)) test_deployStablecoin_InvalidTokenConfiguration() (gas: -51213 (-0.602%)) test_deployStablecoin_Success_RWAAsToken1() (gas: -51615 (-0.667%)) test_deployStablecoin_InvalidAgoraContract() (gas: -50942 (-0.685%)) test_receiveStablecoin_InvalidAgoraContract() (gas: -51212 (-0.687%)) test_maliciousStablecoin_TransferFee_StablecoinToRwa() (gas: -51212 (-0.887%)) test_maliciousStablecoin_BalanceManipulation() (gas: -50942 (-0.890%)) Overall gas change: -365167 (-0.210%)

    Recommendation

    Git patch:

    diff --git a/src/ProtocolStablecoinDelegates/AUSDDelegate.sol b/src/ProtocolStablecoinDelegates/AUSDDelegate.solindex 838a5d6..bf1f5c5 100644--- a/src/ProtocolStablecoinDelegates/AUSDDelegate.sol+++ b/src/ProtocolStablecoinDelegates/AUSDDelegate.sol@@ -187,9 +187,10 @@ contract AUSDDelegate is StablecoinDelegate, IExternalSwapStablecoinDelegate {             AgoraStableSwapPair(agoraStableSwapContract[params.rwaAddress]);         if (address(agoraContract) == address(0)) revert InvalidAgoraContract(); +        IERC20 _stablecoin = IERC20(stablecoinAddress);         // Calculate expected AUSD amount         uint256 expectedAusdAmount = _getExpectedOutput(-            address(agoraContract), stablecoinAddress, params.rwaAddress, params.rwaAmount+            address(agoraContract), address(_stablecoin), params.rwaAddress, params.rwaAmount         );          // Ensure we get enough AUSD to cover vault fee + user amount@@ -203,13 +204,13 @@ contract AUSDDelegate is StablecoinDelegate, IExternalSwapStablecoinDelegate {         );          // Check balance before swap-        uint256 balanceBefore = IERC20(stablecoinAddress).balanceOf(address(this));+        uint256 balanceBefore = _stablecoin.balanceOf(address(this));          // Execute the swap-        _performSwap(agoraContract, expectedAusdAmount, address(this), stablecoinAddress);+        _performSwap(agoraContract, expectedAusdAmount, address(this), address(_stablecoin));          // Verify actual amount received-        uint256 balanceAfter = IERC20(stablecoinAddress).balanceOf(address(this));+        uint256 balanceAfter = _stablecoin.balanceOf(address(this));         uint256 actualReceived = balanceAfter - balanceBefore;         uint256 totalRequired = params.vaultAmount + params.userAmount; @@ -218,8 +219,8 @@ contract AUSDDelegate is StablecoinDelegate, IExternalSwapStablecoinDelegate {         }          // Distribute the received AUSD-        IERC20(stablecoinAddress).safeTransfer(params.vault, params.vaultAmount);-        IERC20(stablecoinAddress).safeTransfer(params.user, params.userAmount);+        _stablecoin.safeTransfer(params.vault, params.vaultAmount);+        _stablecoin.safeTransfer(params.user, params.userAmount);     }      function _executeStablecoinToRwaSwap(SwapIntoRwaParams memory params) internal {@@ -227,17 +228,18 @@ contract AUSDDelegate is StablecoinDelegate, IExternalSwapStablecoinDelegate {             AgoraStableSwapPair(agoraStableSwapContract[params.rwaAddress]);         if (address(agoraContract) == address(0)) revert InvalidAgoraContract(); -        IERC20(stablecoinAddress).safeTransferFrom(params.user, address(this), params.totalAmount);-        IERC20(stablecoinAddress).safeTransfer(params.vault, params.vaultAmount);+        IERC20 _stablecoin = IERC20(stablecoinAddress);+        _stablecoin.safeTransferFrom(params.user, address(this), params.totalAmount);+        _stablecoin.safeTransfer(params.vault, params.vaultAmount);          uint256 stablecoinToSwap = params.totalAmount - params.vaultAmount;         uint256 expectedRwaAmount = _getExpectedOutput(-            address(agoraContract), params.rwaAddress, stablecoinAddress, stablecoinToSwap+            address(agoraContract), params.rwaAddress, address(_stablecoin), stablecoinToSwap         );          if (expectedRwaAmount < params.rwaAmount) revert InsufficientRWAAmount(); -        IERC20(stablecoinAddress).safeTransfer(address(agoraContract), stablecoinToSwap);+        _stablecoin.safeTransfer(address(agoraContract), stablecoinToSwap);          // Check user's RWA balance before swap         uint256 userRwaBalanceBefore = IERC20(params.rwaAddress).balanceOf(params.user);
  2. Duplicate rate check

    Severity

    Severity: Gas optimization

    Submitted by

    HickupHH3


    Summary

    The referenced checks are repeated; it'll be done in multliquidSwap.

    Recommendation

    Remove the referenced lines.