CALC - Manager/Scheduler/Strategy - THORChain


Prepared by:

Halborn Logo

HALBORN

Last Updated 08/27/2025

Date of Engagement: August 11th, 2025 - August 20th, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

10

Critical

1

High

0

Medium

1

Low

4

Informational

4


1. Introduction

This report was commissioned by THORChain, a leading decentralized liquidity network, to assess the security and robustness of the CALC Manager, Scheduler, and Strategy smart contracts. The assessment was performed by Halborn’s experienced security team, focusing on the code released at commit 632c63b. The review covered all functionality in manager.wasm, scheduler.wasm, and strategy.wasm between the 11th August 11, 2025, and 20th August 20, 2025. The primary objective of this engagement’s core purpose was to identify vulnerabilities, ensure protocol reliabilityand strengthen overall security.

2. Assessment Summary

Halborn’s team of seasoned specialists performed a comprehensive security assessment over a 8-day period. The key goals included discovering critical vulnerabilities, evaluating strategic robustness, and improving protocol defenses.


The overall security posture showed ambitious protocols with substantial complexity; several impactful issues were identified. Noteworthy fixes include resolution of a severe rebate-stealing vulnerability in the Scheduler contract, along with remediation of other high- and medium-priority issues—such as input validation weaknesses, logic errors in price comparison, and insufficient robustness against market manipulation. Operational and configurability enhancements were also successfully implemented.


All findings have been addressed and remediated by the Calc team.

3. Test Approach and Methodology

A hybrid methodology was used, balancing deep manual review with targeted automated analysis. The work began with codebase familiarization, design verification, and threat modeling. Manual inspection dissected business logic, access control, storage management, and validator relationships. Automated static analysis scanned for low-level errors and overlooked vulnerabilities. Simulated execution and scenario testing further stressed edge cases and protocol invariants. The rigorous sequencing of methods ensures that coverage was exhaustive, with no reliance on checklist-based auditing. Continuous collaboration with the development team enabled rapid triage and remediation of critical findings.

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

REPOSITORIES
(a) Repository: calc-rs
(b) Assessed Commit ID: 939d055
(a) Repository: calc-rs
(b) Assessed Commit ID: 632c63b
(c) Items in scope:
  • contracts/scheduler/src/lib.rs
  • contracts/scheduler/src/state.rs
  • contracts/scheduler/src/contract.rs
↓ Expand ↓
(a) Repository: calc-rs
(b) Assessed Commit ID: 39df0d2
(c) Items in scope:
  • packages/calc-rs/src/conditions/asset_value_ratio.rs
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

1

High

0

Medium

1

Low

4

Informational

4

Security analysisRisk levelRemediation Date
Public trigger enumeration enables rebate theft via overwriteCriticalSolved - 08/13/2025
Duplicate denoms are double-counted leading to over-allocationMediumSolved - 08/18/2025
RUNE incorrectly treated as non-secured in Distribution depositsLowSolved - 08/18/2025
LinearScalar compares inverse price metricsLowSolved - 08/18/2025
LinearScalar ignores available balance (Thor)LowSolved - 08/18/2025
Top-of-book reliance enables cheap price spoofing to influence strategy decisionsLowSolved - 08/21/2025
Missing guards in FIN pricing pathsInformationalSolved - 08/20/2025
Withdraw policy on partial fills may cause unnecessary churn or exposure gapsInformationalSolved - 08/20/2025
Over-fetching FIN Book levels (limit=10) while using only top-of-bookInformationalSolved - 08/21/2025
Strategy Balances query reports only limit-order positions, omitting other contract fundsInformationalSolved - 08/23/2025

7. Findings & Tech Details

7.1 Public trigger enumeration enables rebate theft via overwrite

//

Critical

Description

The Scheduler contract contains a vulnerability in its Create trigger flow that allows any user to overwrite an existing trigger if they can reproduce the same trigger_id. When such a collision occurs, the contract deletes the prior trigger and refunds its execution_rebate to the new caller (info.sender), without verifying that the caller is the original creator.


The trigger_id is computed from fields such as condition, msg and contract_address. All of these parameters are persisted in the Trigger struct and are fully accessible through public query endpoints, which can list all stored triggers via pagination.


Because of this, an attacker can enumerate all existing triggers, retrieve their parameters, recompute the same trigger_id, and submit a Create transaction that overwrites the original trigger. This action automatically refunds the execution_rebate (funds deposited by the original trigger creator) to the attacker’s account.


This vulnerability enables direct theft of user funds and can be exploited at scale by automating the enumeration and overwriting process.


Code Location

Code of execute function from contracts/scheduler/src/contract.rs file:

    let mut sub_messages = Vec::with_capacity(2);
    let trigger_id = create_command.id()?;

    if let Ok(existing_trigger) = TRIGGERS.load(deps.storage, trigger_id) {
        TRIGGERS.delete(deps.storage, existing_trigger.id.into())?;

        if !existing_trigger.execution_rebate.is_empty() {
            sub_messages.push(SubMsg::reply_never(BankMsg::Send {
                to_address: info.sender.to_string(),
                amount: existing_trigger.execution_rebate,
            }));
        }
    }

Proof of Concept

Scenario


The owner creates a trigger in the scheduler with a pre-funded rebate (e.g., 12,345 rune) using Create for a specific tuple (condition, msg, contract_address).

The attacker calls Create with zero funds using the same inputs. The contract detects an existing trigger with that id, deletes it, and refunds the stored execution_rebate to the new caller (attacker), not the original creator.


Test code

#[test]
fn test_scheduler_create_overwrite_refunds_rebate_to_new_caller_and_updates_balances() {
    let mut harness = CalcTestApp::setup();

    let scheduler = harness.scheduler_addr.clone();
    let strategy = Addr::unchecked("strategy");
    let owner = harness.owner.clone();
    let attacker = harness.unknown.clone();

    let owner_before = harness.query_balance(&owner, "rune");
    let attacker_before = harness.query_balance(&attacker, "rune");

    let create = CreateTriggerMsg {
        condition: Condition::BlocksCompleted(100),
        msg: Binary::default(),
        contract_address: strategy,
        executors: vec![],
        jitter: None,
    };

    // Owner creates a trigger with a funded rebate
    let rebate = Uint128::new(12_345);
    harness
        .app
        .execute_contract(
            owner.clone(),
            scheduler.clone(),
            &SchedulerExecuteMsg::Create(Box::new(create.clone())),
            &[Coin::new(rebate.u128(), "rune")],
        )
        .unwrap();

    // Attacker overwrites the same trigger with zero funds, stealing the rebate
    harness
        .app
        .execute_contract(
            attacker.clone(),
            scheduler.clone(),
            &SchedulerExecuteMsg::Create(Box::new(create.clone())),
            &[],
        )
        .unwrap();

    let owner_after = harness.query_balance(&owner, "rune");
    let attacker_after = harness.query_balance(&attacker, "rune");

    // Owner paid the rebate once when creating the trigger
    assert_eq!(owner_after.amount, owner_before.amount - rebate);
    // Attacker received the refunded rebate on overwrite
    assert_eq!(attacker_after.amount, attacker_before.amount + rebate);
}

Result


The owner’s rune balance decreases by the rebate amount used to fund the original Create and the attacker’s rune balance increases by the same rebate amount after issuing a second Create with identical fields (overwrite).

This matches the expected scenario: overwrite succeeds and the attacker receives the original rebate.

BVSS
Recommendation

It is recommended to:

  • Persist the original trigger creator address in the contract state.

  • Enforce that only the original creator can overwrite an existing trigger.

  • Ensure that any execution_rebate from a replaced trigger is refunded only to the original creator, not to the new caller.

  • Include info.sender in the trigger_id hash preimage to harden against collisions across different accounts. This ensures that a trigger created by one account cannot be overwritten by another account replicating the same public parameters.


Remediation Comment

SOLVED: The Calc team solved this issue by applying all the recommended fixes.

Remediation Hash

7.2 Duplicate denoms are double-counted leading to over-allocation

//

Medium

Description

The distribution action aggregates balances for each entry in denoms, but does not validate or deduplicate duplicate entries. When Distribution.denoms contains the same denom more than once, execute_unsafe queries the balance for each occurrence and adds them into a Coins accumulator, which merges by denom and sums amounts. This double-counts the available balance.


The subsequent pro‑rata split uses this inflated balance, producing Bank/Wasm/Deposit messages that exceed the contract’s real balance per denom; the chain will reject the transaction, effectively producing a DoS in that strategy step and preventing progress until configuration is fixed. The missing duplicate-denom validation in init allows the bad input to pass unchecked.


Code Location

Code of execute_unsafe function from packages/calc-rs/src/actions/distribution.rs file:

pub fn execute_unsafe(
    self,
    deps: Deps,
    env: &Env,
) -> StdResult<(Vec<CosmosMsg>, Distribution)> {
    let mut balances = Coins::default();

    for denom in &self.denoms {
        balances.add(
            deps.querier
                .query_balance(env.contract.address.as_ref(), denom)?,
        )?;
    }

    if balances.is_empty() {
        return Ok((vec![], self));
    }

BVSS
Recommendation

It is recommended to reject or eliminate duplicate entries in denoms during init by validating for uniqueness (for example, using a set). Alternatively, construct the balance map from a set of unique denoms before aggregation to ensure each denom is queried and included only once.

Remediation Comment

SOLVED: The Calc team solved the issue by using a HashSet to verify uniqueness.

Remediation Hash

7.3 RUNE incorrectly treated as non-secured in Distribution deposits

//

Low

Description

The Distribution action prevents THORChain deposits if any denom in denoms lack a hyphen, by evaluating has_native_denoms as any !d.contains('-'). This misclassifies rune (which has no hyphen) as non-secured and rejects Recipient::Deposit. However, the THORChain deposit process explicitly supports rune within MsgDeposit::into_cosmos_msg, treating rune as a secured asset.


As a result, strategies that attempt to deposit RUNE via Distribution are blocked at init (causing a functional denial of service) and behave inconsistently with the Thorchain module.


Code Location

Code of Distribution::initfunction from packages/calc-rs/src/actions/distribution.rsfile:

fn init(self, deps: Deps, _env: &Env, affiliates: &[Affiliate]) -> StdResult<Distribution> {
    if self.denoms.is_empty() {
        return Err(StdError::generic_err("Denoms cannot be empty"));
    }

    if self.destinations.is_empty() {
        return Err(StdError::generic_err("Destinations cannot be empty"));
    }

    let has_native_denoms = self.denoms.iter().any(|d| !d.contains('-'));
    let mut total_shares = Uint128::zero();

    for destination in self.destinations.iter() {
        if destination.shares.is_zero() {
            return Err(StdError::generic_err("Destination shares cannot be zero"));
        }

        match &destination.recipient {
            Recipient::Bank { address, .. } | Recipient::Contract { address, .. } => {
                deps.api.addr_validate(address.as_ref()).map_err(|_| {
                    StdError::generic_err(format!("Invalid destination address: {address}"))
                })?;
            }
            Recipient::Deposit { memo } => {
                if has_native_denoms {
                    return Err(StdError::generic_err(format!(
                        "Only secured assets can be deposited with memo {memo}"
                    )));
                }
            }
        }

        total_shares += destination.shares;
    }

BVSS
Recommendation

It is recommended to align the secured-asset check with the Thorchain route by treating rune as secured, e.g., compute has_native_denoms with d.to_lowercase() != "rune" && !d.contains('-'), or directly check with a helper consistent with is_secured_asset.

Remediation Comment

SOLVED: The Calc team solved the issue by adding RUNE as a secure asset.

Remediation Hash

7.4 LinearScalar compares inverse price metrics

//

Low

Description

Both the FIN and THORChain swap routes implement a LinearScalar adjustment intended to resize swaps proportionally to deviations in price. However, the implementation computes the base price as receive-per-input (base_receive_amount / swap_amount) while computing the current price as input-per-receive (swap_amount / expected_receive_amount). These two metrics are mathematical inverses, and directly comparing them produces distorted deltas.


This inconsistency causes the adjustment logic to measure price differences incorrectly:

  • When prices fall, the calculation overstates the delta, causing the algorithm to over-scale swap amounts (executing larger trades than intended).

  • When prices rise, the delta is understated, causing the algorithm to under-scale swap amounts (executing smaller trades than intended).


As a result, strategies using this adjustment deviate from their intended risk/reward calibration. Although this issue does not cause immediate loss of funds or security compromise, it represents a logic flaw in swap sizing and can materially affect strategy performance.


Code Location

Code of FinRoute::adjust function from packages/calc-rs/src/actions/swaps/fin.rs file:

SwapAmountAdjustment::LinearScalar {
    base_receive_amount,
    minimum_swap_amount,
    scalar,
} => {
    let swap_balance = deps.querier.query_balance(
        env.contract.address.clone(),
        quote.swap_amount.denom.clone(),
    )?;

    let new_swap_amount = Coin::new(
        min(swap_balance.amount, quote.swap_amount.amount),
        quote.swap_amount.denom.clone(),
    );

    let simulation = deps.querier.query_wasm_smart::<SimulationResponse>(
        self.pair_address.clone(),
        &QueryMsg::Simulate(new_swap_amount.clone()),
    )?;

    let expected_receive_amount =
        Coin::new(simulation.returned, quote.swap_amount.denom.clone());

    let base_price =
        Decimal::from_ratio(base_receive_amount.amount, quote.swap_amount.amount);

    let current_price =
        Decimal::from_ratio(quote.swap_amount.amount, expected_receive_amount.amount);

    let price_delta = base_price.abs_diff(current_price) / base_price;
    let scaled_price_delta = price_delta * scalar;

    let scaled_swap_amount = if current_price < base_price {
        new_swap_amount
            .amount
            .mul_floor(Decimal::one().saturating_add(scaled_price_delta))
    } else {
        new_swap_amount
            .amount
            .mul_floor(Decimal::one().saturating_sub(scaled_price_delta))
    };

Code of ThorchainRoute::adjust function from packages/calc-rs/src/actions/swaps/thor.rs file:

SwapAmountAdjustment::LinearScalar {
    base_receive_amount,
    minimum_swap_amount,
    scalar,
} => {
    let quote = get_swap_quote(deps, &route)?;

    let base_price =
        Decimal::from_ratio(base_receive_amount.amount, route.swap_amount.amount);

    let current_price =
        Decimal::from_ratio(route.swap_amount.amount, quote.expected_amount_out);

    let price_delta = base_price.abs_diff(current_price) / base_price;
    let scaled_price_delta = price_delta * scalar;

    let scaled_swap_amount = if current_price < base_price {
        route
            .swap_amount
            .amount
            .mul_floor(Decimal::one().saturating_add(scaled_price_delta))
    } else {
        route
            .swap_amount
            .amount
            .mul_floor(Decimal::one().saturating_sub(scaled_price_delta))
    };

BVSS
Recommendation

It is recommended to calculate both prices using the same metric, for example:

  • current_receive_per_input = expected_receive_amount / swap_amount

  • base_receive_per_input = base_receive_amount / original_swap_amount

  • then define price_delta = |current − base| / base.


Remediation Comment

SOLVED: The Calc team resolved this issue by refactoring the adjustment function and correcting the inaccurate calculation.

Remediation Hash

7.5 LinearScalar ignores available balance (Thor)

//

Low

Description

In ThorchainRoute::adjust under the LinearScalar branch, the function requests an L1 swap quote and computes a scaled_swap_amount without first capping by the contract’s actual on-chain input balance. Moreover, it calls get_swap_quote using the uncapped route.swap_amount.amount.


This creates two issues:

  1. Unrealistic price basis for scaling. If the wallet’s balance is lower than the requested amount, the quote is computed for a size the contract cannot execute. The resulting “current price” (and thus the delta and scaling) may be materially different from the one corresponding to the executable size. This produces mis-sized adjustments.

  2. Execution failure / liveness hit. The adjusted new_swap_amount can exceed the available balance. When the step proceeds to deposit/swap on THORChain (e.g., MsgDeposit), the tx fails due to insufficient funds, halting the strategy step and requiring retries. No funds are lost, but the strategy suffers unnecessary failures and potential keeper costs.


BVSS
Recommendation

It is recommended to cap the swap size by the contract’s actual on-chain balance before requesting the quote and again after computing the scaled amount. Use the capped amount consistently for price calculations, scaling, and rescaling minimum_receive_amount, and add unit tests to cover insufficient-balance cases.

Remediation Comment

SOLVED: The Calc team addressed this issue by refactoring the adjust function and implementing the missing check.

Remediation Hash

7.6 Top-of-book reliance enables cheap price spoofing to influence strategy decisions

//

Low

Description

Several components derive prices or decisions from the FIN orderbook using only the top-of-book level (book[0]) or a mid-price built from the best levels. In thin markets, an attacker can cheaply post a tiny (low-liquidity) quote at an extreme price to temporarily become the best level. If the code trusts that single level:

  • Offset strategies may reset prices and withdraw/re-place orders unnecessarily (churn, gas).

  • Conditions (e.g., AssetValueRatio, CanSwap) can flip true/false spuriously, triggering or suppressing actions.

  • Slippage checks and route selection may be skewed toward suboptimal choices.

While this does not directly steal funds, it degrades execution quality, increases costs, and creates noisy, manipulable behavior.


Code Location

Code of get_new_price function from packages/calc-rs/src/actions/limit_orders/fin_limit_order.rs:

pub fn get_new_price(
    &self,
    deps: Deps,
    pair_address: &Addr,
    side: &Side,
) -> StdResult<Decimal> {
    Ok(match self {
        PriceStrategy::Fixed(price) => *price,
        PriceStrategy::Offset {
            direction, offset, ..
        } => {
            let book_response = deps.querier.query_wasm_smart::<BookResponse>(
                pair_address.clone(),
                &QueryMsg::Book {
                    limit: Some(10),
                    offset: None,
                },
            )?;

            let book = if side == &Side::Base {
                book_response.base
            } else {
                book_response.quote
            };

            if book.is_empty() {
                return Err(StdError::generic_err("Order book is empty"));
            }

            let price = book[0].price;

Code of is_satisfied function from packages/calc-rs/src/conditions/asset_value_ratio.rs:

let price = match self.oracle.clone() {
    PriceSource::Fin { address } => {
        let book_response = deps.querier.query_wasm_smart::<BookResponse>(
            &address,
            &QueryMsg::Book {
                limit: Some(1),
                offset: None,
            },
        )?;

        let pair = deps
            .querier
            .query_wasm_smart::<ConfigResponse>(address, &QueryMsg::Config {})?;

        let mid_price = (book_response.base[0].price + book_response.quote[0].price)
            / Decimal::from_ratio(2u128, 1u128);

        if pair.denoms.base() == self.numerator {
            Decimal::one() / mid_price
        } else {
            mid_price
        }
    }

Code of validate_adjusted function from packages/calc-rs/src/actions/swaps/fin.rs:

let book_response = deps.querier.query_wasm_smart::<BookResponse>(
    self.pair_address.clone(),
    &QueryMsg::Book {
        limit: Some(1),
        offset: None,
    },
)?;

let mid_price = (book_response.base[0].price + book_response.quote[0].price)
    / Decimal::from_ratio(2u128, 1u128);

let pair = deps
    .querier
    .query_wasm_smart::<ConfigResponse>(self.pair_address.clone(), &QueryMsg::Config {})?;

let spot_price = if route.swap_amount.denom == pair.denoms.base() {
    Decimal::one() / mid_price
} else {
    mid_price
};

BVSS
Recommendation

It is recommended to harden price derivation against top-of-book spoofing:

  • Require minimal liquidity at the level before considering it (absolute or relative to intended trade size).

  • Aggregate multiple levels (e.g., VWAP across 3–5 levels) or use a median filter, then quantize to tick.



Remediation Comment

SOLVED: The Calc team solved this finding by implementing some new functions for price calculation. These commits represent improvements over the initial fix:

a0758c174bffa1ebd0b8bf28f66da679e22f3ae5

7121e1a945a2e3832e505c77df8831f03deef7c1

092d6b9b3d877dc37f63b8f8d91e988f32206cf3 (last version)

Remediation Hash

7.7 Missing guards in FIN pricing paths

//

Informational

Description

Several FIN-related pricing paths lack defensive checks that can lead to transaction reverts (panics):

  • Division-by-zero risks: Decimal::from_ratio panics when denominator is zero. If a simulation returns 0 (illiquid pair/rounding), or a computed mid price is 0 then inversions can panic and revert the whole tx.

  • Empty orderbook access: validate_adjusted uses base[0] and quote[0] without non-empty guards, which can panic/Err on thin markets.

  • Unbounded percentage offsets: PriceStrategy::Offset uses Decimal::percent(100 ± offset) without bounding offset; values ≥100 (below) collapse to 0, and very large values can explode multipliers.


Additionally, the same gap applies to AssetValueRatio with FIN as the price source, so equivalent guards should be added (or treat the case as not satisfied). These issues do not directly enable theft, but they can cause step-level DoS, brittle behavior under illiquidity, and degraded execution quality.


Code Location

Code of adjust function from packages/calc-rs/src/actions/swaps/fin.rs file:

let simulation = deps.querier.query_wasm_smart::<SimulationResponse>(
    self.pair_address.clone(),
    &QueryMsg::Simulate(new_swap_amount.clone()),
)?;
let expected_receive_amount =
    Coin::new(simulation.returned, quote.swap_amount.denom.clone());

let base_price =
    Decimal::from_ratio(base_receive_amount.amount, quote.swap_amount.amount);

let current_price =
    Decimal::from_ratio(quote.swap_amount.amount, expected_receive_amount.amount); // denom may be 0

Code of validate_adjusted function from packages/calc-rs/src/actions/swaps/fin.rs file:

let book_response = deps.querier.query_wasm_smart::<BookResponse>(
    self.pair_address.clone(),
    &QueryMsg::Book { limit: Some(1), offset: None },
)?;
let mid_price = (book_response.base[0].price + book_response.quote[0].price)   // requires both non-empty
    / Decimal::from_ratio(2u128, 1u128);

let pair = deps
    .querier
    .query_wasm_smart::<ConfigResponse>(self.pair_address.clone(), &QueryMsg::Config {})?;

let spot_price = if route.swap_amount.denom == pair.denoms.base() {
    Decimal::one() / mid_price   // mid_price must be > 0
} else {
    mid_price
};

Code of get_new_price function from packages/calc-rs/src/actions/limit_orders/fin_limit_order.rs file:

match offset.clone() {
    Offset::Exact(offset) => match direction {
        Direction::Above => price.saturating_add(offset),
        Direction::Below => price.saturating_sub(offset),
    },
    Offset::Percent(offset) => {
        match direction {
            Direction::Above => price
                .saturating_mul(Decimal::percent(100u64.saturating_add(offset))),
            Direction::Below => price
                .saturating_mul(Decimal::percent(100u64.saturating_sub(offset))),
        }
    }
}

BVSS
Recommendation

It is recommended to:

  • Guard zero denominators and convert to explicit StdError:

    • If simulation.returned == 0, return Err before calling Decimal::from_ratio(..., 0).

    • Ensure mid_price > 0 before inversion; if not, return Err.

  • Add non-empty checks for both orderbook sides before indexing [0] in validate_adjusted; treat empty sides as a recoverable Err.

  • Bound percentage offsets:

    • Enforce Offset::Percent in [0, 100] (or a documented max). For Direction::Below, require offset < 100 to avoid zeroing; for Above, cap multiplier to a sane max.

  • Emit attributes on handled errors for observability.


Remediation Comment

SOLVED: The Calc team addressed this issue by implementing the recommended fixes. Changes related to the asset_value_ratio.rs file were committed in this commit.

Some of these fixes are not applicable, as certain functions were refactored during the resolution of another finding.

Remediation Hash

7.8 Withdraw policy on partial fills may cause unnecessary churn or exposure gaps

//

Informational

Description

The current logic on Fin Limit Orders withdraws on any partial fill (filled > 0) or on a price reset, which can be beneficial in asynchronous execution contexts (unknown next run via trigger/owner), because it consolidates proceeds and avoids stale positions.


However, doing so systematically may introduce avoidable churn (extra gas and operational noise) and temporary exposure gaps (no pro‑rata participation while withdrawing/replacing) when the target price has not changed materially. This is primarily a policy/design trade‑off rather than a correctness bug. Making the withdraw behavior configurable (e.g., thresholds/timeout) would balance liveness and efficiency across different market conditions and execution cadences.

Code Location

Code of saturating_withdraw function from packages/calc-rs/src/actions/limit_orders/fin_limit_order.rs file:

pub fn saturating_withdraw(
    self,
    deps: Deps,
) -> StdResult<FinLimitOrderState<WithdrawingOrder>> {
    let new_price = self.config.strategy.get_new_price(
        deps,
        &self.config.pair_address,
        &self.config.side,
    )?;

    let should_withdraw = self.state.filled.gt(&Uint128::zero())
        || self
            .config
            .strategy
            .should_reset(self.state.price, new_price);

    if should_withdraw {
        let withdrawing_order = self.withdraw()?;

BVSS
Recommendation

It is recommended to make withdraw behavior configurable to suit asynchronous execution, for example:

  • Only when should_reset(current, new) is true (price change per strategy), or

  • When fully filled (remaining == 0), or

  • When partial fill exceeds a configurable min_fill_ratio (e.g., filled / offer >= X%), or

  • When the order is older than a configurable timeout (blocks/seconds).

  • For Fixed strategies, avoid withdrawing on small partial fills if price is unchanged; prefer full‑fill or timeout criteria.



Remediation Comment

SOLVED: The Calc team addressed this issue by updating the requirements that must be checked before proceeding with a withdrawal. An edge case related to this remediation was fixed in this commit.

Remediation Hash

7.9 Over-fetching FIN Book levels (limit=10) while using only top-of-book

//

Informational

Description

The get_new_price function from FIN Limit Order requests 10 orderbook levels from FIN (limit: Some(10)) from FIN, but uses only utilizes the first entry (book[0]).


This approach incurs additional query/gas cost without providing any robustness advantages. If only the top-of-book data is required, the query should request should be optimized to retrieve a single level. Conversely, if robustness against top-of-book manipulation or minimal quote sizes is necessary, the code should explicitly aggregate multiple levels (e.g., volume-weighted) and then quantize the result to the pair’s tick size, as previously recommended.


Code Location

Code of get_new_price function from packages/calc-rs/src/actions/limit_orders/fin_limit_order.rs file:

pub fn get_new_price(
    &self,
    deps: Deps,
    pair_address: &Addr,
    side: &Side,
) -> StdResult<Decimal> {
    Ok(match self {
        PriceStrategy::Fixed(price) => *price,
        PriceStrategy::Offset {
            direction, offset, ..
        } => {
            let book_response = deps.querier.query_wasm_smart::<BookResponse>(
                pair_address.clone(),
                &QueryMsg::Book {
                    limit: Some(10),    // over-fetch: only the first level is used
                    offset: None,
                },
            )?;

            let book = if side == &Side::Base {
                book_response.base
            } else {
                book_response.quote
            };

BVSS
Recommendation

It is recommended to:

  • If only top-of-book is required, change the query to limit: Some(1) to reduce gas.

  • If more robust pricing is needed, aggregate across N levels (e.g., volume-weighted average across the first 3–5 levels) and then quantize the result to the pair’s tick.


Remediation Comment

SOLVED: The Calc team resolved this issue by modifying the requested quantity. However, this issue is now obsolete, as this functionality has been replaced by a new method of calculating the price.

Remediation Hash

7.10 Strategy Balances query reports only limit-order positions, omitting other contract funds

//

Informational

Description

The StrategyQueryMsg::Balances query aggregates balances by calling balances() on each node. Currently, only Action::LimitOrder implements balances, returning the remaining bid and filled amounts (ask) associated with FIN limit orders. Other actions (Swap, Distribute) return empty balances.


As a result, the query does not reflect the strategy contract’s full holdings (e.g., free native balances, funds held for swaps or pending distributions), which may mislead operators relying on this endpoint for accounting or monitoring.



Code Location

Code of Action::balances from packages/calc-rs/src/actions/action.rs:

fn balances(&self, deps: Deps, env: &Env) -> StdResult<Coins> {
    match self {
        Action::LimitOrder(limit_order) => limit_order.balances(deps, env),
        _ => Ok(Coins::default()),
    }
}

BVSS
Recommendation

It is recommended to:

  • Document that Balances reports only node-exposed positions (currently FIN limit orders).

  • Extend Action::balances for Swap and Distribute to include relevant denoms, or

  • Add a separate query (e.g., AllBalances) that aggregates bank::all_balances of the strategy contract plus external positions (like orders), ensuring no double-counting.


Remediation Comment

SOLVED: The Calc team solved this issue by querying all the denoms in the contract.

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.