Prepared by:
HALBORN
Last Updated 05/27/2025
Date of Engagement: April 21st, 2025 - April 28th, 2025
100% of all REPORTED Findings have been addressed
All findings
10
Critical
0
High
0
Medium
1
Low
3
Informational
6
Odra
engaged Halborn to conduct a security assessment of the Liquid Staking contract, beginning on April 21st, 2025 and ending on April 28th, 2025. This security assessment was scoped to the smart contracts in the liquid-staking-contracts GitHub repository.
This project implements a Liquid Staking protocol for CSPR on the Casper blockchain. It allows users to stake CSPR and receive a fungible token, sCSPR, which represents their share of the staked assets and can be used freely while earning rewards.
The team at Halborn assigned a full-time security engineer to verify the security of the smart contracts. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that smart contract functions operate as intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were successfully addressed by the Odra team
. The main ones were the following:
Ensure remove_validator calls collect_fee() before undelegation and updates last_recorded_delegated_amount after.
Expose a secure admin-only ownership transfer function, either via delegation or a custom wrapper.
Enforce a maximum fee_percentage (e.g., 20%) and revert in init() if it exceeds 10,000 basis points (100%).
Restrict withdraw_from_the_contract to only allow withdrawal of CSPR explicitly contributed by the add_to_the_pool function, and enforce automatic or mandatory re-delegation after any undelegation triggered by remove_validator or remove_from_the_pool.
Halborn performed a combination of the manual view of the code and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of the smart contract assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation, automated testing techniques help enhance the coverage of smart contracts. They can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the assessment:
Research into architecture, purpose, and use of the platform.
Manual code read and walk through.
Manual Assessment of use and safety for the critical Rust variables and functions in scope to identify any arithmetic related vulnerability classes.
Cross contract call controls.
Architecture related logical controls.
Scanning of Rust files for vulnerabilities (cargo audit
)
Test analysis using the BDD system deployed with Cucumber
tool.
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
1
Low
3
Informational
6
Security analysis | Risk level | Remediation Date |
---|---|---|
Validator removal bypasses fee collection and breaks delegation tracking | Medium | Solved - 05/21/2025 |
Admin can extract backed CSPR and devalue sCSPR through stake manipulation | Low | Solved - 05/14/2025 |
Contract lacks mechanism to transfer ownership after deployment | Low | Solved - 05/07/2025 |
Lack of validation on fee percentage allows inflation abuse | Low | Solved - 05/05/2025 |
Users can delegate CSPR without receiving sCSPR | Informational | Solved - 05/06/2025 |
Predictable validator selection due to weak randomness | Informational | Solved - 05/07/2025 |
Lack of error handling when adding an already registered validator | Informational | Solved - 05/05/2025 |
Unused constant MIN_STAKE and lack of validation allow unsafe initialization | Informational | Solved - 05/05/2025 |
Unmodifiable claim time prevents governance from adapting unstaking delays in the future | Informational | Solved - 05/05/2025 |
Incorrect sCSPR-to-CSPR conversion when no backing stake or supply exists | Informational | Solved - 05/06/2025 |
//
The remove_validator
function is responsible for removing a validator from the internal list and undelegating any stake associated with it. However, before calling undelegate
, the function does not invoke collect_fee()
, nor does it update last_recorded_delegated_amount
after the undelegation.
This leads to two significant issues:
Unclaimed protocol fees
If staking rewards have accrued since the last call to collect_fee()
, and remove_validator
reduces the total delegated stake, those rewards are lost from accounting. Since last_recorded_delegated_amount
remains higher than the new actual stake, future calls to collect_fee()
will observe a negative delta and skip fee minting, even though rewards have been generated.
Inconsistent stake tracking
After undelegation, last_recorded_delegated_amount
becomes outdated. This can undermine the accuracy of subsequent reward calculations and may result in silent fee suppression or unpredictable protocol behavior.
This inconsistency breaks the otherwise well-aligned accounting logic of the contract and may lead to economic discrepancies over time.
Code of remove_validator
function from Token contract.
pub fn remove_validator(&mut self, public_key: PublicKey) {
self.ownable.assert_owner(&self.env().caller());
let mut validators = self.validators.get_or_default();
let mut removed = false;
// Find the position of the validator in the list
if let Some(position) = validators.iter().position(|v| v == &public_key) {
// Only remove if the validator exists
validators.remove(position);
self.validators.set(validators);
// Emit event for removing a validator
self.env().emit_event(ValidatorRemoved {
validator: public_key.clone(),
});
// Unstake all the stake from the validator, but only if there is some stake
let cspr_amount = self.env().delegated_amount(public_key.clone());
if cspr_amount > U512::zero() {
self.undelegate(public_key, cspr_amount);
}
removed = true;
}
// If validator doesn't exist, throw
if !removed {
self.env().revert(ValidatorNotInList);
}
}
It is recommended to update remove_validator
to:
Call collect_fee(self.staked_cspr())
before calling undelegate.
Call self.last_recorded_delegated_amount.set(self.staked_cspr())
after the undelegation is completed. This aligns its behavior with the rest of the contract and ensures fee logic remains correct and rewards are not silently skipped.
SOLVED: The Odra team resolved this issue by adding fee collection and stake tracking logic, along with additional tracking for undelegated stake associated with removed validators.
The following two commits addressed the remaining issues found in the previous implementation:
31794c32259957f1b79d555e713a44520742761f
2531b725427110389eae8c265f46131e907402db
Regarding the strategy used to manage undelegated stake, the client has confirmed that this behavior — where undelegated tokens remain accounted for in the total staked amount and are tracked separately via removed_validator_stake
— is intentional. They stated:
Removing a validator is an edge case in the system — we don’t expect to use this feature often, if at all. However, if it does happen, we believe it’s better to show the “stable” price, since the plan is to restake the tokens as soon as they become available.
//
The contract exposes a design flaw whereby the administrator can reduce the backing of sCSPR by selectively undelegating staked CSPR using remove_from_the_pool
or remove_validator
, and later extracting that undelegated stake through withdraw_from_the_contract
.
These operations do not burn sCSPR, nor enforce re-delegation. The function restake_loose_tokens
exists to rebalance the system, but:
It is optional, not enforced automatically.
Its execution is at the sole discretion of the admin.
As a result, the admin may withdraw CSPR that was originally used to back issued sCSPR, thereby reducing the total staked_cspr while leaving the supply unchanged. This silently devalues all sCSPR in circulation, violating the expectation that 1 sCSPR ≈ share of total delegated CSPR.
Additionally, because the contract does not differentiate between CSPR deposited by users via stake()
and CSPR injected via add_to_the_pool()
, there is no safeguard ensuring that only the latter can be withdrawn. This creates a silent value extraction vector and opens the door to economic manipulation of the sCSPR token.
It is recommended to:
Enforce that any undelegation of stake (via remove_from_the_pool
or remove_validator
) must either:
Be followed by re-delegation of the same amount, or
Be matched by a proportional burn or lock of sCSPR.
Track CSPR origin internally: distinguish between admin-injected and user-deposited stake, and restrict withdraw_from_the_contract
to the admin's own share.
SOLVED: The Odra team has solved this issue by removing the over-powered functions add_to_the_pool
, remove_from_the_pool_
and withdraw_from_the_contract
.
//
The StakedCSPR
contract does not provide any method to transfer ownership after initialization. While the admin is correctly set during deployment via self.ownable.init(admin)
, none of the Ownable
module’s transfer functions — such as transfer_ownership
— are exposed through delegation or wrapped explicitly.
As a result, the admin role is permanently locked to the deployer address, introducing several operational risks:
If the admin key is lost or compromised, the contract becomes unmanageable.
Governance cannot be transferred to a DAO or multisig in the future.
The inability to rotate administrative privileges violates best practices for maintainable and upgradeable smart contracts.
It is recommended to expose a secure function to transfer ownership. This can be done by delegating transfer_ownership
through delegate!
, or by creating a custom wrapper function (e.g., change_admin(new_admin)
) protected with self.ownable.assert_owner(...)
and paired with an event emission for transparency.
SOLVED: The Odra team solved this issue by inheriting from the Ownable2Step module.
//
The fee_percentage
parameter, which determines the amount of sCSPR minted to the admin as a commission on staking rewards, is accepted without any validation in the init
function. This omission allows the contract to be deployed with invalid or abusive values, including 100%
or higher, effectively enabling the admin to mint all staking rewards — or more — as sCSPR. Such behavior presents a severe economic integrity risk, as it distorts staking incentives and allows the value of sCSPR to be arbitrarily inflated, undermining trust in the protocol.
Additionally, there is no enforced upper bound to ensure sane configuration. Leaving this unbounded introduces the possibility of accidental misconfiguration or malicious exploitation.
Code of init
function from Token contract.
pub fn init(
&mut self,
validator_address: PublicKey,
claim_time: u64,
fee_percentage: U512,
min_stake: U512,
) {
let admin = self.env().caller();
// Grant the admin role
self.ownable.init(admin);
// Initialize the validator address.
self.validators.set(vec![validator_address]);
// Initialize the claim time.
self.claim_time.set(claim_time);
// Initialize the token.
self.token.init(
String::from("sCSPR"),
String::from("Staked CSPR"),
9,
U256::zero(),
vec![],
vec![],
Some(Cep18Modality::None),
);
self.fee_percentage.set(fee_percentage);
self.last_recorded_delegated_amount.set(U512::zero());
self.min_stake.set(min_stake);
}
It is recommended to:
Enforce a strict upper limit: fee_percentage < 10_000
(i.e., less than 100%).
Introduce a hardcoded safe maximum (e.g., MAX_FEE_PERCENTAGE
= 2_000, or 20%) as an upper bound for economic fairness and protocol stability.
Revert in init()
if the provided fee exceeds either of these bounds, with an explicit error (e.g., InvalidFeePercentage
).
SOLVED: The Odra team
has solved this issue by adding a constant upper limit to the fee percentage. In addition, they introduced an admin-only function to modify this value in the commit 89d859fbad9c2e6999ac621cade9514cb48ff89b
//
The function add_to_the_pool
can be called by any user without any access control. While this does not result in direct fund loss, users can unintentionally or maliciously delegate CSPR to the contract without receiving sCSPR in return. This creates a user experience risk, as users might believe they are staking and earning yield, when in fact they are simply donating funds to the contract. Moreover, an attacker could craft malicious interfaces or frontends that deceive users into calling this function, leading to confusion and potential loss of trust in the system.
In contrast, the counterpart function remove_from_the_pool
is protected by an ownership check (ownable.assert_owner(...)
), ensuring that only the admin can reduce the pool’s delegated stake. This asymmetry in access control may be intentional for system management purposes, but it should be explicitly justified in the documentation and reflected in the user interface to avoid misunderstandings or abuse vectors.
Code of add_to_the_pool
function from Token contract.
pub fn add_to_the_pool(&mut self) {
let attached_value = self.env().attached_value();
self.assert_min_stake(attached_value);
let staked_cspr = self.staked_cspr();
self.collect_fee(staked_cspr);
// Get random validators and verify that we have at least one
let validators = self.get_random_validators(1);
if validators.is_empty() {
self.env().revert(MisconfiguredValidator);
}
self.delegate(validators[0].clone(), self.env().attached_value());
// Emit event for adding CSPR to the pool
self.env().emit_event(CsprAddedToPool {
amount: attached_value,
});
self.last_recorded_delegated_amount
.set(staked_cspr + attached_value);
}
It is recommended to protect add_to_the_pool
with an ownership check using self.ownable.assert_owner(...)
if the intention is to reserve its usage for administrative purposes. Alternatively, if public usage is desired, it should be clearly documented and restricted via frontend to avoid misuse or confusion.
SOLVED: The Odra team solved this issue by renaming the function to make it clear that it does not perform staking. The responsibility for depositing is explicitly left to the user.
//
The function get_random_validators_indices
uses the current block_time
as the sole source of entropy to pseudo-randomly select validators from the contract’s internal list. Since block_time
is deterministic and known to the caller at the time of transaction execution, the validator selection process becomes entirely predictable.
While this behavior is explicitly acknowledged in code comments as being “sufficient” for the intended design, it contradicts the principles of fairness and neutrality expected from a staking delegation mechanism.
Because functions like stake()
and add_to_the_pool()
are publicly callable, any user can simulate the selection off-chain and determine which validator will be chosen in a given block, enabling some exploit scenarios:
Malicious frontends or interfaces can conceal validator selection logic from users and only permit staking when the contract is about to delegate to a validator controlled by the attacker, thereby redirecting public stake to enrich specific nodes.
Some validators may be consistently favored, particularly if selection is not uniformly randomized or rotated.
These behaviors do not violate protocol rules directly, but they break user expectations of fairness and decentralization, while providing unfair advantages to those with knowledge of the internal selection mechanism.
Code of get_random_validator_indices
function from Token contract.
fn get_random_validator_indices(
&self,
amount: usize,
validators_count: usize,
seed: usize,
) -> Vec<usize> {
// Early return if validators_count is 0
if validators_count == 0 {
return Vec::new();
}
// Cap the amount at the number of validators
let validators_cap = amount.min(validators_count);
let mut all_indices: Vec<usize> = (0..validators_count).collect();
let mut selected_indices = Vec::with_capacity(validators_cap);
// Select unique indices using a deterministic but pseudo-random approach
for i in 0..validators_cap {
// Determine next position based on seed and current iteration
let pos = if all_indices.is_empty() {
break;
} else {
(seed + i * 17) % all_indices.len()
};
// Take the index at that position
let selected = all_indices.remove(pos);
selected_indices.push(selected);
}
selected_indices
}
It is recommended to implement a more robust and fair validator selection mechanism. Potential solutions include:
Replacing block_time
with more secure or less predictable entropy sources (e.g., previous block hashes, block_height, or future on-chain randomness like VRF).
Implementing deterministic but rotational validator selection (e.g., round-robin or stake-weighted rotation).
Selecting multiple validators and distributing stake proportionally or equally, to reduce sensitivity to a single selection outcome.
SOLVED: The Odra team has solved this issue by using a new Odra feature, pseudorandom_bytes
, which uses Casper's host pseudorandom generator.
//
The add_validator
function does not revert or return any error when the provided validator already exists in the internal list. Instead, it silently skips the operation. The lack of an explicit error (e.g., ValidatorAlreadyExists
) makes it difficult to detect logic mistakes or incorrect assumptions in the systems that manage validator registration.
Furthermore, this silent behavior could mislead the admin into thinking that a validator was successfully added, when in fact the function had no effect, potentially leading to governance inconsistencies or operational delays.
Code of add_validator
function from Token contract.
pub fn add_validator(&mut self, public_key: PublicKey) {
self.ownable.assert_owner(&self.env().caller());
let mut validators = self.validators.get_or_default();
// Check if the validator already exists in the list
if !validators.contains(&public_key) {
validators.push(public_key.clone());
self.validators.set(validators);
// Emit event for adding a validator
self.env().emit_event(ValidatorAdded {
validator: public_key,
});
}
}
It is recommended to introduce a specific error variant in the Error enum (e.g., ValidatorAlreadyExists
) and add a check at the beginning of add_validator
that reverts if the validator is already present in the list.
SOLVED: The Odra team has solved this issue by implementing the recommended fix.
//
The contract defines a constant MIN_STAKE = 500_000_000_000
, which implies it represents a critical minimum stake threshold. However, this constant is neither enforced nor referenced in the contract logic, nor is it used as a fallback value in the init()
function.
Instead, min_stake
is accepted as a user-provided parameter during deployment, with no validation, including the possibility of setting it to 0
. This leads to two key issues:
The presence of a hardcoded constant that has no functional impact is misleading and may lead to incorrect assumptions about staking requirements.
Allowing a min_stake = 0
opens the protocol to zero-value staking or pool interactions, which can:
Increase storage bloat through micro-stakes,
Lead to inefficient delegation behavior, and
Reduce economic incentives, weakening the robustness of the protocol’s staking model.
It is recommended to either:
Enforce MIN_STAKE
as a required minimum in the init()
function, rejecting any values below it, or
Remove MIN_STAKE
entirely from the codebase and use an optional min_stake: Option<U512>
parameter with appropriate fallback logic (unwrap_or(default)
), while also ensuring that the effective min_stake
is strictly greater than zero to preserve economic integrity.
SOLVED: The Odra team has solved this issue by checking the min_stake
value on init.
//
The claim_time
parameter defines the mandatory delay between an unstake
action and the moment when the user is allowed to claim their CSPR. However, the contract does not expose any function to update this parameter after deployment. As a result, the delay is permanently fixed to the value set during initialization.
This immutability introduces governance and operational rigidity, as the protocol lacks the flexibility to:
Adjust the claim delay in response to changing network conditions, validator performance, or community governance decisions.
Correct misconfigurations made during deployment (e.g., delay too short or too long) without redeploying the contract, which may result in loss of contract state or user balances.
Conduct protocol upgrades or economic experiments involving unbonding dynamics and withdrawal policies.
This fixed behavior limits the protocol's adaptability and can hinder its long-term evolution.
It is recommended to expose a secure admin-only function (e.g., set_claim_time
) that allows updating the claim_time
value.
SOLVED: The Odra team solved this issue by adding an admin-only function to modify the claim_time
.
//
The scspr_to_cspr
function is responsible for converting sCSPR into its underlying CSPR value. It includes conditional checks for edge cases where total_supply == 0
or staked_cspr == 0
, but its current behavior returns the original sCSPR value (scspr.to_u512()
) in both cases, as if fully backed.
This design introduces subtle inconsistencies:
The case of total_supply == 0
is unreachable in practice, since users cannot hold or redeem sCSPR without it having been minted. However, the check can be retained as passive defense, and in such a case, the function should return a realistic fallback, such as 0
, instead of assuming full value.
In the case where staked_cspr == 0
, which is reachable under administrative undelegation or system reset, returning a 1:1 conversion misrepresents the actual backing. Even though the unstake()
function will later fail at the undelegate_from_validators
stage, this function should still return 0
or revert to reflect the absence of collateral and prevent misleading results in off-chain calls or partial integrations.
Code of scspr_to_cspr
function from Token contract.
fn scspr_to_cspr(&self, scspr: U256) -> U512 {
// If there's no sCSPR being converted, return 0
if scspr.is_zero() {
return U512::zero();
}
let scspr_total_supply = self.token.total_supply().to_u512();
if scspr_total_supply.is_zero() {
return scspr.to_u512();
}
let staked_cspr = self.staked_cspr();
// If there's no staked CSPR, conversion would be 1:1
if staked_cspr.is_zero() {
return scspr.to_u512();
}
// Calculate CSPR amount using the formula: scspr * staked_cspr / scspr_total_supply
// To minimize rounding errors, we multiply first, then divide
// If there are some value leakage, it can be restaked using the restake_loose_tokens function
(scspr).to_u512() * staked_cspr / scspr_total_supply
}
It is recommended to update the function scspr_to_cspr
to handle these edge cases defensively. Specifically, if scspr_total_supply == 0 or staked_cspr == 0
, the function should return 0 or revert with an error such as NoBackingForRedemption
.
SOLVED: The Odra team has solved this issue by reverting the transaction in the edge cases.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
Odra - Liquid Staking
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed