View Audit Report
This document presents the results of the security review conducted by Nethermind Security for Null Solutions's smart contracts for the NullV1 Protocol for perpetual derivative markets.
NullV1 is a derivatives protocol where the system creates isolated markets consisting of two counterparty pools. A Receiver pool and a Payer pool that accepts ERC-20 tokens as collateral. Participants take receiver (long) or payer (short) positions on the funding rate of an underlying asset price. All collateral is held in a single NullV1Market contract and accounted across two ERC-4626 vaults representing the receiver and payer pools.
NullV1Factory deploys EIP-1167 proxy clones for markets and paired pools keyed by unique collateral, oracle, and funding rate model. As the core settlement engine, NullV1Market holds all collateral tokens and enforces a balance invariant across the receiver pool, payer pool, and protocol fees. On every interaction, the Settlement library reconciles balances, accrues fees, updates PnL, and settles funding transfers.
The periphery uses NullV1Router as a stateless entry point handling user interactions, slippage protection, deadline checks, and collateral wrapping. Pricing data is sourced from Chainlink Data Streams oracles.
The audit comprises 1220 lines of Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | periphery/NullV1Router.sol | 132 | 55 | 41.7% | 15 | 202 |
| 2 | periphery/frm/NullV1ImbalanceFundingRateModel.sol | 40 | 30 | 75.0% | 14 | 84 |
| 3 | periphery/oracle/INullV1UpdatableOracle.sol | 7 | 10 | 142.9% | 4 | 21 |
| 4 | periphery/oracle/chainlink/NullV1ChainlinkOracleV11.sol | 174 | 108 | 62.1% | 43 | 325 |
| 5 | periphery/oracle/chainlink/NullV1ChainlinkOracleFactory.sol | 49 | 16 | 32.7% | 11 | 76 |
| 6 | core/NullV1Pool.sol | 187 | 56 | 29.9% | 39 | 282 |
| 7 | core/NullV1Factory.sol | 125 | 27 | 21.6% | 21 | 173 |
| 8 | core/NullV1NoDelegate.sol | 15 | 9 | 60.0% | 9 | 33 |
| 9 | core/NullV1Market.sol | 183 | 77 | 42.1% | 39 | 299 |
| 10 | core/NullV1ReentrancyGuard.sol | 25 | 35 | 140.0% | 12 | 72 |
| 11 | core/interfaces/INullV1Pool.sol | 11 | 5 | 45.5% | 5 | 21 |
| 12 | core/interfaces/INullV1Market.sol | 38 | 30 | 78.9% | 17 | 85 |
| 13 | core/interfaces/INullV1FundingRateModel.sol | 17 | 11 | 64.7% | 4 | 32 |
| 14 | core/interfaces/INullV1Oracle.sol | 9 | 19 | 211.1% | 6 | 34 |
| 15 | core/interfaces/INullV1Factory.sol | 26 | 27 | 103.8% | 14 | 67 |
| 16 | core/libraries/ConstantsLib.sol | 12 | 22 | 183.3% | 11 | 45 |
| 17 | core/libraries/SettlementLib.sol | 148 | 35 | 23.6% | 22 | 205 |
| 18 | core/libraries/ErrorsLib.sol | 22 | 23 | 104.5% | 19 | 64 |
| Total | 1220 | 595 | 48.8% | 305 | 2120 |
NullV1 is a decentralised financial protocol designed to create and settle perpetual derivative markets backed by counterparty ERC-4626 liquidity pools. The system creates isolated markets consisting of two counterparty pools: a Receiver pool and a Payer pool. Participants supply ERC-20 as collateral to either side to take exposure, earning returns or incurring losses based on asset price changes provided by oracles and funding rates calculated from pool asset imbalances.
The system is organised into two subsystems, namely Core and Periphery.
4.1.1 Core
The Core smart contracts comprise:
4.1.2 Periphery
The Periphery smart contracts comprise:
4.2.1 Market Creation
4.2.2 User Interaction
4.2.3 Settlement
Every pool operation (deposit, mint, withdraw, redeem) triggers settlement.
4.3.1 MarketState
MarketState {
lastUpdate: uint256 // Timestamp of last settlement
receiverPool: uint256 // Gross receiver pool assets (includes MIN_POOL_ASSETS floor)
payerPool: uint256 // Gross payer pool assets (includes MIN_POOL_ASSETS floor)
protocolFees: uint256 // Accumulated protocol fees
price: uint256 // Last settled oracle price
}4.3.2 Settleable Balance
Settlement only applies to the portion of each pool above the minimum asset pool floor. This floor prevents zero-size settlement and ensures a minimum dust balance remains for accounting integrity.
4.3.3 Price PnL
4.3.4 Funding
4.3.5 Circuit breaker
If the price difference is greater than 20%, settlement skips PnL entirely and resets the reference price. This prevents exploitation from oracle discontinuities.
f = mu * (c * R - P) / (c * R + P)Where:
Pricing is sourced from the Chainlink oracle. The protocol supports 3 feed types.
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.
mint, causing depositors to lose a portion of their assets| File | Fix commit | Type | Status |
/src/core/NullV1Pool.sol | — | Medium | Fixed |
NullV1Pool charges its entry and exit fees on notional — the size of the bet a deposit represents — rather than on the deposit amount itself.
The fee is computed by one of two helpers, depending on the flow:
feeOnTotal — the caller handed over a fixed sum of collateral, so the fee has to be taken out of it;feeOnRawNotional — the caller asked for a fixed position size(shares), so the fee has to be added on top;function feeOnTotal(uint256 assets, uint256 feeBasisPoints) internal pure returns (uint256) { return Math.mulDiv(assets, feeBasisPoints, feeBasisPoints + MAX_BASIS_POINTS, Math.Rounding.Ceil); }<br>function feeOnRawNotional(uint256 notional, uint256 assets, uint256 feeBasisPoints) internal pure returns (uint256) { if (notional == 0 || feeBasisPoints == 0) return 0; uint256 denom = assets * (feeBasisPoints + MAX_BASIS_POINTS); if (notional * feeBasisPoints >= denom) revert ErrorsLib.FeeExceedsAssets(); return Math.mulDiv(notional, assets * feeBasisPoints, denom - notional * feeBasisPoints, Math.Rounding.Ceil); }The two functions are meant to be mirror implementations: feeOnRawNotional quotes the fee the user will pay, and feeOnTotal should pull exactly that same fee back out. This equivalence holds only if the fee rate stays constant regardless of deposit size
_notional places the depositor's own asset amount into the denominator:
function _notional(uint256 assets, bool depositing) private view returns (uint256) {
(, uint256 receiverAssets, uint256 payerAssets,,) = _market.state();
uint256 ownAssets =
(poolKind == NullV1PoolKind.Receiver ? receiverAssets : payerAssets) + (depositing ? assets : 0);
if (depositing && poolKind == NullV1PoolKind.Receiver) receiverAssets += assets;
uint256 settleableR = SettlementLib._settleableAssets(receiverAssets);
return ownAssets == 0 || settleableR == 0 || (depositing && SettlementLib._settleableAssets(payerAssets) == 0)
? 0
@==> : assets.mulDiv(settleableR, ownAssets);
}As a result, a larger deposit enlarges the payer pool, which reduces the depositor's effective leverage, which in turn shrinks their fee ( fee = assets × settleableReceiver ÷ (payerPool + assets)) and vice versa.
The mint entry point is where this dependency is not handled correctly. mint first derives the fee from the net asset amount and passes the resulting gross amount to _deposit (step 1):
function previewDeposit(uint256 assets) public view override(IERC4626, ERC4626Upgradeable) returns (uint256) {
uint256 fee = Math.min(assets, feeOnTotal(_notional(assets, true), _market.entryFee()));
return super.previewDeposit(assets - fee);
}
function deposit(uint256 assets, address receiver) public virtual returns (uint256) {
// --SNIP
uint256 shares = previewDeposit(assets);
_deposit(_msgSender(), receiver, assets, shares);
return shares;
}_deposit then recomputes the fee from the gross amount (step 2):
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override {
// --SNIP
@==> uint256 fee = Math.min(assets, feeOnTotal(_notional(assets, true), _market.entryFee()));
IERC20(asset()).safeTransferFrom(caller, address(_market), assets);
_market.recordDeposit(assets, fee, poolKind);
_mint(receiver, shares);
emit Deposit(caller, receiver, assets, shares);
}The fee is thus calculated twice, and the two calculations are expected to agree. In the mint flow, however, they do not, because each step computes the notional from a different amount:
previewMint (step 1) calculates the fee from the smaller, net amount, which corresponds to a higher notional ratio and therefore a higher fee rate. It quotes the user a gross amount to pay based on this fee;_deposit (step 2) discards that fee and recomputes it from the larger, gross amount, which corresponds to a lower fee rate (because of smaller notional) and therefore a smaller fee;Because the fee actually charged is smaller than the one quoted when calculating the gross amount, more capital ends up in the pool than the minted shares were priced for. Critically, the share count is fixed in step 1 — before _deposit is called — and nothing subsequently adjusts it once the fee is recalculated from the gross amount. The resulting surplus capital sits in the payer pool with no shares attached to it, and is effectively distributed among all existing payer-shareholders. The depositor thereby loses a portion of the assets they paid in, without receiving any shares in return.
Consider the following example, which illustrates the issue: Pre-state: the payer pool holds 5,000 assets, the receiver pool holds 1,000,000 assets, and the entry fee is 0.25%.
10,000 USDC. The notional for this position is 666,666, and the share count is fixed at this point. Bob calls mint, which calls previewMint to calculate the fee:;previewMint is 1,994.015.11,994.015.11,994.015 USDC against the shares requested, and this gross amount is passed to _deposit;_deposit recalculates the fee from this gross amount:;11,994.015 is 705,778.0761,760.045The fee actually taken ( 1,760.045) is therefore 233.97 USDC less than the fee originally quoted to Bob. The pool receives 10,233.97 USDC, but the shares minted for Bob remain fixed at the step-1 priced at 10,000 USDC. As a result, Bob effectively loses 233.97 USDC, since no shares back this amount — it is instead attributed to the existing payer-shareholders.
NOTE: Same issue exists in the withdraw flow.
Recommendation
Ensure the fee used to determine the minted share count and the fee actually recorded/transferred in _deposit are computed from the same basis. Concretely, either:
mint/ previewMint) and pass it through to _deposit instead of recomputing it there from the gross amount, or;_deposit after the fee is finalised, so that the minted shares always match the fee actually charged;_deposit does not revert on zero shares| File | Fix commit | Type | Status |
/src/core/NullV1Pool.sol | 1f0562a | Low | Fixed |
When depositing into an underfunded pool, leverage is high, and the fee charged against the deposited assets can saturate to the full asset amount. As a result, a depositor can be charged a 100% fee on their assets:
function previewDeposit(uint256 assets) public view override(IERC4626, ERC4626Upgradeable) returns (uint256) {
uint256 fee = Math.min(assets, feeOnTotal(_notional(assets, true), _market.entryFee()));
return super.previewDeposit(assets - fee);
}In this case, the shares minted for the depositor will be zero. The issue is that _deposit does not guard against this:
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override {
if (NullV1Factory(_market.factory()).isDepositsPaused()) {
revert ErrorsLib.DepositsPaused();
}
_checkAllowed(caller, true);
_checkAllowed(receiver, true);
IERC20(asset()).safeTransferFrom(caller, address(_market), assets);
uint256 fee = Math.min(assets, feeOnTotal(_notional(assets, true), _market.entryFee()));
_market.recordDeposit(assets, fee, poolKind);
_mint(receiver, shares);
emit Deposit(caller, receiver, assets, shares);
}This allows the depositor to pay a 100% fee while receiving zero shares, resulting in a loss of funds.
Recommendation
Add a check in _deposit that reverts if shares is zero.
deposit flow is cheaper than mint flow, causing protocol to lose portion of fees| File | Fix commit | Type | Status |
/src/core/NullV1Pool.sol | 9a57c6a | Low | Fixed |
Both payer and receiver pools charge entry fees against the notional, which is the leverage the deposit is making ( leverage = settleableReceiver / (payerPool + your deposit)). The higher the deposit is, the less the leverage is:
function _notional(uint256 assets, bool depositing) private view returns (uint256) {
(, uint256 receiverAssets, uint256 payerAssets,,) = _market.state();
uint256 ownAssets =
(poolKind == NullV1PoolKind.Receiver ? receiverAssets : payerAssets) + (depositing ? assets : 0);
if (depositing && poolKind == NullV1PoolKind.Receiver) receiverAssets += assets;
uint256 settleableR = SettlementLib._settleableAssets(receiverAssets);
return ownAssets == 0 || settleableR == 0 || (depositing && SettlementLib._settleableAssets(payerAssets) == 0)
? 0
@==> : assets.mulDiv(settleableR, ownAssets);
}When making deposits into the pool, there are two flows: mint which spends the assets based on the requested shares, and deposit, which mints shares based on the requested assets. However, if we take a look at how both functions charge the fees: 1. previewMint:
function previewMint(uint256 shares) public view override(IERC4626, ERC4626Upgradeable) returns (uint256) {
uint256 assets = super.previewMint(shares);
return assets + feeOnRawNotional(_notional(assets, true), assets, _market.entryFee());
}2. previewDeposit:
function previewDeposit(uint256 assets) public view override(IERC4626, ERC4626Upgradeable) returns (uint256) {
uint256 fee = Math.min(assets, feeOnTotal(_notional(assets, true), _market.entryFee()));
return super.previewDeposit(assets - fee);
}previewMint calculates the leverage in _notional based on the net asset the user will deposit into the pool, while previewDeposit measures the leverage against a gross deposit that includes the fee that is about to be charged. That fee doesn't go into the pool, but still is included in the leverage calculation, which makes it less and thus gives higher shares. So, for depositing the same amount of assets, the deposit flow will be cheaper than mint flow, in which the former will charge fewer fees than the latter.
Consider the following example demonstrating the issue:
1_000_000 assets and the payer pool has 5_000 assets;10_000, you need spend 11_994,015555 and request shares of 10000000000000000000011994015555100000000000000000000119940155551760045072102339704830000000000So, through the deposit flow, the position's value has more 233,97 USDC than the mint flow, for the same spent amount.
Recommendation
Consider disabling one of the flows. It is tricky to fix the issue while keeping both flows given the notional logic.
| File | Fix commit | Type | Status |
/src/core/NullV1Factory.sol/src/core/NullV1Market.sol/src/periphery/NullV1Router.sol | — | Info | Fixed |
Reentrancy protection is implemented across the protocol to prevent untrusted external calls from re-entering state-modifying functions.
However, three distinct versions of reentrancy guards are used across the codebase rather than standardising on the custom NullV1ReentrancyGuard contract.
NullV1Market uses NullV1ReentrancyGuard;NullV1Factory uses ReentrancyGuard which is storage based;NullV1Router uses ReentrancyGuardTransient from OZ library;This inconsistency creates architectural fragmentation and maintenance overhead across the codebase. Additionally, using persistent storage in NullV1Factory incurs higher execution gas costs than transient storage implementations.
Recommendation
Consider standardising all contracts across the project to use NullV1ReentrancyGuard as the single transient storage-based reentrancy guard implementation.
| File | Fix commit | Type | Status |
/src/core/NullV1Factory.sol | — | Info | Fixed |
In the NullV1Factory contract, the whitelistAdmin grants market creation privileges to specific addresses by calling the addToWhitelist(...) function. Whitelisted users can then call the createMarket(...) function to instantiate new markets with specific parameters, such as collateral tokens, oracle feeds, and funding rate models.
However, the NullV1Factory contract does not provide a removeFromWhitelist(...) function or any other mechanism to revoke an address's whitelisted status once it has been granted. While deposit functionality can be independently restricted using the setDepositsBlocked(...) function in the NullV1Pool contract via _checkAllowed(...), the createMarket(...) function relies solely on the isWhitelisted mapping without any secondary blocklist control.
function createMarket(...) external noDelegateCall nonReentrant returns (INullV1Market market) {
// ...
// @audit-issue The contract checks whitelist status, but there is no mechanism to revoke it.
if (!isWhitelisted[msg.sender]) {
revert ErrorsLib.NotAllowed(msg.sender);
}
// ...
}This means that if a whitelisted address is compromised or should otherwise be restricted, the whitelistAdmin has no way to revoke its ability to create new markets. The compromised account can continue calling createMarket(...) indefinitely to deploy markets with arbitrary or malicious oracle parameters and funding rate models into the protocol.
Recommendation
Consider extending the existing blocklist check to also gate the createMarket(...) function.
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 Null Solution team provided a comprehensive overview of the main system components during the kick-off call, including a detailed explanation of the intended functionalities and a written specification for the different contracts. Additionally, the team addressed all questions and concerns raised by the Nethermind Security team, providing valuable insights and an in-depth understanding of the project's technical aspects.
| 447Tests 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