Stellar AMM - Normal Finance


Prepared by:

Halborn Logo

HALBORN

Last Updated 08/15/2025

Date of Engagement: June 25th, 2025 - August 5th, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

12

Critical

0

High

3

Medium

1

Low

2

Informational

6


1. Introduction

Halborn was engaged by the Normal AMM team to perform a targeted security assessment of their Soroban-based automated market maker (AMM) protocol on Stellar. The scope included analyzing the core on-chain components responsible for liquidity management, routing, oracle integration, incentives, and access control. The assessment aimed to verify correctness, safety, and economic robustness.

2. Assessment Summary

A senior blockchain security engineer from Halborn conducted the review full-time. The engineer has extensive experience in Rust, Soroban/Stellar, AMM design, and protocol security, including upgradeability, authorization, and oracle systems.


The codebase components reviewed included:

    • Contracts: pool, pool_router, oracle_registry, pool_plane, pool_swap_fee, insurance_fund, liquidity_calculator, token

    • Shared modules: access_control, incentives, utils, upgrade


The review focused on verifying economic invariants (such as pricing, fees, and shares), oracle validation and safeguards, privilege boundaries, emergency controls, and upgrade procedures.


Engagement objectives included:

    • Ensuring that new and modified functions behave as intended and are resistant to misuse and edge cases.

    • Identifying vulnerabilities that could lead to fund loss, invariant violations, incorrect state transitions, oracle manipulation, privilege escalation, or denial of service.

    • Highlighting areas where stronger invariants, defensive checks, or clearer authorization controls can reduce operational and economic risks.


3. Test Approach and Methodology

To ensure comprehensive and cross-cutting coverage, the assessment employed a consistent methodology across all first-party contracts and shared modules within the workspace:

    • Contracts: buffer, insurance_fund, liquidity_calculator, oracle_registry, pool, pool_plane, pool_router, pool_swap_fee, token

    • Shared modules: access_control, incentives, pool_tokens, upgrade, utils

    • Integration tests located in: contracts/integration_tests


The approach combined architecture mapping, manual expert review, static and dynamic analysis, property-based testing, and adversarial scenario simulations:


1) Repository-wide inventory and architecture mapping

    • Mapped all public entry points declared in interface.rs and within #[contractimpl] blocks; analyzed data and control flows among pool, pool_router, oracle_registry, pool_plane, liquidity_calculator, insurance_fund, and token contracts.

    • Identified trust boundaries, administrative roles, kill switches, upgrade paths, and oracle dependencies; documented invariants for each component.


2) Manual line-by-line review (across all crates)

    • Examined each contract’s contract.rs, storage.rs, errors.rs, and events.rs files:

    • Verified authorization and role checks via modules/access_control.

    • Assessed storage keys, data lifetimes, bumping strategies, and differentiated between persistent and temporary data usage.

    • Analyzed mathematical invariants within pool.rs, liquidity_calculator, pool_swap_fee, utils::math, and incentives mechanisms.

    • Reviewed cross-contract calls (router, oracle, plane, token) for error propagation, potential DoS vulnerabilities, and reentrancy issues.

    • Verified upgrade flows via modules/upgrade across upgradable contracts.

    • Assessed emergency/kill toggles, expiry, and settlement transition mechanisms.

    • Ensured event schemas are consistent and correctly implemented.


3) Static analysis and linting (workspace-wide)

    • Applied linting tools and targeted grep scans to identify anti-patterns, including:

    • Unchecked type conversions, saturation underflows, panics affecting user flows.

    • Misuse of U256 and fixed-point math, including rounding errors.

    • Incomplete authorization checks and role management inconsistencies.


4) Property-based testing and fuzzing for critical math routines

Focused on:

    • Swap route correctness, fee adjustments, and invariant violations (such as fee residues).

    • Precision in receive computations and boundary condition handling.

    • Price functions (peg_price, get_delta_a) and rolling metrics.

    • Incentives accumulation and distribution, including per-user and per-pool calculations, with correct handling of pagination constants.

    • Tested edge cases involving zero or near-zero values, maximum fees, large reserves, and rounding boundaries.


5) Stateful adversarial testing and PoC development

    • Designed multi-step scenarios across contracts to detect emergent behaviors:

    • Upgrade commit, apply, and revert; emergency mode activation and deactivation within deadlines.

    • Kill/un-kill toggles; roles for pausing versus emergency pauses.

    • Handling of oracle data volatility and staleness, cached versus fresh data paths, with safeguards.

    • Insurance fund claims constrained by tiers and thresholds.

    • Router orchestration and liquidity calculator during failures.

    • Prototype calculations for NAV/share underpricing during rebalancing, supported by mathematical proof independent from harness dependencies.


6) Integration testing and cross-contract orchestration

    • Utilized existing integration tests and created custom scenarios:

    • Deployment of contracts using contractimport! for pool_plane, liquidity_calculator, pool_router, and oracle_registry.

    • End-to-end testing covering initialization, deposits, swaps, withdrawals, incentives, claims, rebalancing, upgrades, and emergency procedures.

    • Used functions like utils::test_utils::jump to manipulate time for testing staleness, delays, and expiry conditions.


7) Permission boundary testing (positive and negative cases)

    • Systematically verified role-based permissions for Admin, EmergencyAdmin, RewardsAdmin, OperationsAdmin, PauseAdmin, and EmergencyPauseAdmin. Patterns followed those in each crate’s test_permissions.rs.

    • Reviewed ownership transfer workflows and deadline enforcement for correctness.


8) Oracle validation and economic safety assessment

    • Validated oracle safeguards, including volatility thresholds, staleness limits, clamping, TWAP updates, and cached versus live data handling.

    • Assessed core economic invariants such as:

    • Enforcement of AMM invariant, accurate fee accounting, and residue handling.

    • Share valuation consistency aligned with pool NAV during rebalancing.

    • Limits on insurance coverage per tier, as well as revenue withdrawals and settlement procedures.


9) Token interactions and assumptions testing

    • Verified token helper functions (utils::token), pool token management (pool_tokens), and behaviors regarding allowances and transfers.

    • Analyzed dependencies on standard token implementations and error handling pathways for non-standard tokens in strict-receive and refund scenarios.


10) Performance, resource consumption, and reliability evaluations

    • Considered ledger instruction limits, storage growth, and pagination (e.g., PAGE_SIZE).

    • Examined pool_plane updates for potential liveness issues and recommended implementing soft-fail patterns where appropriate to improve reliability.


11) Test execution and validation strategy

    • Developed targeted tests and proofs of concept to validate identified risks and invariants, including snapshot captures of critical error pathways.


This methodology achieved full coverage of the codebase with focused depth on high-risk areas such as oracle integration, upgradeability, access control, AMM mathematics, and incentives, enabling the detection of both correctness and economic vulnerabilities.

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
(a) Repository: normal-stellar-amm
(b) Assessed Commit ID: 5e746ed
(c) Items in scope:
  • insurance_fund
  • oracle_registry
  • pool
↓ Expand ↓
Remediation Commit ID:
  • 1c76060
  • bf069b9
  • ad15b98
  • 281bb6a
  • c9e27d6
  • 6443f8b
  • d8a2755
  • 9b274e9
  • 3361556
  • dc12345
  • 211a138
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

0

High

3

Medium

1

Low

2

Informational

6

Security analysisRisk levelRemediation Date
Oracle Registry Argument Swap Triggers Panic on Price Up-TicksHighSolved - 08/01/2025
Sanitize New Price Underflows On Price Drop, Triggering Protocol Wide PanicHighSolved - 08/01/2025
NAV Underpricing via Fixed Share MintingHighSolved - 08/04/2025
PoolRouter Reward Front-Run Enables Zero-Emission GriefingMediumSolved - 08/01/2025
Dust-Initialisation / Fee-Skimming Risk — totalLP Can Fall to 0LowSolved - 07/29/2025
Stale-delay Allows Frozen or Cached Prices to Look FreshLowSolved - 08/01/2025
Zero-Amount Deposits AllowedInformationalSolved - 07/30/2025
Zero-Amount Stake AcceptedInformationalSolved - 07/30/2025
Price-override Cooldown can be BypassedInformationalSolved - 08/01/2025
Redundant Ownership-Transfer: Future Owner Can Equal Current OwnerInformationalSolved - 08/01/2025
sanitize_clamp_denominator is Unchecked (Division-by-Zero Risk)InformationalSolved - 07/31/2025
Unbounded Synthetic-Minting During “Reduce-Only” ModeInformationalSolved - 08/01/2025

