Download audit report
This document presents the results of a security review conducted by Nethermind Security for Spline. The engagement focused on Spline’s liquidity profile framework, which is built as an extension to the Ekubo DEX protocol. Unlike standard AMM implementations, Spline implements a Cauchy distribution-based liquidity profile to create smooth liquidity curves across price ranges. The LiquidityProvider contract serves as the main interface, allowing users to create pools, add/remove liquidity, and manage positions using the Cauchy mathematical distribution.
The CauchyLiquidityProfile contract implements the core liquidity distribution logic, using the Cauchy probability distribution to determine how liquidity is allocated across different price ticks. The system uses the component SymmetricLiquidityProfileComponent to handle discrete tick range management, while the Cauchy profile applies the mathematical distribution formula to calculate actual liquidity amounts at each tick.
The audit comprises 897 lines of Cairo code. The audit was performed using (a) manual analysis of the codebase, (b) automated analysis tools, and (c) creation of test cases.
| Audit assessment | |
| Audit Type | Security Review |
| Initial Report | June 2, 2025 |
| Final Report | July 1, 2025 |
| Repositories | spline-v0 |
| Initial Commit | ec3337d96f |
| Final Commit | d116e66c8c |
| Documentation | HackMD |
| Documentation Assessment | High |
| Test Suite Assessment | Medium |
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | src/lp.cairo | 553 | 82 | 14.8% | 102 | 737 |
| 2 | src/shared_locker.cairo | 18 | 1 | 5.6% | 1 | 20 |
| 3 | src/lib.cairo | 19 | 0 | 0.0% | 0 | 19 |
| 4 | src/sweep.cairo | 29 | 6 | 20.7% | 4 | 39 |
| 5 | src/math.cairo | 8 | 2 | 25.0% | 3 | 13 |
| 6 | src/token.cairo | 39 | 13 | 33.3% | 11 | 63 |
| 7 | src/profile.cairo | 21 | 8 | 38.1% | 4 | 33 |
| 8 | src/profiles/cauchy.cairo | 138 | 22 | 15.9% | 23 | 183 |
| 9 | src/profiles/symmetric.cairo | 67 | 23 | 34.3% | 12 | 102 |
| 10 | src/profiles/bounds.cairo | 5 | 1 | 20.0% | 0 | 6 |
| Total | 897 | 158 | 17.6% | 160 | 1215 |
The Spline protocol is a sophisticated liquidity management framework engineered as an extension to the Ekubo DEX protocol. At its core, the LiquidityProvider contract deviates from standard AMM liquidity provision models. Instead of using discrete price ranges or uniform distributions, it functions as a coordinator that delegates liquidity distribution logic to specialized mathematical profile contracts. These interactions are orchestrated through a standardized ILiquidityProfile interface.
The current implementation focuses on Cauchy distribution-based liquidity profiles, which create smooth, continuous liquidity curves that concentrate around a center price while maintaining liquidity across the full price spectrum.
The system operates as an Ekubo extension. The LiquidityProvider contract maintains pool state, manages ERC20 LP tokens for each pool, and handles fee collection and compounding, while delegating the mathematical complexity of liquidity distribution to the profile contracts.
The following diagram provides a high-level illustration of the Spline architecture.

LiquidityProvider
The LiquidityProvider contract serves as the main entry point and coordinator for all liquidity management operations within the Spline protocol. Operating as an Ekubo extension, it manages the complete lifecycle of liquidity pools while delegating mathematical distribution logic to specialized profile contracts.
ILiquidityProfile implementation, which determines how liquidity is allocated across price ranges.CauchyLiquidityProfile
The CauchyLiquidityProfile contract implements the mathematical logic for distributing liquidity according to a Cauchy probability distribution. It serves as the primary profile implementation in the current Spline system, creating smooth liquidity curves that concentrate around a center price while maintaining some liquidity across the full price spectrum.
Converts the continuous mathematical distribution into discrete liquidity positions by leveraging the SymmetricLiquidityProfileComponent to determine tick boundaries and calculating appropriate liquidity deltas for each range.
MIN_TICK to MAX_TICK) to ensure some liquidity is always available for large price movements.LiquidityProvider contract through the pool_key.extension validation mechanism.SymmetricLiquidityProfileComponent
The SymmetricLiquidityProfileComponent serves as a reusable component that handles the discretization of continuous liquidity distributions into manageable tick ranges. It provides the geometric framework that the Cauchy profile uses to determine where to place liquidity positions.
resolution parameter, so the step size within each segment is segment_width / resolution.get_bounds_for_liquidity_updates function that returns an array of Bounds structures, which the Cauchy profile uses to determine where to place liquidity positions.tick_start), ensuring balanced liquidity distribution on both sides of the expected price range by mirroring the segmentation pattern in both positive and negative directions.The security posture of the Spline protocol relies on the following assumptions:
The risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.
Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:
When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.
Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:
When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.
| Impact / Likelihood | Low | Medium | High |
| High | Medium | High | Critical |
| Medium | Low | Medium | High |
| Low | Info / Best Practices | Low | Medium |
| Undetermined | Undetermined | Undetermined | Undetermined |
To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.
| File | Fix commit | Type | Status |
src/sweep.cairo | e4234181993c7f7b390f529844dc | Critical | Fixed |
When a new pool is created, initial liquidity is provided. The corresponding LP shares representing this initial liquidity are minted to the LP contract itself and are intended to be locked indefinitely. This is shown in the create_and_initialize_pool(...) function:
fn create_and_initialize_pool(...) {
// ...
let shares = initial_liquidity_factor.try_into().unwrap();
call_core_with_callback::<
(PoolKey, i129, i129, u256, ContractAddress), (),
>(core, @(pool_key, liquidity_factor_delta, Zero::<i129>::zero(), shares, caller));
// lock initial minted lp tokens forever in this contract
let pool_token = self.pool_tokens.read(pool_key);
// @audit-issue Initial LP tokens, representing the first liquidity, are minted to this contract.
ILiquidityProviderTokenDispatcher { contract_address: pool_token }
.mint(get_contract_address(), shares);
}This initial locked liquidity ensures that the pool’s total_factor (total liquidity factor) is non-zero. The total_factor is crucial for share calculations, as demonstrated in the calculate_shares(...) function, which asserts total_factor > 0:
fn calculate_shares(...) -> u256 {
// @audit-issue This assertion requires total_factor to be positive. If it becomes '0', subsequent liquidity additions will fail.
assert(total_factor > 0, 'Total factor is 0');
let denom: u256 = total_factor.try_into().unwrap();
let num: u256 = factor.try_into().unwrap();
let shares: u256 = muldiv(total_shares, num, denom);
shares
}The problem arises because the LP contract embeds a SweepableComponent which exposes a sweep(...) function. This function allows anyone to withdraw any ERC20 tokens held by the LP contract.
#[embeddable_as(Sweepable)]
impl SweepableImpl<...> of super::ISweepable<ComponentState<TContractState>> {
fn sweep(...) {
let balance = IERC20Dispatcher { contract_address: token }
.balance_of(get_contract_address());
// @audit-issue The function checks for minimum balance but does not restrict which token can be swept or who can call it.
assert(balance >= amount_min, 'Insufficient balance');
IERC20Dispatcher { contract_address: token }.transfer(recipient, balance);
}
}Crucially, the sweep(...) function does not prevent the pool’s own LP tokens (those representing the initial locked liquidity) from being swept. An attacker can call sweep(...), specifying the pool’s LP token address as the token and their own address as the recipient. This allows them to take ownership of the initially locked LP shares.
Once the attacker possesses these LP shares, they can call remove_liquidity(...) to withdraw the actual underlying initial liquidity from the pool. This action reduces the pool’s liquidity_factor_delta and subsequently the total_factor to 0. As a result, the assert(total_factor > 0, ’Total factor is 0’) in calculate_shares(...) will fail for all future attempts to add liquidity, effectively causing a permanent Denial of Service (DoS) for the affected pool. All subsequent operations that depend on adding liquidity will become impossible.
The following Proof of Concept demonstrates the attack:
#[test]
#[fork("mainnet")]
#[should_panic(expected: ('Total factor is 0',))]
fn test_poc_foo() {
let (pool_key, lp, _, _, default_profile_params, token0, token1) = setup_add_liquidity();
let initial_liquidity_factor = lp.pool_liquidity_factor(pool_key);
assert_eq!(initial_liquidity_factor, 1000000000000000000);
// @audit Attacker (bob) identifies the pool's LP token and its balance held by the LP contract.
let pool_token = IERC20Dispatcher { contract_address: lp.pool_token(pool_key) };
let locked_shares = pool_token.balance_of(lp.contract_address);
let lp_sweep = ISweepableDispatcher { contract_address: lp.contract_address };
// @audit Attacker calls sweep(...) to transfer the locked LP shares to themselves.
cheat_caller_address(lp.contract_address, 123.try_into().unwrap(), CheatSpan::TargetCalls(1));
lp_sweep.sweep(pool_token.contract_address, 123.try_into().unwrap(), locked_shares);
// @audit Attacker calls remove_liquidity(...) with the stolen LP shares, draining initial liquidity.
cheat_caller_address(lp.contract_address, 123.try_into().unwrap(), CheatSpan::TargetCalls(1));
lp.remove_liquidity(pool_key, locked_shares);
// @audit A legitimate user attempts to add liquidity to the pool.
let step = *default_profile_params[2];
let n = *default_profile_params[3];
let factor = 100000000000000000000; // 100 * 1e18
let amount: u128 = (step.mag * n.mag * (factor)) / (1900000);
token0.transfer(lp.contract_address, amount.into());
token1.transfer(lp.contract_address, amount.into());
// @audit-issue This call will now panic because total_factor is '0', causing a DoS.
lp.add_liquidity(pool_key, factor);
}Recommendation
Consider preventing the sweeping of the initial, locked LP shares specific to any pool managed by this contract.
Resolution
Fixed in e423418, 1993c7f and 7b390f5
Update from Nethermind Security
The safe_transfer_from(...) in shared_locker doesn’t handle the case for the tokens which returns false and doesn’t revert in the case of error.
Update from the client
Fixed in 29844dc
add_liquidity and remove_liquidity| File | Fix commit | Type | Status |
src/lp.cairo | d3ff60e | Medium | Fixed |
The add_liquidity(...) function enables users to deposit assets into the underlying liquidity pool. In return, users receive pool shares proportional to their contributed liquidity relative to the total pool size.
fn add_liquidity(ref self: ContractState, pool_key: PoolKey, factor: u128) -> u256 {
// compound fees if possible. also checks pool key and pool initialized
self.compound_fees(pool_key);
// calculate shares to mint
let liquidity_factor_delta = i129 { mag: factor, sign: false };
self.check_liquidity_factor_delta(pool_key, liquidity_factor_delta);
let pool_token = self.pool_tokens.read(pool_key);
let total_shares = IERC20Dispatcher { contract_address: pool_token }.total_supply();
let liquidity_factor = self.pool_liquidity_factors.read(pool_key);
let shares = self.calculate_shares(total_shares, factor, liquidity_factor);
// add amount to liquidity factor in storage
let new_liquidity_factor = liquidity_factor + factor;
self.pool_liquidity_factors.write(pool_key, new_liquidity_factor);
// obtain core lock. should also effectively lock this contract for unique pool key
let core = self.core.read();
let caller = get_caller_address();
call_core_with_callback::<
(PoolKey, i129, i129, u256, ContractAddress),
()
>(
core,
@(
pool_key,
liquidity_factor_delta,
Zero::<i129>::zero(),
shares,
caller
)
);
// mint pool token shares to caller
ILiquidityProviderTokenDispatcher { contract_address: pool_token }.mint(caller, shares);
shares
}The current implementation lacks any form of slippage protection. In the add_liquidity(...) function, the number of pool shares a user receives is determined by the factor they provide and the pool’s state (specifically total_shares and current liquidity_factor) at the time of execution via the self.calculate_shares(...) call. However, this state can change due to other transactions (e.g., deposits or withdrawals by other users) that are processed between the user submitting their transaction and its actual execution on-chain. This timing difference exposes the user to potential slippage: they might receive fewer pool shares than they expected for their deposited liquidity if the pool’s total liquidity or composition changed unfavorably in the interim.
A similar vulnerability exists in the remove_liquidity(...) function. When a user removes liquidity, the amount of underlying assets they receive is calculated based on the pool’s state at that moment. If other users’ transactions alter the pool composition or value just before the withdrawal is processed, the withdrawing user might receive fewer underlying assets than anticipated for their redeemed pool shares.
Recommendation
Consider allowing users to specify a minimum acceptable number of shares (for add_liquidity(...)) or a minimum amount of underlying assets (for remove_liquidity(...)) they are willing to receive. The functions should then revert if the calculated amount falls below this user-defined threshold due to market movements.
Resolution
Fixed in d3ff60e
params for _set_grid_for_bounds(...)| File | Fix commit | Type | Status |
src/profiles/symmetric.cairo | 5796e4eae0ed13 | Low | Mitigated |
When a pool is created and initialized, the _set_grid_for_bounds(...) is used to set the parameters for calculating bounds for a symmetric liquidity profile.
fn _set_grid_for_bounds(
ref self: ComponentState<TContractState>, pool_key: PoolKey, params: Span<i129>,
) {
assert(params.len() == 4, 'Invalid params length');
assert(!*params[0].sign, 'Invalid grid s');
assert(!*params[1].sign, 'Invalid grid resolution');
let (s, res, tick_start, tick_max) = (
*params[0].mag, *params[1].mag, *params[2], *params[3],
);
// @audit-issue It doesn't ensure res to be power of 2 rather it ensures res to be an even number.
assert((res > 0 && (res % 2 == 0)), 'resolution must be power of 2');
assert(tick_start < tick_max, 'tick_start must be < tick_max');
assert(s > 0 && (s % pool_key.tick_spacing == 0), 's must divide by tick_spacing');
// @audit-issue step is not 2*s/res rather step is s/res, so this check is incorrect.
assert((2 * s) / res != 0, 'step must be non-zero');
self.grid.write(pool_key, (s, res, tick_start, tick_max));
}The _set_grid_for_bounds(...) function contains several asserts to ensure that the params are valid. However, some of these asserts are incorrect, and additional validations are missing.
Specifically, the following asserts are incorrect:
res > 0 (res % 2 == 0) only ensures res is an even number greater than 0. A correct check for res being a power of 2 and greater than 0 would be res > 0 (res (res - 1)) == 0;(2 * s) / res != 0. The actual step in the calculation is s / res. Therefore, even if the current assert passes, the true step (s / res) could still be 0, leading to incorrect behavior;Furthermore, the following crucial conditions are not asserted:
tick_start and tick_max values should be valid ticks, meaning they must be divisible by tick_spacing;get_bounds_for_liquidity_updates(...) should always fall within the valid tick range of [MIN_TICK, MAX_TICK], i.e., [-88722883, 88722883]. The current logic does not prevent a combination of s / res (step), tick_start, and tick_max from resulting in bounds outside this valid range;s should be divisible by res else the segments won’t be divided into equally spaced bins resulting in the case where the check ticks.upper == next.upper can be missed in the get_bounds_for_liquidity_updates(...);s / res, should also be divisible by tick_spacing to ensure that the calculated bounds align with valid tick positions. If it’s not, the bounds could point to invalid ticks;Recommendation
Consider fixing the incorrect asserts and adding the missing asserts to ensure the params lead to a valid grid configuration.
Resolution
Fixed in 5796e4e
Update from Nethermind Security
There are 2 specific checks to ensure that tick_start and tick_max are within the range of [MIN_TICK, MAX_TICK]. These 2 checks aren’t enough. It should be ensured that all the ticks calculated in the get_bounds_for_liquidity_updates(...) by adding/subtracting with steps in the while loop are valid ticks.
Update from the client
Fixed in ae0ed13
Update from Nethermind Security
This will revert even for certain valid configurations. For example, if the configurations are as follows:
Then, the last bound will be [-452, 256]. But tick_min will be -480, which is less than -460. Thus, it will revert even though the lower tick of the last bound, i.e., -452, is a valid tick.
Update from the client
This is fine since it wouldn’t revert when the pool is live, so basically just excludes certain configs.
initial_tick may mismatch profile’s tick_start| File | Fix commit | Type | Status |
src/lp.cairo | — | Info | Acknowledged |
The create_and_initialize_pool(...) function is responsible for setting up a new liquidity pool. It accepts initial_tick and profile_params as parameters. The initial_tick is used to initialize the pool on the Ekubo core. The profile_params array is used to configure the liquidity profile associated with the pool, where profile_params[2] represents tick_start and profile_params[5] represents mu for the Cauchy distribution.
The cauchy::set_liquidity_profile(...) function, called during pool creation, correctly asserts that mu (profile_params[5]) is equal to tick_start (profile_params[2]).
fn set_liquidity_profile(ref self: ContractState, pool_key: PoolKey, params: Span<i129>) {
// ...
// @audit This asserts that mu (params[5]) equals tick_start (params[2]).
assert(
*params[5].mag == *params[2].mag && *params[5].sign == *params[2].sign,
'mu != symmetric::tick_start',
);
// ...
}However, there is no corresponding validation in lp::create_and_initialize_pool(...) to ensure that the initial_tick (passed as a direct argument) is equal to tick_start (i.e., profile_params[2]).
fn create_and_initialize_pool(
ref self: ContractState,
pool_key: PoolKey,
initial_tick: i129, // @audit Used to initialize the pool on core.
profile_params: Span<i129>, // @audit profile_params[2] is tick_start.
) {
// ...
// @audit profile_params are set, which includes tick_start (profile_params[2]).
profile.set_liquidity_profile(pool_key, profile_params);
// ...
// @audit Pool is initialized with initial_tick.
// @audit-issue No check ensures initial_tick matches profile_params[2] (tick_start).
core.initialize_pool(pool_key, initial_tick);
// ...
}This omission means that the pool can be initialized on the Ekubo core with an initial_tick that is different from the tick_start value defined in its liquidity profile parameters.
Recommendation
Consider adding a validation step within the lp::create_and_initialize_pool(...) function to ensure that the provided initial_tick argument is equal to profile_params[2] (the tick_start value from the profile parameters).
Resolution
The initial tick mismatch with tick start should be fine since it leaves flexibility to initialize pool not at center of liquidity distribution (although unlikely we’ll ever do that).
try_call_core_with_callback(...) function reverts on deserialization failure, defeating its purpose of safely handling call errors| File | Fix commit | Type | Status |
src/shared_locker.cairo | — | Info | Acknowledged |
The try_call_core_with_callback(...) function is intended to make a call to an external core contract and handle potential errors without reverting the entire transaction. It uses a match statement on the call_result to differentiate between successful calls (Result::Ok) and failed calls (Result::Err). In the case of a failed call, it correctly returns Option::None.
However, if the external call to core.contract_address is successful (i.e., Result::Ok), the function then attempts to deserialize the output_span using Serde::deserialize(ref output_span).expect(’DESERIALIZE_RESULT_FAILED’). If this deserialization fails for any reason (e.g., unexpected data format returned by the core contract), the .expect(...) call will cause the entire transaction to revert with the message ’DESERIALIZE_RESULT_FAILED’. This behavior contradicts the function’s apparent goal of catching errors from the external call and its aftermath without causing a full revert.
The problem lies in this line:
pub fn try_call_core_with_callback<TInput, TOutput, +Serde<TInput>, +Serde<TOutput>>(
core: ICoreDispatcher, input: @TInput,
) -> Option<TOutput> {
let data = serialize(input).span();
let mut calldata = serialize(@data).span();
// TODO: is this valid for ekubo core with callback into lp? to bypass full tx reverts
let call_result = call_contract_syscall(core.contract_address, selector!("lock"), calldata);
match call_result {
Result::Ok(mut output_span) => {
// @audit-issue If Serde::deserialize fails, the .expect() call will revert the transaction.
Option::Some(Serde::deserialize(ref output_span).expect('DESERIALIZE_RESULT_FAILED'))
},
Result::Err(_) => { Option::None(()) },
}
}This means that even if the call to the core contract itself does not revert, a subsequent failure in processing its return data can still lead to a revert, which might be an unintended behavior for a "try_call" type of function.
Recommendation
Consider modifying the deserialization step to handle potential errors gracefully.
Resolution
The try/call reverting on deserialization should be ok for spline purposes given more so intended usage is to catch any rounding issues on harvest fees. Deserialization errors on our end should revert as a bug.
Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:
These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.
The Spline team has provided a comprehensive walkthrough of the project in the kick-off call. The HackMD page and the code comments include the explanation of the intended functionalities. Moreover, the team addressed the questions and concerns raised by the Nethermind Security team, providing valuable insights and a comprehensive understanding of the project’s technical aspects.
| 60Tests passed | 0Failed |
The complete test output is in the full report.
As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.
This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.
The Leading Engineers of Blockchain Infrastructure