Soroban zkCrossDex - ZKCross


Prepared by:

Halborn Logo

HALBORN

Last Updated 09/15/2025

Date of Engagement: August 18th, 2025 - August 22nd, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

13

Critical

0

High

0

Medium

0

Low

6

Informational

7


1. Introduction

The security assessment was commissioned by ZKCross, a cross-chain interoperability protocol focused on DeFi infrastructure, to assess the security and robustness of the Rust-based Stellar LockAndRelease smart contract. The assessment was performed by Halborn’s experienced security team, focusing on the code released at commit faa29f7. The review covered all functionality in contracts/lock_release/src/lib.rs between August 25th, 2025, and August 27th, 2025. The primary objective of this engagement’s core purpose was to identify vulnerabilities, ensure protocol reliability and strengthen overall security.

2. Assessment Summary

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 contract


In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were addressed and properly solved by the ZKCross team. The main findings were the following:

    • Reject lock() calls if RevenueSet has not yet been established, or allow the owner to sweep residual fees once a revenue address has been configured.

    • Introduce a pending state with expiry for each lock; admins must release it before expiry, or users can refund.

    • Add a global paused flag, controlled by the owner/admin, to restrict lock/release actions.

    • Enforce percentage in the range 1..=2000, or handle 0 as a special case by skipping min_amount and safely setting fee = 0.


3. Test Approach and Methodology

The assessment combined structured manual code review, requirements verification, and automated testing. Key steps included:

    • Review of functional and threat modeling documentation (data flows, STRIDE methodology)
    • Manual source review for logic errors, state machine soundness, replay/reentrancy surfaces, and access control correctness
    • Execution of the project’s public unit and on-chain test suite to validate specification adherence and stress system invariants

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (mem_e)METRIC VALUENUMERICAL 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
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL 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
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

REPOSITORY
(b) Assessed Commit ID: faa29f7
(c) Items in scope:
  • contracts/lock_release/src/lib.rs
Out-of-Scope: Third party dependencies and economic attacks.
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

0

Low

6

Informational

7

Security analysisRisk levelRemediation Date
Protocol Revenue Permanently Lost When Revenue Address Not SetLowSolved - 09/08/2025
User Funds Stuck Without Recourse When Cross-Chain Transfer FailsLowSolved - 09/15/2025
Contract Cannot Be Halted During Security IncidentsLowSolved - 09/08/2025
Disabled Lock Feature When Fee Set to ZeroLowSolved - 09/08/2025
USDC Minimum Protection Ineffective on Stellar NetworkLowSolved - 09/08/2025
Missing Revenue TrackingLowSolved - 09/08/2025
Revenue Address Cannot Be Changed If CompromisedInformationalSolved - 09/08/2025
Lock-hash lifecycle mis-management allows either replay attacks or rent DoSInformationalSolved - 09/08/2025
External token calls and reentrancy surfaces in lock and releaseInformationalSolved - 09/08/2025
Indexer Confusion from Redundant Token ParametersInformationalSolved - 09/08/2025
Event Monitoring Blind Spots from Inconsistent NamingInformationalSolved - 09/08/2025
Unnecessary Computation Overhead in Validation PathInformationalSolved - 09/08/2025
Performance Overhead from Unnecessary Memory CloningInformationalSolved - 09/08/2025

7. Findings & Tech Details

7.1 Protocol Revenue Permanently Lost When Revenue Address Not Set

//

Low

Description

If users call lock() before the owner sets a revenue address, the fee portion of each deposit is transferred to the contract itself and remains there indefinitely because the contract does not provide a withdrawal function for those stranded tokens. When the owner later calls set_revenue_address, only future fees are forwarded— the already stranded balance remains locked inside the contract with no mechanism for recovery.

Code Location

Code snippet from the lock function:

#[cfg(not(test))]
token::Client::new(&env, &from_token).transfer(&user_address, &env.current_contract_address(), &in_amount);

let admin_data: AdminData = env.storage().instance().get(&DataKey::Admin).unwrap();
let admin_address = admin_data.admin_address;

#[cfg(not(test))]
token::Client::new(&env, &from_token).transfer(&env.current_contract_address(), &admin_address, &swapped_amount);

#[cfg(not(test))]
if env.storage().instance().has(&DataKey::Revenue) {
    let revenue_data: RevenueData = env.storage().instance().get(&DataKey::Revenue).unwrap();
    token::Client::new(&env, &from_token).transfer(
        &env.current_contract_address(),
        &revenue_data.revenue_address,
        &fee,
    );
}

BVSS
Recommendation

Reject lock() calls if RevenueSet has not yet been established, or allow the owner to sweep residual fees once a revenue address has been configured.

