Incident,
Source: De.Fi incident database.
- Audits before it
- 4.
- Audits since
- None on Trustblock.
As of 3 Nov 2026, Saddle has 4 audit reports by 2 security firms on Trustblock; the latest is by Quantstamp, dated 24 Sep 2021. Those reports list no open critical findings. It also holds 2 incidents (latest 29 Apr 2022).
Record last updated . It holds audits, findings and contracts up to Nov 2026, and incidents up to Oct 2026. Trustblock does not review code.
Newest first
| Audit | Security firm | Date | Scope | Critical findings | Report |
|---|---|---|---|---|---|
| Saddle Token | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
| Saddle Finance VirtualSwap | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
| Saddle Contracts Audit | Security firm: OpenZeppelin | Date: | Scope: Files named in the report | No open critical findings | Open · blog.openzeppelin.com |
| Saddle Finance | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
Statuses are the security firm’s own, from each report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 1 |
| Medium | 1 | 2 | 6 |
| Low | 3 | 6 | 29 |
The `SwapUtils.getD` function contains an incorrect `add(1)` for the denominator of `dP` inside the inner `for`-loop, on L241. This will lead to an incorrect value of `D` being returned every time `SwapUtils.getD` is called.
Open the reportThe comment on L275-276 states: "Reverts if the given `itemId` does not represent a `synthToToken` or a `tokenToSynth` swap." However, the function checks `swapType > PendingSwapType.TokenToSynth`, which allows either `SynthToToken` or `TokenToToken` swap types.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportInteger overflow/underflow occur when an integer hits its bit-size limit. Every integer has a set range; when that range is passed, the value loops back around. A clock is a good analogy: at 11:59, the minute hand goes to 0, not 60, because 59 is the largest possible minute. Integer overflow and underflow may cause many unexpected kinds of behavior and was the core reason for the `batchOverflow` attack. <br> The subtraction inside `SwapUtils.withdrawAdminFees` does not use `SafeMath`, which could cause an underflow if `token.balanceOf(address(this)) < self.balances[i]`. The result of the underflow would then be assigned to the `uint256 balance` variable, which would be positive and therefore always pass the check on the subsequent line: `if (balance > 0)`. <br> A similar underflow issue occurs on L504 inside `SwapUtils.getAdminBalance`.
Open the reportThe following functions are missing checks for user input: 1. **[Fixed]** The `Swap.getToken` function does not check if the value of the `index` parameter is lower than the length of the `pooledTokens` array. 2. **[Fixed]** The `Swap.getTokenBalance` function does not check if the value of the `index` parameter is lower than the length of the `pooledTokens` array. 3. **[Fixed]** The `Swap.calculateSwap` function does not check if value of the 2 `tokenIndex{From | To}` parameters are lower than the length of the `pooledTokens` array. It also does not check if the value of the `dx` input parameter is lower than the amount of tokens that the user actually has. 4. **[Fixed]** The `Swap.calculateRemoveLiquidity` function does not check if value of the `amount` parameter is larger than the total supply of the `swapStorage.lpToken`. 5. **[Fixed]** The `Swap.calculateRemoveLiquidityOneToken` function does not check if the value of the `tokenIndex` parameter is lower than the length of the `pooledTokens` array. It also doesn't check if the value of the `tokenAmount` is lower than the actual amount available for that token. 6. **[Fixed]** The `Swap.getAdminBalance` function does not check if the value of the `index` parameter is lower than the length of the `pooledTokens` array. 7. **[Fixed]** The `Swap.swap` function does not check if value of the 2 `tokenIndex{From | To}` parameters are lower than the length of the `pooledTokens` array. It also does not check if the value of the `dx` input parameter is lower than the amount of tokens that the user actually has. 8. **[Fixed]** There should be checks for `poolAddress != address(0x0)` in the functions `Allowlist.setPoolAccountLimit`, and `Allowlist.setPoolCap`. 9. **[Fixed]** The `Swap.constructor` does not check if the length of the `_pooledTokens.length > 1`. This is an implicit assumption, which should be made explicit. 10. **[Fixed]** The `Swap.constructor` does not check if all the addresses provided in `_pooledTokens` are different. This would allow pools with multiple tokens, where the tokens have the same address. The consequences of an exploit of the aforementioned items vary. However, some of the items mentioned above could significantly impact the reputation of the project and should be therefore addressed.
Open the reportThe constant `DURATION = 2 * (52 weeks)` is not documented. Since the provided requirement states that "transfer is blocked until 52 weeks pass...", it is not clear if this is defined correctly.
Open the reportGiven that the constructor is using a loop to distribute the initial amount of the token inside `Vesting()` contracts, there is the concern that given an enough long list of recipients the constructor will run out of gas. Also, there is no other way to include further recipients afterward if the list of recipients list has to be shortened.
Open the reportSince [Solidity 0.8.x does not perform safe casting by default](https://github.com/ethereum/solidity/issues/10195), there is a potential integer overflow on L96 in `RetroactiveVesting.sol` where a summation of 2 `uint256` variables is cast to `uint120`.
Open the reportOn `rescueTokens()` there is a check to avoid transferring 0 tokens, but there is a check missing to transfer 0 ETH.
Open the report1. `Vesting.initialize` should ensure that `_token` is non-zero and `_durationInSeconds` is non-zero (the latter is to avoid a possible divide-by-zero in `vestedAmount`). 2. `RetroactiveVesting.constructor` should ensure that all three address arguments are non-zero. 3. `SDL.constructor` should check that `_pausePeriod > 0` and also `_pausePeriod <= 52 weeks`. If the value were set to zero, it would allow immediate transfers since `_pause` would not be invoked. The constructor should also check that `_governance`, `_vestingContractTarget`, and each subfield of `_recipients` is non-zero (except `cliffPeriod`). 1. `RetroactiveVesting.verifyAndClaimReward()` does not check if `totalAmount` is greater than zero and if it fits into `uint120`. Due to the cast on L69, this could lead to an integer overflow in case `totalAmount` is greater than the largest integer in `uint120`. 3. `Vesting.changeBeneficiary()` does not check if `newBeneficiary != beneficiary`. 5. `SimpleGovernance.changeGovernance()` does not check that `newGovernance != governance`.
Open the reportAccording to *natspec* the addresses in `allowedTransferee` are allowed to transfer tokens even if the contract is paused. Is understood that these addresses can be `from` addresses when transferring. But in the logic at `_beforeTokenTransfer()` if the `to` address is in allowed then the transfer is allowed (`allowedTransferee[from] || allowedTransferee[to]`). This logic seems unclear and could allow users to partner with an allowed accomplice account to drain funds out of the contract, even during a paused contract scenario.
Open the reportThe `GenericERC20WithGovernance` contract extends `SimpleGovernance` and its description in the code comments indicates: "This contract simulates a generic ERC20 token that is mintable and burnable." First, it is important to note that there is no publicly accessible burn function. Therefore, there is a deviation from the specification at this point. Second, and more importantly, the `owner` and not the `governance` address is the one which is only allowed to call the `mint()` function. This is confusing as one would expect the decision to mint tokens would be given to the `governance` address, not to the `owner`.
Open the reportThe function `changeBeneficiary` allows governance to set a new beneficiary address for any `Vesting` contract. Suppose the `Vesting` contract owns 100 total tokens and they are 50% vested. If the old beneficiary address has recently been claimed, they would have received up to 50 tokens and the new beneficiary would only receive around 50 as well (after full vesting). However, if the previous beneficiary has not claimed any tokens yet, that address will not be entitled to any tokens after the change, and therefore the new address will be allotted all 100 tokens after the full vesting period. It is not clear which case is desirable.
Open the reportThe contract mentions that vesting is "optionally revocable by the owner" and later mentions that `currentBalance` can be 0 in case of vesting being revoked earlier." However, it does not appear that any revocation logic exists.
Open the reportThe `Vesting.vestedAmount()` function returns the minimum between `currentBalance` and `unreleased` on L129, and the comment right above on L128 indicates: "`currentBalance` can be 0 in case of vesting being revoked earlier." However, in the case when `currentBalance == 0`, the subtraction on L126 would underflow and the function would throw. This would also seemingly block the beneficiary from calling `release()`. However, since the intended vested amount would be anyway `0` it would effectively not make a difference to the beneficiary's balance. Nevertheless, the intended error message: "No tokens to release" would not be displayed to the beneficiary.
Open the reportThe function `completeToSynth` is callable by any user after the `maxSecsLeftInWaitingPeriod`. Although the `synth` wil be sent to the correct `nftOwner` regardless of `msg.sender`, this could have impact on external contracts that rely upon calling `completeToSynth`. For example, suppose some contract has a function of the form: ``` function getSynthAndTransfer(...) { Bridge.completeToSynth(id); synth.transfer(external_addr); } ``` If another arbitrary user invokes `completeToSynth` before the contract invokes it, the funds could be locked forever (assuming the contract did not have sufficient "retrieval" functions).
Open the reportTo save some gas on settlement and storage in general (in case of backup or migration), a cloned `SynthSwapper` that has already been used for settled swaps, can be destroyed. You can also remove the swaps from the pending list.
Open the reportThese pending swap states are set but never checked anywhere; this must be due to implicit assumptions on the state of swaps, but opens the door for duplicated calls to, for example, `completeToToken()`. A second duplicated call to `completeToToken()` might be possible with `swapAmount = 0` even if it was completed before.
Open the reportIn order to ensure that the functions updated state properly, it should be checked that `transfer` calls in `swapSynthToToken` and `withdraw` return true.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThere is no verification when the `stopRampA()` function is called to check if: 1. the current ramp has already ended due to the `self.futureATime` being in the past. 2. the `stopRampA()` was already called before to stop the current ramp. This allows calling this function multiple times (consecutively) with the effect that each time the `self.initialATime` is set to the current block timestamp, which prevents a new ramp for `MIN_RAMP_TIME` seconds. This could be problematic if the `stopRampA` function is called multiple times by mistake.
Open the reportThe [checks-effects-interactions pattern](https://solidity.readthedocs.io/en/develop/security-considerations.html?highlight=check%20effects#use-the-checks-effects-interactions-pattern) is respected by most methods with one notable exception being the `SwapUtils.addLiquidity` method.
Open the reportThe return value of the following call on L94: `allowlist.getAllowedAmount(address(this), address(0)); // crude check of the allowlist`, is ignored inside the `Swap.constructor`. It seems that this function is called to check if the `allowlist` address is indeed the address of an `Allowlist` contract instance. However, there are 2 problems with this approach: 1. The address can point to any address even `address(0)`, an EOA or a contract that does not have the `getAllowedAmount` and that call will still not cause a revert and will only return `0`. 2. If the address indeed points to the `Allowlist` contract, then the parameters passed to this call: `allowlist.getAllowedAmount(address(this), address(0));` should correctly return `0`. Therefore, wrapping the call to `allowlist.getAllowedAmount` in a `require` statement that checks if the return value of that function is equal to `0` will not confirm if the address indeed points to the `Allowlist` contract.
Open the reportThe `SwapUtils.rampA()` function uses primitive addition (+) and multiplication (*) operators on L921, L922 and L925. The latter could cause an overflow if the value of the `futureA_` input parameter is too large.
Open the reportThe 2 functions `SwapUtils.calculateRemoveLiquidity` and `SwapUtils.calculateRebalanceAmounts` have the same implementation logic. However, they have different names and the former is `external` and the latter is `internal`.
Open the reportThe [reference implementation](https://github.com/curvefi/curve-contract/blob/master/contracts/pool-templates/base/SwapTemplateBase.vy#L577) of the `removeLiquidityImbalance` function contains an additional assertion that check if the `tokenAmount` is different from zero, before adding one to it. This is missing on L759 in Saddle.
Open the reportThe error message of the first `require` statement inside `rampA()` says that: "Ramp already ongoing". However, this `require` statement only checks if the `MIN_RAMP_TIME` has passed since the last call to `rampA()`, when the value of `self.initialATime` was set. The time when the previous ramp ends is actually given by `self.futureATime`, which is required to be greater or equal to `self.initialATime + MIN_RAMP_TIME` on L922.
Open the reportEvery Solidity file specifies in the header a version number of the format `pragma solidity (^)0.5.*`. The caret (`^`) before the version number implies an unlocked pragma, meaning that the compiler will use the specified version _and above_, hence the term "unlocked".
Open the reportThe `SwapUtils.getVirtualPrice` claims to calculate "the virtual price, scaled to the POOL_PRECISION". In order to do this it multiplies `D` by `10 ** uint256(getPoolPrecisionDecimals())` and divides the result by `self.lpToken.totalSupply()`. Even though in the current implementation inside `Swap.sol` we can see that the `LPToken` is created with the value of `SwapUtils.getPoolPrecisionDecimals()` as the number of decimals, this may change during maintenance or when adding features and there is no mechanism inside `SwapUtils.getVirtualPrice` that will indicate that the number of decimals of the `self.lpToken` is no longer equal to `getPoolPrecisionDecimals()`. This would lead to an incorrect virtual price.
Open the reportAttached through a shared contract address. Our incident data ends in Oct 2026.
Source: De.Fi incident database.
After the Saddle Finance official launch, some pools were arbitraged for millions within hours of launch.
Source: De.Fi incident database.
The reports name a repository or files. Open a report to see its scope.
Is this your project? Show this record on your own website. The label states what the record holds, links to this page and updates by itself. It is free and needs no sign-in.
Automatic: the three strongest facts of this project’s own record (its last audit and two more), highlighted. They update with the record. Picking a fact turns Automatic off.
<head><script src="https://widget.trustblock.run/project-widget.min.js" defer></script></head><body><divdata-tb-project-widgetdata-tb-project-slug="saddle-finance"></div></body>
Paste the code where the label should appear. It uses no API credit and updates by itself.
Something 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