Lido
ZK Oracle

Covered the SP1 Accounting Report contract, its zkVM circuit, and deployment scripts, which prove and store state changes between Lido oracle reports.
report id
0530
category
Identity & Cryptography
language
Solidity
final report
July 30, 2025
lines of code
3586
Content

Download audit report

1

Executive Summary

This document presents the security review performed by Nethermind Security for the Lido Accounting SP1 Oracle smart contracts and zkVM circuit from Jun. 16, 2025 to Jul. 4, 2025. The Lido Accounting Oracle SP1 implementation is a circuit that verifies the correct reporting by the existing Lido oracles. This system aims to prove the change between two lido states represented in a report. The proof for the circuit is submitted on-chain and stored for the given reference slot. These proofs are then stored historically for each reference slot to be called when a negative rebase, according to LIP-23, has been detected.

The Lido Protocol is a staking platform that enables users to stake their assets on Ethereum, receiving liquid staking tokens in return, which can then be used across DeFi applications for additional yield and utility.

The audited code comprises 341 lines of code written in the Solidity language and 3,245 lines of code written in the Rust language. The audit included the SP1 Acccounting Report contract together with the zkVM circuit itself and deployment scripts.

The audit was performed using (a) manual analysis of the codebase and (b) automated analysis tools.

Audit assessment
Audit TypeSecurity Review
Initial ReportJuly 4, 2025
Final Report
Response from ClientRegular responses during audit engagement
Repositorysp1-lido-accounting-zk
Audit Commit303a0ef048
Final Commit06bb68acdc
Documentationlip-23, oracle-report-sanity-checker
Documentation AssessmentHigh
Total findings
9
Fixed
8
Acknowledged
1
Mitigated
0
Unresolved
0

Findings by severity

  • Critical0
  • High1
  • Medium1
  • Low1
  • Informational5
  • Best Practices1

4

System Overview

In this section we present the Lido Accounting zk-Oracle and the relevant parts to the audit. For more in-depth discussion of the oracle please refer to LIP-23.

The zk-Oracle is a circuit written in SP1 that aims to prove and validate the reports submitted on-chain by Lido's oracles. The zk-Oracle is to be used as a second-opinion when the Lido state requires verification when there has been a negative rebase as defined in LIP-23.

Traditionally, Lido has an approved set of 9 oracles that agree on the state of the Lido validators by performing a hash consensus. This hash consensus is a multi-sig with 5-out-of-9 needed to agree on the status of the beacon chain. Specifically, this hash consensus contains all the relevant beacon chain information that is used to define the status of Lido validators. To verify this consensus, Lido is adding a zk-Oracle. The overall flow is shown in Figure 2.

General Flow Diagram
Fig. 2 General Flow Diagram

4.1 Contract

The main contract is called the Sp1LidoAccountingReportContract and is used to submit and access proofs of past Lido states. The flow is shown in Figure 3.

On-chain Report Publishing Flow Diagram
Fig. 3 On-chain Report Publishing Flow Diagram

The reports provided by the existing oracles are keyed by a Lido reference slot. This slot is almost equivalent to the beacon chain slot, except in the case of missed blocks. Similarly, the proofs submitted are also keyed by reference slot.

To verify the correctness of the proofs, the contract uses existing Lido state and beacon chain information. Proofs are submitted together with the public values (described in the next section), which contain the reference slot and the beacon chain slot, as well as the report. These values are validated by asserting that they correspond to the same slot and, due to EIP-4788, to the same beacon hash. Then, the SP1 plonk verifier validates the proof with the public values, and the proof is stored in a map of the reference slot to the Report. The contract also allows for an external call to retrieve the report for a provided reference slot.

Additionally, the contract includes Pausable and Resumable functionality, allowing the contract owner to freeze the submission of new proofs for a given time period and later resume operations. During the audit, there were some changes in the contract to delegate these permissions to specific roles. The updated version implementing this change is available at commit 06bb68acdc.

4.2 Circuit

4.2.1 SP1 Summary

