Multiliquid
Cantina Security Report
Organization
- @multiliquid
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
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
Issuer admin can transfer user funds from StablecoinDelegate approvals
State
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. ThedepositStablecoin()
function allows the issuer admin to transfer stablecoins from any user who has approved the delegate contract. Additionally, thewithdrawStablecoin()
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 removedepositStablecoin()
function to reduce risk.RWA redemption fees are swapped to RWA and sent to user
State
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
Repeated scaling in RWA and stablecoin amount conversion view functions
State
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
HickupHH3
Description
The external view functions
calculateRWAAmt()
andcalculateStableAmt(
) 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
Comment Clarification
State
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
Absent checks in calculation methods
State
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.
Add slippage protection to swaps
State
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
stablecoinAddress can be cached in memory
State
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);
Duplicate rate check
State
Severity
- Severity: Gas optimization
Submitted by
HickupHH3
Summary
The referenced checks are repeated; it'll be done in
multliquidSwap
.Recommendation
Remove the referenced lines.