Remediation Comment

SOLVED: The zkCross team solved this issue by implementing:

  • A mandatory check to reject lock() calls when RevenueSet has not been established.

  • A sweep_accumulated_revenue() function to enable the contract owner to recover accumulated fees for specified tokens.

  • Per-token accumulated revenue tracking (AccumulatedRevenue(Address)).


Remediation Hash

7.2 User Funds Stuck Without Recourse When Cross-Chain Transfer Fails

//

Low

Description

After lock, the full swapped_amount is transmitted to the administrator immediately. If the cross-chain operation fails or stalls, users lack an on-chain mechanism to recover their funds. The current model depends entirely on off-chain processes and administrator discretion for refunds.

This situation can leave user funds permanently inaccessible if the relayer or destination chain encounters an issue, potentially undermining trust and leading to increased support and liability challenges for operators.

BVSS
Recommendation

Introduce a pending state with an associated expiry: record lock_hash -> pending(amount, user, expiry). Require the administrator to release the pending transaction before expiry. If not released in time, the user may invoke refund(lock_hash) to recover the funds from the contract balance. Alternatively, implement an explicit refund function for administrators, accompanied by auditable events.

Remediation Comment

SOLVED: The zkCross team solved this issue in the following commit IDs:


The team restructured the flow so user funds remain in the contract until confirmed processing. Specifically: after lock, funds are stored as a pending lock with a 24h TTL; a new atomic process_and_mark_lock moves funds to the admin and simultaneously records ProcessedLocks (only if not expired), preventing double-processing; an admin-auth refund path now returns funds to the user after expiry.

Remediation Hash

7.3 Contract Cannot Be Halted During Security Incidents

//

Low

Description

The contract does not include an owner- or administrator-controlled pause function to swiftly halt lock and/or release operations in response to emergencies such as security breaches, pricing anomalies, or integration failures.


This absence could allow continued inflows or outflows during critical incidents, thereby enlarging the incident impact and increasing operational losses before off-chain mitigation measures are implemented.

BVSS
Recommendation

Add a global paused flag and restrict lock/release operations behind this flag, which can only be controlled by the owner or administrator through pause/unpause functions. Emit PauseEvent and UnpauseEvent to signal these state changes. Optionally, enable selective pausing, such as pausing only the release functionality. Consider implementing multi-signature or time-lock governance mechanisms to regulate pause state transitions.

Remediation Comment

SOLVED: The zkCross team addressed this issue through the following changes:

  • A new Paused flag was added to storage

  • New Functions:

    • pause() — an owner-restricted function implemented to halt operations

    • unpause() — an owner-restricted function implemented to resume operations

  • Integration: Both lock() and release() now verify the paused state prior to execution


Remediation Hash

7.4 Disabled Lock Feature When Fee Set to Zero

//

Low

Description

set_fee_percentage permits setting percentage to zero. In lock, min_amount is calculated as ceil(10_000 / fee_bps). If fee_bps == 0, this division causes a panic, rendering the lock function unusable for all users until the fee is modified by the owner.


This situation could lead to a complete denial of service for the lock function if the fee is inadvertently or maliciously set to zero.

Code Location

Code of the set_fee_percentage function:

pub fn set_fee_percentage(env: Env, percentage: u32) {
    let owner: Address = env.storage().instance().get(&DataKey::Owner).unwrap();
    owner.require_auth();
    
    if percentage > 2000 {
        env.panic_with_error(Error::from_type_and_code(
            ScErrorType::Contract,
            ScErrorCode::InvalidAction,
        ));
    }
    env.storage().instance().set(&DataKey::FeePercentage, &percentage);
    env.events().publish(("set_fee_percentage_event",), percentage);
}

Code snippet from the lock function:

let fee_bps: u32 = env
    .storage()
    .instance()
    .get(&DataKey::FeePercentage)
    .unwrap_or(30);

// 1) Compute raw fee in stroops
//    fee = floor(in_amount * fee_bps / 10_000)
let fee = in_amount
    .checked_mul(fee_bps as i128)
    .expect("overflow")
    / 10_000;

// 2) Enforce a minimum in_amount so that fee >= 1 stroop:
//    We need in_amount * fee_bps / 10_000 >= 1
//    ⟹ in_amount >= ceil(10_000 / fee_bps)
let min_amount: i128 = {
    let numerator = 10_000i128 + fee_bps as i128 - 1;
    numerator / (fee_bps as i128)
};

BVSS
Recommendation

Disallow percentage == 0 by enforcing a valid range 1..=2000, or handle the specific case when percentage is zero to skip min_amount calculation and set fee = 0 safely.

Remediation Comment

SOLVED: The zkCross team solved this issue by handling the case where percentage equals zero; the min_amount calculation was skipped and fee = 0 was applied safely.

Remediation Hash

7.5 USDC Minimum Protection Ineffective on Stellar Network

//

Low

Description

The contract hardcodes a minimum USDC input check of 2_000_000, implicitly assuming 6 decimal places ("2 USDC"). However, on Stellar, issued assets such as USDC are represented with 7 decimal places; therefore, 2_000_000 corresponds to only 0.2 USDC, not 2.0 USDC. As a result, the intended minimum threshold is ineffective on Stellar.


This discrepancy could lead to acceptance of USDC amounts below the intended minimum (e.g., 0.2 USDC instead of 2.0), thereby undermining the intended economic protections.

BVSS
Recommendation

Retrieve the token's decimals metadata from the token contract and calculate the minimum amount dynamically.

Remediation Comment

SOLVED: The zkCross team solved this issue through setting the minimum locked amount for USDC to 20,000,000 stroops, corresponding to 2 USDC on the Stellar network.

Remediation Hash

7.6 Missing Revenue Tracking

//

Low

Description

AccumulatedRevenue is initialized to 0 in set_revenue_address but is neither updated nor accessed thereafter. Since the fee is computed within the lock function, this key should be incremented by the fee on each invocation to enable proper on-chain accounting and support deferred sweeping when Revenue is not yet configured. Currently, fees are transferred directly to revenue_address (if set) or remain stranded within the contract without a ledger of what is owed.


This structure risks missing on-chain records of collected fees, complicating reconciliation and obscuring stranded balances when Revenue is unset.

BVSS
Recommendation

Either remove AccumulatedRevenue entirely, or implement it correctly:

  • Increment an accumulated counter during lock by the computed fee.

  • If Revenue is unset, record the fee in the accumulator instead of silently discarding it; when Revenue is set, allow an owner- or admin-only sweep_accumulated_revenue(token) function that transfers the accumulated amount to revenue_address and resets the counter.

  • Prefer per-token accounting (e.g., AccumulatedRevenue(Address)) keyed by token to prevent mixing assets within a single i128.

  • Emit events upon accumulation and sweep actions to facilitate auditability and off-chain reconciliation.


Remediation Comment

SOLVED: The zkCross team solved this issue by Implementing an accurate Per-Token revenue tracking:

  • Data Structure: Introduced AccumulatedRevenue(address) mapping, keyed by token address, to store collected fees per token.

  • Functionality:

    • Revenue is now accurately tracked per token during lock() operations.

    • Added sweep_accumulated_revenue(token) function, enabling the contract owner to withdraw accumulated fees for a specific token.

    • Emitted relevant events to ensure transparency and support auditability.


Remediation Hash

7.7 Revenue Address Cannot Be Changed If Compromised

//

Informational

Description

The set_revenue_address() function is permanently disabled after its initial execution through the RevenueSet flag. If the initially configured revenue account is lost, compromised, or requires rotation for operational reasons, the contract does not provide a mechanism to update it.

This limitation could lead to irrevocable loss of future protocol revenue or result in ransom scenarios if the associated key is compromised.

Code Location

Code snippet from the set_revenue_address() function:

pub fn set_revenue_address(env: Env, revenue_address: Address) {
    if env.storage().instance().has(&DataKey::RevenueSet) {
        env.panic_with_error(Error::from_type_and_code(
            ScErrorType::Contract,
            ScErrorCode::InvalidAction,
        ));
    }
    // ...
}

BVSS
Recommendation

Replace the existing one-time pattern with an owner-only update_revenue_address() function that emits an event and enforces explicit authentication.

Remediation Comment

SOLVED: The zkCross team solved this issue in the specified commit ID.

Remediation Hash

7.8 Lock-hash lifecycle mis-management allows either replay attacks or rent DoS

//

Informational

Description

After each successful release, the contract records a LockHashStatus { processed: true } entry under the corresponding lock-hash key. If these markers are retained indefinitely, the storage set will grow unboundedly, leading to increased rent costs that the administrator must continually cover. Conversely, manual removal of these entries via remove_old_lock_hash reuses the same identifiers, which compromises the bridge’s critical replay protection.


This vulnerability could lead to double-release of funds if a deleted hash is replayed, or result in a denial-of-service condition if rising rent costs cause the contract to be evicted due to insufficient balance replenishment by the administrator.

BVSS
Recommendation

Deprecate remove_old_lock_hash in favor of setting a conservative automatic TTL (e.g., 90 days) when a hash is marked processed. This approach relies on Soroban's expiry mechanism to garbage-collect the hash after the replay window has safely closed. Maintain the extend_lock_hash_ttl helper (admin-only) to allow operators to extend the TTL of individual hashes if downstream finality requires additional time. Emit an event on any TTL modification to enhance auditability.

Remediation Comment

SOLVED: The zkCross team solved this issue in the specified commit ID.

Remediation Hash

7.9 External token calls and reentrancy surfaces in lock and release

//

Informational

Description

Both lock and release functions perform external calls to token contracts:

  • lock transfers tokens from the user to the contract, then to the administrator, and emits a LockEvent.

  • release transfers tokens from the administrator to the user, then marks lock_hash as processed and emits a ReleaseEvent.


These functions follow a check-effects-interactions pattern, which could, in theory, be vulnerable to reentrancy if the token contract is malicious or exotic. However, lock requires user_address.require_auth() and release requires admin.require_auth(), ensuring that reentrant calls triggered by token contracts cannot satisfy these authorization checks to re-enter state-changing functions.


This design may lead to confusing event ordering or unexpected control flow when interacting with non-standard tokens, but it effectively prevents double-locking or double-releasing under normal circumstances, due to the authorization constraints enforced for users and administrators.

BVSS
Recommendation

As part of defense-in-depth measures: (1) update the state before external calls where feasible (for example, set processed=true prior to executing the transfer in release, relying on transaction atomicity), (2) consider adding a simple reentrancy guard as an optional safeguard.

Remediation Comment

SOLVED: The zkCross team solved this issue in the specified commit ID.

Remediation Hash

7.10 Indexer Confusion from Redundant Token Parameters

//

Informational

Description

In lock, both from_token: Address and src_token: Address refer to Stellar addresses. This redundancy for the source-side lock can lead to confusion. Allowing both fields to be specified enables users to provide conflicting values, which do not affect on-chain enforcement but may cause issues for indexers and downstream systems.

BVSS
Recommendation

Remove one of the redundant parameters, src_token or from_token, and utilize only the remaining parameter to represent the source token address throughout the function.

Remediation Comment

SOLVED: The zkCross team solved this issue by removing the redundant src_token parameter.

Remediation Hash

7.11 Event Monitoring Blind Spots from Inconsistent Naming

//

Informational

Description

Event names exhibit inconsistency: some utilize CamelCase with an Event suffix (e.g., AdminSetEvent, AdminUpdatedEvent, RevenueAddressSetEvent, UsdcTokenAddressSetEvent), while others do not (initialize) or employ snake_case (set_fee_percentage_event). Off-chain indexers and analytics systems typically depend on strict naming conventions to function correctly.


This inconsistency may lead to missed event subscriptions, unreliable queries, or monitoring blind spots when tools expect uniform naming patterns.

BVSS
Recommendation

Standardize naming conventions by adopting a consistent approach. For example, rename events to InitializeEvent and SetFeePercentageEvent. Ensure these names remain stable in future implementations.

Remediation Comment

SOLVED: The zkCross team solved this issue in the specified commit ID.

Remediation Hash

7.12 Unnecessary Computation Overhead in Validation Path

//

Informational

Description

In lock, the code calculates fee before verifying that in_amount >= min_amount. Although the impact is minimal, performing this arithmetic prior to validation may lead to unnecessary computations and slightly reduce code readability. This order also obscures the original validation intent.


This could result in inefficient execution and reduced clarity, although it does not introduce any security vulnerabilities.

BVSS
Recommendation

Perform the min_amount validation first, then compute fee and swapped_amount. Ensure the validation process remains efficient and free of unnecessary calculations.

Remediation Comment

SOLVED: The zkCross team solved this issue in the specified commit ID.

Remediation Hash

7.13 Performance Overhead from Unnecessary Memory Cloning

//

Informational

Description

When constructing LockEventData, several fields clone their values (e.g., dest_token.clone(), from_token.clone(), src_token.clone()), which could often be replaced with field-init shorthand if these original values are not used elsewhere. Additionally, swapped_amount: swapped_amount redundantly repeats the field name and could be simplified to swapped_amount using the shorthand.


This approach may lead to minor, avoidable CPU and memory overhead, and adds unnecessary noise to the code without providing any functional benefit.

BVSS
Recommendation

When ownership permits, assign values directly and utilize field-initializer shorthand; clone only when necessary. Apply these practices consistently in event construction to enhance clarity and enable minor optimizations.

Remediation Comment

SOLVED: The zkCross team solved this issue in the specified commit ID.

Remediation Hash

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.

© Halborn 2025. All rights reserved.