zkVM's enable developers to seamlessly transition from developing unverifiable code to developing verifiable code. zkVM's follow a standard approach to generating this proof. Firstly, the code is compiled down into the target architecture and expressed in ELF format. This is then fed into a VM executor which will execute the instructions present in the ELF as well as taking snapshots of the VM's architecture, for example memory and CPU registers, enabling the prover to generate a ZK proof of each cycle. This proof can then be verified using a verification key.

SP1 is a zkVM that allows the proving of arbitrary code by compiling it into the RISC-V architecture. Most commonly this enables Rust code to be proven in ZK, but any language that can be compiled to RISC-V can be used.

4.2.2 Lido Oracle Circuit

The circuit is defined in the SP1 program and aims to perform validation of the oracle report.

The public values of the circuit are the report itself and the report metadata.

struct Report {
uint256 reference_slot;
uint256 deposited_lido_validators;
uint256 exited_lido_validators;
uint256 lido_cl_valance;
uint256 lido_withdrawal_vault_balance;
}

struct ReportMetadataSolidity {
uint256 bc_slot;
uint256 epoch;
bytes32 lido_withdrawal_credentials;
bytes32 beacon_block_hash;
LidoValidatorStateSolidity state_for_previous_report;
LidoValidatorStateSolidity new_state;
LidoWithdrawalVaultDataSolidity withdrawal_vault_data;
}

struct LidoValidatorStateSolidity {
uint256 slot;
bytes32 merkle_root;
}

struct LidoWithdrawalVaultDataSolidity {
uint256 balance;
address vault_address;
}

The flow is visualised in Figure 4 and performs the following:

First the program verifies the program input by checking:

  • a) Proving the correct beacon state by providing the correct beacon hash.
  • b) Proving the validity of the old lido state by checking the inputted state with the old state hash.
  • c) Proving the changed validators and balances exist within the beacon state.
  • d) Verifying the withdrawal vault input.

Next, the circuit computes the new lido validator state by merging the old state with the changes to the validators. The new Lido state has its root computed and compared with the inputted new Lido state root, if they are not the same the code panics and the proof is not generated. Finally, the public values are computed based on the values inputted and computed along the way. They are encoded and then committed.

Therefore, by verifying the state of the beacon chain and Lido states the circuit is able to generate a report for the total lido validator balances.

Circuit Flow Diagram
Fig. 4 Circuit Flow Diagram

5

Risk Rating Methodology

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:

  1. High: the issue is trivial to exploit and has no specific conditions that need to be met;
  2. Medium: the issue is moderately complex and may have some conditions that need to be met;
  3. Low: the issue is very complex and requires very specific conditions to be met.

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:

  1. High: the issue can cause significant damage, such as loss of funds or the protocol entering an unrecoverable state;
  2. Medium: the issue can cause moderate damage, such as impacts that only affect a small group of users or only a particular part of the protocol;
  3. Low: the issue can cause little to no damage, such as bugs that are easily recoverable or cause unexpected interactions that cause minor inconveniences.

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 / LikelihoodLowMediumHigh
HighMediumHighCritical
MediumLowMediumHigh
LowInfo / Best PracticesLowMedium
UndeterminedUndeterminedUndeterminedUndetermined

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.

  1. Informational findings do not pose any risk to the application, but they carry some information that the audit team intends to pass to the client formally;
  2. Best Practice findings are used when some piece of code does not conform with smart contract development best practices;
  3. Undetermined findings are used when we cannot predict the impact or likelihood of the issue.

6

Issues

6.1 The cl_balance manipulation and oracle DOS through incorrect validator indices in lido_changed

FileFix commitTypeStatus
input_verification.rs
lido.rs
PR #12HighFixed

Classification: Under-constrained.

The new validators in the all_added may be included also in the list of changed validators lido_changed. This could be exploited to artificially increase the reported sum of all validator balances in cl_balance. The validators in all_added represent all the new validators that were registered between the previous and current state. All validators in the lido_changed list are ensured to be in the current beacon state:

self.verify_validator_inclusion_proof(
&format!("{vals_and_bals_prefix}.validator_inclusion_proofs.lido_changed"),
total_validators,
&beacon_state.validators,
&input.validators_and_balances.validators_delta.lido_changed,
proof,
)?;