7. Findings & Tech Details

7.1 Oracle Registry Argument Swap Triggers Panic on Price Up-Ticks

//

High

Description

A bug in the Oracle Registry causes every on-chain operation swaps, deposits, withdrawals, rebalances, insurance claims, etc. to panic whenever the live oracle price exceeds the stored time-weighted average price (TWAP).


The panic is caused by a single line that feeds the TWAP and the current price to the comparison routine in the wrong order, producing an unsigned under-flow.

// contracts/oracle_registry/src/contract.rs  – get_price()

let block = block_operation(
    &e,
    &oracle_price_data,
    historical.last_oracle_price_twap, // ❌ TWAP (should be 4th arg)
    oracle_price_data.price,           // ❌ live price (should be 3rd arg)
    action,
);

block_operation() expects current price first, TWAP second. Down-stream, the math routine tries to compute:

// oracle.rs – calculate_oracle_twap_price_spread_pct()

let price_spread = (twap as u64).safe_sub(e, current_price as u64); // under-flows when current > twap

On any price uptick (live > TWAP), safe_sub underflows (1.00 – 1.02) and panics with error 511, reverting the transaction. Since every user action invokes get_price(), the entire protocol grinds to a halt.

Proof of Concept
#[cfg(test)]
mod tests {
    use soroban_sdk::{Env, Symbol, testutils::Address as _, Address, vec};
    use crate::{OracleRegistryClient, oracle::calculate_oracle_twap_price_spread_pct};
    use utils::state::oracle_registry::NormalAction;
    use crate::storage::put_historical_oracle_data;
    use crate::storage_types::HistoricalOracleData;
    use sep_40_oracle::testutils::{Asset, MockPriceOracleClient, MockPriceOracleWASM};

    #[test]
    #[should_panic(expected = "Error(Contract, #1)")]
    fn oracle_panics_on_price_uptick() {
        let e = Env::default();
        e.mock_all_auths();
        
        // Setup
        let admin = Address::generate(&e);
        let emergency_admin = Address::generate(&e);
        let registry = OracleRegistryClient::new(&e, &e.register(crate::OracleRegistry, ()));
        registry.initialize(&admin, &emergency_admin);
        
        // Create mock oracle
        let mock_oracle_id = e.register(MockPriceOracleWASM, ());
        let mock_oracle_client = MockPriceOracleClient::new(&e, &mock_oracle_id);
        
        // Set up asset
        let asset_symbol = Symbol::new(&e, "XLM");
        let asset_address = Address::generate(&e);
        
        // Register oracle with initial TWAP of 1.00
        let initial_twap = 1_000_000_u128; // 1.00 with 6 decimals
        registry.register_oracle(
            &admin,
            &asset_symbol,
            &asset_address,
            &mock_oracle_id,
            &18,
            &100 // sanitize_clamp_denominator
        );
        
        // Set historical data with TWAP = 1.00
        let historical_data = HistoricalOracleData {
            last_oracle_price_twap: initial_twap,
            last_oracle_price: initial_twap,
            last_oracle_delay: 0,
            last_oracle_price_twap_ts: e.ledger().timestamp(),
        };
        put_historical_oracle_data(&e, &asset_symbol, &historical_data);
        
        // Configure mock oracle to return higher price (1.02)
        let higher_price = 1_020_000_u128; // 1.02 with 6 decimals
        mock_oracle_client.set_data(
            &admin,
            &Asset::Stellar(asset_address.clone()),
            &vec![&e, Asset::Stellar(asset_address.clone())],
            &7,
            &300
        );
        mock_oracle_client.set_price_stable(&vec![&e, higher_price as i128]);
        
        // This call will panic because:
        // 1. Live price (1.02) is higher than TWAP (1.00)
        // 2. Due to parameter confusion, it calculates: TWAP - Live = 1.00 - 1.02
        // 3. safe_sub panics on underflow
        registry.get_price(&asset_symbol, &false, &NormalAction::Swap);
    }

