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
Redundant InvalidAddress error input
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
The
invalidAddressparameter 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();Missing sanity check in maxOrderDuration setter
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
durationis checked to be non-zero in theLimitOrderBaseconstructor, but not insetMaxOrderDuration().Recommendation
Add the check in the setter.
+ if (duration == 0) revert LimitOrderTypes.MaxOrderDurationZero();Missing check for smaller quote quantity for exact quote buys
State
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
In the case of exact quote sells, there is a max asset validation for
quote.quantityto 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+ ); } // ═══════════════════════════════════════════════════════════════════════════Consider using execution price instead of limit price for validation check
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
HickupHH3
Description
EXACT_QUOTEquantity guard checks use the order'slimitPriceinstead of the execution price (which has been checked to be at least better thanlimitPrice), 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.limitPricewithquote.price.
Gas Optimizations1 finding
Clamping is redundant due to cap on devBps
State
Severity
- Severity: Gas optimization
Submitted by
HickupHH3
Description
Clamping the subtraction is redundant because
devBpshas a cap ofMAX_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;