Multiliquid V2
Cantina Security Report
Organization
- @multiliquid
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Medium Risk
3 findings
3 fixed
0 acknowledged
Low Risk
3 findings
3 fixed
0 acknowledged
Informational
9 findings
8 fixed
1 acknowledged
Medium Risk3 findings
Fee is overcharged for RWA to RWA swap exactOut case
State
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Low Submitted by
rvierdiiev
Description
For the RWA to RWA swap in case of
exactOut, the redemption fee is currently calculated as follows:outputs.redemptionFeeAmt = _fromWad( (rwaInUSDWad.divWadUp(WAD - redemptionFeeRateIn) - rwaInUSDWad) + (rwaOutUSDWad - rwaOutAmtWad.mulWad(rwaOutPrice)), params.stablecoinDecimals);In this formula, the redemption fee on the input is effectively applied twice, since
rwaInUSDWadalready includes the fee. As a result, the user is charged a higher redemption fee than intended.Recommendation
Use the following corrected formula to calculate the total redemption fee:
outputs.redemptionFeeAmt = _fromWad( rwaInUSDWad - rwaOutAmtWad.mulWad(rwaOutPrice), params.stablecoinDecimals);Redemption fee is overcharged for stablecoin to RWA swaps
State
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Low Submitted by
rvierdiiev
Description
swapIntoRWA()function charge the redemption fee based on the entire stablecoin amount. However, not the whole stablecoin amount is actually swapped into the RWA — a portion is first deducted as a protocol fee. Therefore, onlystablecoinAmount - protocolFeeis effectively swapped and should be used to calculate the redemption fee.As a result, users are currently overcharged.
The correct approach is already used in other functions such as:
swapIntoRWAExactOut()swapRWAToRWA()swapIntoStablecoin()swapStablecoinToStablecoin()
Recommendation
Align fee calculation across all swap functions, ensuring that only
stablecoinAmount - protocolFeeis used when calculating the redemption fee.Protocol fee calculation is incorrect for exact out RWA to RWA swaps
State
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
HickupHH3
Description
There is a discrepancy in the calculation of the protocol fee for the exact out case, where the amount calculated is less than expected.
POC
Adding console logs to the function:
console.log("protocolFeeWad", protocolFeeWad);console.log("rwaInUSDWad", rwaInUSDWad);console.log("### FOWARD CALC ###");uint256 protocolFeeWadReversed = totalRwaInUSDWad.mulWadUp(_calculateMultiliquidFee(rwaInUSDWad));console.log( "protocolFeeWadReversed", protocolFeeWadReversed);console.log("usdAfterProtocolFeeWad", totalRwaInUSDWad - protocolFeeWadReversed);Current behaviour:
[PASS] test_swapRWAToRWA_exactOut_with_higher_redemptionFees() (gas: 508957)Logs: protocolFeeWad 265144819575263251 rwaInUSDWad 53028963915052650186 ### FOWARD CALC ### protocolFeeWadReversed 266470543673139568 usdAfterProtocolFeeWad 53027638190954773869With the change:
[FAIL: Higher redemption fees should increase input required: 46343861844048634640 != 46342703247502533424] test_swapRWAToRWA_exactOut_with_higher_redemptionFees() (gas: 520351)Logs: protocolFeeWad 266477205603279650 rwaInUSDWad 53028963915052650186 ### FOWARD CALC ### protocolFeeWadReversed 266477205603279650 usdAfterProtocolFeeWad 53028963915052650186Recommendation
// Step 3: Reverse protocol fee to find total rwaIn USD neededuint256 totalRwaInUSDWad = rwaInUSDWad.divWadUp(WAD - _calculateMultiliquidFee(rwaInUSDWad)); // Step 4: Calculate protocol fee from differenceuint256 protocolFeeWad = totalRwaInUSDWad - rwaInUSDWad;
Low Risk3 findings
Protocol fee is calculated based on stablecoin amount instead of usd amount
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
rvierdiiev
Description
When a user performs a swap, the fee is determined according to the defined fee tiers.
function _calculateMultiliquidFee(uint256 stableAmt) internal view returns (uint256 feeBps) { if (feeTiers.length == 0) revert InvalidFeeTiers(); uint256 length = feeTiers.length; for (uint256 i = 0; i < length;) { if (stableAmt <= feeTiers[i].maxVolume) { return feeTiers[i].fee; } unchecked { ++i; } } // If the stableAmt is greater than the max volume of the last fee tier, use the last fee tier return feeTiers[feeTiers.length - 1].fee;}FeeTier.maxVolumeis denominated in USD, which means that all compared amounts should be converted to USD value before comparison. However, for stablecoins, this conversion is skipped — the code directly uses the stablecoin amount. As a result, the protocol may miscalculate the fee for stablecoin swaps.Recommendation
Convert the stablecoin amount to its USD equivalent before calculating the applicable fee tier.
Protocol and redemption fee are not converted to stablecoin in case of rwa to rwa swaps
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
rvierdiiev
Description
When swapping from RWA to RWA, the calculations inside the
_calculateRWAToRWA()function are performed in USD, including both the protocol and redemption fees. However, these fees are later passed to the stablecoin delegate without converting them from USD to the stablecoin denomination, resulting in incorrect fee amounts being used.Recommendation
Convert the protocol and redemption fees from USD to stablecoin value inside the
_calculateRWAToRWA()function before passing them to the stablecoin delegate.Rounding issues may cause dust wei to be unaccounted for
State
Severity
- Severity: Low
Submitted by
HickupHH3
Description
Because of rounding, it's possible that
stablecoinOutUSDWad + protocolFeeWad + acceptanceFeeWad + redemptionFeeWad != stablecoinInUSDWad.Proof of Concept
Add this invariant:
if (stablecoinOutUSDWad + protocolFeeWad + acceptanceFeeWad + redemptionFeeWad != stablecoinInUSDWad) revert("INVARIANT FAILED");Add this fuzz test in
StableToStableBalanceSheetTest:/// forge-config: default.fuzz.runs = 100_000function test_fuzzExactOutRounding(uint256 exactAmountOut) public { exactAmountOut = bound(exactAmountOut, 1e6, 1_000_000e6); uint256 maxAmountIn = type(uint256).max; multiliquidSwap.calculateStablecoinToStablecoin( true, false, stablecoinID1, maxAmountIn, stablecoinID3, exactAmountOut );}There should have a counter-example produced from running the test.
Recommendation (optional)
Consider calculating
protocolFeeWadas the remainder (difference), because eitheracceptanceFeeWadorredemptionFeeWadis zero for the stablecoin swap.protocolFeeWad = stablecoinInUSDWad - stablecoinOutUSDWad - redemptionFeeWad - acceptanceFeeWad;
Informational9 findings
Deprecated blacklist can be renamed for clarity on its deprecation
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description & Recommendation
Can perhaps be renamed to
DEPRECATED_MAPPINGfor clarity.Refactor slippage protection checks into the internal _calculateStablecoinToStablecoin function
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description & Recommendation
The slippage protection checks that exist in
swapStablecoinToStablecoinare absent in the corresponding calculation function.if (inputs.exactOut) { // exactOut: check that stablecoinIn required doesn't exceed maximum user is willing to spend if (outputs.stablecoinInAmount > inputs.stablecoinInAmount) { revert InsufficientStablecoinInput(); } } else { // exactIn: check that stablecoinOut received meets minimum user expects if (outputs.stablecoinOutAmount < inputs.stablecoinOutAmount) { revert InsufficientStablecoinOutput(); } }Consider refactoring these checks into the internal
_calculateStablecoinToStablecoinfunction.Redundant depositStablecoin() function
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
The delegate is not expected to be holding any assets, as the RWA and stablecoin assets should be held by the relevant custody wallets which can't be set to
address(this).Recommendation
Remove
depositStablecoin(), and consider generalising thewithdrawStablecoin()function to withdraw any ERC20 that's accidentally sent to this contract.Incorrect comment
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
Incorrect comment as token approval is not required for direct burns.
Recommendation
Remove the referenced comment.
Unused RWA_DELEGATE_ROLE
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
The
RWA_DELEGATE_ROLEis defined but unused.Recommendation
Remove the referenced role.
Deprecated blacklist functionality
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
The blacklist functionality has been deprecated in V2 and has been modified to whitelist in the delegates themselves.
Recommendation
Remove the referenced function. Since
RWADelegateis inherited byULTRADelegate, if it needs the whitelist functionality, then thestablecoinWhitelistmapping and setter function should be added.Sanity check identical RWA ID swaps
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description & Recommendation
Consier sanity checking that
rwaInID != rwaOutID.Minor wei differences due to precision differences for exact in vs out specifications
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
There are some minor differences in the
in&outamounts calculated due to truncation when specifying an amount with a stablecoin of higher decimals.In the examples below for a DAI <> USDC swap with the existing USDC delegate, it would be slightly more advantageous to specify the exact DAI output amount for USDC -> DAI (same USDC in, but get a little more out,
100e18vs99.999e18), and the exact USDC output amount for the DAI -> USDC swap (less DAI requested).function test_feeEquivalence_daiToUsdc() public { uint256 exactInDaiAmount = 100e18 + 123456789; vm.startPrank(multiliquidAdmin); multiliquidSwapV2.setStablecoinAcceptanceForSwap( daiID, dai, true ); multiliquidSwapV2.setStablecoinUSDValue(daiID, 1e18); vm.stopPrank(); vm.prank(usdcDelegateIssuerAdmin); // set acceptance fee amount for DAI -> USDC usdcDelegateV2.setStablecoinAcceptanceFee(dai, 1e18 / 100_000); // 0.001% IMultiliquidSwapV2.StablecoinToStablecoinReturnParams memory exactInParams = multiliquidSwapV2.calculateStablecoinToStablecoin( false, // exactOut true, // useDelegateForStablecoinOut daiID, exactInDaiAmount, usdcID, 0 ); IMultiliquidSwapV2.StablecoinToStablecoinReturnParams memory exactOutParams = multiliquidSwapV2.calculateStablecoinToStablecoin( true, // exactOut true, // useDelegateForStablecoinOut daiID, type(uint256).max, usdcID, exactInParams.stablecoinOutAmount ); console.log("exactInParams.stablecoinInAmount", exactInParams.stablecoinInAmount); console.log("exactOutParams.stablecoinInAmount", exactOutParams.stablecoinInAmount); console.log("exactInParams.stablecoinOutAmount", exactInParams.stablecoinOutAmount); console.log("exactOutParams.stablecoinOutAmount", exactOutParams.stablecoinOutAmount); console.log("exactInParams.protocolFeeAmt", exactInParams.protocolFeeAmt); console.log("exactOutParams.protocolFeeAmt", exactOutParams.protocolFeeAmt); console.log("exactInParams.acceptanceFeeAmt", exactInParams.acceptanceFeeAmt); console.log("exactOutParams.acceptanceFeeAmt", exactOutParams.acceptanceFeeAmt); console.log("exactInParams.redemptionFeeAmt", exactInParams.redemptionFeeAmt); console.log("exactOutParams.redemptionFeeAmt", exactOutParams.redemptionFeeAmt); // exactInParams should be == exactOutParams assertEq(exactOutParams.stablecoinInAmount, exactInParams.stablecoinInAmount); assertEq(exactOutParams.stablecoinOutAmount, exactInParams.stablecoinOutAmount); assertEq(exactOutParams.protocolFeeAmt, exactInParams.protocolFeeAmt); assertEq(exactOutParams.acceptanceFeeAmt, exactInParams.acceptanceFeeAmt); assertEq(exactOutParams.redemptionFeeAmt, exactInParams.redemptionFeeAmt);}will get
exactOutParams.stablecoinInAmountas100e18.
function test_fuzzFeeEquivalence_usdcToDaiStartWithExactOut(uint256 exactOutDaiAmount) public { exactOutDaiAmount = bound(exactOutDaiAmount, 100e18, 1_000_000e18); vm.startPrank(multiliquidAdmin); multiliquidSwapV2.setStablecoinAcceptanceForSwap( daiID, dai, true ); multiliquidSwapV2.setStablecoinUSDValue(daiID, 1e18); vm.stopPrank(); // set redemption fee amount for USDC -> DAI vm.startPrank(usdcDelegateIssuerAdmin); usdcDelegateV2.setStablecoinRedemptionFee(dai, 1e18 / 20_000); // 0.005% // set acceptance fee amount for DAI -> USDC usdcDelegateV2.setStablecoinAcceptanceFee(dai, 1e18 / 100_000); // 0.001% vm.stopPrank(); // USDC -> DAI, want to use USDC delegate, so useDelegateForStablecoinOut is false IMultiliquidSwapV2.StablecoinToStablecoinReturnParams memory exactOutParams = multiliquidSwapV2.calculateStablecoinToStablecoin( true, // exactOut false, // useDelegateForStablecoinOut usdcID, type(uint256).max, daiID, exactOutDaiAmount ); IMultiliquidSwapV2.StablecoinToStablecoinReturnParams memory exactInParams = multiliquidSwapV2.calculateStablecoinToStablecoin( false, // exactOut false, // useDelegateForStablecoinOut usdcID, exactOutParams.stablecoinInAmount, daiID, 0 ); console.log("exactInParams.stablecoinInAmount", exactInParams.stablecoinInAmount); console.log("exactOutParams.stablecoinInAmount", exactOutParams.stablecoinInAmount); console.log("exactInParams.stablecoinOutAmount", exactInParams.stablecoinOutAmount); console.log("exactOutParams.stablecoinOutAmount", exactOutParams.stablecoinOutAmount); // exactInParams should be == exactOutParams assertEq(exactOutParams.stablecoinInAmount, exactInParams.stablecoinInAmount); assertEq(exactOutParams.stablecoinOutAmount, exactInParams.stablecoinOutAmount); assertEq(exactOutParams.protocolFeeAmt, exactInParams.protocolFeeAmt); assertEq(exactOutParams.acceptanceFeeAmt, exactInParams.acceptanceFeeAmt); assertEq(exactOutParams.redemptionFeeAmt, exactInParams.redemptionFeeAmt); }will have
exactInParams.stablecoinOutAmount 99999999634500000000exactOutParams.stablecoinOutAmount 100000000000000000000Recommendation
No action required as the overall system is generally permissioned, but these small differences is something to be aware of.
Consider using burnFrom() instead of burn(address, uint256) interface
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
The
burn(address, uint256)function is non-standard; the OpenZeppelin implementation that most burnable ERC20 tokens utilise does not have it. Instead, there is theburnFrom()method.Recommendation
Replace the
burn()function withburnFrom().