    #[test]
    #[should_panic(expected = "Error(Contract, #1)")]
    fn calculate_spread_panics_when_twap_less_than_live() {
        let e = Env::default();
        
        // Direct test of the vulnerable function
        // When called with swapped parameters (as happens in production):
        // - other_price = TWAP (1.00)
        // - last_oracle_price_twap = Live price (1.02)
        let twap_price = 1_000_000_u128;
        let live_price = 1_020_000_u128;
        
        // This will panic because twap_price < live_price
        calculate_oracle_twap_price_spread_pct(&e, twap_price, live_price);
    }
} 

BVSS
Recommendation

It is recommended to swap the ordering of the TWAP and current price arguments in get_price()

// contracts/oracle_registry/src/contract.rs – inside get_price()

-    historical.last_oracle_price_twap,   // TWAP

-    oracle_price_data.price,             // Live price

+    oracle_price_data.price,             // Live price  (reserve_price)

+    historical.last_oracle_price_twap,   // TWAP        (last_oracle_price_twap)

Remediation Comment

SOLVED: The Normal Finance team solved this issue by refactoring the price validation logic to clearly separate oracle-to-oracle price checks from pool-to-oracle price checks.

They discovered that the previous implementation had two problems:

  1. The parameters passed to block_operation() were in the wrong order.

  2. The reserve_price (pool price) was never passed to the Oracle Registry for comparison; it was instead using fresh oracle_price_data, bypassing pool price vs. oracle price validation entirely.

To fix this, the client:

  • Abstracted oracle price validations to be handled only within the Oracle Registry, and moved all pool price vs. oracle price validations to the Pool contract.

    This ensures get_price() in the Oracle Registry stays implementation-agnostic for future use cases and avoids unnecessary pool price dependencies.

  • Removed the cache argument from get_price() (and action), simplifying logic around timing differences from oracle updates, TWAP updates, and oracle delays, and ensuring all fetched prices are always fresh unless the oracle is frozen.

  • Updated the Pool to use the TWAP value instead of the most recent oracle price. This prevents short-term volatility or manipulation from bypassing safeguards, ensuring more stable and manipulation-resistant price references.


Remediation Hash
1c7606037849340f3bdebfa0d47978ece2b6fd54

7.2 Sanitize New Price Underflows On Price Drop, Triggering Protocol Wide Panic

//

High

Description

The sanitize_new_price() helper in the Oracle Registry computes the percentage change between a freshly reported price (new_price) and the stored time-weighted average price (last_twap) using unsigned subtraction.


Whenever the market dips (i.e. new_price < last_twap), this subtraction underflows, panics inside safe_sub(), and reverts every transaction that depends on a fresh price, effectively locking all swaps, deposits, withdrawals, rebalances, and insurance claims.

// contracts/oracle_registry/src/oracle.rs   (function sanitize_new_price)

pub fn sanitize_new_price(
    e: &Env,
    new_price: u128,
    last_twap: u128,
    clamp_denominator: i64
) -> u128 {
    /* … */
    let price_change = (new_price as u64)
        .safe_sub(e, last_twap as u64);   // ❌ under-flows when new_price < last_twap

    // further maths on price_change …
}

Proof of Concept
#[cfg(test)]
mod tests {
    use soroban_sdk::Env;
    use utils::math::pool::sanitize_new_price;

    #[test]
    #[should_panic(expected = "Error(Contract, #511)")]
    fn oracle_panics_on_price_down_tick() {
        let e = Env::default();

        let last_twap = 1_000_000_u128;  // 1.00
        let new_price = 980_000_u128;    // 0.98 (price went down)

        // sanitize_clamp_denominator = 100 (any non-zero value works)
        // This will call new_price.safe_sub(last_twap) which under-flows and panics
        let _ = sanitize_new_price(&e, new_price, last_twap, 100);
    }
} 

BVSS
Recommendation

It is recommended that signed arithmetic be adopted to allow negative deltas without underflow. For example:

let price_change = (new_price as i128) - (last_twap as i128);

Alternatively, if unsigned arithmetic must be retained, it is advised that the operand order be reversed so that the minuend is always greater than or equal to the subtrahend:

let price_change = (last_twap as u64)
    .safe_sub(e, new_price as u64);
  • It is suggested that unit and regression tests be added to cover both upward and downward price moves, ensuring that sanitize_new_price() never panics.

  • It is further recommended that all other uses of safe_sub() on unsigned integers be audited to confirm that the first operand can never be smaller than the second.

  • It is encouraged that explicit pre-condition checks or require! statements be introduced to validate expected price relationships before performing subtraction.


Remediation Comment

SOLVED: The Normal Finance team solved this issue by updating all contracts to handle spread direction calculations using unsigned values, removing potential sign-related logic errors.

Additionally, they implemented saturating subtraction across all contracts and related assertions to prevent underflow conditions when subtracting values.

These changes ensure more predictable math behavior, eliminate the risk of negative value propagation, and improve overall contract safety and consistency.

Remediation Hash
bf069b9febf511f031fd7821963c5db6a19f1159

7.3 NAV Underpricing via Fixed Share Minting

//

High

Description

The pool rebalances using oracle prices and may mint synthetic Token A to re-peg. However, deposits always mint LP shares at a fixed 1:1 rate with Token B and withdrawals pay Token B 1:1. After a rebalance that increases Token A (NAV↑), NAV per share exceeds 1 B, yet new LPs still mint shares priced at 1 B per share. This lets entrants acquire an underpriced claim on the pool, diluting incumbents.


  • Fixed 1:1 share minting on deposit() (ignores NAV/share):

// Now calculate how many new pool shares to mint
let total_shares = get_total_lp_tokens(&e);
let shares_to_mint = token_b_amount;

mint_lp_tokens(&e, &user, shares_to_mint as i128);

  • B-only withdrawal at 1:1 per share:

// Transfer any remaining to the user
transfer_b(&e, &user, share_amount);
set_reserve_b(&e, &(reserve_b - share_amount));

  • Rebalance can mint synthetic A (NAV increases):

if delta_a > 0 {
    mint_synthetic_tokens(&e, &e.current_contract_address(), delta_a);
    set_reserve_a(&e, &(reserve_a + (delta_a as u128)));
}

  • Share pricing ignores NAV: Shares mint strictly 1:1 with Token B regardless of pool value, while rebalance can mint/burn A and change NAV/share.

  • Withdrawal policy: B-only redemption at 1:1 per share preserves the underpricing gap for entrants and socializes the cost onto incumbents over time.


Exploit Scenario

  1. An LP deposits B. The pool rebalances (pre-deposit) and may mint A over time to track the oracle peg, increasing NAV/share above 1 B.

  2. An attacker waits for a rebalance that mints A (NAV/share > 1 B).

  3. Attacker deposits B and receives shares at 1 B per share (under fair value).

  4. The attacker now holds an over-sized claim on pool value (including A), diluting existing LPs. Withdrawals are B-only 1:1, but the unfair entry pricing transfers value over time via fee flows and subsequent pool dynamics.


Impact

  • Enables economic extraction without any privileged access.

  • Scales with the size/frequency of synthetic mints.

  • Encourages timing attacks (MEV) around rebalances and oracle movements.


BVSS
Recommendation

To mitigate this issue, the following improvements are recommended:

  • Correct share pricing to NAV:

    • Mint shares based on deposit value at current NAV/share.

    • Example: shares_to_mint = deposit_value_in_B / share_price, where share_price = NAV_in_B / total_shares and NAV_in_B = reserve_b + reserve_a × peg.

  • Withdrawals:

    • Pay pro‑rata in A and B, or convert A->B internally at current peg (or internal invariant) so the B redemption reflects NAV.

  • Safeguards:

    • Add unit/integration tests around rebalance -> deposit sequencing to ensure NAV/share invariants hold.

    • Consider oracle TWAP/rate-limits for large deltas; add circuit-breakers for abnormal conditions.


Remediation Comment

SOLVED: The Normal Finance team solved this issue by adding validation logic to the share calculation process to ensure that after synthetic Token A minting, new depositors cannot receive a disproportionate number of shares compared to their actual contribution.


This prevents potential value extraction by verifying that minted shares accurately reflect the depositor’s real share of the pool’s total value, safeguarding against exploitation of synthetic minting events.

Remediation Hash
ad15b98367f6a99429480b9da65ffe661d65b8f3

7.4 PoolRouter Reward Front-Run Enables Zero-Emission Griefing

//

Medium

Description

An attacker can front-run the two public, unauthenticated steps that configure a pool’s reward rate fill_liquidity(asset) and config_pool_rewards(asset) to lock any new pool at 0 tokens per second (TPS). Once processed, the pool’s liquidity and reward rate cannot be updated, permanently starving LPs of emissions.

The PoolRouter contract distributes rewards in a two-step sequence:

  1. fill_liquidity(asset)

    • Reads the pool’s on-chain liquidity (initially zero for a fresh pool).

    • Sets processed = true and stores total_liquidity.


  2. config_pool_rewards(asset)

    • Verifies processed == true.

    • Computes pool_tps based on the recorded total_liquidity.

    • Calls the pool’s set_incentives_config() with the calculated TPS.


Both functions are public and lack any access control:

// contracts/pool_router/src/contract.rs

fn fill_liquidity(e: Env, asset: Symbol) {
    // Public, no require_auth
    let mut data = tokens_with_liquidity.get(asset).unwrap();
    if data.processed {
        panic_with_error!(…LiquidityAlreadyFilled)
    }
    data.processed = true;                 // irreversible
    data.total_liquidity = total_liquidity; // zero if no deposits yet
    tokens_with_liquidity.set(asset, data);
}

fn config_pool_rewards(e: Env, asset: Symbol) -> u128 {
    // Public, no require_auth
    if !reward_info.processed {
        panic_with_error!(…LiquidityNotFilled)
    }
    let pool_tps = if reward_info.total_liquidity > 0 {
        /* compute based on liquidity */
    } else {
        0  // locked at zero
    };
    /* set incentives on the pool */
}

BVSS
Recommendation

To mitigate this issue, following steps are recommended:

1- Access-control

  • Gate fill_liquidity() and config_pool_rewards() with require_auth(rewards_admin) or

  • Restrict them to the pool contract itself (called via a trusted callback).

2- Replay / sanity checks

  • Reject fill_liquidity() when total_liquidity == 0.

  • Allow an admin-only “refill” that overwrites liquidity and recalculates TPS.


Remediation Comment

SOLVED: The Normal Finance team solved this issue by restricting access to fill_liquidity() and config_pool_rewards() so they can only be executed by the rewards_admin, preventing unauthorized calls.


They also added a check to reject fill_liquidity() when total_liquidity == 0, ensuring the function cannot be called in an uninitialized or empty pool state, which mitigates potential misconfigurations or abuse.

Remediation Hash
281bb6a8c23370412842b5ee82f36e1d5f72d90f

7.5 Dust-Initialisation / Fee-Skimming Risk — totalLP Can Fall to 0

//

Low

Description

The Pool contract allows the LP-token total supply to reach zero:

  • deposit() mints LP tokens 1-for-1 with the Token-B amount;

  • withdraw() burns the same amount, enabling the last LP to empty the pool.

  • When the pool is empty, an attacker can:

  1. Supply a dust-sized deposit (e.g., 1 Token B) and thereby own 100% of the LP supply.

  2. Any swaps executed before other LPs join pay their entire fee to that attacker (fees are credited pro-rata to LP balances).

  3. The attacker burns their single LP token, withdrawing the dust plus all accrued fees, resetting totalLP back to zero.

  4. This maneuver can be repeated whenever the pool is emptied.


    Price manipulation is prevented by the contract’s oracle-pegged rebalance(), so the impact is limited to economic fee leakage and potential griefing, rather than direct theft of user funds.


BVSS
Recommendation

To address this issue it is recommended to implement the industry-standard “minimum-liquidity burn” on the first deposit:

// inside deposit() just before minting to the user

const MIN_LIQUIDITY: u128 = 1_000;          // any small constant

let mut shares_to_mint = token_b_amount;

if get_total_lp_tokens(&e) == 0 {

    // lock MIN_LIQUIDITY LP tokens forever

    mint_lp_tokens(

        &e,

        &Address::from_contract_id(&e, &BytesN::from_array(&e, &[0; 32])), // burn address

        MIN_LIQUIDITY as i128,

    );

    shares_to_mint = shares_to_mint.saturating_sub(MIN_LIQUIDITY);

}

mint_lp_tokens(&e, &user, shares_to_mint as i128);

Optional hardening:

  • Reject withdrawals that would push Token-B reserves below the value represented by MIN_LIQUIDITY.

  • Emit an event logging the permanently locked liquidity for transparency.


Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue with the following improvements:

  • Automated minimum liquidity recovery: Modified set_status function to automatically trigger minimum liquidity
    recovery when a pool is set to Delisted status

  • Streamlined workflow: Pool delisting now automatically recovers locked minimum liquidity tokens and transfers the
    underlying Token B amount to the admin who initiated the delisting

  • Minimum balance validation - Added validation to always make sure the pool has atleast the funds to send back to the admin


Remediation Hash
References
Uniswap V2 Whitepaper, section 3.4 “Minimum Liquidity”
Uniswap V2 Core implementation — MINIMUM_LIQUIDITY = 1000 burned to address(0)

7.6 Stale-delay Allows Frozen or Cached Prices to Look Fresh

//

Low

Description

When cached == true or oracle.frozen == true, get_price immediately returns:

OraclePriceData {
    price: historical.last_oracle_price_twap,
    delay: historical.last_oracle_delay,   // ← static snapshot
}

last_oracle_delay is updated only inside update_twap, which never runs in this branch. As soon as the oracle is frozen (or callers routinely request cached data) the stored delay stops changing.

Any downstream code that checks:

delay <= guard.seconds_before_stale_for_pool

will always pass, even if the underlying quote is hours or days old. Pools can therefore trade on stale prices, exposing LPs and traders to manipulation or bad-debt risk.


BVSS
Recommendation

To remediate this issue we recommend the following corrective action:

if cached || oracle.frozen {
    let now   = e.ledger().timestamp();
    let delay = now - historical.last_oracle_price_twap_ts;  // recompute in real time

    return OraclePriceData { price: historical.last_oracle_price_twap, delay };
}

Alternatively, remove last_oracle_delay from storage entirely and always derive it from the stored timestamp.

Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue by computing delay instead of using a stored one and temporal type (delay) implementation.


Remediation Hash
6443f8b3af88d0d7ec66e6dfdfd0e48d32956d33

7.7 Zero-Amount Deposits Allowed

//

Informational

Description

After the pool is initialized, deposit() accepts calls with token_b_amount = 0. The function still:

  1. Executes a 0 amount transfer_token (cross-contract call).

  2. Queries both oracles and runs rebalance().

  3. Emits deposit_liquidity and rebalance events with zero values.


This wastes gas for the caller and lets anyone spam on-chain logs and oracle calls, potentially throttling genuine activity.

BVSS
Recommendation

To mitigate this issue it is recommended to add a check at the top of deposit():

if token_b_amount == 0 {
    panic_with_error!(&e, PoolValidationError::ZeroAmount);
}

Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue by adding a non-zero check.

Remediation Hash
d8a27555b4c2f1f724a33ff71de1f908fa85283d

7.8 Zero-Amount Stake Accepted

//

Informational

Description

InsuranceFund::deposit(user, amount) does not check that amount > 0.

When amount == 0 the function:

  1. Skips the token transfer.

  1. Mints 0 IF-shares and leaves total_shares unchanged.

  2. Still executes all accounting code and emits an if_stake_record event.<


Impact

  • No stake inflation: percentage ownership and future interest remain unchanged.

  • Wastes gas and clutters event logs; a bot could spam zero-stakes at very low cost.


BVSS
Recommendation

It is recommended to add a check to ensure amount > 0.

Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue by implementing a non-zero check.

Remediation Hash
d8a27555b4c2f1f724a33ff71de1f908fa85283d

7.9 Price-override Cooldown can be Bypassed

//

Informational

Description

set_oracle_price attempts to rate-limit admin overrides:

if now - oracle.last_updated <= guard.seconds_before_stale_for_pool {
    panic!(PriceOverrideTooSoon);
}


However, the function never updates oracle.last_updated after a successful override. Once the first override has passed the threshold, every subsequent ledger tick also passes, allowing an authorized account to push a new price each block.

While only privileged roles can exploit this, it defeats the intended operational safety net.


BVSS
Recommendation

To mitigate this issue we recommend the following changes

  • After a successful override, set oracle.last_updated = now and persist the modified OracleInfo with put_oracle.

  • Alternatively, introduce a dedicated last_override_ts field and compare now - last_override_ts against the cooldown.


Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue by updating lst_updated to the current time in function.

Remediation Hash
9b274e9056a4ee682e0b97c26e662626ef14ce5d

7.10 Redundant Ownership-Transfer: Future Owner Can Equal Current Owner

//

Informational

Description

commit_transfer_ownership does not forbid future_address == current_address.

Admin can inadvertently trigger the three-day delay but end up with the same owner after apply, causing needless downtime.

BVSS
Recommendation

It is recommended to reject identical addresses or treat it as a no-op.

Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue by adding a check.

Remediation Hash
3361556aac47f7919c64515cf1d049b6477d32ec

7.11 sanitize_clamp_denominator is Unchecked (Division-by-Zero Risk)

//

Informational

Description

Admins can supply any i64 value including 0 or negative numbers for sanitize_clamp_denominator. If update_twap divides by this field, a zero triggers a panic that bricks price updates; a negative value may yield signed arithmetic anomalies and corrupt TWAP.

BVSS
Recommendation

To mitigate this issue we recommend the following hard-validation in both registration and update paths:

require!(sanitize_clamp_denominator > 0, OracleRegistryError::InvalidClampDenominator);

You can also consider capping it to a reasonable upper bound to avoid accidental overflow in downstream math.

Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue by checking util for negative denominators - avoids twap clamps and validating util in sync with prev type changes.

Remediation Hash
dc12345f8f6f09613ea67c3eab75413304f5966c

7.12 Unbounded Synthetic-Minting During “Reduce-Only” Mode

//

Informational

Description

When the pool’s status is set to ReduceOnly, the swap logic blocks direct Token B → Token A buys, but rebalance() can still mint any positive amount of Token A if deposits or Token A→Token B swaps create a peg deficit.

Because there is no quantitative limit, a sequence of large Token B deposits can expand total synthetic supply contradicting the intent of “reduce-only” (wind-down risk).


Impact

  • No immediate loss of funds, but the pool’s net synthetic exposure can increase while users believe risk is being reduced.

  • If synthetic supply grows significantly, settlement collateral requirements rise and downstream risk metrics become inaccurate.

  • Classified as Informational because the behaviour is within spec but undermines policy assumptions.


BVSS
Recommendation

It is recommended to keep minting enabled but cap it per ledger to a small, configurable fraction of current supply.

Example patch inside rebalance():

let delta_a = get_delta_a(&e, base_price, quote_price);

if pool.is_reduce_only() && delta_a > 0 {

    // allow minting up to 0.1 % of current supply per ledger

    let mint_cap = (get_total_synthetic_tokens(&e) / 1000) as i128;

    if delta_a > mint_cap {

        panic_with_error!(&e, PoolError::SwapReduceOnly);

    }

}
  • Synthetic supply can still rise enough to keep trades flowing, but risk is bounded.

  • Make mint_cap_bps a governance parameter so admins can tighten or loosen the limit as needed.

  • Edge-case behaviour (pool reaches cap and price drifts) should be documented for integrators.

  • Consider emitting an event when minting is blocked by the cap to alert risk dashboards.


Remediation Comment

SOLVED: The Normal Finance team successfuly remediated this issue by making the following improvements.

  • Strict ReduceOnly for all the use cases

  • Configurable rebalance mint cap and event


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.