However, there is no check to ensure that the lido_changed are the validators from the old state. This allows for including the validators from all_added also into lido_changed. The function merge_validator_delta(...) checks validators' status and adds them to the pending, deposited, or exited lists. From the exploit perspective, we are only interested in creating duplicates in deposited since those are used to calculate cl_balance. This can be achieved by adding from all_added to lido_changed only those validators that currently have "Deposited" status and were not registered in the previous state. The status is determined by the status(...) function:

fn status(&self, epoch: Epoch) -> ValidatorStatus {
if epoch >= self.exit_epoch {
ValidatorStatus::Exited
} else if epoch >= self.activation_eligibility_epoch {
ValidatorStatus::Deposited
} else {
ValidatorStatus::FutureDeposit
}
}

The merge_validator_delta(...) first iterates over the all_added and gives our targeted validators status of Deposited and adds them to new_deposited:

for validator_with_index in &validator_delta.all_added {
let validator = &validator_with_index.validator;
if !validator.is_lido(lido_withdrawal_credentials) {
continue;
}
match validator.status(epoch) {
// @audit: our targeted validators would be added here
ValidatorStatus::Deposited => new_deposited.push(validator_with_index.index),
ValidatorStatus::FutureDeposit => {
new_pending_deposit.insert(validator_with_index.index);
}
ValidatorStatus::Exited => {
new_deposited.push(validator_with_index.index);
new_exited.push(validator_with_index.index);
}
}
}

Then, the validators in the lido_changed are distributed by comparing the status at the old epoch and new epoch:

for validator_with_index in &validator_delta.lido_changed {
let validator = &validator_with_index.validator;
// It is expected that the caller will filter out non-Lido validators, but worth double-checking
if !validator.is_lido(lido_withdrawal_credentials) {
return Err(Error::NonLidoValidatorInDelta {
index: validator_with_index.index,
withdrawal_credentials: validator.withdrawal_credentials,
});
}

let old_status = validator.status(self.epoch);
let new_status = validator.status(epoch);

match (&old_status, &new_status) {
// @audit: our targeted validators did not exist in the old state, so its old status is determined as FutureDeposit.
// They currently have Deposited status, so those validators would be match this case
(ValidatorStatus::FutureDeposit, ValidatorStatus::Deposited) => {
new_deposited.push(validator_with_index.index);
new_pending_deposit.remove(&validator_with_index.index);
}
...
}
}

Our targeted validators in the current epoch have Deposited status and didn't exist in the old status. The function merge_validator_delta(...) assumes that the existence of the validators was checked before, so it treats non-existing validators as FutureDeposit. Our targeted validators would match the case: (ValidatorStatus::FutureDeposit, ValidatorStatus::Deposited). In this case, the validator is added to the new_deposited and is removed from new_pending_deposit, which contains all pending validators from the old state. Our targeted validators did not exist in the old state, so they won't be in new_pending_deposit. Removal from the HashSet results in removing the element if it exists and returning a boolean value that informs whether the element is present. The operation new_pending_deposit.remove(&validator_with_index.index) doesn't check the return boolean value, which for our targeted validator would be false. The deposited_lido_validator_indices would contain the duplicated values. The compute_from_state(...) function would iterate over duplicated validators and artificially increase the sum of balances stored in cl_balance.

pub fn compute_from_state(...) -> Self {
let mut cl_balance: u64 = 0;

let deposited_indices = &lido_validators_state.deposited_lido_validator_indices;
// @audit: through duplicated validator indices, the sum of balances is artificially increased
for index in deposited_indices {
cl_balance += balances[u64_to_usize(*index)];
}
...
}

Another similar scenario can happen with a validator that is both in all_added and lido_changed, but it currently has an Exited status. In the lido_changed loop, it would match the case (ValidatorStatus::FutureDeposit, ValidatorStatus::Exited). The effects would be similar to those described above, with an additional presence in the new_exited. This scenario, however, is less likely since the validator can be exited 9 days from being activated while the oracle should be run daily. Additionally, the duplicated balance would exist only around 1 day while it's in the exit queue waiting for withdrawal. After withdrawal, the balance duplication is not harmful since it's zero.

