Prepared by:
HALBORN
Last Updated 08/15/2025
Date of Engagement: June 25th, 2025 - August 5th, 2025
100% of all REPORTED Findings have been addressed
All findings
12
Critical
0
High
3
Medium
1
Low
2
Informational
6
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.
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.
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.
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
3
Medium
1
Low
2
Informational
6
Security analysis | Risk level | Remediation Date |
---|---|---|
Oracle Registry Argument Swap Triggers Panic on Price Up-Ticks | High | Solved - 08/01/2025 |
Sanitize New Price Underflows On Price Drop, Triggering Protocol Wide Panic | High | Solved - 08/01/2025 |
NAV Underpricing via Fixed Share Minting | High | Solved - 08/04/2025 |
PoolRouter Reward Front-Run Enables Zero-Emission Griefing | Medium | Solved - 08/01/2025 |
Dust-Initialisation / Fee-Skimming Risk — totalLP Can Fall to 0 | Low | Solved - 07/29/2025 |
Stale-delay Allows Frozen or Cached Prices to Look Fresh | Low | Solved - 08/01/2025 |
Zero-Amount Deposits Allowed | Informational | Solved - 07/30/2025 |
Zero-Amount Stake Accepted | Informational | Solved - 07/30/2025 |
Price-override Cooldown can be Bypassed | Informational | Solved - 08/01/2025 |
Redundant Ownership-Transfer: Future Owner Can Equal Current Owner | Informational | Solved - 08/01/2025 |
sanitize_clamp_denominator is Unchecked (Division-by-Zero Risk) | Informational | Solved - 07/31/2025 |
Unbounded Synthetic-Minting During “Reduce-Only” Mode | Informational | Solved - 08/01/2025 |
//
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.
#[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);
}
}
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)
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:
The parameters passed to block_operation() were in the wrong order.
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.
//
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 …
}
#[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);
}
}
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.
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.
//
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.
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.
An attacker waits for a rebalance that mints A (NAV/share > 1 B).
Attacker deposits B and receives shares at 1 B per share (under fair value).
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.
Enables economic extraction without any privileged access.
Scales with the size/frequency of synthetic mints.
Encourages timing attacks (MEV) around rebalances and oracle movements.
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.
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.
//
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:
fill_liquidity(asset)
Reads the pool’s on-chain liquidity (initially zero for a fresh pool).
Sets processed = true and stores total_liquidity.
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 */
}
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.
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.
//
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:
Supply a dust-sized deposit (e.g., 1 Token B) and thereby own 100% of the LP supply.
Any swaps executed before other LPs join pay their entire fee to that attacker (fees are credited pro-rata to LP balances).
The attacker burns their single LP token, withdrawing the dust plus all accrued fees, resetting totalLP back to zero.
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.
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.
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
//
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.
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.
SOLVED: The Normal Finance team successfuly remediated this issue by computing delay instead of using a stored one and temporal type (delay) implementation.
//
After the pool is initialized, deposit() accepts calls with token_b_amount = 0
. The function still:
Executes a 0 amount transfer_token
(cross-contract call).
Queries both oracles and runs rebalance()
.
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.
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);
}
SOLVED: The Normal Finance team successfuly remediated this issue by adding a non-zero check.
//
InsuranceFund::deposit(user, amount)
does not check that amount > 0
.
When amount == 0
the function:
Skips the token transfer.
Mints 0 IF-shares and leaves total_shares
unchanged.
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.
It is recommended to add a check to ensure amount > 0
.
SOLVED: The Normal Finance team successfuly remediated this issue by implementing a non-zero check.
//
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.
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.
SOLVED: The Normal Finance team successfuly remediated this issue by updating lst_updated
to the current time in function.
//
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.
It is recommended to reject identical addresses or treat it as a no-op.
SOLVED: The Normal Finance team successfuly remediated this issue by adding a check.
//
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.
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.
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.
//
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.
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.
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
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
Stellar AMM
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed