Ondo Finance

Ondo: RWA internal

Cantina Security Report

Organization

@Ondofinance

Engagement Type

Cantina Solo

Period

-

Researchers


Findings

Informational

4 findings

3 fixed

1 acknowledged

Gas Optimizations

1 findings

1 fixed

0 acknowledged


Informational4 findings

  1. Redundant InvalidAddress error input

    Severity

    Severity: Informational

    Submitted by

    HickupHH3


    Description

    The invalidAddress parameter emitted together with the error is redundant because it is always the null address. Dropping it also makes it consistent with the same error defined in other contracts.

    Recommendation

    - error InvalidAddress(address invalidAddress);+ error InvalidAddress();
  2. Missing sanity check in maxOrderDuration setter

    Severity

    Severity: Informational

    Submitted by

    HickupHH3


    Description

    duration is checked to be non-zero in the LimitOrderBase constructor, but not in setMaxOrderDuration().

    Recommendation

    Add the check in the setter.

    + if (duration == 0) revert LimitOrderTypes.MaxOrderDurationZero();
  3. Missing check for smaller quote quantity for exact quote buys

    Severity

    Severity: Informational

    Submitted by

    HickupHH3


    Description

    In the case of exact quote sells, there is a max asset validation for quote.quantity to prevent overspending of the user's token allowance, but in the corresponding case of exact quote buys, there isn't one to guard against the user from receiving fewer assets than expected.

    Proof of Concept

    function test_executeOrder_orderSucceedsForSmallerQuoteQuantity() public {    uint256 expiry = block.timestamp + DEFAULT_EXPIRY;    vm.prank(orderMaker);    uint256 orderId = limitOrder.createBuyOrderExactIn(      address(gmToken),      address(usdc),      DEFAULT_QUOTE_AMOUNT,      DEFAULT_LIMIT_PRICE,      expiry    );
        vm.prank(orderMaker);    usdc.approve(address(limitOrder), DEFAULT_QUOTE_AMOUNT);        LimitOrderTypes.Quote memory quote = LimitOrderTypes.Quote({      chainId: block.chainid,      attestationId: 1,      userId: ORDER_MAKER_ID,      asset: address(gmToken),      price: DEFAULT_LIMIT_PRICE,      quantity: DEFAULT_GM_AMOUNT / 2, // Attempt to under-receive: 0.5 GM instead of 1 GM      expiration: block.timestamp + 1 hours,      side: LimitOrderTypes.QuoteSide.BUY,      additionalData: bytes32(0)    });
        bytes memory signature = _createAttestation(attesterPrivateKey, quote);
        uint256 gmAmountBefore = gmToken.balanceOf(orderMaker);    uint256 usdcAmountBefore = usdc.balanceOf(orderMaker);    vm.prank(executor);    limitOrder.executeOrder(orderId, quote, signature);
        // check amount received is DEFAULT_GM_AMOUNT / 2    assertEq(gmToken.balanceOf(orderMaker), gmAmountBefore + DEFAULT_GM_AMOUNT / 2);    // check amount spent is DEFAULT_QUOTE_AMOUNT    assertEq(usdc.balanceOf(orderMaker), usdcAmountBefore - DEFAULT_QUOTE_AMOUNT);  }

    Recommendation

    diff --git a/contracts/limit-order/LimitOrderLib.sol b/contracts/limit-order/LimitOrderLib.solindex f250f7a8..7b2a1014 100644--- a/contracts/limit-order/LimitOrderLib.sol+++ b/contracts/limit-order/LimitOrderLib.sol@@ -248,16 +248,16 @@ library LimitOrderLib {       }     } -    // Max asset quantity validation for EXACT_QUOTE SELL orders-    // Prevents executor from overspending user's asset token allowance-    if (-      order.exactType == LimitOrderTypes.ExactType.EXACT_QUOTE-        && order.side == LimitOrderTypes.QuoteSide.SELL-    ) {-      uint256 maxAsset = calculateMaxAsset(order.exactAmount, order.limitPrice, order.quoteToken);-      if (quote.quantity > maxAsset) {-        revert LimitOrderTypes.QuantityTooHigh();-      }+    // Asset quantity validation for EXACT_QUOTE orders+    // For SELL orders, prevents executor from overspending user's asset token allowance+    // For BUY orders, prevents executor from receiving fewer assets than expected+    if (order.exactType == LimitOrderTypes.ExactType.EXACT_QUOTE) {+      validateAssetQuantity(+        order.exactAmount,+        order.limitPrice,+        order.quoteToken,+        order.side,+        quote.quantity);     }   } @@ -280,16 +280,30 @@ library LimitOrderLib {       : numerator / divisor;   } -  function calculateMaxAsset(uint256 quoteAmount, uint256 limitPrice, address quoteToken)+  function validateAssetQuantity(+    uint256 quoteAmount,+    uint256 limitPrice,+    address quoteToken,+    LimitOrderTypes.QuoteSide side,+    uint256 quoteQuantity+  )     internal     view-    returns (uint256)   {     uint8 tokenDecimals = IERC20Metadata(quoteToken).decimals();     // Normalize quoteAmount to 18 decimals (USD value)     uint256 usdValue = quoteAmount * (10 ** (USD_DECIMALS - tokenDecimals));-    // maxAsset = usdValue * 1e18 / limitPrice (round up to allow for rounding tolerance)-    return (usdValue * NORMALIZER_18 + limitPrice - 1) / limitPrice;+    // assetQuantity = usdValue * 1e18 / limitPrice +    // if side is BUY, round down to allow for rounding tolerance+    // if side is SELL, round up to allow for rounding tolerance+    uint256 assetQuantity;+    if (side == LimitOrderTypes.QuoteSide.BUY) {+      assetQuantity = (usdValue * NORMALIZER_18) / limitPrice;+      if (quoteQuantity < assetQuantity) revert LimitOrderTypes.QuantityTooLow();+    } else {+      assetQuantity = (usdValue * NORMALIZER_18 + limitPrice - 1) / limitPrice;+      if (quoteQuantity > assetQuantity) revert LimitOrderTypes.QuantityTooHigh();+    }   }    // ─────────────────────────────────────────────────────────────────────────────diff --git a/contracts/limit-order/LimitOrderTypes.sol b/contracts/limit-order/LimitOrderTypes.solindex b54fe4ca..8aacd553 100644--- a/contracts/limit-order/LimitOrderTypes.sol+++ b/contracts/limit-order/LimitOrderTypes.sol@@ -251,6 +251,9 @@ library LimitOrderTypes {   /// Error emitted when quote quantity doesn't match order's exactAmount   error QuantityMismatch(); +  /// Error emitted when quote quantity is too low for EXACT_QUOTE BUY orders+  error QuantityTooLow();+   /// Error emitted when quote quantity exceeds maximum for EXACT_QUOTE orders   error QuantityTooHigh(); diff --git a/forge-tests/limit-order/GMTokenLimitOrder.t.sol b/forge-tests/limit-order/GMTokenLimitOrder.t.solindex 76e39969..64fd0138 100644--- a/forge-tests/limit-order/GMTokenLimitOrder.t.sol+++ b/forge-tests/limit-order/GMTokenLimitOrder.t.sol@@ -1418,6 +1418,39 @@ contract GMTokenLimitOrderTest is Test {     limitOrder.executeOrder(orderId, quote, signature);   } +  function test_executeOrder_revertsOnQuantityTooLow() public {+    uint256 expiry = block.timestamp + DEFAULT_EXPIRY;+    vm.prank(orderMaker);+    uint256 orderId = limitOrder.createBuyOrderExactIn(+      address(gmToken),+      address(usdc),+      DEFAULT_QUOTE_AMOUNT,+      DEFAULT_LIMIT_PRICE,+      expiry+    );++    vm.prank(orderMaker);+    usdc.approve(address(limitOrder), DEFAULT_QUOTE_AMOUNT);+    +    LimitOrderTypes.Quote memory quote = LimitOrderTypes.Quote({+      chainId: block.chainid,+      attestationId: 1,+      userId: ORDER_MAKER_ID,+      asset: address(gmToken),+      price: DEFAULT_LIMIT_PRICE,+      quantity: DEFAULT_GM_AMOUNT / 2, // Attempt to under-receive: 0.5 GM instead of 1 GM+      expiration: block.timestamp + 1 hours,+      side: LimitOrderTypes.QuoteSide.BUY,+      additionalData: bytes32(0)+    });++    bytes memory signature = _createAttestation(attesterPrivateKey, quote);++    vm.prank(executor);+    vm.expectRevert(LimitOrderTypes.QuantityTooLow.selector);+    limitOrder.executeOrder(orderId, quote, signature);+  }+   // ═══════════════════════════════════════════════════════════════════════════   // VIEW FUNCTION TESTS   // ═══════════════════════════════════════════════════════════════════════════diff --git a/forge-tests/limit-order/PortfolioTokenLimitOrder.t.sol b/forge-tests/limit-order/PortfolioTokenLimitOrder.t.solindex 29d3e622..43c20bc5 100644--- a/forge-tests/limit-order/PortfolioTokenLimitOrder.t.sol+++ b/forge-tests/limit-order/PortfolioTokenLimitOrder.t.sol@@ -198,12 +198,14 @@ contract LimitOrderLibHarness {     return LimitOrderLib.calculateQuoteAmount(quote, quoteToken);   } -  function calculateMaxAsset(uint256 quoteAmount, uint256 limitPrice, address quoteToken)-    external-    view-    returns (uint256)-  {-    return LimitOrderLib.calculateMaxAsset(quoteAmount, limitPrice, quoteToken);+  function validateAssetQuantity(+    uint256 quoteAmount,+    uint256 limitPrice,+    address quoteToken,+    LimitOrderTypes.QuoteSide side,+    uint256 quoteQuantity+  ) external view {+    LimitOrderLib.validateAssetQuantity(quoteAmount, limitPrice, quoteToken, side, quoteQuantity);   }    function createOrder(@@ -1470,6 +1472,39 @@ contract PortfolioTokenLimitOrderTest is Test {     limitOrder.executeOrder(orderId, quote, signature);   } +  function test_executeOrder_revertsOnQuantityTooLow() public {+    uint256 expiry = block.timestamp + DEFAULT_EXPIRY;++    vm.prank(orderMaker);+    uint256 orderId = limitOrder.createBuyOrderExactIn(+      address(portfolioToken),+      address(usdc),+      DEFAULT_QUOTE_AMOUNT,+      DEFAULT_LIMIT_PRICE,+      expiry+    );++    vm.prank(orderMaker);+    usdc.approve(address(limitOrder), DEFAULT_QUOTE_AMOUNT);++    LimitOrderTypes.Quote memory quote = LimitOrderTypes.Quote({+      chainId: block.chainid,+      attestationId: 1,+      userId: ORDER_MAKER_ID,+      asset: address(portfolioToken),+      price: DEFAULT_LIMIT_PRICE,+      quantity: DEFAULT_PORTFOLIO_AMOUNT / 2, // Attempt to under-deliver: 0.5 token instead of 1+      expiration: block.timestamp + 1 hours,+      side: LimitOrderTypes.QuoteSide.BUY,+      additionalData: bytes32(0)+    });+    bytes memory signature = _createAttestation(attesterPrivateKey, quote);++    vm.prank(executor);+    vm.expectRevert(LimitOrderTypes.QuantityTooLow.selector);+    limitOrder.executeOrder(orderId, quote, signature);+  }+   // ═══════════════════════════════════════════════════════════════════════════   // VIEW FUNCTION TESTS   // ═══════════════════════════════════════════════════════════════════════════@@ -2014,33 +2049,106 @@ contract LimitOrderLibTest is Test {   }    // ═══════════════════════════════════════════════════════════════════════════-  // calculateMaxAsset TESTS+  // validateAssetQuantity TESTS   // ═══════════════════════════════════════════════════════════════════════════ -  function test_calculateMaxAsset_basic() public view {-    // 150 USDC at $150/token => max 1 portfolio token-    uint256 maxPortfolio = harness.calculateMaxAsset(150e6, 150e18, address(usdc));-    assertEq(maxPortfolio, 1e18);+  // ─── SELL (ceil max; QuantityTooHigh) ───────────────────────────────────────++  function test_validateAssetQuantity_sell_exactDivision_passes() public view {+    // 150 USDC at $150/token => max 1 portfolio token; quantity == max passes+    harness.validateAssetQuantity(+      150e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.SELL, 1e18+    );+  }++  function test_validateAssetQuantity_sell_belowMax_passes() public view {+    // Quantity under the ceil max is allowed (partial asset spend)+    harness.validateAssetQuantity(+      150e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.SELL, 0.5e18+    );+  }++  function test_validateAssetQuantity_sell_roundsUp_boundary() public view {+    // 151 USDC at $150/token => 1.0066...e18, rounded up+    // usdValue = 151e18; ceil(151e18 * 1e18 / 150e18) = ceil(1.0066...e18)+    uint256 maxAsset = (uint256(151e18) * 1e18 + 150e18 - 1) / 150e18;+    assertGt(maxAsset, 1e18);++    // At the ceil boundary: passes+    harness.validateAssetQuantity(+      151e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.SELL, maxAsset+    );   } -  function test_calculateMaxAsset_roundsUp() public view {-    // 151 USDC at $150/token => slightly more than 1 token, rounds up-    uint256 maxPortfolio = harness.calculateMaxAsset(151e6, 150e18, address(usdc));-    // 151e6 * 1e12 = 151e18 USD value-    // 151e18 * 1e18 / 150e18 = 1.00666...e18, rounded up-    assertGt(maxPortfolio, 1e18);+  function test_validateAssetQuantity_sell_aboveMax_reverts() public {+    // 150 USDC at $150 => max 1e18; 1e18 + 1 reverts+    vm.expectRevert(LimitOrderTypes.QuantityTooHigh.selector);+    harness.validateAssetQuantity(+      150e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.SELL, 1e18 + 1+    );   } -  function test_calculateMaxAsset_exactDivision() public view {-    // 300 USDC at $150/token => exactly 2 tokens-    uint256 maxPortfolio = harness.calculateMaxAsset(300e6, 150e18, address(usdc));-    assertEq(maxPortfolio, 2e18);+  function test_validateAssetQuantity_sell_18decimals_passes() public view {+    // 150e18 quote18 at $150/token => max 1 portfolio token+    harness.validateAssetQuantity(+      150e18, 150e18, address(quote18), LimitOrderTypes.QuoteSide.SELL, 1e18+    );   } -  function test_calculateMaxAsset_18decimals() public view {-    // 150e18 quote18 at $150/token => 1 portfolio token-    uint256 maxPortfolio = harness.calculateMaxAsset(150e18, 150e18, address(quote18));-    assertEq(maxPortfolio, 1e18);+  function test_validateAssetQuantity_sell_18decimals_aboveMax_reverts() public {+    vm.expectRevert(LimitOrderTypes.QuantityTooHigh.selector);+    harness.validateAssetQuantity(+      150e18, 150e18, address(quote18), LimitOrderTypes.QuoteSide.SELL, 1e18 + 1+    );+  }++  // ─── BUY (floor min; QuantityTooLow) ────────────────────────────────────────++  function test_validateAssetQuantity_buy_exactDivision_passes() public view {+    // 150 USDC at $150/token => min 1 portfolio token; quantity == min passes+    harness.validateAssetQuantity(+      150e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.BUY, 1e18+    );+  }++  function test_validateAssetQuantity_buy_aboveMin_passes() public view {+    // Quantity above the floor min is allowed (more asset for the same spend)+    harness.validateAssetQuantity(+      150e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.BUY, 2e18+    );+  }++  function test_validateAssetQuantity_buy_roundsDown_boundary() public view {+    // 151 USDC at $150/token => floor(1.0066...e18) = 1.0066...e18 truncated+    // usdValue = 151e18; floor(151e18 * 1e18 / 150e18)+    uint256 minAsset = (uint256(151e18) * 1e18) / 150e18;+    assertGt(minAsset, 1e18);++    // At the floor boundary: passes+    harness.validateAssetQuantity(+      151e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.BUY, minAsset+    );+  }++  function test_validateAssetQuantity_buy_belowMin_reverts() public {+    // 150 USDC at $150 => min 1e18; 1e18 - 1 reverts+    vm.expectRevert(LimitOrderTypes.QuantityTooLow.selector);+    harness.validateAssetQuantity(+      150e6, 150e18, address(usdc), LimitOrderTypes.QuoteSide.BUY, 1e18 - 1+    );+  }++  function test_validateAssetQuantity_buy_18decimals_passes() public view {+    harness.validateAssetQuantity(+      150e18, 150e18, address(quote18), LimitOrderTypes.QuoteSide.BUY, 1e18+    );+  }++  function test_validateAssetQuantity_buy_18decimals_belowMin_reverts() public {+    vm.expectRevert(LimitOrderTypes.QuantityTooLow.selector);+    harness.validateAssetQuantity(+      150e18, 150e18, address(quote18), LimitOrderTypes.QuoteSide.BUY, 1e18 - 1+    );   }    // ═══════════════════════════════════════════════════════════════════════════
  4. Consider using execution price instead of limit price for validation check

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    HickupHH3


    Description

    EXACT_QUOTE quantity guard checks use the order's limitPrice instead of the execution price (which has been checked to be at least better than limitPrice), resulting in a looser bound.

    Proof of Concept

    The test below should revert, but it doesn't.

    function test_executeOrder_shouldUseExecutionPriceForValidationInsteadOfLimitPrice() public {    uint256 expiry = block.timestamp + DEFAULT_EXPIRY;
        vm.prank(orderMaker);    uint256 orderId = limitOrder.createSellOrderExactOut(      address(portfolioToken),      address(usdc),      DEFAULT_QUOTE_AMOUNT,      DEFAULT_LIMIT_PRICE,      expiry    );
        vm.prank(orderMaker);    portfolioToken.approve(address(limitOrder), DEFAULT_PORTFOLIO_AMOUNT);
        LimitOrderTypes.Quote memory quote = LimitOrderTypes.Quote({      chainId: block.chainid,      attestationId: 1,      userId: ORDER_MAKER_ID,      asset: address(portfolioToken),      price: DEFAULT_LIMIT_PRICE * 105 / 100, // Set positive slippage, above limit price      quantity: DEFAULT_PORTFOLIO_AMOUNT, // Default amount is actually an overspend due to looser bound, should be lowered      expiration: block.timestamp + 1 hours,      side: LimitOrderTypes.QuoteSide.SELL,      additionalData: bytes32(0)    });    bytes memory signature = _createAttestation(attesterPrivateKey, quote);
        vm.prank(executor);    vm.expectRevert(LimitOrderTypes.QuantityTooHigh.selector);    limitOrder.executeOrder(orderId, quote, signature);}

    Recommendation

    Replace order.limitPrice with quote.price.

Gas Optimizations1 finding

  1. Clamping is redundant due to cap on devBps

    Severity

    Severity: Gas optimization

    Submitted by

    HickupHH3


    Description

    Clamping the subtraction is redundant because devBps has a cap of MAX_ALLOWED_DEVIATION_BPS = 10_000. Furthermore, with solidity's division truncation, it is guaranteed that the subtraction will not underflow.

    Recommendation

    - uint256 lowerAum = aum > delta ? aum - delta : 0;+ uint256 lowerAum = aum - delta;