Apart from the artificially increased sum of balance, in both scenarios, the Lido state hash that is registered on the chain would be incorrect. This would cause the DOS since the next fair oracle would not be able to publish correct data because the old state hash from the Sp1LidoAccountingReportContract would not match. Submitting the old state that would match the on-chain state would be impossible since the deposited list contains duplicates for which the circuit throws an error. Therefore, the oracle is completely blocked without the possibility of continuation.

Recommendation

Consider ensuring that the validators in the lido_changed are not new validators but were registered in the old state. This could be done by adding a check that the last index in lido_changed is less or equal to the max_validator_index. Additionally, consider checking that the boolean value of new_pending_deposit.remove(...) is equal to true to ensure that it exists before removing it.

Resolution

Criticality of this issue could be discussed. According to LIP-23, the use case of ZK Oracle is only for the negative rebase. This means that increasing CL Balance during attack is useless to confirm the negative rebase. However, in general this issue is valid and fixed. Fixed in PR #12.

Update from Nethermind Security

After discussing with the Lido team to clarify the severity classification, they acknowledged its relevance in the context of zkOracle as a standalone project. Thus, the severity has been retained.

6.2 Arbitrary old state can be used

Classification: Under-constrained.

The report publication includes Report and ReportMetadata. Part of the ReportMetadata is the old_state which is the previous state to which the changes are compared. Expected scenario is that the old_state is the root hash of the state from the last published report.

Diagram from the audit report

However, there are no checks to ensure that. Moreover, the reports can be published in the past, or already published reports can be republished. This allows for many scenarios that would cause incorrect state on-chain that are confusing and not expected:

  • the old state can be the future state: let's assume that there are reports published at x and x+2 slots. The report can be published at the x+1 slot with old_state pointing to the x+2. This is incorrect since the future state can't be an old state. Note that for this to happen between x+1 and x+2 states, there can't be any changes in new validators;
Diagram from the audit report
  • the old state can be current state: let's assume that there is report published at x slot with old state at x-1. The report can be republished at x with old state at x. The old state should not be the current state since that does not prove the correctness of provided data;
Diagram from the audit report

The old state should be based on which the oracle verifies the correctness of changes and shows the history of state change. In both scenarios, the old state does not provide any information. The second scenario is the most dangerous since every report can be republished this way and show old_state as its own state. Note that this does not allow for publishing incorrect data, however the on-chain state reports could not be trusted since they would show that the old state is equal to the current state, which does not prove correct verified change.

Recommendation

Consider adding a requirement in the circuit program that the new state epoch is greater than the old state epoch.

Resolution

Fixed by adding check to the contract. PR #14

6.3 No checks on new state

FileFix commitTypeStatus
main.rsPR #17LowFixed

Classification: Under-constrained.

The proof is to ensure that the new state is correctly constructed (from the old state). Currently this would potentially allow a new invalid state with duplicates to have a proof constructed.

Recommendation

Check that the new state is correctly computed by performing the sorted and unique checks on the new state and also checking the hash tree root of the new state is equivalent to the one passed in.

Specifically, the new state must be verified. In main.rs, after computing the new lido state we propose to perform the following check:

if !Self::is_sorted_and_unique(&mut new_state.deposited_lido_validator_indices.iter()) {
return Err(Error::ConditionCheck(
ConditionCheckFailure::DepositedValidatorsNotSorted,
));
}
// no need to check pending since it is a hash set which would not allow duplicates and also gets sorted once it is converted into a list.

Finally, the code must check that the computed new state root equals the input new state root. This is correctly performed in the code.

Resolution

Accepted and fixed. Fixed at PR #17.

6.4 Inefficient checks for old lido state

FileFix commitTypeStatus
main.rs—InfoAcknowledged

To verify the validity of the old lido state passed as the private input the following checks are performed in input_verification.rs:

  1. Check correct epoch value;
  2. Check if the deposited validator indices are unique and sorted;
  3. Check if the pending deposited validator indices are unique and sorted;

This is performed as follows in the code:

pub fn prove_input(&self, input: &ProgramInput) -> Result<(), Error> {
...
self.cycle_tracker.start_span("prove_input.old_state");
let epoch_from_slot = input.old_lido_validator_state.slot.epoch();
let actual_epoch = input.old_lido_validator_state.epoch;
if actual_epoch != epoch_from_slot {
return Err(Error::ConditionCheck(ConditionCheckFailure::OldStateEpochMismatch {
epoch: actual_epoch,
epoch_from_slot,
}));
}
self.cycle_tracker.start_span("prove_input.old_state.deposited.sorted");
if !Self::is_sorted_and_unique(&mut input.old_lido_validator_state.deposited_lido_validator_indices.iter()) {
return Err(Error::ConditionCheck(
ConditionCheckFailure::DepositedValidatorsNotSorted,
));
}
self.cycle_tracker.end_span("prove_input.old_state.deposited.sorted");

self.cycle_tracker.start_span("prove_input.old_state.pending.sorted");
if !Self::is_sorted_and_unique(
&mut input
.old_lido_validator_state
.pending_deposit_lido_validator_indices
.iter(),
) {
return Err(Error::ConditionCheck(
ConditionCheckFailure::PendingDepositValidatorsNotSorted,
));
}
self.cycle_tracker.end_span("prove_input.old_state.pending.sorted");
self.cycle_tracker.end_span("prove_input.old_state");
...
}

This requires looping through all the indices of two lists and can be expensive depending on the size of lists. Since the old state is trusted these checks do not need to occur for this value.

Recommendation

Since we trust the old state is correctly computed, these checks are redundant. We recommend to pass the old state root as an input in order to replace the checks described above for simplicity and efficiency. We can pass the old state hash in the inputs and do the check they are equal, similarly to the new state hash, and then finally add in the report the calculated value. Specifically, these checks above can be avoided by adding as an additional value in the ProgramInput struct which is the old lido state's hash tree root:

#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
pub struct ProgramInput {
...
pub validators_and_balances: ValsAndBals,
pub old_lido_validator_state: LidoValidatorState,
pub old_lido_validator_state_root: Hash256, // added this
pub new_lido_validator_state_hash: Hash256,
pub withdrawal_vault_data: WithdrawalVaultData,
}

Remove the above checks in input_verification.rs, and in main.rs add the following check:

input.old_lido_validator_state.tree_hash_root() == input.old_lido_validator_state_root

Why this works? As we trust the first hash of the lido state we can thus trust the previous root is correct. Additionally, merkelization results in a different root if validator indices are in incorrect order and/or are not unique so this implicitly checks the sorted and uniqueness of the indices. This provides stronger guarantees of correctness.

Resolution

