CertiK reviewed XRP Ledger (XRPL AMM) on 21 Jun 2023: 31 findings, 0 critical; 29 marked fixed, 2 open.
Report hosted on skynet.certik.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 0 |
| Medium | 0 | 0 | 8 |
| Low | 2 | 0 | 21 |
`AMMDeposit(tfTwoAsset)` allows specifying `amount` and `amount2`. They are used as maximum values consumed by the deposit operation. However, that doesn't allow the user to set the protection from front-running. Other implementations of AMM, like Uniswap, allow the user to set the minimum amounts accepted: ```solidity function addLiquidity( address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin, address to, uint deadline ``` If `amountAMin`/`amountBMin` conditions are not satisfied, the transaction is rejected. This effectively protects the user from front-running and sudden pool spot-price changes for other reasons. Other modes of `AMMDeposit` also don't provide protection from price fluctuations.
```cpp=242 if (computedPrice >= *bidMin && computedPrice <= *bidMax) return computedPrice; JLOG(ctx_.journal.debug()) << "AMM Bid: not in range " << computedPrice << *bidMin << " " << *bidMax; ``` `AMMBid` allows setting `sfBidMin` and `sfBidMax`. `computedPrice` is calculated based on the current slot state. The code doesn't allow raising the bid via setting `sfBinMin` if `sfBidMax` is also specified.
```cpp=81 cur.out = toAmount<TOut>( getIssue(balances.out), cur.out * fib[ammContext_.curIters() - 1], Number::rounding_mode::downward); cur.in = swapAssetOut(balances, cur.out, tradingFee_); ``` `generateFibSeqOffer()` is supposed to generate AMM offers with the offer size based on Fibonacci sequence. The starting `cur.in = InitialFibSeqPct * initialBalances_.in = 0.025% of initialBalances_.in`. `cur.out` is approximately the same. Then `cur.out` is multiplied by `fib` elements based on the iteration. Since `fib[17] = 4181`, `cur.out` will get bigger than `balances.out`. That is unexpected by `swapAssetOut()` - `cur.in` will become negative or overflow.
It is possible to perform a single asset withdrawal via `AMMWithdraw(tfOneAssetLPToken)`. However, the formula (8) is inaccurate. It is more advantageous for the user to perform `AMMWithdraw(tfLPToken)` and then swap the redundant token.
In `AMMWithdraw::preclaim()` if `sfAmount`/`sfAmount2` are set, it is checked that * account is authorized to hold the tokens and the trustline exists * AMM account or currency is not frozen * account is not frozen However, if `tfLPToken` is set, it is not checked that account is authorized to hold the tokens. If `tfWithdrawAll` is set, all three checks are skipped. As a result, the frozen account can get tokens from AMM or tokens can be extracted from frozen AMM.
It is possible to perform a single asset deposit via `AMMDeposit(tfSingleAsset)`. However, the formulas [(3) and (4)](https://github.com/XRPLF/XRPL-Standards/discussions/78) are inaccurate. Fees are always taken for half of the deposited asset and pool value change due to fees is not accounted for. The variable amount should be virtually swapped with the corresponding fee taken. So the users will prefer `AMMDeposit(tfSingleAsset)` then `AMMWithdraw(tfLPToken)` instead of normal swap to save some fees. Current logic behind single asset deposit formula **(3)**: $$\Delta_{LPTokens} = \Gamma_{LPTokens} * \left[ \left(1+ \frac{\Delta_{B}-TFee*(1-W_B)*\Delta_B}{\Gamma_B} \right) ^ {W_B} - 1 \right] $$ 1. The deposited amount $$ \Delta_B $$ is split into $$ W_B\Delta_B + (1-W_B)\Delta_B = W_B\Delta_B + W_A\Delta_B$$ 2. Then $$W_A\Delta_B$$ of AssetB is virtually swapped for AssetA 3. Pool spot-price $$ SP = \frac{\frac{\Gamma_A}{W_A}}{\frac{\Gamma_B}{W_B}} $$ (see **(II)**) is used to get $$ \Delta_A = W_B\Delta_B \frac{\Gamma_A}{\Gamma_B}$$ 4. Two assets $$\left( \Delta_A, W_B\Delta_B \right) = \left( W_B\Delta_B \frac{\Gamma_A}{\Gamma_B} , W_B\Delta_B \right)$$ are deposited to pool with $$\left( \Gamma_A, \Gamma_B \right)$$ reserves 5. The pool ratio $$ \frac{\Gamma_A}{\Gamma_B} $$ (and therefore pool spot-price) remains the same since $$ \frac{\Gamma'_A}{\Gamma'_B} = \frac{\Gamma_A + W_B\Delta_B \frac{\Gamma_A}{\Gamma_B}}{\Gamma_B + W_B\Delta_B} = \frac{\Gamma_A}{\Gamma_B} $$ 6. Since we virtually swapped $$W_A\Delta_B$$, only $$ TFee W_A $$ was taken, that's why $$ \Delta_B $$ in **(3)** is reduced by $$ \left( 1-TFee(1-W_B)\right) $$ However, the pool state changes during the swap operation, so we can't perform the swap using the spot-price and then perform a deposit using the same spot-price. In case $$ \Delta_B $$ is comparable to $$ \Gamma_B $$ and $$ Tfee $$ is significant, the resulting $$ \Delta_{LPTokens} $$ will be higher than obtainable via real swap and `AMMDeposit(tfTwoAsset)`. See the example in the Scenario section. The proposed logic of single asset deposit is: 1. Let $$ W_A = W_B = 0.5 $$ 2. We want to split $$ \Delta_B $$ into two parts $$ x + (\Delta_B - x) $$ and swap $$ x $$ for $$ \Delta_A = \Gamma_A \frac{x(1-TFee)}{\Gamma_B + x(1-TFee)} $$ (see **(9)**) 3. The $$x$$ should be so that the final pool reserves ratio $$\frac{\Gamma_A}{\Gamma_B+\Delta_B} $$ is equal to the ratio of assets deposited $$ \frac{\Delta_A}{\Delta_B - x} $$ and is equal to the pool reserves ratio after the swap $$ \frac{\Gamma_A - \Delta_A}{\Gamma_B + x} $$ 4. Using $$ \frac{\Gamma_A}{\Gamma_B + \Delta_B} = \frac{\Delta_A}{\Delta_B-x} $$ and substituting $$ \Delta_A $$ we get $$ (\Delta_B - x)(\Gamma_B + x(1-TFee)) = (\Gamma_B + \Delta_B)x(1-TFee) $$ 5. Solving it gives $$ x = \Gamma_B (\sqrt{ \left( \frac{1-TFee/2}{1-TFee} \right) ^2 + \frac{\Delta_B}{\Gamma_B(1-TFee)}} - \frac{1-TFee/2}{1-TFee} )$$ 6. After the swap of $$ x $$ of AssetB for $$ \Delta_A $$ of AssetA, we perform all assets deposit with $$ \Delta_{LPTokens} = \Gamma_{LPTokens} \frac{\Delta_A}{\Gamma_A - \Delta_A} = \Gamma_{LPTokens} \frac{\Delta_B-x}{\Gamma_B + x} $$ The final formula instead of **(3)** would be: $$ \Delta_{LPTokens} = \Gamma_{LPTokens} \frac{\Delta_B-x}{\Gamma_B + x} = \Gamma_{LPTokens} \frac{ \frac{\Delta_B}{\Gamma_B} - (\sqrt{ \left( \frac{1-TFee/2}{1-TFee} \right) ^2 + \frac{\Delta_B}{\Gamma_B(1-TFee)}} - \frac{1-TFee/2}{1-TFee} ) }{1 + {\sqrt{ \left( \frac{1-TFee/2}{1-TFee} \right) ^2 + \frac{\Delta_B}{\Gamma_B(1-TFee)}} - \frac{1-TFee/2}{1-TFee} } } $$
It is possible to perform a single asset withdrawal via `AMMWithdraw(tfSingleAsset)`. However, the formulas [(7) and (8)](https://github.com/XRPLF/XRPL-Standards/discussions/78) are inaccurate. It is more advantageous for the user to perform `AMMWithdraw(tfLPToken)` and then swap the redundant token. We propose to follow this logic: 1. Our goal is to get $$ \Delta_B = b_{withdrawn} + b_{swapped} $$ 2. Assuming we first withdraw $$ x $$ fraction of $$ \Gamma_{LPTokens} $$ 3. That gives $$ (x \Gamma_A, x\Gamma_B) $$ and pool in state $$ ((1-x)\Gamma_A, (1-x)\Gamma_B) $$ 4. Using **(9)** we can swap $$ x\Gamma_A $$ of AssetA for $$ b_{swapped} = \Gamma'_B \frac{x\Gamma_A(1-TFee)}{\Gamma'_A + x\Gamma_A(1-TFee)} $$ of AssetB 5. Using $$ \Delta_B = b_{withdrawn}+ b_{swapped} = x\Gamma_B + \Gamma'_B \frac{x\Gamma_A(1-TFee)}{\Gamma'_A + x\Gamma_A(1-TFee)} $$ we get $$ \Delta_B - x\Gamma_B = (1-x)\Gamma_B \frac{x\Gamma_A(1-TFee)}{(1-x)\Gamma_A + x\Gamma_A(1-TFee)} $$ 6. Simplifying that we get $$ (\frac{\Delta_B}{\Gamma_B} - x)(1-x + x(1-TFee)) = (1-x)x(1-TFee)$$ 7. Which gives $$ x^2 + x(TFee - 2 - \frac{\Delta_B}{\Gamma_B}Tfee) + \frac{\Delta_B}{\Gamma_B} = 0 $$ 8. Updated **(7')** is $$ \frac{\Delta_{LPTokens}}{\Gamma_{LPTokens}}= x = \frac{ \frac{\Delta_B}{\Gamma_B}Tfee + 2 - TFee - \sqrt{(\frac{\Delta_B}{\Gamma_B}Tfee + 2 - TFee)^2 - 4\frac{\Delta_B}{\Gamma_B}}}{2} $$ With current formulas, users should never `AMMWithdraw(tfSingleAsset)` to prevent extra spending.
`MinSlotPrice` is set to zero. So, if `bidMin` is not specified, `sfPrice` is also set to zero according to the formula. There is no motivation for arbitrageurs to raise the price. They can always buy a slot for zero, perform the arbitrage and give up the slot to the next participant for free.
```h=168 JSS(asset_frozen); // in: amm_info JSS(asset2_frozen); // in: amm_info ``` `asset_frozen` and `asset2_frozen` fields are described as `in`, however, they are in fact `out`. `EPrice` is used not only by `AMMDeposit`, but also by `AMMWithdraw`.
```cpp=151 if (bidMin && bidMax && bidMin >= bidMax) ``` It is disallowed to have `bidMin == bidMax` in `AMMBid::preclaim()`, however, that is a valid scenario: the user wants to pay exactly `bidMin`, not more.
`invalidAMMAmount()` has the argument `nonNegative` which is `false` by default. The comment states: ```cpp=60 * If zero is false and amount is beast::zero then invalid amount. ``` The code is: ```cpp=101 if (!nonNegative && amount <= beast::zero) return temBAD_AMOUNT; return tesSUCCESS; ``` As a result, the meaning of `!nonNegative` is `mustBePositive`. However, in math "nonNegative" means "zero or positive". Providing `true` as an argument to the function allows the `amount` to be zero. Using negations in bool variable names and negation of the variable itself decreases the code readability.
```cpp=652 auto const amount2Withdraw = amount2Balance * frac; ``` `multiply(STAmount, STAmount, Issue)` performs some checks and Number normalization, however, it is not used in `AMMWithdraw::equalWithdrawTokens()`.
`sfDiscountedFee` is stored as `UINT32`, but `sfTradingFee` as `UINT16`. Discounted fee is supposed to be less or equal to normal trading fee. It is unclear why bigger type used.
The TradingFee of the AMM instance is a votable parameter. Any account that holds the corresponding LPTokens can cast a vote using the new `AMMVote` transaction. During `AMMVote` processing the weights of all the votes are recomputed, and the TradingFee for the AMM instance is updated. Up to 8 voting slots supported. However, `sfVoteSlots` is only updated if a voter is part of it. Consider the scenario: - The Griefer obtains a big amount of LPTokens via `AMMDeposit`. - The Griefer separates the amount into 8 equal parts, distributes it among 8 accounts, and performs `AMMVote(1%)` from each. - All 8 `sfVoteSlots` are now occupied by an account with a significant weight. - The Griefer combines the LPTokens and performs `AMMWithdraw`. - As a result, **no one can refresh the votes array** until they get a significant LPTokens balance.
`std::chrono` is not used in `ammAuctionTimeSlot()`.
In `changeSpotPriceQuality()` and `withdrawByTokens()` the argument `tFee` is declared as `uint32_t`. In other code it is `uint16_t`.
`tfWithdrawAll` flag is checked in `AMMDeposit::preclaim()`, however, it can't be used with `AMMDeposit`.
The current slot price is calculated by the formula: $$ f(t) = B * 1.05*(1-t^{60}) + M $$ According to the description: "The slot price approaches `M` as the slot approaches expiration (i.e., as `t` approaches 1)." However, 1. During the first 18 slots the price drops gradually from $$ \approx B * 1.05 + M $$ (with `t = 0.05`) to $$ \approx B * 1.048 + M $$ (with `t = 0.90`) 2. Then drops significantly to $$ \approx B + M $$ (with `t = 0.95`) 3. And then drops to $$ M $$, which is currently set to zero So, the speed of descent is highly uneven.
```cpp=192 return (xrpLiquid(ctx.view, accountID, !sle, ctx.j) >= deposit) ? TER(tesSUCCESS) : (!sle ? tecINSUF_RESERVE_LINE : tecAMM_UNFUNDED); ``` Expressions with ternary conditional operators are harder to read. The expression can be simplified using regular `if`s and `return`.
```cpp=122 // Returns the amount an account can spend of the currency type saDefault, or // returns saDefault if this account is the issuer of the the currency in // question. Should be used in favor of accountHolds when questioning how much // an account can spend while also allowing currency issuers to spend // unlimited amounts of their own currency (since they can always issue more). [[nodiscard]] STAmount accountHolds( ReadView const& view, ``` The comment describes `accountFunds()` function, not `accountHolds()`. ```cpp=38 * specified by Asset1In. This is essentially an equal asset deposit * and a swap. ``` In fact, that is a swap and a deposit. ```cpp=48 * a. amount of asset1 if specified in Asset1In specifies the maximum * amount of asset1 that the trader is willing to deposit ``` `amount` is always specified in this mode, however, it can be zero. ```cpp=80 /** Attempt to create the AMM instance. */ ``` The same comment is used in `AMMWithdraw.h`, `AMMDeposit.h`, and other files. It should be removed or updated. ```cpp=535 * Use equation 1 to compute the amount of , given the amount in Asset1In. ``` `t` is missing. ```cpp=153 * @param ctx ``` There is no argument `ctx`.
```cpp=497 JLOG(j.fatal()) << "Invariant failed: account root created " "by a non-Payment or by an unsuccessful transaction"; ``` `ValidNewAccountRoot::finalize()` error message assumes the account can only be created by `ttPAYMENT` transaction. However, `ttAMM_CREATE` should also be mentioned. ```cpp=309 JLOG(j_.debug()) << "AMM Instance: success " << *ammAccount << " " << ammKeylet.key << " " << lptIss << " " << amount << " " << amount2; auto addOrderBook = ``` Using `lpTokens` instead of `lptIss` is preferable. ```cpp=245 << "AMM Bid: not in range " << computedPrice << *bidMin << " " ``` " " is absent between values.
```cpp=567 auto const [withdrawAmount, withdraw2Amount] = getAmounts(frac); ``` ```cpp=577 auto const [withdrawAmount, withdraw2Amount] = getAmounts(frac); ``` `withdrawAmount` and `withdraw2Amount` are shadowed by the local variable with the same names.
```cpp=153 auto const timeSlot = ammAuctionTimeSlot( ledger->info().parentCloseTime.time_since_epoch().count(), auctionSlot); auction[jss::time_interval] = timeSlot ? *timeSlot : 0; ``` `ammAuctionTimeSlot()` returns 0-19 in case the auction has not yet expired and `std::nullopt` otherwise. In case of expired `auction[jss::time_interval]` is set 0. The same value is set during the first 1.2 hours. That could be misleading.
```cpp=218 auto res = redeemIOU(sb, account_, saBurn, lpTokens.issue(), ctx_.journal); if (res != tesSUCCESS) { JLOG(ctx_.journal.debug()) << "AMM Bid: failed to redeem."; return res; } ammSle->setFieldAmount(sfLPTokenBalance, lptAMMBalance - saBurn); ``` `saBurn` LPTokens are redeemed from `account_` and then `sfLPTokenBalance` is reduced. However, these values can be different due to rounding error.
```cpp=73 constexpr std::uint32_t fib[AMMContext::MaxIterations] = { 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040}; ``` `fib[29]` is zero because the initializer list has only 29 elements. This can be unexpected.
```cpp=296 // Refund the previous owner. If the time slot is 0 then // the owner is refunded full amount. auto const refund = fractionRemaining * pricePurchased; if (refund > *payPrice) ``` Despite the comment states that the owner is refunded full amount during slot 0, in fact, `fractionRemaining` is maximum 0.95 and the owner always looses money even if outbid in the first second.
Different magic numbers are used as-is in code.
The TradingFee of the AMM instance is a votable parameter. Any account that holds the corresponding LPTokens can cast a vote using the new `AMMVote` transaction. During `AMMVote` processing the weights of all the votes are recomputed, and the TradingFee for the AMM instance is updated. Up to 8 voting slots supported. The voter doesn't know what `sfTradingFee` to set. Consider the scenario: - Imagine that a Voter with weight 10 wants to set the fee to 0.5%. - At that moment all other voters with a sum weight of 10 voted for a 1% fee. - So, Voter has to submit `AMMVote(0%)` to get the desired 0.5%. - After some time other voters change the desired fee to 0%. - The Voter has to re-submit `AMMVote(1%)` to get the desired 0.5%. - As a result, the voter **can't set the desired fee** but rather sets the value that balances the other votes instead. - This can lead to more `AMMVote` submitted than expected.
The argument `deposit` of `AMMDeposit::deposit()` should be named `view`. `lptTokensAMMBalance` should be named `lptAMMBalance`.
The report names a repository or files. Open the report to see its scope.
1,044 projects reviewed · 1,375 reports · latest May 2025
See CertiK’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us