Organization
- @Ondofinance
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Low Risk
2 findings
1 fixed
1 acknowledged
Informational
3 findings
1 fixed
2 acknowledged
Gas Optimizations
1 findings
1 fixed
0 acknowledged
Low Risk2 findings
Batch execution of limit orders can be DOS'ed
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Chinmay Farkya
Description
The
executeOrderBatch()function is used to fill multiple orders at once, and it follows a complete-or-fail approach. If any of the orders' execution reverts, the whole batch reverts.There are multiple ways in which users can purposefully DOS a batch execution by manipulating one of their orders included in that batch.
- User can frontrun
executeOrderBatch()to cancel their own order, which marks it asCANCELLEDand reverts - A token approval from user to the LimitOrder contract is required in order to proceed with order execution, but the tokens are not escrowed and remain in the user's wallet until execution. Thus, a user can frontrun
executeOrderBatch()to revoke their token approval at the last moment, thus reverting the whole batch transaction.
The problem here is that the batch will also include orders from other users, whose execution can be repeatedly made to fail.
Recommendation
Consider implementing
executeOrderBatch()in such a way that if any of the orders' execution fails due to any reason, the logic can continue to execute other orders successfully.Ondo
Acknowledged: We intentionally chose atomic batch execution to avoid the indeterminate control flow introduced by a try/catch pattern. Monitoring and operational mitigations are sufficient from our view - failed batches can be resubmitted excluding the problematic order, and persistent bad actors can be removed via admin cancellation or compliance revocation. Additionally, executors can always revert to single-order execution as a fallback.
Cantina
Acknowledged.
Precision loss due to sequential divisions in calculateQuoteAmount() can undercharge BUY orders
Severity
- Severity: Low
Submitted by
Giovanni Di Siena
Description
LimitOrderLib::calculateQuoteAmountsuffers from precision loss due to intermediate truncation caused by performing two sequential divisions instead of a single combined division.quantity * priceis first divided by 18, then further scaled up by the token decimals before the conditional rounding logic is applied; however, performing the computation in two division steps introduces intermediate truncation that can silently discard fractional value before the rounding logic executes. This edge case manifests for USDC when(quantity * price) % 1e12is zero and can result inBUYorders being undercharged by one wei.Proof of Concept
The following test should be added to
GMTokenLimitOrder.t.sol:function test_calculateQuoteAmount_precisionLoss_USDC() public view { // Example values: // quantity = 1e17 (0.1 GM token) // price = 1e18 + 1 ($1.000000000000000001) // quantity * price = 1e35 + 1e17 // q = 1e17, r = 1e17 // // Current Implementation: // Step 1: usdValue = (1e35 + 1e17) / 1e18 // usdValue = floor(100000000000000000.1) // usdValue = 1e17 [LOST: remainder 1e17] // // Step 2: scaled = 1e17 * 1e6 = 1e23 // // Step 3: result = ceil(1e23 / 1e18) // result = ceil(100000.0) ← EXACT INTEGER // result = 100000 // // Correct Implementation: // numerator = (1e35 + 1e17) * 1e6 = 1e41 + 1e23 // result = ceil((1e41 + 1e23) / 1e36) // = ceil(100000.0000000000001) ← HAS FRACTIONAL PART // = 100001 // // USDC Result: Current = 100000, Correct = 100001 // LOSS OF 1 UNIT ($0.000001) uint256 quantity = 1e17; uint256 price = 1e18 + 1; IGMTokenManager.Quote memory quote = IGMTokenManager.Quote({ chainId: block.chainid, attestationId: 1, userId: TEST_USER_ID, asset: address(gmToken), price: price, quantity: quantity, expiration: block.timestamp + 1 hours, side: IGMTokenManager.QuoteSide.BUY, additionalData: bytes32(0) }); // Current implementation result uint256 currentResult = harness.calculateQuoteAmount(quote, address(usdc)); // Correct implementation result uint256 numerator = quantity * price * 1e6; uint256 correctResult = (numerator + 1e36 - 1) / 1e36; assertEq(currentResult, 100000, "Current implementation should return 100000"); assertEq(correctResult, 100001, "Correct implementation should return 100001"); assertApproxEqAbs(correctResult, currentResult, 1, "Implementations should differ by 1 wei");}Recommendation
Consider refactoring to perform all arithmetic in a single division operation, preserving full precision until the final rounding step. The corrected implementation combines the scaling factor into the numerator and uses a single denominator to ensure that no intermediate remainder is discarded before the rounding decision is made, resulting in correct ceiling behavior for
BUYorders and floor behavior forSELLorders across all quote token decimal configurations.Ondo
Fixed in commit f760a19.
Cantina
Verified. The computation has been combined into single division to avoid intermediate truncation precision loss.
Informational3 findings
LimitOrderLib::calculateQuoteAmount can round down to zero for small buy orders
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
The calculation of
usdValuewithinLimitOrderLib::calculateQuoteAmountcan round down to zero for a sufficiently smallquote.quantity * quote.pricedividend. This can result in the entire function returning zero, as the intended rounding up behavior for buy side orders is not applied. In the absence ofminimumDepositUSDvalidation withinGMTokenManager, this could be abused to systematically extract value from the protocol without paying any quote token.Recommendation
Ensure that
minimumDepositUSDis always configured to be non-zero.Ondo
Acknowledged: The
minimumDepositUSDparameter inGMTokenManagerwill always be configured to a non-zero value in production. There is no operational reason to ever allow ~zero-value deposits.Cantina
Acknowledged.
Partial fills of buy orders can result in user losses due to the absence of refunds
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
LimitOrderLib::validateExecutionperforms validation to protect users from overspending the GM token allowance intended forEXACT_QUOTEsell orders; however, there is no equivalent validation forEXACT_QUOTEbuy orders. The earlier price check ensuresquote.price <= limitPrice, but there is no check that the user receives a minimum amount of GM tokens. Without it, an executor can provide a quote with a valid price (at or below the limit) but aquote.quantitymuch lower than expected. Consider the following scenario:LimitOrderLib::executeBuypullsorder.exactAmount(e.g., 100 USDC) from the user.- Calls
gmTokenManager.mintWithAttestation(quote, sig, USDC, 100e6). GMTokenManagercalculatesmintUSDonValue = quote.quantity * quote.price(e.g., 5 × $10 = $50).- Converts all 100 USDC -> 100 USDon.
- Uses only 50 USDon for minting, refunds 50 USDon to
GMTokenLimitOrder. - Mints 5 GM tokens to
GMTokenLimitOrder. GMTokenLimitOrdertransfers 5 GM to the user.
To summarise, the user pays 100 USDC but receives only 5 GM tokens worth ~$50 while the excess 50 USDon remains stuck in
GMTokenLimitOrder.It is understood that this scenario can, in practice, occur only if the market gaps down, in which case the protocol still acts as expected despite being potentially unfavorable for users. In any case, the question remains whether excess funds should be refunded to the user. Currently, it is intended to avoid returning small balances of a separate token to avoid confusion, e.g.
createSellOrderExactOut()will return exactly the USDC specified, even if the swap itself results in slightly more being received. However, given that there is nothing strictly preventing partial fills in the above edge case, the discrepancy between the returned and refunded amounts could be significant.Recommendation
Consider returning refunded tokens to the user.
Ondo
Acknowledged. This contract is purpose-built for a streamlined UX flow. Under nominal market conditions, any USDon refund would be negligible (well under 1 cent), and automatically returning a secondary token on every trade would only confuse users. For the unlikely edge case of a significant market gap on these tradfi-backed assets, any material excess can be retrieved via
retrieveTokens()and returned to the affected user manually. We accept this operational trade-off to preserve clean UX for the common case.Cantina
Acknowledged.
Incorrect mocked quote typehash
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
While it does not affect the existing tests, the mocked quote typehash is incorrect and should be updated to match the actual implementation.
Recommendation
Use
bytes32 additionalDatain place ofbytes additionalData.Ondo
Fixed in commit dd6e841.
Cantina
Verified.
Gas Optimizations1 finding
ReentrancyGuardTransient can be used to save gas on all nonReentrant calls
Severity
- Severity: Gas optimization
Submitted by
Chinmay Farkya
Description
The GMTokenLimitOrder.sol contract uses ReentrancyGuard from OpenZeppelin.
There is a more gas efficient library for blocking reentrancies: ReentrancyGuardTransient.
Recommendation
Consider using ReentrancyGuardTransient.
Ondo
Fixed in commit 201e466.
Cantina
Verified.