This optimizes the amount of data and checks to be done in the proof - at a cost of capturing issues much later in the process (at contract). Since this is an info, we would like to keep the checks in place for (1) added security, (2) added convenience in testing (tests failing proof don't need to hit the prover network).

6.5 TimestampOutOfRange error reports parameters in the wrong order

FileFix commitTypeStatus
Sp1LidoAccountingReportContract.sola61db08InfoFixed

The TimestampOutOfRange error is declared as:

error TimestampOutOfRange(uint256 target_slot, uint256 target_timestamp, uint256 earliest_available_timestamp);

However, in the _findBeaconBlockHash function, the earliest and target timestamps are swapped. This can:

function _findBeaconBlockHash(uint256 slot) internal view virtual returns (bytes32) {
uint256 targetBlockTimestamp = _slotToTimestamp(slot + 1);

uint256 earliestBlockTimestamp = block.timestamp - (BEACON_ROOTS_HISTORY_BUFFER_LENGTH * SECONDS_PER_SLOT);
if (targetBlockTimestamp <= earliestBlockTimestamp) {
// @audit the timestamps order are mismatched
revert TimestampOutOfRange(slot, earliestBlockTimestamp, targetBlockTimestamp);
}
...
}

This could produce error data that leads to misinterpretation of error messages.

Recommendation

Consider applying the following change:

-revert TimestampOutOfRange(slot, earliestBlockTimestamp, targetBlockTimestamp);
+revert TimestampOutOfRange(slot, targetBlockTimestamp, earliestBlockTimestamp);

Resolution

Fixed a61db08

6.6 Redundant merkle proof check

FileFix commitTypeStatus
input_verification.rsPR #17InfoFixed

There is a merkle multiproof check that verifies that the validator hash and the balances hash from the inputted beacon state. This can be seen below:

let vals_and_bals_multiproof_leaves = [beacon_state.validators, beacon_state.balances];
beacon_state
.verify_serialized(
&input.validators_and_balances.validators_and_balances_proof,
&[BeaconStateFields::validators, BeaconStateFields::balances],
&vals_and_bals_multiproof_leaves,
)
.map_err(|e| Error::MerkleProofError {
operation: "Verify Inclusion Proof for validators and balances",
error: e,
})?;

As can be seen in the code, the balances and validators hashes are already taken from the beacon state itself which makes proving their inclusion in their own struct redundant.

Recommendation

We recommend to remove this proof from the inputs and not perform this check.

Why can we remove this check since we need to prove the validators and balances changed and added are included in the beacon state? This will not break the chain of proofs leading from validators/balances to beacon root since those hashes in the beacon state are used to verify the inclusion of the validators and balances in question and since the beacon header and root are checked against the public beacon root. This means that we know the validators are included in the validators list which is included in the beacon state which is included in the beacon root (equivalently for the balances).

Existing check for the added validators inclusion proof that proves validators ∈ validator list:

let proof =
merkle_proof::serde::deserialize_proof(&input.validators_and_balances.added_validators_inclusion_proof)
.map_err(|err| Error::MerkleProofError {
operation: "Deserialize all_added Inclusion Proof",
error: err,
})?;
self.verify_validator_inclusion_proof(
&format!("{vals_and_bals_prefix}.validator_inclusion_proofs.all_added"),
total_validators,
&beacon_state.validators,
&input.validators_and_balances.validators_delta.all_added,
proof,

Existing check that beacon state ∈ beacon root:

let beacon_block_header = &input.beacon_block_header;
let beacon_state = &input.beacon_state;

// Beacon Block root == merkle_tree_root(BeaconBlockHeader)
self.cycle_tracker.start_span("prove_input.beacon_block_header");
let bh_root = beacon_block_header.tree_hash_root();
if bh_root != input.beacon_block_hash {
return Err(Error::ConditionCheck(ConditionCheckFailure::BeaconBlockHashMismatch {
actual: bh_root,
expected: input.beacon_block_hash,
}));
}
self.cycle_tracker.end_span("prove_input.beacon_block_header");

// merkle_tree_root(BeaconState) is included into BeaconBlockHeader
self.cycle_tracker.start_span("prove_input.beacon_state");
let bs_root = beacon_state.tree_hash_root();
if bs_root != beacon_block_header.state_root {
return Err(Error::ConditionCheck(ConditionCheckFailure::BeaconStateHashMismatch {
actual: bs_root,
expected: beacon_block_header.state_root,
}));
}
self.cycle_tracker.end_span("prove_input.beacon_state");

On the other hand, should this check be kept we recommend to add the latest_execution_payload_header to the values proven for completeness since it is also used later in the circuit.

Resolution

Fixed. PR #17

6.7 Unnecessary data in lido_changed

FileFix commitTypeStatus
lido.rsPR #17InfoFixed

The data preparation script in validator_delta.rs::compute_changed(...) gathers the validators to lido_changed. All the pending validators from the old state are added. Then, it iterates over the deposited validators in the old state and checks if the activation_epoch has changed:

for index in &self.old_state.deposited_lido_validator_indices {
// for already deposited validators, we want to check if something material has changed:
// this can only be an activation epoch or exist epoch. Theoretically "slashed" can also be
// relevant, but for now we have no use for it
let index_usize = u64_to_usize(*index);
let old_validator = &self.old_bs.validators[index_usize];
let new_validator = &self.new_bs.validators[index_usize];

if !self.skip_verification && old_validator.pubkey != new_validator.pubkey {
return Err(Error::ValidatorPubkeyMismatch {
index: *index,
old: old_validator.pubkey.clone(),
new: new_validator.pubkey.clone(),
});
}
if check_epoch_based_change(
old_bs_epoch,
new_bs_epoch,
old_validator.exit_epoch,
new_validator.exit_epoch,
) {
lido_changed_indices.insert(*index);
}
if check_epoch_based_change(
old_bs_epoch,
new_bs_epoch,
old_validator.activation_epoch,
new_validator.activation_epoch,
) {
lido_changed_indices.insert(*index);
}
}

In case the activation_epoch is changed for a validator, then it is included in the lido_changed. The circuit handles lido_changed in the lido.rs::merge_validator_delta(...). In the presented scenario, our validator previously had Deposited since it was in the deposited validators. The merge_validator_delta(...) would check its status, and the new status would also be Deposited. It would match the (ValidatorStatus::Deposited, ValidatorStatus::Deposited) case, which would result in no operation.

Recommendation

Consider not including the validators that had Deposited status in the old state to the lido_changed if their activation epoch changed. This would save computation and proving time.

Resolution

Fixed. PR #17

6.8 Update crates/program rust-toolchain

FileFix commitTypeStatus
crates/program/rust-toolchainPR #11InfoFixed

Originally, the channel had to be 1.82 because of SP1. As specified in the code below:

> ==== cargo edition and rustc compatibility ====
> These dependencies are pinned to a concrete version as a compatibility mechanism between new version of the crates
> (moving on oo edition=2024 and rust-version=1.85) and sp1 compiler that's based on rustc=1.82
> This can be unpinned when sp1 rustc moves to at least 1.85
> 1.2.0 requires rust >= 1.85, sp1 uses 1.82-dev
> Note: there are overrides in script and service Cargo.toml in dev-dependencies - needs to be updated there as well

sp1 rustc has moved to at least 1.85, so the value can be updated to maintain consistency, reduce maintenance overheads and access the bug fixes included in 1.85

Recommendation

Change channel = "1.82" to channel = "1.85".

Resolution

Fixed. PR #11

6.9 Unchecked integer overflows

Some arithmetic operations in the codebase are performed without explicit overflow checks. Since SP1 operates over release mode, overflow behavior does not panic no immediate security vulnerability has been identified, as overflows would require unrealistically large inputs. However, the absence of explicit checks could lead to unexpected behavior if malicious input is used.

Recommendation

Consider integrating checks like checked_add() to the calculations e.g. of the accumulated balance and total validators. Using overflow-checks = true on Cargo.toml won't be enough on the SP1 platform.

Resolution

Fixed. PR #16 and PR #22

7

Documentation Evaluation

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:

  • Technical whitepaper: A technical whitepaper is a comprehensive document describing the smart contract’s design and technical details. It includes information about the purpose of the contract, its architecture, its components, and how they interact with each other;
  • User manual: A user manual is a document that provides information about how to use the smart contract. It includes step-by-step instructions on how to perform various tasks and explains the different features and functionalities of the contract;
  • Code documentation: Code documentation is a document that provides details about the code of the smart contract. It includes information about the functions, variables, and classes used in the code, as well as explanations of how they work;
  • API documentation: API documentation is a document that provides information about the API (Application Programming Interface) of the smart contract. It includes details about the methods, parameters, and responses that can be used to interact with the contract;
  • Testing documentation: Testing documentation is a document that provides information about how the smart contract was tested. It includes details about the test cases that were used, the results of the tests, and any issues that were identified during testing;
  • Audit documentation: Audit documentation includes reports, notes, and other materials related to the security audit of the smart contract. This type of documentation is critical in ensuring that the smart contract is secure and free from vulnerabilities.

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.

8

Remarks & Test Suite

The Lido Accounting ZK Oracle protocol documentation was made available through lip-23 and oracle-report-sanity-checker with a high-level overview. In addition, the team provided audit-specific documentation, detailing the core contracts and files, outlining the flow step by step with comments in the code. Furthermore, the Lido team thoroughly addressed all questions and concerns raised by the Nethermind Security team, providing valuable insights and a comprehensive understanding of the project's technical aspects.

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.