K2
Findings & Analysis Report
2026-08-20
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] Worsening liquidations create bad debt
- [H-02] Stale collateral indices understate health factor
- [H-03]
swap_collateralbypassesMAX_USER_RESERVESandMIN_FIRST_DEPOSIT, enabling reserve fragmentation that strands bad debt - [H-04] Single-asset cap check wrongfully forgives all remaining debt
- [H-05] Every flash liquidation permanently drains the debt-asset reserve, making depositors unable to withdraw their funds
- [H-06]
internal_liquidation_callvalidates close factor beforemin_remaining_debtbump, allowing liquidators to seize 100% of debt when only 50% is permitted - [H-07] Deficit treated as available liquidity
- [H-08] Swap output is trusted without settlement verification
- [H-09] Stale price cache bypasses oracle config changes
- [H-10] Cached oracle prices ignore per-asset freshness limits
- [H-11] Zero-scaled repay clears debt
- [H-12] Zero-balance collateral opt-in can make unhealthy positions unliquidatable
- [H-13] Self-liquidation with
receive_a_token = truebypasses collateral seizure viafrom == toself-transfer intransfer_on_liquidation, socializing residual debt to reserve deficit - [H-14] Dust cleanup burns more debt than callers account for
- [H-15] Unchecked token transfers mint unbacked collateral
- [H-16] Exact-collateral liquidation skips deficit accounting
- [H-17] Same-Asset Borrow Positions Can Become Insolvent With No Working Liquidation Path
- [H-18]
MIN_LEFTOVER_BASEPrevents Valid Max-Close-Factor Liquidations
-
- [M-01] Post-exit settlement erases pending incentive rewards
- [M-02] Collateral can be enabled despite pause/freeze or invalid pricing
- [M-03] Configured health-factor threshold is ignored in key paths
- [M-04] Permanent protocol brick via reserve ID exhaustion — dropreserve retires IDs without recycling, causing irreversible initreserve DoS after 64 cumulative markets
- [M-05] Global pause does not stop flash loans
- [M-06] Emission updates retroactively rewrite unaccrued rewards
- [M-07] Reserve-slot squatting DoS via forced aToken recipient collateral bit
- [M-08] Emergency admin can unpause reserves, breaking pause asymmetry
- [M-09] Incentives silently skip reward checkpointing by querying
scaled_total_supply()with the wrong ABI type - [M-10] Admin transfer leaves stale emergency pause authority
- [M-11] Users can bypass the first deposit check
- [M-12] Public getter preserves stale baseline
- [M-13] Stale reward indexes accrue emissions across inactive periods
- [M-14] Manual reward claims use stale accrued balances
- [M-15] Engine uses uncapped debt for executed liquidation
- [M-16] Router uses stale oracle config after upstream changes
- [M-17] ACL membership checks ignore entries after the first 64
- [M-18] Reserve accounting breaks fee collection when debt exceeds supply
- [M-19] Scaled-share rounding mismatch breaks transfer and withdraw accounting
- [M-20] Cap and ceiling setters call the router with the wrong ABI
- [M-21] Liquidation flow bypasses reserve-level access controls
- [M-22] AMM quote overflow can disable swaps and liquidations
- [M-23] Global pause can be bypassed via direct aToken transfers, breaking containment across remediation
- [M-24] Prepared flash liquidation can bypass collateral reserve pause
- [M-25] Disabled oracle assets remain usable through the batch-price TTL cache
- [M-26] Reserve factor changes do not affect treasury accrual until an unrelated rate-recalc path runs
- [M-27] Unbounded liquidation history can DoS helper liquidation paths
- [M-28] Broken asset-list invariant causes DoS and orphaned whitelist state
- [M-29] Inactive collateral remains counted for new borrowing while liquidation and withdrawal reject the same reserve as
AssetNotActive - [M-30] Deeply underwater liquidations panic instead of capping collateral
- [M-31] Quote helper mutates persistent close-factor state
- [M-32] Wrong active-slot check
- [M-33] Factory reserve tokens cannot be upgraded through the intended path
- [M-34] Wrong actor forwarding bricks token upgrade flows
- [M-35] Rewards lost when total supply drops to zero
- [M-36] Protocol fee is significantly overcharged upon liquidations
- [M-37] Dust-sized debt can turn a very safe account into a MathOverflow denial of service
- [M-38] Indebted users can be blocked from otherwise-safe aToken transfers
- [M-39] Global pause freezes borrower repayment but not borrow interest, leaving positions immediately liquidatable on unpause
- [M-40] Reward checkpointing can consume the full liquidation CPU budget and disable aToken-based collateral seizure
- [M-41] Reserve deficit is excluded from borrow-cap and debt-ceiling checks, allowing new borrows above configured risk limits
- [M-42] Approved spender exceed nominal allowance by splitting aToken transfers into dust chunks
-
Low Risk and Informational Issues
- Summary
- QA-01: Burn-and-transfer can overburn source aTokens
- QA-02: Router reentrancy lock omits public state-writing entrypoints
- QA-03: Router close-factor arithmetic can overflow on large selected debt
- QA-04:
configure_asset_rewardsdoes not validatedistribution_end > current_timestamp - QA-05: Health factor and account-data threshold can be overstated by rounded weighted thresholds
- QA-06: Reserve access-list updates can strand existing positions
- QA-07: Action finalizers can overwrite in-flight reserve config changes
- QA-08: Health-factor, borrow-capacity, and account-data views floor existing debt values
- QA-09: Aquarius adapter does not have admin transfer mechanism
- QA-10: Broken rate strategy contract permanently blocks all reserve operations including liquidations
- QA-11: Treasury
sync_balanceoverwrites internal balance unconditionally - QA-12: Liquidation gates floor borrower collateral support
- QA-13: Oracle circuit-breaker deviation math can overflow before division
- QA-14:
collect_protocol_reservescan drain underlying needed for withdrawal during utilization spikes - QA-15: Pool configurator
initializesets bothpool_adminandemergency_adminto the same address - QA-16: Pool configurator exposes no function to update the price oracle address post-deployment
- QA-17:
prepare_liquidationcan store authorizations for unheld collateral or unwhitelisted handlers - QA-18: Staged liquidation swap minimums round down
- QA-19: Custom/batch oracle setters overwrite shared
max_ageused by the other source - QA-20: Reward index updates can overflow at multiple arithmetic stages
- QA-21: Router aToken mints can credit less claim value than funded
- QA-22: Health-factor narrowing can fail overlarge healthy positions
- QA-23: Uninitialized interest-rate strategies silently quote zero rates
- QA-24: Liquidation and swap routes bypass reserve-level blacklist checks for treasury/liquidator
- QA-25:
set_distribution_enddoes not freshen the reward index before changing end timestamp - QA-26: Liquidation engine
execute_liquidationalways passes_receive_a_token=false - QA-27: Withdrawals can leave zero-balance collateral bits set
- QA-28: omitted
- QA-30:
validate_withdrawdoes not checkis_frozen()whilevalidate_supplyandvalidate_borrowdo - QA-31: : omitted
- QA-32: Non-risk actions depend on unnecessary risk data
- QA-33: Incentives
claim_rewardsdrains accrued state without freshening the global reward index - QA-34:
fund_rewardsreusesMathOverflowerror code for zero-amount rejection - QA-35: Borrow capacity and post-action health checks floor action deltas
- QA-36: Withdrawals can trap payouts in protocol token contracts
- QA-37: Collateral-swap protocol fees can round to zero
- QA-38: Incentives emission manager is a single point of failure with no transfer mechanism
- QA-39: Public swap adapters can spend adapter-held balances
- QA-40: Standalone liquidation previews diverge from router settlement in multiple dimensions
- QA-41: Liquidation access-list updates can leave no eligible liquidator
- QA-42: Batch adapter prices are downcast before normalization
- QA-43: aToken transfer sender rewards settle from post-transfer balance
- QA-44: Debt-free and non-collateral withdrawals blocked by unnecessary oracle dependency
- QA-45: Flash-loan premium sweeps bypass reserve recipient lists
- QA-46: Staged liquidation price tolerance lower bounds round down
- QA-47: Direct aToken transfers move rounded-up scaled value while risk-checking nominal amount
- QA-48: Low oracle price precision can underprice debt
- QA-49: Receive-aToken liquidations can under-transfer collateral
- QA-50: : omitted
- QA-51: Idempotent collateral toggles unnecessarily depend on oracle/health-factor checks
- QA-52:
set_flash_loan_premium_maxdoes not cross-validate against current base + surcharge total - QA-53: Supply caps can undercount post-mint aggregate supply
- QA-54: Reserve drops can strand user bitmap slots across re-registration
- QA-55: Borrows can overmint debt and undercheck risk limits
- QA-56: Incentives
delete_reward_tokendoes not verify all users have claimed before deletion - QA-57: aToken
transfer_on_liquidationusesray_div_downwhile regular transfers useray_div_up - QA-58: Configured incentives break factory reserve deployment
- QA-59:
claim_rewards/claim_all_rewardsdrain accrued state before validating transfer domain - QA-60: Collateral swaps bypass the destination first-deposit minimum
- QA-61: Reserve strategy setters can bind unusable or wrong rate strategies
- QA-62: Incentive emissions during zero-supply windows are permanently lost with no recovery mechanism
- QA-63: Standalone liquidation wrapper uses unbounded per-user history vector
- QA-64: Swap adapters do not validate that
from_token != to_token - QA-65: Upgrade co-signature documentation diverges from per-contract upgrade authority
- QA-66: : omitted
- QA-67: Factory/manual reserve deployment does not verify underlying SEP-41 or token ABI
- QA-68: Disabled fallback oracle state can fail typed storage lookup
- QA-69: Reserve-spread sweeps bypass reserve recipient lists
- QA-70: Staged liquidation price tolerance uses narrow intermediate math
- QA-71: Staged liquidation callback does not authorize router-owned debt-asset spends
- QA-72: Strategy rate caps can overflow reserve indices
- QA-73:
swap_collateraldoes not enforce oracle-based slippage minimum unlike flash liquidation callback - QA-74: Weighted-threshold health-factor arithmetic can overflow before normalization
- QA-75: Swap-handler and reward-token whitelist entries not validated against required ABI
- QA-76:
drop_reservedoes not check for outstanding deficit before removing reserve tracking - QA-77: Reserve sweeps can collect fragmented aToken rounding dust
- QA-78: Liquidations can seize collateral on rounded-down debt burns
- QA-79: Direct liquidation fee conversion can undercollect protocol fees
- QA-80:
set_partial_liq_hf_thresholdandset_hf_liquidation_thresholdallow contradictory boundaries - QA-81: aToken transfers bypass reserve pause, freeze, and active gates
- QA-82: Flash loans accept impossible high asset counts
- QA-83: Factory reserve deployment does not verify token WASM ABI
- QA-84: Account-data LTV arithmetic can overflow on large collateral values
- QA-85: Access-list activation flag and payload state can desynchronize
- QA-86: Account-data silently skips unresolved active bitmap positions
- QA-87:
validate_repaychecksamount == 0separately fromvalidate_amountcreating redundant error paths - QA-88: : omitted
- QA-89: Collateral swaps can require prices for exited source reserves
- QA-90: Reward rounding can overpay emitted incentives by dust units
- QA-91: Liquidation engine records
collateral_to_liquidatefrom its own calculation, not from the router’s actual settlement - QA-92:
set_liquidation_price_toleranceallows zero tolerance silently disabling price deviation protection - QA-93: Pool-configurator rate-strategy rotation lacks downstream contract authorization
- QA-94: aToken transfers can re-enable receiver collateral without consent
- QA-95: Collateral-swap minimum output is enforced before protocol fees
- QA-96: Token contract
initializedoes not require admin auth - QA-97: Partial withdrawals can overburn aToken claim value relative to payout
- QA-98:
remove_asset_rewarddeactivates config but does not update the global reward index - QA-99: MAXUSERRESERVES overrejects slot-neutral same-reserve transitions
- QA-100: : omitted
- QA-101: Claim-all rewards rejects supported asset registries
- QA-102: Whitelist/blacklist membership checks scan only the first 64 entries
- QA-103: Batch price queries group only the first adapter cohort
- QA-104: Oracle config accepts out-of-range circuit-breaker thresholds
- QA-105: Debt token
burn_scaleddust threshold of<= 1may leave non-trivial phantom debt for high-index reserves - QA-106: Collateral-swap fee transfers bypass destination reserve recipient lists
- QA-107: Accepted reserve limits can overflow during user-path conversion
- Disclosures
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the K2 smart contract system. The audit took place from April 17 to May 27, 2026.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 60 unique vulnerabilities. Of these vulnerabilities, 18 received a risk rating in the category of HIGH severity and 42 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 52 QA reports compiling issues with a risk rating of LOW severity or informational.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and K2 team.
Considering the number of issues identified, it is statistically likely that there are more complex bugs still present that could not be identified given the time-boxed nature of this engagement. It is recommended that a follow-up audit and development of a more complex stateful test suite be undertaken prior to continuing to deploy significant monetary capital to production.
Scope
The code under review can be found within the C4 K2 repository, and is composed of 81 files written in the Rust programming language and includes 15,487 lines of Rust code.
The code in C4’s K2 repository was pulled from:
- Repository: https://github.com/Shapeshifter-Technologies/k2-contracts
- Commit hash:
a961e4d8d0122206aa66db3a10ec19149dbc12b7
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/informational.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (18)
[H-01] Worsening liquidations create bad debt
Submitted by v12, also found by 0xastronatey, 0xbereket, aestheticbhai, aman, ArsenBai, ClaudioGoose, constantquadruped, Dex_ter, dhaxor, Felh, Haerincode, LeoGold, Nyx, oieusouogoku, qed, RevolXX, sentient, SOPROBRO, Spottedhyena0, taronsung, TheWeb3Mechanic, twicek, y4y, and zubyoz
Found by V12
contracts/kinetic-router/src/liquidation.rs#L124
internal_liquidation_call checks that the borrower is unhealthy before liquidation and enforces close factor, but it never recomputes or compares the borrower’s post-liquidation health factor after collateral is seized and debt is burned. The repo’s own liquidation comments/tests distinguish a safe partial-liquidation zone above LT * (1 + bonus); with the common 8500 liquidation threshold and 500 bonus, that bound is 0.8925, while the 50% close-factor zone still extends down to 0.5. For borrowers in that reachable band, a 50% liquidation removes collateral faster than debt and makes the health factor worse, yet this function still accepts the trade because its only post-state checks are leftover-value and bitmap updates. Once collateral is exhausted, the same function explicitly burns the remaining debt and books it as reserve_deficit. A liquidator can therefore keep taking bonus collateral from a position that is getting less solvent after each step until the protocol is forced to absorb the residual bad debt.
Root Cause
The function validates only the pre-liquidation state and never enforces the direct-liquidation invariant that the borrower’s post-liquidation position must be safer. validate_close_factor limits size, but nothing proves that the chosen liquidation actually improves solvency before the function finalizes state and, if needed, socializes the remainder as deficit.
Impact
An attacker can repeatedly liquidate borderline unhealthy positions that should be rejected under the documented ‘HF must improve’ invariant, harvesting liquidation bonuses while driving the borrower deeper underwater. That accelerates collateral exhaustion and can end in the reserve_deficit path, shifting unrecovered debt onto the protocol.
Proof of Concept
See this gist for full Proof of Concept
[H-02] Stale collateral indices understate health factor
Submitted by v12, also found by 0xApple, 0xc0ffEE, Augusta, Bizarro, D_Auditor, DemoreX, Es, hezze, JohnTears, kb520, KineticsOfWeb3, oxwhite, qed, Regem21, samuraii77, snufflesrea, TheSchnilch, twicek, wuji, xiaoye, and Xmannuel
Found by V12
contracts/kinetic-router/src/calculation.rs#L99contracts/kinetic-router/src/calculation.rs#L549contracts/kinetic-router/src/calculation.rs#L585
Targets
- KineticRouter::calculateuseraccountdataunified
- KineticRouter::calculateuseraccount_data
- KineticRouter::validateswaphealth_factor
Affected Locations
- KineticRouter.calculateuseraccountdataunified: Core solvency logic values collateral with stored
reserve_data.liquidity_indexinstead of a time-accrued current liquidity index, while debt is accrued withget_current_variable_borrow_index_with_data. - KineticRouter.calculateuseraccount_data: Wrapper entrypoint forwards account checks into the unified implementation, so callers inherit collateral undervaluation when reserve liquidity indices in storage are stale.
- KineticRouter.validateswaphealth_factor: Swap solvency validation is reported to apply the same stale collateral-index treatment, extending the same accounting bias to another health-factor gate.
Description
The router’s health-factor accounting applies fresh accrual to debt balances but not to collateral balances. In calculate_user_account_data_unified, collateral is valued with stored reserve_data.liquidity_index, even though reserve state is lazily updated and a reserve may have accrued supply interest since last_update_timestamp. By contrast, debt uses get_current_variable_borrow_index_with_data, so the two sides of the account are measured at different effective timestamps. This understates total_collateral_base, available_borrows_base, and health_factor for users supplying into untouched reserves, and the same stale-index bias is also reported in validate_swap_health_factor. Because wrapper and validation flows rely on this shared solvency logic, the issue propagates into borrow, withdraw, liquidation, and swap-related authorization decisions.
Root Cause
Collateral valuation paths use stale stored liquidity_index data instead of recomputing the current liquidity index before calling balance_of_with_index.
Impact
Users can be treated as undercollateralized even when accrued supply yield keeps their true health_factor above the liquidation threshold. That can allow premature liquidation and capture of the liquidation bonus from otherwise healthy positions, and it can also incorrectly block withdrawals, borrowing, or swaps until reserves are refreshed.
Proof of Concept
See this gist for full Proof of Concept
[H-03] swap_collateral bypasses MAX_USER_RESERVES and MIN_FIRST_DEPOSIT, enabling reserve fragmentation that strands bad debt
Submitted by Babauca, also found by 4funnds, Agontuk, ahahaHard1k, coinsspor, Cosmo, crunter, deccs, dyce1110, Eniwealth, fellows, hjo, lostOpcode, lucilfer12, Manga, Messiah, mskey, Nyx, oxwhite, oziajibogu, Rikka, S1m0n, smchuzza, Spottedhyena0, Takarez, tipao, twicek, v12, and ZakMHTX
Found with V12
contracts/kinetic-router/src/swap.rs#L298-L320contracts/kinetic-router/src/storage.rs#L9contracts/kinetic-router/src/operations.rs#L96-L106contracts/kinetic-router/src/operations.rs#L415-L425
swap_collateral() can create a brand new reserve position without enforcing either of the two invariants that every other reserve-entry path enforces:
- the per-user reserve cap,
MAX_USER_RESERVES = 15 - the minimum first position size,
MIN_FIRST_DEPOSIT = 1_000
Root Cause
The standard reserve-entry paths enforce the per-user reserve limit:
supply()at operations.rs:96-106 checkscount_active_reserves() >= MAX_USER_RESERVESbefore allowing a first position in a new reserve.borrow()at operations.rs:415-425 performs the same check before allowing a first borrow in a new reserve.validate_and_finalize_transfer()at router.rs:1876-1884 enforces the limit for aToken transfer recipients.
The supply() path also enforces the first-deposit floor at operations.rs:89-92:
if is_first_supply && amount < k2_shared::MIN_FIRST_DEPOSIT {
return Err(KineticRouterError::InvalidAmount);
}
swap_collateral() at swap.rs:298-320 skips both protections. It calls mint_scaled() on the destination aToken (line 305), then immediately sets user_config.set_using_as_collateral(...) (line 320) — with no count_active_reserves() check and no MIN_FIRST_DEPOSIT check.
The protocol documentation at docs/03-CORE-CONCEPTS.md:358-363 explicitly states this limit is security-critical:
the protocol enforces MAXUSERRESERVES = 15 per user (storage.rs:9). Supply and borrow operations revert with MaxUserReservesExceeded if a user attempts to interact with more than 15 distinct reserves. This tighter limit bounds the health-factor computation loop and prevents reserve fragmentation attacks.
Impact
- An attacker can expand their user bitmap beyond the documented hard cap using dust swaps (
amount=2, min_amount_out=1). - Each new reserve slot costs virtually nothing, making fragmentation cheap.
- A fragmented underwater account can become unliquidatable when the health-factor computation exceeds Soroban resource limits, stranding bad debt and threatening protocol solvency.
Recommended Remediation
Apply the same new-reserve protections inside swap_collateral() that already exist in supply() and borrow():
- Detect whether the destination reserve is a first position for the user.
- If it is, reject the swap when
count_active_reserves() >= MAX_USER_RESERVES. - If it is, reject the swap when
amount_to_supply < MIN_FIRST_DEPOSIT.
Proof of Concept
See original finding for full Proof of Concept
[H-04] Single-asset cap check wrongfully forgives all remaining debt
Submitted by v12, also found by 0xanmol, 0xApple, 0xastronatey, 0xfallin1, AD404, Agontuk, AlexeyMilovanov, AvantGard, biakia, Bizarro, Brene, coinsspor, D_Auditor, DemoreX, dqt2506, Drothon, Emmy, Eniwealth, futurehelp, fuzious, harry, hezze, ht111111, Ibukun, j0bear, jaredkusnev, jerryoung, Khan2018, kimnoic, KineticsOfWeb3, Lamsy, legat, LightManKnows, maxzuvex, mishoko, nashhacks43, qed, quietledger, Ronke, samuraii77, snap, spectator, twicek, unsullied, and wuji
Found by V12
contracts/kinetic-router/src/liquidation.rs#L124
Targets
- KineticRouter::internalliquidationcall
Affected Locations
- KineticRouter.internalliquidationcall: When
collateral_amount_to_transfer > user_collateral_balancefor the selectedcollateral_asset, the function treats that single-asset exhaustion as if all user collateral has been exhausted and then clears the remaining debt as bad debt.
Description
The liquidation flow conflates exhaustion of one chosen collateral asset with exhaustion of the user’s entire collateral portfolio. In internal_liquidation_call, if collateral_amount_to_transfer > user_collateral_balance for the liquidated collateral_asset, collateral_cap_triggered is set and the code proceeds to burn the user’s remaining debt and socialize it as protocol bad debt. That assumption is invalid in a multi-collateral account, because the borrower may still hold substantial balances in other collateral assets. A liquidator can therefore target a dust-sized secondary collateral balance, intentionally trigger the cap condition, and cause the protocol to forgive debt that is still fully backed by other assets. The user can then keep or later withdraw their primary collateral while their debt has already been erased from the system.
Root Cause
collateral_cap_triggered is derived only from the balance of the selected collateral_asset, but the function incorrectly uses it as a global signal that all collateral is exhausted and the user’s remaining debt should be forgiven.
Impact
An attacker can shed a large borrowed position by liquidating only a dust collateral asset, leaving the protocol to absorb the rest as deficit. This creates a direct loss of protocol value because the forgiven debt is not actually unrecoverable and the user’s other collateral remains intact and withdrawable.
Proof of Concept
Please review this gist for PoC
[H-05] Every flash liquidation permanently drains the debt-asset reserve, making depositors unable to withdraw their funds
Submitted by juldesbourds, also found by 0xABSattar, 0xastronatey, 0xFBI, 0xJoker42, 0xSmartContractSamurai, 4funnds, abdelhaq16, Agontuk, ahahaHard1k, akgyan03, AvantGard, bogach_avgustina, codeconflict, crunter, Cryptor, D_Auditor, daivo, darkodi, dkv27, edoscoba, Eze, falconz28, Fenice, gahbi, gwumex, honey-k12, jjjj05, Joseph_Nwodoh, kammiz, latzcho, Link1337, makishevOmar, Manga, peter941221, pingy97, qed, ranzo, RustyLock, sh1v, snufflesrea, starrohan, t4sk, TheWeb3Mechanic, tipao, twicek, vvuzy, xKeywordx, and yol
contracts/kinetic-router/src/flash_loan.rs#L345-L561contracts/kinetic-router/src/flash_loan.rs#L237-L254contracts/kinetic-router/src/router.rs#L963-L982
The execute_liquidation path in the Kinetic Router mints an internal flash loan equal to actual_debt_to_cover of the debt asset. The loan is sent from the aToken to the pool contract (router) to fund the liquidation. Inside the liquidation callback, the swap proceeds arrive at the pool, the debt token is burned, collateral is transferred to the liquidator, and the pool sends actual_debt_to_cover back to the aToken as the “repayment”.
The repayment verification only checks that the aToken balance is at least as high as it was before the loan. That check passes trivially because the same funds that left the aToken are the same funds that came back.
What is never checked is whether any net-new underlying reached the aToken. The swap proceeds minus protocol fees and liquidator profit equal actual_debt_to_cover, which the pool spends elsewhere. The amount originally sourced from the aToken is simply returned to the aToken, so from the aToken’s perspective nothing improved. The pool, however, received actual_debt_to_cover from the aToken and never eliminates the corresponding residual from its own balance. That amount remains trapped in the router contract.
After every flash liquidation, actual_debt_to_cover units of the debt asset accumulate in the router address with no mechanism to recover them into the reserve. The seized collateral only funds liquidator profit and protocol fees; it does not restore reserve solvency. The reserve becomes short by exactly the sum of all actual_debt_to_cover values across all flash liquidations.
Root Cause
File: contracts/kinetic-router/src/flash_loan.rs, execute_liquidation_callback (lines 345–561)
The circular fund flow is:
aToken ──[flash: debt_to_cover]──► pool
pool ──[seize collateral]──────► pool
pool ──[swap collateral]───────► handler
handler ──[swap output: debt_received]──► pool
pool balance = debt_to_cover + debt_received
pool ──[repay flash: debt_to_cover]────► aToken
pool ──[protocol fee]──────────────────► treasury
pool ──[profit]────────────────────────► liquidator
(total out besides flash repay = debt_received)
pool residual = debt_to_cover
verify_repayment only confirms that the aToken regained at least its starting balance. Since the repayment transfer equals the original loan amount, the check passes even though the reserve has not received any net-new value.
// flash_loan.rs:237–254
fn verify_repayment(env, underlying_asset, atoken_address, debt) {
let current_balance = get_underlying_balance(env, underlying_asset, atoken_address);
let expected_balance = debt.initial_balance
.checked_add(debt.premium) // premium = 0 for internal loans
.ok_or(KineticRouterError::MathOverflow)?;
require!(
current_balance >= expected_balance, // PASSES: aToken gets its own money back
KineticRouterError::FlashLoanNotRepaid
);
}
There is no symmetric post-condition such as “router balance of the debt asset must be zero” after the callback returns.
The Accounting Illusion
There are two sources of funds available to the pool inside execute_liquidation_callback:
| Source | Amount |
|---|---|
Internal flash loan from aToken |
debt_to_cover |
| Swap proceeds from collateral liquidation | debt_received |
| Pool total in | debt_to_cover + debt_received |
And the outgoing transfers are:
| Destination | Amount |
|---|---|
Repay flash loan back to aToken |
debt_to_cover |
| Protocol fee to treasury | protocol_fee |
| Liquidator profit | debt_received - debt_to_cover - protocol_fee |
| Pool total out excluding residual | debt_received |
Residual left in the router:
(debt_to_cover + debt_received) - debt_received = debt_to_cover
The flash loan repayment is not repayment in any economic sense. The pool is simply returning the aToken’s own funds. The swap proceeds, which are the only new funds introduced during liquidation, are consumed by fees and liquidator profit. Nothing replenishes the reserve for the credit it extended.
No Recovery Path
treasury.rs::collect_protocol_reserves only inspects the difference between the aToken’s underlying balance and the protocol’s recorded available_liquidity, then sweeps that excess from the aToken address to the treasury. It cannot touch assets stranded on the router contract.
cover_deficit allows an external actor to manually send funds into the aToken, but it requires off-chain detection and manual recapitalization. There is no automated mechanism that moves the trapped router balance back into the reserve.
The Alice and Bob Scenario
Setup:
- Alice is a passive LP and deposits
1,000,000 asset_b. - Bob supplies
1,000 asset_aas collateral. - Bob borrows
800 asset_b.
At oracle prices of $1.00 for both assets:
Collateral value × 85% threshold = $850
Debt = $800
Health factor = 1.0625
Bob is healthy.
Now asset_a drops to $0.50:
Collateral value × 85% threshold = $425
Debt = $800
Health factor ≈ 0.53
Bob is liquidatable.
Now a flash liquidation covers 400 asset_b:
aTokensends400 asset_bto the router as an internal flash loan.aTokenbalance becomes999,600.- Router burns Bob’s
400debt tokens. - Router seizes Bob’s collateral and receives about
840 asset_a. - Router swaps
840 asset_afor420 asset_b. - Router sends
400 asset_bback to theaToken. - Router pays
1.2 asset_bin protocol fees. - Router pays
18.8 asset_bin liquidator profit. - Router still holds
400 asset_b.
verify_repayment passes because the aToken recovered the exact funds it originally lent, but the reserve did not receive net-new liquidity.
From the reserve’s perspective:
original LP deposit: 1,000,000
minus Bob borrow: -800
plus flash repay: +400
---------
aToken underlying: 999,600
LP claim: 1,000,000
Shortfall: 400 (sitting in router address, unreachable)
If Alice tries to withdraw the full 1,000,000, the withdrawal reverts due to insufficient liquidity even though the missing 400 is still inside the protocol, just trapped on the router instead of being restored to the reserve.
Impact
Every successful flash liquidation permanently reduces reserve solvency by exactly actual_debt_to_cover units of the debt asset. These tokens are not lost due to rounding, not burned, and not theoretical; they accumulate on the router contract with every liquidation. There is no on-chain mechanism to move them back into the aToken.
As liquidations continue, the insolvency compounds monotonically. Long-term depositors will find that increasing portions of their funds cannot be withdrawn. During sharp market downturns, when many undercollateralized positions are liquidated, the shortfall can grow rapidly. The protocol cannot self-correct because there is no automatic reconciliation, no circuit breaker, and no admin function to sweep the stranded router balance back into the reserve.
Proof of Concept
See original finding for full Proof of Concept
Recommended Mitigation
The internal flash-loan mechanism should be removed from the liquidation path. The router does not need to borrow reserve liquidity in order to liquidate debt; it needs to settle debt against seized collateral and ensure the reserve receives the recovered debt asset.
Option A (preferred)
Remove internal_flash_loan_with_reserve_data from execute_liquidation and avoid circular reserve borrowing entirely:
- Seize collateral from the borrower.
- Swap collateral into the debt asset.
- Transfer
debt_to_coverof the recovered debt asset directly into theaToken. - Burn the borrower’s debt against actual recovered value.
- Send only true excess proceeds to the liquidator and protocol fee recipient.
Under this design, the aToken receives net-new underlying from liquidation and the router finishes with zero residual balance.
Option B (stopgap)
After verify_repayment, assert that the router’s balance of the debt asset is zero:
// After verify_repayment for each debt:
let pool_residual = token::Client::new(env, &debt.asset)
.balance(&env.current_contract_address());
require!(pool_residual == 0, KineticRouterError::FlashLoanAccountingError);
This is a defense-in-depth invariant check. It would revert any liquidation that leaves stranded funds on the router, protecting depositors until the liquidation design itself is corrected.
[H-06] internal_liquidation_call validates close factor before min_remaining_debt bump, allowing liquidators to seize 100% of debt when only 50% is permitted
Submitted by harshsukhii, also found by 0xanmol, 0xApple, 0xBeastBoy, 0xc0ffEE, 0xJoker42, akgyan03, aman, amrin123, astahovavaleriya, deki, dhaxor, firatstory, futurehelp, gzeon, hezze, Jean_Pierre_Polnaref, Joseph_Nwodoh, kammiz, Leader11, LeoGold, MadaniSora, Oxhelm, pepoc, phuocpham, prodigysml, qed, rubencrxz, samuraii77, sh1v, Sharkeeeeeee, SOPROBRO, twicek,whitehatanon1, and z3rco
contracts/kinetic-router/src/liquidation.rs#L234-L262
internal_liquidation_call (called by both liquidation_call and execute_liquidation) enforces the close factor in two separate steps that are intentionally decoupled — but should not be:
Step 1 — CF Validation (L234–237):
The original caller-supplied debt_to_cover is checked against the position’s close factor. For positions with HF ∈ (0.5 WAD, 1.0 WAD) and individual debt/collateral ≥ $2,000, the close factor is 50%, meaning at most half the debt may be liquidated per transaction.
// liquidation.rs:234-237
validate_close_factor(
env, user_account_data.health_factor,
individual_debt_base, individual_collateral_base, debt_to_cover_base,
)?;
Step 2 — min_remaining_debt bump (L239–262):
After validation passes, debt_to_cover is silently escalated to the full outstanding balance whenever the remainder would fall below the configured min_remaining_debt threshold:
// liquidation.rs:239-262
let debt_to_cover = {
if remaining > 0 {
if remaining_u128 < min_remaining_debt_val {
safe_i128_to_u128(env, debt_balance) // ← bumped to 100% of debt
} else { debt_to_cover }
} else { debt_to_cover }
};
The bumped value is then used for the actual liquidation computation (L271) and token burn downstream without a second CF check.
Attack Conditions
- Admin has set
min_remaining_debtfor the reserve (legitimate dust-prevention configuration) - The borrower’s position is in the partial-liquidation HF range (HF ∈ (0.5 WAD, 1.0 WAD)) with individual debt and collateral both ≥ $2,000, so the 50% close factor applies
- A liquidator passes exactly the 50%-cap amount, satisfying CF validation
min_remaining_debttriggers a bump to 100%
Concrete Example
- Borrower has 39,000 token debt ($39,000 at$1.00)
- Admin sets
min_remaining_debt = 19,501whole tokens - HF = 0.926 → 50% close factor applies
- Liquidator calls
liquidation_callwithdebt_to_cover = 19,500(exactly 50%) - CF validation:
19,500 ≤ 19,500→ passes - Remaining after 50% =
39,000 − 19,500 = 19,500 < 19,501→ bumped to39,000 - Actual liquidation: 100% of debt is covered — 2× what the close factor permits
- Borrower loses all collateral; liquidator pays only the 50% amount they declared
Impact: A liquidator can bypass the close factor protection entirely and seize a borrower’s full collateral in a single transaction. The borrower suffers up to double the expected loss. The attack requires only that (1) the position is in the partial-liquidation HF range and (2) the admin has configured min_remaining_debt — a routine operational setting — at a value that creates this overlap. No elevated privileges are needed for the liquidator.
Proof of Concept
See original finding for full Proof of Concept
[H-07] Deficit treated as available liquidity
Submitted by v12, also found by 0xCiphky, 6FigFundedTrader, abdelhaq16, Afriauditor, bchen, botdidy, D_Auditor, Drothon, fuzious, gz627, happykilling, hezze, johnyfwesh, kb520, LTC_DJ, LTCDingZhen, maigadoh, merlinboii, PadIA, samuraii77, SOPROBRO, Steven96, Trynax, y4y, yuzengbao, Z3r0darkth1rty, and z3rco
Found by V12
contracts/kinetic-router/src/calculation.rs#L1000
update_interest_rates_and_store recalculates reserve rates through calculate_interest_rates_for_reserve, which derives utilization from total_supply and total_debt and then sets available_liquidity = total_supply - total_debt. Both liquidation flows can burn unrecoverable debt and book it separately with storage::add_reserve_deficit(...) before calling this function again, so the bad debt is removed from total_debt without any underlying liquidity being restored. Because the rate update does not subtract the stored deficit or read the aToken’s actual underlying balance, it writes artificially low current_variable_borrow_rate and current_liquidity_rate back to storage after a bad-debt event. The borrow path itself checks real aToken cash before lending, so the protocol can remain short of underlying while this function reprices the reserve as if liquidity had returned. Once a reserve carries deficit, any borrower can keep debt there at a systematically discounted rate until treasury/admins cover the hole.
Root Cause
The rate update logic assumes total_supply - total_debt is the source of truth for available liquidity. That invariant stops holding once bad debt is externalized into reserve_deficit, but calculate_interest_rates_for_reserve does not subtract the deficit or use actual underlying liquidity.
Impact
A deficit-bearing reserve is repriced as healthier than it really is, so borrowers can open or maintain debt at undercharged interest for as long as the deficit persists. That leaks yield from suppliers and the treasury, and the leakage scales with the size and lifetime of the bad-debt deficit.
Proof of Concept
See this gist for full Proof of Concept
[H-08] Swap output is trusted without settlement verification
Submitted by v12 |
Found by V12
contracts/aquarius-swap-adapter/src/lib.rs#L220
Targets
- AquariusSwapAdapter::execute_swap
Affected Locations
- AquariusSwapAdapter.execute_swap:
execute_swapcalls the external pool, accepts its returnedamount_outas authoritative, and then transfers that amount torecipientwithout checking the adapter’s actualto_tokenbalance increase.
Description
execute_swap crosses a trust boundary into an external Aquarius pool but never verifies settlement against the adapter’s real token balances. After pool.swap(...) returns, the function only checks the reported amount_out and then forwards exactly that many to_token units to recipient. There is no balance-before/balance-after check to confirm that the pool actually delivered the claimed output, even though the shared swap helper already uses that pattern for external handlers. The supplied test scenario demonstrates the gap: a mock pool can return a value without moving tokens, and the swap still succeeds because the adapter already holds prefunded to_token. As a result, any buggy, malicious, or misregistered pool, or any output token that under-delivers relative to the reported amount, can make the adapter subsidize the swap from unrelated inventory.
Root Cause
execute_swap treats pool.swap’s returned amount_out as authoritative instead of validating the adapter’s actual to_token balance delta after the external call.
Impact
A malicious or broken registered pool can cause the adapter to pay recipients out of its existing to_token balance, draining residual funds or mistaken transfers that happen to be held there. If the adapter does not have enough inventory to cover the overstated amount_out, the final transfer fails and swaps for that pair can be repeatedly disrupted until the condition is corrected.
Proof of Concept
See original finding for full Proof of Concept
Remediation
AquariusSwapAdapter::executeswap now snapshots the adapter’s output-token balance before calling the external pool, computes the actual settled balance delta after the call, enforces minamountout against that real delta, and only transfers/returns the actually received amount. This closes the trust-boundary bug where a pool could overstate amountout and drain preexisting adapter inventory.
[H-09] Stale price cache bypasses oracle config changes
Submitted by v12, also found by 0xterrah, hezze, ht111111, JohnTears, Kear_07, Lamsy, maxzuvex, Mikeyxyz, orangesantra, oziajibogu, Ronke, siddforreal_1, suhail007, Trynax, and y4y
Found by V12
contracts/price-oracle/src/contract.rs#L438contracts/price-oracle/src/contract.rs#L176contracts/price-oracle/src/contract.rs#L193contracts/price-oracle/src/contract.rs#L212contracts/price-oracle/src/contract.rs#L251contracts/price-oracle/src/contract.rs#L647contracts/kinetic-router/src/price.rs#L88
Targets
- PriceOracleContract::getassetprices_vec
- PriceOracleContract::setassetenabled
- PriceOracleContract::updatereflectorcontract
- PriceOracleContract::setcustomoracle
- PriceOracleContract::setfallbackoracle
- PriceOracleContract::setbatchoracle
- KineticRouter::verifyoraclepriceexistsand_nonzero
Affected Locations
- PriceOracleContract.getassetprices_vec: Returns cached
LastPriceDatabefore reading current asset configuration or enforcingconfig.enabledfor batch price requests. - PriceOracleContract.setassetenabled: Disables an asset in config but does not invalidate the cached price entry that batch callers can still consume.
- PriceOracleContract.updatereflectorcontract: Updates reflector configuration without clearing
LastPriceData, so prior cached prices can survive the source change. - PriceOracleContract.setcustomoracle: Changes the custom oracle source without invalidating cached prices already stored for the asset.
- PriceOracleContract.setfallbackoracle: Changes the fallback oracle source without invalidating cached prices already stored for the asset.
- PriceOracleContract.setbatchoracle: Changes the batch oracle source without invalidating cached prices already stored for the asset.
- KineticRouter.verifyoraclepriceexistsand_nonzero: Accepts any fresh non-zero price returned through the batch oracle path, exposing router flows to stale cached prices after admin mitigation.
Description
PriceOracleContract::get_asset_prices_vec uses LastPriceData before it loads the asset’s live configuration, so a cached price can be returned even after the asset has been disabled or its oracle source has been changed. The router relies on this batch path, including through verify_oracle_price_exists_and_nonzero, which means price-sensitive flows continue trusting the cached value during the TTL window. This creates an inconsistent trust boundary because get_asset_price_data_with_config checks enabled before consulting cache, but batch resolution does not. The issue is amplified because admin mitigation functions such as set_asset_enabled, update_reflector_contract, set_custom_oracle, set_fallback_oracle, and set_batch_oracle update config without invalidating LastPriceData. The same fix pattern resolves all of these cases: enforce current config before serving cached data and clear cached prices whenever oracle-relevant configuration changes.
Root Cause
The batch oracle path in get_asset_prices_vec trusts cached LastPriceData before checking current asset configuration, and oracle-config admin functions do not invalidate that cache.
Impact
After admins disable a broken asset or rotate to a safer oracle, users can still execute borrow, withdraw, collateral, swap, or liquidation flows using the stale cached price until the TTL expires. If that cached price is stale or manipulated, the protocol can accept undercollateralized debt, mis-handle liquidations, or otherwise misallocate value based on a price the admin explicitly tried to stop using.
Proof of Concept
See this gist for full Proof of Concept
[H-10] Cached oracle prices ignore per-asset freshness limits
Submitted by v12, also found by 0xsolisec, abdelhaq16, Bale, and qed
Found by V12
contracts/price-oracle/src/contract.rs#L319contracts/price-oracle/src/contract.rs#L438contracts/kinetic-router/src/liquidation.rs#L53contracts/price-oracle/src/oracle.rs#L287contracts/price-oracle/src/oracle.rs#L376
Targets
- PriceOracleContract::getassetpricedatawith_config
- PriceOracleContract::getassetprices_vec
- PriceOracleContract::getassetprices_batch
- PriceOracle::querycustomoracle
- PriceOracleContract::querybatchadapter_direct
Affected Locations
- PriceOracleContract.getassetpricedatawith_config: Cache-hit validation accepts
LastPriceDatausingcache_age <= cache_ttlandprice_age <= oracle_config.price_staleness_threshold, but does not re-applyAssetConfig.max_agebefore returning a cached price. - PriceOracleContract.getassetprices_vec: The batched price path also serves cached entries under the global staleness threshold only, so downstream callers can receive prices that already exceeded the asset-specific heartbeat.
- PriceOracleContract.getassetprices_batch: Router-side batch consumption trusts the oracle response and applies separate freshness settings, so it does not restore the source-specific
max_ageinvariant. - PriceOracle.querycustomoracle: Fresh external custom-oracle reads enforce the stricter per-asset freshness rule, highlighting that the inconsistency is introduced only when the cached path is taken.
- PriceOracleContract.querybatchadapter_direct: Fresh adapter reads honor
config.max_age, but that protection is lost once the value is stored and later returned from cache.
Description
The oracle enforces stricter freshness limits through AssetConfig.max_age when it fetches fresh prices from custom or batch adapters, but that invariant is dropped after the value enters LastPriceData. On cache hits, both get_asset_price_data_with_config and get_asset_prices_vec validate only against oracle_config.price_staleness_threshold plus the cache TTL, so an asset configured for a short heartbeat can still be served for the broader global window. Because callers such as get_asset_prices_batch and router flows consume the returned timestamp under separate staleness settings, they do not re-enforce the asset-specific limit. Public price-refreshing paths let an attacker warm the cache while a favorable quote is valid, then wait until max_age has expired and continue using that stale price. The same code change fixes all reported variants: cached entries must be rejected once they exceed the effective per-asset freshness bound, not just the global threshold.
Root Cause
Cache validation in get_asset_price_data_with_config and get_asset_prices_vec uses only the global oracle_config.price_staleness_threshold instead of the effective per-asset freshness limit derived from AssetConfig.max_age.
Impact
For assets that depend on a tighter max_age, users can borrow, withdraw, or liquidate against prices older than the protocol explicitly configured as acceptable. If markets move during that gap, attackers can over-borrow, delay necessary liquidations and increase bad debt, or liquidate positions using stale valuations and capture collateral at an unfair bonus.
Proof of Concept
See this gist for full Proof of Concept
[H-11] Zero-scaled repay clears debt
Submitted by v12, also found by 0xanmol, 0xBookerH, Agontuk, bunnyhunter, coinsspor, DemoreX, dorkerdevil, Ezrel, hezze, indieshipx247, K42, kind0dev, legat, maigadoh, mfosec, nairobi7, odeili, oziajibogu, qed, rsquared, samuraii77, toski04, tradingview, wuji, and y4y
Found by V12
contracts/shared/src/utils.rs#L79
Description
ray_div_down floor-divides amount * RAY / index, so any positive repayment smaller than index / RAY produces 0. DebtToken::burn_scaled uses that value as amount_scaled and never rejects the zero case. When the borrower’s remaining scaled debt is 1, the later dust-cleanup branch if new_scaled_debt <= 1 overwrites amount_scaled with the full current_scaled_debt and deletes the debt entirely, even though the caller only paid the tiny underlying amount that floored to zero. KineticRouter::repay transfers only amount_to_repay to the pool before calling burn_scaled and ignores the tuple returned by the burn, so the underpayment is not detected. A borrower can intentionally repay down to 1 scaled unit and then clear the entire remainder with a smaller-than-owed payment once the borrow index has grown enough that one scaled unit represents more than the minimal repayment.
Root Cause
ray_div_down legitimately truncates small repayments to zero, but DebtToken::burn_scaled allows that zero-scaled result to enter its new_scaled_debt <= 1 dust-cleanup path and convert it into a full debt wipe. KineticRouter::repay compounds the issue by assuming the requested underlying amount was actually burned instead of validating the effective burn result returned by the debt token.
Impact
A borrower can extinguish more debt than they actually repay, leaving the reserve short and pushing the loss onto suppliers. On reserves with coarse decimals or sufficiently grown borrow indices, the forgiven remainder can exceed a full underlying unit per position, and a motivated attacker can repeat the pattern across many accounts or reserves they control.
Proof of Concept
See this gist for full Proof of Concept
[H-12] Zero-balance collateral opt-in can make unhealthy positions unliquidatable
Submitted by ChainSentry, also found by 0xB4B, 0xSmartContractSamurai, ABAIKUNANBAEV, abdelhaq16, accessControl, AD404, Agontuk, aman, APex, auditinfra-io, AvantGard, beanie95, Bigsam, botdidy, bunnyhunter, CipherGhost, coinsspor, DarkWingCipher, deccs, deki, destro4evr, edoscoba, eightzerofour, emp_mirage, Emperor, ErmanSec, faldiox, fromeo_016, goodman387, gwumex, happykilling, harry, honey-k12, hyun, Insomnia, izhelyazkov, jesjupyter, juldesbourds, Kalogerone, kongwen68, legat, littlebull, lizardon, merlinboii, mosskappa, Najnomics, niffylord, nstatoshi, oade_hacks, odeili, omeftouh, opseeker, oziajibogu, PolarizedLight, Redteamer, ret2basic, Rifter, scanpt, sh1v, Squad_Depth, Stakeados, t4sk, TheCarrot, TheodorNEngoy, trapAkula, Trynax, twicek, udogodwin, Umesh1145, verbabuilders, Voltvoks, Wamir, WhiteKnightK, wladiluv3, wuji, ZakMHTX, ZanyBonzy, and zpbrent
contracts/kinetic-router/src/router.rs#L1256-L1281contracts/kinetic-router/src/calculation.rs#L130-L163contracts/kinetic-router/src/calculation.rs#L197-L245contracts/kinetic-router/src/liquidation.rs#L170-L185
K2 exposes a public entrypoint, set_user_use_reserve_as_coll(user, asset, true), that allows a borrower to mark an active reserve as collateral even when that borrower holds zero aTokens for that reserve. The same function also omits the protocol’s advertised MAX_USER_RESERVES = 15 anti-fragmentation check.
The router’s unified account-data logic later treats every bitmap bit as an active position to price and inspect, even if the reserve carries no user value. Direct liquidation calls that same unified accounting path before repaying debt. As a result, a borrower can:
- open an ordinary 2-asset borrow position,
- self-mark many unfunded reserves as collateral,
- leave their real collateral and debt unchanged, and then
- cause
liquidation_callto exceed realistic Soroban budget limits before any repayment occurs.
This is a liquidation-liveness failure on the protocol’s primary risk-management path.
Why the Protocol Documentation Nakes This a Bug
The project documentation explicitly says the 15-reserve cap exists to prevent this class of failure:
From 03-CORE-CONCEPTS.md:
“While the bitmap structurally supports 64 reserves, the protocol enforces
MAX_USER_RESERVES = 15per user… This tighter limit bounds the health-factor computation loop and prevents reserve fragmentation attacks.”
That statement matters because the bug is exactly a failure of that guarantee: set_user_use_reserve_as_coll(true) creates new active slots without enforcing the 15-reserve limit.
From 10-STORAGE.md:
“To find all of a user’s positions, iterate the
UserConfigurationbitmap.”
That statement matters because the implementation trusts the bitmap as the source of truth for which reserves to inspect. Once phantom bits are written, later health-factor and liquidation logic performs real work for those fake positions.
From 06-LIQUIDATION.md:
“The liquidation system is the primary risk-management mechanism of the K2 protocol.”
That statement matters because the PoC does not merely make a view function expensive. It breaks the documented primary mechanism for resolving unhealthy debt.
Root Cause
1. Zero-balance collateral opt-in writes a bitmap bit anyway
In contracts/kinetic-router/src/router.rs, set_user_use_reserve_as_coll(true) checks only that:
- the reserve exists,
- the reserve is active, and
- the oracle reports a nonzero price.
It does not check that the caller has a nonzero aToken balance for that reserve, and it does not check whether enabling the bit would exceed MAX_USER_RESERVES.
Relevant logic:
if use_as_collateral {
crate::price::verify_oracle_price_exists_and_nonzero(&env, &asset)?;
let mut user_config = storage::get_user_configuration(&env, &caller);
user_config.set_using_as_collateral(
k2_shared::safe_reserve_id(&env, reserve_data.id),
true
);
storage::set_user_configuration(&env, &caller, &user_config);
}
2. Unified account-data trusts the bitmap and iterates every marked reserve
In contracts/kinetic-router/src/calculation.rs, unified account-data:
- iterates the bitmap to discover active positions,
- batch-fetches prices for all marked assets, and
- calls
balance_of_with_indexper marked reserve.
Relevant logic:
let next_reserve_id = storage::get_next_reserve_id(env);
let iteration_bound = next_reserve_id.min(MAX_RESERVES as u32) as u8;
for reserve_id in 0..iteration_bound {
if user_config.is_using_as_collateral(reserve_id)
|| user_config.is_borrowing(reserve_id)
{
if let Some(asset) = storage::get_reserve_address_by_id(env, reserve_id as u32) {
...
all_assets.push_back(asset.clone());
active_positions.push_back((asset, reserve_data));
}
}
}
let price_map = crate::price::get_prices_for_assets(env, &all_assets)?;
for i in 0..active_positions.len() {
let (asset, reserve_data) = active_positions.get(i).unwrap();
let is_collateral = user_config.is_using_as_collateral(...);
let is_borrowing = user_config.is_borrowing(...);
...
let collateral_balance = if is_collateral {
// invokes balance_of_with_index(...)
}
3. Direct liquidation inherits that cost before debt repayment
In contracts/kinetic-router/src/liquidation.rs, liquidation_call computes borrower health using unified account-data before performing liquidation:
// Calculate health factor with fresh prices using unified function
let result = calculation::calculate_user_account_data_unified(env, &user, params)?;
let user_account_data = result.account_data;
if user_account_data.health_factor >= WAD {
return Err(KineticRouterError::InvalidLiquidation);
}
So once the borrower has written enough zero-balance collateral bits, liquidation spends budget on fake positions before any debt is repaid.
Impact
This bug turns a nominally healthy control path into a broken risk-management path under the same economic state.
In the validated PoC:
- the control borrower has 2 real positions: one collateral reserve and one debt reserve,
- the fragmented borrower has the same real collateral value and the same real debt value,
- the only difference is that the fragmented borrower self-added zero-balance collateral bits on extra reserves,
- the control borrower remains liquidatable,
- the fragmented borrower causes direct
liquidation_callto panic withHostError: Error(Budget, ExceededLimit).
The threshold is not theoretical:
- direct liquidation fails at 26 active slots total, created from only 24 zero-balance extra toggles
get_user_account_datafails at 34 active slots total, created from 32 zero-balance extra toggles
That is far below the 64-slot structural bitmap maximum, and it is exactly the sort of reserve-fragmentation failure the documentation says the 15-reserve cap is supposed to prevent.
Why This is Not the Same Root Cause as the Published Fragmentation Issues
There are published issues about fragmentation, but the public path here is materially different.
Published issue family 1:
swap_collateralbypasses the reserve cap by directly setting destination collateral bits during swaps.
Published issue family 2:
- unsolicited aToken transfers force collateral bits onto a victim through transfer finalization.
This submission does not use swaps and does not use inbound transfers.
Instead, the borrower uses their own public set_user_use_reserve_as_coll(true) calls on reserves where their aToken balance remains exactly zero throughout the PoC. The missing ownership check and missing reserve-cap check inside that specific entrypoint are the root cause here.
Recommendation
The fix should address both the state corruption and the downstream amplification:
- In
set_user_use_reserve_as_coll(true), require a nonzero aToken balance before setting the collateral bit. - In the same function, enforce
MAX_USER_RESERVESif the call would create a new active slot. - As defense in depth, have unified account-data and liquidation ignore collateral-only slots whose effective balance is zero, so any stale or phantom bitmap entries cannot amplify runtime cost.
Final Assessment
The protocol documents a 15-reserve cap specifically to bound health-factor work and prevent fragmentation attacks. This public entrypoint bypasses that guarantee without requiring swaps, transfers, or special privileges. The result is not merely a cosmetic bitmap inconsistency; it is a tested path that prevents liquidation of an unhealthy borrower under realistic Soroban limits while the same economic position remains liquidatable in the control case.
Proof of Concept
How to reproduce
If reproducing from a clean checkout, place the following test module at:
tests/integration-tests/src/test_zero_balance_slot_liquidation_budget.rs
and register it in:
tests/integration-tests/src/lib.rs
with:
mod test_zero_balance_slot_liquidation_budget;
Then run:
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" bash build.sh
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" cargo test --package k2-integration-tests zero_balance_collateral_toggles_break -- --nocapture
See original finding for full Proof of Concept
What the PoC proves
The PoC is intentionally differential.
It first proves the control case:
- a borrower with only the real collateral and debt slots is below HF 1.0, and
- direct
liquidation_callsucceeds under the same realistic budget limits.
It then proves the exploit case:
- the fragmented borrower has 26 active slots but only 2 economic positions,
- the first extra reserve has zero aToken balance,
total_collateral_baseis unchanged versus the control account,total_debt_baseis unchanged versus the control account,- and direct
liquidation_callnow panics withExceededLimit.
The second test confirms the same root cause independently through the public account-data path.
Observed results
Observed output from the validated run:
running 2 tests
direct liquidation budget panic reproduced at active_slots=26 extra_zero_balance_slots=24 panic=panicked at ...:
HostError: Error(Budget, ExceededLimit)
...
test test_zero_balance_slot_liquidation_budget::test_poc_zero_balance_collateral_toggles_break_direct_liquidation_budget ... ok
account-data budget panic reproduced at active_slots=34 extra_zero_balance_slots=32 panic=panicked at ...:
HostError: Error(Budget, ExceededLimit)
...
test test_zero_balance_slot_liquidation_budget::test_poc_zero_balance_collateral_toggles_break_account_data_budget ... ok
test result: ok. 2 passed; 0 failed
The direct-liquidation panic log also showed repeated balance_of_with_index calls on reserves whose user balance was zero, confirming that the budget was being spent on phantom positions rather than on any change in the borrower’s real economics.
[H-13] Self-liquidation with receive_a_token = true bypasses collateral seizure via from == to self-transfer in transfer_on_liquidation, socializing residual debt to reserve deficit
Submitted by Silvermist, also found by 0xBookerH, 0xterrah, abdelhaq16, Agontuk, ahmad044, AvantGard, botdidy, bunnyhunter, ChainSentry, deividrobinson, fuzious, harry, hezze, honey-k12, HUNTERRRRRRR, HUNTTTTTTER, ix2b100, Lamsy, meshaqRapha, minicookie, namaila, niffylord, Noahdgoat, openchat97, qed, rends_east, sahuang, sanidhyaved, TheSpark, twicek, unineko, WhiteKnightK, X-Tray03, Yellow-Wolf, and yonko
contracts/a-token/src/contract.rs#L471-L528contracts/kinetic-router/src/liquidation.rs#L387-L487contracts/kinetic-router/src/liquidation.rs#L576-L612contracts/a-token/src/contract.rs#L579-L587
The kinetic router’s liquidation_call does not reject liquidator == user, and the aToken’s transfer_on_liquidation does not short-circuit from == to. When a borrower self-liquidates with receive_a_token = true, the main collateral transfer silently becomes a storage no-op, but the router continues its accounting on the assumption that the collateral was actually seized. Under collateral_cap_triggered, this causes the router to burn the borrower’s residual debt and push it into reserve_deficit, socializing the loss to depositors while the borrower retains the principal and bonus portions of what should have been seized collateral.
The aToken implementation in contracts/a-token/src/contract.rs debits the sender and credits the receiver in two sequential storage reads, and this sequence silently round-trips when the two addresses are the same:
pub fn transfer_on_liquidation(
env: Env,
caller: Address,
from: Address,
to: Address,
amount: u128,
index: u128,
) -> Result<bool, TokenError> {
// ... auth + validation ...
// Debit from sender
let from_balance = storage::get_scaled_balance(&env, &from);
if from_balance < amount_scaled {
return Err(TokenError::InsufficientBalance);
}
storage::set_scaled_balance(&env, &from, &(from_balance - amount_scaled));
// Credit to receiver
let to_balance = storage::get_scaled_balance(&env, &to);
let is_first = to_balance == 0;
let new_to_balance = to_balance.checked_add(amount_scaled)
.ok_or(TokenError::TransferFailed)?;
storage::set_scaled_balance(&env, &to, &new_to_balance);
// ...
}
When from == to, the debit write persists balance - amount_scaled, the credit read observes that just-written value, and the credit write restores the original balance. Net balance change for the borrower is zero. The sibling function transfer_internal in the same file has precisely the guard that is missing here, with a WP-C6 comment that shows the authors understood the self-transfer hazard in the sibling path but never ported the fix:
fn transfer_internal(env: Env, from: Address, to: Address, amount: i128) -> Result<(), TokenError> {
if amount <= 0 {
return Err(TokenError::InvalidAmount);
}
// WP-C6: self-transfer would overwrite the debit with the credit, inflating balance
if from == to {
return Ok(());
}
// ...
}
The router compounds this. In contracts/kinetic-router/src/liquidation.rs, the receive_a_token branch calls transfer_on_liquidation and then derives effective_collateral_removed from the nominal input quantities, never re-reading the post-transfer aToken balance:
if _receive_a_token {
// ... transfer_on_liquidation(user, liquidator, liquidator_collateral, ...) ...
effective_collateral_removed = liquidator_collateral
.checked_add(actually_transferred_fee)
.ok_or(KineticRouterError::MathOverflow)?;
} else {
// ... burn + transfer underlying ...
effective_collateral_removed = collateral_amount_to_transfer;
}
When the transfer was a self-transfer no-op, the liquidator_collateral scaled tokens remain with the borrower, but the router records them as removed. If collateral_cap_triggered is true, the router then takes the bad-debt socialization path on the false premise that all collateral was seized:
if remaining_debt_balance > 0 {
let remaining_debt_u128 = safe_i128_to_u128(env, remaining_debt_balance);
if collateral_cap_triggered {
// H-05: All collateral seized - remaining debt is unrecoverable bad debt.
// ... burn_scaled remaining debt ...
storage::add_reserve_deficit(env, &debt_asset, remaining_debt_u128);
remaining_debt_balance = 0;
// ...
}
}
The residual debt is burned and added to reserve_deficit, and depositors absorb the loss.
An underwater borrower whose position reaches collateral_cap_triggered can call liquidation_call with themselves as both liquidator and user and receive_a_token = true. Per exploit, the borrower pays only the cap-adjusted debt_to_cover (a fraction of the outstanding debt), retains the principal and bonus collateral that would otherwise be paid to an external liquidator (only the treasury-fee slice, a user -> treasury non-self transfer, is actually debited), and the residual debt is burned and pushed into reserve_deficit, socialized to depositors. In a legitimate external liquidation of the same cap-triggered state, the borrower would lose the full collateral balance and the external liquidator would capture the 5% bonus. The exploit redirects that reward to the borrower. The marginal extraction versus a baseline third-party liquidation is approximately scaled_coll_before * (10_000 - flash_loan_premium_bps) / 10_000 in scaled aTokens, effectively the entire liquidator reward on every cap-triggered underwater position.
Attack Scenario
- The attacker opens a borrow position with high LTV.
- The collateral asset price declines through a sequence of sub-20% moves (each permissible under the default
max_price_change_bps = 2000circuit breaker), pushing the position deeply underwater and intocollateral_cap_triggeredstate. The Reflector feed andrefresh_pricesare both permissionless, so this sequence requires no admin intervention. - The attacker calls
liquidation_call(attacker, collateral_asset, debt_asset, attacker, debt_to_cover, true). - The router verifies
HF < WAD(true), computescollateral_cap_triggered = true, and callstransfer_on_liquidation(caller=router, from=attacker, to=attacker, amount=liquidator_collateral, ...), which is a no-op on the attacker’s scaled balance. - The router derives
effective_collateral_removedfrom the nominal input and proceeds to the cap-triggered branch: the residual debt is burned and added toreserve_deficit. - The attacker retains the seized portion of the collateral and exits with their debt wiped and the loss socialized to depositors.
Recommended Mitigation Steps
Reject self-liquidation in the router before any state mutation. Add to internal_liquidation_call in contracts/kinetic-router/src/liquidation.rs:
fn internal_liquidation_call(
env: &Env,
liquidator: Address,
collateral_asset: Address,
debt_asset: Address,
user: Address,
debt_to_cover: u128,
_receive_a_token: bool,
) -> Result<(), KineticRouterError> {
validation::validate_liquidation_whitelist_access(env, &liquidator)?;
validation::validate_liquidation_blacklist_access(env, &liquidator)?;
if liquidator == user {
return Err(KineticRouterError::InvalidLiquidation);
}
// ...
}
Proof of Concept
See original finding for full Proof of Concept
[H-14] Dust cleanup burns more debt than callers account for
Submitted by v12, also found by niffylord
Found by V12
contracts/debt-token/src/contract.rs#L205contracts/kinetic-router/src/storage.rs#L256contracts/kinetic-router/src/operations.rs#L450
Targets
- DebtTokenContract::burn_scaled
- KineticRouter::execute_repay
- KineticRouterContract::addreservedeficit
- KineticRouter::repay
Affected Locations
- DebtTokenContract.burn_scaled:
burn_scaledrounds the requested amount into scaled units and, whennew_scaled_debt <= 1, promotes a partial burn into a full debt wipe instead of preserving the caller’s requested burn size. - KineticRouter.execute_repay:
execute_repayignores theburn_scaledreturn value and derivesremaining_debtfromdebt_balance - amount_to_repay, which leaves router state inconsistent when dust cleanup zeroes the position early. - KineticRouterContract.addreservedeficit:
add_reserve_deficitpersists the stale caller-supplied amount instead of the actual debt written off, so liquidation bad-debt accounting can under-record losses when dust cleanup increases the effective burn. - KineticRouter.repay:
repayis the external entrypoint that transfers only the caller-requested underlying amount, then relies on pre-burn arithmetic for debt cleanup even when the debt token may have burned more debt than requested.
Description
The protocol lets DebtTokenContract::burn_scaled change the economic result of a partial burn through its dust-cleanup branch, but surrounding router logic still assumes the caller-requested amount is the amount of debt actually removed. In the repay flow, this means the router transfers only amount_to_repay underlying and then can have burn_scaled extinguish the borrower’s final scaled unit anyway, forgiving debt that was never paid. The same mismatch also corrupts state cleanup, because borrow-bit clearing is derived from pre-burn remaining_debt instead of the debt token’s actual outcome, leaving is_borrowing set after debt is already zero. The issue is repeated in bad-debt handling, where liquidation paths compute a deficit amount before calling burn_scaled and add_reserve_deficit records that stale value even if dust cleanup wrote off more debt. These behaviors all stem from the same semantic gap between what burn_scaled is allowed to do and what its callers account for.
Root Cause
DebtTokenContract::burn_scaled may increase a partial burn into a full dust cleanup, but KineticRouter uses requested and pre-burn amounts as the source of truth instead of reconciling transfers, borrow-flag updates, and deficit accounting to the actual burn result.
Impact
A borrower can repay just enough to trigger dust cleanup and have more debt canceled than the underlying they supplied, leaking reserve value over repeated cycles. The same desynchronization can also strand users with phantom borrow positions that consume one of the MAX_USER_RESERVES = 15 slots, and it can understate reserve deficits so later protocol-reserve collection treats part of a real shortfall as withdrawable funds.
Proof of Concept
See this gist for full Proof of Concept
[H-15] Unchecked token transfers mint unbacked collateral
Submitted by v12
Found by V12
contracts/kinetic-router/src/operations.rs#L11contracts/kinetic-router/src/swap.rs#L38contracts/kinetic-router/src/validation.rs#L331
Targets
- KineticRouter::supply
- KineticRouter::swap_collateral
- KineticRouterContract::validatesupplycapafterinterest
Affected Locations
- KineticRouter.supply: Calls external token transfer logic and mints based on the requested
amountwithout verifying how much underlying actually arrived. - KineticRouter.swap_collateral: Performs the same nominal-amount minting flow during collateral swaps after only checking transfer success.
- KineticRouterContract.validatesupplycapafterinterest: Validates supply caps against the caller-provided amount instead of a measured post-transfer balance delta.
Description
The router crosses a trust boundary when interacting with listed SEP-41 reserve tokens, but it treats a successful transfer or transfer_from call as proof that the full requested amount was delivered. In supply, it mints aTokens and grants collateral credit for the nominal amount even though the reserve token may be fee-on-transfer, rebasing, faulty, upgradeable, or malicious. The same issue exists in swap_collateral, where amount_to_supply is minted after checking only call success rather than the actual balance increase at a_token_address. validate_supply_cap_after_interest does not mitigate this because it also reasons about the user-supplied amount instead of measured funds received. As a result, the protocol can mint claims against assets it never received and drift into undercollateralized accounting.
Root Cause
The code uses the caller-supplied amount as the source of truth across transfer, cap validation, and minting instead of verifying the actual post-transfer balance delta after calling an external token contract.
Impact
A user can obtain unbacked aTokens and inflated collateral credit by depositing or swapping into a listed reserve token that transfers less than requested. They can then borrow other backed assets or withdraw honest liquidity when available, leaving the pool with a shortfall that is ultimately borne by remaining suppliers as bad debt.
Proof of Concept
See this gist for full Proof of Concept
[H-16] Exact-collateral liquidation skips deficit accounting
Submitted by v12, also found by darkodi, Eniwealth, Ezrel, hezze, khaye26, kimnoic, oxwhite, and sin99xx-waris
Found by V12
contracts/kinetic-router/src/calculation.rs#L849
calculate_liquidation_amounts_with_reserves returns a discrete collateral_amount_to_seize, and both liquidation callers only treat collateral exhaustion as a cap event when that amount is strictly greater than the user’s balance. If the computed seizure is exactly equal to the borrower’s full collateral balance, the liquidation proceeds as an ordinary partial liquidation even though it will remove all collateral. In contracts/kinetic-router/src/liquidation.rs, leftover debt is only burned into reserve_deficit inside the collateral_cap_triggered branch, and the MIN_LEFTOVER_BASE guard only runs when both debt and collateral remain. contracts/kinetic-router/src/router.rs repeats the same > comparison and bad-debt handling in the flash-liquidation path. This lets an underwater user choose a debt_to_cover whose computed seizure exactly matches their collateral and end with zero collateral plus positive debt that is never converted into tracked deficit.
Root Cause
Bad-debt cleanup is keyed entirely off collateral_cap_triggered, but that flag is derived from a strict > comparison against the helper’s returned seizure amount. The liquidation code never handles the equally dangerous collateral_amount_to_seize == user_collateral_balance && remaining_debt > 0 case.
Impact
A borrower can self-liquidate, or use a cooperating liquidator, to reclaim all remaining collateral while leaving unrecoverable debt outstanding on the account. The reserve is left with untracked bad debt and inconsistent insolvency accounting, which breaks deficit handling and leaves operators with a stranded position that the protocol no longer has collateral to recover.
Proof of Concept
See this gist for full Proof of Concept
[H-17] Same-Asset Borrow Positions Can Become Insolvent With No Working Liquidation Path
Submitted by ChainSentry, also found by 0x04, 0xApple, 0xBeastBoy, 0xbyteninja, 0xc0ffEE, 0xCiphky, 0xterrah, ABAIKUNANBAEV, abdelhaq16, Agontuk, AvantGard, bchen, Bigsam, blackpanther, botdidy, CircuitBreaker, cnwangjihe, coinsspor, defi_cit, DemoreX, edoscoba, Ekomzy, Eniwealth, Fade_cyrpto, faldiox, fromeo_016, fuzious, fxch, gh0xt, gwumex, happykilling, harry, hezze, insecuremary, Jesse, Joseph_Nwodoh, Kafoo, kematens, KeyZora, khaye26, Kingdavid, Kryptos, lanrebayode77, legat, lostOpcode, lufP, maigadoh, maxzuvex, mounbasit, n0fr33w1f14u, osuolale, pepoc, poppy, rajatbeladiya, Rexx, samuraii77, Silvermist, SOPROBRO, t4sk, Tewogbade, TheodorNEngoy, TheSchnilch, TheWeb3Mechanic, tradingview, trapAkula, tubadeligoz, twicek, udogodwin, unsullied, XDZIBECX, and yaskow92i
contracts/kinetic-router/src/operations.rs#L285-L330contracts/kinetic-router/src/validation.rs#L517-L534contracts/kinetic-router/src/router.rs#L519-L681contracts/kinetic-router/src/router.rs#L734-L804contracts/kinetic-router/src/flash_loan.rs#L387-L516
The protocol allows a user to create a position where the collateral asset and the borrowed asset are the same reserve. That position starts healthy, but over time the debt index grows faster than the liquidity index, so the debt can overtake the collateral even if the market price never changes.
When that happens, the protocol has no working incentive-compatible liquidation path:
- direct liquidation rejects
collateral_asset == debt_asset - prepared liquidation still authorizes the same-asset pair
- execution of that prepared liquidation fails with
FlashLoanNotRepaideven when the swap handler returns a perfect 1:1 same-token output
This produces unliquidatable bad debt through normal public entrypoints.
Why the Documentation Matters
The liquidation documentation explicitly says that collateral and debt assets must differ, and explains that same-asset liquidation is invalid because it would let a borrower repay debt with the same collateral being seized.
That statement matters because it establishes a protocol invariant: if liquidation is intentionally designed to reject same-asset pairs, then the borrow path must either:
- reject same-asset positions up front, or
- provide some other supported resolution path for them
The contracts do neither. The borrow path allows the position to be created, but the liquidation paths do not reliably resolve it once it becomes unhealthy.
Documentation reference:
06-LIQUIDATION.md:1038-1048
Code References
-
Borrow path allows the position to be created:
contracts/kinetic-router/src/operations.rs:285-330
-
Direct liquidation rejects same-asset pairs:
contracts/kinetic-router/src/validation.rs:517-534
-
Prepared liquidation checks health factor and reserve state, but never enforces
collateral_asset != debt_assetbefore storing authorization:contracts/kinetic-router/src/router.rs:510-681
-
Execute path consumes the stored authorization and proceeds into flash-loan settlement for the same-asset pair:
contracts/kinetic-router/src/router.rs:734-982
-
Flash-liquidation callback burns same-reserve collateral, then transfers back only
debt_to_cover, which is insufficient whencollateral_to_seize > debt_to_cover:contracts/kinetic-router/src/flash_loan.rs:387-557
Root Cause
The protocol relies on a same-asset prohibition during liquidation, but it does not preserve that invariant during borrow.
The failure is a cross-path mismatch:
borrowvalidates reserve state, caps, and health factor, but it never rejects borrowing the same reserve that is being used as collateral.- Direct liquidation explicitly rejects same-asset pairs as
InvalidLiquidation. prepare_liquidationdoes not apply that rejection and stores an authorization for the same-asset pair.execute_liquidationuses a flash loan from the debt reserve’s aToken and then, inside the callback, burns the borrower’s collateral out of that same reserve before transferring onlydebt_to_coverback to the aToken.
In the reproduced PoC, the prepared authorization contained:
debt_to_cover = 1_000_000_000collateral_to_seize = 1_050_000_000
Because the collateral and debt asset are the same, the reserve loses 1.05 units of underlying for every 1.00 unit returned. That is exactly why the flash-loan repayment check fails even with a perfect 1:1 same-token swap handler.
Impact
Any user can open a same-asset collateral/debt position through the public borrow entrypoint. That position can become unhealthy and then insolvent over time without any oracle manipulation or admin action.
Once insolvent, ordinary liquidators cannot resolve it:
- direct liquidation is hard-rejected
- prepared liquidation is accepted and then fails at execution
That means the protocol’s normal liquidation mechanism cannot clear the debt. At that point, only an uncompensated third-party repayment or some out-of-band intervention can reduce the position. That is not a functioning liquidation path.
This is therefore a protocol liveness and solvency issue, not a cosmetic validation mismatch or a mere gas-griefing edge case.
Recommendation
The most robust fix is to reject same-asset debt positions at creation time.
Minimum fix set:
- In
borrow, reject opening debt against the same reserve that is enabled as collateral for that user. - In
prepare_liquidationandexecute_liquidation, enforce the samecollateral_asset != debt_assetinvariant used by direct liquidation. -
Add regression coverage proving that:
- same-asset borrow is rejected, or
- if recursive same-asset leverage is intentionally supported, it has a dedicated insolvency-resolution path that does not rely on a same-reserve flash loan.
Severity Rationale
- It is reachable through normal public entrypoints
- It can deterministically create insolvent positions over time
- Once those positions cross into bad debt, the protocol’s intended liquidation flows do not work
The core impact is that the protocol can accumulate bad debt that its own liquidation system cannot clear through the documented permissionless liquidation path.
Proof of Concept
See original finding for full Proof of Concept
Why this is defensibly different from other unliquidatable-debt findings
This issue does not rely on oracle rotation, stale price caches, bitmap inflation, or zero-balance collateral toggles.
Its root cause is different:
- same-asset debt positions are allowed to be created
- the liquidation system is explicitly designed to reject same-asset settlement
- the prepared liquidation path inconsistently authorizes the pair and then fails during same-reserve flash-loan settlement
[H-18] MIN_LEFTOVER_BASE Prevents Valid Max-Close-Factor Liquidations
Submitted by Egobouros, also found by AvantGard
contracts/shared/src/constants.rs#L37-L40contracts/kinetic-router/src/liquidation.rs#L20-L35contracts/kinetic-router/src/liquidation.rs#L633-L654contracts/kinetic-router/src/router.rs#L1095-L1125
The protocol defines MIN_LEFTOVER_BASE as 1_000 * WAD, so any partial liquidation that leaves either the borrower’s remaining debt value or remaining collateral value below $1,000 reverts.
This is much larger than a dust threshold. It conflicts with the liquidation close-factor logic because the close factor is supposed to define the maximum debt that may be repaid, but the leftover check rejects otherwise valid repayments below that maximum. In common liquidation states, the maximum 50% liquidation is unusable: full liquidation is rejected by the close factor, while the maximum permitted partial liquidation is rejected because the liquidation bonus pushes the remaining collateral below the $1,000 leftover floor.
Vulnerability Details
MIN_LEFTOVER_BASE is hardcoded to $1,000:
pub const MIN_CLOSE_FACTOR_THRESHOLD: u128 = 2_000_000_000_000_000_000_000; // 2000 * WAD
pub const MIN_LEFTOVER_BASE: u128 = 1_000_000_000_000_000_000_000; // 1000 * WAD
For positions whose individual debt and collateral are both at least $2,000, and whose health factor is above the partial-liquidation threshold, the router applies the default 50% close factor:
let close_factor = if individual_debt_base < MIN_CLOSE_FACTOR_THRESHOLD
|| individual_collateral_base < MIN_CLOSE_FACTOR_THRESHOLD
|| health_factor < partial_liq_threshold {
MAX_LIQUIDATION_CLOSE_FACTOR
} else {
DEFAULT_LIQUIDATION_CLOSE_FACTOR
};
let max_liquidatable_debt = individual_debt_base
.checked_mul(close_factor)?
.checked_div(BASIS_POINTS_MULTIPLIER)?;
if debt_to_cover_base > max_liquidatable_debt {
return Err(KineticRouterError::LiquidationAmountTooHigh);
}
After the liquidation executes, the code checks the remaining debt and collateral values:
if remaining_debt_balance > 0 && remaining_collateral_balance > 0 {
...
if remaining_debt_value < MIN_LEFTOVER_BASE || remaining_collateral_value < MIN_LEFTOVER_BASE {
panic_with_error!(env, LiquidationError::LeavesTooLittleDebt);
}
}
The two constraints are inconsistent. The close factor says the liquidator may repay up to D * 50%, but the leftover check silently imposes a second limit:
remaining_collateral = C - debt_to_cover * (1 + liquidation_bonus)
remaining_collateral must be either 0 or >= 1000 WAD
Therefore, any repayment in this interval is rejected:
(C - 1000 WAD) / (1 + bonus) < debt_to_cover < C / (1 + bonus)
This interval frequently overlaps the valid close-factor range. When it does, the maximum close-factor liquidation reverts even though debt_to_cover_base <= max_liquidatable_debt.
The same leftover check is also present in the two-step flash liquidation path in router.rs, returning InvalidLiquidation.
Impact
Liquidators cannot reliably use the protocol’s advertised close factor. Some liquidations that should be valid under the 50% close-factor rule revert solely because the remaining collateral or debt is below an arbitrary $1,000 floor.
This reduces liquidation throughput exactly when rapid deleveraging is needed. In stressed markets, forcing liquidators to search for smaller, non-reverting repayment amounts increases execution risk, reduces competition, and can leave more bad debt in the system. Full liquidation is not a workaround for affected positions, because the close-factor check rejects repayments above the 50% cap while the leftover check rejects the maximum permitted partial repayment.
Recommended Mitigation
Do not use a fixed $1,000 threshold for leftover debt or collateral.
Prefer one of the following:
- Reduce
MIN_LEFTOVER_BASEto a true dust value, such as a few dollars in base currency. - Make the leftover threshold reserve-specific and configurable by governance.
- Apply the check only when the remaining value is genuinely uneconomical relative to transaction costs and liquidation incentives.
- If a liquidation would otherwise leave dust, automatically clamp the repayment to a full close-out when that is allowed by the close-factor rules.
At minimum, the leftover rule must not reject any repayment amount that the close-factor logic explicitly permits.
Proof of Concept
Consider a borrower with:
- Debt value: $4,000
- Collateral value: $3,000
- Liquidation threshold: 80%
- Liquidation bonus: 5%
- Partial liquidation threshold: default
0.5 WAD
The borrower’s health factor is:
HF = collateral * liquidation_threshold / debt
HF = 3000 * 0.80 / 4000 = 0.60
Because HF >= 0.5, debt is at least $2,000, and collateral is at least$2,000, the default 50% close factor applies. The maximum allowed repayment is therefore:
max_liquidatable_debt = 4000 * 50% = 2000
A liquidator repays exactly the maximum valid amount, $2,000. With a 5% liquidation bonus, the protocol seizes:
collateral_seized = 2000 * 1.05 = 2100
Post-liquidation balances are:
remaining_debt = 4000 - 2000 = 2000
remaining_collateral = 3000 - 2100 = 900
The liquidation passes the close-factor check because $2,000 <= $2,000, but then reverts because the remaining collateral is non-zero and below MIN_LEFTOVER_BASE:
remaining_collateral_value = 900 WAD < 1000 WAD
The following self-contained Rust test reproduces the failing condition:
#[test]
fn poc_min_leftover_rejects_valid_max_close_factor_liquidation() {
const WAD: u128 = 1_000_000_000_000_000_000;
const BPS: u128 = 10_000;
const DEFAULT_CLOSE_FACTOR: u128 = 5_000;
const MIN_CLOSE_FACTOR_THRESHOLD: u128 = 2_000 * WAD;
const MIN_LEFTOVER_BASE: u128 = 1_000 * WAD;
let debt_value = 4_000 * WAD;
let collateral_value = 3_000 * WAD;
let health_factor = 600_000_000_000_000_000u128; // 0.60 WAD
let partial_liq_threshold = 500_000_000_000_000_000u128; // default 0.50 WAD
let liquidation_bonus_bps = 500u128; // 5%
assert!(debt_value >= MIN_CLOSE_FACTOR_THRESHOLD);
assert!(collateral_value >= MIN_CLOSE_FACTOR_THRESHOLD);
assert!(health_factor >= partial_liq_threshold);
let max_liquidatable_debt = debt_value * DEFAULT_CLOSE_FACTOR / BPS;
assert_eq!(max_liquidatable_debt, 2_000 * WAD);
let debt_to_cover = max_liquidatable_debt;
// This is accepted by validate_close_factor().
assert!(debt_to_cover <= max_liquidatable_debt);
let collateral_seized = debt_to_cover * (BPS + liquidation_bonus_bps) / BPS;
assert_eq!(collateral_seized, 2_100 * WAD);
let remaining_debt = debt_value - debt_to_cover;
let remaining_collateral = collateral_value - collateral_seized;
assert_eq!(remaining_debt, 2_000 * WAD);
assert_eq!(remaining_collateral, 900 * WAD);
// This matches the liquidation.rs/router.rs leftover check and causes a revert.
assert!(remaining_debt > 0);
assert!(remaining_collateral > 0);
assert!(remaining_collateral < MIN_LEFTOVER_BASE);
}
This demonstrates that a liquidation can be simultaneously within the protocol’s close-factor limit and rejected by the $1,000 leftover check.
Medium Risk Findings (42)
[M-01] Post-exit settlement erases pending incentive rewards
Submitted by v12, also found by 0xApple, 0xjoe, 0xterrah, aestheticbhai, Agontuk, ah_mo, akupemulaygbaik, anonymous237, arkheionx, AvantGard, D_Auditor, DemoreX, edoscoba, eta, happykilling, harry, hezze, Hola123, insecuremary, Joseph_Nwodoh, Kalogerone, la-arana-inteligente, lostOpcode, oxwhite, qed, R3D_XplOiT, rienne, Ryo, sh1v, TheSchnilch, and yongskiws
Found by V12
contracts/incentives/src/contract.rs#L71contracts/incentives/src/contract.rs#L207contracts/incentives/src/contract.rs#L369
Targets
- IncentivesContract::handle_action
- IncentivesContract::claim_rewards
- IncentivesContract::claimallrewards
Affected Locations
- IncentivesContract.handle_action:
handle_actioncomputesbalance_for_rewardsasif user_data.balance_snapshot == 0 { 0 } else { user_data.balance_snapshot.min(user_balance) }, even though token flows call it after balances have already been reduced. - IncentivesContract.claim_rewards:
claim_rewardsonly transfers already storedaccruedamounts and does not advance indices, so it cannot reliably crystallize pending rewards before a balance reduction. - IncentivesContract.claimallrewards:
claim_all_rewardsattempts to readscaled_total_supplyasu128even though the token interface returnsi128, causing index advancement to be skipped and removing a practical pre-exit settlement path.
Description
The incentives system accrues rewards lazily, so settlement must use the balance that was actually held during the elapsed interval. Instead, handle_action is called after withdrawals, repayments, transfers, and liquidation-driven balance reductions, and it computes the rewardable amount as min(balance_snapshot, user_balance). That means a balance-reducing action settles the elapsed interval against the reduced post-action balance rather than the prior balance that earned the emissions, and a full exit can reduce the settled amount to 0. The duplicate reports also note that claim_rewards does not advance indices and claim_all_rewards mis-handles scaled_total_supply, so users cannot reliably crystallize pending rewards before the balance drop. Together, these behaviors cause legitimately earned incentives to disappear when positions are reduced or closed.
Root Cause
handle_action settles rewards from the post-mutation user_balance via min(balance_snapshot, user_balance) instead of the prior balance_snapshot, and the claim paths do not reliably advance indices before payout.
Impact
Users can permanently lose incentive emissions that accrued since their last successful reward settlement when they withdraw, repay, transfer interest-bearing tokens, or are liquidated. On a full exit, the entire unclaimed interval can be wiped out, and the broken claim flows described in the reports make it impractical for users to consistently avoid that loss by claiming first.
Proof of Concept
See this gist for full Proof of Concept
[M-02] Collateral can be enabled despite pause/freeze or invalid pricing
Submitted by v12, also found by 0xAmmie, 0xDemon, Agontuk, binbin2803, coinsspor, fuzious, gh7rx0k3p7, hezze, legat, miinee, qed, ret2basic, Stakeados, TrudentHunter, Trynax, and Willy_Petrus
Found by V12
contracts/kinetic-router/src/router.rs#L1840contracts/kinetic-router/src/router.rs#L1256contracts/a-token/src/contract.rs#L579
Targets
- KineticRouter::validateandfinalize_transfer
- KineticRouterContract::setuserusereserveas_coll
- ATokenContract::transfer_internal
Affected Locations
- KineticRouter.validateandfinalize_transfer: Automatically marks the recipient as using the reserve as collateral when
to_balance_after > 0, but does not apply the same collateral-eligibility checks used on other enablement paths. - KineticRouterContract.setuserusereserveas_coll: Lets users re-enable a reserve as collateral without rejecting reserves that are paused or frozen, so emergency reserve state is not consistently enforced.
- ATokenContract.transfer_internal: Allows existing aTokens to be transferred into another account without enforcing reserve pause/freeze or validating whether the received balance should be eligible collateral before downstream state updates.
Description
Collateral eligibility is enforced inconsistently across the protocol’s entrypoints. ATokenContract::transfer_internal can move existing aTokens to any recipient, and KineticRouter::validate_and_finalize_transfer then auto-enables the received reserve as collateral whenever to_balance_after > 0 without checking whether the reserve is paused, frozen, or otherwise ineligible. KineticRouterContract::set_user_use_reserve_as_coll likewise allows collateral re-enablement without honoring pause/freeze, while the transfer path additionally skips the oracle validation expected on normal collateral-enabling flows. Downstream borrow and health-factor accounting then trusts the UserConfiguration collateral bit, so paused or frozen reserves can still support fresh borrowing and reserves with broken pricing can be force-enabled through unsolicited dust transfers. The same inconsistency therefore defeats emergency reserve controls and creates a griefing path during oracle outages or misconfiguration windows.
Root Cause
Collateral-enable logic is fragmented, so validate_and_finalize_transfer and set_user_use_reserve_as_coll can set UserConfiguration collateral bits without one shared eligibility check for pause/freeze and oracle validity, while later accounting treats those bits as authoritative.
Impact
A user can still use paused or frozen reserve positions as backing for new borrows in other markets, undermining incident-response controls and potentially extracting healthy assets while risky collateral remains counted. If the reserve was disabled because of a depeg, oracle issue, or market stress, this can leave the protocol exposed to undercollateralized debt. Separately, during a missing, zero, or stale-price condition, an attacker with dust aTokens can repeatedly force that reserve into a victim’s collateral set and block borrow or withdraw actions until the victim clears the flag.
Proof of Concept
See this gist for full Proof of Concept
[M-03] Configured health-factor threshold is ignored in key paths
Submitted by v12, also found by 0xBeastBoy, AvantGard, ChainSentry, coinsspor, fellows, hezze, mickeyjoe, qed, samuraii77, SarveshLimaye, and twicek
Found by V12
contracts/shared/src/utils.rs#L276contracts/kinetic-router/src/router.rs#L463contracts/kinetic-router/src/router.rs#L700contracts/kinetic-router/src/router.rs#L336contracts/kinetic-router/src/validation.rs#L160contracts/kinetic-router/src/validation.rs#L228contracts/kinetic-router/src/router.rs#L295contracts/kinetic-router/src/router.rs#L1256contracts/kinetic-router/src/calculation.rs#L585
Targets
- KineticRouter::is_liquidatable
- KineticRouterContract::prepare_liquidation
- KineticRouterContract::execute_liquidation
- KineticRouterContract::sethfliquidation_threshold
- KineticRouterContract::validateusercan_borrow
- KineticRouterContract::validateusercan_withdraw
- KineticRouterContract::liquidation_call
- KineticRouterContract::setuserusereserveas_coll
- KineticRouterContract::validateswaphealth_factor
Affected Locations
- KineticRouter.is_liquidatable: This helper hardcodes liquidation eligibility instead of consuming the configured threshold.
- KineticRouterContract.prepare_liquidation: Liquidation preparation still compares health factor against
WAD, not the stored parameter. - KineticRouterContract.execute_liquidation: The liquidation execution path continues enforcing the hardcoded
1.0cutoff. - KineticRouterContract.sethfliquidation_threshold: Stores the configurable liquidation threshold that should act as the single source of truth for health-factor enforcement.
- KineticRouterContract.validateusercan_borrow: Borrow validation reads the stored liquidation threshold, showing the intended invariant for post-action health factors.
- KineticRouterContract.validateusercan_withdraw: Withdraw validation also uses the configured threshold, which diverges from hardcoded checks elsewhere.
- KineticRouterContract.liquidation_call: The externally reachable liquidation entry uses
WADas the liquidation boundary, enabling mismatched liquidations. - KineticRouterContract.setuserusereserveas_coll: Collateral-toggle logic hardcodes
1.0e18, extending the threshold mismatch beyond liquidation itself. - KineticRouterContract.validateswaphealth_factor: Swap health-factor validation is another path that keeps using the hardcoded boundary.
Description
set_hf_liquidation_threshold stores a configurable health-factor boundary, and validate_user_can_borrow plus validate_user_can_withdraw enforce that stored value. However, liquidation-related and other health-factor-sensitive paths still compare against hardcoded WAD or HEALTH_FACTOR_LIQUIDATION_THRESHOLD instead of reading get_health_factor_liquidation_threshold, including is_liquidatable, prepare_liquidation, execute_liquidation, liquidation_call, set_user_use_reserve_as_coll, and validate_swap_health_factor. This creates two incompatible protocol rules whenever governance selects a supported non-1.0 threshold. If the threshold is lowered below 1.0, positions that the router explicitly permits can still be liquidated immediately under the hardcoded rule. If the threshold is raised above 1.0, some flows keep enforcing only 1.0, which leads to inconsistent blocking, bypass of the intended safety buffer, or users becoming stuck between incompatible checks.
Root Cause
set_hf_liquidation_threshold writes a configurable threshold, but several liquidation and collateral-management paths still compare health factor to hardcoded WAD or HEALTH_FACTOR_LIQUIDATION_THRESHOLD instead of consistently reading get_health_factor_liquidation_threshold.
Impact
When the configured threshold is below 1.0, liquidators can seize collateral and collect liquidation bonuses from borrowers whose positions should still be considered healthy under the configured risk model. When the threshold is above 1.0, users can remain in or move into states that some paths reject and others allow until health factor falls below 1.0, increasing bad-debt exposure and creating inconsistent or stuck user behavior.
Proof of Concept
See this gist for full Proof of Concept
[M-04] Permanent protocol brick via reserve ID exhaustion — dropreserve retires IDs without recycling, causing irreversible initreserve DoS after 64 cumulative markets
Submitted by starrohan, also found by CarlosMB, eyad_404, kodkirmizi, Krace, thrasos, and wuji
contracts/kinetic-router/src/storage.rs#L355-L366contracts/kinetic-router/src/reserve.rs#L471-L475contracts/shared/src/utils.rs#L265-L270contracts/shared/src/constants.rs#L42
K2’s reserve management uses a monotonically increasing NEXT_RESERVE_ID counter to assign unique IDs to each new market. The UserConfiguration bitmap (u128) allocates 2 bits per reserve (one for collateral, one for borrowing), creating a hard architectural ceiling of 64 reserves. When drop_reserve delists a market, the reserve’s data is cleaned up but its ID is permanently retired — the counter is never decremented and the ID slot is never recycled. Once the cumulative number of reserves ever created reaches 64 (regardless of how many were dropped), init_reserve will hard-panic, permanently bricking the protocol.
Vulnerability Details
increment_and_get_reserve_id only increments — it never decrements or recycles dropped IDs (storage.rs#L355-L366):
pub fn increment_and_get_reserve_id(env: &Env) -> u32 {
let current = get_next_reserve_id(env);
let next = current.checked_add(1).unwrap_or_else(|| {
panic_with_error!(env, KineticRouterError::MathOverflow)
});
env.storage().persistent().set(&NEXT_RESERVE_ID, &next);
current
}
drop_reserve removes all reserve data but explicitly retires the ID without making it available for reuse (reserve.rs#L471-L475):
// Reserve can be dropped but ID is permanently retired (never reused)
let reserve_id = _reserve_data.id;
storage::remove_reserve_data(&env, &asset);
storage::remove_reserve_from_list(&env, &asset)?;
storage::remove_reserve_address_by_id(&env, reserve_id);
The developers are explicitly aware this is the current behavior. calculation.rs#L128-L129 states:
// EFF-03: Must use next_reserve_id, NOT reserves_count, because reserve IDs are never
// reused after drop_reserve. Using count would skip higher IDs when gaps exist.
safe_reserve_id enforces the hard ceiling with an unrecoverable panic (utils.rs#L265-L270):
pub fn safe_reserve_id(env: &soroban_sdk::Env, id: u32) -> u8 {
if id >= 64 {
panic_with_error!(env, crate::KineticRouterError::MathOverflow);
}
id as u8
}
This function is called during init_reserve → first supply → set_using_as_collateral. Once NEXT_RESERVE_ID reaches 64, every future init_reserve will eventually trigger a hard panic when users try to interact with the new reserve.
The hard limit is defined in constants.rs#L42:
pub const MAX_RESERVES: u32 = 64;
There is no admin function to reset NEXT_RESERVE_ID, compact the bitmap, or recycle retired IDs. Once 64 reserves have cumulatively existed, new market creation is permanently bricked.
Impact
Permanent, irreversible Denial of Service on init_reserve:
- K2 is designed for RWA and AI agent markets — these are inherently experimental assets with high listing/delisting churn. RWA tokens expire, default, or get deprecated regularly.
- After enough list/delist cycles, the protocol permanently loses the ability to add new markets — even if only a handful of markets are actually active.
- The only fix requires deploying a V2 protocol and migrating all TVL (depositors, borrowers, liquidation infrastructure), creating significant liquidity risk during the migration window.
- There is no admin-level workaround — the
NEXT_RESERVE_IDcounter andUserConfigurationbitmap are architecturally coupled.
Scenario
- K2 DAO lists 20 experimental RWA markets over 2 years (IDs 0–19)
- 10 of those RWA tokens fail → admin calls
drop_reserveon each (IDs 0–9 retired, counter stays at 20) - 30 more markets listed over the next 3 years (IDs 20–49), 10 more dropped (counter stays at 50)
- 14 more markets listed (IDs 50–63) — counter reaches 64
init_reservepermanently panics. Only ~44 markets are active, but no new markets can ever be added.
Recommended Mitigation
Implement a free-list to recycle retired IDs in drop_reserve:
const FREE_RESERVE_IDS: Symbol = symbol_short!("FREEIDS");
pub fn recycle_reserve_id(env: &Env, id: u32) {
let mut free_ids: Vec<u32> = env.storage().persistent()
.get(&FREE_RESERVE_IDS).unwrap_or(Vec::new(env));
free_ids.push_back(id);
env.storage().persistent().set(&FREE_RESERVE_IDS, &free_ids);
}
pub fn allocate_reserve_id(env: &Env) -> u32 {
let mut free_ids: Vec<u32> = env.storage().persistent()
.get(&FREE_RESERVE_IDS).unwrap_or(Vec::new(env));
if let Some(recycled) = free_ids.pop_back() {
env.storage().persistent().set(&FREE_RESERVE_IDS, &free_ids);
return recycled;
}
increment_and_get_reserve_id(env)
}
Call recycle_reserve_id(env, reserve_id) inside drop_reserve, and replace increment_and_get_reserve_id with allocate_reserve_id in init_reserve.
Proof of Concept
Add the following test to tests/c4/src/lib.rs. It demonstrates that after 64 cumulative init_reserve calls (even if all reserves are dropped in between), the 65th call permanently fails.
#[test]
fn test_reserve_id_exhaustion_dos() {
let env = Env::default();
let setup = Setup::new(&env);
// Phase 1: Create 32 reserves and drop all of them.
// Each init_reserve increments NEXT_RESERVE_ID.
// Each drop_reserve does NOT decrement it — the ID is permanently retired.
let mut assets_to_drop: Vec<Address> = Vec::new();
for _ in 0..32u32 {
let asset = env.register_stellar_asset_contract_v2(setup.admin.clone());
let params = default_init_reserve_params();
setup.configurator.init_reserve(
&setup.admin,
&asset.address(),
&setup.a_token_wasm,
&setup.debt_token_wasm,
&setup.interest_strategy,
&setup.treasury,
¶ms,
);
assets_to_drop.push(asset.address());
}
// Drop all 32 reserves — IDs 0..31 are permanently retired
// NEXT_RESERVE_ID remains at 32
for asset in &assets_to_drop {
setup.configurator.drop_reserve(&setup.admin, asset);
}
// Phase 2: Create 32 more reserves (IDs 32..63)
for _ in 0..32u32 {
let asset = env.register_stellar_asset_contract_v2(setup.admin.clone());
let params = default_init_reserve_params();
setup.configurator.init_reserve(
&setup.admin,
&asset.address(),
&setup.a_token_wasm,
&setup.debt_token_wasm,
&setup.interest_strategy,
&setup.treasury,
¶ms,
);
}
// At this point: NEXT_RESERVE_ID == 64
// Only 32 reserves are active, but the ID counter doesn't care about drops
// Phase 3: Try to create the 65th cumulative reserve — PANICS
let final_asset = env.register_stellar_asset_contract_v2(setup.admin.clone());
let params = default_init_reserve_params();
// This will panic with MathOverflow in safe_reserve_id(64)
// when the first user tries to supply to this reserve:
// set_using_as_collateral(safe_reserve_id(64), true)
// -> safe_reserve_id: if id >= 64 { panic!(MathOverflow) }
let result = std::panic::catch_unwind(|| {
setup.configurator.init_reserve(
&setup.admin,
&final_asset.address(),
&setup.a_token_wasm,
&setup.debt_token_wasm,
&setup.interest_strategy,
&setup.treasury,
¶ms,
);
});
assert!(
result.is_err(),
"EXPLOIT CONFIRMED: init_reserve permanently bricked after 64 cumulative reserves"
);
}
What the test proves
- Phase 1: 32 reserves are created (IDs 0–31) then all dropped.
NEXT_RESERVE_ID= 32. Zero active reserves. - Phase 2: 32 more reserves are created (IDs 32–63).
NEXT_RESERVE_ID= 64. 32 active reserves. - Phase 3: Attempting to create one more reserve panics in
safe_reserve_id(64)because64 >= MAX_RESERVES. - Result: The protocol has only 32 active markets but can never add another one.
init_reserveis permanently bricked. The only recovery path is deploying an entirely new protocol version and migrating all TVL.
Key code path that triggers the panic
init_reserve()
→ storage::increment_and_get_reserve_id() // returns 64
→ ReserveData { id: 64, ... }
→ (later, on first supply to this reserve)
→ supply() → set_using_as_collateral()
→ safe_reserve_id(64)
→ if id >= 64 { panic_with_error!(MathOverflow) } // PERMANENT BRICK
[M-05] Global pause does not stop flash loans
Submitted by v12, also found by ahahaHard1k, AllTooWell, Anirruth, boserba77, dee24, DemoreX, dev2be, Killerpl123, Magnum, miinee, Phronesis54, piki, qed, rakanaji, ret2basic, rfa, twicek, viper3, yaractf, and zp6119126
Found by V12
contracts/kinetic-router/src/flash_loan.rs#L36contracts/kinetic-router/src/router.rs#L415contracts/kinetic-router/src/emergency.rs#L6
Targets
- KineticRouterContract::internalflashloanwithreserve_data
- KineticRouterContract::flash_loan
- KineticRouterContract::pause
Affected Locations
- KineticRouterContract.internalflashloanwithreserve_data: This helper enforces only reserve-local conditions such as
is_flashloan_enabled(),is_active(), andconfiguration.is_paused(), so global pause is never applied before funds are lent out. - KineticRouterContract.flash_loan: Public flash-loan entrypoint forwards execution without checking the protocol-wide
storage::is_paused(&env)flag first. - KineticRouterContract.pause: The emergency pause sets the shared
PAUSEDflag, but that state is not consumed by the flash-loan path.
Description
The protocol-wide PAUSED flag set by pause is not enforced by the flash-loan execution path. The public flash_loan entrypoint authenticates and forwards into the internal flash-loan flow, where internal_flash_loan_with_reserve_data checks only reserve-local conditions such as is_flashloan_enabled(), is_active(), and configuration.is_paused(). Because no storage::is_paused(&env) guard is applied, flash loans can still reach asset disbursal and borrower callback execution while the router is globally paused. This is inconsistent with other state-changing flows cited in the findings, which do consult the global pause flag before proceeding. The emergency stop therefore fails to cover one of the protocol’s most powerful liquidity primitives.
Root Cause
The flash-loan path validates only per-reserve pause state and never checks the global PAUSED flag set by pause, so flash_loan enforces the wrong pause invariant.
Impact
After admins pause the protocol, any external user can still obtain flash liquidity from reserves that remain individually active and unpaused, then execute arbitrary receiver callback logic in the same transaction. This weakens incident containment and can allow flash-loan-assisted exploit chains, manipulation strategies, or other emergency-time activity to continue while operators believe user operations are halted.
Proof of Concept
See this gist for full Proof of Concept
[M-06] Emission updates retroactively rewrite unaccrued rewards
Submitted by v12, also found by 0xFBI, 6FigFundedTrader, aestheticbhai, Agozie, audithor, doe611810, FANGO, kmxunan, pepoc, and Wojack
Found by V12
contracts/incentives/src/contract.rs#L596contracts/incentives/src/contract.rs#L677contracts/incentives/src/contract.rs#L726
Targets
- Incentives::setemissionper_second
- Incentives::setdistributionend
- Incentives::removeassetreward
Affected Locations
- Incentives.setemissionper_second: Admin emission updates attempt to checkpoint rewards, but the
scaled_total_supplyinvocation is decoded asu128instead of the token’si128, causing the fallback path to skip the index update. - Incentives.setdistributionend: This admin setter updates the distribution horizon without first accruing rewards through the current timestamp, so the new end time is applied to the entire uncheckpointed interval.
- Incentives.removeassetreward: Removing an asset reward also mutates emission configuration without first checkpointing the prior reward index, retroactively changing reward accounting for elapsed time.
Description
Reward-configuration setters in Incentives do not reliably checkpoint accrued rewards before changing emission parameters, so new settings are applied retroactively to already elapsed time. In set_emission_per_second, the contract tries to fetch scaled_total_supply before updating the index, but it invokes the token with try_invoke_contract::<u128> even though the token returns i128. That type mismatch causes the call to fall through to _ => 0, which prevents the intended accrual update from happening. The other admin setters, set_distribution_end and remove_asset_reward, do not attempt this checkpoint at all before mutating reward parameters. As a result, changing emissions, shortening distribution, or removing a reward can rewrite the reward rate for the whole unaccrued interval instead of only affecting future accruals.
Root Cause
Administrative reward-parameter setters mutate emission state without first checkpointing the current reward index, and set_emission_per_second additionally decodes scaled_total_supply with the wrong return type.
Impact
Users can lose rewards they had already earned but that were not yet checkpointed when an admin changes emission settings. Conversely, raising emissions before checkpointing can over-credit the prior interval and leak excess rewards from the incentive program. Any administrative adjustment to reward configuration can therefore produce materially incorrect reward accounting.
Proof of Concept
[M-07] Reserve-slot squatting DoS via forced aToken recipient collateral bit
Submitted by coinsspor, also found by 0xki, AllTooWell, deccs, Falofi, honey-k12, MinionTechs, samuraii77, twicek, v12, and wuji
Found with V12
contracts/kinetic-router/src/router.rs#L1877-L1882contracts/kinetic-router/src/operations.rs#L100-L105contracts/kinetic-router/src/liquidation.rs#L414-L421contracts/kinetic-router/src/storage.rs#L9
validate_and_finalize_transfer unconditionally sets is_using_as_collateral(reserve_id) = true on any aToken transfer recipient whose post-transfer scaled balance is greater than zero. Combined with the K2-specific hard cap MAX_USER_RESERVES = 15, an attacker can permanently grief any target address by dust-transferring 1 wei of aTokens from 15 distinct reserves. Once the victim’s user-config bitmap is saturated, every subsequent supply() that would open a new reserve position panics with MaxUserReservesExceeded, and the same MAX_USER_RESERVES check in liquidation_call blocks any liquidator whose bitmap was pre-squatted from seizing collateral via the _receive_a_token=true path.
Root Cause
In validate_and_finalize_transfer (router.rs:1877-1882), after computing the recipient’s new aToken balance, the function executes:
if to_balance_after > 0 && !to_config.is_using_as_collateral(reserve_id) {
to_config.set_using_as_collateral(reserve_id, true);
}
This auto-enables collateral on fresh aToken receipt with no consent gate and no collateral-cap check. Aave V3’s SupplyLogic.executeFinalizeTransfer contains no such recipient-side flip — it only manipulates the sender’s bitmap. This is a novel K2-introduced behavior that diverges from Aave V3.
The MAX_USER_RESERVES = 15 hard cap (K2-specific; Aave V3 allows 128) turns a previously-innocuous auto-enable into a DoS weapon: 15 dust transfers completely saturate any target’s bitmap.
Attack Scenario — Base DoS
- Attacker supplies
MIN_FIRST_DEPOSIT = 1000wei into each of 15 configured reserves. Total cost: 15 times 1000 wei across 15 distinct tokens — sub-cent on any realistic asset. - For each reserve, attacker sends 1 wei of the corresponding aToken to the victim. Each transfer triggers
validate_and_finalize_transfer, flipping the collateral bit for that reserve on the victim’s user-config. - Victim’s
count_active_reserves()equals 15. - Any
supply()call by the victim into a 16th reserve panics atoperations.rs:104withMaxUserReservesExceeded. - The victim’s only “escape” is to manually withdraw the 1-wei dust from an old reserve to clear the bit — but the attacker re-stuffs the freed slot at near-zero cost on the next block. Unbounded griefing.
Attack Scenario — Liquidator DoS (Higher Impact)
An underwater-prone borrower can weaponize this bug before going underwater to lock out specific liquidation bots:
- Borrower supplies collateral and borrows to near-max LTV.
- Borrower (or a Sybil address they control) stuffs a target liquidator’s user-config with 15 dust aToken positions. Cost: about 15k wei total.
- Asset price drops, the borrower’s HF falls below 1.
- The target liquidator calls
liquidation_call(..., _receive_a_token=true). - Liquidation reverts at
liquidation.rs:417-419withInvalidLiquidationbecause the liquidator’scount_active_reserves() >= MAX_USER_RESERVEScheck fails.
The _receive_a_token=false fallback path still works, but:
- It requires sufficient underlying liquidity in the aToken contract — often drained during market stress when liquidations matter most.
- It forces the liquidator to accept underlying immediately instead of aTokens, disrupting bots optimized for the aToken-receive path (avoiding DEX slippage, earning supply rate while waiting to exit).
- During volatile markets, this can delay liquidations long enough for bad debt to materialize.
Impact
Direct user-facing DoS:
- Any user can have their ability to open new reserve positions permanently griefed at near-zero attacker cost.
- The victim must actively monitor and sweep dust from saturated slots; the attacker re-saturates faster than the victim can respond in practice (a single attacker tx vs a user-initiated withdraw loop requiring price oracle availability and per-reserve transactions).
Protocol-level impact via liquidator DoS:
- Sophisticated liquidation bots using
_receive_a_token=truecan be selectively disabled by targets before those targets go underwater. - The fallback path (
_receive_a_token=false) exists, preventing full unliquidatability. - Under market stress, when the aToken’s underlying liquidity is drained (liquidations create the outflows that deplete it), the fallback itself may fail with
InsufficientBalance, leaving positions genuinely unliquidatable and creating bad-debt accrual.
Proof of Concept
Two runnable tests in tests/c4/src/lib.rs demonstrate the vulnerability end-to-end:
test_forced_collateral_bit_on_atoken_transfer— confirms the root-cause primitive: a 1-wei aToken transfer flips the recipient’s collateral bit without consent.test_liquidator_slot_squatting_blocks_liquidation— the full impact chain: the borrower squats a liquidator’s 15 slots, price drops,liquidation_call(_receive_a_token=true)reverts withInvalidLiquidation, while the_receive_a_token=falsefallback confirms the bug is isolated to the aToken-receive path.
Running the PoC
./build.sh
cargo test --package k2-c4 test_forced_collateral_bit_on_atoken_transfer -- --nocapture
cargo test --package k2-c4 test_liquidator_slot_squatting_blocks_liquidation -- --nocapture
Expected output
test test_forced_collateral_bit_on_atoken_transfer ... ok
test result: ok. 1 passed; 0 failed
[liquidator] post-squat total_collateral_base: 1500000000000
[borrower] HF after price drop: 991666666666666666
[liquidator] try_liquidation_call(receive_a_token=true) = Err(Ok(InvalidLiquidation))
[liquidator] fallback(receive_a_token=false) = Ok(Ok(()))
test test_liquidator_slot_squatting_blocks_liquidation ... ok
test result: ok. 1 passed; 0 failed
Both tests pass, confirming the DoS chain end-to-end: the forced collateral bit flip is the primitive, the MAX_USER_RESERVES cap is the weaponization surface, and the liquidation path is the highest-impact target.
Recommended Mitigation
Mirror Aave V3’s executeFinalizeTransfer semantics: do not auto-flip the recipient’s is_using_as_collateral bit on aToken receipt. The recipient should explicitly opt in via set_user_use_reserve_as_coll. The supply-side flow that legitimately benefits from auto-enable (first supply by the caller themselves) is already covered separately in operations::supply and does not require this code path.
Minimal patch to validate_and_finalize_transfer:
// REMOVE the force-set block on recipient:
if to_balance_after > 0 && !to_config.is_using_as_collateral(reserve_id) {
to_config.set_using_as_collateral(reserve_id, true); // DELETE THIS LINE
}
If auto-enable is preferred for UX, gate it on the recipient’s current count_active_reserves() < MAX_USER_RESERVES and fall back to leaving the bit unset when the cap is reached. This preserves the UX convenience for normal flows while removing the DoS vector — an attacker-forced dust transfer into a 16th reserve becomes a no-op on the bitmap rather than a revert on the victim’s next supply.
[M-08] Emergency admin can unpause reserves, breaking pause asymmetry
Submitted by v12, also found by oxwhite, qed, and y4y
Found by V12
contracts/pool-configurator/src/reserve.rs#L334
The KineticRouter’s global pause system enforces an intentional asymmetry (documented as M-04 fix in emergency.rs): pause() uses validate_emergency_admin (allowing emergency admin or pool admin), while unpause() uses validate_admin (pool admin only). The comment in emergency.rs:14 explicitly states: “Emergency admin can pause but cannot unpause, preventing a compromised emergency key from undoing a deliberate pause set by the pool admin.” However, the PoolConfigurator’s set_reserve_pause function uses validate_emergency_admin for both directions of the boolean paused parameter. This means a compromised emergency admin key can call set_reserve_pause(asset, false) to unpause any individually-paused reserve, completely bypassing the M-04 security invariant at the reserve level. Every other configuration function in the PoolConfigurator (set_reserve_freeze, set_reserve_factor, etc.) correctly uses validate_admin (pool admin only).
Root Cause
set_reserve_pause gates both pause and unpause behind validate_emergency_admin, which accepts the emergency admin OR pool admin, instead of restricting unpause (paused=false) to validate_admin (pool admin only).
Impact
A compromised emergency admin key can re-enable operations on individually paused reserves by calling set_reserve_pause(asset, false). This directly undermines the designed security guarantee that a compromised emergency key cannot undo a deliberate pause, potentially allowing trading on a reserve that was paused due to an exploit or price oracle issue.
Proof of Concept
See this gist for full Proof of Concept
[M-09] Incentives silently skip reward checkpointing by querying scaled_total_supply() with the wrong ABI type
Submitted by ht111111, also found by 0xrex, 4funnds, Ace2, hezze, JuggerNaut63, MikePeth, pepoc, qed, samuraii77, secssilia, v12, and zp6119126
Found with V12
contracts/incentives/src/contract.rs#L418-L427contracts/incentives/src/contract.rs#L623-L629contracts/a-token/src/contract.rs#L534-L536contracts/debt-token/src/contract.rs#L280-L281
K2’s incentives contract assumes that scaled_total_supply() returns a u128. The real token contracts do not:
a-token::scaled_total_supply()returnsi128debt-token::scaled_total_supply()returnsi128
That mismatch is not harmless. In both claim_all_rewards() and set_emission_per_second(), the incentives contract performs a cross-contract query like:
try_invoke_contract::<u128, IncentivesError>(..., "scaled_total_supply", ...)
and then treats every non-Ok(Ok(_)) result as:
total_supply = 0
The PoC confirms the runtime behavior on the real a-token WASM:
- querying
scaled_total_supply()asi128returns the expected nonzero supply; - querying the exact same function as
u128does not land inOk(Ok(_)); - the incentives call sites therefore fall through to the
_ => 0branch.
This breaks two live reward-accounting paths.
First, claim_all_rewards() does not checkpoint the latest pending interval. If the caller has a real positive token supply but the cross-contract u128 decode is swallowed into 0, the function skips the global index update entirely. The user only receives rewards that had already been materialized in storage before the claim. The pending interval remains invisible until some later handle_action() call checkpoints it through the token callback path.
Second, set_emission_per_second() does not checkpoint the old emission regime before switching to the new one. The intended behavior is:
- settle rewards earned under the old rate up to the mutation timestamp;
- then store the new emission rate.
Instead, the same ABI mismatch collapses total_supply to 0, so the old interval is never checkpointed. The next real handle_action() then accrues the entire skipped interval using the new emission rate, retroactively repricing rewards that should have been settled under the old one.
This is not a theoretical type neatness issue. It is a live accounting bug that changes user-facing reward outcomes:
claim_all_rewards()underpays by skipping the most recent interval;set_emission_per_second()can misprice a historical interval after an admin emission change.
Recommended mitigation steps
Make the incentives contract query scaled_total_supply() using the token contracts’ real ABI (i128), then convert to u128 only after a successful decode and sign check. More broadly, do not treat cross-contract decode failures as a silent 0 supply in reward checkpointing paths; bubble the error or fail the checkpoint explicitly.
Proof of Concept
Executable PoC file
tests/unit-tests/src/finding_007_poc.rs
Test names
test_claim_all_rewards_skips_latest_interval_on_real_atokentest_set_emission_per_second_skips_old_checkpoint_on_real_atoken
Run command
cargo test -p k2-unit-tests finding_007_poc -- --nocapture
What it proves
The PoC uses the real a-token WASM instead of a mock and demonstrates two concrete effects of the ABI mismatch:
- reading
scaled_total_supply()asi128succeeds and returns the expected nonzero value; - reading the same function as
u128does not land inOk(Ok(_)), which matches the incentives contract’s_ => 0fallback path.
From there it proves two user-visible failures:
claim_all_rewards()transfers0and leaves the reward index unchanged, even though a fresh reward interval has accrued;set_emission_per_second()does not checkpoint the old interval before changing the emission rate, so the nexthandle_action()reprices the skipped interval under the new rate.
That shows the finding is not a static ABI style concern. It changes reward accounting on live paths.
See original finding for full Proof of Concept
[M-10] Admin transfer leaves stale emergency pause authority
Submitted by v12, also found by nuhtasha and Sikoh
Found by V12
contracts/pool-configurator/src/contract.rs#L504contracts/pool-configurator/src/contract.rs#L14contracts/pool-configurator/src/contract.rs#L275contracts/pool-configurator/src/contract.rs#L302contracts/pool-configurator/src/storage.rs#L113contracts/pool-configurator/src/reserve.rs#L334
Targets
- PoolConfiguratorContract::accept_admin
- storage::validateemergencyadmin
- reserve::setreservepause
- PoolConfiguratorContract::initialize
- PoolConfiguratorContract::pausereservedeployment
- PoolConfiguratorContract::unpausereservedeployment
Affected Locations
- PoolConfiguratorContract.accept_admin:
accept_adminrotatesPOOL_ADMINand the upgrade admin but does not update or clearEMERGENCY_ADMIN, leaving the former admin authorized after handoff. - storage.validateemergencyadmin:
validate_emergency_adminauthorizes either the stored emergency admin or the current pool admin, so the stale address continues to satisfy emergency checks. - reserve.setreservepause: Stale emergency authority reaches reserve pausing, which updates live reserve state and causes router-side paused-reserve checks to reject user actions.
- PoolConfiguratorContract.initialize:
initializesetsEMERGENCY_ADMINto the same address as the initialPOOL_ADMIN, anchoring emergency authority to the original admin. - PoolConfiguratorContract.pausereservedeployment: A former admin can still call this deployment pause entrypoint because it is protected by the stale emergency-admin authorization path.
- PoolConfiguratorContract.unpausereservedeployment: A former admin can still call this deployment unpause entrypoint because it is protected by the stale emergency-admin authorization path.
Description
The admin handoff flow leaves emergency authority behind on the previous admin address. During initialize, the contract stores the initial admin into both POOL_ADMIN and EMERGENCY_ADMIN, but accept_admin later rotates only POOL_ADMIN and the upgrade admin. Because storage::validate_emergency_admin accepts either the current pool admin or the stored emergency admin, the old address continues to pass authorization indefinitely after transfer. That stale privilege still reaches pause_reserve_deployment, unpause_reserve_deployment, and reserve::set_reserve_pause, so a retired or compromised former admin can keep changing emergency-controlled protocol state. The issue accumulates across transfers because there is no path to clear or rotate EMERGENCY_ADMIN after initialization.
Root Cause
accept_admin updates POOL_ADMIN but never synchronizes or clears EMERGENCY_ADMIN, while validate_emergency_admin continues accepting the stored emergency-admin address.
Impact
A former admin can still pause reserves or halt reserve deployment after governance believes control has been transferred away, causing router checks on paused reserves to block actions such as supply, withdraw, borrow, repay, liquidation, and similar asset interactions. If the old admin key was compromised or intentionally removed, the attacker retains effective emergency control and can trigger a temporary freeze of live markets until the current admin reverses the changes.
Proof of Concept
See this gist for full Proof of Concept
[M-11] Users can bypass the first deposit check
Submitted by Kalogerone, also found by abdelhaq16, DemoreX, marcioj, mustapha, oziajibogu, Peter3144, ProngsDev, Quintus, samuraii77, and TheCodeReaper
These are the fist supply checks:
pub fn supply(
env: Env,
caller: Address,
asset: Address,
amount: u128,
on_behalf_of: Address,
_referral_code: u32,
) -> Result<(), KineticRouterError> {
// ...
// M-04: Enforce minimum first deposit to prevent share inflation attacks
if is_first_supply && amount < k2_shared::MIN_FIRST_DEPOSIT {
return Err(KineticRouterError::InvalidAmount);
}
// Update user configuration only if first time using this reserve
// Preserve user's existing collateral preference for subsequent supplies
if is_first_supply {
let mut user_config = storage::get_user_configuration(&env, &on_behalf_of);
let reserve_id = k2_shared::safe_reserve_id(&env, updated_reserve_data.id);
// Check if this is a new reserve position (not already using as collateral)
if !user_config.is_using_as_collateral(reserve_id) {
let active_count = user_config.count_active_reserves();
if active_count >= storage::MAX_USER_RESERVES {
panic_with_error!(&env, UserReserveError::MaxUserReservesExceeded);
}
}
crate::price::verify_oracle_price_exists_and_nonzero(&env, &asset)?;
user_config.set_using_as_collateral(reserve_id, true);
storage::set_user_configuration(&env, &on_behalf_of, &user_config);
}
// ...
}
Τhis can be bypassed. Imagine a user who has never supplied and has 0 ATokens. Someone transfers this user just 1 Wei of AToken setting his scaled balance > 0. In the mint_scaled called his scaled balance is checked and because it is > 0, it returns is_first_supply as false.
pub fn mint_scaled(
env: Env,
caller: Address,
on_behalf_of: Address,
amount: u128,
index: u128,
) -> Result<(bool, i128, i128), TokenError> {
// ...
// M-14
let scaled_u128 = ray_div_down(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let amount_scaled = safe_u128_to_i128(&env, scaled_u128);
let current_scaled_balance = storage::get_scaled_balance(&env, &on_behalf_of);
let is_first_supply = current_scaled_balance == 0;
// ...
}
A user is able to supply into a reserve without checking if the oracle is valid (crate::price::verify_oracle_price_exists_and_nonzero(&env, &asset)). The user supplies into a reserve that can’t be priced. They earn yield but their HF math reverts thereafter, so withdrawal-with-debt and liquidation both break.
Additionally, his supply doesn’t enable the collateral bit (user_config.set_using_as_collateral(reserve_id, true)) requiring the user to go through additional transactions to do it.
Lastly, the user bypasses the MIN_FIRST_DEPOSIT amount and it’s possible that an inflation attack can now take place as the comment suggests:
// M-04: Enforce minimum first deposit to prevent share inflation attacks
if is_first_supply && amount < k2_shared::MIN_FIRST_DEPOSIT {
return Err(KineticRouterError::InvalidAmount);
}
[M-12] Public getter preserves stale baseline
Submitted by v12
Found by V12
contracts/price-oracle/src/storage.rs#L268
storage::get_last_price is supposed to be a read helper for the circuit-breaker baseline, but it also calls extend_ttl on PersistentKey::LastPrice whenever the entry exists. The contract exposes this helper through the unauthenticated PriceOracle::get_last_price entry point, so any external account can refresh the lifetime of a baseline without producing a new oracle observation. validate_price_change later consumes the same stored value and rejects moves above max_price_change_bps, while a missing value is treated as an uninitialized first query. A patient attacker can therefore keep an old baseline alive across long periods of inactivity on a reserve, even though the underlying market has moved far away from that price. When the asset is used again, the next legitimate price query can fail purely because the attacker preserved stale state through a public getter.
Root Cause
get_last_price is not a pure read: it mutates TTL on a security-critical baseline and is exposed through an unauthenticated public entry point. That makes the lifetime of circuit-breaker state attacker-controlled.
Impact
An attacker can grief a rarely used reserve into a temporary freeze once the real market moves far enough from the attacker-kept baseline. After that, any router flow that needs this asset price can revert with PriceChangeTooLarge until an admin notices and manually resets the circuit breaker.
Remediation
Make the circuit-breaker baseline read helper pure by removing TTL extension from storage::getlastprice. This prevents unauthenticated getlastprice calls from preserving stale baselines; baseline lifetime now only extends when a fresh validated observation is stored via setlastprice.
For suggested patch, see the original finding
[M-13] Stale reward indexes accrue emissions across inactive periods
Submitted by v12, also found by 4funnds, BlackAdam, D_Auditor, ht111111, makarov, oxwhite, Silvermist, SOPROBRO, Tigerfrake, and wuji
Found by V12
contracts/incentives/src/contract.rs#L726contracts/incentives/src/contract.rs#L780contracts/incentives/src/storage.rs#L385contracts/incentives/src/contract.rs#L531contracts/incentives/src/calculation.rs#L12contracts/incentives/src/contract.rs#L369contracts/incentives/src/contract.rs#L71
Targets
- IncentivesContract::removeassetreward
- IncentivesContract::deleterewardtoken
- storage::removerewardtoken
- IncentivesContract::configureassetrewards
- IncentivesContract::updateassetreward_index
- IncentivesContract::claimallrewards
- IncentivesContract::handle_action
Affected Locations
- IncentivesContract.removeassetreward: Disabling an asset reward only flips
is_activeand leaves the existing reward index timestamp in place. - IncentivesContract.deleterewardtoken: Deleting a reward token removes registration metadata but can leave per-asset reward config/index state reusable on later reconfiguration.
- storage.removerewardtoken: The helper cleanup for reward-token deletion does not clear all persisted state tied to the removed reward.
- IncentivesContract.configureassetrewards: Reconfiguration reuses an existing index when present instead of resetting its timestamp for a newly reactivated reward.
- IncentivesContract.updateassetreward_index: The stale
last_update_timestampis consumed here to accrue rewards across periods when the reward was inactive or deleted. - IncentivesContract.claimallrewards: User-triggered reward claims can be the first path that realizes the retroactive accrual.
- IncentivesContract.handle_action: Regular balance updates can also trigger the stale index update after reactivation.
Description
Reward lifecycle transitions do not fully reset or finalize index state when a reward is deactivated or deleted. In remove_asset_reward, the code marks the reward inactive without first advancing the index timestamp, and delete_reward_token/remove_reward_token can leave related ASSET_REWARD_CONFIG or ASSET_REWARD_INDEX data behind. Later, configure_asset_rewards only initializes a fresh index when none exists, so reactivation of the same asset/reward pair reuses the stale last_update_timestamp. The next call into update_asset_reward_index through handle_action or claim_all_rewards then treats the inactive gap as elapsed active time. This causes rewards to be accrued retroactively for periods when emissions were supposed to be off.
Root Cause
Reward removal and deactivation paths fail to clear or checkpoint existing reward index state, and configure_asset_rewards reuses that stale index instead of resetting last_update_timestamp on reactivation.
Impact
Users can claim reward tokens for inactive or deleted periods, causing the incentives system to over-distribute from prefunded reward balances. If a reward is re-enabled with a different emission rate, the entire gap can be repriced at the new rate, making the misallocation substantially larger.
Proof of Concept
See this gist for full Proof of Concept
[M-14] Manual reward claims use stale accrued balances
Submitted by v12, also found by 4funnds, ht111111, rubencrxz, VictoryGod, and Xeekx
Found by V12
contracts/incentives/src/contract.rs#L207
Targets
- IncentivesContract::claim_rewards
Affected Locations
- IncentivesContract.claim_rewards:
claim_rewardssums storeduser_data.accruedvalues and pays from them without first updating the asset reward index or recalculating newly earned rewards.
Description
claim_rewards computes payouts from the stored user_data.accrued snapshot and then subtracts the claimed amount, but it never settles rewards that accrued after the user’s last handle_action. Unlike claim_all_rewards, it does not call update_asset_reward_index or calculate_user_accrued_rewards before determining total_claimable. As a result, rewards earned between the last balance-changing action and the current claim are omitted from the payout. This causes claim_rewards to systematically underpay relative to the full up-to-date entitlement, including when amount = 0. Depending on later user activity and whether emissions have already ended, the missed accrual may only be recoverable through another state-changing action or may remain stranded on this claim path.
Root Cause
claim_rewards reads user_data.accrued without first calling update_asset_reward_index and calculate_user_accrued_rewards to settle newly earned rewards.
Impact
Users calling claim_rewards can receive fewer reward tokens than they have actually earned at the time of the call. In the best case they must perform an extra balance-changing interaction or switch to claim_all_rewards to recover the gap; if emissions have already ended or no further action occurs, part of the earned rewards can remain unmaterialized.
Proof of Concept
For proof of concept and suggested patch, see the original finding.
[M-15] Engine uses uncapped debt for executed liquidation
Submitted by v12, also found by 0xrex, akshay2796, CePeni, pepoc, and y4y
Found by V12
contracts/liquidation-engine/src/contract.rs#L49contracts/liquidation-engine/src/contract.rs#L33contracts/kinetic-router/src/router.rs#L295
Targets
- LiquidationEngineContract::execute_liquidation
- KineticRouterContract::liquidation_call
- LiquidationEngineContract::calculate_liquidation
Affected Locations
- LiquidationEngineContract.execute_liquidation: Computes
actual_debt_to_coverand same-transaction close-factor usage, but then forwards the original user input instead of the capped amount. - KineticRouterContract.liquidation_call: Executes the liquidation with the uncapped
debt_to_coverreceived from the engine, so state transition and recorded budget can diverge. - LiquidationEngineContract.calculate_liquidation: Produces the capped
actual_debt_to_coverused for engine-side accounting that never controls the downstream execution amount.
Description
calculate_liquidation computes a capped actual_debt_to_cover and records same-transaction usage through add_user_liquidated_this_tx, but execute_liquidation forwards the original debt_to_cover to kinetic_router.liquidation_call. That makes the engine’s accounting and LiquidationCall record reflect one amount while the router may execute another. Because the router only validates each call in isolation, the engine’s intended cumulative per-transaction close-factor protection is never applied to the real liquidation amount. A liquidator can therefore split calls within one atomic transaction and exceed the intended cap while also leaving inconsistent debt and collateral data in liquidation history. Fixing execute_liquidation to pass and record actual_debt_to_cover consistently resolves both the batch bypass and the accounting mismatch.
Root Cause
execute_liquidation computes a capped actual_debt_to_cover but discards it and calls kinetic_router.liquidation_call with the original debt_to_cover instead.
Impact
A liquidator can atomically repay and seize more debt and collateral than the protocol’s close-factor rules are supposed to permit, capturing extra liquidation bonus from the borrower. The engine’s internal tracking and emitted liquidation records also become unreliable because they no longer match the amount actually executed by the router.
Proof of Concept
See this gist for full Proof of Concept
[M-16] Router uses stale oracle config after upstream changes
Submitted by v12, also found by qed
Found by V12
contracts/kinetic-router/src/price.rs#L133contracts/kinetic-router/src/storage.rs#L155contracts/kinetic-router/src/storage.rs#L161contracts/price-oracle/src/storage.rs#L116
Targets
- KineticRouterContract::getoracleconfig
- KineticRouterContract::setcachedoracle_config
- KineticRouterContract::flushoracleconfig_cache
- PriceOracleContract::setoracleconfig
Affected Locations
- KineticRouterContract.getoracleconfig: Caches
OracleConfiglocally and reuses it across transactions even when the upstream oracle can change effective settings without changing address. - KineticRouterContract.setcachedoracle_config: Persists the router-side oracle config snapshot, which lets stale precision and staleness parameters survive until an explicit flush.
- KineticRouterContract.flushoracleconfig_cache: Manual cache invalidation is separated into a privileged call instead of being enforced when upstream config changes.
- PriceOracleContract.setoracleconfig: Upstream oracle config can be updated in place, allowing the router cache to drift from the live configuration.
Description
The router caches OracleConfig and keeps serving the cached snapshot from get_oracle_config until the oracle address changes or an admin explicitly calls flush_oracle_config_cache. That assumption is unsafe because the upstream oracle can change configuration in place, including the effective price_precision, without changing its address. As a result, the router can keep using stale precision and staleness parameters in valuation paths such as account-data computation, borrow validation, and liquidation math that derives oracle_to_wad. Those paths compare mis-scaled values against fixed WAD-denominated thresholds like close-factor and leftover-dust limits, so both general health checks and liquidation constraints can be evaluated incorrectly. The issue persists until someone manually synchronizes the router cache, leaving an exploitable desynchronization window after normal oracle administration.
Root Cause
KineticRouterContract.get_oracle_config caches OracleConfig by oracle address even though PriceOracleContract can change the effective configuration, especially price_precision, without changing that address.
Impact
After an upstream oracle or reflector-related config change, users and liquidators can transact while the router is still using outdated scaling assumptions. This can let borrowers over-borrow against overstated collateral or let liquidators seize positions more aggressively than intended, causing wrongful liquidations and bad debt.
Proof of Concept
See this gist for full Proof of Concept
[M-17] ACL membership checks ignore entries after the first 64
Submitted by v12, also found by 0xsh, Bughacker, coinsspor, curiux, DemoreX, goto, lostOpcode, Mpengele, qed, t4sk, TheWeb3Mechanic, and usmanatique
Found by V12
contracts/kinetic-router/src/storage.rs#L813contracts/kinetic-router/src/storage.rs#L873contracts/kinetic-router/src/storage.rs#L759contracts/kinetic-router/src/storage.rs#L933contracts/kinetic-router/src/storage.rs#L829contracts/kinetic-router/src/storage.rs#L889contracts/kinetic-router/src/storage.rs#L770contracts/kinetic-router/src/storage.rs#L946contracts/kinetic-router/src/storage.rs#L1001contracts/a-token/src/contract.rs#L722contracts/kinetic-router/src/validation.rs#L37contracts/kinetic-router/src/validation.rs#L51contracts/kinetic-router/src/validation.rs#L24contracts/kinetic-router/src/operations.rs#L11contracts/kinetic-router/src/operations.rs#L132contracts/kinetic-router/src/operations.rs#L285contracts/kinetic-router/src/operations.rs#L450contracts/kinetic-router/src/router.rs#L415contracts/kinetic-router/src/swap.rs#L38contracts/kinetic-router/src/liquidation.rs#L102contracts/kinetic-router/src/router.rs#L463contracts/kinetic-router/src/router.rs#L700contracts/kinetic-router/src/storage.rs#L858contracts/kinetic-router/src/storage.rs#L745
Targets
- KineticRouterContract::setreservewhitelist
- KineticRouterContract::setreserveblacklist
- KineticRouterContract::setliquidationwhitelist
- KineticRouterContract::setliquidationblacklist
- KineticRouterContract::isaddresswhitelistedforreserve
- KineticRouterContract::isaddressblacklistedforreserve
- KineticRouterContract::isaddresswhitelistedforliquidation
- KineticRouterContract::isaddressblacklistedforliquidation
- KineticRouterContract::isswaphandler_whitelisted
- ATokenContract::validatenotblacklisted
- KineticRouterContract::validatereserveblacklist_access
- KineticRouterContract::validateliquidationblacklist_access
- KineticRouterContract::validateliquidationwhitelist_access
- KineticRouterContract::supply
- KineticRouterContract::withdraw
- KineticRouterContract::borrow
- KineticRouterContract::repay
- KineticRouterContract::flash_loan
- KineticRouterContract::swap_collateral
- KineticRouterContract::liquidation_call
- KineticRouterContract::prepare_liquidation
- KineticRouterContract::execute_liquidation
- KineticRouterContract::getreserveblacklist
- KineticRouterContract::getliquidationwhitelist
Affected Locations
- KineticRouterContract.setreservewhitelist: Admin setter persists an arbitrary reserve allowlist without enforcing the same bound used by later membership checks.
- KineticRouterContract.setreserveblacklist: Admin setter persists an arbitrary reserve denylist without enforcing the same bound used by later membership checks.
- KineticRouterContract.setliquidationwhitelist: Admin setter persists an arbitrary liquidation allowlist without enforcing the same bound used by later membership checks.
- KineticRouterContract.setliquidationblacklist: Admin setter persists an arbitrary liquidation denylist without enforcing the same bound used by later membership checks.
- KineticRouterContract.isaddresswhitelistedforreserve: Reserve whitelist membership is evaluated with
list.len().min(MAX_RESERVES), so only the first 64 configured addresses are honored. - KineticRouterContract.isaddressblacklistedforreserve: Reserve blacklist membership is evaluated with
list.len().min(MAX_RESERVES), so blacklist entries after slot 63 are ignored. - KineticRouterContract.isaddresswhitelistedforliquidation: Liquidation whitelist membership is evaluated with
list.len().min(MAX_RESERVES), so approved liquidators after slot 63 are treated as unwhitelisted. - KineticRouterContract.isaddressblacklistedforliquidation: Liquidation blacklist membership is evaluated with
list.len().min(MAX_RESERVES), so blacklisted liquidators after slot 63 are ignored. - KineticRouterContract.isswaphandler_whitelisted: Swap-handler whitelist uses the same
MAX_RESERVES-bounded scan pattern, so handlers after slot 63 are never recognized. - ATokenContract.validatenotblacklisted:
aTokentransfer/approval enforcement delegates to the router blacklist check, so the reserve blacklist truncation reaches token-level restrictions. - KineticRouterContract.validatereserveblacklist_access: Reserve access validation consumes the truncated reserve blacklist result and propagates the bypass into reserve-facing flows.
- KineticRouterContract.validateliquidationblacklist_access: Liquidation denylist validation consumes the truncated liquidation blacklist result and propagates the bypass into liquidation flows.
- KineticRouterContract.validateliquidationwhitelist_access: Liquidation allowlist validation consumes the truncated liquidation whitelist result and propagates false denials into liquidation flows.
- KineticRouterContract.supply: User-facing reserve entrypoint relies on ACL helpers that ignore addresses after the first 64 positions.
- KineticRouterContract.withdraw: User-facing reserve entrypoint relies on ACL helpers that ignore addresses after the first 64 positions.
- KineticRouterContract.borrow: User-facing reserve entrypoint relies on ACL helpers that ignore addresses after the first 64 positions.
- KineticRouterContract.repay: User-facing reserve entrypoint relies on ACL helpers that ignore addresses after the first 64 positions.
- KineticRouterContract.flash_loan: Flash-loan flow depends on reserve ACL checks that only honor the first 64 configured addresses.
- KineticRouterContract.swap_collateral: Swap flow depends on reserve ACL or handler ACL checks that only honor the first 64 configured addresses.
- KineticRouterContract.liquidation_call: Liquidation entrypoint depends on truncated liquidation whitelist/blacklist checks.
- KineticRouterContract.prepare_liquidation: Two-step liquidation path depends on truncated liquidation whitelist/blacklist checks.
- KineticRouterContract.execute_liquidation: Two-step liquidation path depends on truncated liquidation whitelist/blacklist checks.
- KineticRouterContract.getreserveblacklist: Getter returns the full stored reserve blacklist, which can make operators believe tail entries are enforced when runtime checks ignore them.
- KineticRouterContract.getliquidationwhitelist: Getter loads the full stored liquidation whitelist even though only the first 64 entries are enforced.
Description
The router stores reserve and liquidation ACLs as arbitrary Vec<Address> values, but the corresponding membership checks stop at list.len().min(MAX_RESERVES). Because MAX_RESERVES is the 64-reserve protocol limit rather than an ACL size limit, only the first 64 entries of reserve whitelists, reserve blacklists, liquidation whitelists, liquidation blacklists, and the swap-handler whitelist are ever enforced. This creates a write/read mismatch where setters and getters accept or expose the full list while runtime validation silently ignores every address after slot 63. Tail entries in blacklists therefore bypass validate_reserve_blacklist_access or validate_liquidation_blacklist_access, while tail entries in whitelists fail the corresponding allowlist checks even though they were configured correctly. Since these helpers are reused across router operations and aToken blacklist enforcement, the bug affects real state-changing flows rather than only view functions.
Root Cause
ACL membership checks reuse MAX_RESERVES as the iteration bound while the corresponding setters store unbounded Vec<Address> lists, so only the first 64 configured addresses are ever enforced.
Impact
Once an operator configures an ACL with more than 64 addresses, accounts placed in the tail of a blacklist can keep using reserve operations or liquidation paths, including supply, borrow, withdraw, repay, flash-loan, swap, and aToken-gated actions for the affected reserve. At the same time, legitimate addresses placed in the tail of a whitelist are denied reserve access or liquidation rights, which can reduce keeper participation and leave unhealthy positions unresolved longer.
Proof of Concept
See this gist for full Proof of Concept
[M-18] Reserve accounting breaks fee collection when debt exceeds supply
Submitted by v12, also found by DemoreX and volpasec
Found by V12
contracts/kinetic-router/src/calculation.rs#L1225contracts/kinetic-router/src/treasury.rs#L5contracts/kinetic-router/src/calculation.rs#L938
Targets
- KineticRouter::getprotocolreserves
- KineticRouter::collectprotocolreserves
- KineticRouter::calculateinterestratesforreserve
Affected Locations
- KineticRouter.getprotocolreserves: Computes protocol reserves from
total_supply - total_borrowand returnsInvalidAmountwhen debt exceeds supply, even though that state can occur from normal accrual under a positivereserve_factor. - KineticRouter.collectprotocolreserves: Reuses the same
total_borrow > total_supplyassumption during treasury withdrawal, which causes reserve collection to fail whenever the reserve-accounting view reaches that state. - KineticRouter.calculateinterestratesforreserve: Already models
total_debt >= total_supplyas a possible high-utilization state by clamping available liquidity and emittinginsolvent, showing the accounting assumption is inconsistent across the reserve path.
Description
The reserve-accounting path assumes total_supply must stay greater than or equal to total_borrow, and treats the opposite case as an error in both get_protocol_reserves and collect_protocol_reserves. That invariant does not hold in this protocol because borrower debt compounds at the higher borrow rate while supplier balances grow at the lower liquidity rate, with the spread diverted by reserve_factor. As a result, a heavily utilized market can naturally reach total_borrow > total_supply even without any abnormal behavior, especially since protocol fees are not simultaneously reflected by minting corresponding supplier-side claims. Once that happens, the reserve view begins reverting or returning InvalidAmount, and the treasury collection path is bricked by the same check. The implementation should account for this state explicitly, such as zero-clamping available liquidity and limiting collection to actual underlying cash, rather than assuming total_supply - total_borrow must always be positive.
Root Cause
get_protocol_reserves and collect_protocol_reserves rely on the false invariant total_borrow <= total_supply even though the protocol’s interest model allows debt to outgrow supplier claims as reserves accrue.
Impact
Protocol reserve fees become unqueryable and uncollectible for any reserve that enters total_borrow > total_supply, so treasury revenue can remain stuck for an extended period and potentially indefinitely under sustained high utilization. Off-chain automation that relies on the reserve view will also fail during that interval, and any idle cash in the aToken may be unnecessarily frozen because the code rejects the state instead of handling it safely.
Proof of Concept
See this gist for full Proof of Concept
[M-19] Scaled-share rounding mismatch breaks transfer and withdraw accounting
Submitted by v12, also found by 0xc0ffEE, akhilmanga, bunnyhunter, DemoreX, Kalogerone, Nyx, oziajibogu, Petrus, qed, smchuzza, and wuji
Found by V12
contracts/kinetic-router/src/validation.rs#L228contracts/a-token/src/contract.rs#L254contracts/kinetic-router/src/router.rs#L1840contracts/kinetic-router/src/operations.rs#L132contracts/a-token/src/contract.rs#L579contracts/a-token/src/balance.rs#L26
Targets
- KineticRouter::validateusercan_withdraw
- AToken::burn_scaled
- KineticRouter::validateandfinalize_transfer
- KineticRouter::withdraw
- ATokenContract::transfer_internal
- AToken::balanceofwith_index
Affected Locations
- KineticRouter.validateusercan_withdraw: Health-factor validation subtracts collateral using the requested underlying
amount, even though the transfer path can move a larger effective balance once scaled-share rounding is applied. - AToken.burn_scaled: Withdrawal settlement burns capped scaled balances, so the actual state change can be smaller than the underlying amount the router pays out unless the amount is recomputed afterward.
- KineticRouter.validateandfinalize_transfer: This transfer finalization path forwards the nominal transfer
amountinto validation instead of reconciling against the rounded scaled-share amount actually consumed by the token contract. - KineticRouter.withdraw: Withdrawals use the rounded underlying view returned by the token balance helper as if it were safely spendable, including the max-withdraw path.
- ATokenContract.transfer_internal: Token transfers settle in scaled units with upward division, so the real balance reduction can exceed the nominal underlying amount checked by the router.
- AToken.balanceofwith_index: This helper exposes a half-up rounded underlying balance, which the router later treats as an exact spendable amount.
Description
The router and aToken logic do not agree on what amount is actually being moved when balances are represented in scaled shares and converted back to underlying with rounding. In the transfer path, validate_user_can_withdraw and validate_and_finalize_transfer validate only the caller-supplied underlying amount, while transfer_internal can reduce the sender’s real collateral by more once rounded scaled shares are applied. In the withdrawal path, withdraw trusts the half-up rounded value from balance_of_with_index, but burn_scaled settles against capped scaled balances and may burn less than that rounded amount implies. Because the router does not derive the final underlying delta from the scaled amount actually transferred or burned, it can both under-check health factor on collateral exits and overpay reserve assets on withdraw. The common fix is to make transfer and withdrawal flows reconcile on the exact scaled-share movement first, then compute the corresponding underlying amount used for validation and payout with non-overpaying rounding.
Root Cause
Underlying-denominated checks and payouts are derived from rounded balance views or user input instead of from the exact scaled-share amount actually transferred or burned in transfer_internal and burn_scaled.
Impact
A borrower can move out more collateral value than the health-factor check accounts for, leaving a debt position undercollateralized and pushing losses onto the protocol if liquidation cannot cover the shortfall. Separately, withdrawers can collect rounded-up dust from shared reserve liquidity by receiving more underlying than their capped scaled balance supports. Together these mismatches enable conditional pool value extraction and creation of bad debt.
Proof of Concept
See this gist for full Proof of Concept
[M-20] Cap and ceiling setters call the router with the wrong ABI
Submitted by v12, also found by AndreyL1231, heykerme, KarlGoodGuy, KingsleyCaesar, NoesaaDecodes, Oxhelm, oxwhite, pepoc, qed, sam-wiz, and yonko
Found by V12
contracts/pool-configurator/src/reserve.rs#L474contracts/pool-configurator/src/reserve.rs#L510contracts/pool-configurator/src/reserve.rs#L546contracts/kinetic-router/src/reserve.rs#L149contracts/kinetic-router/src/reserve.rs#L176contracts/kinetic-router/src/storage.rs#L238
Targets
- PoolConfiguratorContract::setsupplycap
- PoolConfiguratorContract::setborrowcap
- PoolConfiguratorContract::setdebtceiling
- KineticRouter::setreservesupply_cap
- KineticRouter::setreserveborrow_cap
- KineticRouter::setreservedebt_ceiling
Affected Locations
- PoolConfiguratorContract.setsupplycap: The configurator forwards
[caller, asset, value]when setting the supply cap, even though the router entrypoint expects only(asset, value). - PoolConfiguratorContract.setborrowcap: The configurator forwards
[caller, asset, borrow_cap]to the router borrow-cap setter instead of the router’s real two-argument ABI. - PoolConfiguratorContract.setdebtceiling: The configurator forwards
[caller, asset, value]when setting the debt ceiling, creating the same ABI mismatch as the other cap setters. - KineticRouter.setreservesupply_cap: This router entrypoint is defined for
(asset, cap)and is the target whose signature the configurator call does not match. - KineticRouter.setreserveborrow_cap: This router entrypoint is defined for
(asset, borrow_cap)and is invoked with an extra leadingcallerargument by the configurator. - KineticRouter.setreservedebt_ceiling: This router entrypoint is defined for
(asset, debt_ceiling)and likewise receives a malformed argument list from the configurator.
Description
set_supply_cap, set_borrow_cap, and set_debt_ceiling in the configurator all build raw cross-contract calls with an extra leading caller argument. The corresponding router entrypoints accept only (asset, value) and perform admin authorization internally, so the configurator is speaking the wrong ABI. As a result, these administrative paths do not update the requested parameter and instead deterministically fail during Soroban argument handling. This breaks the documented configurator workflow for changing supply caps, borrow caps, and debt ceilings. The borrow-cap path is especially important because operators are instructed to use it as part of emergency risk response, and the issue was partially masked by a test mock that copied the broken signature.
Root Cause
The configurator manually invokes set_reserve_supply_cap, set_reserve_borrow_cap, and set_reserve_debt_ceiling with [caller, asset, value] even though those router functions expect only (asset, value).
Impact
Administrators cannot use the standard configurator interface to tighten or relax supply caps, borrow caps, or debt ceilings. During fast-moving market conditions, this can delay risk mitigation until operators discover and execute direct router calls or another workaround.
Proof of Concept
See this gist for full Proof of Concept
[M-21] Liquidation flow bypasses reserve-level access controls
Submitted by v12, also found by Agontuk, ahahaHard1k, CircuitBreaker, coinsspor, david_xia, edoscoba, Fade_cyrpto, fuddle_yichi, gzeon, happykilling, honey-k12, lostOpcode, marvelous, Messiah, osuolale, oziajibogu, piki, qed, Tigerfrake, and Trynax
Found by V12
contracts/kinetic-router/src/liquidation.rs#L124contracts/kinetic-router/src/liquidation.rs#L102contracts/kinetic-router/src/router.rs#L463contracts/kinetic-router/src/router.rs#L700contracts/a-token/src/contract.rs#L471contracts/a-token/src/contract.rs#L316
Targets
- KineticRouterContract::internalliquidationcall
- ATokenContract::transferonliquidation
- ATokenContract::transferunderlyingto
- KineticRouterContract::liquidation_call
- KineticRouterContract::prepare_liquidation
- KineticRouterContract::execute_liquidation
Affected Locations
- KineticRouterContract.internalliquidationcall:
internal_liquidation_callenforces only liquidation-specific whitelist/blacklist checks before settling collateral and never composes them with reserve-level access validation. - ATokenContract.transferonliquidation:
transfer_on_liquidationcredits seized aTokens to the liquidator without the reserve whitelist/blacklist recipient checks used by normal transfers. - ATokenContract.transferunderlyingto:
transfer_underlying_tosends underlying collateral to the liquidation recipient without validating reserve access for that target. - KineticRouterContract.liquidation_call:
liquidation_callis an externally reachable liquidation entrypoint that exposes the reserve ACL bypass through the direct liquidation path. - KineticRouterContract.prepare_liquidation:
prepare_liquidationparticipates in the two-step liquidation flow and validates liquidation access without restoring reserve-specific gating. - KineticRouterContract.execute_liquidation:
execute_liquidationcompletes the two-step liquidation flow under liquidation-only ACL assumptions, allowing settlement without reserve checks.
Description
Liquidation handling applies a weaker access-control policy than normal reserve operations. In internal_liquidation_call, and likewise across the liquidation_call / prepare_liquidation / execute_liquidation flows, the router checks only validate_liquidation_whitelist_access and validate_liquidation_blacklist_access before moving reserve assets. Settlement then uses transfer_on_liquidation or transfer_underlying_to, and those helpers do not perform the reserve recipient validation that exists in normal transfer, withdraw, and borrow paths. As a result, an address that is blocked or not approved for a reserve can still repay restricted debt and receive restricted collateral through liquidation. When collateral is delivered as aTokens, the recipient can also end up holding in-protocol exposure to that restricted reserve despite the intended reserve gating.
Root Cause
The liquidation paths treat validate_liquidation_* as a substitute for validate_reserve_*, while transfer_on_liquidation and transfer_underlying_to also omit the reserve-level recipient checks enforced elsewhere.
Impact
A liquidator who is allowed by liquidation-specific lists but barred by reserve-specific lists can still participate in liquidations involving that reserve and collect the seized collateral or liquidation bonus. This breaks reserve-level permissioning and blacklist guarantees, allowing restricted addresses to obtain reserve assets and, in the aToken branch, potentially retain that exposure inside the protocol.
Proof of Concept
See this gist for full Proof of Concept
[M-22] AMM quote overflow can disable swaps and liquidations
Submitted by v12, also found by qed
Found by V12
contracts/shared/src/dex.rs#L55contracts/kinetic-router/src/swap.rs#L38contracts/kinetic-router/src/flash_loan.rs#L345
Targets
- dex::calculateamountout
- dex::swap_collateral
- KineticRouter::executeliquidationcallback
Affected Locations
- dex.calculateamountout: AMM output estimation multiplies
amount_in_with_feebyreserve_outusingi128, which can overflow for common 18-decimal token reserves and swap sizes. - dex.swap_collateral: Collateral swap flows depend on
calculate_amount_out, so liquidation-related swaps revert when the AMM math overflows. - KineticRouter.executeliquidationcallback: Flash-liquidation callback paths rely on the same AMM quote helper, propagating overflow-induced failures into liquidation execution.
Description
The AMM quote helper calculate_amount_out performs its invariant numerator computation with native i128 arithmetic, specifically multiplying amount_in_with_fee by reserve_out. For 18-decimal assets, ordinary reserve depths and liquidation-sized swaps can push this intermediate product beyond i128::MAX, causing checked_mul to return None and the quote path to fail. Because both ordinary swap handling and liquidation flows depend on this helper, the overflow turns into a transaction revert rather than a bounded pricing result. This is not limited to extreme inputs: standard scaled token balances can naturally reach the unsafe range, and an adversary can also inflate pool reserves to force the failure condition. The same remediation applies across both reports: perform the constant-product math in a wider integer domain such as U256 and only downcast after bounds are validated.
Root Cause
calculate_amount_out uses i128 intermediate multiplication for AMM invariant math instead of a wider integer type, so valid 18-decimal reserve and swap values can overflow before output computation completes.
Impact
Liquidations that need the AMM path can be blocked, allowing underwater positions to avoid being unwound and leaving bad debt in the protocol. An attacker may exacerbate this by increasing external pool reserves until calculate_amount_out consistently overflows, disabling direct or callback-based flash liquidation routes for targeted positions.
Proof of Concept
See this gist for full Proof of Concept
[M-23] Global pause can be bypassed via direct aToken transfers, breaking containment across remediation
Submitted by hodlturk, also found by 0xepley, Agontuk, BigLizzyaudit0x, coinsspor, daivo, Dest1ny_rs, edoscoba, Holl0w, maigadoh, MikePeth, oziajibogu, piki, princebeladiya, qed, rook6, and rox_k
contracts/a-token/src/contract.rs#L579-L681contracts/kinetic-router/src/router.rs#L1840-L1887
K2 documents protocol pause as a global emergency control that “blocks all state-changing operations” (docs/04-COMPONENTS.md:1910-1913).
In practice, that guarantee does not hold for the public aToken.transfer() path.
While the router correctly rejects paused user operations such as withdraw(), any holder of aTokens can still call the public aToken contract directly and move their paused position to a fresh address. During that transfer, the aToken contract calls back into the router’s validate_and_finalize_transfer() hook, which performs stateful transfer finalization without checking the global paused flag.
This allows a user whose paused position is being remediated during an incident response window to:
- move paused collateral to a clean address,
- survive subsequent blacklist/remediation actions applied to the original address, and
- redeem the underlying from the fresh address after unpause.
The issue is not just a bookkeeping inconsistency: the PoC demonstrates that collateral can be reassigned during pause and later redeemed from a fresh address after the original address is remediated.
Security Property Broken
Emergency containment / pause boundary
The intended property is that once the protocol is paused, it should act as a hard containment boundary for user-facing value reassignment until administrators complete mitigation.
That property is broken because direct aToken.transfer() remains live and still mutates:
- aToken balances
- router-side transfer-finalization state
- the economic owner of withdrawable collateral after unpause
Root Cause
The global pause check exists on router entrypoints such as pause-gated liquidation/flash flows and router operations, but does not exist on the aToken transfer path or on the router transfer-finalization hook.
This is a pause-boundary bug, not a collateral-eligibility bug:
- the attacker does not need the recipient to borrow against paused/frozen collateral,
- the exploit works because a public value-moving path stays live during a protocol-wide emergency pause,
- the critical outcome is post-remediation collateral reassignment and later redemption, not temporary overvaluation of collateral.
Relevant code
The documented pause invariant:
docs/04-COMPONENTS.md
- **Effect**: Blocks all state-changing operations
Source:
docs/04-COMPONENTS.md:1910-1913
- **Auth Required**: Emergency admin OR pool admin (M-04)
- **Effect**: Blocks all state-changing operations
- **Use Case**: Discovery of critical vulnerability
- **Reversibility**: Pool admin can unpause
Router pause entrypoints:
// contracts/kinetic-router/src/router.rs
pub fn pause(env: Env, caller: Address) -> Result<(), KineticRouterError> {
crate::emergency::pause(env, caller)
}
pub fn unpause(env: Env, caller: Address) -> Result<(), KineticRouterError> {
crate::emergency::unpause(env, caller)
}
Source:
contracts/kinetic-router/src/router.rs:1383-1388
Public aToken transfer path:
// contracts/a-token/src/contract.rs
fn transfer_internal(env: Env, from: Address, to: Address, amount: i128) -> Result<(), TokenError> {
// ...
let result = env.try_invoke_contract::<(), k2_shared::KineticRouterError>(
&state.pool_address,
&Symbol::new(&env, "validate_and_finalize_transfer"),
args,
);
match result {
Ok(Ok(())) => {}
_ => return Err(TokenError::TransferFailed),
}
storage::set_scaled_balance(&env, &from, &new_from_balance);
storage::set_scaled_balance(&env, &to, &new_to_balance);
Ok(())
}
Source:
contracts/a-token/src/contract.rs:579-681
Router hook called by the aToken:
// contracts/kinetic-router/src/router.rs
pub fn validate_and_finalize_transfer(
env: Env,
underlying_asset: Address,
from: Address,
to: Address,
amount: u128,
from_balance_after: u128,
to_balance_after: u128,
) -> Result<(), KineticRouterError> {
let reserve_data = storage::get_reserve_data(&env, &underlying_asset)?;
let reserve_id = k2_shared::safe_reserve_id(&env, reserve_data.id);
reserve_data.a_token_address.require_auth();
let mut from_config = storage::get_user_configuration(&env, &from);
if from_config.has_any_borrowing() {
let reserve_data = crate::calculation::update_state_without_store(&env, &reserve_data)?;
let oracle_config = crate::price::get_oracle_config(&env)?;
let oracle_to_wad = k2_shared::calculate_oracle_to_wad_factor(oracle_config.price_precision);
validation::validate_user_can_withdraw(&env, &from, &underlying_asset, amount, &reserve_data, oracle_to_wad)?;
}
if from_balance_after == 0 {
from_config.set_using_as_collateral(reserve_id, false);
storage::set_user_configuration(&env, &from, &from_config);
}
if to_balance_after > 0 {
let mut to_config = storage::get_user_configuration(&env, &to);
if !to_config.is_using_as_collateral(reserve_id) {
let active_count = to_config.count_active_reserves();
if active_count >= storage::MAX_USER_RESERVES {
panic_with_error!(&env, k2_shared::UserReserveError::MaxUserReservesExceeded);
}
to_config.set_using_as_collateral(reserve_id, true);
storage::set_user_configuration(&env, &to, &to_config);
}
}
Ok(())
}
Source:
contracts/kinetic-router/src/router.rs:1840-1887
There is no storage::is_paused(&env) guard in this flow.
Attack Path
Lowest-privilege attacker
Any user holding aTokens.
Concrete exploit sequence
- The attacker supplies collateral and receives aTokens.
- Admin pauses the protocol in response to an incident.
- Router
withdraw()is correctly blocked while paused. - The attacker calls
aToken.transfer(from, fresh_address, amount)directly on the aToken contract. transfer_internal()succeeds and invokes the router’svalidate_and_finalize_transfer()hook.- The hook finalizes the transfer even though the protocol is paused.
- Admin blacklists or otherwise remediates the original address during the pause window.
- After unpause, the fresh address calls
withdraw()and receives the underlying.
Why an attacker likes this
- It is silent: the protocol is “paused”, but the attacker still repositions value.
- It has low preconditions: only requires holding aTokens.
- It defeats a security layer rather than a single business rule.
- It converts pause from a containment boundary into a merely cosmetic router-only gate.
Impact
This issue means global pause no longer acts as a hard containment boundary.
The protocol’s own security documentation presents pause as an emergency brake. If a user can still move a paused collateral position to a fresh address, then:
- the original address can be remediated while the economic claim has already been moved elsewhere,
- pause no longer reliably preserves account-level containment during the remediation window,
- a later unpause can leave the fresh address able to redeem the reassigned collateral.
The PoC shows actual collateral reassignment that survives later remediation of the original address, followed by successful underlying withdrawal from the fresh address after unpause.
Trusted Roles / Contest Rules
This finding does not require any trusted role compromise or malicious trusted-role behavior.
Lowest-privilege attacker:
- any user holding aTokens
Trusted roles appear in the PoC only to model the protocol’s intended emergency workflow:
Emergency Admin.pause()is used as designed to halt user operations,Pool Admin.set_reserve_blacklist()is used as designed to remediate the original address,Pool Admin.unpause()is used as designed to resume the protocol afterward.
The bug is that despite those intended admin actions, the attacker can still move the paused position through a public user-facing token path and later redeem the underlying from a fresh address.
So the report does not rely on:
- compromised admin keys,
- malicious governance,
- incorrect privileged configuration,
- any trusted-role action outside documented authority.
Real-world impact
On live code, this matters most in the scenarios where pause is expected to buy operators time to respond safely:
- active exploit response,
- stolen key containment,
- insider compromise,
- address-level remediation or containment.
In those situations, operators often need a pause window to identify which accounts should be blocked and then apply blacklists or other controls before unpause.
Because this bug leaves the public aToken transfer path open, a malicious user can front-run remediation operationally:
- move the claim on collateral to a clean address during the pause,
- let the original address absorb the later remediation,
- then redeem the underlying from the fresh address once the protocol resumes.
That is a concrete loss of containment: the protocol may still pause router operations, but the paused position is no longer pinned to the original address.
Why this is distinct
This report is specifically about protocol-global pause bypass.
It is not the same as reserve pause/freeze collateral-eligibility issues. Even if reserve-level collateral-enable bugs are already known, this finding remains distinct because:
- the broken property here is the protocol-wide emergency pause guarantee,
- the exploit path is the public
aToken.transfer()path, - the economic outcome is persistent collateral reassignment across the pause window, with later redemption from a fresh address.
In particular, this report is not claiming that paused/frozen reserves can still be counted as collateral for new borrows. A separate finding could discuss collateral eligibility, but this report is narrower:
- during a protocol-global pause, value can still be reassigned to a fresh address,
- that reassignment survives later remediation of the original address,
- the fresh address can later redeem real underlying.
This report also does not assume that every conceivable token-side action must be listed alongside router entrypoints in the documentation. The narrower claim is simply that the published pause invariant says pause blocks state-changing operations, yet public aToken.transfer() remains a live state-changing value-reassignment path during that pause window.
Based on the repository-accessible audit materials reviewed for this submission:
- the previous-audit summary in
docs/09-SECURITY.mddocuments pause mechanics (docs/09-SECURITY.md:625-687) but does not list a finding about a missing global pause guard on public aToken transfers, and - the accessible V12 High finding
#44815(K2-V12-High-output.md:37205-37230) is framed around collateral eligibility consistency on paused/frozen reserves throughtransfer_internal/validate_and_finalize_transfer/set_user_use_reserve_as_coll, with impact centered on fresh borrowing and collateral-bit griefing, - this report is narrower and different in framing: even without claiming fresh borrowing against paused/frozen collateral, protocol-global pause still permits public collateral reassignment that survives later remediation of the original address and can be redeemed after unpause.
Proof of Concept
See originzl finding for full Proof of Concept
Why this should be fix-priority
Pause is not just a cosmetic flag. It is presented as the protocol’s emergency containment layer.
If users can still move claims on collateral during pause, then:
- response playbooks become unreliable,
- blacklist/remediation sequencing becomes lossy,
- operators cannot assume paused balances remain attached to the original address.
This is the kind of bug that matters most on live code, because it appears only when administrators are already in remediation mode.
Duplicate-Resistance Framing
The strongest and cleanest framing for this submission is:
Global emergency pause is not a real freeze because public aToken transfers remain live, enabling remediation evasion and post-unpause withdrawal from fresh addresses.
The weaker framing to avoid is:
paused/frozen reserve collateral still becomes enabled through token-side transfer
That weaker framing drifts toward reserve-level collateral-eligibility analysis. The core report here is instead about:
- protocol-global pause semantics,
- containment failure,
- recoverability loss,
- real fund escape.
Recommended Fix
Apply the protocol-global pause check consistently to all state-changing collateral movement paths, not only router entrypoints.
At minimum:
- Reject public aToken transfers while
storage::is_paused()is true. - Add a pause gate to
validate_and_finalize_transfer(). - Review any other token-side or helper-side state mutation paths that bypass router pause guards.
A robust fix is to treat the pause boundary as applying to:
- direct token transfers,
- router callback hooks,
- any state-changing internal bridge between token contracts and the router.
[M-24] Prepared flash liquidation can bypass collateral reserve pause
Submitted by bunnyhunter, also found by 0x04, 0x15, 0xABSattar, 0xBeastBoy, 0xc0ffEE, 0xCiphky, 0xDemon, 0xGutzzz, 0xtracer, 0xvd, abdelhaq16, Agontuk, AvantGard, AxiosCapex, basekay, binbin2803, boodieboodieboo, botdidy, Brene, caho, cgdusek, ChainSentry, ChargingFoxSec, codegpt, coinsspor, cwk, Dest1ny_rs, Diavolo, doorpost, dqt2506, Drothon, duan, edoscoba, Emmy, Felh, FISHqianli, goldberg8, gther9, guiz, gwumex, gzeon, Haerincode, harry, hezze, honey-k12, ht111111, Infi, ix2b100, jaydenwistrom, jesjupyter, JostarJoseph, khaye26, kodkirmizi, Kryptos, Lamsy, legat, leonardo777, m0l4b0, maigadoh, marianl30, maxzuvex, metaBug, mscgo77, namaila, niffylord, nol, Nyx, orangesantra, Pelz, penguinmiaou, PeterVN, piki, piyushmali, ppddderiarena, qed, r00p, rajatbeladiya, rakanaji, raphaelbgr, Razkky, ret2basic, rubencrxz, rustviper, sahuang, sgtpepper, Sikoh, StevenQ15, TheWeb3Mechanic, Tigerfrake, tofunmi, TrudentHunter, Trynax, udogodwin, unineko, viper3, y4y, and yinziping
contracts/kinetic-router/src/router.rs#L463
The two-step flash liquidation flow (prepare_liquidation + execute_liquidation) validates reserve pause state only during preparation. If the collateral reserve is paused between the two transactions, execute_liquidation does not revalidate the collateral reserve’s lifecycle flags before burning the borrower’s aTokens, seizing collateral, and settling through a DEX swap. This allows a liquidator to execute a stale authorization against a paused collateral reserve for up to 600 seconds, bypassing the documented emergency stop for that reserve.
Finding Description
K2’s flash liquidation uses a two-step architecture. In the first transaction, prepare_liquidation fetches and updates both the collateral and debt reserves, then correctly rejects the call if either reserve is inactive or paused:
// contracts/kinetic-router/src/router.rs:526-537
if !collateral_reserve_data.configuration.is_active() {
return Err(KineticRouterError::AssetNotActive);
}
if !debt_reserve_data.configuration.is_active() {
return Err(KineticRouterError::AssetNotActive);
}
if collateral_reserve_data.configuration.is_paused() {
return Err(KineticRouterError::AssetPaused);
}
if debt_reserve_data.configuration.is_paused() {
return Err(KineticRouterError::AssetPaused);
}
If this passes, the router stores a LiquidationAuthorization valid for 600 seconds.
In the second transaction, execute_liquidation performs the actual settlement. It checks the protocol-wide pause flag, validates the stored authorization, fetches current prices with tolerance checks, reloads both reserves, updates state, and recalculates the health factor. However, it never repeats the is_active() / is_paused() checks for either reserve after the reload:
// contracts/kinetic-router/src/router.rs:711
if storage::is_paused(&env) {
return Err(KineticRouterError::AssetPaused);
}
// ... authorization, price, and deadline checks ...
// contracts/kinetic-router/src/router.rs:765-769
let raw_debt_reserve = storage::get_reserve_data(&env, &debt_asset)?;
let debt_reserve_data = crate::calculation::update_state(&env, &debt_asset, &raw_debt_reserve)?;
let raw_collateral_reserve = storage::get_reserve_data(&env, &collateral_asset)?;
let collateral_reserve_data = crate::calculation::update_state(&env, &collateral_asset, &raw_collateral_reserve)?;
// No is_paused() or is_active() check follows.
The only subsequent reserve lifecycle validation comes from internal_flash_loan_with_reserve_data, but that function iterates only the assets being flash-loaned. In this flow, the flash-loaned asset is exclusively the debt asset:
// contracts/kinetic-router/src/router.rs:967-982
let mut assets = Vec::new(&env);
assets.push_back(debt_asset.clone());
// ...
crate::flash_loan::internal_flash_loan_with_reserve_data(
&env, pool_address.clone(), pool_address.clone(),
assets, amounts, params_bytes, false, Some(&debt_reserve_data),
)?;
Inside the flash loan loop, each asset is validated for is_active() and !is_paused(), but the collateral asset is never in this vector:
// contracts/kinetic-router/src/flash_loan.rs:70-82
require!(reserve_data.configuration.is_flashloan_enabled(), ...);
require!(reserve_data.configuration.is_active(), ...);
require!(!reserve_data.configuration.is_paused(), ...);
The callback then proceeds to burn the borrower’s collateral aTokens, transfer the underlying collateral to the pool, and swap it through a DEX, all using the paused collateral reserve’s data without any lifecycle revalidation.
By contrast, the standard liquidation_call path uses validate_liquidation, which unconditionally rejects the call if either reserve is paused:
// contracts/kinetic-router/src/validation.rs:517-529
if !collateral_reserve_data.configuration.is_active()
|| !debt_reserve_data.configuration.is_active()
{
return Err(KineticRouterError::AssetNotActive);
}
if collateral_reserve_data.configuration.is_paused()
|| debt_reserve_data.configuration.is_paused()
{
return Err(KineticRouterError::AssetPaused);
}
This creates an inconsistency: prepare_liquidation and liquidation_call both enforce that the collateral reserve must be unpaused, but execute_liquidation does not, treating the prepare-time validation as still valid even though the two functions are split across separate transactions and the collateral reserve configuration can change in between.
The project documentation reinforces that this is an unintended gap. The liquidation documentation states that liquidations are blocked when the protocol or individual reserves are paused, and specifically says liquidation cannot proceed if either reserve is paused. These statements are unconditional and make no distinction between the standard and flash liquidation paths.
Impact
Emergency reserve pause does not reliably stop liquidation settlement for positions that already have a prepared flash-liquidation authorization. During the 600-second authorization window, a liquidator can continue moving and selling collateral from a reserve that an authorized emergency admin has just paused.
Reserve pauses are incident-response controls used when the protocol detects a suspected oracle issue, asset incident, DEX integration problem, or market emergency. If the collateral reserve is paused for any of these reasons, the prepared flash-liquidation path can still seize and swap that asset while direct liquidation and fresh preparation are both correctly blocked. This undermines the safety guarantee that pause is supposed to provide.
The issue is bounded by several constraints: the liquidator must have prepared the authorization before the reserve was paused, the authorization must not be expired, the borrower must still be liquidatable at execution time, current prices must pass the configured tolerance check, and the debt reserve must remain active and unpaused. These constraints prevent arbitrary exploitation but do not eliminate the bypass. Active liquidators routinely maintain prepared authorizations for efficiency, and 600 seconds is a meaningful window during an active incident.
The issue is a real bypass of a documented emergency control that causes unauthorized post-pause state changes on the paused reserve, but it is limited to outstanding prepared authorizations and does not by itself enable arbitrary asset theft or unbounded loss.
Likelihood Explanation
The scenario requires a liquidator to have called prepare_liquidation on a position before the collateral reserve is paused, and then to call execute_liquidation within the 600-second authorization window after the pause. This is a realistic sequence for several reasons.
Active liquidators on lending protocols typically maintain prepared authorizations for unhealthy positions as part of their normal operation, since the two-step flow is designed for exactly this purpose. A liquidator monitoring several undercollateralized positions will often have multiple live authorizations at any given time.
Reserve pauses are inherently reactive: they are triggered after something unexpected is detected. The 600-second overlap between pre-existing authorizations and a new pause is not an edge case requiring unusual timing. It is the standard window in which the mismatch manifests.
The liquidator needs no special privileges, elevated access, or capital beyond what any liquidator already has. The position must be genuinely undercollateralized, so the liquidator is not creating the conditions; they are exploiting a timing gap in the emergency control. Exploitation is straightforward: a single execute_liquidation call with the same parameters that were already accepted during preparation.
Recommendation
After reloading and updating both reserves in execute_liquidation, repeat the same lifecycle validation used by prepare_liquidation and validate_liquidation:
if !collateral_reserve_data.configuration.is_active()
|| !debt_reserve_data.configuration.is_active()
{
return Err(KineticRouterError::AssetNotActive);
}
if collateral_reserve_data.configuration.is_paused()
|| debt_reserve_data.configuration.is_paused()
{
return Err(KineticRouterError::AssetPaused);
}
Place the check immediately after the reserve reload and state update block at line 769. This preserves the existing two-step architecture while ensuring the execution step honors current emergency reserve state.
Proof of Concept
See original finding for full Proof of Concept
[M-25] Disabled oracle assets remain usable through the batch-price TTL cache
Submitted by droopytoops, also found by 0xZHD, Arthelion, cbounty, cht1206, david_xia, Deivitto, dumebi042, GiMu84, harry, hezze, HiteshRajpurohit, hodlturk, indieshipx247, InvisibleDieMinute, junnk6, LIN, marvelous, odeili, qed, SarveshLimaye, Saurabh_Singh, Silvermist, tontobex, and yasuhi829
contracts/price-oracle/src/contract.rs#L456-L475contracts/price-oracle/src/contract.rs#L365-L377contracts/price-oracle/src/contract.rs#L471-L475contracts/price-oracle/src/contract.rs#L456-L475
PriceOracleContract::get_asset_prices_vec() checks and returns the TTL cached LastPriceData before it loads the asset configuration and before it verifies config.enabled.
The relevant logic is:
// contracts/price-oracle/src/contract.rs
for (idx, asset) in assets.iter().enumerate() {
// Try cache first — check both cache freshness and underlying oracle staleness
if cache_ttl > 0 {
if let Some(cached) = storage::get_last_price_data(&env, &asset) {
let cache_age = current_time.saturating_sub(cached.cached_at);
let price_age = current_time.saturating_sub(cached.timestamp);
if cache_age <= cache_ttl && price_age <= oracle_config.price_staleness_threshold {
let price_data = PriceData { price: cached.price, timestamp: cached.timestamp };
Self::validate_price_change(&env, &asset, &price_data.price, &oracle_config)?;
results.push_back(Some(price_data));
continue;
}
}
}
let config = storage::get_asset_config(&env, &asset)
.ok_or(OracleError::AssetNotWhitelisted)?;
if !config.enabled {
return Err(OracleError::AssetDisabled);
}
This means a cached price can be served for a disabled asset because the continue executes before the disabled-asset check.
The single-asset oracle path rejects disabled assets, but the router-facing batch path can continue returning a cached price. This creates an inconsistent and unsafe state:
- The admin disables an asset in the oracle.
- Direct
get_asset_price_data(asset)fails as expected. get_asset_prices_vec([asset])still returns the cached price.- The router uses that batch price during account-data and borrow validation.
- The user can open fresh debt against the disabled asset.
Oracle disablement is a risk-control. It should make the asset fail closed when the asset/feed is unsafe, stale, manipulated, or otherwise should not be used for solvency calculations. Because the cache path bypasses config.enabled, disabling the asset does not reliably remove it from lending calculations.
Impact
Users can continue borrowing against collateral after the asset’s oracle pricing has been disabled. If the asset was disabled because the feed was compromised, stale, broken, or no longer trusted, the protocol can accept unsafe collateral valuations and issue fresh debt against them.
This is not only a view inconsistency. The PoC demonstrates that the router accepts the cached disabled price and mints debt to the user.
Recommended mitigation
get_asset_prices_vec() should validate the asset configuration before returning cached data.
The disabled check should happen before the cache lookup:
let config = storage::get_asset_config(&env, &asset)
.ok_or(OracleError::AssetNotWhitelisted)?;
if !config.enabled {
return Err(OracleError::AssetDisabled);
}
// Only after this point should cached LastPriceData be considered.
Additionally, set_asset_enabled(asset, false) should clear LastPriceData for that asset so stale cached prices cannot survive an explicit risk-control change.
Proof of Concept
See original finding for full Proof of Concept
[M-26] Reserve factor changes do not affect treasury accrual until an unrelated rate-recalc path runs
Submitted by ht111111, also found by 0xGutzzz, abdelhaq16, Agontuk, BigLizzyaudit0x, ChainSentry, CryFlowerBB, fuzious, Jhounx, joeza, maigadoh, PolarizedLight, qed, rare_one, ryan97singh, and ZanyBonzy
contracts/pool-configurator/src/reserve.rs#L385-L427contracts/kinetic-router/src/reserve.rs#L330-L346contracts/kinetic-router/src/calculation.rs#L443-L494contracts/kinetic-router/src/treasury.rs#L5-L18
K2 documents reserve-factor governance as affecting future interest accrual, but the setter only stores the new configuration. It does not close the old accrual interval, it does not recompute current_liquidity_rate, and it does not install a new treasury split for the post-change interval.
PoolConfigurator.set_reserve_factor() loads the current reserve configuration, changes the packed reserve_factor, and forwards that bitmap to the router. The router then overwrites reserve_data.configuration in storage and returns. No index checkpoint or rate recalculation happens in either contract.
That matters because K2’s reserve state is lazy:
update_state()accrues the entire elapsed interval using the storedcurrent_liquidity_rateandcurrent_variable_borrow_rate;- only later
update_interest_rates_and_store()paths install fresh rates; and collect_protocol_reserves()callsupdate_state()but does not callupdate_interest_rates_and_store().
So after governance raises reserve_factor, the treasury path still accrues the entire stale window with the old supplier/protocol split until some unrelated rate-recalc action happens first, such as supply, withdraw, borrow, repay, liquidation, or swap.
The PoC proves the timing gap with two otherwise equivalent scenarios:
- both scenarios create the same reserve utilization and then raise
reserve_factorfrom 10% to 90%; - in the stale path, no later user action is performed before
collect_protocol_reserves(); - in the control path, a zero-net user touch (
supply(1)thenwithdraw(1)in the same timestamp) forcesupdate_interest_rates_and_store()to install the new factor before time continues.
After the same post-change interval elapses:
- the stale path still has the old
current_liquidity_rate; collect_protocol_reserves()still uses that stale rate; and- the treasury collects strictly less than the checkpointed control path.
This is not a public theft primitive and should not be framed as a generic lazy-accrual complaint. The confirmed impact is narrower and concrete: governance can change the reserve factor, but K2 continues using the old treasury split for subsequent elapsed time until an unrelated rate-recalc path happens to run.
That creates deterministic under-collection of protocol reserves after factor increases. The PoC is intentionally scoped to that treasury-facing branch.
Recommended mitigation steps
When changing reserve economics:
- load the reserve;
- checkpoint the old interval at the setter timestamp;
- store the new configuration; and
- immediately recompute and persist
current_liquidity_rate/current_variable_borrow_rateunder the new parameters.
At minimum, reserve-configuration setters and treasury collection should not leave governance-applied factors dormant behind an unrelated future user action.
Proof of Concept
See original finding for full Proof of Concept
[M-27] Unbounded liquidation history can DoS helper liquidation paths
Submitted by v12
Found by V12
contracts/liquidation-engine/src/storage.rs#L100contracts/liquidation-engine/src/contract.rs#L49contracts/liquidation-engine/src/storage.rs#L169
Targets
- LiquidationEngineContract::addliquidationrecord
- LiquidationEngineContract::execute_liquidation
- LiquidationEngineContract::getuserliquidation_ids
Affected Locations
- LiquidationEngineContract.addliquidationrecord: Loads the borrower’s full
user_liquidationsVec<u32>, appends oneliquidation_id, and rewrites the entire vector with no cap, chunking, or pruning. - LiquidationEngineContract.execute_liquidation: Calls the router liquidation flow and then unconditionally records the liquidation, so oversized history can cause the whole helper-mediated liquidation to fail during post-liquidation bookkeeping.
- LiquidationEngineContract.getuserliquidation_ids: Returns the entire per-user liquidation history in one shot and is also used by the append path, amplifying the cost of both reads and writes as history grows.
Description
The contract stores each borrower’s liquidation history as a single persistent Vec<u32> and rewrites that entire collection every time a new record is added. add_liquidation_record first retrieves the full history through get_user_liquidation_ids, appends one more ID, and writes the whole vector back without any cap, pagination, chunking, or archival strategy. Because execute_liquidation always performs this bookkeeping after a successful router liquidation, the helper’s state-changing path inherits a cost that grows linearly with the user’s historical liquidation count. Since liquidations are permissionless and findings note that an attacker can use very small liquidations over time, a target borrower’s history can be inflated until serialization, memory, or execution limits are exceeded. The same unbounded design also makes the public history read path increasingly expensive and eventually unusable for that borrower.
Root Cause
Per-user liquidation history is kept in one ever-growing Vec<u32> that add_liquidation_record and get_user_liquidation_ids process monolithically instead of using bounded or paginated storage.
Impact
An attacker can grief an undercollateralized borrower by repeatedly appending liquidation records until execute_liquidation through the helper reverts during history maintenance. This disables the helper contract’s liquidation and history-query path for the affected user and can force integrators to bypass the helper and use the router directly, while the oversized state remains stuck on-chain.
Proof of Concept
See original finding for full Proof of Concept
Remediation
Replace the per-user monolithic liquidation-id vector with fixed-size paged storage. execute_liquidation now appends to only one bounded page plus a per-user count, preserving record ordering and legacy reads while removing the unbounded read-modify-write that let liquidation history growth exhaust budget on helper liquidations.
See original finding for detailed patch
[M-28] Broken asset-list invariant causes DoS and orphaned whitelist state
Submitted by v12
Found by V12
contracts/price-oracle/src/storage.rs#L229contracts/price-oracle/src/storage.rs#L251contracts/price-oracle/src/storage.rs#L147contracts/price-oracle/src/storage.rs#L116contracts/price-oracle/src/contract.rs#L625contracts/price-oracle/src/storage.rs#L156contracts/price-oracle/src/storage.rs#L199
Targets
- storage::addtoasset_list
- storage::removefromasset_list
- PriceOracleContract::remove_asset
- PriceOracleContract::setoracleconfig
- PriceOracleContract::resetallcircuit_breakers
- PriceOracleContract::getwhitelistedassets
- storage::getassetlist
Affected Locations
- storage.addtoasset_list:
add_to_asset_listchecks duplicates overasset_list.len().min(MAX_RESERVES)and then unconditionally appends, soAssetListcan grow past the documented cap. - storage.removefromasset_list:
remove_from_asset_listrebuilds and overwritesAssetListusing onlyasset_list.len().min(MAX_RESERVES)entries, which truncates any tail beyond index63once oversized state exists. - PriceOracleContract.remove_asset:
remove_assetdeletes config only for the explicitly removed asset, so truncation inAssetListcan orphan later assets whose per-asset records remain in storage. - PriceOracleContract.setoracleconfig:
set_oracle_configiterates over the full result ofget_asset_list, so an oversized or truncated list causes either excessive work or missed assets during bulk updates. - PriceOracleContract.resetallcircuit_breakers:
reset_all_circuit_breakersalso trustsget_asset_listfor full-list iteration, inheriting both the unbounded-loop and orphaned-asset behaviors. - PriceOracleContract.getwhitelistedassets:
get_whitelisted_assetsexposes the stored list directly, so callers observe either an oversized whitelist or a truncated one that no longer matches per-asset storage. - storage.getassetlist:
get_asset_listreturns the full stored vector and serves as the source of truth for list-driven admin and view operations.
Description
AssetList is treated throughout the oracle as if it is permanently bounded by MAX_RESERVES, but the storage helpers do not actually maintain that invariant. add_to_asset_list can grow the list past 64, after which get_asset_list propagates an oversized vector into public enumeration and bulk maintenance paths that iterate over the entire list. Once such oversized state exists, remove_from_asset_list rewrites storage from only the first MAX_RESERVES entries, so removing any asset silently drops every later entry from the canonical list even though their per-asset records remain configured. That means the same invariant break causes both a liveness failure for full-list operations and a persistent desynchronization where list-based administration no longer reaches orphaned assets. The correct fix is to enforce or validate the MAX_RESERVES bound consistently in all list mutation paths and to ensure removal preserves the full list instead of truncating it.
Root Cause
The code relies on AssetList always satisfying the MAX_RESERVES invariant, but add_to_asset_list, remove_from_asset_list, and related list handling do not enforce or preserve that invariant.
Impact
A malicious or compromised oracle admin can bloat AssetList until calls such as set_oracle_config, reset_all_circuit_breakers, or get_whitelisted_assets become too expensive to execute reliably. After the list is oversized, removing a single asset can also hide still-configured assets from canonical enumeration, causing bulk config changes and circuit-breaker resets to skip them while stale per-asset state remains in storage. Repair then requires manual cleanup of inconsistent whitelist and asset configuration state.
Proof of Concept
See original finding for full Proof of Concept
[M-29] Inactive collateral remains counted for new borrowing while liquidation and withdrawal reject the same reserve as AssetNotActive
Submitted by abdelhaq16, also found by 0xCiphky, 0xDemon, ChainSentry, derkomai, elaprendiz1927, fuzious, gwumex, Lamsy, qed, rubencrxz, TheSpark, and yinziping
contracts/pool-configurator/src/reserve.rs#L242-L274contracts/kinetic-router/src/reserve.rs#L330-L346contracts/kinetic-router/src/calculation.rs#L127-L164contracts/kinetic-router/src/calculation.rs#L224-L309contracts/kinetic-router/src/operations.rs#L309-L330contracts/kinetic-router/src/validation.rs#L160-L221contracts/kinetic-router/src/validation.rs#L93-L116contracts/kinetic-router/src/validation.rs#L517-L522
set_reserve_active(false) is a normal reserve lifecycle action exposed through the PoolConfigurator. It updates the reserve’s active permission truth in ReserveData.configuration.
The documented reserve lifecycle describes active=false as a wind-down / deprecation state: new supplies and new borrows are blocked, while withdrawals, repays, and liquidations remain allowed. The issue is not that an admin can deactivate a reserve. The issue is that, after deactivation, the protocol applies the active flag inconsistently: account-data still treats the inactive reserve as valid collateral for new borrowing, while withdrawal and liquidation reject that same reserve as inactive.
As a result, a user can borrow an active asset using collateral from a reserve that has already been marked inactive. Once the debt exists, the same inactive collateral cannot be seized through direct liquidation and cannot be withdrawn through the normal withdrawal path because both consumers revert with AssetNotActive.
The protocol therefore treats the same reserve under incompatible truths:
- account-data / borrow: the inactive reserve is valid collateral and provides borrow capacity;
- liquidation / withdraw: the same reserve is inactive and cannot be used for liquidation or withdrawal.
This can leave the protocol with debt backed by collateral that account-data still counts for new borrowing, but liquidation refuses to seize through the normal direct liquidation path.
Root Cause
The root cause is inconsistent consumption of the reserve active flag across account-data, borrowing, withdrawal, and liquidation consumers.
The PoolConfigurator can mark a reserve inactive by loading the current ReserveData.configuration, calling configuration.set_active(active), and forwarding the updated configuration to the router. The router then replaces reserve_data.configuration and stores it. This does not clear user collateral bits, does not reject deactivation of a collateralized reserve, and does not make account-data ignore inactive reserves.
The account-data calculation iterates over reserves selected by the user’s bitmap. If user_config.is_using_as_collateral(reserve_id) is true, it fetches the reserve and later adds the aToken balance into total_collateral_base, weighted liquidation threshold, and weighted LTV. There is no reserve_data.configuration.is_active() check before the collateral contribution is counted.
The borrow path validates the borrowed reserve itself, then calls validate_user_can_borrow, which calculates user account data and checks the requested borrow against available_borrows_base. When the borrowed reserve is a different active reserve, this validation does not reject the inactive collateral reserve contributing to account-data. Since inactive collateral is still included in account-data, the borrow succeeds.
By contrast, withdraw and liquidation validate the active flag at their final effectful consumers. Withdraw rejects the target reserve if it is inactive, and liquidation rejects either the collateral or debt reserve if inactive. This makes the inactive collateral usable for borrow capacity but unusable for liquidation and withdrawal.
Attack Path
- A user supplies
asset_aand enables it as collateral. - The normal PoolConfigurator/admin lifecycle path calls
set_reserve_active(asset_a, false). - The user’s
asset_acollateral bit remains set inUserConfiguration. get_user_account_dataand the borrow validation account-data calculation still include the inactiveasset_abalance intotal_collateral_baseandavailable_borrows_base.- The user borrows active
asset_busing the inactiveasset_acollateral. - After the position becomes liquidatable, a liquidator tries to liquidate the user using
asset_aas collateral. - Direct liquidation fails with
AssetNotActivebecause the liquidation consumer rejects inactive collateral reserves. - The user also cannot withdraw
asset_a, because withdraw rejects inactive reserves withAssetNotActive.
This is not a malicious-admin scenario. The admin action is a normal reserve lifecycle action. The vulnerability is that the protocol’s consumers do not agree on the meaning of active=false after that action. The PoC does not borrow the inactive reserve itself; it borrows a separate active asset using the inactive reserve as collateral.
Impact
This can create protocol solvency and availability risk:
- users can create new debt in active assets using collateral from a reserve that has already been deactivated;
- unhealthy positions whose relevant collateral is the inactive reserve cannot be liquidated through the direct liquidation path because the collateral reserve is rejected as inactive;
- users cannot withdraw the same inactive collateral either, blocking the normal collateral-exit path;
- the protocol can be left with debt backed by collateral that account-data still counts but liquidation refuses to seize.
This is distinct from a simple exit-path liveness issue. The PoC first proves that deactivated collateral still provides new borrow capacity and is actually used to mint debt and transfer an active borrowed asset out of the reserve. The later AssetNotActive failures demonstrate that the same collateral truth cannot be enforced by liquidation or withdrawal once the debt exists.
The issue is stronger than a harmless revert because the borrow is already allowed before the revert surfaces in liquidation/withdrawal. It is also not user-error-only and not direct trusted-admin misuse: the admin is not stealing funds or choosing an arbitrary harmful parameter; a normal lifecycle transition leaves downstream consumers on incompatible active-flag truths.
Proof of Concept
See original finding for full Proof of Concept
[M-30] Deeply underwater liquidations panic instead of capping collateral
Submitted by v12, also found by AuroraAI, daprolad, dumbbutt, rawsid11, and Spottedhyena0
Found by V12
contracts/liquidation-engine/src/calculation.rs#L7contracts/liquidation-engine/src/contract.rs#L49
Targets
- LiquidationEngineContract::calculate_liquidation
- LiquidationEngineContract::execute_liquidation
Affected Locations
- LiquidationEngineContract.calculate_liquidation:
calculate_liquidationcomputescollateral_amount_base = actual_debt_to_cover + bonus_amountand then subtracts it fromuser_account_data.total_collateral_basewithout capping or checking the result first. - LiquidationEngineContract.execute_liquidation:
execute_liquidationrelies oncalculate_liquidation, so the same arithmetic panic bubbles up and makes this liquidation path revert for deeply underwater accounts.
Description
calculate_liquidation derives collateral_amount_base from the debt to cover plus the liquidation bonus, then subtracts that value from user_account_data.total_collateral_base to compute remaining_collateral. For deeply underwater accounts, the bonus-inclusive seizure value can be greater than the user’s entire collateral base, so this subtraction underflows in u128 and panics instead of returning a capped result. Because execute_liquidation calls calculate_liquidation, the same condition causes the liquidation flow through the engine to revert before it can proceed. The issue is most likely to occur on the very positions that most need liquidation, since low health factors make total_collateral_base especially small relative to debt. The inputs consistently note that another router path has collateral-cap logic, but the liquidation engine path does not.
Root Cause
calculate_liquidation performs user_account_data.total_collateral_base - collateral_amount_base without first ensuring collateral_amount_base <= total_collateral_base or capping the seized collateral.
Impact
Liquidators using the LiquidationEngine path cannot process deeply underwater positions, so integrations that depend on this frontend are denied liquidation exactly when risk is highest. This can delay resolution of bad positions and increase protocol bad debt exposure until an alternate path such as direct liquidation_call is used.
Proof of Concept
See original finding for full Proof of Concept
Remediation
Cap the bonus-inclusive collateral seizure in liquidation-engine calculation to the user’s total collateral base before computing remaining collateral and bonus. This removes the u128 underflow for deeply underwater positions while preserving existing behavior for uncapped liquidations.
See original finding for patch details
[M-31] Quote helper mutates persistent close-factor state
Submitted by v12
Found by V12
contracts/liquidation-engine/src/calculation.rs#L7contracts/liquidation-engine/src/contract.rs#L49contracts/liquidation-engine/src/storage.rs#L266
Targets
- LiquidationEngineContract::calculate_liquidation
- LiquidationEngineContract::execute_liquidation
- LiquidationEngineContract::adduserliquidatedthistx
Affected Locations
- LiquidationEngineContract.calculate_liquidation: Core calculation updates
USER_LIQUIDATED_THIS_TXwhile computingactual_debt_to_cover, making a supposed quote mutate close-factor state. - LiquidationEngineContract.execute_liquidation: Execution path reuses the same helper, so previously poisoned state changes the liquidation values recorded and enforced by the engine.
- LiquidationEngineContract.adduserliquidatedthistx: Temporary-storage counter is incremented from the calculation path even when no real router liquidation has happened.
Description
calculate_liquidation is exposed as a public quote-like entry, but its underlying calculation path mutates USER_LIQUIDATED_THIS_TX by calling add_user_liquidated_this_tx whenever actual_debt_to_cover > 0. execute_liquidation then calls the same helper again, so any prior quote or probe changes remaining_liquidatable even though no router liquidation has occurred. Because this counter is kept in Soroban temporary() storage rather than true transaction-local state, the poisoning can persist beyond a single call for the entry TTL instead of naturally resetting after one transaction. The result is that the engine can reduce actual_debt_to_cover to a smaller value or 0, block its own liquidation flow, and record collateral_to_liquidate and liquidation_bonus from a state-skewed second quote. This breaks both availability of the engine path and integrity of the engine’s liquidation records, even though the router’s own liquidation logic remains separate.
Root Cause
The contract mixes quoting with accounting by letting calculate_liquidation update USER_LIQUIDATED_THIS_TX in Soroban temporary() storage, so an unauthenticated helper call mutates state that execute_liquidation later treats as authoritative.
Impact
An attacker can cheaply call calculate_liquidation for arbitrary users to consume the engine’s close-factor budget that execute_liquidation later relies on, causing engine-mediated liquidations to be reduced, blocked, or recorded as zero-value actions. They can also desynchronize the engine’s history from the router’s actual settlement, so any accounting, rewards, monitoring, or analytics that trust the engine’s records receive false liquidation metadata.
Proof of Concept
See original finding for full Proof of Concept
Remediation
Removed liquidation-engine quote-side close-factor state mutation. calculateliquidation now computes a pure single-call close-factor cap and no longer reads or writes USERLIQUIDATEDTHISTX, so unauthenticated quote/probe calls cannot poison later execute_liquidation metadata or availability. The router remains the settlement-time source of truth for close-factor enforcement.
See original finding for patch details
[M-32] Wrong active-slot check
Submitted by v12, also found by codegpt, dee24, mariannabezgin, oussama_05bm, oxwhite, Pherm007, udogodwin, wuji, and zpbrent
Found by V12
contracts/kinetic-router/src/operations.rs#L11
The first-supply branch in supply tries to enforce MAX_USER_RESERVES, but it uses the wrong predicate to decide whether the deposit opens a new reserve slot. It checks only !user_config.is_using_as_collateral(reserve_id) before counting active reserves, even though count_active_reserves() treats a reserve as active when either the collateral bit or the borrowing bit is set. A user who already borrows this asset but has never supplied it will therefore hit the “new position” path even though the reserve is already active in their bitmap. Once the user is at the 15-reserve limit, supplying to that same borrow-only reserve incorrectly panics with MaxUserReservesExceeded. That blocks a legitimate top-up / self-healing action precisely for users with the most complex positions.
Root Cause
The slot-limit check in supply tests is_using_as_collateral instead of testing whether the reserve is already active through either collateral or borrowing. That misclassifies existing borrow-only positions as new reserve entries.
Impact
An attacker can combine this with unsolicited aToken dusting on other reserves to push a victim to the 15-slot ceiling, then prevent the victim from supplying into a reserve they already borrow in order to improve health factor. This is a practical griefing and liquidation-assistance vector, not just a cosmetic accounting issue, because it removes a recovery path while leaving liquidation open.
Proof of Concept
See this gist for full Proof of Concept
[M-33] Factory reserve tokens cannot be upgraded through the intended path
Submitted by v12, also found by ahahaHard1k, Brene, gzeon, hezze, Izaz_Mahmud, Ledger_Patrol, luckyidiot, oxwhite, Smtilkss, t4sk, tech_bro, and Tigerfrake
Found by V12
contracts/pool-configurator/src/reserve.rs#L720contracts/pool-configurator/src/reserve.rs#L663contracts/pool-configurator/src/reserve.rs#L631contracts/kinetic-router/src/reserve.rs#L381
Targets
- PoolConfiguratorContract::deployandinit_reserve
- KineticRouter::updatetokenimplementation
- PoolConfiguratorContract::updatevariabledebt_token
- PoolConfiguratorContract::update_atoken
Affected Locations
- PoolConfiguratorContract.deployandinit_reserve:
deploy_and_init_reserveinitializes factory-deployed reserve tokens withadmin = configurator_address, making the configurator contract the party that must satisfy future tokenupgrade()auth checks. - KineticRouter.updatetokenimplementation:
update_token_implementationperforms the finaltoken.upgrade(...)call, so it is where the missing nested authorization and caller mismatch materialize. - PoolConfiguratorContract.updatevariabledebt_token:
update_variable_debt_tokenonly covers the first hop into the router and does not prepare authorization for the downstream tokenupgrade()call that still expects the configurator contract as admin. - PoolConfiguratorContract.update_atoken: The advertised aToken maintenance path is part of the same router-mediated upgrade flow that is inconsistent with the deployment-time admin assignment.
Description
The reserve-token upgrade flow is internally inconsistent between deployment-time admin assignment and the later maintenance path. deploy_and_init_reserve stores configurator_address as the upgrade admin for factory-deployed tokens, so future upgrade() calls must authenticate as the PoolConfigurator contract itself. However, the documented upgrade path sends the action through the router, and update_variable_debt_token only authenticates the first hop instead of the nested KineticRouter -> token.upgrade(...) subcall. The same flow also uses a caller model that does not line up with router-side validation, so even reaching the final token-admin check can fail early. As a result, factory-deployed reserve tokens are effectively not upgradeable through the intended admin path unless the upgrade flow is redesigned to match the stored admin model.
Root Cause
The system assigns factory-deployed token admin rights to PoolConfiguratorContract but its router-mediated upgrade path neither uses that contract as the effective caller nor authorizes the nested token upgrade() invocation accordingly.
Impact
Governance cannot reliably apply hotfixes or implementation upgrades to factory-deployed aTokens and debt tokens through the published configurator workflow. If a token-side bug is discovered, the protocol may have to operate with the vulnerable implementation until broader contract changes are shipped.
Proof of Concept
See this gist for full Proof of Concept
[M-34] Wrong actor forwarding bricks token upgrade flows
Submitted by v12
Found by V12
contracts/pool-configurator/src/reserve.rs#L631contracts/pool-configurator/src/reserve.rs#L663contracts/kinetic-router/src/reserve.rs#L381
Targets
- PoolConfiguratorContract::update_atoken
- PoolConfiguratorContract::updatevariabledebt_token
- KineticRouter::updatetokenimplementation
Affected Locations
- PoolConfiguratorContract.update_atoken: Authenticates the pool admin, then incorrectly forwards that external
callerto the router instead of the configurator contract address. - PoolConfiguratorContract.updatevariabledebt_token: Authenticates the pool admin, then incorrectly forwards that external
callerto the router instead of the configurator contract address. - KineticRouter.updatetokenimplementation: Performs
storage::validate_pool_configurator(env, caller), so it rejects the forwarded admin EOA because it expects the stored pool configurator contract address.
Description
Both token-upgrade entrypoints in the configurator validate the pool admin locally, but then pass that admin EOA as caller when invoking the router. The router-side upgrade flow ultimately reaches update_token_implementation, which authorizes caller with storage::validate_pool_configurator, meaning it expects the pool configurator contract address rather than the admin address. Because these are different actors, every otherwise legitimate call fails with Unauthorized before either the aToken or variable-debt-token implementation can be updated. The surrounding configurator-to-router patterns cited in the inputs show the intended handoff: external admin authorization should happen in the configurator, while the router should receive env.current_contract_address() as the calling contract identity. As a result, both documented upgrade paths are bricked by the same cross-contract actor mismatch.
Root Cause
update_atoken and update_variable_debt_token forward the authenticated admin caller across the contract boundary instead of env.current_contract_address(), but KineticRouter.update_token_implementation authorizes the pool configurator contract address.
Impact
Protocol operators cannot use the intended configurator flow to upgrade aToken or variable debt token implementations, so emergency fixes and routine token implementation changes will revert every time. If a production bug is discovered in either token type, the system may remain stuck on the old code until an out-of-band recovery, migration, or redeployment path is used.
Proof of Concept
See this gist for full Proof of Concept
[M-35] Rewards lost when total supply drops to zero
Submitted by v12, also found by Brene, DemoreX, and oxwhite
Found by V12
contracts/incentives/src/calculation.rs#L12
Description
The update_asset_reward_index function advances last_update_timestamp to current_timestamp even when total_supply == 0 (line 61-65 early return). In the aToken contract, handle_incentives_action reads total_supply_scaled from state AFTER the mint/burn has already been applied (line 769, called at line 311 after burn). When the last depositor withdraws, total_supply is passed as 0. The function then skips the reward increment calculation but still sets last_update_timestamp = current_timestamp. This permanently discards all emission_per_second * elapsed_time rewards for the period between the previous update and the moment total_supply hit zero. The next depositor’s index update will only accrue from the new timestamp forward, as if no emissions occurred during the zero-supply window. In Aave V3’s reference implementation, this is mitigated by passing the pre-action total supply so the index is updated before the supply change takes effect.
Root Cause
The early return at line 61 advances last_update_timestamp to current_timestamp when total_supply == 0, permanently skipping the reward increment for the elapsed period, combined with handle_incentives_action passing the post-action (post-burn) total_supply_scaled rather than the pre-action value.
Impact
Reward tokens are permanently lost whenever the total scaled supply of an asset momentarily reaches zero. An attacker who is the sole depositor can grief other future depositors by repeatedly withdrawing fully (zeroing supply) to waste emission budget. Over time, accumulated lost emissions reduce the effective reward rate for all future participants and drain the reward budget without distributing tokens.
Proof of Concept
See this gist for full Proof of Concept
[M-36] Protocol fee is significantly overcharged upon liquidations
Submitted by samuraii77, also found by aestheticbhai, Chimajax, Joseph_Nwodoh, oxwhite, snufflesrea, TheWeb3Mechanic, and WhiteKnightK
contracts/kinetic-router/src/liquidation.rs#L350-L358
Upon liquidations, we compute protocol fee as follows:
// Calculate protocol fee from liquidation premium.
// Fee is taken from collateral bonus (not debt repayment) to maintain proper accounting.
let protocol_fee_bps = storage::get_flash_loan_premium(env);
let (protocol_fee_collateral, liquidator_collateral) = if protocol_fee_bps == 0 {
(0u128, collateral_amount_to_transfer)
} else {
// M-07: Round UP to favor protocol
let protocol_fee_debt = percent_mul_up(debt_to_cover, protocol_fee_bps)?;
The comment there clearly states:
// Fee is taken from collateral bonus (not debt repayment) to maintain proper accounting.
However, as seen, the code does:
let protocol_fee_debt = percent_mul_up(debt_to_cover, protocol_fee_bps)?;
The protocol fee is taken exactly from the debt repayment and not from the collateral bonus, the opposite of what they are saying. If we reference that to Aave, they correctly take it from the collateral bonus as it should be:
vars.liquidationProtocolFee = vars.bonusCollateral.percentMul(
vars.liquidationProtocolFeePercentage
);
This significantly overcharges the fees, against the developer’s intent, as the debt to cover is usually significantly higher than the collateral bonus.
[M-37] Dust-sized debt can turn a very safe account into a MathOverflow denial of service
Submitted by YradAngel, also found by 0xGutzzz, maxzuvex, and Silvermist
contracts/kinetic-router/src/calculation.rs#L29-L52contracts/kinetic-router/src/calculation.rs#L392-L397contracts/kinetic-router/src/operations.rs#L285-L411contracts/kinetic-router/src/operations.rs#L11-L130
The health-factor function uses u128::MAX as the protocol’s “infinite health” value when a user has no debt. But the same function reverts when the account has a tiny nonzero debt and the computed health factor would be larger than u128::MAX.
That makes a very small debt residue dangerous. A user can have a large amount of collateral, only one base unit of debt, and be economically far safer than required, yet get_user_account_data, withdrawals, collateral toggles, swaps, liquidations, and other HF-dependent flows revert with MathOverflow.
Root Cause
calculate_health_factor_u256() calculates HF in U256, then narrows the result to u128.
if total_debt_base == 0 {
return u128::MAX;
}
collateral
.mul(&threshold)
.mul(&wad)
.div(&bps)
.div(&debt)
.to_u128()
.unwrap_or_else(|| panic_with_error!(env, KineticRouterError::MathOverflow))
This creates a discontinuity:
debt = 0 -> return u128::MAX
debt = 1 -> panic if HF > u128::MAX
The second state is not riskier than the first. It is still an extremely safe account. The calculation should saturate to the same maximum sentinel instead of treating the large value as an invalid arithmetic state.
Why This Is Reachable
The user does not need to start with enough collateral to overflow the calculation. They only need to create the dust debt while their account is still below the overflow boundary.
The sequence is:
- Supply collateral below the overflow threshold.
- Borrow one raw unit of an 18-decimal asset.
- The post-borrow HF is huge but still representable, so the borrow succeeds.
- Supply more collateral.
- Supply does not need to verify the supplier’s HF because more collateral cannot make the account less solvent.
- The next HF read computes a value above
u128::MAXand panics.
With a $1 oracle price and 14-decimal oracle precision, one raw unit of debt on an 18-decimal reserve can become total_debt_base = 1. Once collateral value crosses roughly u128::MAX / (liquidation_threshold * WAD / 10000), the U256 HF result no longer fits in u128.
For an 85% liquidation threshold, that boundary is around 4e20 base units, which corresponds to an ordinary few-hundred-dollar collateral position under the protocol’s base-value scale.
Impact
The account becomes operationally stuck despite being overcollateralized. Any code path that recomputes account data can fail with MathOverflow, including:
get_user_account_datawithdraw- additional
borrow set_user_use_reserve_as_collswap_collateralliquidation_call- transfer finalization for users with debt
The user may be able to recover only by repaying the dust through a path that does not first require a health-factor read. If the user no longer has the debt asset, they must acquire it externally because normal protocol flows that would use the collateral may already be blocked.
This can also happen naturally after near-full repayment if interest or rounding leaves a tiny debt remainder and the user later has enough collateral for the health-factor result to exceed u128::MAX.
Recommended Fix
Treat an above-u128::MAX health factor as saturated maximum health, not as an arithmetic failure.
collateral
.mul(&threshold)
.mul(&wad)
.div(&bps)
.div(&debt)
.to_u128()
.unwrap_or(u128::MAX)
This matches the existing zero-debt sentinel and preserves the intended invariant: extremely healthy accounts should remain usable.
Proof of Concept
The PoC creates a new 18-decimal borrow reserve, opens exactly one raw unit of debt while the user has a representable HF, then adds collateral until the health factor no longer fits in u128.
See original finding for full Proof of Concept
[M-38] Indebted users can be blocked from otherwise-safe aToken transfers
Submitted by FreeDropOracle, also found by binbin2803, bunnyhunter, codegpt, discerpo, fuzious, maxzuvex, mazennafe, and WhiteKnightK
contracts/a-token/src/contract.rs#L658-L664contracts/kinetic-router/src/router.rs#L1840-L1879contracts/kinetic-router/src/validation.rs#L256-L321contracts/kinetic-router/src/calculation.rs#L200-L247contracts/a-token/src/contract.rs#L70-L75contracts/debt-token/src/contract.rs#L38-L4
aToken.transfer() is supposed to allow transfers as long as the sender remains above the health-factor threshold after the transfer. In the live path, that is not what happens once the sender has debt.
The issue is caused by the way aToken.transfer_internal() delegates transfer safety checks to router.validate_and_finalize_transfer(...) before balances are written. If the sender has debt, the router enters validate_user_can_withdraw(...), which recomputes user account data through calculate_user_account_data_unified(...).
That account-data path performs cross-contract balance_of_with_index calls back into the reserve token contracts. The important point is that this behaves differently depending on context:
- as a top-level router call, the validation succeeds
- from the live
aTokentransfer path, the same validation fails withKineticRouterError::TokenCallFailed
aToken.transfer_internal() then maps that failure to TokenError::TransferFailed, so the user just sees a reverted transfer.
Impact:
- a borrower can remain above
HF = 1and still be unable to transfer even a tiny amount ofaToken - this is a functional denial of service for indebted suppliers
- integrations that rely on transferable receipt tokens can break for users with debt
So the practical bug is not that an unsafe transfer is allowed. It is the opposite: an otherwise-safe transfer is blocked.
Proof of Concept
See original finding for full Proof of Concept
[M-39] Global pause freezes borrower repayment but not borrow interest, leaving positions immediately liquidatable on unpause
Submitted by LeoGold, also found by Ephraim, honey-k12, pepoc, PolarizedLight, SarveshLimaye, TheWeb3Mechanic, and udogodwin
contracts/kinetic-router/src/validation.rs#L423-L425contracts/kinetic-router/src/calculation.rs#L469-L488
Background: The asymmetry between the pause mechanism and the interest accrual model
The protocol’s global pause is designed as an emergency circuit breaker. When activated, it prevents users from executing state-changing operations including supply, borrow, repay, and liquidation. The intent is to freeze protocol activity during an incident. However, the critical design flaw is that the pause flag is consulted only at the point of user-initiated transaction entry. The variable borrow index, which determines the real-time debt owed by every borrower, is computed purely from timestamps and the stored borrow rate. No component of the interest accrual path reads is_paused().
This creates a one-sided freeze: borrowers cannot repay, but their debt keeps growing.
The root cause: validate_repay gates on the pause flag while the borrow index does not
validate_repay at validation.rs#L423-L425 is the first thing called for every repayment:
pub fn validate_repay(
env: &Env,
_asset: &Address,
amount: u128,
rate_mode: u32,
reserve_data: &k2_shared::ReserveData,
) -> Result<(), KineticRouterError> {
if storage::is_paused(env) {
return Err(KineticRouterError::AssetPaused); // ← repay blocked
}
...
}
The liquidation entry points carry the same guard. prepare_liquidation at router.rs#L476-L478 and execute_liquidation at router.rs#L711-L713 both return AssetPaused immediately when the protocol is paused:
if storage::is_paused(&env) {
return Err(KineticRouterError::AssetPaused);
}
The borrower therefore has no mechanism to act on their position during a pause. They cannot repay debt, add collateral, or trigger a controlled liquidation to preserve some of their position.
Meanwhile, the borrow index accrual at calculation.rs#L469-L488 inside update_state has no is_paused() guard whatsoever:
let cumulated_variable_borrow_interest = calculate_compound_interest(
env,
reserve_data.current_variable_borrow_rate,
reserve_data.last_update_timestamp,
current_timestamp, // ← only depends on time, completely ignores pause state
)?;
updated_data.variable_borrow_index = ray_mul(
env,
reserve_data.variable_borrow_index,
cumulated_variable_borrow_interest,
)?;
updated_data.last_update_timestamp = current_timestamp;
The same absence of any pause awareness exists in get_current_variable_borrow_index_with_data at calculation.rs#L1095-L1125, which is the function that computes the debt a user owes at any given moment without writing a new index to storage. Every call to get_user_account_data during the pause will return a growing debt balance as timestamps advance, but the borrower has no transaction path available to respond:
pub fn get_current_variable_borrow_index_with_data(
env: &Env,
reserve_data: &ReserveData,
) -> Result<u128, KineticRouterError> {
let current_timestamp = get_current_timestamp(env);
...
let cumulated_variable_borrow_interest = calculate_compound_interest(
env,
reserve_data.current_variable_borrow_rate,
reserve_data.last_update_timestamp,
current_timestamp, // ← grows with every passing second, no pause check
)?;
ray_mul(
env,
reserve_data.variable_borrow_index,
cumulated_variable_borrow_interest,
)
}
The compound effect: oracle prices can also move during the pause
The oracle’s set_manual_override function is an admin-level function on a separate contract and is not subject to the router’s pause flag. Real-world market prices also continue moving off-chain and are reflected in Reflector oracle updates that the protocol will read the moment the pause lifts. A borrower who was healthy at pause time can therefore become liquidatable through two simultaneous channels while the pause is active: their denominated debt grows via the borrow index and their collateral value can fall via oracle price discovery. They have no recourse against either.
No grace period on unpause
unpause at emergency.rs#L16-L21 flips the pause flag to false and returns immediately:
pub fn unpause(env: Env, caller: Address) -> Result<(), KineticRouterError> {
storage::validate_admin(&env, &caller)?;
caller.require_auth();
storage::set_paused(&env, false); // ← flag flipped, execution returns immediately
Ok(())
}
There is no grace window, no repayment-only period, and no minimum delay before liquidation becomes available again. The moment the pool admin calls unpause, all liquidation entry points become active against positions that may have crossed the health factor boundary entirely during the pause period when the borrower was unable to take any defensive action.
Impact
A borrower who held a position with a health factor above 1.0 at the moment of pause is exposed to losing their collateral in a liquidation they could not have prevented. Depending on the duration of the pause and the borrow rate at the time, the health factor degradation may be significant. In a scenario involving a prolonged pause of several days at a meaningful borrow rate, a position that was fully healthy at the start of the pause could emerge from it well below the liquidation threshold and be immediately seized by liquidation bots that were monitoring the pause state. The borrower’s only recourse would have been to repay before the pause, which they had no way of anticipating in time for an emergency pause.
Proof of Concept
See original finding for full Proof of Concept
[M-40] Reward checkpointing can consume the full liquidation CPU budget and disable aToken-based collateral seizure
Submitted by Egobouros, also found by maxzuvex
contracts/incentives/src/storage.rs#L75-L78contracts/incentives/src/contract.rs#L91-L187contracts/debt-token/src/contract.rs#L268-L269contracts/a-token/src/contract.rs#L518-L520contracts/kinetic-router/src/liquidation.rs#L387-L487
K2 treats incentives as an inline side effect of aToken and debt-token balance changes. That is safe only if the reward checkpointing cost remains small enough to fit inside every lending operation that can trigger it.
The current incentives cap is local to one token contract: each aToken or debt token may have up to 20 reward tokens. A receive-aToken liquidation, however, touches both the debt token and the collateral aToken several times in the same transaction. As a result, one liquidation can synchronously process up to 100 reward checkpoints while it is already doing the normal liquidation work.
The PoC shows that the same direct liquidation costs:
no incentives: 21,421,816 CPU
max-valid incentives: 103,775,427 CPU
The max-valid case exceeds Soroban’s normal 100,000,000 CPU budget and fails. This blocks the receive-aToken liquidation mode, which is the mode that should remain available when the collateral reserve has insufficient idle underlying for ordinary receive-underlying liquidation.
Root Cause
The incentives limit is applied at the wrong level of composition.
MAX_REWARD_TOKENS_PER_ASSET bounds the number of reward tokens registered for one incentivized token contract:
const MAX_REWARD_TOKENS_PER_ASSET: u32 = 20;
handle_action() then performs work proportional to that list length every time an incentivized token calls it. For each configured reward token, it loads the reward config, checks whether it is active, updates the asset reward index, updates the user’s reward snapshot, and emits an event:
let reward_tokens = storage::get_reward_tokens(&env, &asset);
for i in 0..reward_tokens.len() {
let reward_token = reward_tokens.get(i).ok_or(KineticRouterError::InvalidAmount)?;
let config =
match storage::get_asset_reward_config(&env, &asset, &reward_token, reward_type) {
Some(config) => config,
None => continue,
};
if !config.is_active {
continue;
}
let updated_index = calculation::update_asset_reward_index(
&env,
&config,
¤t_index,
total_supply,
)?;
storage::set_asset_reward_index(
&env,
&asset,
&reward_token,
reward_type,
&updated_index,
);
let mut user_data =
storage::get_user_reward_data(&env, &asset, &reward_token, &user, reward_type);
...
storage::set_user_reward_data(
&env,
&asset,
&reward_token,
&user,
reward_type,
&user_data,
);
}
The protocol never asks whether a full liquidation path can afford the aggregate cost of all hooks it is about to trigger. The per-token cap is therefore insufficient: a high-level router operation can multiply the capped work several times.
Affected Flow
The vulnerable path is direct liquidation with _receive_a_token = true.
First, the liquidator repays the borrower’s debt and the router burns debt tokens:
let debt_burn_result = env.try_invoke_contract::<(bool, i128, i128), KineticRouterError>(
&updated_debt_reserve_data.debt_token_address,
&Symbol::new(env, "burn_scaled"),
args,
);
The debt-token burn checkpoints borrow rewards once:
Self::handle_incentives_action(&env, &pool_address, &borrowed_asset, &on_behalf_of, 1)?;
Then, because _receive_a_token = true, the router transfers collateral aTokens from the borrower to the liquidator:
let xfer_result = env.try_invoke_contract::<bool, KineticRouterError>(
&updated_collateral_reserve_data.a_token_address,
&Symbol::new(env, "transfer_on_liquidation"),
xfer_args,
);
That aToken transfer checkpoints supply rewards for both addresses:
Self::handle_incentives_action(&env, &state.pool_address, &state.underlying_asset, &from, 0)?;
Self::handle_incentives_action(&env, &state.pool_address, &state.underlying_asset, &to, 0)?;
The same receive-aToken branch also sends protocol-fee collateral to treasury when a fee is due and treasury is configured:
let fee_xfer_result = env.try_invoke_contract::<bool, KineticRouterError>(
&updated_collateral_reserve_data.a_token_address,
&Symbol::new(env, "transfer_on_liquidation"),
fee_xfer_args,
);
That second aToken transfer again checkpoints supply rewards for two addresses.
Under a valid configuration with 20 borrow rewards on the debt token and 20 supply rewards on the collateral aToken, one liquidation performs:
debt burn hook:
20 borrow reward streams
collateral aToken transfer to liquidator:
20 borrower supply reward streams
20 liquidator supply reward streams
collateral aToken fee transfer to treasury:
20 borrower supply reward streams
20 treasury supply reward streams
total:
100 reward-stream iterations
The token contracts catch ordinary incentives-call failures and continue, but CPU exhaustion is not an ordinary contract error that can be cleanly ignored. Once the host budget is exhausted, the whole top-level liquidation cannot complete.
Impact
This is a liquidation liveness issue.
The PoC creates an unhealthy borrower and deliberately leaves the collateral reserve short of idle underlying. In that state, receiving underlying collateral is not viable because the collateral aToken does not hold enough cash to transfer out. The receive-aToken path is supposed to be the resilient alternative: it can seize the borrower’s aToken balance without moving underlying.
With max-valid rewards configured, that fallback path becomes too expensive and fails under the normal CPU budget. The result is that an unhealthy account can remain unliquidated until incentives are changed, rewards are disabled, or the position changes enough for another liquidation path to become available.
This does not let an attacker steal funds. The damage is delayed liquidation and increased bad-debt exposure under a configuration the protocol itself permits.
Recommended Mitigation
Keep optional reward accounting out of the critical liquidation budget.
Reasonable fixes include:
- reducing the reward-token cap to a value proven safe for composed liquidation flows;
- using a lower cap for token hooks invoked from liquidation;
- deferring reward checkpoints during liquidation and allowing users to checkpoint later;
- processing only a bounded number of reward streams per hook invocation; or
- moving incentives accounting to a pull-based model for high-cost paths.
The important invariant is that a valid incentives configuration must not be able to make an otherwise valid liquidation exceed the network transaction budget.
Proof of Concept
See original finding for full Proof of Concept
[M-41] Reserve deficit is excluded from borrow-cap and debt-ceiling checks, allowing new borrows above configured risk limits
Submitted by 0xastronatey, also found by accessControl, derkomai, dipanshuchhanikar, Drothon, dzdzdz05, fuddle_yichi, harry, khaye26, mabdelnafea, PadIA, qed, rubencrxz, sahuang, tradingview, udogodwin, xw6fqfrry8, and y4y
contracts/kinetic-router/src/liquidation.rs#L576-L610contracts/kinetic-router/src/validation.rs#L367-L409contracts/kinetic-router/src/operations.rs#L320-L330contracts/kinetic-router/src/treasury.rs#L107-L140
When liquidation burns unrecoverable bad debt, K2 stores that loss in RESERVE_DEFICIT and reduces the debt-token supply accordingly. The borrow-cap and debt-ceiling gate in validate_borrow_cap_after_interest later checks only the live debt-token supply plus the new borrow amount. It never adds the stored reserve deficit, even though the protocol itself treats that deficit as real outstanding reserve exposure that must be covered by transferring underlying tokens back into the reserve (via cover_deficit). After a bad-debt liquidation, any borrower with sufficient collateral can open new debt that makes reserve_deficit + live_debt exceed the configured borrow cap or debt ceiling, bypassing the protocol’s hard exposure limits precisely when the reserve is already impaired.
Root Cause
1) Bad debt is moved out of debt-token supply into RESERVE_DEFICIT
When collateral is exhausted during liquidation, the router burns the borrower’s remaining debt and records the unrecovered amount as a reserve deficit.
contracts/kinetic-router/src/liquidation.rs#L576-L610
// Post-burn bad debt socialization.
// When collateral_cap_triggered, ALL remaining debt is unrecoverable (no collateral left
// for another liquidation). Socialize unconditionally — threshold is irrelevant here.
let min_remaining_whole = updated_debt_reserve_data.configuration.get_min_remaining_debt();
if remaining_debt_balance > 0 {
let remaining_debt_u128 = safe_i128_to_u128(env, remaining_debt_balance);
if collateral_cap_triggered {
// All collateral seized — remaining debt is unrecoverable bad debt.
let mut bad_debt_burn_args = Vec::new(env);
bad_debt_burn_args.push_back(IntoVal::into_val(&env.current_contract_address(), env));
bad_debt_burn_args.push_back(user.to_val());
bad_debt_burn_args.push_back(IntoVal::into_val(&remaining_debt_u128, env));
bad_debt_burn_args.push_back(IntoVal::into_val(
&updated_debt_reserve_data.variable_borrow_index,
env,
));
let bad_debt_burn_result = env.try_invoke_contract::<(bool, i128, i128), KineticRouterError>(
&updated_debt_reserve_data.debt_token_address,
&Symbol::new(env, "burn_scaled"),
bad_debt_burn_args,
);
// ...
// Track bad debt as deficit instead of socializing to depositors
storage::add_reserve_deficit(env, &debt_asset, remaining_debt_u128);
// <-- deficit remains real reserve exposure, but is no longer included in debt-token total supply
After this path executes, the debt token’s total_supply is reduced by the burned amount, but the reserve’s real exposure has not decreased - it has merely been reclassified from live debt into tracked deficit.
2) Borrow-cap and debt-ceiling validation ignores that tracked deficit
The borrow gate computes new_total_debt from only the debt token’s indexed total supply and the requested borrow amount. It never adds storage::get_reserve_deficit(env, asset), even though that deficit is unrecovered bad debt for the same reserve.
contracts/kinetic-router/src/validation.rs#L367-L409
pub fn validate_borrow_cap_after_interest(
env: &Env,
amount: u128,
reserve_data: &k2_shared::ReserveData,
asset: &Address,
variable_borrow_index: u128,
) -> Result<(), KineticRouterError> {
let borrow_cap = reserve_data.configuration.get_borrow_cap();
let debt_ceiling = storage::get_reserve_debt_ceiling(env, asset);
// F-14
if borrow_cap > 0 || debt_ceiling > 0 {
let current_debt = calculation::get_total_supply_with_index(
env,
&reserve_data.debt_token_address,
variable_borrow_index,
)?;
// <-- only live debt-token supply is counted; reserve_deficit is excluded
let decimals = reserve_data.configuration.get_decimals();
let multiplier = 10u128
.checked_pow(decimals as u32)
.ok_or(KineticRouterError::MathOverflow)?;
let new_total_debt = current_debt
.checked_add(amount)
.ok_or(KineticRouterError::MathOverflow)?;
// <-- cap/ceiling compare against current_debt + amount, not current_debt + deficit + amount
if borrow_cap > 0 {
let cap_in_smallest_units = borrow_cap
.checked_mul(multiplier)
.ok_or(KineticRouterError::MathOverflow)?;
if new_total_debt > cap_in_smallest_units {
return Err(KineticRouterError::BorrowCapExceeded);
}
}
if debt_ceiling > 0 {
let ceiling_in_smallest_units = debt_ceiling
.checked_mul(multiplier)
.ok_or(KineticRouterError::MathOverflow)?;
if new_total_debt > ceiling_in_smallest_units {
return Err(KineticRouterError::DebtCeilingExceeded);
}
}
}
The invariant live_debt + reserve_deficit <= borrow_cap is never checked. Only live_debt + new_borrow <= borrow_cap is enforced.
3) The borrow path relies on the deficit-blind check as the sole cap gate
Every borrow calls this validation before health-factor and liquidity checks. If the borrower has enough collateral and the aToken still has cash, the borrow succeeds even when total reserve exposure already exceeds the configured cap once deficit is included.
contracts/kinetic-router/src/operations.rs#L320-L330
if on_behalf_of == updated_reserve_data.debt_token_address {
panic_with_error!(&env, OperationError::RecipientIsDebtToken);
}
// Re-validate cap after interest accrual (interest increases total debt)
validation::validate_borrow_cap_after_interest(
&env, amount, &updated_reserve_data, &asset,
updated_reserve_data.variable_borrow_index)?;
// <-- this is the only borrow-cap/debt-ceiling gate, and it ignores stored reserve deficit
let oracle_config = crate::price::get_oracle_config(&env)?;
let oracle_to_wad = calculate_oracle_to_wad_factor(oracle_config.price_precision);
validation::validate_user_can_borrow(
&env, &on_behalf_of, &asset, amount, &updated_reserve_data, oracle_to_wad)?;
4) The protocol itself treats deficit as a real cash shortfall that must be covered
cover_deficit requires someone to transfer real underlying into the aToken before the deficit is reduced. This confirms the deficit is not just metadata - it is outstanding reserve exposure that represents a real cash shortfall in the lending pool.
contracts/kinetic-router/src/treasury.rs#L107-L140
let current_deficit = storage::get_reserve_deficit(&env, &asset);
if current_deficit == 0 {
return Err(KineticRouterError::InvalidAmount);
}
// Cover at most the current deficit
let cover_amount = amount.min(current_deficit);
// Get the reserve's aToken address
let reserve_data = storage::get_reserve_data(&env, &asset)?;
// Transfer underlying tokens from caller to aToken contract (replenishes pool liquidity)
let transfer_args = soroban_sdk::vec![
&env,
caller.to_val(),
reserve_data.a_token_address.to_val(),
IntoVal::into_val(&safe_u128_to_i128(&env, cover_amount), &env),
];
// <-- deficit is only reduced after real underlying is transferred back into the reserve
// ...
// Reduce the tracked deficit
storage::reduce_reserve_deficit(&env, &asset, cover_amount);
Internal Pre-conditions
- The debt reserve has a non-zero borrow cap or debt ceiling configured.
- A liquidation has created a non-zero
RESERVE_DEFICITfor that reserve (collateral was exhausted before debt was fully repaid). - The debt token’s live total supply was reduced when bad debt was burned.
- The reserve still has enough aToken cash for a later borrow.
- The attacker has enough collateral to pass normal health-factor validation.
External Pre-conditions
A borrower becomes deeply undercollateralized because collateral price falls. The PoC below uses the test oracle admin to model this market movement; the exploit itself does not require malicious admin behaviour. No DEX liquidity, frontrunning, callback reentrancy, or privileged role cooperation is required after the deficit exists.
Attack Path
- The reserve admin has configured
asset_bwith a 900-token borrow cap, a normal risk-control setting. - A borrower supplies 1,000
asset_aas collateral and borrows 800asset_b, staying below the 900-token cap. asset_afalls from$1.00to$0.01. With 7-decimal assets and the test oracle’s 14-decimal$1price, this isPRICE_ONE_DOLLAR / 100 = 1_000_000_000_000.- A liquidator liquidates the borrower. The borrower has only about
$10of collateral against about$800of debt, so collateral is exhausted. The router burns the unrecoverable remaining debt and records it asasset_breserve deficit atliquidation.rs#L609-L610. - Because that remaining debt was burned, the debt token’s total supply for
asset_bis now near zero, butget_reserve_deficit(asset_b)is roughly 790 tokens. - A second attacker supplies 500
asset_bas collateral and borrows 200asset_b. The borrow should be rejected becausedeficit + new_live_debt ≈ 790 + 200 = 990, which exceeds the 900-token cap. - The borrow succeeds because
validate_borrow_cap_after_interestchecks onlycurrent_debt + amountatvalidation.rs#L386-L390, wherecurrent_debtis the live debt-token total after bad debt was burned.
Worked example with realistic parameters
ASSET_DECIMALS = 7, PRICE_ONE_DOLLAR = 100_000_000_000_000, and USER_STARTING_BALANCE = 10_000_000_000 are the existing K2 C4 scaffold defaults in tests/c4/src/lib.rs#L163-L169. A 900-token cap is 900 * 10^7 = 9_000_000_000 units. After the bad-debt liquidation, a deficit of about 7_900_000_000 units remains. A new 2_000_000_000 unit borrow passes because the cap check sees only 0 + 2_000_000_000 <= 9_000_000_000, even though actual exposure is about 7_900_000_000 + 2_000_000_000 > 9_000_000_000.
Impact
- Impact: Configured borrow caps and debt ceilings are hard risk limits designed to bound the protocol’s exposure to any single reserve. Once bad debt is recorded, the protocol can take on additional debt above those limits, increasing supplier and treasury exposure precisely when the reserve is already impaired.
- Likelihood: Requires a reserve deficit, but deficits are an explicitly supported liquidation outcome (not an artificial state). Any deep price crash on a collateral asset can trigger this.
Negative Check
-
Why existing validation does not block this attack:
- Access control does not prevent this. The victim, liquidator, and later borrower each use their own authorized public entrypoints:
borrow,liquidation_call, andborrow. - The reentrancy guard does not apply because this is a normal multi-transaction sequence, not reentrancy.
- The aToken cash check does not prevent this because the reserve can still have large LP liquidity after a bad-debt event.
- Health-factor validation does not prevent this because the later borrower supplies sufficient collateral. The broken invariant is the reserve-level cap, not borrower solvency.
- Access control does not prevent this. The victim, liquidator, and later borrower each use their own authorized public entrypoints:
-
Why this is distinct from known issues:
- This is not the V12 “Deficit treated as available liquidity” issue. That finding targets interest-rate repricing in
calculate_interest_rates_for_reserveatcalculation.rs#L938-L999. This finding targets the separate borrow-cap/debt-ceiling gate invalidation.rs#L367-L409and shows a hard risk-limit bypass even without relying on rate mispricing. - This is not Halborn 7.15. Halborn’s borrow-limit issue was a general cap-enforcement inconsistency; this path depends specifically on post-liquidation
RESERVE_DEFICITbeing excluded after debt-token supply is burned. - This is not a WatchPug duplicate; the listed WatchPug findings cover aToken transfer/liquidation settlement/rounding and min-remaining-debt conflicts, not deficit-aware borrow-cap accounting.
- This is not the V12 “Deficit treated as available liquidity” issue. That finding targets interest-rate repricing in
Mitigation
Include the tracked reserve deficit in both borrow-cap and debt-ceiling exposure calculations.
let current_debt = calculation::get_total_supply_with_index(
env,
&reserve_data.debt_token_address,
variable_borrow_index,
)?;
let current_deficit = storage::get_reserve_deficit(env, asset); // Fixed: include unrecovered bad debt
let new_total_debt = current_debt
.checked_add(current_deficit)
.ok_or(KineticRouterError::MathOverflow)?
.checked_add(amount)
.ok_or(KineticRouterError::MathOverflow)?;
// Compare this deficit-aware exposure against both borrow_cap and debt_ceiling.
Proof of Concept
See original finding for full Proof of Concept
[M-42] Approved spender exceed nominal allowance by splitting aToken transfers into dust chunks
Submitted by johnyfwesh, also found by 0xepley, Agontuk, Benji687, cht1206, coinsspor, khaye26, odeili, p4y4b13, qed, and TheCodeReaper
contracts/a-token/src/contract.rs#L83-L119
ATokenContract::transfer_from() treats allowance as a nominal accounting value. It checks that the spender’s allowance is at least the caller supplied amount, then subtracts exactly that nominal amount from allowance.
The actual balance movement happens later in ATokenContract::transfer_internal(). That function converts the same nominal amount into scaled aToken shares with ray_div_up(amount, liquidity_index). Because this conversion rounds up, a small dust transfer can move one whole scaled share even when the nominal allowance consumed by the call is only one minimal unit.
When the liquidity index is greater than RAY, one scaled share represents more than one nominal unit of redeemable value. A spender can therefore split an approved transfer into many dust-sized transfer_from() calls. Each call consumes only the dust nominal amount from allowance, but each call credits a rounded-up scaled share to the recipient. After enough calls, the recipient can hold scaled shares worth more redeemable underlying than the nominal allowance the holder granted.
The issue does not mint protocol value out of thin air. The excess value is debited from the approving holder’s scaled balance and credited to the recipient. The broken boundary is the holder’s approval limit: an approved spender can move more redeemable aToken value than the approval amount was intended to authorize.
This causes real user fund loss from addresses that granted allowance.
Affected code
let current_allowance = Self::allowance(env.clone(), from.clone(), spender.clone());
if current_allowance < amount {
return Err(TokenError::InsufficientAllowance);
}
if let Some(mut allowance_data) = storage::get_allowance(&env, &from, &spender) {
allowance_data.amount = current_allowance.checked_sub(amount)
.ok_or(TokenError::InsufficientAllowance)?;
storage::set_allowance(&env, &from, &spender, &allowance_data);
}
transfer_from() validates and decrements allowance by the nominal amount parameter. It does not account for the rounded scaled amount that will actually be debited from the holder and credited to the recipient.
let amount_u128 = safe_i128_to_u128(&env, amount);
let scaled_u128 = ray_div_up(&env, amount_u128, liquidity_index).map_err(|_| TokenError::InvalidIndex)?;
let scaled_amount = safe_u128_to_i128(&env, scaled_u128);
let from_scaled_balance = storage::get_scaled_balance(&env, &from);
if from_scaled_balance < scaled_amount {
return Err(TokenError::InsufficientBalance);
}
let new_from_balance = from_scaled_balance.checked_sub(scaled_amount)
.ok_or(TokenError::InsufficientBalance)?;
let to_scaled_balance = storage::get_scaled_balance(&env, &to);
let new_to_balance = to_scaled_balance.checked_add(scaled_amount)
.ok_or(TokenError::TransferFailed)?;
transfer_internal() rounds the scaled share amount upward and applies that rounded amount to balances.
pub fn ray_div_up(env: &Env, a: u128, b: u128) -> Result<u128, crate::KineticRouterError> {
if b == 0 {
return Err(crate::KineticRouterError::MathOverflow);
}
let a_u256 = U256::from_u128(env, a);
let b_u256 = U256::from_u128(env, b);
let ray_u256 = U256::from_u128(env, RAY);
let one = U256::from_u128(env, 1u128);
// Ceiling division: (a * RAY + b - 1) / b
let numerator = a_u256.mul(&ray_u256).add(&b_u256).sub(&one);
let result = numerator.div(&b_u256);
This is the ceiling division responsible for converting dust transfers into one full scaled share when the exact scaled result is fractional.
pub fn balance_of_with_index(env: &Env, id: &Address, liquidity_index: u128) -> i128 {
let scaled_balance = storage::get_scaled_balance(env, id);
// S-04
let scaled_balance_u128 = safe_i128_to_u128(env, scaled_balance);
let result = ray_mul(env, scaled_balance_u128, liquidity_index)
.unwrap_or_else(|_| {
soroban_sdk::panic_with_error!(env, KineticRouterError::MathOverflow)
});
The recipient’s scaled shares are later presented as nominal aToken balance by multiplying by the liquidity index.
let scaled_u128 = ray_div_up(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let mut amount_scaled = safe_u128_to_i128(&env, scaled_u128);
let current_scaled_balance = storage::get_scaled_balance(&env, &on_behalf_of);
...
let actual_amount = ray_mul_down(&env, safe_i128_to_u128(&env, amount_scaled), index)
.map_err(|_| TokenError::InvalidIndex)?;
let actual_amount = actual_amount.min(amount);
The burn-and-transfer path shows that accumulated scaled shares can be converted into underlying value.
Impact
Approved spender can overuse an allowance
A holder may grant a spender an allowance of N nominal aToken units. The spender can split that allowance into N one-unit transfers when the liquidity index is above RAY. Each call spends one nominal allowance unit, but each call can move one scaled share. If the index is 2 * RAY, those N scaled shares are worth 2N nominal units.
The loss is charged to the approving holder
This is not protocol-wide inflation. The rounded-up scaled shares are debited from the holder’s scaled balance. The holder loses more redeemable aToken value than the allowance amount implied, and the recipient receives the corresponding extra shares.
Realistic attacker requirements
- The attacker does not need admin or governance privileges.
- The attacker must be an approved spender for the victim, or control an address/contract that the victim approved.
- The victim must have enough scaled balance to cover the rounded-up transfers.
Recommended mitigation steps
- Make allowance accounting consume the same unit of value that balances consume. For delegated transfers, compute
scaled_amountfirst and charge allowance based on the nominal value represented by that scaled amount at the current index. - Alternatively, reject delegated transfers where
ray_div_up(amount, index)would represent more nominal value thanamount, unless the allowance is charged for the rounded-up represented value. - Consider storing aToken allowances in scaled units, or store both nominal and scaled allowance with a clearly defined conversion rule, so repeated rounding cannot be farmed across many calls.
Proof of Concept
See original finding for full Proof of Concept
Low Risk and Informational Issues
For this audit, 52 QA reports were submitted by wardens compiling low risk and informational issues. The QA report highlighted below by YradAngel received the top score from the judge. 26 Low-severity findings were also submitted individually, and can be viewed here.
The following wardens also submitted QA reports: 0x4non, 0x5ul3x, 0xnija, ABAIKUNANBAEV, Adison-Wisetnakon, adityakumar2898, aestheticbhai, Agontuk, ariam, Augusta, aves, Bale, binbin2803, blackgrease, BlockSentry, brian, ChaseTheLight, chesnok, codertjay, devr0s, Diavolo, Drothon, eta, giangnh1, Grenade, gwumex, hezze, johnyfwesh, k100singh, K42, KineticsDieMinute, legat, natachi, oxp_tr125, oziajibogu, PolarizedLight, pwvux, Ramos, Rikka, SarveshLimaye, Sid_Sisodia, SOPROBRO, Sparrow, TheCarrot, udogodwin, v12, y4y, yonko, and yuzengbao.
Summary
| Finding | Topic |
|---|---|
| QA‑01 | Burn-and-transfer can overburn source aTokens |
| QA‑02 | Router reentrancy lock omits public state-writing entrypoints |
| QA‑03 | Router close-factor arithmetic can overflow on large selected debt |
| QA‑04 | configure_asset_rewards does not validate distribution_end > current_timestamp |
| QA‑05 | Health factor and account-data threshold can be overstated by rounded weighted thresholds |
| QA‑06 | Reserve access-list updates can strand existing positions |
| QA‑07 | Action finalizers can overwrite in-flight reserve config changes |
| QA‑08 | Health-factor, borrow-capacity, and account-data views floor existing debt values |
| QA‑09 | Aquarius adapter does not have admin transfer mechanism |
| QA‑10 | Broken rate strategy contract permanently blocks all reserve operations including liquidations |
| QA‑11 | Treasury sync_balance overwrites internal balance unconditionally |
| QA‑12 | Liquidation gates floor borrower collateral support |
| QA‑13 | Oracle circuit-breaker deviation math can overflow before division |
| QA‑14 | collect_protocol_reserves can drain underlying needed for withdrawal during utilization spikes |
| QA‑15 | Pool configurator initialize sets both pool_admin and emergency_admin to the same address |
| QA‑16 | Pool configurator exposes no function to update the price oracle address post-deployment |
| QA‑17 | prepare_liquidation can store authorizations for unheld collateral or unwhitelisted handlers |
| QA‑18 | Staged liquidation swap minimums round down |
| QA‑19 | Custom/batch oracle setters overwrite shared max_age used by the other source |
| QA‑20 | Reward index updates can overflow at multiple arithmetic stages |
| QA‑21 | Router aToken mints can credit less claim value than funded |
| QA‑22 | Health-factor narrowing can fail overlarge healthy positions |
| QA‑23 | Uninitialized interest-rate strategies silently quote zero rates |
| QA‑24 | Liquidation and swap routes bypass reserve-level blacklist checks for treasury/liquidator |
| QA‑25 | set_distribution_end does not freshen the reward index before changing end timestamp |
| QA‑26 | Liquidation engine execute_liquidation always passes _receive_a_token=false |
| QA‑27 | Withdrawals can leave zero-balance collateral bits set |
| QA‑28 | [omitted] |
| QA‑29 | Batch oracle setup silently assumes 8 decimals when adapter decimals are omitted |
| QA‑30 | validate_withdraw does not check is_frozen() while validate_supply and validate_borrow do |
| QA‑31 | [omitted] |
| QA‑32 | Non-risk actions depend on unnecessary risk data |
| QA‑33 | Incentives claim_rewards drains accrued state without freshening the global reward index |
| QA‑34 | fund_rewards reuses MathOverflow error code for zero-amount rejection |
| QA‑35 | Borrow capacity and post-action health checks floor action deltas |
| QA‑36 | Withdrawals can trap payouts in protocol token contracts |
| QA‑37 | Collateral-swap protocol fees can round to zero |
| QA‑38 | Incentives emission manager is a single point of failure with no transfer mechanism |
| QA‑39 | Public swap adapters can spend adapter-held balances |
| QA‑40 | Standalone liquidation previews diverge from router settlement in multiple dimensions |
| QA‑41 | Liquidation access-list updates can leave no eligible liquidator |
| QA‑42 | Batch adapter prices are downcast before normalization |
| QA‑43 | aToken transfer sender rewards settle from post-transfer balance |
| QA‑44 | Debt-free and non-collateral withdrawals blocked by unnecessary oracle dependency |
| QA‑45 | Flash-loan premium sweeps bypass reserve recipient lists |
| QA‑46 | Staged liquidation price tolerance lower bounds round down |
| QA‑47 | Direct aToken transfers move rounded-up scaled value while risk-checking nominal amount |
| QA‑48 | Low oracle price precision can underprice debt |
| QA‑49 | Receive-aToken liquidations can under-transfer collateral |
| QA‑50 | [omitted] |
| QA‑51 | Idempotent collateral toggles unnecessarily depend on oracle/health-factor checks |
| QA‑52 | set_flash_loan_premium_max does not cross-validate against current base + surcharge total |
| QA‑53 | Supply caps can undercount post-mint aggregate supply |
| QA‑54 | Reserve drops can strand user bitmap slots across re-registration |
| QA‑55 | Borrows can overmint debt and undercheck risk limits |
| QA‑56 | Incentives delete_reward_token does not verify all users have claimed before deletion |
| QA‑57 | aToken transfer_on_liquidation uses ray_div_down while regular transfers use ray_div_up |
| QA‑58 | Configured incentives break factory reserve deployment |
| QA‑59 | claim_rewards / claim_all_rewards drain accrued state before validating transfer domain |
| QA‑60 | Collateral swaps bypass the destination first-deposit minimum |
| QA‑61 | Reserve strategy setters can bind unusable or wrong rate strategies |
| QA‑62 | Incentive emissions during zero-supply windows are permanently lost with no recovery mechanism |
| QA‑63 | Standalone liquidation wrapper uses unbounded per-user history vector |
| QA‑64 | Swap adapters do not validate that from_token != to_token |
| QA‑65 | Upgrade co-signature documentation diverges from per-contract upgrade authority |
| QA‑66 | [omitted] |
| QA‑67 | Factory/manual reserve deployment does not verify underlying SEP-41 or token ABI |
| QA‑68 | Disabled fallback oracle state can fail typed storage lookup |
| QA‑69 | Reserve-spread sweeps bypass reserve recipient lists |
| QA‑70 | Staged liquidation price tolerance uses narrow intermediate math |
| QA‑71 | Staged liquidation callback does not authorize router-owned debt-asset spends |
| QA‑72 | Strategy rate caps can overflow reserve indices |
| QA‑73 | swap_collateral does not enforce oracle-based slippage minimum unlike flash liquidation callback |
| QA‑74 | Weighted-threshold health-factor arithmetic can overflow before normalization |
| QA‑75 | Swap-handler and reward-token whitelist entries not validated against required ABI |
| QA‑76 | drop_reserve does not check for outstanding deficit before removing reserve tracking |
| QA‑77 | Reserve sweeps can collect fragmented aToken rounding dust |
| QA‑78 | Liquidations can seize collateral on rounded-down debt burns |
| QA‑79 | Direct liquidation fee conversion can undercollect protocol fees |
| QA‑80 | set_partial_liq_hf_threshold and set_hf_liquidation_threshold allow contradictory boundaries |
| QA‑81 | aToken transfers bypass reserve pause, freeze, and active gates |
| QA‑82 | Flash loans accept impossible high asset counts |
| QA‑83 | Factory reserve deployment does not verify token WASM ABI |
| QA‑84 | Account-data LTV arithmetic can overflow on large collateral values |
| QA‑85 | Access-list activation flag and payload state can desynchronize |
| QA‑86 | Account-data silently skips unresolved active bitmap positions |
| QA‑87 | validate_repay checks amount == 0 separately from validate_amount creating redundant error paths |
| QA‑88 | [omitted] |
| QA‑89 | Collateral swaps can require prices for exited source reserves |
| QA‑90 | Reward rounding can overpay emitted incentives by dust units |
| QA‑91 | Liquidation engine records collateral_to_liquidate from its own calculation, not from the router’s actual settlement |
| QA‑92 | set_liquidation_price_tolerance allows zero tolerance silently disabling price deviation protection |
| QA‑93 | Pool-configurator rate-strategy rotation lacks downstream contract authorization |
| QA‑94 | aToken transfers can re-enable receiver collateral without consent |
| QA‑95 | Collateral-swap minimum output is enforced before protocol fees |
| QA‑96 | Token contract initialize does not require admin auth |
| QA‑97 | Partial withdrawals can overburn aToken claim value relative to payout |
| QA‑98 | remove_asset_reward deactivates config but does not update the global reward index |
| QA‑99 | MAXUSERRESERVES overrejects slot-neutral same-reserve transitions |
| QA‑100 | [omitted] |
| QA‑101 | Claim-all rewards rejects supported asset registries |
| QA‑102 | Whitelist/blacklist membership checks scan only the first 64 entries |
| QA‑103 | Batch price queries group only the first adapter cohort |
| QA‑104 | Oracle config accepts out-of-range circuit-breaker thresholds |
| QA‑105 | Debt token burn_scaled dust threshold of <= 1 may leave non-trivial phantom debt for high-index reserves |
| QA‑106 | Collateral-swap fee transfers bypass destination reserve recipient lists |
| QA‑107 | Accepted reserve limits can overflow during user-path conversion |
QA-01: Burn-and-transfer can overburn source aTokens
Assessment a-token:burnscaledandtransferto rounds the requested underlying amount up to scaled shares with raydivup, burns that scaled amount from the user and from totalsupplyscaled, but then computes the transferred underlying with raymuldown and caps it to the original requested amount. For amounts that are not exactly representable at the current liquidity index, the live aToken claim removed from the user can exceed the underlying amount returned by the helper. swapcollateral and staged liquidation callbacks consume only the returned actualamount/actual_collateral for DEX input, slippage, health-factor, and liquidation settlement accounting.
Failure mode. The helper treats the protocol-favorable rounded-up scaled burn and the protocol-favorable capped/floor-rounded underlying transfer as the same economic amount. It returns only the transferred underlying amount, not the full live claim value extinguished by the scaled burn, so callers cannot reconcile source collateral loss to the value actually routed onward.
Trigger path. A collateral swap or staged liquidation callback burns a source aToken amount that is not exactly representable as an integer scaled balance at the supplied liquidity index. The user or borrower has enough scaled balance for the rounded-up burn, and the route succeeds using the helper-returned actualamount or actualcollateral.
Why it matters. The affected account can lose more live source collateral value than is swapped, reminted, or sold for debt repayment. The excess remains as unclaimed underlying surplus in aToken custody because totalsupplyscaled is reduced by the rounded-up burn while only the capped underlying amount leaves custody; that surplus can later be classified as protocol reserves. The mismatch is bounded by less than one scaled aToken unit per burn but is repeatable in core swap and staged-liquidation settlement paths.
Evidence. contracts/a-token/src/contract.rs:399-465; contracts/kinetic-router/src/swap.rs:124-149,351-363; contracts/kinetic-router/src/flash_loan.rs:387-417
Code context:
// contracts/a-token/src/contract.rs:399-412
// M-14 / OPT-M2: ray_div_up burns slightly more scaled units (protocol-favorable)
let scaled_u128 = ray_div_up(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let mut amount_scaled = safe_u128_to_i128(&env, scaled_u128);
let current_scaled_balance = storage::get_scaled_balance(&env, &on_behalf_of);
// Cap at actual scaled balance: ray_div_up(ray_mul_down(S,I),I) can exceed S by 1
if amount_scaled > current_scaled_balance {
amount_scaled = current_scaled_balance;
}
if amount_scaled == 0 {
return Err(TokenError::InvalidAmount);
}
// ...
// contracts/kinetic-router/src/swap.rs:124-137
let mut burn_transfer_args = Vec::new(&env);
burn_transfer_args.push_back(pool_address.to_val());
burn_transfer_args.push_back(caller.to_val());
burn_transfer_args.push_back(amount.into_val(&env));
burn_transfer_args.push_back(updated_from_reserve_data.liquidity_index.into_val(&env));
burn_transfer_args.push_back(pool_address.to_val()); // transfer target = router
let burn_transfer_result = env.try_invoke_contract::<(i128, i128, u128), KineticRouterError>(
&updated_from_reserve_data.a_token_address,
&Symbol::new(&env, "burn_scaled_and_transfer_to"),
burn_transfer_args,
);
let (new_user_scaled_balance, from_supply_scaled, actual_amount) = match burn_transfer_result {
// ...
Recommended change Return and account for the full live value represented by the scaled burn, or reject amounts whose rounded-up scaled burn would extinguish more claim value than the requested transfer. Callers should use the same reconciled amount for DEX input, health-factor validation, liquidation authorization caps, events, and reserve-surplus accounting.
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- pub fn burn_and_transfer(...) -> Result<(i128, i128, u128), TokenError> {
+ pub fn burn_and_transfer(...) -> Result<(i128, i128, u128), TokenError> {
@@
- Ok((new_scaled_balance, state.total_supply_scaled, actual_amount))
+ if actual_amount > amount { return Err(TokenError::InvalidAmount); }
+ Ok((new_scaled_balance, state.total_supply_scaled, actual_amount))
}
QA-02: Router reentrancy lock omits public state-writing entrypoints
Assessment The router RAII reentrancy guard is acquired by the main value-moving routes, flash loans, liquidation preparation/execution, treasury reserve/deficit flows, and collateral swaps, but several public state-writing entrypoints do not acquire or check the same lock. Non-router callback paths, including flash-loan receivers, custom swap handlers/DEX adapters, downstream DEX pool/pair/token calls, oracle dependencies, and token incentive hooks, can therefore enter the router while PROTOCOLLOCKED is already true and still reach setuserusereserveascoll, updatereservestate, or aToken-driven validateandfinalize_transfer. This contradicts the documented model that the guard is acquired at the start of every state-changing operation; the concrete borrow-callback fund-loss path that combines this with delayed borrowing-bit writes is tracked separately as M-12.
Failure mode. Guard coverage is applied manually per router entrypoint instead of being enforced uniformly for all mutating public methods. Later state-writing exports were left outside acquirereentrancyguard and also do not reject when storage::isprotocollocked is true.
Trigger path. A guarded router flow is in progress and control reaches an external non-router callback, such as a flash-loan receiver, swap handler/DEX adapter, downstream DEX pool/pair/token, reserve token, oracle dependency, or incentives/reward token callback; the callback can satisfy the relevant authorization boundary for an unguarded state-writing route, for example its own caller auth for collateral toggle, the registered aToken contract auth for transfer finalization, or no auth for reserve refresh.
Why it matters. Callbacks can mutate router risk metadata or reserve refresh state while the protocol lock is active, weakening the intended global critical section and allowing collateral bitmap or reserve timestamp changes during guarded flows. The confirmed borrow-specific reserve-extraction path is tracked as M-12; outside that combined borrow ordering issue, the broad omitted-route surface was not separately shown to drain reserves.
Evidence. contracts/kinetic-router/src/router.rs:22-28; contracts/kinetic-router/src/router.rs:198-304; contracts/kinetic-router/src/router.rs:415-447; contracts/kinetic-router/src/router.rs:463-708; contracts/kinetic-router/src/router.rs:1233-1248; contracts/kinetic-router/src/router.rs:1256-1304; contracts/kinetic-router/src/router.rs:1371-1375; contracts/kinetic-router/src/router.rs:1840-1887; contracts/kinetic-router/src/views.rs:39-44; contracts/kinetic-router/src/calculation.rs:443-502; docs/09-SECURITY.md:913-938
Code context:
// contracts/kinetic-router/src/router.rs:22-28
fn acquire_reentrancy_guard(env: &Env) -> ReentrancyGuard {
storage::extend_instance_ttl(env);
if storage::is_protocol_locked(env) {
panic_with_error!(env, SecurityError::ReentrancyDetected);
}
storage::set_protocol_locked(env, true);
ReentrancyGuard { env }
// contracts/kinetic-router/src/router.rs:198-211
let _guard = acquire_reentrancy_guard(&env);
crate::operations::supply(env.clone(), caller, asset, amount, on_behalf_of, _referral_code)
}
pub fn withdraw(
env: Env,
caller: Address,
asset: Address,
amount: u128,
to: Address,
) -> Result<u128, KineticRouterError> {
let _guard = acquire_reentrancy_guard(&env);
crate::operations::withdraw(env.clone(), caller, asset, amount, to)
}
// ...
Recommended change Apply a consistent guard policy to every public router method that writes storage, or add explicit storage::isprotocollocked rejection to state-writing methods that should not acquire the RAII guard because they are intentionally called from aToken finalization. Document any intentional exception and keep callback-only methods narrowly authorized.
Patch sketch:
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
pub fn update_reserve_configuration(...) -> Result<(), KineticRouterError> {
+ if storage::is_protocol_locked(&env) { return Err(KineticRouterError::ReentrancyDetected); }
...
}
QA-03: Router close-factor arithmetic can overflow on large selected debt
Assessment The router close-factor helper computes maxliquidatabledebt as individualdebtbase * closefactor / 10000 using checked u128 multiplication. For a large but representable selected debt base value above u128::MAX / closefactor, the multiplication overflows even though the final close-factor result would be at most the selected debt value and would fit in u128.
Failure mode. validateclosefactor uses narrow u128 intermediate arithmetic for percentage multiplication instead of U256 or a divide-first/remainder-safe percentage helper.
Trigger path. A borrower has a selected debt position whose normalized base value is representable as u128 but greater than u128::MAX divided by the applicable close factor, and a direct liquidation, staged preparation, or staged execution reaches close-factor validation.
Why it matters. Liquidation or staged liquidation authorization/execution can fail with MathOverflow before evaluating an otherwise valid close-factor amount. This can make very large selected debt positions harder to liquidate until arithmetic is widened or the position changes.
Evidence. contracts/kinetic-router/src/liquidation.rs:28-32; contracts/kinetic-router/src/liquidation.rs:224-237; contracts/kinetic-router/src/router.rs:574-611,831-855
Code context:
// contracts/kinetic-router/src/liquidation.rs:28-32
let max_liquidatable_debt = individual_debt_base
.checked_mul(close_factor)
.ok_or(KineticRouterError::MathOverflow)?
.checked_div(BASIS_POINTS_MULTIPLIER)
.ok_or(KineticRouterError::MathOverflow)?;
// contracts/kinetic-router/src/liquidation.rs:224-237
// C-01 / WP-M2: Close factor validation
let individual_debt_base = calculation::value_in_base(
env, safe_i128_to_u128(env, debt_balance), debt_price, oracle_to_wad, debt_decimals_pow,
)?;
let individual_collateral_base = calculation::value_in_base(
env, user_collateral_balance, collateral_price, oracle_to_wad, collateral_decimals_pow,
)?;
let debt_to_cover_base = calculation::value_in_base(
env, debt_to_cover, debt_price, oracle_to_wad, debt_decimals_pow,
)?;
validate_close_factor(
env, user_account_data.health_factor,
individual_debt_base, individual_collateral_base, debt_to_cover_base,
)?;
Recommended change Compute maxliquidatabledebt with U256 intermediate arithmetic, or use a percentage-multiplication helper that cannot overflow when the final value is bounded by the input debt value.
Patch sketch:
diff --git a/contracts/kinetic-router/src/liquidation.rs b/contracts/kinetic-router/src/liquidation.rs
@@
- let max_liquidatable_debt = individual_debt_base.checked_mul(close_factor)?.checked_div(BASIS_POINTS_MULTIPLIER)?;
+ let max_liquidatable_debt = U256::from_u128(env, individual_debt_base)
+ .mul(&U256::from_u128(env, close_factor))
+ .div(&U256::from_u128(env, BASIS_POINTS_MULTIPLIER))
+ .to_u128().ok_or(KineticRouterError::MathOverflow)?;
QA-04: configure_asset_rewards does not validate distribution_end > current_timestamp
Assessment
IncentivesContract::configure_asset_rewards (incentives/contract.rs:531-586) accepts any distribution_end value including past timestamps. Setting distribution_end to a past timestamp creates a config where is_active=true but no rewards will ever accrue (effectivetime always evaluates to 0 in `updateassetrewardindex`). This is a no-op config that wastes gas on storage writes.
Evidence. contracts/incentives/src/contract.rs:538
Code context:
// contracts/incentives/src/contract.rs:531-544
pub fn configure_asset_rewards(
env: Env,
caller: Address,
asset: Address,
reward_token: Address,
reward_type: u32,
emission_per_second: u128,
distribution_end: u64,
) -> Result<(), IncentivesError> {
// Validate caller
storage::validate_emission_manager(&env, &caller)?;
caller.require_auth();
// Validate reward type
// ...
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Validate distribution_end == 0 || distribution_end > env.ledger().timestamp().
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
if reward_type != storage::REWARD_TYPE_SUPPLY && reward_type != storage::REWARD_TYPE_BORROW {
return Err(IncentivesError::InvalidRewardType);
}
+ if distribution_end != 0 && distribution_end <= env.ledger().timestamp() {
+ return Err(IncentivesError::InvalidDistributionEnd);
+ }
QA-05: Health factor and account-data threshold can be overstated by rounded weighted thresholds
Assessment The shared account-data helper accumulates collateral liquidation thresholds as a weighted sum, then rounds the average liquidation threshold upward. The exact weighted health factor should use the weighted sum directly, and the standalone account-data threshold field should not expose the ceiled average as the weighted threshold. Whenever weightedthresholdsum is not evenly divisible by totalcollateralbase, including mixed-threshold collateral portfolios and single-collateral positions with fractional base-unit collateral value, the rounded-average path can report a health factor or currentliquidationthreshold at or above the exact weighted value.
Failure mode. calculateuseraccountdataunified converts weightedthresholdsum into ceil(weightedthresholdsum / totalcollateralbase). Boundary callers trust the reconstructed health factor for liquidation eligibility, collateral-disable safety, borrow safety, and close-factor mode selection, and the public UserAccountData.currentliquidationthreshold field returns the same ceiled average directly.
Trigger path. A borrower has collateral for which weightedthresholdsum is not evenly divisible by totalcollateralbase, including either mixed-threshold collateral portfolios or a single collateral position whose exact base-unit value makes the quotient fractional, and the account is positioned close to the configured liquidation or partial-liquidation health-factor boundary. For the borrow path, the configured health-factor threshold must be the binding check after other borrow limits.
Why it matters. Solvency can be overstated by less than one liquidation-threshold basis point of collateral value. Near-boundary positions can avoid direct or staged liquidation, pass collateral-disable or borrow checks that should fail under exact weighted math, or be treated as default-close-factor eligible when exact math would allow full close. The public account-data view can also expose both an overstated health factor and an overstated currentliquidationthreshold to users, dashboards, and integrators. The per-position error is bounded but repeatable across core solvency paths and observable account-data outputs.
Evidence. contracts/kinetic-router/src/views.rs:5-10, contracts/kinetic-router/src/calculation.rs:284-397,419-440, contracts/kinetic-router/src/liquidation.rs:12-27,179-184,224-238, contracts/kinetic-router/src/router.rs:503-516,793-803,1290-1295, contracts/kinetic-router/src/validation.rs:208-218
Code context:
// contracts/kinetic-router/src/calculation.rs:284-297
let threshold_u256 = U256::from_u128(env, liquidation_threshold);
let weighted_threshold_value = balance_u256
.mul(&price_u256)
.mul(&oracle_to_wad_u256)
.mul(&threshold_u256)
.div(&decimals_pow_u256)
.to_u128()
.ok_or(KineticRouterError::MathOverflow)?;
weighted_threshold_sum = weighted_threshold_sum
.checked_add(weighted_threshold_value)
.ok_or(KineticRouterError::MathOverflow)?;
let ltv_u256 = U256::from_u128(env, ltv);
// ...
Recommended change Do not reconstruct health factor from a ceiled average threshold. Compute health factor directly as weightedthresholdsum * WAD / 10000 / totaldebtbase for account-data decisions, and expose a separately documented rounded display threshold only if needed. If currentliquidationthreshold remains an account-data field, make its rounding explicit and ensure it cannot be mistaken for an exact or borrower-favorable weighted threshold. Use the same exact weighted value for close-factor mode checks.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- let health_factor = calculate_health_factor_u256(env, total_collateral_base, current_liquidation_threshold, total_debt_base);
+ let health_factor = U256::from_u128(env, weighted_threshold_sum)
+ .mul(&U256::from_u128(env, WAD))
+ .div(&U256::from_u128(env, BASIS_POINTS))
+ .div(&U256::from_u128(env, total_debt_base))
+ .to_u128().unwrap_or(u128::MAX);
QA-06: Reserve access-list updates can strand existing positions
Assessment Reserve whitelist and blacklist setters replace the live access lists without checking whether affected addresses already hold aToken collateral or variable debt. The same reserve whitelist/blacklist validators are then applied to exits and wind-down paths: withdraw validates the caller and third-party recipient, repay validates the payer and still requires the borrower to remain whitelisted on third-party repayments, collateral toggles validate the caller before disabling collateral, and collateral swaps validate the caller against both source and destination reserves.
Failure mode. Reserve access lists are modeled as a generic interact-with-reserve gate and are applied equally to entry and exit paths, while setreservewhitelist and setreserveblacklist have no active-position check, wind-down mode, or exit-only exception. Repay has an explicit exception for blacklisted borrowers on third-party repayment, but no matching exception for borrowers removed from a non-empty whitelist.
Trigger path. A user supplies collateral or borrows from a reserve while allowed by the reserve lists; later the pool admin replaces the reserve whitelist so the user is omitted, or adds the user to the reserve blacklist; the user still needs to withdraw, disable collateral, self-repay, or be repaid by a third party.
Why it matters. Existing reserve positions can become operationally stranded. A debt-free supplier removed from the whitelist cannot withdraw through the router, and if blacklisted also cannot transfer aTokens to an allowed recipient because token transfers check sender and recipient blacklist status. A borrower removed from the whitelist cannot self-repay and cannot be repaid by a third party, because repay requires onbehalfof to pass the whitelist check; liquidation does not help while the account remains healthy. Collateral swap and collateral-disable wind-down routes are also blocked by the same live access gates. The effect is bounded by admin-controlled list updates, but it can deny normal exits and keep debt accruing until governance restores access.
Evidence. contracts/kinetic-router/src/accesscontrol.rs:21; contracts/kinetic-router/src/accesscontrol.rs:100; contracts/kinetic-router/src/operations.rs:132; contracts/kinetic-router/src/operations.rs:450; contracts/kinetic-router/src/router.rs:1256; contracts/kinetic-router/src/swap.rs:62
Code context:
// contracts/kinetic-router/src/access_control.rs:21-21
pub fn set_reserve_whitelist(
// contracts/kinetic-router/src/access_control.rs:100-100
pub fn set_reserve_blacklist(
Recommended change On reserve whitelist/blacklist updates, reject changes that would newly block addresses with active aToken or debt positions, or implement explicit wind-down semantics. At minimum, allow exit-only actions for existing positions: full or safe withdrawal to an allowed recipient, collateral disable when health factor remains safe, source-asset collateral swaps that reduce risk, and repayment by or for an existing borrower even after whitelist removal. Keep any blacklist exception narrowly scoped and documented.
Patch sketch:
diff --git a/contracts/kinetic-router/src/access_control.rs b/contracts/kinetic-router/src/access_control.rs
@@
- storage::set_reserve_blacklist(&env, &asset, &blacklist);
+ ensure_no_active_position_blocked(&env, &asset, &blacklist)?;
+ storage::set_reserve_blacklist(&env, &asset, &blacklist);
QA-07: Action finalizers can overwrite in-flight reserve config changes
Assessment supply, withdraw, borrow, repay, swapcollateral, and staged executeliquidation snapshot ReserveData before one or more external token, DEX, incentive, or callback calls, then later pass the same cached struct into updateinterestratesandstore. That helper clones the cached ReserveData, updates only currentliquidityrate and currentvariableborrow_rate, and stores the whole struct. If an authorized reserve configuration or rate-strategy change for the same reserve is executed during an external callback before finalization, the action can write the older cached configuration back over the admin change while still storing fresh-looking rates.
Failure mode. Reserve accounting fields and admin-controlled configuration share one ReserveData object, and action finalization writes a whole cloned ReserveData instead of merging freshly loaded configuration and strategy fields. The reserve config/admin entrypoints are not blocked by PROTOCOL_LOCKED, while user actions perform external calls before their final reserve-data stores.
Trigger path. A supply, withdraw, borrow, repay, swapcollateral, or staged executeliquidation call is in progress and reaches an external callback path, such as an underlying token, aToken/debtToken incentive hook, custom swap handler, DEX/pair/token, flash-liquidation callback, or other downstream contract; during that callback, an authorized pool admin, emergency admin, or pool-configurator path updates the affected reserve configuration, caps, lifecycle flags, reserve factor, min remaining debt, or interest-rate strategy; the original action then reaches updateinterestratesandstore for that same reserve.
Why it matters. The in-flight admin update can be lost. A reserve pause, freeze, deactivation, cap/risk change, reserve-factor change, min-debt change, or rate-strategy update can be replaced with the pre-action value when the user action stores interest rates. This weakens emergency/configuration controls for transactions that interleave authorized config mutation with active user flows and can leave emitted admin events inconsistent with final stored reserve state.
Evidence. contracts/kinetic-router/src/operations.rs:34-115,149-280,309-432,469-574; contracts/kinetic-router/src/swap.rs:71-99,206-212,351-367; contracts/kinetic-router/src/router.rs:949-982,987-1146,1445-1552; contracts/kinetic-router/src/flash_loan.rs:345-516; contracts/kinetic-router/src/calculation.rs:1000-1024
Code context:
// contracts/kinetic-router/src/operations.rs:34-47
// F-01/F-03
let reserve_data = storage::get_reserve_data(&env, &asset)?;
validation::validate_supply(&env, amount, &reserve_data)?;
let updated_reserve_data = calculation::update_state(&env, &asset, &reserve_data)?;
// Prevent supply to aToken contract to avoid circular ownership and accounting confusion
if on_behalf_of == updated_reserve_data.a_token_address {
panic_with_error!(&env, OperationError::RecipientIsAToken);
}
// Prevent supply to debt token contract
if on_behalf_of == updated_reserve_data.debt_token_address {
panic_with_error!(&env, OperationError::RecipientIsDebtToken);
}
// ...
// contracts/kinetic-router/src/swap.rs:71-84
let from_reserve_data = storage::get_reserve_data(&env, &from_asset)?;
let to_reserve_data = storage::get_reserve_data(&env, &to_asset)?;
let mut user_config = storage::get_user_configuration(&env, &caller);
if !from_reserve_data.configuration.is_active() {
return Err(KineticRouterError::AssetNotActive);
}
if !to_reserve_data.configuration.is_active() {
return Err(KineticRouterError::AssetNotActive);
}
// Check reserve-level pause
if from_reserve_data.configuration.is_paused() {
return Err(KineticRouterError::AssetPaused);
// ...
Recommended change Before storing rates at the end of supply, withdraw, borrow, repay, swapcollateral, staged executeliquidation, and other action finalizers, reload current ReserveData and merge only the intended index/rate/timestamp fields, or split mutable accounting state from immutable/admin configuration. Also make reserve config/admin writers reject while PROTOCOL_LOCKED is true unless explicitly designed as safe during guarded flows.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- storage::set_reserve_data(env, asset, &updated_data);
+ let mut current = storage::get_reserve_data(env, asset)?;
+ current.liquidity_index = updated_data.liquidity_index;
+ current.variable_borrow_index = updated_data.variable_borrow_index;
+ current.current_liquidity_rate = updated_data.current_liquidity_rate;
+ current.current_variable_borrow_rate = updated_data.current_variable_borrow_rate;
+ current.last_update_timestamp = updated_data.last_update_timestamp;
+ storage::set_reserve_data(env, asset, ¤t);
QA-08: Health-factor, borrow-capacity, and account-data views floor existing debt values
Assessment Shared solvency and account-data paths convert each live debt balance into normalized base value with floor division before using totaldebtbase as a health-factor denominator, borrow-capacity subtraction, withdrawal no-debt shortcut, or public account-data output. When exact debt has a fractional base-unit remainder, the floored denominator can make a near-boundary account appear safe, non-liquidatable, below LTV capacity, above the partial-liquidation trigger, or healthier/more borrow-capable in public account data than exact debt math supports. In the withdrawal validator, a positive live debt that floors to 0 base units is treated as debt-free and bypasses the post-withdraw health-factor check entirely.
Failure mode. calculateuseraccountdataunified and validateswaphealthfactor floor debtbalance * price * oracletowad / decimalspow for existing live debt. Boundary decisions and public account-data fields then treat the floor-rounded totaldebtbase as exact even though debt is an upper-bound risk value that should be exact or conservatively rounded. validateusercanwithdraw additionally uses that rounded field for its debt-free early return.
Trigger path. A borrower has live debt whose price and reserve-decimal conversion leaves a nonzero normalized base-value remainder, and the account is positioned close to a liquidation, borrow-capacity, withdrawal, swap, collateral-disable, or partial-liquidation threshold boundary. The strongest withdrawal case is positive live debt whose normalized base value floors to zero before the validator’s totaldebtbase == 0 branch.
Why it matters. A borrower can be treated as safe or below capacity even though exact debt value crosses the boundary. For withdrawals, positive debt that rounds to zero base units can bypass health-factor validation and allow collateral removal while live debt remains. Public account-data views can also underreport totaldebtbase and overreport healthfactor or availableborrows_base. The error is bounded below one normalized base unit per converted debt reserve, but it is systematic across core solvency paths and observable account-data outputs.
Evidence. contracts/kinetic-router/src/calculation.rs:344-397,667-839; contracts/kinetic-router/src/validation.rs:191-218,260-263,307-321; contracts/kinetic-router/src/liquidation.rs:179-184; contracts/kinetic-router/src/router.rs:503-516,793-803,1290-1295
Code context:
// contracts/kinetic-router/src/calculation.rs:316-329
let current_borrow_index =
get_current_variable_borrow_index_with_data(env, &reserve_data)?;
let mut args = soroban_sdk::vec![env, user.clone().into_val(env)];
args.push_back(current_borrow_index.into_val(env));
match env.try_invoke_contract::<i128, KineticRouterError>(
&reserve_data.debt_token_address,
&sym_balance,
args,
) {
Ok(Ok(bal)) => safe_i128_to_u128(env, bal),
Ok(Err(_)) | Err(_) => {
return Err(KineticRouterError::TokenCallFailed);
}
}
// ...
Recommended change Compute boundary health factors, borrow-capacity checks, and public account-data debt-derived fields from exact rational debt values or conservatively ceil debt-side normalized base conversions. Keep any floor-rounded debt display values separate from enforceable risk checks, and document explicit rounding if the public field intentionally rounds.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- let value_base = balance_u256.mul(&price_u256).mul(&oracle_to_wad_u256).div(&decimals_pow_u256)
+ let value_base = div_ceil_u256(
+ balance_u256.mul(&price_u256).mul(&oracle_to_wad_u256),
+ decimals_pow_u256,
+ )
.to_u128()
.ok_or(KineticRouterError::MathOverflow)?;
QA-09: Aquarius adapter does not have admin transfer mechanism
Assessment
AquariusSwapAdapter stores an Admin (lib.rs:58) but provides no function to transfer admin ownership. If the admin key is lost or compromised, the adapter cannot be reconfigured — set_router and register_pool become permanently inaccessible. The Soroswap adapter has the same limitation. Both adapters lack the two-step admin transfer pattern used elsewhere in the protocol (pool-configurator, treasury, interest-rate-strategy).
Evidence. contracts/aquarius-swap-adapter/src/lib.rs:44-64; contracts/soroswap-swap-adapter/src/lib.rs:60-84
Code context:
// contracts/aquarius-swap-adapter/src/lib.rs:51-63
pub fn initialize(env: Env, admin: Address, aquarius_router: Address) -> Result<(), Error> {
admin.require_auth();
if env.storage().instance().has(&DataKey::Initialized) {
return Err(Error::AlreadyInitialized);
}
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::Router, &aquarius_router);
env.storage().instance().set(&DataKey::Initialized, &true);
env.storage().instance().extend_ttl(TTL_THRESHOLD, TTL_EXTENSION);
Ok(())
// contracts/soroswap-swap-adapter/src/lib.rs:68-81
pub fn initialize(env: Env, admin: Address, router: Address, factory: Option<Address>) -> Result<(), Error> {
admin.require_auth();
if env.storage().instance().has(&DataKey::Initialized) {
return Err(Error::AlreadyInitialized);
}
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::Router, &router);
if let Some(f) = factory {
env.storage().instance().set(&DataKey::Factory, &f);
}
env.storage().instance().set(&DataKey::Initialized, &true);
env.storage().instance().extend_ttl(TTL_THRESHOLD, TTL_EXTENSION);
// ...
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Add a two-step propose_admin/accept_admin mechanism consistent with the rest of the protocol.
Patch sketch:
diff --git a/contracts/aquarius-swap-adapter/src/lib.rs b/contracts/aquarius-swap-adapter/src/lib.rs
@@
+ pub fn propose_admin(env: Env, caller: Address, pending: Address) -> Result<(), Error> {
+ require_admin(&env, &caller)?;
+ env.storage().instance().set(&DataKey::PendingAdmin, &pending);
+ Ok(())
+ }
+
+ pub fn accept_admin(env: Env, caller: Address) -> Result<(), Error> {
+ caller.require_auth();
+ require_pending_admin(&env, &caller)?;
+ env.storage().instance().set(&DataKey::Admin, &caller);
+ Ok(())
+ }
QA-10: Broken rate strategy contract permanently blocks all reserve operations including liquidations
Assessment
calculate_interest_rates_for_reserve (calculation.rs:984-992) uses try_invoke_contract to call the external rate strategy. On failure, it returns Err(InvalidAmount), which propagates through update_interest_rates_and_store to the caller. Since every state-changing operation (supply, withdraw, borrow, repay, swapcollateral, and liquidation) calls `updateinterestratesand_store` after completing its core logic, a broken or panicking rate strategy contract creates a complete DoS for the reserve.
Critically, this includes liquidations — both direct liquidation_call and staged execute_liquidation call update_interest_rates_and_store for both collateral and debt reserves. An underwater position in a reserve with a broken rate strategy cannot be liquidated, causing bad debt to accumulate without recourse.
Note: The root cause (unchecked rate strategy address) is also covered in QA-83/QA-02. This finding specifically highlights the liquidation-blocking impact cascade that makes the DoS particularly severe.
Evidence. contracts/kinetic-router/src/calculation.rs:984-992, 1000-1027; liquidation path at router.rs and liquidation.rs
Code context:
// contracts/kinetic-router/src/calculation.rs:984-992
let rates_result = env.try_invoke_contract::<CalculatedRates, KineticRouterError>(
&reserve_data.interest_rate_strategy_address,
&Symbol::new(env, "calculate_interest_rates"),
args,
);
let rates = match rates_result {
Ok(Ok(r)) => r,
Ok(Err(_)) | Err(_) => return Err(KineticRouterError::InvalidAmount),
// contracts/kinetic-router/src/calculation.rs:1014-1024
// Recalculate interest rates based on NEW utilization (after user action)
let (new_liquidity_rate, new_variable_borrow_rate) =
calculate_interest_rates_for_reserve(env, asset, reserve_data, known_a_scaled_total, known_debt_scaled_total)?;
// Create updated reserve data with new rates
let mut updated_data = reserve_data.clone();
updated_data.current_liquidity_rate = new_liquidity_rate;
updated_data.current_variable_borrow_rate = new_variable_borrow_rate;
// Store the updated state with new rates
storage::set_reserve_data(env, asset, &updated_data);
Review trail. Suboptimal design review (DTO-08 escalation)
Recommended change
On rate strategy failure, fall back to the previous stored rates rather than reverting. Alternatively, add a try_update_rates wrapper that stores the last known good rates on failure and emits a warning event.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- Ok(Err(_)) | Err(_) => return Err(KineticRouterError::InvalidAmount),
+ Ok(Err(_)) | Err(_) => {
+ env.events().publish((symbol_short!("ratefail"), asset.clone()), ());
+ return Ok((reserve_data.current_liquidity_rate, reserve_data.current_variable_borrow_rate));
+ }
QA-11: Treasury sync_balance overwrites internal balance unconditionally
Assessment
TreasuryContract::sync_balance (treasury/contract.rs:121-149) sets the internal balance to the actual token balance regardless of direction. If tokens are accidentally sent directly to the treasury (outside the deposit flow), sync_balance will increase the internal balance to match. This is the intended use case. However, if the treasury’s actual balance is lower than the internal balance (e.g., due to a token contract bug, rebase, or fee-on-transfer token), sync_balance silently decreases the internal balance, potentially masking lost funds.
Evidence. contracts/treasury/src/contract.rs:121-149
Code context:
// contracts/treasury/src/contract.rs:137-145
match balance_result {
Ok(Ok(balance)) => {
if balance < 0 {
panic_with_error!(&env, TreasuryError::InvalidAmount);
}
// S-04
let balance_u128 = safe_i128_to_u128(&env, balance);
storage::set_balance(&env, &asset, balance_u128)?;
Ok(balance_u128)
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Consider emitting a warning event when sync_balance decreases the internal balance below its previous value.
Patch sketch:
diff --git a/contracts/treasury/src/contract.rs b/contracts/treasury/src/contract.rs
@@
- storage::set_balance(&env, &asset, balance_u128)?;
+ let previous = storage::get_balance(&env, &asset);
+ if balance_u128 < previous { events::publish_balance_decrease(&env, &asset, previous, balance_u128); }
+ storage::set_balance(&env, &asset, balance_u128)?;
QA-12: Liquidation gates floor borrower collateral support
Assessment Liquidation eligibility and close-factor mode checks consume weighted collateral support after floor division. If exact weighted support is fractionally above a liquidation or partial-liquidation threshold, the floor-rounded support can report the borrower below the boundary and allow liquidation or a larger close factor.
Failure mode. calculateuseraccountdataunified floors each collateral contribution as balance * price * oracletowad * liquidationthreshold / decimalspow before the result is used in health-factor boundary decisions. That borrower-adverse truncation is not separated from enforceable liquidation checks.
Trigger path. A borrower has collateral whose amount, price, oracle precision, liquidation threshold, and decimals produce a nonzero weighted-support remainder, and the account sits near the liquidation or partial-liquidation health-factor boundary.
Why it matters. An exactly safe near-boundary account can be treated as liquidatable, or an ordinary partial-liquidation account can be treated as full-close eligible. The numerical error is bounded below one weighted-support unit per collateral reserve, but it affects borrower collateral safety at liquidation boundaries.
Evidence. contracts/kinetic-router/src/calculation.rs:260-297,369-397; contracts/kinetic-router/src/liquidation.rs:179-184,231-237; contracts/kinetic-router/src/router.rs:503-516,604-611,793-803,845-855
Code context:
// contracts/kinetic-router/src/calculation.rs:260-273
.ok_or(KineticRouterError::PriceOracleNotFound)?;
if asset_price == 0 {
return Err(KineticRouterError::PriceOracleNotFound);
}
let liquidation_threshold =
reserve_data.configuration.get_liquidation_threshold() as u128;
let ltv = reserve_data.configuration.get_ltv() as u128;
let balance_u256 = U256::from_u128(env, collateral_balance);
let price_u256 = U256::from_u128(env, asset_price);
let value_base = balance_u256
// ...
// contracts/kinetic-router/src/liquidation.rs:179-184
let result = calculation::calculate_user_account_data_unified(env, &user, params)?;
let user_account_data = result.account_data;
let mut balance_cache = result.balance_cache;
if user_account_data.health_factor >= WAD {
return Err(KineticRouterError::InvalidLiquidation);
Recommended change Use exact rational weighted collateral support for liquidation boundary checks, or round borrower collateral support in a direction that cannot make an exactly safe account liquidatable. Keep floor-rounded display or conservative view values separate from enforceable liquidation permissions.
Patch sketch:
diff --git a/contracts/kinetic-router/src/liquidation.rs b/contracts/kinetic-router/src/liquidation.rs
@@
- if user_account_data.health_factor >= WAD {
+ if !is_liquidatable_exact(env, &user_account_data, &weighted_threshold_sum)? {
return Err(KineticRouterError::InvalidLiquidation);
}
QA-13: Oracle circuit-breaker deviation math can overflow before division
Assessment The price oracle circuit breaker computes deviation as abs(newprice - lastprice) * 10000 / lastprice using u128 checked multiplication before the division. High but representable prices can have an exact basis-point deviation inside maxpricechangebps, including the default 2000 bps threshold, while the intermediate product overflows and rejects the price as MathOverflow.
Failure mode. calculatepricedeviation_bps uses narrow u128 multiplication for basis-point deviation math and the oracle does not bound accepted PriceData.price or manual override prices to the smaller arithmetic domain required by diff * 10000.
Trigger path. The circuit breaker is enabled; LastPrice stores a high nonzero baseline; a later Reflector, fallback, custom, batch, or manual-override price is also representable and nonzero; the exact deviation is within the configured threshold but abs(newprice - lastprice) exceeds u128::MAX / 10000.
Why it matters. Valid in-threshold price updates can fail with MathOverflow, leaving single-asset reads, batch reads, manual overrides, refreshes, and downstream price-dependent router checks unavailable for the affected high-price asset until governance resets the circuit breaker, disables the threshold, or changes the price domain. The trigger requires an extreme price domain or privileged/source configuration, so severity is low.
Evidence. contracts/price-oracle/src/oracle.rs:238-247; contracts/price-oracle/src/contract.rs:858-867
Code context:
// contracts/price-oracle/src/oracle.rs:238-247
let diff = price1.abs_diff(price2);
let scaled_diff = diff
.checked_mul(10_000)
.ok_or(crate::OracleError::MathOverflow)?;
// price2 != 0 is guaranteed by the guard above
let deviation = scaled_diff / price2;
u32::try_from(deviation).map_err(|_| crate::OracleError::MathOverflow)
// contracts/price-oracle/src/contract.rs:858-867
if let Some(last_price) = storage::get_last_price(env, asset) {
// Defensive check: zero price indicates uninitialized state
if last_price == 0 {
return Ok(());
}
let deviation = oracle::calculate_price_deviation_bps(*new_price, last_price)?;
if deviation > oracle_config.max_price_change_bps {
return Err(OracleError::PriceChangeTooLarge);
Recommended change Compute the deviation with U256 or compare abs(new - last) * 10000 <= maxpricechange_bps * last using a wide intermediate. Alternatively reject or normalize oracle/manual-override prices above the circuit-breaker-supported arithmetic domain before storing or validating them.
Patch sketch:
diff --git a/contracts/price-oracle/src/oracle.rs b/contracts/price-oracle/src/oracle.rs
@@
- let deviation = diff.checked_mul(10000)?.checked_div(price2)?;
+ let deviation = U256::from_u128(env, diff)
+ .mul(&U256::from_u128(env, 10000))
+ .div(&U256::from_u128(env, price2))
+ .to_u128().ok_or(OracleError::MathOverflow)?;
QA-14: collect_protocol_reserves can drain underlying needed for withdrawal during utilization spikes
Assessment
collect_protocol_reserves (treasury.rs:5–89) computes available_reserves = underlying_balance - (total_supply - total_borrow) and transfers the full amount to treasury. It does not account for the fact that total_supply - total_borrow represents the theoretical withdrawable amount, but actual withdrawals require underlying tokens to be present in the aToken contract. During high utilization, if accrued interest pushes underlying_balance slightly above total_supply - total_borrow, the admin can extract those reserves, leaving zero margin for concurrent withdrawals until new repayments arrive.
While the function subtracts deficit, it does not account for pending withdrawal demands or utilization ratio.
Review trail. Generic code-level pass — treasury.rs (router module)
Code context:
// contracts/kinetic-router/src/treasury.rs:47-56
let available_liquidity = total_supply - total_borrow;
let available_reserves = if underlying_balance > available_liquidity {
underlying_balance - available_liquidity
} else {
return Ok(0); // No reserves available
};
// Subtract any uncovered deficit to prevent draining liquidity depositors need
let deficit = storage::get_reserve_deficit(&env, &asset);
let collectible_reserves = available_reserves.saturating_sub(deficit);
// contracts/kinetic-router/src/treasury.rs:65-76
// Transfer reserves from aToken to treasury
// Use same pattern as liquidation_call for consistency
let mut transfer_args = Vec::new(&env);
transfer_args.push_back(env.current_contract_address().into_val(&env));
transfer_args.push_back(treasury.to_val());
transfer_args.push_back(collectible_reserves.into_val(&env));
let transfer_result = env.try_invoke_contract::<bool, KineticRouterError>(
&reserve_data.a_token_address,
&Symbol::new(&env, "transfer_underlying_to"),
transfer_args,
);
Recommended change Leave a minimum buffer (e.g., based on current utilization ratio) in the aToken contract when collecting reserves, or restrict collection when utilization exceeds a threshold.
Patch sketch:
diff --git a/contracts/kinetic-router/src/treasury.rs b/contracts/kinetic-router/src/treasury.rs
@@
- let collectible_reserves = available_reserves.saturating_sub(deficit);
+ let liquidity_buffer = available_liquidity.checked_mul(MIN_LIQUIDITY_BUFFER_BPS)?.checked_div(BASIS_POINTS)?;
+ let collectible_reserves = available_reserves.saturating_sub(deficit).saturating_sub(liquidity_buffer);
QA-15: Pool configurator initialize sets both pool_admin and emergency_admin to the same address
Assessment
PoolConfiguratorContract::initialize (pool-configurator/contract.rs:14-32) sets both pool_admin and emergency_admin to the same pool_admin address. The separation of concerns between pool administration and emergency actions (pause) is only effective if these roles are held by different entities. Initializing them identically means a compromised pool admin key can both configure reserves and trigger emergency pauses, defeating the purpose of role separation.
No set_emergency_admin function is exposed in the configurator to change the emergency admin post-deployment.
Evidence. contracts/pool-configurator/src/contract.rs:26
Code context:
// contracts/pool-configurator/src/contract.rs:24-29
crate::upgrade::initialize_admin(&env, &pool_admin);
storage::set_pool_admin(&env, &pool_admin);
storage::set_emergency_admin(&env, &pool_admin);
storage::set_kinetic_router(&env, &kinetic_router);
storage::set_price_oracle(&env, &price_oracle);
storage::set_initialized(&env);
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Accept emergency_admin as a separate initialization parameter, or add a set_emergency_admin function.
Patch sketch:
diff --git a/contracts/pool-configurator/src/contract.rs b/contracts/pool-configurator/src/contract.rs
@@
- price_oracle: Address,
+ price_oracle: Address,
+ emergency_admin: Address,
@@
- storage::set_emergency_admin(&env, &pool_admin);
+ storage::set_emergency_admin(&env, &emergency_admin);
QA-16: Pool configurator exposes no function to update the price oracle address post-deployment
Assessment
PoolConfiguratorContract::initialize (contract.rs:28) sets the price_oracle address once. Oracle view functions like get_oracle_asset_price delegate to this stored address. If the oracle contract needs to be replaced (e.g., migration, security incident), there is no set_price_oracle function.
The configurator does have set_kinetic_router via the upgrade path, but the oracle address is immutable post-initialization.
Evidence. contracts/pool-configurator/src/contract.rs:28; contracts/pool-configurator/src/storage.rs
Code context:
// contracts/pool-configurator/src/contract.rs:24-29
crate::upgrade::initialize_admin(&env, &pool_admin);
storage::set_pool_admin(&env, &pool_admin);
storage::set_emergency_admin(&env, &pool_admin);
storage::set_kinetic_router(&env, &kinetic_router);
storage::set_price_oracle(&env, &price_oracle);
storage::set_initialized(&env);
// contracts/pool-configurator/src/storage.rs:79-89
pub fn get_kinetic_router(env: &Env) -> Result<Address, KineticRouterError> {
extend_instance_ttl_if_needed(env);
env.storage()
.instance()
.get(&KINETIC_ROUTER)
.ok_or(KineticRouterError::NotInitialized)
}
pub fn set_kinetic_router(env: &Env, router: &Address) {
env.storage().instance().set(&KINETIC_ROUTER, router);
extend_instance_ttl_if_needed(env);
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Add a set_price_oracle function restricted to pool admin.
Patch sketch:
diff --git a/contracts/pool-configurator/src/contract.rs b/contracts/pool-configurator/src/contract.rs
@@
+ pub fn set_price_oracle(env: Env, caller: Address, price_oracle: Address) -> Result<(), KineticRouterError> {
+ caller.require_auth();
+ storage::validate_admin(&env, &caller)?;
+ storage::set_price_oracle(&env, &price_oracle);
+ Ok(())
+ }
QA-17: prepare_liquidation can store authorizations for unheld collateral or unwhitelisted handlers
Assessment
prepare_liquidation does not verify the borrower actively holds the selected collateral (only execute_liquidation checks), and stores unwhitelisted custom handlers that the callback will later reject with UnauthorizedAMM. It can also store min_swap_out > i128::MAX and collateral_to_seize outside the callback’s signed settlement domain.
Traceability. OBL-628–OBL-630, OBL-634
Code context:
// contracts/kinetic-router/src/router.rs:665-678
let auth = storage::LiquidationAuthorization {
liquidator: liquidator.clone(),
user: user.clone(),
debt_asset: debt_asset.clone(),
collateral_asset: collateral_asset.clone(),
debt_to_cover: effective_debt_to_cover,
collateral_to_seize: computed_collateral_to_seize,
min_swap_out,
debt_price: debt_price_data.price,
collateral_price: collateral_price_data.price,
health_factor_at_prepare: user_account_data.health_factor,
expires_at,
nonce,
swap_handler,
// ...
Recommended change Validate selected collateral existence, handler whitelist membership, and amount representability during preparation.
Patch sketch:
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
- let auth = storage::LiquidationAuthorization { ... swap_handler, ... };
+ validate_user_collateral_balance(&env, &user, &collateral_reserve_data)?;
+ if let Some(handler) = &swap_handler { validation::validate_swap_handler(&env, handler)?; }
+ safe_u128_to_i128(&env, min_swap_out);
+ let auth = storage::LiquidationAuthorization { ... swap_handler, ... };
QA-18: Staged liquidation swap minimums round down
Assessment Staged liquidation minimum-output calculations floor lower bounds. The helper computes poolminout with floor division, executeliquidation floors proportional auth.minswapout when collateral capping reduces the swap input, and executeliquidation_callback floors the oracle-derived BPS minimum. Whenever the exact lower bound has a fractional debt-asset base unit, helper-derived or settlement-enforced minimums can be one base unit lower than the intended proportional/user/protocol slippage floor.
Failure mode. Minimum-output thresholds are computed with floor division even though preserving a lower bound requires ceiling division for fractional token base-unit results.
Trigger path. A staged liquidation uses the helper-derived poolminout, hits collateral-cap min-output scaling, or computes an oracle-minimum whose rational result is not exactly divisible; the DEX or handler returns output at the floored threshold.
Why it matters. The liquidator or protocol slippage guard can be underenforced by less than one debt-asset base unit per staged liquidation, and helper-generated minimums can carry the same one-unit downward bias into prepare_liquidation. The effect is small but systematic and weakens explicit minimum-output protections.
Evidence. contracts/kinetic-router/src/router.rs:936-947; contracts/kinetic-router/src/flash_loan.rs:465-484; contracts/flash-liquidation-helper/src/validation.rs:111-113
Code context:
// contracts/kinetic-router/src/router.rs:936-947
// Step 6: Set up callback params for flash loan
// S-01: Scale min_swap_out proportionally when collateral cap reduces amounts
let adjusted_min_swap_out = if collateral_cap_triggered {
let mso = U256::from_u128(&env, auth.min_swap_out);
let acs = U256::from_u128(&env, actual_collateral_to_seize);
let scs = U256::from_u128(&env, safe_collateral_to_seize);
mso.mul(&acs).div(&scs)
.to_u128()
.ok_or(KineticRouterError::MathOverflow)?
} else {
auth.min_swap_out
};
// contracts/kinetic-router/src/flash_loan.rs:465-478
// M-05: Enforce oracle-based slippage minimum
// Convert collateral value to expected debt units, accounting for decimal differences.
// Formula: actual_collateral * collateral_price * debt_decimals_pow * min_swap_bps
// / (debt_price * collateral_decimals_pow * BASIS_POINTS_MULTIPLIER)
let min_swap_bps = storage::get_min_swap_output_bps(&env);
let collateral_decimals_pow = collateral_reserve_data.configuration.get_decimals_pow()?;
let debt_decimals_pow = debt_reserve_data.configuration.get_decimals_pow()?;
let oracle_min_out = {
let cs = soroban_sdk::U256::from_u128(&env, actual_collateral);
let cp = soroban_sdk::U256::from_u128(&env, params.collateral_price);
let ddp = soroban_sdk::U256::from_u128(&env, debt_decimals_pow);
let bps = soroban_sdk::U256::from_u128(&env, min_swap_bps);
let dp = soroban_sdk::U256::from_u128(&env, params.debt_price);
let cdp = soroban_sdk::U256::from_u128(&env, collateral_decimals_pow);
// ...
Recommended change Use ceiling division for helper poolminout, cap-scaled minswapout, and the oracle-derived minimum output, or document and enforce an explicit rounding tolerance if the protocol intentionally permits one base unit of underenforcement.
Patch sketch:
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
- mso.mul(&acs).div(&scs)
+ mso.mul(&acs).add(&scs).sub(&U256::from_u128(&env, 1u128)).div(&scs)
.to_u128()
.ok_or(KineticRouterError::MathOverflow)?
QA-19: Custom/batch oracle setters overwrite shared max_age used by the other source
Assessment
set_custom_oracle writes config.max_age which is also consumed by the batch adapter path, and vice versa for set_batch_oracle. Updating one source’s freshness window silently changes the other’s staleness policy.
Traceability. OBL-713, OBL-714
Code context:
// contracts/price-oracle/src/contract.rs:230-235
let mut config = storage::get_asset_config(&env, &asset)
.ok_or(OracleError::AssetNotWhitelisted)?;
config.custom_oracle = oracle.clone();
config.max_age = max_age_seconds;
config.oracle_decimals = decimals;
storage::set_asset_config(&env, &asset, &config);
// contracts/price-oracle/src/contract.rs:665-671
let mut config = storage::get_asset_config(&env, &asset)
.ok_or(OracleError::AssetNotWhitelisted)?;
config.batch_adapter = adapter.clone();
config.feed_id = feed_id.clone();
config.oracle_decimals = decimals;
config.max_age = max_age_seconds;
storage::set_asset_config(&env, &asset, &config);
Recommended change
Store separate max_age fields per oracle source type.
Patch sketch:
diff --git a/contracts/shared/src/types.rs b/contracts/shared/src/types.rs
@@
- pub max_age: Option<u64>,
+ pub custom_max_age: Option<u64>,
+ pub batch_max_age: Option<u64>,
QA-20: Reward index updates can overflow at multiple arithmetic stages
Assessment
update_asset_reward_index has overflow potential at: (1) emission * elapsed product, (2) ray_div per-unit increment U256→u128 downcast, (3) current_index + increment accumulation, (4) per-user ray_mul(index_diff, balance) accrual, and (5) accrued.checked_add(new_accrued) bucket accumulation. All incentives errors are ignored by aToken/debtToken callbacks, so token operations commit while reward accounting silently stalls.
Traceability. OBL-743–OBL-750
Code context:
// contracts/incentives/src/calculation.rs:68-81
// Calculate reward increment: (emission_per_second × effective_time × RAY) / total_supply
// First multiply emission_per_second by effective_time
let emission_times_time = config.emission_per_second
.checked_mul(effective_time as u128)
.ok_or(IncentivesError::MathOverflow)?;
// Then multiply by RAY and divide by total_supply
// Using ray_div: (emission_times_time * RAY) / total_supply
let reward_increment = k2_shared::ray_div(
env,
emission_times_time,
total_supply,
).map_err(|_| IncentivesError::MathOverflow)?;
// ...
// contracts/incentives/src/calculation.rs:109-115
// Calculate index difference (current_index >= user_snapshot by design)
let index_diff = current_index.checked_sub(user_snapshot)
.ok_or(crate::error::IncentivesError::MathOverflow)?;
// Calculate accrued: (index_diff × user_balance) / RAY
k2_shared::ray_mul(env, index_diff, user_balance)
.map_err(|_| crate::error::IncentivesError::MathOverflow)
Recommended change Use saturating arithmetic or wider types for reward index accumulation, and propagate incentives errors to callers instead of silently ignoring them.
Patch sketch:
diff --git a/contracts/incentives/src/calculation.rs b/contracts/incentives/src/calculation.rs
@@
- let emission_times_time = config.emission_per_second.checked_mul(effective_time as u128)?;
- let reward_increment = k2_shared::ray_div(env, emission_times_time, total_supply)?;
+ let reward_increment = U256::from_u128(env, config.emission_per_second)
+ .mul(&U256::from_u128(env, effective_time as u128))
+ .mul(&U256::from_u128(env, RAY))
+ .div(&U256::from_u128(env, total_supply))
+ .to_u128().ok_or(IncentivesError::MathOverflow)?;
QA-21: Router aToken mints can credit less claim value than funded
Assessment Supply and collateral-swap destination settlement fund aToken custody with a nominal underlying amount before calling aToken mintscaled. The aToken mint floors the scaled amount with raydivdown(amount, index), so the live claim value credited can be lower than the transferred amount. On non-first-supply paths, tiny positive deposits or swap outputs can round to zero scaled units and still complete with no new claim minted. On first-supply paths, the router enforces MINFIRSTDEPOSIT against the nominal amount and then sets the recipient collateral bit from the returned isfirst flag, without checking that the post-mint scaled balance is positive; if a high liquidity index makes a nominal first supply round to zero scaled units, the recipient can receive a collateral bitmap entry with no aToken claim.
Failure mode. Router minting routes settle incoming underlying using nominal requested or swap-output amounts while the aToken credits a floor-rounded scaled amount. mint_scaled does not reject zero-scaled mints or return the actual live claim value minted for router reconciliation.
Trigger path. A supplier or collateral-swap caller submits or receives an amount that is not exactly representable as scaled aToken units at the current liquidity index. For zero-credit non-first direct supplies, the user already has a nonzero scaled balance so the first-supply minimum check is not triggered. For zero-credit first supplies, the nominal amount passes MINFIRSTDEPOSIT but floors to zero scaled units because the liquidity index is high relative to the amount. For swap destinations, the route does not enforce the first-supply minimum at all.
Why it matters. The user can fund more underlying than the live aToken claim value received. The difference remains as surplus underlying in aToken custody instead of being credited, refunded, or rejected, and it is included in the raw reserve surplus that collectprotocolreserves can transfer to the configured treasury. In the zero-scaled first-supply case, the router can also set the recipient collateral bit even though balanceofwith_index remains zero, consuming an active-reserve slot and leaving account-data and transfer-validation paths to iterate a collateral reserve with no backed user claim.
Evidence. contracts/kinetic-router/src/operations.rs:52-110; contracts/kinetic-router/src/swap.rs:292-325; contracts/a-token/src/contract.rs:225-251; contracts/shared/src/utils.rs:79-91; contracts/kinetic-router/src/treasury.rs:19-88
Code context:
// contracts/kinetic-router/src/operations.rs:69-82
// Mint aToken
let mut args = Vec::new(&env);
args.push_back(env.current_contract_address().into_val(&env));
args.push_back(on_behalf_of.to_val());
args.push_back(amount.into_val(&env));
args.push_back(updated_reserve_data.liquidity_index.into_val(&env));
let mint_result = env.try_invoke_contract::<(bool, i128, i128), KineticRouterError>(
&updated_reserve_data.a_token_address,
&Symbol::new(&env, "mint_scaled"),
args,
);
let is_first_supply = match mint_result {
// ...
// contracts/a-token/src/contract.rs:225-231
// M-14
let scaled_u128 = ray_div_down(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let amount_scaled = safe_u128_to_i128(&env, scaled_u128);
let current_scaled_balance = storage::get_scaled_balance(&env, &on_behalf_of);
let is_first_supply = current_scaled_balance == 0;
let new_scaled_balance = current_scaled_balance.checked_add(amount_scaled)
.ok_or(TokenError::TransferFailed)?;
Recommended change Reject zero-scaled mints and reconcile transferred underlying to the actual minted live claim value across all router mint routes before setting collateral state. Either transfer only the amount represented by scaled shares, refund the rounding remainder, or revert non-representable amounts. First-supply checks should use the resulting scaled balance or actual minted claim value, and reserve-sweep accounting should not classify mint-rounding remainders as protocol reserves unless the user was explicitly quoted and accepted that fee.
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- let scaled_u128 = ray_div_down(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
+ let scaled_u128 = ray_div_down(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
+ if scaled_u128 == 0 {
+ return Err(TokenError::InvalidAmount);
+ }
@@
- Ok((is_first_supply, new_scaled_balance, state.total_supply_scaled))
+ let minted_live = ray_mul(&env, scaled_u128, index).map_err(|_| TokenError::InvalidIndex)?;
+ Ok((is_first_supply, new_scaled_balance, state.total_supply_scaled, minted_live))
QA-22: Health-factor narrowing can fail overlarge healthy positions
Assessment Several router health-factor paths compute health factor with U256 but then narrow the final value to u128 even when callers only need to know that the account is safely above the liquidation boundary. For high-collateral positive-debt positions, the exact health factor can exceed u128::MAX while all inputs and boundary decisions remain meaningful.
Failure mode. Health-factor helpers and branch-local validators treat an overlarge healthy health factor as an arithmetic failure or as zero instead of saturating to an in-domain healthy sentinel or comparing in U256 against the configured threshold.
Trigger path. An account has positive debt and very large but representable collateral support such that exact collateral_support * WAD / 10000 / debt exceeds u128::MAX, while the relevant action or account-data view otherwise has representable collateral/debt values and should be classified as safely above the health-factor boundary.
Why it matters. Account-data calculation can panic or fail before returning risk data, borrow can fail its post-borrow health-factor simulation, withdraw and aToken sender transfer finalization can reject safe collateral reductions because the overlarge post-action health factor is converted to zero, and collateral swap can fail with MathOverflow even though exact post-swap health factor is above the enforced threshold.
Evidence. contracts/kinetic-router/src/calculation.rs:31-52,391-397,826-840; contracts/kinetic-router/src/validation.rs:208-218,307-323; contracts/kinetic-router/src/router.rs:1858-1865
Code context:
// contracts/kinetic-router/src/calculation.rs:31-44
pub(crate) fn calculate_health_factor_u256(
env: &Env,
total_collateral_base: u128,
current_liquidation_threshold: u128,
total_debt_base: u128,
) -> u128 {
if total_debt_base == 0 {
return u128::MAX; // No debt = infinitely healthy (correct)
}
let collateral = U256::from_u128(env, total_collateral_base);
let threshold = U256::from_u128(env, current_liquidation_threshold);
let wad = U256::from_u128(env, WAD);
let bps = U256::from_u128(env, 10000u128);
let debt = U256::from_u128(env, total_debt_base);
// ...
// contracts/kinetic-router/src/validation.rs:208-218
let new_health_factor = crate::calculation::calculate_health_factor_u256(
env,
user_account_data.total_collateral_base,
user_account_data.current_liquidation_threshold,
new_total_debt,
);
// Reject if the new health factor would be below the liquidation threshold
let liquidation_threshold = crate::storage::get_health_factor_liquidation_threshold(env);
if new_health_factor < liquidation_threshold {
return Err(KineticRouterError::HealthFactorTooLow);
Recommended change Keep health-factor boundary checks in U256, and only downcast after capping overlarge healthy values to u128::MAX for u128 return fields. Avoid using unwrap_or(0) for failed health-factor downcasts.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- .to_u128()
- .unwrap_or_else(|| panic_with_error!(env, KineticRouterError::MathOverflow))
+ .to_u128()
+ .unwrap_or(u128::MAX)
QA-23: Uninitialized interest-rate strategies silently quote zero rates
Assessment
calculate_interest_rates never checks INITIALIZED. When no per-asset override exists, get_interest_rate_params returns zero for every missing global parameter. An uninitialized strategy returns zero liquidity and variable-borrow rates as a valid quote, and the router stores those zero rates.
Traceability. OBL-834
Code context:
// contracts/interest-rate-strategy/src/contract.rs:42-55
/// Calculate interest rates based on supply and demand
pub fn calculate_interest_rates(
env: Env,
asset: Address,
available_liquidity: u128,
total_variable_debt: u128,
reserve_factor: u128,
) -> Result<CalculatedRates, KineticRouterError> {
// Validate reserve_factor is in valid basis points range (0-10000)
// Prevents underflow in RAY - reserve_factor_ray calculation
if reserve_factor > BASIS_POINTS {
return Err(KineticRouterError::InvalidAmount);
}
// ...
Recommended change
Add an INITIALIZED check in calculate_interest_rates and reject queries to uninitialized strategies.
Patch sketch:
diff --git a/contracts/interest-rate-strategy/src/contract.rs b/contracts/interest-rate-strategy/src/contract.rs
@@
pub fn calculate_interest_rates(env: Env, asset: Address, ...) -> Result<CalculatedRates, KineticRouterError> {
+ if !storage::is_initialized(&env) {
+ return Err(KineticRouterError::NotInitialized);
+ }
...
}
QA-24: Liquidation and swap routes bypass reserve-level blacklist checks for treasury/liquidator
Assessment Multiple liquidation settlement branches (receive-aToken, underlying, staged profit) and collateral-swap fee paths send tokens to the treasury or liquidator without checking the relevant reserve’s blacklist. A treasury or liquidator explicitly blacklisted for a specific reserve can still receive funds through these paths.
Traceability. OBL-753, OBL-764–OBL-772
Code context:
// contracts/kinetic-router/src/liquidation.rs:429-442
let actually_transferred_fee = if protocol_fee_collateral > 0 {
if let Some(treasury) = storage::get_treasury(env) {
let mut fee_xfer_args = Vec::new(env);
fee_xfer_args.push_back(IntoVal::into_val(&env.current_contract_address(), env));
fee_xfer_args.push_back(user.to_val());
fee_xfer_args.push_back(treasury.to_val());
fee_xfer_args.push_back(IntoVal::into_val(&protocol_fee_collateral, env));
fee_xfer_args.push_back(IntoVal::into_val(
&updated_collateral_reserve_data.liquidity_index,
env,
));
let fee_xfer_result = env.try_invoke_contract::<bool, KineticRouterError>(
&updated_collateral_reserve_data.a_token_address,
// ...
// contracts/kinetic-router/src/swap.rs:239-252
let mut fee_transfer_args = Vec::new(&env);
fee_transfer_args.push_back(pool_address.to_val());
fee_transfer_args.push_back(treasury.to_val());
fee_transfer_args.push_back(protocol_fee_i128.into_val(&env));
let fee_transfer_result = env.try_invoke_contract::<(), KineticRouterError>(
&to_asset,
&sym_transfer,
fee_transfer_args,
);
match fee_transfer_result {
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => {
// ...
Recommended change
Apply validate_reserve_blacklist_access for all recipient addresses on liquidation and swap fee settlement paths.
Patch sketch:
diff --git a/contracts/kinetic-router/src/liquidation.rs b/contracts/kinetic-router/src/liquidation.rs
@@
if let Some(treasury) = storage::get_treasury(env) {
+ validation::validate_reserve_blacklist_access(env, collateral_asset, &treasury)?;
transfer_fee_to_treasury(...)?;
}
+ validation::validate_reserve_blacklist_access(env, collateral_asset, liquidator)?;
QA-25: set_distribution_end does not freshen the reward index before changing end timestamp
Assessment
set_distribution_end (incentives/contract.rs:677-716) updates config.distribution_end without first calling update_asset_reward_index. If the new distribution end is earlier than the current timestamp and the old one was later, un-accrued rewards between last_update_timestamp and the new end will be lost. Conversely, set_emission_per_second (line 619-642) does freshen the index if config.is_active.
Evidence. contracts/incentives/src/contract.rs:677-716 vs :619-642
Code context:
// contracts/incentives/src/contract.rs:695-703
// Get existing configuration
let mut config = storage::get_asset_reward_config(&env, &asset, &reward_token, reward_type)
.ok_or(IncentivesError::AssetRewardConfigNotFound)?;
// Update distribution end
config.distribution_end = new_distribution_end;
// Save updated configuration
storage::set_asset_reward_config(&env, &asset, &reward_token, reward_type, &config)?;
// contracts/incentives/src/contract.rs:618-631
// N-12
if config.is_active {
// Get total supply for this asset
let total_supply = {
let mut args = Vec::new(&env);
match env.try_invoke_contract::<u128, IncentivesError>(
&asset,
&Symbol::new(&env, "scaled_total_supply"),
args,
) {
Ok(Ok(supply)) => supply,
_ => 0, // Skip update if query fails
}
};
// ...
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Freshen the global reward index before modifying distribution_end, similar to how set_emission_per_second does.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
- // Update distribution end
+ freshen_asset_reward_index(&env, &asset, &reward_token, reward_type, &config)?;
+ // Update distribution end
config.distribution_end = new_distribution_end;
QA-26: Liquidation engine execute_liquidation always passes _receive_a_token=false
Assessment
LiquidationEngineContract::execute_liquidation (liquidation-engine/contract.rs:49-97) accepts _receive_a_token: bool as a parameter but always passes false (line 78) to the router’s liquidation_call. The parameter is ignored. This makes the standalone liquidation engine incapable of performing receive-aToken liquidations, even though the parameter is exposed in the public API.
Evidence. contracts/liquidation-engine/src/contract.rs:78
Code context:
// contracts/liquidation-engine/src/contract.rs:49-62
pub fn execute_liquidation(
env: Env,
liquidator: Address,
collateral_asset: Address,
debt_asset: Address,
user: Address,
debt_to_cover: u128,
_receive_a_token: bool,
) -> Result<LiquidationCall, KineticRouterError> {
let current_timestamp = get_current_timestamp(&env);
validation::validate_liquidation_params(
&env,
&collateral_asset,
// ...
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Forward the _receive_a_token parameter to the router call instead of hardcoding false.
Patch sketch:
diff --git a/contracts/liquidation-engine/src/contract.rs b/contracts/liquidation-engine/src/contract.rs
@@
- args.push_back(false.into_val(&env));
+ args.push_back(_receive_a_token.into_val(&env));
QA-27: Withdrawals can leave zero-balance collateral bits set
Assessment withdraw reads the caller’s rounded live aToken balance, calls burnscaled, ignores the burn result that says whether the post-burn scaled balance is zero, and clears the collateral bit only when the rounded live balance minus the requested withdrawal amount is zero. Because burnscaled uses raydivup, an explicit withdrawal that looks partial in live units can burn all scaled aTokens while the router leaves the reserve marked as collateral.
Failure mode. The router derives post-withdraw position state from pre-burn rounded live-balance arithmetic instead of using the authoritative post-burn scaled-balance result returned by aToken burn_scaled.
Trigger path. A supplier has a scaled aToken balance and liquidity index where the rounded live balance is greater than the explicit withdrawal amount, while raydivup(amount, index) equals or caps to the user’s full scaled balance; for example scaled balance 5 at 1.1 RAY has live balance 6, and withdrawing 5 burns all 5 scaled units.
Why it matters. The user’s router collateral bitmap can continue marking a reserve as collateral despite zero aToken scaled balance. Account-data, transfer-validation, and active-reserve-count logic can keep iterating the stale reserve, and clearing may require a separate manual collateral-disable path if it remains available.
Evidence. contracts/kinetic-router/src/operations.rs:164-264; contracts/a-token/src/contract.rs:254-313; contracts/a-token/src/balance.rs:26-37; contracts/shared/src/utils.rs:37-109
Code context:
// contracts/kinetic-router/src/operations.rs:224-231
let burn_result = env.try_invoke_contract::<(bool, i128), KineticRouterError>(
&updated_reserve_data.a_token_address,
&Symbol::new(&env, "burn_scaled"),
args,
);
match burn_result {
Ok(Ok(_)) => {}
// contracts/kinetic-router/src/operations.rs:256-263
// Disable collateral usage if user withdrew entire position
let remaining_balance = user_balance_u128
.checked_sub(amount_to_withdraw)
.ok_or(KineticRouterError::MathOverflow)?;
if remaining_balance == 0 {
let mut user_config = storage::get_user_configuration(&env, &caller);
user_config.set_using_as_collateral(k2_shared::safe_reserve_id(&env, updated_reserve_data.id), false);
storage::set_user_configuration(&env, &caller, &user_config);
// contracts/a-token/src/contract.rs:275-288
// M-14 / OPT-M2: ray_div_up burns slightly more scaled units (protocol-favorable)
let scaled_u128 = ray_div_up(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let mut amount_scaled = safe_u128_to_i128(&env, scaled_u128);
let current_scaled_balance = storage::get_scaled_balance(&env, &on_behalf_of);
// Cap at actual scaled balance: ray_div_up(ray_mul_down(S,I),I) can exceed S by 1
if amount_scaled > current_scaled_balance {
amount_scaled = current_scaled_balance;
}
if amount_scaled == 0 {
return Err(TokenError::InvalidAmount);
}
// ...
Recommended change Use the boolean returned by burn_scaled, or query the post-burn scaled balance, to clear the collateral bit whenever the actual scaled balance becomes zero; do not decide bitmap state from pre-burn rounded live-balance subtraction.
Patch sketch:
diff --git a/contracts/kinetic-router/src/operations.rs b/contracts/kinetic-router/src/operations.rs
@@
- match burn_result {
- Ok(Ok(_)) => {}
+ let burned_to_zero = match burn_result {
+ Ok(Ok((is_zero, _))) => is_zero,
Ok(Err(_)) | Err(_) => return Err(KineticRouterError::ATokenBurnFailed),
- }
+ };
@@
- let remaining_balance = user_balance_u128.checked_sub(amount_to_withdraw)?;
- if remaining_balance == 0 {
+ if burned_to_zero {
user_config.set_using_as_collateral(reserve_id, false);
}
QA-28: [omitted]
This finding has been omitted from the report as it was deemed invalid by the judge.
---
## QA-29: Batch oracle setup silently assumes 8 decimals when adapter decimals are omitted
**Assessment**
`set_batch_oracle` accepts `decimals = None` and stores `config.oracle_decimals = None`. Batch read branches use `config.oracle_decimals.unwrap_or(8)`. A non-8-decimal adapter is normalized with the wrong precision on every batch read.
**Traceability.** OBL-721
Code context:
```rust
// contracts/price-oracle/src/contract.rs:387-390
let decimals = config.oracle_decimals.unwrap_or(8);
oracle::query_batch_adapter_direct(
&env, &adapter, &feed_id, decimals,
config.max_age,
// contracts/price-oracle/src/contract.rs:498-511
batch_decimals.push_back(config.oracle_decimals.unwrap_or(8));
batch_max_ages.push_back(config.max_age);
batch_indices.push_back(idx as u32);
results.push_back(None); // placeholder
continue;
}
// Different adapter — resolve individually
}
// Fall through: resolve individually via standard path
let price_data = Self::get_asset_price_data_with_config(env.clone(), asset.clone(), &oracle_config)?;
results.push_back(Some(price_data));
}
// ...
Recommended change
Require explicit decimals during batch oracle setup, or probe the adapter’s decimals() method.
Patch sketch:
diff --git a/contracts/price-oracle/src/contract.rs b/contracts/price-oracle/src/contract.rs
@@
- config.oracle_decimals = decimals;
+ let decimals = decimals.ok_or(OracleError::InvalidConfig)?;
+ config.oracle_decimals = Some(decimals);
QA-30: validate_withdraw does not check is_frozen() while validate_supply and validate_borrow do
Assessment
validate_supply (validation.rs:78–80) and validate_borrow (validation.rs:145–147) both reject operations when a reserve’s configuration has the frozen flag set. validate_withdraw (validation.rs:93–117) checks is_active() and is_paused() but omits the is_frozen() check. While the Aave V3 design intentionally allows withdrawals from frozen reserves (since users should not be trapped), the K2 codebase inconsistently documents this: the validation module comments reference “F-01/F-03” uniformly for all three functions, suggesting identical lifecycle checks were intended.
If the protocol intends to prevent withdrawals from frozen reserves (e.g., during incident response), this gap allows users to drain liquidity from a reserve that the admin believed was fully locked down.
Review trail. Generic code-level pass — validation.rs
Code context:
// contracts/kinetic-router/src/validation.rs:92-105
/// F-01/F-03
pub fn validate_withdraw(
env: &Env,
_asset: &Address,
amount: u128,
reserve_data: &k2_shared::ReserveData,
) -> Result<(), KineticRouterError> {
if storage::is_paused(env) {
return Err(KineticRouterError::AssetPaused);
}
// u128::MAX signals max withdrawal
if amount != u128::MAX {
validate_amount(amount)?;
// ...
// contracts/kinetic-router/src/validation.rs:137-147
if !reserve_data.configuration.is_active() {
return Err(KineticRouterError::AssetNotActive);
}
if reserve_data.configuration.is_paused() {
return Err(KineticRouterError::AssetPaused);
}
if reserve_data.configuration.is_frozen() {
return Err(KineticRouterError::AssetFrozen);
}
Recommended change
Either add is_frozen() to validate_withdraw if the protocol design requires it, or explicitly document in the validation module that frozen reserves intentionally permit withdrawals.
Patch sketch:
diff --git a/contracts/kinetic-router/src/validation.rs b/contracts/kinetic-router/src/validation.rs
@@
if reserve_data.configuration.is_paused() {
return Err(KineticRouterError::AssetPaused);
}
+ if reserve_data.configuration.is_frozen() {
+ return Err(KineticRouterError::AssetFrozen);
+ }
QA-31: : [omitted]
This finding has been omitted from the report as it was deemed invalid by the judge.
QA-32: Non-risk actions depend on unnecessary risk data
Assessment Withdrawal, manual collateral-toggle, and aToken transfer-finalization routes enter risk-data or oracle checks before branches that prove no health-factor or collateral-entry check is needed. Debt-free collateral withdrawals fetch oracle config and compute full account data before the no-debt exit. Non-collateral withdrawals fetch oracle config before the non-collateral return. Debt-free collateral-disable calls run full account data after clearing the bit. Idempotent collateral-toggle calls also do unnecessary work: an already-disabled false call runs account data, and an already-enabled true call requires a fresh price. In aToken sender transfer finalization, an indebted sender transferring a reserve not marked as collateral also fetches oracle config before the shared validator can return. These steps can fail even though the requested action cannot worsen collateralization or change relevant collateral state.
Failure mode. No-risk predicates are checked too late. operations::withdraw fetches oracle configuration before validateusercanwithdraw, validateusercanwithdraw computes full oracle-dependent account data before returning on zero debt, setuserusereserveascoll does not inspect the current user collateral bit or no-borrowing state before branch-specific price or account-data validation, and validateandfinalizetransfer checks only hasanyborrowing before fetching oracle config instead of first checking whether the transferred reserve is collateral.
Trigger path. A requested withdrawal is otherwise within live balance and reserve liquidity, and either the withdrawn reserve is not marked as caller collateral or the caller has no active borrowing bits for a collateral withdrawal; or a caller uses setuserusereserveas_coll for a debt-free collateral-disable, an already-disabled false no-op, or an already-enabled true no-op; or an aToken sender has unrelated borrowing but transfers a reserve whose collateral bit is false. Oracle config, price, or account-data dependencies fail or are unavailable.
Why it matters. Withdrawals can revert before aToken burn and underlying transfer, collateral-toggle calls can revert before committing an otherwise no-risk bitmap reduction or no-op setter result, and non-collateral aToken transfers by indebted senders can be rejected before balance movement. The failure is repeatable while oracle config, price, or relevant account-data dependencies remain unavailable.
Evidence. contracts/kinetic-router/src/operations.rs:212-217; contracts/kinetic-router/src/router.rs:1275-1290,1858-1864; contracts/kinetic-router/src/price.rs:133-156; contracts/kinetic-router/src/validation.rs:237-262; contracts/kinetic-router/src/calculation.rs:104-198,255-264
Code context:
// contracts/kinetic-router/src/operations.rs:212-217
// Validate health factor before withdrawal to prevent unsafe positions
// F-15
// NEW-02
let oracle_config = crate::price::get_oracle_config(&env)?;
let oracle_to_wad = calculate_oracle_to_wad_factor(oracle_config.price_precision);
validation::validate_user_can_withdraw(&env, &caller, &asset, amount_to_withdraw, &updated_reserve_data, oracle_to_wad)?;
Recommended change Move no-risk checks before oracle configuration, price gates, and full account-data calculation. After withdrawal balance and reserve-liquidity checks, return for reserves not marked as caller collateral; return or use debt-only checks for users with no active borrowing. In setuserusereserveascoll, load user configuration first, return for idempotent no-op requests, and skip account-data health-factor calculation when the caller has no borrowing bits. In validateandfinalizetransfer, check whether the transferred reserve is sender collateral before fetching oracle config. Fetch oracle config and prices only for actions that actually create collateral state or require post-action health-factor validation.
Patch sketch:
diff --git a/contracts/kinetic-router/src/operations.rs b/contracts/kinetic-router/src/operations.rs
@@
- let oracle_config = crate::price::get_oracle_config(&env)?;
- validation::validate_user_can_withdraw(&env, &caller, &asset, amount_to_withdraw, &updated_reserve_data, oracle_to_wad)?;
+ let user_config = storage::get_user_configuration(&env, &caller);
+ if user_config.has_any_borrowing() && user_config.is_using_as_collateral(reserve_id) {
+ let oracle_config = crate::price::get_oracle_config(&env)?;
+ validation::validate_user_can_withdraw(&env, &caller, &asset, amount_to_withdraw, &updated_reserve_data, oracle_to_wad)?;
+ }
QA-33: Incentives claim_rewards drains accrued state without freshening the global reward index
Assessment
claim_rewards (incentives/contract.rs:207-351) reads user_data.accrued in a two-phase read-then-drain pattern. However, unlike claim_all_rewards (line 440-458) which freshens the global reward index via update_asset_reward_index before draining, claim_rewards only reads the stale user_data.accrued without updating the global index first. If time has elapsed since the last handle_action, the user forfeits rewards that would have accrued between the last index update and the claim.
Uncertainty: This may be by design (users are expected to call handle_action first via a supply/borrow interaction). But it creates an inconsistency with claim_all_rewards which does freshen indices.
Evidence. contracts/incentives/src/contract.rs:226-244 vs :430-460
Code context:
// contracts/incentives/src/contract.rs:226-239
let mut total_claimable = 0u128;
for i in 0..assets.len() {
let asset = assets.get(i).ok_or(KineticRouterError::ReserveNotFound)?;
for reward_type in [storage::REWARD_TYPE_SUPPLY, storage::REWARD_TYPE_BORROW] {
let user_data = storage::get_user_reward_data(
&env,
&asset,
&reward_token,
&caller,
reward_type,
);
// ...
// contracts/incentives/src/contract.rs:439-447
// Update global reward index
let current_index = storage::get_asset_reward_index(&env, &asset, &reward_token, reward_type);
let updated_index = calculation::update_asset_reward_index(
&env,
&config,
¤t_index,
total_supply,
)?;
storage::set_asset_reward_index(&env, &asset, &reward_token, reward_type, &updated_index);
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Either freshen the global reward index in claim_rewards before reading accrued, or document this behavior explicitly.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
- for reward_type in [storage::REWARD_TYPE_SUPPLY, storage::REWARD_TYPE_BORROW] {
+ for reward_type in [storage::REWARD_TYPE_SUPPLY, storage::REWARD_TYPE_BORROW] {
+ freshen_user_reward_index(&env, &asset, &reward_token, &caller, reward_type)?;
let user_data = storage::get_user_reward_data(...);
QA-34: fund_rewards reuses MathOverflow error code for zero-amount rejection
Assessment
IncentivesContract::fund_rewards (incentives/contract.rs:891-892) returns IncentivesError::MathOverflow when amount == 0. This is semantically incorrect — zero amount is an input validation error, not a math overflow. The comment “Reuse error for invalid amount” acknowledges the workaround.
Evidence. contracts/incentives/src/contract.rs:892
Code context:
// contracts/incentives/src/contract.rs:888-893
// Validate caller is emission manager
storage::validate_emission_manager(&env, &caller)?;
if amount == 0 {
return Err(IncentivesError::MathOverflow); // Reuse error for invalid amount
}
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Add a dedicated InvalidAmount variant to IncentivesError or use an existing validation error.
Patch sketch:
diff --git a/contracts/incentives/src/error.rs b/contracts/incentives/src/error.rs
@@
MathOverflow = ...,
+ InvalidAmount = ...,
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
- return Err(IncentivesError::MathOverflow);
+ return Err(IncentivesError::InvalidAmount);
QA-35: Borrow capacity and post-action health checks floor action deltas
Assessment Borrow and withdraw simulations can floor action deltas in a borrower-favorable direction. Borrow converts the requested debt amount through valueinbase, compares the floored result to availableborrowsbase, and adds the floored result to total debt before checking post-borrow health factor. If a positive borrow normalizes to 0 base units, the simulated new debt can remain zero and the debt-zero health-factor sentinel returns u128::MAX even though live debt will be minted. Withdraw starts from a floor-rounded weighted threshold sum and subtracts a separately floor-rounded withdrawal contribution instead of recomputing the weighted contribution from the remaining balance.
Failure mode. The post-action checks compare boundary-sensitive solvency and capacity using independently floor-rounded action deltas. For borrow, the requested debt base value is floored before both capacity comparison and health-factor simulation. For withdraw, floor(full contribution) - floor(withdraw contribution) can exceed floor(remaining contribution).
Trigger path. For borrow, the requested amount has a nonzero normalized base-value remainder; the strongest case is a positive requested amount whose base value floors to zero, with sufficient reserve liquidity and no configured minimum debt blocking it. For withdraw, the full and withdrawn weighted collateral contributions have fractional remainders such that subtracting floors overstates the remaining weighted contribution.
Why it matters. Near-boundary borrows or withdrawals can be accepted even though exact post-action capacity or health-factor math would reject them. In the zero-base borrow case, even an account with no collateral can open live debt below one normalized base unit because the simulated debt remains zero. The error is bounded to less than one normalized base unit of debt for borrow and one weighted-threshold unit for withdraw, but it is repeatable at solvency boundaries.
Evidence. contracts/kinetic-router/src/validation.rs:191-218; contracts/kinetic-router/src/validation.rs:291-321; contracts/kinetic-router/src/calculation.rs:13-27
Code context:
// contracts/kinetic-router/src/operations.rs:324-330
// Re-validate cap after interest accrual (interest increases total debt)
validation::validate_borrow_cap_after_interest(&env, amount, &updated_reserve_data, &asset, updated_reserve_data.variable_borrow_index)?;
// NEW-01
let oracle_config = crate::price::get_oracle_config(&env)?;
let oracle_to_wad = calculate_oracle_to_wad_factor(oracle_config.price_precision);
validation::validate_user_can_borrow(&env, &on_behalf_of, &asset, amount, &updated_reserve_data, oracle_to_wad)?;
// contracts/kinetic-router/src/validation.rs:168-181
let (user_account_data, price_map) = crate::calculation::calculate_user_account_data_with_prices(env, user, Some(asset))?;
let asset_price = price_map
.try_get(asset.clone())
.ok()
.flatten()
.ok_or(KineticRouterError::PriceOracleNotFound)?;
if asset_price == 0 {
return Err(KineticRouterError::PriceOracleNotFound);
}
// NEW-01
let asset_decimals = reserve_data.configuration.get_decimals() as u32;
Recommended change For debt additions, compare using exact rational arithmetic or conservatively ceil the candidate debt base value before health-factor and borrowing-power checks. For withdrawals, recompute the weighted threshold from the post-withdraw remaining balance, or otherwise use a conservative rounded delta that cannot overstate remaining collateral support.
Patch sketch:
diff --git a/contracts/kinetic-router/src/validation.rs b/contracts/kinetic-router/src/validation.rs
@@
- let added_debt_base = value_in_base(env, amount, asset_price, oracle_to_wad, decimals_pow)?;
+ let added_debt_base = value_in_base_round_up(env, amount, asset_price, oracle_to_wad, decimals_pow)?;
@@
- let new_health_factor = calculate_health_factor_u256(env, collateral, threshold, new_total_debt);
+ let new_health_factor = calculate_health_factor_u256(env, collateral, threshold, new_total_debt);
QA-36: Withdrawals can trap payouts in protocol token contracts
Assessment withdraw blocks only the selected reserve aToken and debt-token addresses as recipients. It does not block the underlying asset token contract address itself, and it does not reject aToken or debt-token contract addresses belonging to other reserves. A valid withdrawal can therefore burn the caller aTokens and transfer underlying from aToken custody into a token contract address that cannot later authorize the outbound transfer.
Failure mode. The recipient safety check is hardcoded to the current reserve aToken and debt-token contracts instead of using a protocol-wide forbidden-recipient classification. transferunderlyingto then performs a standard token transfer to any target address, and the canonical token contract accepts balances for arbitrary addresses, including contract addresses with no recovery path.
Trigger path. A supplier calls withdraw with a valid amount and sets to to the reserve underlying asset contract, or to an aToken/debt-token contract address from another registered reserve. Balance, liquidity, access-list, and health-factor checks pass. If a reserve whitelist is configured, the selected contract recipient is included; with an empty whitelist, recipients are open by default.
Why it matters. The user aToken claim is burned and reserve cash leaves aToken custody, but the payout is credited to a contract address such as the underlying token contract itself or a different reserve aToken/debt-token contract. These token contracts require authorization from the credited address for standard outbound transfers and expose no generic recovery entrypoint for arbitrary underlying balances. The loss is self-directed by the withdrawing caller, but it is irreversible for the affected amount and is the same class of fund-loss condition the same-reserve aToken/debt-token recipient guards try to prevent.
Evidence. contracts/kinetic-router/src/operations.rs:154-161,237-246; contracts/a-token/src/contract.rs:316-371; contracts/token/src/contract.rs:57-86; contracts/kinetic-router/src/storage.rs:305-369
Code context:
// contracts/kinetic-router/src/operations.rs:154-161
// Prevent withdrawals to aToken contract to avoid fund loss
if to == updated_reserve_data.a_token_address {
panic_with_error!(&env, OperationError::RecipientIsAToken);
}
// Prevent withdrawals to debt token contract
if to == updated_reserve_data.debt_token_address {
panic_with_error!(&env, OperationError::RecipientIsDebtToken);
// contracts/a-token/src/contract.rs:316-329
pub fn transfer_underlying_to(
env: Env,
caller: Address,
target: Address,
amount: u128,
) -> Result<bool, TokenError> {
caller.require_auth();
let state = storage::get_state(&env)?;
if caller != state.pool_address {
return Err(TokenError::Unauthorized);
}
if amount == 0 {
return Err(TokenError::InvalidAmount);
// ...
Recommended change Extend withdraw recipient validation to reject to == asset, the router address, and every known reserve aToken/debt-token address, not only the current reserve pair. Use the bounded reserve registry or a maintained reverse index for this classification, or require explicit contract-recipient support before allowing protocol-mediated payouts to contracts.
Patch sketch:
diff --git a/contracts/kinetic-router/src/operations.rs b/contracts/kinetic-router/src/operations.rs
@@
- if to == updated_reserve_data.a_token_address || to == updated_reserve_data.debt_token_address {
+ if to == asset || to == env.current_contract_address()
+ || storage::is_known_protocol_token(&env, &to) {
return Err(KineticRouterError::InvalidAmount);
}
QA-37: Collateral-swap protocol fees can round to zero
Assessment Collateral swaps derive the protocol fee from the destination swap output with percentmul, which rounds half-up rather than protocol-favorably upward. When a positive fee rate is configured and output * feebps is a positive remainder below HALFBASISPOINTS, the computed protocol_fee becomes zero, so the swap completes and supplies the full output as collateral without paying any protocol fee.
Failure mode. The collateral-swap fee branch reuses the general percentmul helper instead of percentmulup or an explicit minimum-fee/reject rule for positive exact fees. Flash-loan and liquidation fee paths use percentmul_up where protocol fees are intended not to be silently rounded away.
Trigger path. A positive swap protocol fee rate is configured via the flash-loan premium setting, a treasury fee sink is configured, and a collateral swap returns a small positive destination output such that output * feebps has a nonzero value that rounds to zero under percentmul.
Why it matters. The protocol can undercollect collateral-swap fees by up to one destination-asset smallest unit per affected swap, and tiny swaps can avoid the fee entirely. Users can repeat or split swaps to keep each fee computation below the rounding threshold, making this a low-severity but deterministic fee-accounting leak.
Evidence. contracts/kinetic-router/src/swap.rs:196-217; contracts/shared/src/utils.rs:122-128
Code context:
// contracts/kinetic-router/src/swap.rs:196-209
// Calculate protocol fee from swap output
let protocol_fee = k2_shared::utils::percent_mul(to_amount_received, swap_config.flash_loan_premium_bps)?;
// Ensure fee doesn't exceed swap output
let amount_to_supply = if to_amount_received > protocol_fee {
to_amount_received - protocol_fee
} else {
return Err(KineticRouterError::MathOverflow);
};
// F-3: Validate supply cap on destination reserve before minting aTokens
crate::validation::validate_supply_cap_after_interest(
&env,
amount_to_supply,
// ...
// contracts/shared/src/utils.rs:122-128
pub fn percent_mul(value: u128, percentage: u128) -> Result<u128, crate::KineticRouterError> {
value
.checked_mul(percentage)
.and_then(|prod| prod.checked_add(HALF_BASIS_POINTS))
.and_then(|sum| sum.checked_div(BASIS_POINTS_MULTIPLIER))
.ok_or(crate::KineticRouterError::MathOverflow)
}
Recommended change Use percentmulup for collateral-swap protocol fee calculation, or reject positive-output swaps when a positive configured fee would round to zero. If zero-fee dust swaps are intentional, document the threshold and enforce it explicitly.
Patch sketch:
diff --git a/contracts/kinetic-router/src/swap.rs b/contracts/kinetic-router/src/swap.rs
@@
- let protocol_fee = k2_shared::utils::percent_mul(to_amount_received, swap_config.flash_loan_premium_bps)?;
+ let protocol_fee = k2_shared::utils::percent_mul_up(to_amount_received, swap_config.flash_loan_premium_bps)?;
+ if swap_config.flash_loan_premium_bps > 0 && protocol_fee == 0 {
+ return Err(KineticRouterError::InvalidAmount);
+ }
QA-38: Incentives emission manager is a single point of failure with no transfer mechanism
Assessment
The incentives contract stores a single emission_manager address (storage.rs:105-108) set during initialization. There is no set_emission_manager or two-step transfer function. If the emission manager key is lost or compromised, reward configuration, pausing/unpausing, and fund management become permanently locked.
All other critical contracts (treasury, pool-configurator, interest-rate-strategy) implement two-step admin transfer. The incentives contract is the only one missing this pattern.
Evidence. contracts/incentives/src/storage.rs:104-117; contracts/incentives/src/contract.rs:38-52
Code context:
// contracts/incentives/src/contract.rs:38-51
pub fn initialize(
env: Env,
emission_manager: Address,
lending_pool: Address,
) -> Result<(), IncentivesError> {
if storage::is_initialized(&env) {
return Err(IncentivesError::AlreadyInitialized);
}
storage::set_emission_manager(&env, &emission_manager);
storage::set_lending_pool(&env, &lending_pool);
storage::set_initialized(&env);
Ok(())
// ...
// contracts/incentives/src/storage.rs:104-117
/// Set emission manager address
pub fn set_emission_manager(env: &Env, manager: &Address) {
env.storage().instance().set(&EMISSION_MANAGER, manager);
extend_instance_ttl_if_needed(env);
}
/// Validate emission manager
pub fn validate_emission_manager(env: &Env, caller: &Address) -> Result<(), IncentivesError> {
let manager = get_emission_manager(env)?;
if caller != &manager {
return Err(IncentivesError::Unauthorized);
}
Ok(())
}
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Add propose_emission_manager / accept_emission_manager following the same two-step pattern.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
+ pub fn propose_emission_manager(env: Env, caller: Address, pending: Address) -> Result<(), IncentivesError> {
+ storage::validate_emission_manager(&env, &caller)?;
+ caller.require_auth();
+ storage::set_pending_emission_manager(&env, &pending);
+ Ok(())
+ }
+
+ pub fn accept_emission_manager(env: Env, caller: Address) -> Result<(), IncentivesError> {
+ caller.require_auth();
+ storage::accept_pending_emission_manager(&env, &caller)
+ }
QA-39: Public swap adapters can spend adapter-held balances
Assessment
The Soroswap and Aquarius adapter execute_swap entrypoints are public and do not check that the caller is the Kinetic Router. Each adapter then self-authorizes downstream token movement from env.current_contract_address(), so any external caller can route balances that already sit on the adapter to an arbitrary recipient.
This does not bypass router settlement for in-flight swaps, but it exposes stranded adapter custody such as residual dust or mistaken transfers.
Code context:
// contracts/soroswap-swap-adapter/src/lib.rs:207-274
pub fn execute_swap(
env: Env,
from_token: Address,
to_token: Address,
amount_in: u128,
min_amount_out: u128,
recipient: Address,
) -> Result<u128, Error> {
let adapter_address = env.current_contract_address();
let router = Self::get_router(env.clone())?;
// no router caller/auth check before adapter self-authorization
env.authorize_as_current_contract(...);
token::Client::new(&env, &from_token).transfer(&adapter_address, &pair_address, &amount_in_i128);
// contracts/aquarius-swap-adapter/src/lib.rs:220-280
pub fn execute_swap(env: Env, from_token: Address, to_token: Address, amount_in: u128, min_amount_out: u128, recipient: Address)
-> Result<u128, Error>
{
let adapter_address = env.current_contract_address();
env.authorize_as_current_contract(auth_entries);
env.try_invoke_contract::<u128, Error>(&pool_address, &symbol_short!("swap"), ...)?;
}
Recommended change Bind adapter execution to the router and provide a separate admin recovery path for accidental balances.
Patch sketch:
diff --git a/contracts/soroswap-swap-adapter/src/lib.rs b/contracts/soroswap-swap-adapter/src/lib.rs
@@
- pub fn execute_swap(env: Env, from_token: Address, to_token: Address, amount_in: u128, min_amount_out: u128, recipient: Address)
+ pub fn execute_swap(env: Env, caller: Address, from_token: Address, to_token: Address, amount_in: u128, min_amount_out: u128, recipient: Address)
-> Result<u128, Error> {
+ caller.require_auth();
+ if caller != Self::get_router(env.clone())? {
+ return Err(Error::Unauthorized);
+ }
QA-40: Standalone liquidation previews diverge from router settlement in multiple dimensions
Assessment
liquidation-engine:calculate_liquidation uses a hardcoded WAD threshold (not the configurable one), aggregate account totals instead of selected-pair balances, ignores close-factor mode selection, omits the collateral cap and adjusted-debt rule, skips reserve lifecycle gates (pause/active), uses bare oracle prices without freshness validation, and can use a different oracle reference than the router. Previews can report liquidation amounts that are unexecutable.
Traceability. OBL-462–OBL-468, OBL-718–OBL-724, OBL-840–OBL-841
Code context:
// contracts/liquidation-engine/src/contract.rs:68-81
let liquidation_result =
calculation::calculate_liquidation(&env, collateral_asset.clone(), debt_asset.clone(), user.clone(), debt_to_cover)?;
let kinetic_router = storage::get_kinetic_router(&env)?;
let mut args = Vec::new(&env);
args.push_back(liquidator.clone().into_val(&env));
args.push_back(collateral_asset.clone().into_val(&env));
args.push_back(debt_asset.clone().into_val(&env));
args.push_back(user.clone().into_val(&env));
args.push_back(debt_to_cover.into_val(&env));
args.push_back(false.into_val(&env));
let _: () =
env.invoke_contract(&kinetic_router, &Symbol::new(&env, "liquidation_call"), args);
// contracts/liquidation-engine/src/contract.rs:83-94
let liquidation_call = LiquidationCall {
liquidator,
user,
collateral_asset,
debt_asset,
debt_to_cover,
collateral_to_liquidate: liquidation_result.collateral_amount,
liquidation_bonus: liquidation_result.bonus_amount,
timestamp: current_timestamp,
};
storage::add_liquidation_record(&env, &liquidation_call);
Recommended change Either align the preview logic with router settlement, or clearly document the preview as an approximation.
Patch sketch:
diff --git a/contracts/liquidation-engine/src/calculation.rs b/contracts/liquidation-engine/src/calculation.rs
@@
- // local approximation
- calculate_liquidation_local(...)
+ // Delegate preview to router-compatible calculation path.
+ query_router_liquidation_preview(env, collateral_asset, debt_asset, user, debt_to_cover)
QA-41: Liquidation access-list updates can leave no eligible liquidator
Assessment Liquidation whitelist and blacklist setters replace the live global lists without validating that the resulting policy leaves any eligible liquidator. A non-empty whitelist allows only listed addresses, while the blacklist always blocks listed addresses. Direct liquidation, prepareliquidation, and executeliquidation all require the signed liquidator to pass both gates. A blacklist update can therefore disable liquidation when it blocks every whitelisted liquidator, including by blacklisting the last remaining eligible liquidator after authorizations have already been prepared.
Failure mode. The liquidation access-control model is split across independent whitelist and blacklist setters. Neither setter checks overlap with the other list, checks that a non-empty whitelist has at least one unblacklisted member, or treats an existing prepared liquidation as a wind-down exception. The code’s own fuzz invariant states that liquidation whitelist and blacklist membership should not overlap, but the production setters store overlapping vectors unchanged.
Trigger path. The protocol uses a non-empty liquidation whitelist; one or more borrower positions are liquidatable or can become liquidatable; the pool admin replaces the liquidation blacklist so that every whitelisted liquidator is also blacklisted, or removes the last unblacklisted whitelisted liquidator through the whitelist setter; no separate altruistic repayment clears the debt.
Why it matters. The liquidation risk-control path can be disabled by configuration even while unhealthy positions exist. Direct liquidation and both staged liquidation phases reject at the liquidator list checks, including executeliquidation for previously prepared authorizations, so collateral-capped liquidation cannot burn residual debt or create RESERVEDEFICIT. cover_deficit cannot cover anything until that deficit is recorded. If prices keep moving while there is no eligible liquidator, bad debt can remain in borrower debt balances and supplier liquidity can worsen until governance fixes the lists.
Evidence. contracts/kinetic-router/src/accesscontrol.rs:62; contracts/kinetic-router/src/accesscontrol.rs:139; contracts/kinetic-router/src/storage.rs:759; contracts/kinetic-router/src/storage.rs:933; contracts/kinetic-router/src/liquidation.rs:133; contracts/kinetic-router/src/router.rs:480; contracts/kinetic-router/src/router.rs:715
Code context:
// contracts/kinetic-router/src/access_control.rs:62-62
pub fn set_liquidation_whitelist(
// contracts/kinetic-router/src/access_control.rs:139-139
pub fn set_liquidation_blacklist(
Recommended change Validate liquidation-list updates against the combined policy. Reject any address that appears in both liquidation lists, and when the whitelist is non-empty require at least one unblacklisted whitelisted liquidator, preferably before allowing updates while liquidatable debt exists. If emergency removal of a compromised liquidator is needed, require a replacement liquidator or an explicit global liquidation pause mode. Decide whether already prepared liquidations by still-safe liquidators should have a narrow execution grace path or should be intentionally invalidated, and document that behavior.
Patch sketch:
diff --git a/contracts/kinetic-router/src/access_control.rs b/contracts/kinetic-router/src/access_control.rs
@@
- storage::set_liquidation_whitelist(&env, &whitelist);
+ validate_liquidation_policy_nonempty(&env, &whitelist, &storage::get_liquidation_blacklist(&env))?;
+ storage::set_liquidation_whitelist(&env, &whitelist);
QA-42: Batch adapter prices are downcast before normalization
Assessment Batch adapter price data uses a U256 raw price, but the price oracle converts that raw value to u128 before decimal normalization. In both single-feed and grouped adapter paths, a high-decimal raw source price above u128::MAX can be rejected even when scaling it down to the configured target precision would produce a valid u128 normalized price.
Failure mode. The adapter normalization helpers apply the final u128 output-domain bound to the raw source-domain U256 value before performing decimal scale-down.
Trigger path. A batch adapter returns a U256 price whose source decimals are greater than the oracle target precision, the raw value exceeds u128::MAX, and the target-normalized value fits u128. The single-feed or grouped batch adapter branch is selected.
Why it matters. Affected batch-adapter price reads can fail with MathOverflow even though the normalized price is representable. In grouped reads, one such feed can abort the entire vector read and make otherwise valid price-dependent operations unavailable for the request.
Evidence. contracts/price-oracle/src/oracle.rs:395-412; contracts/price-oracle/src/oracle.rs:455-470; docs/08-ORACLE.md:311-320; docs/08-ORACLE.md:392-419
Code context:
// contracts/price-oracle/src/oracle.rs:395-408
let price_u128 = adapter_data.price.to_u128()
.ok_or(crate::OracleError::MathOverflow)?;
// Convert milliseconds to seconds
let timestamp_secs = adapter_data.package_timestamp / 1000;
let current_timestamp = env.ledger().timestamp();
if timestamp_secs > current_timestamp {
return Err(crate::OracleError::PriceTooOld);
}
let max_age_seconds = max_age.unwrap_or(staleness_threshold);
let age = current_timestamp.saturating_sub(timestamp_secs);
if age > max_age_seconds {
return Err(crate::OracleError::PriceTooOld);
// ...
// contracts/price-oracle/src/oracle.rs:455-468
let price_u128 = data.price.to_u128()
.ok_or(crate::OracleError::MathOverflow)?;
let timestamp_secs = data.package_timestamp / 1000;
if timestamp_secs > current_timestamp {
return Err(crate::OracleError::PriceTooOld);
}
let max_age_seconds = max_age.unwrap_or(staleness_threshold);
let age = current_timestamp.saturating_sub(timestamp_secs);
if age > max_age_seconds {
return Err(crate::OracleError::PriceTooOld);
}
// ...
Recommended change Normalize adapter prices in a widened integer domain first, then downcast only the target-precision normalized result to u128. Apply the same widened normalization to both querybatchadapterdirect and batchquery_adapter.
Patch sketch:
diff --git a/contracts/price-oracle/src/oracle.rs b/contracts/price-oracle/src/oracle.rs
@@
- let price = u128::try_from(raw_price).map_err(|_| OracleError::MathOverflow)?;
- normalize_price(price, decimals, target_precision)
+ normalize_price_u256(raw_price, decimals, target_precision)?
+ .to_u128()
+ .ok_or(OracleError::MathOverflow)
QA-43: aToken transfer sender rewards settle from post-transfer balance
Assessment
transfer_internal writes new_from_balance before calling handle_incentives_action for the sender. The helper reads the already-reduced scaled balance and passes it to incentives:handle_action, where elapsed rewards are calculated with min(previous_snapshot, post_transfer_balance). Rewards attributable to the transferred-out balance during the elapsed interval are lost.
Traceability. OBL-662, OBL-663, OBL-734
Code context:
// contracts/a-token/src/contract.rs:668-674
// Write balances after validation passed
storage::set_scaled_balance(&env, &from, &new_from_balance);
storage::set_scaled_balance(&env, &to, &new_to_balance);
// N-03
Self::handle_incentives_action(&env, &state.pool_address, &state.underlying_asset, &from, 0)?;
Self::handle_incentives_action(&env, &state.pool_address, &state.underlying_asset, &to, 0)?;
Recommended change Settle sender rewards before writing the reduced balance, or pass the pre-transfer balance to the incentives handler.
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- storage::set_scaled_balance(&env, &from, &new_from_balance);
- storage::set_scaled_balance(&env, &to, &new_to_balance);
-
Self::handle_incentives_action(&env, &state.pool_address, &state.underlying_asset, &from, 0)?;
+ storage::set_scaled_balance(&env, &from, &new_from_balance);
+ storage::set_scaled_balance(&env, &to, &new_to_balance);
Self::handle_incentives_action(&env, &state.pool_address, &state.underlying_asset, &to, 0)?;
QA-44: Debt-free and non-collateral withdrawals blocked by unnecessary oracle dependency
Assessment
operations::withdraw calls get_oracle_config and validate_user_can_withdraw calls calculate_user_account_data_unified before checking whether the user has any debt. A debt-free user withdrawing collateral, or any user withdrawing a non-collateral reserve, can be blocked by missing/stale oracle data that is unnecessary for the safety decision.
Traceability. OBL-688, OBL-689, OBL-763
Code context:
// contracts/kinetic-router/src/operations.rs:212-217
// Validate health factor before withdrawal to prevent unsafe positions
// F-15
// NEW-02
let oracle_config = crate::price::get_oracle_config(&env)?;
let oracle_to_wad = calculate_oracle_to_wad_factor(oracle_config.price_precision);
validation::validate_user_can_withdraw(&env, &caller, &asset, amount_to_withdraw, &updated_reserve_data, oracle_to_wad)?;
Recommended change
Short-circuit before oracle lookups when !user_config.has_any_borrowing() or when the withdrawn reserve is not marked as collateral.
Patch sketch:
diff --git a/contracts/kinetic-router/src/operations.rs b/contracts/kinetic-router/src/operations.rs
@@
- let oracle_config = crate::price::get_oracle_config(&env)?;
- validation::validate_user_can_withdraw(...)?;
+ let user_config = storage::get_user_configuration(&env, &caller);
+ if user_config.has_any_borrowing() && user_config.is_using_as_collateral(reserve_id) {
+ let oracle_config = crate::price::get_oracle_config(&env)?;
+ validation::validate_user_can_withdraw(...)?;
+ }
QA-45: Flash-loan premium sweeps bypass reserve recipient lists
Assessment Public flash loans validate the initiator and receiver against each borrowed reserve’s whitelist and blacklist, but the premium recipient is a separate configured treasury address. After repayment verification succeeds, transferpremiumtotreasury sends the premium to that treasury through a-token transferunderlying_to without checking whether the treasury is permitted by the borrowed reserve whitelist or blocked by the borrowed reserve blacklist.
Failure mode. Flash-loan access control is applied only to the user-controlled initiator and receiver, while the later pool-authorized premium transfer assumes configured-treasury status is sufficient. transferunderlyingto itself only verifies the pool caller and does not enforce reserve recipient policy.
Trigger path. A borrowed reserve has a non-empty whitelist that excludes the configured treasury or a reserve blacklist that includes the treasury; the flash-loan initiator and receiver pass reserve access checks; the flash loan is premium-bearing and repayment verification succeeds.
Why it matters. Permissioned-reserve recipient restrictions can be bypassed for flash-loan fee routing. The configured treasury can receive restricted reserve underlying even when it is not whitelisted for the reserve or is reserve-blacklisted. The transferred amount is the premium paid into the flow rather than arbitrary reserve principal, so the impact is mainly policy and incident-response control degradation.
Evidence. contracts/kinetic-router/src/router.rs:434-441; contracts/kinetic-router/src/flash_loan.rs:160-169,256-277; contracts/a-token/src/contract.rs:320-371; contracts/kinetic-router/src/operations.rs:141-147
Code context:
// contracts/kinetic-router/src/flash_loan.rs:160-169
verify_repayment(env, &debt.asset, &reserve_data.a_token_address, &debt)?;
if debt.premium > 0 {
transfer_premium_to_treasury(
env,
&debt.asset,
&reserve_data.a_token_address,
&treasury,
debt.premium,
)?;
// contracts/kinetic-router/src/flash_loan.rs:256-269
fn transfer_premium_to_treasury(
env: &Env,
_underlying_asset: &Address,
atoken_address: &Address,
treasury: &Address,
premium: u128,
) -> Result<(), KineticRouterError> {
let mut args = Vec::new(env);
args.push_back(env.current_contract_address().into_val(env));
args.push_back(treasury.into_val(env));
args.push_back(premium.into_val(env));
let transfer_result = env.try_invoke_contract::<bool, KineticRouterError>(
atoken_address,
// ...
Recommended change Before transferpremiumtotreasury, validate the configured treasury against validatereservewhitelistaccess and validatereserveblacklistaccess for the borrowed asset, or move equivalent fail-closed recipient checks into transferunderlying_to so all pool-authorized reserve underlying payouts enforce the same recipient policy.
Patch sketch:
diff --git a/contracts/kinetic-router/src/flash_loan.rs b/contracts/kinetic-router/src/flash_loan.rs
@@
- transfer_premium_to_treasury(env, &debt.asset, &reserve_data.a_token_address, &treasury, debt.premium)?;
+ validation::validate_reserve_whitelist_access(env, &debt.asset, &treasury)?;
+ validation::validate_reserve_blacklist_access(env, &debt.asset, &treasury)?;
+ transfer_premium_to_treasury(env, &debt.asset, &reserve_data.a_token_address, &treasury, debt.premium)?;
QA-46: Staged liquidation price tolerance lower bounds round down
Assessment Two-step liquidation execution computes the lower debt-price and collateral-price tolerance bounds with floor division. A live price equal to the floored bound can be below the exact configured tolerance envelope but still pass the replay-safety check.
Failure mode. executeliquidation uses floor division for lower-bound tolerance checks: authprice * (10000 - tolerance_bps) / 10000. Lower bounds require ceiling division to preserve an exact percentage envelope.
Trigger path. A prepared liquidation exists; the configured tolerance is nonzero; the stored debt or collateral price times the lower factor is not exactly divisible by 10000; the live price moves downward to the floored lower bound while the rest of execution remains valid.
Why it matters. Staged liquidation can execute after a debt or collateral price move that is slightly larger than the configured lower tolerance. The per-check gap is less than one oracle price unit, but it is systematic and can be more meaningful with low oracle precision or low-priced assets.
Evidence. contracts/kinetic-router/src/router.rs:748-759
Code context:
// contracts/kinetic-router/src/router.rs:748-759
// Validate debt price within tolerance
let debt_price_min = auth.debt_price.checked_mul(lower_factor).ok_or(KineticRouterError::MathOverflow)?.checked_div(10000).ok_or(KineticRouterError::MathOverflow)?;
let debt_price_max = auth.debt_price.checked_mul(upper_factor).ok_or(KineticRouterError::MathOverflow)?.checked_div(10000).ok_or(KineticRouterError::MathOverflow)?;
if current_debt_price.price < debt_price_min || current_debt_price.price > debt_price_max {
return Err(KineticRouterError::InvalidLiquidation);
}
// Validate collateral price within tolerance
let collateral_price_min = auth.collateral_price.checked_mul(lower_factor).ok_or(KineticRouterError::MathOverflow)?.checked_div(10000).ok_or(KineticRouterError::MathOverflow)?;
let collateral_price_max = auth.collateral_price.checked_mul(upper_factor).ok_or(KineticRouterError::MathOverflow)?.checked_div(10000).ok_or(KineticRouterError::MathOverflow)?;
if current_collateral_price.price < collateral_price_min || current_collateral_price.price > collateral_price_max {
return Err(KineticRouterError::InvalidLiquidation);
Recommended change Compute lower price bounds with ceiling division, or explicitly define and enforce an integer price-unit tolerance. Keep the upper bounds as floor-rounded maximums or otherwise ensure the max check remains conservative.
Patch sketch:
diff --git a/contracts/kinetic-router/src/flash_loan.rs b/contracts/kinetic-router/src/flash_loan.rs
@@
- let min_price = prepared_price.checked_mul(BASIS_POINTS - tolerance)?.checked_div(BASIS_POINTS)?;
+ let numerator = prepared_price.checked_mul(BASIS_POINTS - tolerance)?;
+ let min_price = numerator.checked_add(BASIS_POINTS - 1)?.checked_div(BASIS_POINTS)?;
QA-47: Direct aToken transfers move rounded-up scaled value while risk-checking nominal amount
Assessment
transfer_internal computes scaled_amount = ray_div_up(amount, liquidity_index), debits/credits that scaled amount, but passes the original nominal amount to validate_and_finalize_transfer. Whenever the rounded-up scaled amount represents more live claim value than amount, the router health-factor simulation removes less collateral than actually debited.
Traceability. OBL-655
Code context:
// contracts/a-token/src/contract.rs:627-640
// S-04: Compute scaled amounts and post-transfer balances
let amount_u128 = safe_i128_to_u128(&env, amount);
let scaled_u128 = ray_div_up(&env, amount_u128, liquidity_index).map_err(|_| TokenError::InvalidIndex)?;
let scaled_amount = safe_u128_to_i128(&env, scaled_u128);
let from_scaled_balance = storage::get_scaled_balance(&env, &from);
if from_scaled_balance < scaled_amount {
return Err(TokenError::InsufficientBalance);
}
let new_from_balance = from_scaled_balance.checked_sub(scaled_amount)
.ok_or(TokenError::InsufficientBalance)?;
let to_scaled_balance = storage::get_scaled_balance(&env, &to);
let new_to_balance = to_scaled_balance.checked_add(scaled_amount)
// ...
Recommended change Pass the actual live value of the rounded scaled amount to the router for health-factor simulation.
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- args.push_back(amount_u128.into_val(&env));
+ let actual_live = ray_mul(&env, scaled_u128, liquidity_index).map_err(|_| TokenError::InvalidIndex)?;
+ args.push_back(actual_live.into_val(&env));
QA-48: Low oracle price precision can underprice debt
Assessment setoracleconfig accepts any priceprecision up to 18, including very low target precisions. normalizeprice floors source prices down to that target precision before router WAD valuation. For a debt asset with source price 1.99 and target precision 0, the normalized price becomes 1; calculateoracletowadfactor then scales this truncated price to WAD and router borrow, health-factor, withdrawal, and liquidation checks consume it as exact. When collateral prices are not similarly truncated, borrowers can be allowed more debt or avoid liquidation than exact price math supports. At the lower boundary, if a positive source price is smaller than one target-precision unit, scale-down floors the normalized price to zero. Custom and batch helpers reject that zero directly, while Reflector and fallback results are rejected by the contract-level price-change validation before caching, so the zero case fails closed but can make affected assets unpriceable under a low precision configuration.
Failure mode. The OracleConfig price_precision domain validates only an upper bound, while normalization floors high-precision oracle prices to the configured target precision without enforcing a safe minimum precision or applying conservative debt-side rounding. Router accounting then treats the normalized price as an exact WAD-convertible input.
Trigger path. Governance or the oracle admin configures a low price_precision value, source oracle prices have fractional components at that target precision, and debt-side truncation is borrower-favorable relative to collateral price truncation. The scale-down-to-zero variant requires a configured target precision below the source price granularity, a positive source price smaller than one target-precision unit, and a price path that reaches normalization.
Why it matters. Debt can be underpriced in borrow-capacity checks, health-factor denominators, withdrawal safety checks, and liquidation eligibility. The error is bounded by less than one configured price unit per token, but at low precision and large position sizes it can be material enough to create undercollateralized positions or delay liquidation. If normalization floors a positive price to zero, the oracle rejects the read before caching or returning it. That avoids unsafe zero-price acceptance, but it can make otherwise live Reflector, fallback, custom, or batch-priced assets unavailable for supply, borrow, withdrawal, liquidation, transfer-risk, and swap checks until precision or source configuration is corrected.
Evidence. contracts/price-oracle/src/contract.rs:565-574; contracts/price-oracle/src/oracle.rs:205-228; contracts/shared/src/constants.rs:49-57; contracts/kinetic-router/src/calculation.rs:13-27; contracts/kinetic-router/src/calculation.rs:98-430; contracts/kinetic-router/src/validation.rs:158-325; contracts/price-oracle/src/contract.rs:842-850; contracts/price-oracle/src/oracle.rs:153-160; contracts/price-oracle/src/oracle.rs:193-200; contracts/price-oracle/src/oracle.rs:350-352; contracts/price-oracle/src/oracle.rs:411-413; contracts/price-oracle/src/oracle.rs:469-471
Code context:
// contracts/price-oracle/src/contract.rs:565-574
pub fn set_oracle_config(env: Env, caller: Address, config: OracleConfig) -> Result<(), OracleError> {
admin::require_admin(&env, &caller).map_err(|_| OracleError::Unauthorized)?;
caller.require_auth();
// M-05
if config.price_precision > 18 {
return Err(OracleError::InvalidConfig);
}
storage::set_oracle_config(&env, &config);
// contracts/price-oracle/src/oracle.rs:205-218
fn normalize_price(
price: u128,
source_decimals: u32,
target_decimals: u32,
) -> Result<u128, crate::OracleError> {
if source_decimals == target_decimals {
return Ok(price);
}
if source_decimals > target_decimals {
let scale_down = 10_u128.checked_pow(
source_decimals.checked_sub(target_decimals)
.ok_or(crate::OracleError::MathOverflow)?
).ok_or(crate::OracleError::MathOverflow)?;
// ...
Recommended change Enforce a minimum oracle price precision suitable for risk accounting, or preserve source precision through exact rational valuation. If target downscaling is unavoidable, use conservative rounding by rounding debt prices up and collateral prices down in enforceable risk checks. Also reject precision settings that would make supported asset prices quantize to zero, or perform normalization with enough retained precision and apply path-specific conservative rounding before enforcing nonzero price checks.
Patch sketch:
diff --git a/contracts/price-oracle/src/contract.rs b/contracts/price-oracle/src/contract.rs
@@
- if config.price_precision > 18 {
+ if config.price_precision < MIN_RISK_PRICE_PRECISION || config.price_precision > 18 {
return Err(OracleError::InvalidConfig);
}
QA-49: Receive-aToken liquidations can under-transfer collateral
Assessment The direct liquidation receive-aToken branch transfers liquidator collateral and protocol fee collateral through separate transferonliquidation calls, but then accounts the nominal sum as removed from the borrower. transferonliquidation floor-rounds each requested amount in scaled units and returns only first-balance status, so the live aToken value actually moved can be lower than the nominal amount used for payout, fee, remaining-collateral accounting, and collateral-bit clearing.
Failure mode. The router does not reconcile nominal aToken liquidation transfer amounts with the actual scaled units moved by transferonliquidation. Independent floor rounding of the liquidator and treasury transfers can under-transfer while router accounting uses the nominal split.
Trigger path. A receive-aToken direct liquidation has a collateral split that is not exactly representable as scaled aTokens at the current liquidity index, especially when protocol fee and liquidator portions are both nonzero.
Why it matters. Liquidators and treasury can receive less live aToken collateral than calculated, while the borrower debt is still reduced and router remaining-collateral accounting can treat nominal collateral as removed. In full nominal removals, residual borrower scaled collateral can remain after the collateral bit is cleared. The value mismatch is bounded by less than one scaled aToken unit per transfer but is repeatable.
Evidence. contracts/kinetic-router/src/liquidation.rs:387-487,560-568,630-655; contracts/a-token/src/contract.rs:471-527
Code context:
// contracts/kinetic-router/src/liquidation.rs:387-400
if _receive_a_token {
// WP-M3: Transfer aTokens from borrower to liquidator (no underlying movement)
let mut xfer_args = Vec::new(env);
xfer_args.push_back(IntoVal::into_val(&env.current_contract_address(), env));
xfer_args.push_back(user.to_val());
xfer_args.push_back(liquidator.to_val());
xfer_args.push_back(IntoVal::into_val(&liquidator_collateral, env));
xfer_args.push_back(IntoVal::into_val(
&updated_collateral_reserve_data.liquidity_index,
env,
));
let xfer_result = env.try_invoke_contract::<bool, KineticRouterError>(
&updated_collateral_reserve_data.a_token_address,
// ...
// contracts/a-token/src/contract.rs:493-500
// Single ray_div_down for both debit and credit (prevents total_supply_scaled drift)
// M-1: Use ray_div_down to match burn_scaled rounding and avoid protocol fee revert
let scaled_u128 = ray_div_down(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let amount_scaled = safe_u128_to_i128(&env, scaled_u128);
if amount_scaled == 0 {
return Err(TokenError::InvalidAmount);
}
Recommended change Have transferonliquidation return the actual scaled units and live value moved, and use those values for fee accounting, liquidator payout, remaining-collateral checks, bitmap updates, and events. Alternatively combine transfers before rounding or reject non-representable liquidation splits.
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- ) -> Result<bool, TokenError> {
+ ) -> Result<(bool, i128, u128), TokenError> {
@@
- Ok(is_first)
+ let actual_live = ray_mul(&env, scaled_u128, index).map_err(|_| TokenError::InvalidIndex)?;
+ Ok((is_first, amount_scaled, actual_live))
}
QA-50: : [omitted]
This finding has been omitted from the report as it was deemed invalid by the judge.
QA-51: Idempotent collateral toggles unnecessarily depend on oracle/health-factor checks
Assessment
set_user_use_reserve_as_coll(false) does not check whether the bit is already false before storing and computing account data. set_user_use_reserve_as_coll(true) verifies price before checking whether the bit is already true. No-op calls can fail on oracle/HF dependencies when no state change occurs.
Traceability. OBL-836, OBL-837
Code context:
// contracts/kinetic-router/src/router.rs:1276-1289
if use_as_collateral {
crate::price::verify_oracle_price_exists_and_nonzero(&env, &asset)?;
let mut user_config = storage::get_user_configuration(&env, &caller);
user_config.set_using_as_collateral(k2_shared::safe_reserve_id(&env, reserve_data.id), true);
storage::set_user_configuration(&env, &caller, &user_config);
} else {
// C-02
// factor calculation reflects the post-toggle state (asset no longer
// counted as collateral). If HF is too low, revert the toggle.
let mut user_config = storage::get_user_configuration(&env, &caller);
user_config.set_using_as_collateral(k2_shared::safe_reserve_id(&env, reserve_data.id), false);
storage::set_user_configuration(&env, &caller, &user_config);
// ...
Recommended change Short-circuit when the requested state matches the current bitmap state.
Patch sketch:
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
let mut user_config = storage::get_user_configuration(&env, &caller);
+ if user_config.is_using_as_collateral(reserve_id) == use_as_collateral {
+ return Ok(());
+ }
QA-52: set_flash_loan_premium_max does not cross-validate against current base + surcharge total
Assessment
set_flash_loan_premium_max (params.rs:26–43) validates that the new maximum does not exceed 10000 bps (100%), but does not check whether the current flash_loan_premium + flash_liquidation_premium already exceeds the new maximum. An admin lowering max below the existing combined premiums creates an inconsistent state where the invariant base + surcharge <= max is violated. The individual premium setters check combined > max, but the max setter does not retroactively enforce the invariant.
Review trail. Generic code-level pass — params.rs
Code context:
// contracts/kinetic-router/src/params.rs:26-35
pub fn set_flash_loan_premium_max(env: Env, max_premium_bps: u128) -> Result<(), KineticRouterError> {
let admin = storage::get_pool_admin(&env)?;
admin.require_auth();
// N-10
if max_premium_bps > 10000 {
return Err(KineticRouterError::InvalidAmount);
}
storage::set_flash_loan_premium_max(&env, max_premium_bps);
Recommended change
Add a cross-validation check: if new_max < current_base + current_surcharge { return Err(...) }.
Patch sketch:
diff --git a/contracts/kinetic-router/src/params.rs b/contracts/kinetic-router/src/params.rs
@@
if max_premium_bps > 10000 { return Err(KineticRouterError::InvalidAmount); }
+ let current_total = storage::get_flash_loan_premium(&env)
+ .checked_add(storage::get_flash_liquidation_premium(&env))
+ .ok_or(KineticRouterError::MathOverflow)?;
+ if current_total > max_premium_bps { return Err(KineticRouterError::InvalidAmount); }
QA-53: Supply caps can undercount post-mint aggregate supply
Assessment Supply-cap validation computes a separately rounded current live supply and adds the requested direct-supply amount or collateral-swap destination amount before mint. The aToken ledger ultimately represents aggregate supply as total scaled supply multiplied by the liquidity index. At a cap boundary, separately rounded current and candidate values can undercount the post-mint aggregate live supply by one smallest unit, so a direct supply or collateral swap can be accepted even though the aggregate indexed supply after mint is above the configured cap.
Failure mode. The cap gate reconstructs post-mint total additively from pre-mint live supply plus the candidate direct-supply or destination-swap amount, instead of comparing the cap against the aggregate live supply represented by post-mint scaled total supply. Half-up ray rounding is not additive across separately rounded scaled components.
Trigger path. A reserve has a configured supply cap; current scaled supply and the candidate direct-supply or destination-swap scaled mint have rounding remainders that combine across the aggregate raymul(totalscaledaftermint, liquidity_index) value; and the operation is submitted near the cap boundary after the larger settlement and cap-conversion roots are fixed.
Why it matters. The cap overage is bounded by scaled-index rounding, but the configured reserve supply cap can be exceeded at the smallest-unit boundary through direct supply or collateral-swap destination minting. This weakens reserve-limit accounting and is the supply-side counterpart of the already tracked aggregate borrow-cap rounding issue.
Evidence. contracts/kinetic-router/src/validation.rs:331-362; contracts/kinetic-router/src/operations.rs:45-82; contracts/kinetic-router/src/swap.rs:206-212; contracts/a-token/src/contract.rs:224-251; contracts/shared/src/utils.rs:37-47
Code context:
// contracts/kinetic-router/src/validation.rs:337-350
let supply_cap = reserve_data.configuration.get_supply_cap();
if supply_cap > 0 {
// Use get_total_supply_with_index
let current_supply = calculation::get_total_supply_with_index(
env,
&reserve_data.a_token_address,
liquidity_index,
)?;
let decimals = reserve_data.configuration.get_decimals();
let multiplier = 10u128
.checked_pow(decimals as u32)
.ok_or(KineticRouterError::MathOverflow)?;
let cap_in_smallest_units = supply_cap
// ...
// contracts/kinetic-router/src/operations.rs:69-82
// Mint aToken
let mut args = Vec::new(&env);
args.push_back(env.current_contract_address().into_val(&env));
args.push_back(on_behalf_of.to_val());
args.push_back(amount.into_val(&env));
args.push_back(updated_reserve_data.liquidity_index.into_val(&env));
let mint_result = env.try_invoke_contract::<(bool, i128, i128), KineticRouterError>(
&updated_reserve_data.a_token_address,
&Symbol::new(&env, "mint_scaled"),
args,
);
let is_first_supply = match mint_result {
// ...
Recommended change Validate supply caps against the aggregate live supply represented by post-mint scaled total supply, or use an equivalent conservative aggregate calculation. Reject candidate direct supplies and destination swaps whose post-mint aggregate indexed supply would exceed the cap, rather than adding separately rounded live values.
Patch sketch:
diff --git a/contracts/kinetic-router/src/validation.rs b/contracts/kinetic-router/src/validation.rs
@@
- let new_total_supply = current_supply.checked_add(amount)?;
+ let minted_scaled = ray_div_down(env, amount, liquidity_index)?;
+ let post_scaled_total = current_scaled_total.checked_add(minted_scaled)?;
+ let new_total_supply = ray_mul(env, post_scaled_total, liquidity_index)?;
QA-54: Reserve drops can strand user bitmap slots across re-registration
Assessment dropreserve only checks aggregate scaled aToken supply and debt-token supply before deleting ReserveData, removing the reserve from RLIST, and removing RID2ADDR for the retired id. It cannot enumerate or clear per-user UserConfiguration bits. If a user already has a stale collateral or borrowing bit for that zero-supply reserve, the bit becomes orphaned once the reserve metadata is removed. Re-registering the same asset uses a new retired-id-safe bitmap slot, so the old bit is not overwritten, but it continues to count in countactivereserves and can keep hasanyborrowing true. The swap health-factor validator also loops to nextreserve_id and fails closed on an active bit whose RID2ADDR mapping has been removed.
Failure mode. UserConfiguration is keyed only by reserve id, while user-facing cleanup routes are keyed by asset and first load ReserveData. drop_reserve removes the asset-to-reserve metadata and id-to-asset mapping without either proving no user bitmap bits exist for the retired id or preserving a cleanup path for users to clear orphaned bits. The aggregate scaled-supply/debt checks prove no live token balances remain, but they do not prove the user bitmap is empty.
Trigger path. A reserve has zero aggregate scaled aToken supply and zero aggregate scaled debt so it can be dropped, but one or more users retain stale bitmap bits for that reserve id. Stale bits can arise from the already reported zero-balance collateral and zero-debt borrowing synchronization cases, or from collateral enablement on a zero-balance reserve. Governance drops the reserve and may later register the same asset or another reserve under a new id.
Why it matters. Affected users can carry permanent phantom active-reserve slots after the reserve is dropped. Shared account-data/HF calculation skips the dropped id once RID2ADDR is removed; in the ordinary dropreserve path this does not hide live collateral or debt because dropreserve first requires both aggregate scaled aToken supply and aggregate scaled debt to be zero. The remaining impact is state availability: first supply, first borrow, aToken receiver finalization, and receive-aToken liquidation paths use countactivereserves and can reject otherwise valid new positions once stale dropped-reserve slots push the user to MAXUSERRESERVES. A stale dropped borrowing bit can also keep hasanyborrowing true, routing debt-free users through unnecessary risk/oracle checks. swapcollateral can also fail in validateswaphealthfactor when a user with debt, or with a stale borrowing bit, has any active stale dropped-reserve bit outside the selected swap pair. Re-registration under a new id does not repair the old bit, and asset-address cleanup cannot clear it after ReserveData is removed.
Evidence. contracts/kinetic-router/src/reserve.rs:437-475; contracts/kinetic-router/src/storage.rs:344-394,673-701; contracts/kinetic-router/src/operations.rs:96-110,415-428; contracts/kinetic-router/src/router.rs:1270-1295,1874-1883; contracts/kinetic-router/src/calculation.rs:617-634,670-710; contracts/shared/src/utils.rs:509-531
Code context:
// contracts/kinetic-router/src/reserve.rs:437-450
pub fn drop_reserve(
env: Env,
caller: Address,
asset: Address,
) -> Result<(), KineticRouterError> {
storage::validate_pool_configurator(&env, &caller)?;
caller.require_auth();
let _reserve_data = storage::get_reserve_data(&env, &asset)?;
let a_token_scaled_supply: i128 = match env.try_invoke_contract::<i128, KineticRouterError>(
&_reserve_data.a_token_address,
&Symbol::new(&env, "scaled_total_supply"),
soroban_sdk::vec![&env],
) {
// ...
// contracts/kinetic-router/src/storage.rs:344-357
pub fn get_next_reserve_id(env: &Env) -> u32 {
if env.storage().persistent().has(&NEXT_RESERVE_ID) {
env.storage()
.persistent()
.extend_ttl(&NEXT_RESERVE_ID, TTL_THRESHOLD, TTL_EXTENSION);
}
env.storage().persistent().get(&NEXT_RESERVE_ID).unwrap_or(0)
}
/// Increment and get next reserve ID
/// Bounded by MAX_RESERVES (64) - enforced in reserve creation
pub fn increment_and_get_reserve_id(env: &Env) -> u32 {
let current = get_next_reserve_id(env);
// I-04
// ...
Recommended change Before dropping a reserve, provide an explicit cleanup/migration path for user bitmap entries or keep a tombstone mapping that allows users/admin maintenance to clear the retired id. At minimum, expose a guarded clear-by-reserve-id path for retired zero-supply reserves, and make active-reserve counting and swap health-factor iteration ignore or prune retired ids whose mapping no longer resolves. Continue to keep reserve ids retired on re-registration to avoid true bitmap slot reuse.
Patch sketch:
diff --git a/contracts/kinetic-router/src/reserve.rs b/contracts/kinetic-router/src/reserve.rs
@@
- storage::remove_reserve_address_by_id(&env, reserve_id);
+ storage::mark_retired_reserve_id(&env, reserve_id);
+ storage::remove_reserve_address_by_id(&env, reserve_id);
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
+ pub fn clear_retired_reserve_bit(env: Env, caller: Address, reserve_id: u8) -> Result<(), KineticRouterError> {
+ caller.require_auth();
+ storage::clear_user_retired_reserve(&env, &caller, reserve_id)
+ }
QA-55: Borrows can overmint debt and undercheck risk limits
Assessment Borrow validates borrow cap, debt ceiling, user borrow capacity, and aToken liquidity against pre-mint reconstructed values, then mints debt with debt-token:mintscaled. The debt token rounds the scaled mint upward with raydivup(amount, index), so the live debt value charged can exceed both the underlying disbursed and the value that passed pre-mint risk/cap checks. A positive base variable borrow rate can also leave currentvariableborrowrate nonzero at zero utilization after all debt is repaid; later updatestate calls then grow variableborrow_index while aggregate scaled debt is zero, enlarging the one-scaled-unit overmint bound for the next borrower. Even after substituting the rounded live minted amount, cap and ceiling checks must compare against post-borrow aggregate scaled debt because separately rounded current and candidate debt values can undercount the aggregate live total.
Failure mode. The router treats the requested borrow amount as both the disbursement amount and future live debt liability, while the debt token settles liability using a rounded-up scaled amount and does not return the live minted value for precheck/reconciliation. Reserve-limit validation also reconstructs post-borrow total debt additively instead of comparing against the aggregate live debt represented by post-mint scaled total debt. The borrow index is advanced from stored currentvariableborrow_rate without checking whether aggregate scaled debt is zero, so a positive base-rate idle interval can increase the rounding unit before the next borrow.
Trigger path. A borrower submits an amount that is not exactly representable as scaled debt units at the current variable borrow index, or acts at a cap/ceiling boundary where separately rounded current and candidate debt values undercount the post-borrow aggregate live debt. The amplified idle-index case additionally needs a reserve with positive basevariableborrowrate, all prior debt repaid so total scaled debt is zero, elapsed time before the next borrow, and an updatestate call that advances variableborrowindex before mint.
Why it matters. The borrower can be charged more debt value than the underlying received, and the actual minted or aggregate liability can exceed the nominal amount checked against capacity, reserve caps, debt ceiling, or liquidity. The per-call excess is bounded by one scaled debt unit at the active borrow index, but positive-base idle index growth can make that bound larger before the next borrower enters.
Evidence. contracts/kinetic-router/src/operations.rs:324-413; contracts/kinetic-router/src/validation.rs:160-415; contracts/kinetic-router/src/calculation.rs:463-486,938-996; contracts/interest-rate-strategy/src/contract.rs:95-106; contracts/debt-token/src/contract.rs:174-202; contracts/shared/src/utils.rs:95-109,151-215
Code context:
// contracts/kinetic-router/src/operations.rs:324-337
// Re-validate cap after interest accrual (interest increases total debt)
validation::validate_borrow_cap_after_interest(&env, amount, &updated_reserve_data, &asset, updated_reserve_data.variable_borrow_index)?;
// NEW-01
let oracle_config = crate::price::get_oracle_config(&env)?;
let oracle_to_wad = calculate_oracle_to_wad_factor(oracle_config.price_precision);
validation::validate_user_can_borrow(&env, &on_behalf_of, &asset, amount, &updated_reserve_data, oracle_to_wad)?;
let a_token_liquidity = calculation::get_atoken_underlying_balance(
&env,
&asset,
&updated_reserve_data.a_token_address,
)?;
if a_token_liquidity < amount {
// ...
// contracts/debt-token/src/contract.rs:174-181
// M-14
let amount_scaled = ray_div_up(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let current_scaled_debt = storage::get_scaled_debt(&env, &on_behalf_of);
// WP-L3: Check actual debt before mint to determine if this is the first borrow
let is_first_borrow = current_scaled_debt == 0;
let new_scaled_debt = current_scaled_debt.checked_add(amount_scaled)
.ok_or(TokenError::TransferFailed)?;
storage::set_scaled_debt(&env, &on_behalf_of, new_scaled_debt);
Recommended change Compute or return the post-borrow aggregate live debt represented by scaled debt after mint before accepting the borrow, and use that value for borrow-cap, debt-ceiling, available-borrow, liquidity, event, and disbursement reconciliation. Alternatively reject borrow amounts whose rounded scaled mint or aggregate rounded debt would exceed the requested live amount or configured reserve limits. Consider skipping variable-borrow-index accrual, or otherwise preserving a principal-neutral index, while aggregate scaled debt is zero.
Patch sketch:
diff --git a/contracts/kinetic-router/src/operations.rs b/contracts/kinetic-router/src/operations.rs
@@
- match mint_result {
- Ok(Ok(_)) => {}
+ let (_, _, total_debt_scaled) = match mint_result {
+ Ok(Ok(values)) => values,
Ok(Err(_)) | Err(_) => return Err(KineticRouterError::DebtTokenMintFailed),
- }
+ };
+ let post_total_debt = ray_mul(&env, safe_i128_to_u128(&env, total_debt_scaled), updated_reserve_data.variable_borrow_index)?;
+ validation::validate_borrow_limits_post_mint(&env, post_total_debt, &updated_reserve_data, &asset)?;
QA-56: Incentives delete_reward_token does not verify all users have claimed before deletion
Assessment
delete_reward_token (incentives/contract.rs:780-814) permanently removes a reward token from the asset’s enumeration list. It checks that both supply and borrow configs are inactive, but does not verify that all users have claimed their accrued rewards. After deletion, users who still have accrued > 0 in their UserRewardData cannot claim because the reward token is no longer in the enumeration list and claim_all_rewards iterates get_reward_tokens().
Single-token claim_rewards still works since it doesn’t iterate the list, but claim_all_rewards will silently skip the deleted token’s accumulated rewards.
Evidence. contracts/incentives/src/contract.rs:780-814
Code context:
// contracts/incentives/src/contract.rs:789-802
// Ensure both supply and borrow configs are inactive
for reward_type in [storage::REWARD_TYPE_SUPPLY, storage::REWARD_TYPE_BORROW] {
if let Some(config) =
storage::get_asset_reward_config(&env, &asset, &reward_token, reward_type)
{
if config.is_active {
return Err(IncentivesError::RewardTokenStillActive);
}
}
}
// Remove from registered list
storage::remove_reward_token(&env, &asset, &reward_token);
// ...
Review trail. Generic code-level pass (find-qa-weak)
Recommended change Either prevent deletion when any user has unclaimed rewards, or document that users must claim before the admin deletes a reward token.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
- // Remove from registered list
+ if storage::get_unclaimed_reward_total(&env, &asset, &reward_token) != 0 {
+ return Err(IncentivesError::UnclaimedRewardsExist);
+ }
+ // Remove from registered list
QA-57: aToken transfer_on_liquidation uses ray_div_down while regular transfers use ray_div_up
Assessment
ATokenContract::transfer_on_liquidation (a-token/contract.rs:495) computes scaled_u128 = ray_div_down(amount, index), meaning the liquidator receives slightly fewer scaled units per underlying amount. Regular transfer_internal (line 629) uses ray_div_up, debiting slightly more scaled units from the sender.
This asymmetry means liquidation transfers are less generous to the receiver compared to regular transfers. While individually each rounding direction is defensible, the inconsistency could confuse integrators expecting uniform behavior.
Evidence. contracts/a-token/src/contract.rs:495 vs :629
Code context:
// contracts/a-token/src/contract.rs:493-500
// Single ray_div_down for both debit and credit (prevents total_supply_scaled drift)
// M-1: Use ray_div_down to match burn_scaled rounding and avoid protocol fee revert
let scaled_u128 = ray_div_down(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let amount_scaled = safe_u128_to_i128(&env, scaled_u128);
if amount_scaled == 0 {
return Err(TokenError::InvalidAmount);
}
// contracts/a-token/src/contract.rs:627-630
// S-04: Compute scaled amounts and post-transfer balances
let amount_u128 = safe_i128_to_u128(&env, amount);
let scaled_u128 = ray_div_up(&env, amount_u128, liquidity_index).map_err(|_| TokenError::InvalidIndex)?;
let scaled_amount = safe_u128_to_i128(&env, scaled_u128);
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Document the rationale for the asymmetry, or unify to ray_div_down for both (protocol-conservative).
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- let scaled_u128 = ray_div_up(&env, amount_u128, liquidity_index).map_err(|_| TokenError::InvalidIndex)?;
+ let scaled_u128 = ray_div_down(&env, amount_u128, liquidity_index).map_err(|_| TokenError::InvalidIndex)?;
+ if scaled_u128 == 0 { return Err(TokenError::InvalidAmount); }
QA-58: Configured incentives break factory reserve deployment
Assessment
pool-configurator:deploy_and_init_reserve registers the new reserve through the router. The router already propagates the configured incentives address into the freshly deployed aToken and debt token. The configurator then repeats the same token calls directly while passing the router address as data, but it cannot satisfy kinetic_router_address.require_auth() from the configurator context. Once incentives are configured, the duplicate post-registration step can make factory reserve deployment fail with TokenInitializationFailed.
Code context:
// contracts/kinetic-router/src/reserve.rs:113-116
if let Some(incentives) = storage::get_incentives_contract(&env) {
propagate_incentives_to_tokens(&env, &a_token_impl, &variable_debt_impl, &incentives)?;
}
// contracts/pool-configurator/src/reserve.rs:842-873
if let Some(incentives) = incentives_contract {
env.try_invoke_contract::<Result<(), TokenError>, KineticRouterError>(
&a_token_address,
&Symbol::new(env, "set_incentives_contract"),
vec![env, kinetic_router_address.clone().into_val(env), incentives.clone().into_val(env)],
)?;
// same duplicate call for debt token
}
Recommended change Remove the duplicate configurator-side incentives propagation and rely on the router-side call, or expose a router entrypoint that performs any later sync from the router context.
Patch sketch:
diff --git a/contracts/pool-configurator/src/reserve.rs b/contracts/pool-configurator/src/reserve.rs
@@
- let incentives_contract: Option<Address> = env.invoke_contract(&kinetic_router_address, &Symbol::new(env, "get_incentives_contract"), Vec::new(env));
- if let Some(incentives) = incentives_contract {
- // duplicate set_incentives_contract calls into aToken and debtToken
- }
+ // Router::init_reserve already propagates incentives to both tokens from router context.
Ok((a_token_address, debt_token_address))
QA-59: claim_rewards / claim_all_rewards drain accrued state before validating transfer domain
Assessment
Both claim paths zero user_data.accrued slots before checking that the aggregate payout fits i128 for the reward-token transfer. A payout above i128::MAX aborts at conversion after state mutation. Soroban rollback prevents committed loss, but the semantics are incorrect.
Traceability. OBL-736, OBL-739
Code context:
// contracts/incentives/src/contract.rs:299-312
if claimable > 0 {
// Update user's accrued balance
user_data.accrued = user_data
.accrued
.checked_sub(claimable)
.ok_or(IncentivesError::MathOverflow)?;
// Save updated user data
storage::set_user_reward_data(
&env,
&asset,
&reward_token,
&caller,
reward_type,
// ...
Recommended change Validate the aggregate payout fits the transfer domain before mutating accrued state.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
- // PHASE 3: Claim rewards and update state
+ safe_u128_to_i128(&env, amount_to_claim);
+ // PHASE 3: Claim rewards and update state
QA-60: Collateral swaps bypass the destination first-deposit minimum
Assessment Normal supply rejects first aToken supplies below MINFIRSTDEPOSIT, but collateral-swap destination settlement discards the isfirst flag returned by mintscaled and accepts any positive post-fee destination amount. A user can therefore open a destination collateral position through swap with a seed amount that direct supply would reject.
Failure mode. The collateral-swap destination mint path calls aToken mint_scaled directly but does not mirror the first-supply minimum check implemented in operations::supply.
Trigger path. The caller has no existing destination aToken balance for the reserve, and the post-fee collateral swap output is positive but below MINFIRSTDEPOSIT while satisfying the other swap and health-factor checks.
Why it matters. The route bypasses the protocol minimum seed for first aToken supplies, allowing dust-sized destination collateral positions through swap. Direct value impact is limited, but the safety policy is not consistently enforced.
Evidence. contracts/kinetic-router/src/swap.rs:299-316; contracts/kinetic-router/src/operations.rs:89-91; contracts/a-token/src/contract.rs:204-251; contracts/shared/src/constants.rs:73
Code context:
// contracts/kinetic-router/src/swap.rs:298-311
// Mint aTokens for the new collateral (amount after fee deduction)
let mut mint_args = Vec::new(&env);
mint_args.push_back(pool_address.to_val());
mint_args.push_back(caller.to_val());
mint_args.push_back(amount_to_supply.into_val(&env));
mint_args.push_back(updated_to_reserve_data.liquidity_index.into_val(&env));
let mint_result = env.try_invoke_contract::<(bool, i128, i128), KineticRouterError>(
&updated_to_reserve_data.a_token_address,
&Symbol::new(&env, "mint_scaled"),
mint_args,
);
let (to_user_new_scaled_balance, to_supply_scaled) = match mint_result {
// ...
// contracts/kinetic-router/src/operations.rs:89-91
// M-04: Enforce minimum first deposit to prevent share inflation attacks
if is_first_supply && amount < k2_shared::MIN_FIRST_DEPOSIT {
return Err(KineticRouterError::InvalidAmount);
Recommended change Use the isfirst flag returned by destination mintscaled and reject amounttosupply below MINFIRSTDEPOSIT before accepting swap settlement, preferably before transferring destination underlying into the aToken.
Patch sketch:
diff --git a/contracts/kinetic-router/src/swap.rs b/contracts/kinetic-router/src/swap.rs
@@
- let (to_user_new_scaled_balance, to_supply_scaled) = match mint_result {
- Ok(Ok((_is_first, user_scaled_bal, total_supply_scaled))) => (user_scaled_bal, total_supply_scaled),
+ let (is_first, to_user_new_scaled_balance, to_supply_scaled) = match mint_result {
+ Ok(Ok((is_first, user_scaled_bal, total_supply_scaled))) => (is_first, user_scaled_bal, total_supply_scaled),
Ok(Err(_)) | Err(_) => return Err(KineticRouterError::ATokenMintFailed),
};
+ if is_first && amount_to_supply < k2_shared::MIN_FIRST_DEPOSIT {
+ return Err(KineticRouterError::InvalidAmount);
+ }
QA-61: Reserve strategy setters can bind unusable or wrong rate strategies
Assessment Factory/manual reserve-registration paths and the router updatereserveratestrategy path store the caller-supplied interest-rate strategy address for a reserve without probing that the strategy can quote rates for the target asset. Later supply, withdraw, borrow, and repay flows call calculateinterest_rates on the stored address with that asset. If the configured address is wrong for the reserve, wrong-interface, rejecting, or a callable but uninitialized strategy, user operations can fail during rate updates or commit zero rates from default parameters.
Failure mode. The configurator and router treat interestratestrategy as an opaque trusted address during reserve registration and strategy updates. The update path only authenticates the configurator and writes the new address into ReserveData for the supplied asset; it does not verify a successful calculateinterestrates(asset, …) quote before persisting the dependency.
Trigger path. A pool/configurator admin initializes a reserve or later updates an existing reserve with an unusable strategy address, a wrong-reserve or wrong-interface strategy, a rejecting strategy, or a callable but uninitialized interest-rate-strategy contract.
Why it matters. The affected reserve can remain registered but user operations that need post-action rate updates can revert, or can commit zero liquidity and variable-borrow rates from an uninitialized strategy. Through the update route this can break or misprice an already live reserve until administration replaces or initializes the strategy.
Evidence. contracts/pool-configurator/src/reserve.rs:6-120,441-460,720-835; contracts/kinetic-router/src/reserve.rs:5-107,357-370; contracts/kinetic-router/src/calculation.rs:938-995; contracts/interest-rate-strategy/src/contract.rs:42-58
Code context:
// contracts/pool-configurator/src/reserve.rs:6-19
pub fn init_reserve(
env: &Env,
caller: &Address,
underlying_asset: &Address,
a_token_impl: &Address,
variable_debt_impl: &Address,
interest_rate_strategy: &Address,
treasury: &Address,
params: InitReserveParams,
) -> Result<(), KineticRouterError> {
storage::validate_admin(env, caller)?;
caller.require_auth();
// Emergency pause mechanism: block new reserve deployments during incidents.
// ...
// contracts/kinetic-router/src/reserve.rs:5-18
pub fn init_reserve(
env: Env,
caller: Address,
underlying_asset: Address,
a_token_impl: Address,
variable_debt_impl: Address,
interest_rate_strategy: Address,
_treasury: Address,
params: InitReserveParams,
) -> Result<(), KineticRouterError> {
storage::validate_pool_configurator(&env, &caller)?;
caller.require_auth();
if storage::get_reserve_data(&env, &underlying_asset).is_ok() {
// ...
Recommended change Probe the supplied strategy during both reserve registration and strategy update for the target asset, and require a successful non-default initialized quote. Alternatively restrict strategies through a registry of initialized implementations. In the strategy contract, make calculateinterestrates reject before initialization instead of defaulting missing curve parameters to zero.
Patch sketch:
diff --git a/contracts/kinetic-router/src/reserve.rs b/contracts/kinetic-router/src/reserve.rs
@@
- reserve_data.interest_rate_strategy_address = strategy;
+ probe_interest_rate_strategy(&env, &strategy, &asset)?;
+ reserve_data.interest_rate_strategy_address = strategy;
QA-62: Incentive emissions during zero-supply windows are permanently lost with no recovery mechanism
Assessment
update_asset_reward_index (incentives/calculation.rs:61-66) returns early with effective_time = 0 when total_supply == 0, meaning no rewards accrue during zero-supply windows. This is correct to prevent division by zero. However, the emission tokens allocated during this window via fund_rewards remain in the contract permanently — no user can claim them (no accrual happened), and there is no recover_unaccrued or sweep function for the emission manager to retrieve them.
For a new or low-activity reserve, the window between configure_asset_rewards (which starts emissions) and the first supply can be non-trivial. Emissions during this gap are permanently stranded.
Impact: Accumulative — every zero-supply window loses emission_per_second * window_duration tokens. For high-emission reserves with supply volatility, the total can become material.
Evidence. contracts/incentives/src/calculation.rs:61-66; contracts/incentives/src/contract.rs (no recovery function)
Code context:
// contracts/incentives/src/calculation.rs:60-66
// If no effective time or zero supply, only update timestamp
if effective_time == 0 || total_supply == 0 {
return Ok(AssetRewardIndex {
index: current_index.index,
last_update_timestamp: current_timestamp,
});
}
Review trail. Suboptimal design review (DTO-12)
Recommended change
Add an admin recover_unaccrued function that calculates total emissions minus total claimed and allows withdrawal of the surplus. Alternatively, do not start emissions until total_supply > 0.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
+ pub fn recover_unaccrued(env: Env, caller: Address, reward_token: Address, to: Address, amount: u128)
+ -> Result<(), IncentivesError> {
+ storage::validate_emission_manager(&env, &caller)?;
+ caller.require_auth();
+ require_unaccrued_surplus(&env, &reward_token, amount)?;
+ token::Client::new(&env, &reward_token).transfer(&env.current_contract_address(), &to, &safe_u128_to_i128(&env, amount));
+ Ok(())
+ }
QA-63: Standalone liquidation wrapper uses unbounded per-user history vector
Assessment
storage::add_liquidation_record appends to a persistent ULIQUID(user) vector without bounds, sharding, or pagination. A borrower with enough prior wrapper liquidations can make the history write exceed storage-entry limits, blocking future wrapper executions while direct router liquidation remains available.
Traceability. OBL-842
Code context:
// contracts/liquidation-engine/src/storage.rs:154-166
// Add to user's liquidation list (store just the ID)
let mut user_liquidations = get_user_liquidation_ids(env, &liquidation.user);
user_liquidations.push_back(liquidation_id);
let user_list_key = (symbol_short!("ULIQUID"), liquidation.user.clone());
env.storage()
.persistent()
.set(&user_list_key, &user_liquidations);
env.storage()
.persistent()
.extend_ttl(&user_list_key, TTL_THRESHOLD, TTL_EXTENSION);
// Increment total count (unbounded counter, but only increments on liquidations)
set_total_liquidations_count(env, liquidation_id.checked_add(1).expect("Liquidation ID overflow"));
Recommended change Bound the history vector length, use ring-buffer semantics, or shard by epoch.
Patch sketch:
diff --git a/contracts/liquidation-engine/src/storage.rs b/contracts/liquidation-engine/src/storage.rs
@@
user_liquidations.push_back(liquidation_id);
+ while user_liquidations.len() > MAX_USER_LIQUIDATION_HISTORY {
+ user_liquidations.pop_front();
+ }
QA-64: Swap adapters do not validate that from_token != to_token
Assessment
Both AquariusSwapAdapter::execute_swap and SoroswapSwapAdapter::execute_swap accept from_token and to_token as parameters but never validate they are different. Passing the same token as both input and output would either fail at the pool level or produce undefined behavior depending on the DEX implementation. A defensive check would provide a clearer error.
Evidence. contracts/aquarius-swap-adapter/src/lib.rs:220-328; contracts/soroswap-swap-adapter/src/lib.rs:207-391
Code context:
// contracts/aquarius-swap-adapter/src/lib.rs:220-229
pub fn execute_swap(
env: Env,
from_token: Address,
to_token: Address,
amount_in: u128,
min_amount_out: u128,
recipient: Address,
) -> Result<u128, Error> {
if amount_in == 0 {
return Err(Error::InvalidAmount);
// contracts/soroswap-swap-adapter/src/lib.rs:207-216
pub fn execute_swap(
env: Env,
from_token: Address,
to_token: Address,
amount_in: u128,
min_amount_out: u128,
recipient: Address,
) -> Result<u128, Error> {
if amount_in == 0 {
return Err(Error::InvalidAmount);
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Add if from_token == to_token { return Err(Error::InvalidAmount); } at the start of both adapters.
Patch sketch:
diff --git a/contracts/aquarius-swap-adapter/src/lib.rs b/contracts/aquarius-swap-adapter/src/lib.rs
@@
if amount_in == 0 {
return Err(Error::InvalidAmount);
}
+ if from_token == to_token { return Err(Error::InvalidAmount); }
diff --git a/contracts/soroswap-swap-adapter/src/lib.rs b/contracts/soroswap-swap-adapter/src/lib.rs
@@
if amount_in == 0 {
return Err(Error::InvalidAmount);
}
+ if from_token == to_token { return Err(Error::InvalidAmount); }
QA-65: Upgrade co-signature documentation diverges from per-contract upgrade authority
Assessment
Architecture/admin docs describe business-logic upgrades as requiring both the upgrade admin and pool admin, while most non-router contracts authorize upgrade(new_wasm_hash) with only their local upgrade/admin key. The contest README also lists a trusted per-contract Upgrade Admin, so this is not a standalone H/M security issue under the trusted-role model, but the documentation and implementation should be made explicit.
Code context:
// contracts/pool-configurator/src/upgrade.rs:10-14
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), UpgradeError> {
let admin = admin::get_admin(&env)?;
admin.require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash);
Ok(())
}
// contracts/kinetic-router/src/upgrade.rs:33-47
let admin = admin::get_admin(&env)?;
admin.require_auth();
if let Ok(pool_admin) = storage::get_pool_admin(&env) {
pool_admin.require_auth();
}
Recommended change Either document that each contract’s trusted upgrade admin can upgrade independently, or enforce the same co-signature rule on every contract that is intended to follow the router’s dual-authorization model.
Patch sketch:
diff --git a/contracts/pool-configurator/src/upgrade.rs b/contracts/pool-configurator/src/upgrade.rs
@@
let admin = admin::get_admin(&env)?;
admin.require_auth();
+ let pool_admin = storage::get_pool_admin(&env).map_err(|_| UpgradeError::Unauthorized)?;
+ pool_admin.require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash);
QA-66: : [omitted]
This finding has been omitted from the report as it was deemed invalid by the judge.
QA-67: Factory/manual reserve deployment does not verify underlying SEP-41 or token ABI
Assessment
deploy_and_init_reserve and init_reserve accept opaque underlying_asset, a_token_impl, and variable_debt_impl addresses. They only call initialize but never probe the settlement methods (transfer, transfer_from, balance, mint_scaled, burn_scaled, etc.) that later user-facing paths depend on. A wrong-interface address can be published as a live reserve and fail only on first user interaction.
Traceability. OBL-642–OBL-645
Code context:
// contracts/pool-configurator/src/contract.rs:34-47
pub fn init_reserve(
env: Env,
caller: Address,
underlying_asset: Address,
a_token_impl: Address,
variable_debt_impl: Address,
interest_rate_strategy: Address,
treasury: Address,
params: InitReserveParams,
) -> Result<(), KineticRouterError> {
reserve::init_reserve(
&env,
&caller,
&underlying_asset,
// ...
// contracts/kinetic-router/src/reserve.rs:136-143
let result = env.try_invoke_contract::<Result<(), TokenError>, KineticRouterError>(
token, &sym, args,
);
match result {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(_))) | Ok(Err(_)) | Err(_) => {
return Err(KineticRouterError::TokenInitializationFailed);
}
Recommended change
Probe at least one settlement-critical method (e.g., balance) during reserve registration to verify ABI compatibility.
Patch sketch:
diff --git a/contracts/kinetic-router/src/reserve.rs b/contracts/kinetic-router/src/reserve.rs
@@
- storage::set_reserve_data(env, asset, &reserve_data);
+ probe_underlying_token(env, asset)?;
+ probe_atoken(env, &reserve_data.a_token_address)?;
+ probe_debt_token(env, &reserve_data.debt_token_address)?;
+ storage::set_reserve_data(env, asset, &reserve_data);
QA-68: Disabled fallback oracle state can fail typed storage lookup
Assessment
The price oracle represents a disabled fallback oracle by storing Option::<Address>::None under InstanceKey::FallbackOracle during initialization and when set_fallback_oracle(None) is called. The later fallback-selection helper get_fallback_oracle reads the same key as an Address. Soroban storage only returns None when the key is absent; if the key is present, it attempts to convert the stored value to the requested type and panics on conversion failure. As a result, a configuration with fallback intentionally disabled can fail at fallback lookup when Reflector resolution fails, instead of cleanly taking the no-fallback branch.
Failure mode. FallbackOracle storage uses inconsistent value types for disabled and enabled states: optional address for disabled writes, raw Address for enabled writes and reads.
Trigger path. The price oracle is initialized without a fallback oracle, or the oracle admin clears fallback with set_fallback_oracle(None). Later, Reflector-backed price resolution fails for an asset and the price path checks whether fallback is configured.
Why it matters. Price reads that should either use a configured fallback or return the underlying Reflector failure can instead abort at the fallback storage lookup. Any supply, borrow, withdraw, liquidation, transfer-risk, or swap path depending on that price can become unavailable while the primary Reflector source is failing and fallback is disabled.
Evidence. contracts/price-oracle/src/contract.rs:50; contracts/price-oracle/src/contract.rs:251-269; contracts/price-oracle/src/storage.rs:64-70
Code context:
// contracts/price-oracle/src/contract.rs:50-50
.set(&storage::InstanceKey::FallbackOracle, &Option::<Address>::None);
// contracts/price-oracle/src/contract.rs:251-264
pub fn set_fallback_oracle(
env: Env,
caller: Address,
fallback_contract: Option<Address>,
) -> Result<(), OracleError> {
admin::require_admin(&env, &caller).map_err(|_| OracleError::Unauthorized)?;
caller.require_auth();
// Clone for event emission
let fallback_contract_clone = fallback_contract.clone();
if let Some(fallback) = fallback_contract {
storage::set_fallback_oracle(&env, &fallback);
} else {
// ...
Recommended change
Represent disabled fallback state by removing InstanceKey::FallbackOracle, or change both setter and getter to consistently store and read Option<Address>. Avoid storing Option::<Address>::None under a key later decoded as Address.
Patch sketch:
diff --git a/contracts/price-oracle/src/contract.rs b/contracts/price-oracle/src/contract.rs
@@
- env.storage().instance().set(&storage::InstanceKey::FallbackOracle, &Option::<Address>::None);
+ env.storage().instance().remove(&storage::InstanceKey::FallbackOracle);
QA-69: Reserve-spread sweeps bypass reserve recipient lists
Assessment collectprotocolreserves transfers collected reserve spread from the reserve aToken contract to the configured treasury without checking whether that treasury is permitted by the reserve whitelist or blocked by the reserve blacklist. Permissioned reserves therefore apply recipient policy to ordinary third-party withdrawals but not to reserve-spread treasury payouts.
Failure mode. The reserve-spread collection path treats pool-admin authorization and the configured treasury address as sufficient for payout. It never calls validatereservewhitelistaccess or validatereserveblacklistaccess for the treasury before invoking aToken transferunderlyingto, and the aToken helper checks only that the caller is the pool.
Trigger path. A reserve has a non-empty whitelist that does not include the configured treasury or has the treasury in its blacklist; collectible protocol reserves are nonzero; the pool admin calls collectprotocolreserves for that reserve.
Why it matters. Restricted reserve assets can be transferred to a treasury address outside the reserve recipient policy. The transferred amount is limited to collectible reserve spread, but repeated collections weaken whitelist/blacklist controls and create inconsistent enforcement between ordinary withdrawals and protocol revenue routing.
Evidence. contracts/kinetic-router/src/treasury.rs:62-88; contracts/a-token/src/contract.rs:316-371; contracts/kinetic-router/src/operations.rs:141-147
Code context:
// contracts/kinetic-router/src/treasury.rs:62-75
// Get treasury address
let treasury = storage::get_treasury(&env).ok_or(KineticRouterError::TreasuryNotSet)?;
// Transfer reserves from aToken to treasury
// Use same pattern as liquidation_call for consistency
let mut transfer_args = Vec::new(&env);
transfer_args.push_back(env.current_contract_address().into_val(&env));
transfer_args.push_back(treasury.to_val());
transfer_args.push_back(collectible_reserves.into_val(&env));
let transfer_result = env.try_invoke_contract::<bool, KineticRouterError>(
&reserve_data.a_token_address,
&Symbol::new(&env, "transfer_underlying_to"),
transfer_args,
// ...
// contracts/a-token/src/contract.rs:316-329
pub fn transfer_underlying_to(
env: Env,
caller: Address,
target: Address,
amount: u128,
) -> Result<bool, TokenError> {
caller.require_auth();
let state = storage::get_state(&env)?;
if caller != state.pool_address {
return Err(TokenError::Unauthorized);
}
if amount == 0 {
return Err(TokenError::InvalidAmount);
// ...
Recommended change Before transferring collected reserves, validate the configured treasury against the reserve whitelist and blacklist, or document and enforce an explicit governance-only treasury exemption separate from normal recipient policy.
Patch sketch:
diff --git a/contracts/kinetic-router/src/treasury.rs b/contracts/kinetic-router/src/treasury.rs
@@
let treasury = storage::get_treasury(&env).ok_or(KineticRouterError::TreasuryNotSet)?;
+ validation::validate_reserve_whitelist_access(&env, &asset, &treasury)?;
+ validation::validate_reserve_blacklist_access(&env, &asset, &treasury)?;
QA-70: Staged liquidation price tolerance uses narrow intermediate math
Assessment Two-step liquidation execution computes both debt-price and collateral-price tolerance bounds with u128 checked multiplication before dividing by basis points. A prepared authorization can contain a high but representable stored price, and a live price that is still inside the configured tolerance envelope can be rejected because auth.debtprice * factor or auth.collateralprice * factor overflows before the division.
Failure mode. The replay-safety price checks use narrow integer multiplication for basis-point math instead of a wide intermediate or cross-product comparison, and the router does not bound oracle prices to the smaller domain required by that arithmetic.
Trigger path. A staged liquidation authorization stores a high debt price or high collateral price, for example above u128::MAX / 10000 when tolerance is zero or low. The corresponding live price remains within the intended tolerance envelope, and the rest of execution would otherwise be valid. Such a price can be introduced by a first oracle/manual-override value or by a source path that returns a representable u128 price without a router-side maximum.
Why it matters. Valid staged liquidation execution can fail with MathOverflow for affected high-price debt or collateral feeds, reducing liquidation availability. The trigger requires an extreme price domain or privileged/source configuration, so the realistic severity is low.
Evidence. contracts/kinetic-router/src/router.rs:743-759
Code context:
// contracts/kinetic-router/src/router.rs:936-947
// Step 6: Set up callback params for flash loan
// S-01: Scale min_swap_out proportionally when collateral cap reduces amounts
let adjusted_min_swap_out = if collateral_cap_triggered {
let mso = U256::from_u128(&env, auth.min_swap_out);
let acs = U256::from_u128(&env, actual_collateral_to_seize);
let scs = U256::from_u128(&env, safe_collateral_to_seize);
mso.mul(&acs).div(&scs)
.to_u128()
.ok_or(KineticRouterError::MathOverflow)?
} else {
auth.min_swap_out
};
Recommended change Compute tolerance bounds with U256 and clamp/saturate upper bounds that exceed u128::MAX, or compare prices through wide cross-products. Alternatively reject or normalize prices above the router-supported arithmetic domain before storing liquidation authorization.
Patch sketch:
diff --git a/contracts/kinetic-router/src/flash_loan.rs b/contracts/kinetic-router/src/flash_loan.rs
@@
- let max_price = prepared_price.checked_mul(BASIS_POINTS + tolerance)?.checked_div(BASIS_POINTS)?;
+ let max_price = U256::from_u128(&env, prepared_price)
+ .mul(&U256::from_u128(&env, BASIS_POINTS + tolerance))
+ .div(&U256::from_u128(&env, BASIS_POINTS))
+ .to_u128().ok_or(KineticRouterError::MathOverflow)?;
QA-71: Staged liquidation callback does not authorize router-owned debt-asset spends
Assessment
execute_liquidation_callback transfers debt-asset amounts (repayment, protocol fee, profit) via token.transfer(pool_address, ...) without calling env.authorize_as_current_contract. Standard tokens requiring from authorization will reject these transfers.
Traceability. OBL-668–OBL-670
Code context:
// contracts/kinetic-router/src/flash_loan.rs:499-512
// Repay debt: transfer debt asset to aToken
let repay_transfer_args = soroban_sdk::vec![
&env,
pool_address.to_val(),
debt_reserve_data.a_token_address.to_val(),
safe_u128_to_i128(&env, params.debt_to_cover).into_val(&env),
];
let repay_transfer_result = env.try_invoke_contract::<(), KineticRouterError>(
¶ms.debt_asset,
&sym_transfer,
repay_transfer_args,
);
// ...
// contracts/kinetic-router/src/flash_loan.rs:540-553
if profit_in_debt > 0 {
let profit_transfer_args = soroban_sdk::vec![
&env,
pool_address.to_val(),
params.liquidator.to_val(),
safe_u128_to_i128(&env, profit_in_debt).into_val(&env),
];
let profit_transfer_result = env.try_invoke_contract::<(), KineticRouterError>(
¶ms.debt_asset,
&sym_transfer,
profit_transfer_args,
);
// ...
Recommended change
Add env.authorize_as_current_contract before each router-owned debt-asset transfer call in the callback.
Patch sketch:
diff --git a/contracts/kinetic-router/src/flash_loan.rs b/contracts/kinetic-router/src/flash_loan.rs
@@
let repay_transfer_args = soroban_sdk::vec![...];
+ env.authorize_as_current_contract(soroban_sdk::vec![&env, transfer_auth(¶ms.debt_asset, &repay_transfer_args)]);
let repay_transfer_result = env.try_invoke_contract::<(), KineticRouterError>(...);
QA-72: Strategy rate caps can overflow reserve indices
Assessment The interest-rate strategy validator accepts admin parameter sets with base + slope1 + slope2 up to 20 * RAY and individual components up to 10 * RAY, and the docs describe that cap as preventing downstream overflow. The cap is only locally safe for one rate quote. Stored currentvariableborrowrate and currentliquidityrate are later compounded into reserve indices over elapsed time, and repeated or long-idle accrual at allowed high rates can make updatestate overflow when multiplying the stored index by the interest factor. For example, at the allowed 20 RAY variable borrow rate, yearly updates multiply the borrow index by roughly 1554, so the fourth annual update exceeds u128 even from an initial 1 RAY index.
Failure mode. validateinterestrateparams bounds the instantaneous curve components but does not validate them against the downstream cumulative index-growth domain, elapsed-time assumptions, or existing reserve indices. updatestate always accrues from the stored current rates before any post-action repricing, so once an allowed high rate has made the elapsed index update overflow, later lowering strategy parameters does not help because the overflow happens before the reserve can be repriced.
Trigger path. The interest-rate strategy admin initializes or updates global or per-asset parameters to an allowed high-rate configuration; a reserve stores a high currentliquidityrate or currentvariableborrow_rate through normal post-action repricing; enough time and refreshes elapse for the liquidity or borrow index multiplication to exceed u128. The strongest borrow-index example uses an allowed 20 RAY rate and annual update cadence, overflowing on the fourth annual update from a 1 RAY index.
Why it matters. Once the index update overflows, reserve actions that call update_state fail before settlement, including supply, withdraw, borrow, repay, liquidation-related refreshes, reserve-state refreshes, and protocol-reserve collection. Affected users can be unable to exit or repay through normal routes until an upgrade or storage migration resets the reserve state.
Evidence. contracts/interest-rate-strategy/src/validation.rs:5-54; contracts/interest-rate-strategy/src/contract.rs:157-185,283-313; contracts/kinetic-router/src/calculation.rs:443-502; contracts/shared/src/utils.rs:151-215
Code context:
// contracts/interest-rate-strategy/src/validation.rs:5-18
/// Maximum total interest rate (base + slope1 + slope2) to prevent overflow
/// Set to 20 * RAY (2000% APR) as a reasonable upper bound
const MAX_TOTAL_RATE: u128 = 20 * RAY;
/// Maximum individual component rate (10 * RAY = 1000% APR)
const MAX_COMPONENT_RATE: u128 = 10 * RAY;
pub fn validate_interest_rate_params(params: &InterestRateParams) -> Result<(), KineticRouterError> {
// 1. Validate optimal_utilization_rate: must be strictly between 0 and RAY
// Prevents degenerate curves where optimal = 0 (single branch) or optimal = RAY (no second branch)
if params.optimal_utilization_rate == 0 || params.optimal_utilization_rate >= RAY {
return Err(KineticRouterError::InvalidAmount);
}
// ...
// contracts/interest-rate-strategy/src/contract.rs:157-170
/// Update interest rate parameters (admin only)
pub fn update_interest_rate_params(
env: Env,
caller: Address,
base_variable_borrow_rate: u128,
variable_rate_slope1: u128,
variable_rate_slope2: u128,
optimal_utilization_rate: u128,
) -> Result<(), KineticRouterError> {
storage::validate_admin(&env, &caller)?;
caller.require_auth();
// Validate parameters
let new_params = InterestRateParams {
// ...
Recommended change Validate strategy rates against the maximum supported index-growth horizon and current reserve index domain, not only the instantaneous curve formula. Use much lower hard caps, cap elapsed accrual per update, fail-safe reprice/reset paths for admin recovery, or store/admin-update reserve rates only after accruing to the change timestamp under safe bounds. If 20 RAY is retained as a quote-only bound, prevent it from becoming a stored reserve rate that later accrues unboundedly.
Patch sketch:
diff --git a/contracts/interest-rate-strategy/src/validation.rs b/contracts/interest-rate-strategy/src/validation.rs
@@
- if params.variable_rate_slope2 > MAX_RATE { return Err(KineticRouterError::InvalidAmount); }
+ if params.variable_rate_slope2 > MAX_INDEX_SAFE_RATE { return Err(KineticRouterError::InvalidAmount); }
+ validate_max_index_growth(params, MAX_ACCRUAL_INTERVAL_SECONDS)?;
QA-73: swap_collateral does not enforce oracle-based slippage minimum unlike flash liquidation callback
Assessment
execute_liquidation_callback (flashloan.rs:465–485) enforces a protocol-level oracle-based minimum swap output using `getminswapoutputbps, computing the fair-value minimum from oracle prices and checkingdebtreceived >= oracleminout.swapcollateral(swap.rs:38–371) only checks the user-suppliedminamount_out` against the DEX output — it does not perform the oracle-based slippage check.
This means users performing collateral swaps have no protocol-enforced protection against sandwich attacks or extreme DEX slippage beyond their own min_amount_out parameter. A user who accidentally sets min_amount_out too low gets no protocol safety net, while the same operation through the liquidation path would be protected.
Review trail. Generic code-level pass — swap.rs vs flash_loan.rs
Code context:
// contracts/kinetic-router/src/swap.rs:160-173
&to_asset,
actual_amount_i128,
safe_u128_to_i128(&env, min_amount_out),
&pool_address,
)?
} else if let Some(ref factory) = swap_config.dex_factory {
// Use Soroswap direct swap (optimized)
k2_shared::dex::swap_exact_tokens_direct(
&env,
factory,
&from_asset,
&to_asset,
actual_amount_i128,
safe_u128_to_i128(&env, min_amount_out),
// ...
// contracts/kinetic-router/src/flash_loan.rs:465-478
// M-05: Enforce oracle-based slippage minimum
// Convert collateral value to expected debt units, accounting for decimal differences.
// Formula: actual_collateral * collateral_price * debt_decimals_pow * min_swap_bps
// / (debt_price * collateral_decimals_pow * BASIS_POINTS_MULTIPLIER)
let min_swap_bps = storage::get_min_swap_output_bps(&env);
let collateral_decimals_pow = collateral_reserve_data.configuration.get_decimals_pow()?;
let debt_decimals_pow = debt_reserve_data.configuration.get_decimals_pow()?;
let oracle_min_out = {
let cs = soroban_sdk::U256::from_u128(&env, actual_collateral);
let cp = soroban_sdk::U256::from_u128(&env, params.collateral_price);
let ddp = soroban_sdk::U256::from_u128(&env, debt_decimals_pow);
let bps = soroban_sdk::U256::from_u128(&env, min_swap_bps);
let dp = soroban_sdk::U256::from_u128(&env, params.debt_price);
let cdp = soroban_sdk::U256::from_u128(&env, collateral_decimals_pow);
// ...
Recommended change
Apply the same oracle-based slippage floor (get_min_swap_output_bps) in swap_collateral as is applied in the flash liquidation callback.
Patch sketch:
diff --git a/contracts/kinetic-router/src/swap.rs b/contracts/kinetic-router/src/swap.rs
@@
if to_amount_received < min_amount_out { return Err(KineticRouterError::InsufficientSwapOut); }
+ let oracle_min_out = calculation::oracle_min_swap_out(&env, &from_asset, &to_asset, actual_amount)?;
+ if to_amount_received < oracle_min_out { return Err(KineticRouterError::InsufficientSwapOut); }
QA-74: Weighted-threshold health-factor arithmetic can overflow before normalization
Assessment Health-factor paths store liquidation-threshold-weighted collateral support as collateralvalue * liquidationthreshold in u128 before the basis-point normalization. Large representable collateral values can make this pre-normalized product exceed u128::MAX even when the normalized support after division by 10000 and the final health-factor decision would fit.
Failure mode. The router represents pre-normalized basis-point weighted threshold sums in u128 instead of keeping the percentage product in U256 until after normalization or doing direct U256 boundary comparisons.
Trigger path. A user has large but representable collateral base value and a positive liquidation threshold such that collateralvalue * liquidationthreshold exceeds u128::MAX, while the final normalized support and relevant health-factor comparison are still representable or safely classifiable.
Why it matters. Account-data calculation can fail before returning risk data; withdraw and aToken sender transfer validation can fail while computing the removed weighted contribution; collateral-swap validation can fail while accumulating post-swap weighted support. These failures can block otherwise valid high-collateral actions or risk-management paths.
Evidence. contracts/kinetic-router/src/calculation.rs:284-295,767-776; contracts/kinetic-router/src/validation.rs:291-305; contracts/kinetic-router/src/router.rs:1858-1865
Code context:
// contracts/kinetic-router/src/calculation.rs:40-51
let collateral = U256::from_u128(env, total_collateral_base);
let threshold = U256::from_u128(env, current_liquidation_threshold);
let wad = U256::from_u128(env, WAD);
let bps = U256::from_u128(env, 10000u128);
let debt = U256::from_u128(env, total_debt_base);
// collateral * threshold * WAD / 10000 / debt
// U256 division never overflows. If to_u128() fails (result > u128::MAX),
// panic with error rather than silently returning u128::MAX sentinel.
collateral.mul(&threshold).mul(&wad).div(&bps).div(&debt)
.to_u128()
.unwrap_or_else(|| panic_with_error!(env, KineticRouterError::MathOverflow))
Recommended change Keep liquidation-threshold weighted support in U256 through basis-point normalization and threshold comparisons, and only downcast final values that are actually required to fit in exposed u128 fields.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- pub weighted_threshold_sum: u128,
+ pub weighted_threshold_sum: U256,
@@
- weighted_threshold_sum = weighted_threshold_sum.checked_add(weighted_threshold_value)?;
+ weighted_threshold_sum = weighted_threshold_sum.add(&weighted_threshold_value_u256);
QA-75: Swap-handler and reward-token whitelist entries not validated against required ABI
Assessment
set_swap_handler_whitelist stores opaque addresses without probing execute_swap. configure_asset_rewards stores reward_token without probing balance. Wrong-interface entries can repeatedly brick selected routes or funding-health views.
Traceability. OBL-665, OBL-740
Code context:
// contracts/kinetic-router/src/access_control.rs:180-188
pub fn set_swap_handler_whitelist(
env: Env,
whitelist: Vec<Address>,
) -> Result<(), KineticRouterError> {
let admin = storage::get_pool_admin(&env)?;
admin.require_auth();
storage::set_swap_handler_whitelist(&env, &whitelist);
// contracts/incentives/src/contract.rs:550-557
// Create or update configuration
let config = storage::AssetRewardConfig {
emission_per_second,
distribution_end,
is_active: true,
};
storage::set_asset_reward_config(&env, &asset, &reward_token, reward_type, &config)?;
Recommended change Probe at least one critical method during registration to verify ABI compatibility.
Patch sketch:
diff --git a/contracts/kinetic-router/src/access_control.rs b/contracts/kinetic-router/src/access_control.rs
@@
- storage::set_swap_handler_whitelist(&env, &whitelist);
+ for handler in whitelist.iter() { probe_swap_handler(&env, &handler)?; }
+ storage::set_swap_handler_whitelist(&env, &whitelist);
QA-76: drop_reserve does not check for outstanding deficit before removing reserve tracking
Assessment
drop_reserve (reserve.rs:437–507) checks that both a_token_scaled_supply == 0 and debt_token_scaled_supply == 0 before removing a reserve, but does not check whether get_reserve_deficit > 0 for the asset. The deficit is cleaned up (line 496–498), but this means an admin can drop a reserve that has outstanding bad debt, permanently erasing the deficit record. Any subsequent cover_deficit calls for that asset will fail with ReserveNotFound.
This allows governance to sweep bad debt under the rug by dropping a reserve rather than covering the deficit.
Review trail. Generic code-level pass — reserve.rs
Code context:
// contracts/kinetic-router/src/reserve.rs:445-458
let _reserve_data = storage::get_reserve_data(&env, &asset)?;
let a_token_scaled_supply: i128 = match env.try_invoke_contract::<i128, KineticRouterError>(
&_reserve_data.a_token_address,
&Symbol::new(&env, "scaled_total_supply"),
soroban_sdk::vec![&env],
) {
Ok(Ok(supply)) => supply,
Ok(Err(_)) | Err(_) => return Err(KineticRouterError::TokenCallFailed),
};
if a_token_scaled_supply != 0 {
panic_with_error!(&env, ReserveManagementError::CannotDropActiveReserve);
}
// Check total debt - must be zero before dropping
// ...
Recommended change
Require get_reserve_deficit == 0 before allowing a reserve to be dropped, or emit a prominent warning event when dropping a reserve with outstanding deficit.
Patch sketch:
diff --git a/contracts/kinetic-router/src/reserve.rs b/contracts/kinetic-router/src/reserve.rs
@@
if debt_token_scaled_supply != 0 { panic_with_error!(...); }
+ if storage::get_reserve_deficit(&env, &asset) != 0 {
+ return Err(KineticRouterError::InvalidAmount);
+ }
QA-77: Reserve sweeps can collect fragmented aToken rounding dust
Assessment
collect_protocol_reserves classifies surplus by subtracting aggregate indexed total supply from aToken custody, while user withdrawals materialize each holder’s balance_of_with_index independently. Because ray_mul is half-up and not additive across fragmented balances, a trusted admin reserve sweep can collect dust that the withdrawal surface would otherwise pay to users.
This is bounded by smallest-unit rounding across fragmented accounts, so it is better treated as QA unless a concrete deployment uses low-decimal/high-value assets or extreme account fragmentation that makes the aggregate drift material.
Code context:
// contracts/kinetic-router/src/treasury.rs:25-56
let total_supply = calculation::get_total_supply_with_index(
&env,
&reserve_data.a_token_address,
reserve_data.liquidity_index,
)?;
let available_liquidity = total_supply - total_borrow;
let available_reserves = underlying_balance - available_liquidity;
let deficit = storage::get_reserve_deficit(&env, &asset);
let collectible_reserves = available_reserves.saturating_sub(deficit);
// contracts/a-token/src/balance.rs:26-37
let scaled_balance = storage::get_scaled_balance(env, id);
let result = ray_mul(env, scaled_balance_u128, liquidity_index)?;
Concrete example: if two users each have scaled balance 1 and the liquidity index maps 1 to 1.5, aggregate supply rounds 2 * 1.5 = 3, while each user balance rounds to 2. Total user-visible withdrawable balance is 4, so a one-unit dust gap can be mistaken for collectible reserves.
Recommended change Use a sweep formula conservative relative to the withdrawal surface: floor user-visible balances consistently, preserve a rounding buffer, or keep a dedicated non-collectible rounding reserve.
Patch sketch:
diff --git a/contracts/kinetic-router/src/treasury.rs b/contracts/kinetic-router/src/treasury.rs
@@
-let available_reserves = underlying_balance - available_liquidity;
+let rounding_buffer = storage::get_rounding_reserve(&env, &asset);
+let protected_liquidity = available_liquidity
+ .checked_add(rounding_buffer)
+ .ok_or(KineticRouterError::MathOverflow)?;
+let available_reserves = underlying_balance.saturating_sub(protected_liquidity);
QA-78: Liquidations can seize collateral on rounded-down debt burns
Assessment Direct and staged liquidation pass a nominal debttocover into debt-token burnscaled, but burnscaled floors amount / borrow_index and can burn fewer scaled debt units than the nominal amount represents, including zero scaled debt for small positive amounts. The liquidation paths continue to transfer repayment assets, seize or burn collateral, and compute remaining debt from nominal or callback values without reconciling collateral seizure to the actual live debt burned.
Failure mode. DebtToken burnscaled settles debt in scaled units with raydivdown and does not reject amountscaled == 0. Router liquidation paths treat the requested or adjusted nominal debttocover as the amount of debt actually covered. Direct liquidation ignores the returned userremainingdebt, while staged liquidation preserves the scaled remainder but still sizes the collateral burn/swap and repayment transfer from the nominal prepared amount.
Trigger path. A borrower is liquidatable; the selected debt amount is not exactly representable as scaled debt at the current borrow index, especially when the selected amount is below one scaled debt unit; the selected collateral quote remains positive and the minremainingdebt or close-factor checks do not revert the transaction.
Why it matters. A liquidation can remove borrower collateral and pay the liquidator/protocol according to the nominal debt amount while reducing the borrower’s scaled debt by less than that amount or not at all. This leaves dust debt shares behind, can make the borrower lose collateral without corresponding debt relief, and can be repeated at rounding boundaries until other limits or economics stop the flow. The per-call mismatch is bounded by less than one scaled debt unit but is systematic.
Evidence. contracts/debt-token/src/contract.rs:205-271; contracts/kinetic-router/src/liquidation.rs:299-345; contracts/kinetic-router/src/liquidation.rs:490-574; contracts/kinetic-router/src/flashloan.rs:369-407; contracts/kinetic-router/src/flashloan.rs:499-516; contracts/kinetic-router/src/router.rs:987-1003
Code context:
// contracts/debt-token/src/contract.rs:205-218
pub fn burn_scaled(
env: Env,
caller: Address,
on_behalf_of: Address,
amount: u128,
index: u128,
) -> Result<(bool, i128, i128), TokenError> {
caller.require_auth();
let mut state = storage::get_state(&env)?;
if !storage::is_authorized_caller(&env, &caller, &state.pool_address) {
return Err(TokenError::Unauthorized);
}
if amount == 0 {
// ...
// contracts/kinetic-router/src/liquidation.rs:299-312
// Liquidator transfers debt asset directly to aToken contract
// (Debt must be fully repaid to maintain proper aToken accounting)
let mut liquidation_transfer_args = Vec::new(env);
liquidation_transfer_args.push_back(IntoVal::into_val(&env.current_contract_address(), env));
liquidation_transfer_args.push_back(liquidator.to_val());
liquidation_transfer_args.push_back(updated_debt_reserve_data.a_token_address.to_val());
liquidation_transfer_args.push_back(IntoVal::into_val(&safe_u128_to_i128(env, debt_to_cover), env));
let transfer_result = env.try_invoke_contract::<(), KineticRouterError>(
&debt_asset,
&Symbol::new(env, "transfer_from"),
liquidation_transfer_args,
);
// ...
Recommended change Have debt-token burn_scaled reject zero-scaled burns and return the actual live debt amount burned. Liquidation should size collateral seizure, repayment transfer, close-factor/dust checks, remaining-debt accounting, and events from the actual debt burned, or precompute the scaled burn and reject non-representable liquidation amounts. Residual cleanup should require funding for the full live value of any force-closed scaled debt.
Patch sketch:
diff --git a/contracts/debt-token/src/contract.rs b/contracts/debt-token/src/contract.rs
@@
- ) -> Result<(bool, i128), TokenError> {
+ ) -> Result<(bool, i128, u128), TokenError> {
@@
- Ok((final_user_scaled_debt == 0, state.total_debt_scaled))
+ let actual_burned = ray_mul(&env, amount_scaled, index).map_err(|_| TokenError::InvalidIndex)?;
+ Ok((final_user_scaled_debt == 0, state.total_debt_scaled, actual_burned))
}
QA-79: Direct liquidation fee conversion can undercollect protocol fees
Assessment Direct liquidation computes the protocol fee in debt units with percentmulup, but then converts that fee into collateral units using floor division. When the rounded debt fee maps to a fractional collateral amount, the collateral fee is rounded down and can become zero despite a positive debt-denominated fee.
Failure mode. The fee path applies upward rounding before the unit conversion, but does not use a ceiling division or equivalent protocol-favorable rounding for the debt-value-to-collateral conversion.
Trigger path. Direct liquidation has a nonzero protocol fee bps; the debt fee converted through the current prices and decimal factors is not exactly divisible into collateral units; the liquidation otherwise succeeds.
Why it matters. The protocol undercollects direct liquidation fees by up to one collateral base unit per affected liquidation, and tiny positive debt-denominated fees can be waived entirely after collateral conversion. Liquidators can repeatedly receive the rounding remainder as extra collateral payout.
Evidence. contracts/kinetic-router/src/liquidation.rs:352-369; contracts/shared/src/utils.rs:131-136
Code context:
// contracts/kinetic-router/src/liquidation.rs:352-365
let protocol_fee_bps = storage::get_flash_loan_premium(env);
let (protocol_fee_collateral, liquidator_collateral) = if protocol_fee_bps == 0 {
(0u128, collateral_amount_to_transfer)
} else {
// M-07: Round UP to favor protocol
let protocol_fee_debt = percent_mul_up(debt_to_cover, protocol_fee_bps)?;
let protocol_fee_collateral = {
let pfd = U256::from_u128(&env, protocol_fee_debt);
let dp = U256::from_u128(&env, debt_price);
let cdp = U256::from_u128(&env, collateral_decimals_pow);
let cp = U256::from_u128(&env, collateral_price);
let ddp = U256::from_u128(&env, debt_decimals_pow);
// ...
// contracts/shared/src/utils.rs:131-136
pub fn percent_mul_up(value: u128, percentage: u128) -> Result<u128, crate::KineticRouterError> {
value
.checked_mul(percentage)
.and_then(|prod| prod.checked_add(BASIS_POINTS_MULTIPLIER - 1))
.and_then(|sum| sum.checked_div(BASIS_POINTS_MULTIPLIER))
.ok_or(crate::KineticRouterError::MathOverflow)
Recommended change Convert the debt-denominated fee to collateral units with a single ceiling division over the full numerator and denominator, then cap against available collateral if necessary.
Patch sketch:
diff --git a/contracts/kinetic-router/src/liquidation.rs b/contracts/kinetic-router/src/liquidation.rs
@@
- let protocol_fee_collateral = debt_fee * debt_price / collateral_price;
+ let protocol_fee_collateral = U256::from_u128(env, debt_fee)
+ .mul(&U256::from_u128(env, debt_price))
+ .add(&U256::from_u128(env, collateral_price).sub(&U256::from_u128(env, 1)))
+ .div(&U256::from_u128(env, collateral_price))
+ .to_u128().ok_or(KineticRouterError::MathOverflow)?;
QA-80: set_partial_liq_hf_threshold and set_hf_liquidation_threshold allow contradictory boundaries
Assessment
set_partial_liq_hf_threshold can store the full-close trigger at or above the liquidation eligibility boundary, and set_hf_liquidation_threshold can lower the liquidation boundary to or below the full-close trigger. Neither setter cross-validates against the other, allowing a governance misconfiguration that makes partial liquidation unreachable or always-full.
Traceability. OBL-426, OBL-427
Code context:
// contracts/kinetic-router/src/params.rs:49-61
pub fn set_hf_liquidation_threshold(env: Env, threshold: u128) -> Result<(), KineticRouterError> {
let admin = storage::get_pool_admin(&env)?;
admin.require_auth();
// N-10
// HF threshold determines when positions become liquidatable
let min_threshold = 500_000_000_000_000_000u128; // 0.5 WAD
let max_threshold = 1_200_000_000_000_000_000u128; // M-09: 1.2 WAD
if threshold < min_threshold || threshold > max_threshold {
return Err(KineticRouterError::InvalidAmount);
}
storage::set_health_factor_liquidation_threshold(&env, threshold);
// contracts/kinetic-router/src/params.rs:106-116
pub fn set_partial_liq_hf_threshold(env: Env, threshold: u128) -> Result<(), KineticRouterError> {
let admin = storage::get_pool_admin(&env)?;
admin.require_auth();
// N-10
// Partial liquidation threshold must be below the main liquidation threshold (1.0 WAD)
if threshold == 0 || threshold >= k2_shared::WAD {
return Err(KineticRouterError::InvalidAmount);
}
storage::set_partial_liquidation_hf_threshold(&env, threshold);
Recommended change
Cross-validate both thresholds in each setter to ensure partial_liq_hf_threshold < hf_liquidation_threshold.
Patch sketch:
diff --git a/contracts/kinetic-router/src/params.rs b/contracts/kinetic-router/src/params.rs
@@
- storage::set_health_factor_liquidation_threshold(&env, threshold);
+ if storage::get_partial_liquidation_hf_threshold(&env) >= threshold {
+ return Err(KineticRouterError::InvalidAmount);
+ }
+ storage::set_health_factor_liquidation_threshold(&env, threshold);
QA-81: aToken transfers bypass reserve pause, freeze, and active gates
Assessment
Both a-token:transfer and transfer_from reach transfer_internal, which does not inspect reserve configuration. Router validate_and_finalize_transfer loads reserve data only to authenticate the aToken caller and derive the reserve id—it never checks is_paused(), is_active(), or is_frozen(). Direct and delegated aToken transfers can therefore move scaled claims and mutate collateral bits while the underlying reserve is paused, frozen, or inactive.
Additionally, validate_and_finalize_transfer does not check the router global pause (storage::is_paused), so transfers proceed during emergency pause.
Traceability. OBL-646, OBL-649–OBL-653
Code context:
// contracts/kinetic-router/src/router.rs:1849-1862
let reserve_data = storage::get_reserve_data(&env, &underlying_asset)?;
let reserve_id = k2_shared::safe_reserve_id(&env, reserve_data.id);
// Caller must be the aToken contract for this reserve.
// In the legitimate flow, the aToken invokes this function via
// env.try_invoke_contract, so require_auth() succeeds. Any other
// caller (EOA or unrelated contract) will fail this check.
reserve_data.a_token_address.require_auth();
// --- HF validation (WP-C1) ---
let mut from_config = storage::get_user_configuration(&env, &from);
if from_config.has_any_borrowing() {
let reserve_data = crate::calculation::update_state_without_store(&env, &reserve_data)?;
let oracle_config = crate::price::get_oracle_config(&env)?;
// ...
Recommended change
Add reserve lifecycle and global pause checks in validate_and_finalize_transfer before processing balance updates.
Patch sketch:
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
let reserve_data = storage::get_reserve_data(&env, &underlying_asset)?;
+ if storage::is_paused(&env) { return Err(KineticRouterError::AssetPaused); }
+ if !reserve_data.configuration.is_active() { return Err(KineticRouterError::AssetNotActive); }
+ if reserve_data.configuration.is_paused() { return Err(KineticRouterError::AssetPaused); }
+ if reserve_data.configuration.is_frozen() { return Err(KineticRouterError::AssetFrozen); }
QA-82: Flash loans accept impossible high asset counts
Assessment
flash_loan accepts vectors up to MAX_RESERVES even though the successful multi-asset path writes several ledger entries per asset. With the default nonzero premium, each distinct borrowed asset touches at least the receiver balance, reserve aToken balance, and treasury balance through the underlying token transfer flow. A request with enough distinct assets can therefore pass K2 validation while exceeding Soroban’s transaction footprint before protocol-specific bookkeeping is counted.
This is a liveness/interface mismatch rather than a fund-loss issue: users can request fewer assets, and the failed high-count transaction does not corrupt protocol state.
Code context:
// contracts/kinetic-router/src/router.rs
if assets.len() > k2_shared::MAX_RESERVES || amounts.len() > k2_shared::MAX_RESERVES {
return Err(KineticRouterError::InvalidAmount);
}
// contracts/kinetic-router/src/flash_loan.rs
for debt in debts.iter() {
transfer_underlying_to(env, &debt.asset, &reserve_data.a_token_address, receiver, debt.amount)?;
}
for debt in debts.iter() {
verify_repayment(env, &debt.asset, &reserve_data.a_token_address, &debt)?;
if debt.premium > 0 {
transfer_premium_to_treasury(env, &debt.asset, &reserve_data.a_token_address, &treasury, debt.premium)?;
}
}
Recommended change Use a flash-loan-specific asset cap derived from the actual successful-path ledger footprint, and apply it before any per-asset transfer work.
Patch sketch:
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
-if assets.len() > k2_shared::MAX_RESERVES || amounts.len() > k2_shared::MAX_RESERVES {
+if assets.len() > k2_shared::MAX_ASSETS_PER_TX || amounts.len() > k2_shared::MAX_ASSETS_PER_TX {
return Err(KineticRouterError::InvalidAmount);
}
QA-83: Factory reserve deployment does not verify token WASM ABI
Assessment Factory reserve deployment consumes stored aToken and debt-token WASM hashes, deploys those contracts, and only calls initialize before registering the fresh addresses in router reserve metadata. A wrong, swapped, duplicated, or partial WASM that accepts initialize can be published as a live reserve token even though later router flows require the aToken slot to implement collateral mint/burn, balance views, scaledtotalsupply, and underlying transfer helpers, and require the debt-token slot to implement non-transferable debt mint/burn and debt balance views.
Failure mode. The factory treats a successful token initializer as proof of the full reserve-token role and settlement ABI, and hash setters do not prove that the aToken hash and debt-token hash correspond to distinct intended implementations.
Trigger path. The pool configurator has stored an aToken or debt-token WASM hash whose contract accepts the initializer but lacks or changes later reserve-token settlement methods, including swapped or duplicated canonical aToken/debt-token hashes, then deployandinit_reserve is used for a new reserve.
Why it matters. The reserve can become live while supply, withdraw, borrow, repay, liquidation, or aToken transfer-finalization paths fail or execute against incompatible token semantics. If the factory hashes are swapped or duplicated, supplied underlying can be routed to a token contract that lacks aToken payout helpers, or debt can be represented by an aToken-like transferable ledger. The issue can affect all users of a factory-created reserve until the reserve is replaced or the token implementation is upgraded.
Evidence. contracts/pool-configurator/src/contract.rs:58-93; contracts/pool-configurator/src/storage.rs:137-153; contracts/pool-configurator/src/reserve.rs:720-831; contracts/kinetic-router/src/reserve.rs:5-119
Code context:
// contracts/pool-configurator/src/contract.rs:58-71
pub fn set_a_token_wasm_hash(
env: Env,
caller: Address,
hash: BytesN<32>,
) -> Result<(), KineticRouterError> {
caller.require_auth();
storage::validate_admin(&env, &caller)?;
storage::set_a_token_wasm_hash(&env, &hash);
// Emit event to enable off-chain monitoring of critical contract code changes
env.events().publish(
(
symbol_short!("atoken"),
symbol_short!("wasm"),
// ...
// contracts/pool-configurator/src/storage.rs:137-150
pub fn get_a_token_wasm_hash(env: &Env) -> Option<BytesN<32>> {
extend_instance_ttl_if_needed(env);
env.storage().instance().get(&A_TOKEN_WASM_HASH)
}
pub fn set_a_token_wasm_hash(env: &Env, hash: &BytesN<32>) {
env.storage().instance().set(&A_TOKEN_WASM_HASH, hash);
extend_instance_ttl_if_needed(env);
}
pub fn get_debt_token_wasm_hash(env: &Env) -> Option<BytesN<32>> {
extend_instance_ttl_if_needed(env);
env.storage().instance().get(&DEBT_TOKEN_WASM_HASH)
}
// ...
Recommended change Before router registration, probe required aToken and debt-token role-specific methods, require the two deployed token roles to be distinct, and verify stored WASM hashes against an allowlist of audited role-specific token code. Keep hash updates behind explicit governance review and reject factory deployment if any required ABI or role check fails.
Patch sketch:
diff --git a/contracts/pool-configurator/src/reserve.rs b/contracts/pool-configurator/src/reserve.rs
@@
- // deploy token contracts
+ verify_token_wasm_hash(&a_token_impl, TokenRole::AToken)?;
+ verify_token_wasm_hash(&variable_debt_impl, TokenRole::DebtToken)?;
+ if a_token_impl == variable_debt_impl { return Err(KineticRouterError::InvalidAmount); }
+ // deploy token contracts
QA-84: Account-data LTV arithmetic can overflow on large collateral values
Assessment The unified account-data helper stores weighted LTV values and recomputes availableborrowsbase with narrow u128 basis-point arithmetic. For large representable collateral values, intermediate products such as collateralvalue * ltv or totalcollateral_base * ltv can overflow even though the final LTV-adjusted capacity after division by 10000 would fit.
Failure mode. calculateuseraccountdataunified performs LTV basis-point arithmetic in u128 before the basis-point division instead of using U256 or a percentage helper with wide intermediates.
Trigger path. An account has a large collateral base value and a positive LTV such that the exact LTV-adjusted capacity fits in u128, but the pre-division weighted LTV product or totalcollateralbase * ltv product exceeds u128::MAX.
Why it matters. Account-data calculation can fail before returning health-factor or capacity data. Because the same helper is consumed by borrow, collateral toggle, liquidation, and staged liquidation paths, very large collateralized accounts can hit avoidable route failures even when the final risk values are representable.
Evidence. contracts/kinetic-router/src/calculation.rs:297-417
Code context:
// contracts/kinetic-router/src/calculation.rs:297-304
let ltv_u256 = U256::from_u128(env, ltv);
let weighted_ltv_value = balance_u256
.mul(&price_u256)
.mul(&oracle_to_wad_u256)
.mul(<v_u256)
.div(&decimals_pow_u256)
.to_u128()
.ok_or(KineticRouterError::MathOverflow)?;
Recommended change Keep weighted LTV and available-borrow percentage arithmetic in U256 until after division by 10000, then downcast only the final representable result.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- let available_borrows_base = total_collateral_base.checked_mul(current_ltv)?.checked_div(BASIS_POINTS)?;
+ let available_borrows_base = U256::from_u128(env, total_collateral_base)
+ .mul(&U256::from_u128(env, current_ltv))
+ .div(&U256::from_u128(env, BASIS_POINTS))
+ .to_u128().ok_or(KineticRouterError::MathOverflow)?;
QA-85: Access-list activation flag and payload state can desynchronize
Assessment
Reserve whitelist/blacklist, liquidation whitelist/blacklist, and swap-handler whitelist all use a two-part storage pattern: an activation flag/map and a separate persistent payload vector. set_reserve_whitelist writes the full vector to persistent storage and separately sets RESERVE_WL_MAP to true. The checker reads the activation flag first; if the persistent payload is unavailable (expired or absent) while the flag remains true, the empty-vector default causes whitelists to open access and blacklists to stop blocking.
The inverse split is also possible: when the persistent payload exists but the flag is false/missing, the checker returns its default result without reading the payload.
This affects: reserve WL/BL across supply, withdraw, borrow, repay, collateral-toggle, flash-loan, liquidation, and swap paths; liquidation WL/BL across direct/staged liquidation; swap-handler WL across collateral-swap and staged liquidation callback.
Traceability. OBL-737, OBL-738, OBL-774–OBL-807, OBL-813–OBL-830, OBL-844–OBL-859
Code context:
// contracts/kinetic-router/src/storage.rs:813-823
pub fn set_reserve_whitelist(env: &Env, asset: &Address, whitelist: &Vec<Address>) {
let key = (WHITELIST, asset.clone());
env.storage().persistent().set(&key, whitelist);
env.storage()
.persistent()
.extend_ttl(&key, TTL_THRESHOLD, TTL_EXTENSION);
// M-01: Update consolidated whitelist Map
let mut wl_map: Map<Address, bool> = env.storage().instance().get(&RESERVE_WL_MAP).unwrap_or(Map::new(env));
wl_map.set(asset.clone(), !whitelist.is_empty());
env.storage().instance().set(&RESERVE_WL_MAP, &wl_map);
}
// contracts/kinetic-router/src/storage.rs:829-841
pub fn is_address_whitelisted_for_reserve(env: &Env, asset: &Address, address: &Address) -> bool {
// M-01: Read from consolidated whitelist Map
let wl_map: Map<Address, bool> = env.storage().instance().get(&RESERVE_WL_MAP).unwrap_or(Map::new(env));
let has_whitelist = wl_map.get(asset.clone()).unwrap_or(false);
if !has_whitelist {
return true;
}
let whitelist = get_reserve_whitelist(env, asset);
if whitelist.is_empty() {
return true;
}
Recommended change Atomically couple the activation flag write with the payload write, or derive the “active” state from the payload existence itself rather than a separate flag. When the payload lookup returns empty while the flag says active, fail closed (reject all) rather than fail open.
Patch sketch:
diff --git a/contracts/kinetic-router/src/storage.rs b/contracts/kinetic-router/src/storage.rs
@@
- let has_whitelist = wl_map.get(asset.clone()).unwrap_or(false);
- if !has_whitelist { return true; }
+ let has_whitelist = env.storage().persistent().has(&(WHITELIST, asset.clone()));
+ if !has_whitelist { return true; }
@@
- if whitelist.is_empty() { return true; }
+ if whitelist.is_empty() { return false; }
QA-86: Account-data silently skips unresolved active bitmap positions
Assessment
calculate_user_account_data_unified iterates active user configuration bits, but if get_reserve_address_by_id returns none or get_reserve_data fails, the position is silently skipped. Health-factor decisions can then be made with incomplete collateral or debt data. The swap HF helper treats the same condition as ReserveNotFound, showing inconsistency.
Traceability. OBL-839, OBL-860, OBL-861
Code context:
// contracts/kinetic-router/src/calculation.rs:130-143
let next_reserve_id = storage::get_next_reserve_id(env);
let iteration_bound = next_reserve_id.min(MAX_RESERVES as u32) as u8;
for reserve_id in 0..iteration_bound {
if user_config.is_using_as_collateral(reserve_id) || user_config.is_borrowing(reserve_id) {
// Get asset address from reserve ID
if let Some(asset) = storage::get_reserve_address_by_id(env, reserve_id as u32) {
// Use known_reserves if available, fallback to storage for cache misses
// (liquidation.rs passes partial maps — missing entries must not be skipped)
let reserve_data = if let Some(known) = params.known_reserves {
match known.try_get(asset.clone()).ok().flatten() {
Some(data) => Some(data),
None => storage::get_reserve_data(env, &asset).ok(),
}
} else {
// ...
Recommended change Fail the account-data calculation when an active bitmap position cannot be resolved, consistent with the swap HF helper.
Patch sketch:
diff --git a/contracts/kinetic-router/src/calculation.rs b/contracts/kinetic-router/src/calculation.rs
@@
- if let Some(asset) = storage::get_reserve_address_by_id(env, reserve_id as u32) {
+ let asset = storage::get_reserve_address_by_id(env, reserve_id as u32)
+ .ok_or(KineticRouterError::ReserveNotFound)?;
@@
- if let Some(reserve_data) = reserve_data {
+ let reserve_data = reserve_data.ok_or(KineticRouterError::ReserveNotFound)?;
QA-87: validate_repay checks amount == 0 separately from validate_amount creating redundant error paths
Assessment
validate_repay (validation.rs:416–450) checks amount == 0 explicitly on line 428 before calling validate_amount(amount) on line 432 (only when amount != u128::MAX). validate_amount also rejects zero. This creates two distinct error paths for the same condition (amount == 0), returning InvalidAmount from different locations. While functionally equivalent, this complicates debugging and audit trail analysis because the same failure condition produces error events from different code locations.
Review trail. Generic code-level pass — validation.rs
Code context:
// contracts/kinetic-router/src/validation.rs:416-429
pub fn validate_repay(
env: &Env,
_asset: &Address,
amount: u128,
rate_mode: u32,
reserve_data: &k2_shared::ReserveData,
) -> Result<(), KineticRouterError> {
if storage::is_paused(env) {
return Err(KineticRouterError::AssetPaused);
}
// Validate amount (u128::MAX signals full repayment, 0 is invalid)
if amount == 0 {
return Err(KineticRouterError::InvalidAmount);
// ...
Recommended change
Remove the explicit amount == 0 check and let validate_amount handle it, with the u128::MAX guard applied first.
Patch sketch:
diff --git a/contracts/kinetic-router/src/validation.rs b/contracts/kinetic-router/src/validation.rs
@@
- if amount == 0 {
- return Err(KineticRouterError::InvalidAmount);
- }
if amount != u128::MAX {
validate_amount(amount)?;
}
QA-88: : [omitted]
This finding has been omitted from the report as it was deemed invalid by the judge.
QA-89: Collateral swaps can require prices for exited source reserves
Assessment Indebted full-source collateral swaps can reject because validateswaphealth_factor always requires a nonzero source-asset oracle price before it checks the post-swap active reserve set. When the swap fully clears the source collateral bit and the user has no source debt, that source price is not used in the resulting health-factor calculation.
Failure mode. The swap-health helper unconditionally pushes fromasset into the oracle batch and validates fromprice before determining whether the source reserve remains collateral or debt in the post-swap user configuration.
Trigger path. A borrower has debt in another reserve, swaps the full source collateral balance into a priced destination reserve, has no source-asset debt, and the source reserve price is missing or zero while all remaining active reserve prices are valid and the post-swap HF would pass.
Why it matters. The transaction reverts with PriceOracleNotFound even though the exited source reserve is absent from post-swap solvency math. This can block users from rebalancing out of a broken or temporarily unpriceable source collateral into a healthy priced collateral reserve.
Evidence. contracts/kinetic-router/src/swap.rs:318-363; contracts/kinetic-router/src/calculation.rs:617-650; contracts/kinetic-router/src/calculation.rs:669-845
Code context:
// contracts/kinetic-router/src/swap.rs:318-331
// Update user configuration to use new asset as collateral
// Price validation moved into validate_swap_health_factor (saves 1 oracle cross-contract call)
user_config.set_using_as_collateral(k2_shared::safe_reserve_id(&env, updated_to_reserve_data.id), true);
// If user withdrew entire position from from_asset, disable it as collateral.
// Use new_user_scaled_balance from burn return (more accurate than subtraction, no rounding drift).
if new_user_scaled_balance == 0 {
user_config.set_using_as_collateral(k2_shared::safe_reserve_id(&env, updated_from_reserve_data.id), false);
}
// Compute remaining underlying balance for HF validation
let remaining_balance = if new_user_scaled_balance > 0 {
k2_shared::ray_mul(
&env,
safe_i128_to_u128(&env, new_user_scaled_balance),
// ...
// contracts/kinetic-router/src/calculation.rs:617-630
// Build asset list for a SINGLE batch oracle call — includes swap assets + any other active positions
let mut assets = Vec::new(env);
assets.push_back(from_asset.clone());
assets.push_back(to_asset.clone());
let next_reserve_id = storage::get_next_reserve_id(env);
let bound = next_reserve_id.min(MAX_RESERVES as u32) as u8;
for rid in 0..bound {
if user_config.is_using_as_collateral(rid) || user_config.is_borrowing(rid) {
if rid != from_id && rid != to_id {
if let Some(asset) = storage::get_reserve_address_by_id(env, rid as u32) {
assets.push_back(asset);
}
}
// ...
Recommended change Build the oracle batch and required price checks from the post-swap active reserve set. Only require the source reserve price when the post-swap configuration still uses the source as collateral or has source debt; otherwise skip source price validation.
Patch sketch:
diff --git a/contracts/kinetic-router/src/swap.rs b/contracts/kinetic-router/src/swap.rs
@@
- calculation::validate_swap_health_factor(&env, &caller, &from_asset, &to_asset, ...)?;
+ let mut post_config = user_config.clone();
+ post_config.set_using_as_collateral(from_reserve_id, new_user_scaled_balance != 0);
+ post_config.set_using_as_collateral(to_reserve_id, true);
+ calculation::validate_swap_health_factor_with_config(&env, &caller, &post_config, ...)?;
QA-90: Reward rounding can overpay emitted incentives by dust units
Assessment Incentive accrual rounds the global reward index with half-up RAY division, then rounds each user’s pending rewards with half-up RAY multiplication. Because rounding is applied independently at the index layer and again per user, the sum of user claims can exceed the configured emission by small base-unit amounts.
The unit is the reward token’s smallest unit, not a whole token. For a 7-decimal reward token, one excess unit is 0.0000001 token; for an 18-decimal reward token, one excess unit is 0.000000000000000001 token. The issue is therefore dust-sized per user/settlement unless a low-decimal reward token is used or account fragmentation and frequent settlement make the aggregate drift material.
Code context:
// contracts/incentives/src/calculation.rs:70-80
let emission_times_time = config.emission_per_second
.checked_mul(effective_time as u128)
.ok_or(IncentivesError::MathOverflow)?;
let reward_increment = k2_shared::ray_div(
env,
emission_times_time,
total_supply,
)?;
// contracts/incentives/src/calculation.rs:110-115
let index_diff = current_index.checked_sub(user_snapshot)
.ok_or(crate::error::IncentivesError::MathOverflow)?;
k2_shared::ray_mul(env, index_diff, user_balance)
.map_err(|_| crate::error::IncentivesError::MathOverflow)
Concrete example: if emission_times_time = 1 and total_supply = 2, the global index increment rounds to 0.5 RAY. Two users with balance_snapshot = 1 each then round 0.5 RAY * 1 to 1 base unit, making 2 claimable base units even though only 1 base unit was emitted.
Recommended change Use emission-conservative accounting: floor index increments and user accrual, and carry undistributed residual reward units forward at the per-asset/per-reward configuration layer so total claimable rewards cannot exceed total emitted rewards.
Patch sketch:
diff --git a/contracts/incentives/src/calculation.rs b/contracts/incentives/src/calculation.rs
@@
- let reward_increment = k2_shared::ray_div(
+ let reward_increment = k2_shared::ray_div_down(
env,
emission_times_time,
total_supply,
).map_err(|_| IncentivesError::MathOverflow)?;
@@
- k2_shared::ray_mul(env, index_diff, user_balance)
+ k2_shared::ray_mul_down(env, index_diff, user_balance)
.map_err(|_| crate::error::IncentivesError::MathOverflow)
QA-91: Liquidation engine records collateral_to_liquidate from its own calculation, not from the router’s actual settlement
Assessment
LiquidationEngineContract::execute_liquidation (liquidation-engine/contract.rs:49-97) calls calculation::calculate_liquidation to get liquidation_result, then calls the router’s liquidation_call. The router performs its own independent liquidation calculation. The engine then records liquidation_result.collateral_amount (from its own calculation) in the liquidation record, not the actual amount from the router.
If the router’s calculation differs (e.g., due to rounding, index differences, or price changes between calls), the recorded value may be inaccurate. This is an observability / audit trail issue.
Evidence. contracts/liquidation-engine/src/contract.rs:68-96
Code context:
// contracts/liquidation-engine/src/contract.rs:68-81
let liquidation_result =
calculation::calculate_liquidation(&env, collateral_asset.clone(), debt_asset.clone(), user.clone(), debt_to_cover)?;
let kinetic_router = storage::get_kinetic_router(&env)?;
let mut args = Vec::new(&env);
args.push_back(liquidator.clone().into_val(&env));
args.push_back(collateral_asset.clone().into_val(&env));
args.push_back(debt_asset.clone().into_val(&env));
args.push_back(user.clone().into_val(&env));
args.push_back(debt_to_cover.into_val(&env));
args.push_back(false.into_val(&env));
let _: () =
env.invoke_contract(&kinetic_router, &Symbol::new(&env, "liquidation_call"), args);
// ...
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Capture the router’s liquidation_call return value and record that instead.
Patch sketch:
diff --git a/contracts/liquidation-engine/src/contract.rs b/contracts/liquidation-engine/src/contract.rs
@@
- let _: () = env.invoke_contract(&kinetic_router, &Symbol::new(&env, "liquidation_call"), args);
+ let actual: LiquidationCall = env.invoke_contract(&kinetic_router, &Symbol::new(&env, "liquidation_call"), args);
@@
- collateral_to_liquidate: liquidation_result.collateral_amount,
+ collateral_to_liquidate: actual.collateral_to_liquidate,
QA-92: set_liquidation_price_tolerance allows zero tolerance silently disabling price deviation protection
Assessment
set_liquidation_price_tolerance (params.rs:194–212) validates tolerance_bps > 5000 as an upper bound but does not enforce a minimum. Setting tolerance_bps = 0 is valid and silently disables all price deviation protection during staged liquidation execution. Combined with the circuit-breaker (which has its own independent threshold), this allows a governance misconfiguration where oracle-detected price manipulation during liquidation execution goes unchecked.
Review trail. Generic code-level pass — params.rs
Code context:
// contracts/kinetic-router/src/params.rs:194-205
pub fn set_liquidation_price_tolerance(env: Env, tolerance_bps: u128) -> Result<(), KineticRouterError> {
let admin = storage::get_pool_admin(&env)?;
admin.require_auth();
// N-02
// Cap at 5000 bps (50%) as a reasonable maximum tolerance
if tolerance_bps > 5000 {
return Err(KineticRouterError::InvalidAmount);
}
storage::set_liquidation_price_tolerance_bps(&env, tolerance_bps);
Recommended change Enforce a non-zero minimum for the liquidation price tolerance, or document that zero is an explicit “disable” sentinel.
Patch sketch:
diff --git a/contracts/kinetic-router/src/params.rs b/contracts/kinetic-router/src/params.rs
@@
- if tolerance_bps > 5000 {
+ if tolerance_bps == 0 || tolerance_bps > 5000 {
return Err(KineticRouterError::InvalidAmount);
}
QA-93: Pool-configurator rate-strategy rotation lacks downstream contract authorization
Assessment
pool-configurator:set_reserve_interest_rate passes env.current_contract_address() as caller to the router but does not call env.authorize_as_current_contract. The router then calls caller.require_auth() on that address, which fails at the contract-auth boundary.
Traceability. OBL-717
Code context:
// contracts/pool-configurator/src/reserve.rs:361-370
env.invoke_contract::<Result<(), KineticRouterError>>(
&storage::get_kinetic_router(env)?,
&soroban_sdk::Symbol::new(env, "update_reserve_configuration"),
soroban_sdk::vec![
env,
env.current_contract_address().into_val(env),
asset.into_val(env),
lending_pool_config.into_val(env),
],
)
Recommended change
Add env.authorize_as_current_contract() before the downstream router call.
Patch sketch:
diff --git a/contracts/pool-configurator/src/reserve.rs b/contracts/pool-configurator/src/reserve.rs
@@
- env.invoke_contract::<Result<(), KineticRouterError>>(&router, &sym, args)?;
+ env.authorize_as_current_contract(vec![env, router_auth(&router, &sym, &args)]);
+ env.invoke_contract::<Result<(), KineticRouterError>>(&router, &sym, args)?;
QA-94: aToken transfers can re-enable receiver collateral without consent
Assessment
validate_and_finalize_transfer sets the receiver collateral bit when to_balance_after > 0 and !to_config.is_using_as_collateral(reserve_id). If the receiver already had a positive scaled balance and had manually disabled collateral for that reserve, an incoming transfer silently re-enables it. This also causes false rejections at the MAX_USER_RESERVES limit when the receiver already has the reserve active through borrowing.
Traceability. OBL-654, OBL-656–OBL-659
Code context:
// contracts/kinetic-router/src/router.rs:1874-1884
// Receiver: set collateral bit if they now have a position
if to_balance_after > 0 {
let mut to_config = storage::get_user_configuration(&env, &to);
if !to_config.is_using_as_collateral(reserve_id) {
let active_count = to_config.count_active_reserves();
if active_count >= storage::MAX_USER_RESERVES {
panic_with_error!(&env, k2_shared::UserReserveError::MaxUserReservesExceeded);
}
to_config.set_using_as_collateral(reserve_id, true);
storage::set_user_configuration(&env, &to, &to_config);
}
Recommended change Track whether the receiver already has a position before setting the collateral bit. Do not re-enable collateral for receivers who previously opted out while holding a positive balance.
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- args.push_back(to_balance_u128.into_val(&env));
+ args.push_back(to_balance_u128.into_val(&env));
+ args.push_back(safe_i128_to_u128(&env, to_scaled_balance).into_val(&env));
diff --git a/contracts/kinetic-router/src/router.rs b/contracts/kinetic-router/src/router.rs
@@
- if to_balance_after > 0 {
+ if to_balance_before == 0 && to_balance_after > 0 {
to_config.set_using_as_collateral(reserve_id, true);
}
QA-95: Collateral-swap minimum output is enforced before protocol fees
Assessment Collateral swaps compare minamountout against the gross destination amount returned by the DEX or custom handler, then deduct the protocol fee before transferring destination underlying into the aToken, minting collateral, and returning the result. The successful return value and minted collateral can therefore be below the caller-selected minimum whenever gross output only barely satisfies the minimum and a positive fee is charged.
Failure mode. Minimum-output enforcement is applied before the protocol-fee deduction even though the route returns and supplies the post-fee amount. The code does not recheck amounttosupply against minamountout before reporting success.
Trigger path. A positive collateral-swap protocol fee rate is configured; the selected swap path returns a gross destination output at or slightly above minamountout; and the computed protocolfee makes gross output minus fee less than minamount_out.
Why it matters. User slippage protection can be underenforced by up to the configured protocol-fee amount. A user can receive and collateralize less destination asset than their stated minimum while the swap still succeeds; the issue is bounded by configured fees but repeatable across collateral swaps.
Evidence. contracts/kinetic-router/src/swap.rs:192-204; contracts/kinetic-router/src/swap.rs:259-370; docs/04-COMPONENTS.md:297-325
Code context:
// contracts/kinetic-router/src/swap.rs:192-205
if to_amount_received < min_amount_out {
return Err(KineticRouterError::InsufficientSwapOut);
}
// Calculate protocol fee from swap output
let protocol_fee = k2_shared::utils::percent_mul(to_amount_received, swap_config.flash_loan_premium_bps)?;
// Ensure fee doesn't exceed swap output
let amount_to_supply = if to_amount_received > protocol_fee {
to_amount_received - protocol_fee
} else {
return Err(KineticRouterError::MathOverflow);
};
// ...
Recommended change Apply minamountout to the post-fee amounttosupply, or expose two explicit parameters for gross DEX output and net received collateral. If gross-output semantics are intended, update the interface and docs to state that protocol fees can reduce the returned collateral below minamountout.
Patch sketch:
diff --git a/contracts/kinetic-router/src/swap.rs b/contracts/kinetic-router/src/swap.rs
@@
- if to_amount_received < min_amount_out {
+ if to_amount_received < min_amount_out {
return Err(KineticRouterError::InsufficientSwapOut);
}
@@
- let amount_to_supply = to_amount_received - protocol_fee;
+ let amount_to_supply = to_amount_received - protocol_fee;
+ if amount_to_supply < min_amount_out {
+ return Err(KineticRouterError::InsufficientSwapOut);
+ }
QA-96: Token contract initialize does not require admin auth
Assessment
TokenContract::initialize (token/contract.rs:204-215) does not call admin.require_auth(). Any caller who front-runs deployment can set arbitrary admin, name, symbol, and decimals. While the has_admin check prevents re-initialization, the first caller controls the token’s metadata and minting authority.
This applies only to the standalone test token contract (not aToken/debtToken which are deployed by the pool configurator). If this contract is used for underlying assets in production, it presents a front-running risk.
Evidence. contracts/token/src/contract.rs:204-215
Code context:
// contracts/token/src/contract.rs:203-214
/// Initialize the token contract
pub fn initialize(env: Env, admin: Address, name: String, symbol: String, decimals: u32) {
// Check if already initialized
if storage::has_admin(&env) {
panic_with_error!(&env, TokenError::AlreadyInitialized);
}
// Set admin and metadata
storage::set_admin(&env, &admin);
storage::set_name(&env, &name);
storage::set_symbol(&env, &symbol);
storage::set_decimals(&env, decimals);
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Add admin.require_auth() to the token initialize function.
Patch sketch:
diff --git a/contracts/token/src/contract.rs b/contracts/token/src/contract.rs
@@
pub fn initialize(env: Env, admin: Address, name: String, symbol: String, decimals: u32) {
+ admin.require_auth();
if storage::has_admin(&env) {
QA-97: Partial withdrawals can overburn aToken claim value relative to payout
Assessment
withdraw pays the caller’s nominal amount_to_withdraw, but a-token:burn_scaled rounds the burn upward with ray_div_up(amount, liquidity_index). For partial withdrawals that are not exactly representable in scaled shares, the user can burn more live aToken claim value than the underlying amount transferred back. This is user-adverse and protocol-favorable rather than attacker-profitable, so it belongs in QA instead of the main report.
Code context:
// contracts/kinetic-router/src/operations.rs:224-245
let burn_result = env.try_invoke_contract::<(bool, i128), KineticRouterError>(
&updated_reserve_data.a_token_address,
&Symbol::new(&env, "burn_scaled"),
args,
);
// ...
transfer_args.push_back(amount_to_withdraw.into_val(&env));
env.try_invoke_contract::<bool, KineticRouterError>(
&updated_reserve_data.a_token_address,
&Symbol::new(&env, "transfer_underlying_to"),
transfer_args,
);
// contracts/a-token/src/contract.rs:275-303
let scaled_u128 = ray_div_up(&env, amount, index).map_err(|_| TokenError::InvalidIndex)?;
let mut amount_scaled = safe_u128_to_i128(&env, scaled_u128);
// ...
let actual_amount = ray_mul(&env, safe_i128_to_u128(&env, amount_scaled), index)
.map_err(|_| TokenError::InvalidIndex)?;
Recommended change Return the actual underlying value implied by the scaled shares burned and make the router transfer that settled amount, or reject non-representable partial withdrawal amounts.
Patch sketch:
diff --git a/contracts/a-token/src/contract.rs b/contracts/a-token/src/contract.rs
@@
- ) -> Result<(bool, i128), TokenError> {
+ ) -> Result<(bool, i128, u128), TokenError> {
@@
- Ok((new_scaled_balance == 0, state.total_supply_scaled))
+ Ok((new_scaled_balance == 0, state.total_supply_scaled, actual_amount))
diff --git a/contracts/kinetic-router/src/operations.rs b/contracts/kinetic-router/src/operations.rs
@@
- let burn_result = env.try_invoke_contract::<(bool, i128), KineticRouterError>(...);
+ let burn_result = env.try_invoke_contract::<(bool, i128, u128), KineticRouterError>(...);
+ let actual_amount_to_transfer = burn_result?.2.min(amount_to_withdraw);
@@
- transfer_args.push_back(amount_to_withdraw.into_val(&env));
+ transfer_args.push_back(actual_amount_to_transfer.into_val(&env));
QA-98: remove_asset_reward deactivates config but does not update the global reward index
Assessment
IncentivesContract::remove_asset_reward (incentives/contract.rs:726-767) sets config.is_active = false without first freshening the global reward index. Any emissions between the last handle_action and the deactivation are lost. This is the same pattern as set_distribution_end (QA-25) but applied to the active flag.
set_emission_per_second (line 619-642) correctly freshens the index before changing the emission rate.
Evidence. contracts/incentives/src/contract.rs:726-767
Code context:
// contracts/incentives/src/contract.rs:743-751
// Get existing configuration
let mut config = storage::get_asset_reward_config(&env, &asset, &reward_token, reward_type)
.ok_or(IncentivesError::AssetRewardConfigNotFound)?;
// Deactivate
config.is_active = false;
// Save updated configuration
storage::set_asset_reward_config(&env, &asset, &reward_token, reward_type, &config)?;
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Call update_asset_reward_index before deactivating to capture remaining emissions.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
- // Deactivate
+ freshen_asset_reward_index(&env, &asset, &reward_token, reward_type, &config)?;
+ // Deactivate
config.is_active = false;
QA-99: MAXUSERRESERVES overrejects slot-neutral same-reserve transitions
Assessment Several routes decide whether MAXUSERRESERVES applies by looking only at the specific bitmap bit they are about to set, instead of whether the selected reserve already counts as active through the opposite bit. On first supply into a debt-only reserve, operations::supply checks only isusingascollateral before rejecting at the active-reserve cap. On first borrow from a collateral-only reserve, operations::borrow checks only isborrowing. The same pattern appears in aToken receiver finalization and receive-aToken liquidation: if the recipient or liquidator already has the reserve active through its borrowing bit, the route can still reject when setting the collateral bit at the cap even though no new active reserve slot would be created.
Failure mode. The router has no shared helper that answers the real question for the active-reserve bound: whether neither collateral nor borrowing bit is currently set for the reserve. Supply, borrow, transfer finalization, and receive-aToken liquidation each gate on the bit being written rather than on distinct active-reserve-slot creation.
Trigger path. A user, recipient, or liquidator is already at MAXUSERRESERVES, and the selected reserve is already active for that address through the opposite bitmap bit only. Examples include a debt-only reserve receiving its first collateral entry through supply or receive-aToken liquidation, or a collateral-only reserve receiving its first borrowing bit through borrow.
Why it matters. The affected routes can reject otherwise valid same-reserve transitions that do not increase the active reserve count. In the strongest practical case, a borrower at the reserve-fragmentation limit can be blocked from adding collateral to an existing debt reserve, preventing a same-reserve top-up that should improve position safety without opening a new slot. The same root can also make incoming aToken transfers or receive-aToken liquidation settlement fail for already-active recipients. The impact is bounded to accounts at the configured reserve-count limit, but it creates repeatable availability failures on legitimate state transitions.
Evidence. contracts/kinetic-router/src/operations.rs:94-110,415-428; contracts/kinetic-router/src/router.rs:1874-1883; contracts/kinetic-router/src/liquidation.rs:412-421
Code context:
// contracts/kinetic-router/src/operations.rs:94-107
// Update user configuration only if first time using this reserve
// Preserve user's existing collateral preference for subsequent supplies
if is_first_supply {
let mut user_config = storage::get_user_configuration(&env, &on_behalf_of);
let reserve_id = k2_shared::safe_reserve_id(&env, updated_reserve_data.id);
// Check if this is a new reserve position (not already using as collateral)
if !user_config.is_using_as_collateral(reserve_id) {
let active_count = user_config.count_active_reserves();
if active_count >= storage::MAX_USER_RESERVES {
panic_with_error!(&env, UserReserveError::MaxUserReservesExceeded);
}
}
// ...
// contracts/kinetic-router/src/router.rs:1874-1883
// Receiver: set collateral bit if they now have a position
if to_balance_after > 0 {
let mut to_config = storage::get_user_configuration(&env, &to);
if !to_config.is_using_as_collateral(reserve_id) {
let active_count = to_config.count_active_reserves();
if active_count >= storage::MAX_USER_RESERVES {
panic_with_error!(&env, k2_shared::UserReserveError::MaxUserReservesExceeded);
}
to_config.set_using_as_collateral(reserve_id, true);
storage::set_user_configuration(&env, &to, &to_config);
Recommended change Route every MAXUSERRESERVES decision through a shared helper that first checks whether either bitmap bit is already set for the reserve. Only enforce the cap when neither bit is set and the route would create a distinct new active reserve slot. Reuse that helper in supply, borrow, transfer finalization, and receive-aToken liquidation recipient updates.
Patch sketch:
diff --git a/contracts/kinetic-router/src/storage.rs b/contracts/kinetic-router/src/storage.rs
@@
+pub fn require_new_reserve_slot(config: &UserConfiguration, reserve_id: u8) -> Result<(), UserReserveError> {
+ if !config.is_using_as_collateral(reserve_id) && !config.is_borrowing(reserve_id)
+ && config.count_active_reserves() >= MAX_USER_RESERVES {
+ return Err(UserReserveError::MaxUserReservesExceeded);
+ }
+ Ok(())
+}
QA-100: : [omitted]
This finding has been omitted from the report as it was deemed invalid by the judge.
QA-101: Claim-all rewards rejects supported asset registries
Assessment The incentives registry accepts up to 100 configured assets and 20 reward tokens per asset, but claimallrewards treats an empty assets vector as a request to process the full configured asset list and then rejects the call if that expanded list is longer than MAXCLAIMABLEASSETS, currently 10. Once the emission manager configures more than ten reward assets, the empty-list claim-all mode always returns MaxAssetsExceeded before claiming anything.
Failure mode. The implementation uses one global registry for all reward assets and a much smaller per-transaction processing cap, but the empty-list sentinel expands to the global registry before enforcing the per-call cap. The function has no cursor or pagination argument to process the global list in bounded batches.
Trigger path. The emission manager configures rewards for more than ten distinct asset token addresses, which is allowed by the MAXASSETS registry bound and ordinary multi-reserve deployment. A user calls claimall_rewards with an empty assets vector to claim all configured rewards.
Why it matters. The documented all-assets claim path becomes unavailable for every user once the configured incentives registry exceeds ten assets. Users can still claim rewards by supplying explicit batches of ten or fewer assets, so rewards are not permanently stuck, but the all-claim operation fails in a supported many-asset configuration and integrations relying on the empty-list mode can break.
Evidence. contracts/incentives/src/storage.rs:75-78,315-326,343-355; contracts/incentives/src/constants.rs:1; contracts/incentives/src/contract.rs:386-397,407-483
Code context:
// contracts/incentives/src/storage.rs:75-78
// Maximum number of assets (prevents unbounded growth)
const MAX_ASSETS: u32 = 100;
// Maximum number of reward tokens per asset (prevents unbounded growth)
const MAX_REWARD_TOKENS_PER_ASSET: u32 = 20;
// contracts/incentives/src/constants.rs:1-1
pub const MAX_CLAIMABLE_ASSETS: u32 = 10;
Recommended change Separate the all-assets registry from the per-transaction budget cap. Add a paginated claimallrewards variant with start/cursor and limit, or make the empty-list mode process a bounded page and return the next cursor. Alternatively reject empty assets when the registry is larger than the cap with a documented pagination requirement, and expose helper views for clients to construct batches.
Patch sketch:
diff --git a/contracts/incentives/src/contract.rs b/contracts/incentives/src/contract.rs
@@
+ pub fn claim_all_rewards_page(env: Env, user: Address, cursor: u32, limit: u32, to: Address)
+ -> Result<Option<u32>, IncentivesError> {
+ let assets = storage::get_assets_page(&env, cursor, limit)?;
+ Self::claim_all_rewards(env, user, assets, to)?;
+ storage::next_assets_cursor(cursor, limit)
+ }
QA-102: Whitelist/blacklist membership checks scan only the first 64 entries
Assessment
is_address_whitelisted_for_reserve and is_address_blacklisted_for_reserve iterate only the first MAX_RESERVES (64) entries of the stored vector. set_reserve_whitelist / set_reserve_blacklist store the full admin-supplied vector without length validation. Addresses configured at index 64+ are present in the admin policy but invisible to the membership check. The same pattern applies to liquidation and swap-handler whitelists.
Traceability. OBL-347, OBL-640, OBL-641
Code context:
// contracts/kinetic-router/src/storage.rs:843-852
let len = whitelist.len().min(MAX_RESERVES);
for i in 0..len {
if let Some(whitelisted_addr) = whitelist.get(i) {
if whitelisted_addr == *address {
return true;
}
}
}
false
// contracts/kinetic-router/src/storage.rs:903-912
let len = blacklist.len().min(MAX_RESERVES);
for i in 0..len {
if let Some(blacklisted_addr) = blacklist.get(i) {
if blacklisted_addr == *address {
return true;
}
}
}
false
// contracts/kinetic-router/src/storage.rs:1013-1022
let len = whitelist.len().min(MAX_RESERVES);
for i in 0..len {
if let Some(whitelisted_addr) = whitelist.get(i) {
if whitelisted_addr == *handler {
return true;
}
}
}
false
Recommended change Either enforce a maximum list length at the setter, or use a map-based lookup instead of linear scan. If linear scan is kept, scan the full vector.
Patch sketch:
diff --git a/contracts/kinetic-router/src/storage.rs b/contracts/kinetic-router/src/storage.rs
@@
pub fn set_reserve_whitelist(env: &Env, asset: &Address, whitelist: &Vec<Address>) {
+ if whitelist.len() > MAX_ACCESS_LIST_LEN { panic_with_error!(env, KineticRouterError::InvalidAmount); }
let key = (WHITELIST, asset.clone());
QA-103: Batch price queries group only the first adapter cohort
Assessment The oracle documentation says getassetpricesvec automatically groups assets by adapter and uses batch queries where possible, but the implementation keeps only one batchadapteraddr. Assets whose adapter matches the first batch adapter encountered are grouped into one readpricedata(feedids) call; assets on any later adapter fall through to getassetpricedatawithconfig. If two or more later assets share that later adapter, they are still resolved one by one through readpricedataforfeed instead of a grouped readprice_data call. Router account-data and swap-health paths build vector price requests from user action assets plus active reserves, so an action asset on a minority adapter can make a larger repeated adapter cohort non-first and force many single-feed oracle calls.
Failure mode. The vector query classifier stores one global adapter group instead of grouping by adapter address. It has no map from adapter address to that adapter’s feed ids, per-feed metadata, and result indices, so only the first adapter cohort can reach the grouped batchqueryadapter path.
Trigger path. Price cache entries do not satisfy the request; the vector input contains assets from more than one configured batch adapter; at least two uncached assets share an adapter that is not the first batch adapter encountered; the route depends on getassetpricesvec through account data, borrow/withdraw validation, or swap health validation; and the extra direct readpricedatafor_feed calls exceed practical execution budget or a configured adapter’s single-feed path is less available than its grouped path.
Why it matters. When the later single-feed calls succeed, the returned prices remain positionally correct and this does not by itself create mispricing. The impact is availability and budget fragility: valid price-dependent operations can require many more cross-contract calls than the documented grouped vector path, and can fail even though the same asset set could have been served through one grouped call per adapter.
Evidence. contracts/price-oracle/src/contract.rs:449-508; contracts/price-oracle/src/contract.rs:512-545; contracts/price-oracle/src/oracle.rs:376-480; docs/08-ORACLE.md:399-421; contracts/kinetic-router/src/calculation.rs:118-198; contracts/kinetic-router/src/validation.rs:160-168; contracts/kinetic-router/src/validation.rs:228-256; contracts/kinetic-router/src/calculation.rs:617-634
Code context:
// contracts/price-oracle/src/contract.rs:490-503
// Classify: batch adapter vs other
if let (Some(adapter), Some(feed_id)) = (config.batch_adapter.clone(), config.feed_id.clone()) {
if batch_adapter_addr.is_none() {
batch_adapter_addr = Some(adapter.clone());
}
// Batch if same adapter
if batch_adapter_addr.as_ref() == Some(&adapter) {
batch_feed_ids.push_back(feed_id);
batch_decimals.push_back(config.oracle_decimals.unwrap_or(8));
batch_max_ages.push_back(config.max_age);
batch_indices.push_back(idx as u32);
results.push_back(None); // placeholder
continue;
}
// ...
// contracts/price-oracle/src/contract.rs:512-523
// Phase 2: Batch-fetch adapter prices
if batch_feed_ids.len() > 0 {
if let Some(adapter_addr) = &batch_adapter_addr {
let batch_results = if batch_feed_ids.len() == 1 {
// Single feed — use direct call (no batch overhead)
let feed_id = batch_feed_ids.get(0).ok_or(OracleError::OracleQueryFailed)?;
let decimals = batch_decimals.get(0).unwrap_or(8);
let max_age = batch_max_ages.get(0).unwrap_or(None);
let pd = oracle::query_batch_adapter_direct(
&env, adapter_addr, &feed_id, decimals, max_age,
oracle_config.price_precision, oracle_config.price_staleness_threshold,
)?;
Recommended change Collect batch candidates in per-adapter groups keyed by adapter address, preserving each feed id, decimals, maxage, and original result index. After classification, resolve every group with batchqueryadapter when it has multiple feeds and querybatchadapterdirect when it has one feed, then write each result back to its saved index. Keep the existing standard single-asset ladder for non-batch assets.
Patch sketch:
diff --git a/contracts/price-oracle/src/contract.rs b/contracts/price-oracle/src/contract.rs
@@
- let mut batch_adapter_addr: Option<Address> = None;
+ let mut adapter_groups: Map<Address, BatchGroup> = Map::new(&env);
@@
- if batch_adapter_addr.as_ref() == Some(&adapter) { ... }
+ adapter_groups = add_to_adapter_group(adapter_groups, adapter, feed_id, decimals, max_age, idx)?;
@@
- // Phase 2: Batch-fetch adapter prices
+ // Phase 2: Batch-fetch every adapter group
+ resolve_all_adapter_groups(&env, adapter_groups, &mut results)?;
QA-104: Oracle config accepts out-of-range circuit-breaker thresholds
Assessment The oracle docs define maxpricechangebps as a basis-points value in the 0-10000 range, with 0 explicitly disabling the circuit breaker. setoracleconfig validates only priceprecision and stores maxpricechange_bps values above 10000, allowing the deviation gate to be weakened outside its documented basis-points domain.
Failure mode. OracleConfig validation is incomplete: setoracleconfig checks priceprecision > 18 but does not reject maxpricechangebps > 10000 before storing the config consumed by validatepricechange.
Trigger path. The oracle admin calls setoracleconfig with maxpricechange_bps greater than 10000; the circuit breaker is expected to enforce a basis-points threshold rather than be explicitly disabled with 0; a later price read returns a large nonzero deviation that is below the oversized stored threshold.
Why it matters. Price-dependent router checks can accept oracle price moves larger than 100% without requiring the explicit disabled value. This makes accidental or unintended governance configuration capable of neutering the circuit breaker while still appearing as an enabled nonzero threshold.
Evidence. contracts/price-oracle/src/contract.rs:565-575; contracts/price-oracle/src/contract.rs:853-867; docs/08-ORACLE.md:789-790
Code context:
// contracts/price-oracle/src/contract.rs:842-855
fn validate_price_change(
env: &Env,
asset: &k2_shared::Asset,
new_price: &u128,
oracle_config: &k2_shared::OracleConfig,
) -> Result<(), OracleError> {
// M-10
if *new_price == 0 {
return Err(OracleError::InvalidPrice);
}
// Disabled when threshold is 0 (useful for testing or emergency situations)
if oracle_config.max_price_change_bps == 0 {
return Ok(());
// ...
Recommended change Reject maxpricechangebps above BASISPOINTS/10000 in setoracleconfig, keeping 0 as the explicit disabled value. Emit or document any intentionally wider emergency mode as a separate explicit setting rather than overloading the basis-points threshold.
Patch sketch:
diff --git a/contracts/price-oracle/src/contract.rs b/contracts/price-oracle/src/contract.rs
@@
- storage::set_oracle_config(&env, &config);
+ if config.max_price_change_bps > BASIS_POINTS && config.max_price_change_bps != 0 {
+ return Err(OracleError::InvalidConfig);
+ }
+ storage::set_oracle_config(&env, &config);
QA-105: Debt token burn_scaled dust threshold of <= 1 may leave non-trivial phantom debt for high-index reserves
Assessment
DebtTokenContract::burn_scaled (debt-token/contract.rs:240-248) treats remaining scaled debt <= 1 as dust and force-clears it. For a reserve with a very high variable borrow index (e.g., 2 * RAY = 2e27), 1 scaled unit represents 2 underlying tokens (i.e., ray_mul(1, 2*RAY) = 2). This means up to 2 underlying tokens of debt can be silently forgiven per repayment.
Over many repayments across many users, the cumulative forgiven debt could become non-trivial for high-index reserves. The threshold should scale with index.
Evidence. contracts/debt-token/src/contract.rs:241
Code context:
// contracts/debt-token/src/contract.rs:237-248
// When ray_div_down(ray_mul(scaled, index), index) < scaled by 1 unit,
// a tiny phantom debt remains. Force full burn if residual is dust (≤ 1).
let final_user_scaled_debt;
if new_scaled_debt <= 1 {
amount_scaled = current_scaled_debt;
storage::set_scaled_debt(&env, &on_behalf_of, 0);
final_user_scaled_debt = 0i128;
} else {
storage::set_scaled_debt(&env, &on_behalf_of, new_scaled_debt);
final_user_scaled_debt = safe_u128_to_i128(&env, new_scaled_debt);
}
Review trail. Generic code-level pass (find-qa-weak)
Recommended change
Scale the dust threshold by dividing a fixed underlying-amount floor by the current borrow index, rather than using a fixed 1 scaled unit.
Patch sketch:
diff --git a/contracts/debt-token/src/contract.rs b/contracts/debt-token/src/contract.rs
@@
- if new_scaled_debt <= 1 {
+ let dust_scaled = ray_div_up(&env, MIN_UNDERLYING_DEBT_DUST, index).map_err(|_| TokenError::InvalidIndex)?;
+ if new_scaled_debt <= dust_scaled {
amount_scaled = current_scaled_debt;
QA-106: Collateral-swap fee transfers bypass destination reserve recipient lists
Assessment Collateral swaps validate the caller against the source and destination reserve whitelist/blacklist policy, but the protocol-fee recipient is a separate configured treasury address. After the swap returns output, swap_collateral transfers the destination-asset protocol fee to that treasury without checking whether the treasury is permitted by the destination reserve whitelist or blocked by its blacklist.
Failure mode. Destination reserve access control is applied only to the user-controlled swap caller. The later protocol-fee transfer assumes configured-treasury status is sufficient and does not apply the third-party recipient checks used by other protocol-mediated reserve asset movements.
Trigger path. A destination reserve has a non-empty whitelist that excludes the configured treasury or a reserve blacklist that includes the treasury; the swap caller passes source and destination reserve access checks; the swap succeeds with nonzero protocol fee and a configured treasury.
Why it matters. Permissioned destination-reserve recipient restrictions can be bypassed for collateral-swap fee routing. The treasury can receive destination reserve output assets even when it is not whitelisted for the reserve or is reserve-blacklisted. The amount is limited to the swap protocol fee, so the impact is mainly policy and incident-response control degradation.
Evidence. contracts/kinetic-router/src/swap.rs:65-69,214-257; contracts/kinetic-router/src/operations.rs:29-32,141-147
Code context:
// contracts/kinetic-router/src/swap.rs:239-252
let mut fee_transfer_args = Vec::new(&env);
fee_transfer_args.push_back(pool_address.to_val());
fee_transfer_args.push_back(treasury.to_val());
fee_transfer_args.push_back(protocol_fee_i128.into_val(&env));
let fee_transfer_result = env.try_invoke_contract::<(), KineticRouterError>(
&to_asset,
&sym_transfer,
fee_transfer_args,
);
match fee_transfer_result {
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => {
// ...
Recommended change Before transferring the swap protocol fee, validate the configured treasury against validatereservewhitelistaccess and validatereserveblacklistaccess for the destination asset, or document and implement an explicit treasury exemption consistently across reserve recipient checks.
Patch sketch:
diff --git a/contracts/kinetic-router/src/swap.rs b/contracts/kinetic-router/src/swap.rs
@@
if protocol_fee > 0 {
if let Some(treasury) = swap_config.treasury {
+ validation::validate_reserve_whitelist_access(&env, &to_asset, &treasury)?;
+ validation::validate_reserve_blacklist_access(&env, &to_asset, &treasury)?;
let mut fee_transfer_args = Vec::new(&env);
QA-107: Accepted reserve limits can overflow during user-path conversion
Assessment Reserve configuration accepts whole-token supply caps, borrow caps, debt ceilings, and minimum-debt floors independently from reserve decimals. The raw updatereserveconfiguration path validates only decoded field ranges from ReserveConfiguration, so encoded supplycap, borrowcap, and minremainingdebt can still be accepted even when they are not representable after scaling by the reserve decimals. User, prepare-liquidation, and liquidation execution paths later convert those limits into smallest units with checked u128 multiplication by 10^decimals. For high-decimal reserves, accepted values can overflow during supply, borrow, repay, direct liquidation, staged liquidation preparation, or staged liquidation execution validation and cause MathOverflow instead of enforcing the configured rule.
Failure mode. Configuration writers and the raw ReserveConfiguration validator bound whole-token limits by type-sized or decoded bitmap widths, but they do not validate that each limit is representable after scaling by the reserve decimals consumed by runtime validation.
Trigger path. Governance configures a high-decimal reserve with a nonzero whole-token supply cap, borrow cap, debt ceiling, or minimum remaining debt above the representable smallest-unit maximum. At 38 decimals, any whole-token limit above 3 overflows u128 when scaled.
Why it matters. Capped supplies, capped borrows, minimum-debt borrow or partial-repay checks, and direct or staged liquidation preparation/execution dust checks can become unavailable for accepted configuration. The issue is admin/configuration-triggered and causes route-level DoS rather than direct value loss.
Evidence. contracts/kinetic-router/src/validation.rs:331-411; contracts/kinetic-router/src/operations.rs:363-390; contracts/kinetic-router/src/operations.rs:502-518; contracts/kinetic-router/src/liquidation.rs:246-250; contracts/kinetic-router/src/liquidation.rs:621-623; contracts/kinetic-router/src/router.rs:623-627; contracts/kinetic-router/src/router.rs:865-870; contracts/kinetic-router/src/router.rs:1055-1065; contracts/kinetic-router/src/reserve.rs:149-258; contracts/kinetic-router/src/reserve.rs:266-348; contracts/pool-configurator/src/reserve.rs:474-575
Code context:
// contracts/kinetic-router/src/validation.rs:347-352
let multiplier = 10u128
.checked_pow(decimals as u32)
.ok_or(KineticRouterError::MathOverflow)?;
let cap_in_smallest_units = supply_cap
.checked_mul(multiplier)
.ok_or(KineticRouterError::MathOverflow)?;
// contracts/kinetic-router/src/reserve.rs:157-166
// Validate supply_cap fits in 64 bits to prevent silent truncation
// This ensures the stored value matches the input value
const U64_MAX: u128 = u64::MAX as u128;
if supply_cap > U64_MAX {
return Err(KineticRouterError::InvalidAmount);
}
let mut reserve_data = storage::get_reserve_data(&env, &asset)?;
reserve_data.configuration.set_supply_cap(supply_cap);
storage::set_reserve_data(&env, &asset, &reserve_data);
Recommended change When initializing reserves, updating raw ReserveConfiguration, or updating individual whole-token limits, validate limit <= u128::MAX / 10^reservedecimals. Apply the same representability check to supply cap, borrow cap, debt ceiling, and minremaining_debt setters, and consider surfacing a shared helper for scaled-limit conversion.
Patch sketch:
diff --git a/contracts/kinetic-router/src/reserve.rs b/contracts/kinetic-router/src/reserve.rs
@@
+fn validate_scaled_limit(limit: u128, decimals: u32) -> Result<(), KineticRouterError> {
+ let multiplier = 10_u128.checked_pow(decimals).ok_or(KineticRouterError::MathOverflow)?;
+ if limit > u128::MAX / multiplier { return Err(KineticRouterError::InvalidAmount); }
+ Ok(())
+}
@@
- reserve_data.configuration.set_supply_cap(supply_cap);
+ validate_scaled_limit(supply_cap, reserve_data.configuration.get_decimals() as u32)?;
+ reserve_data.configuration.set_supply_cap(supply_cap);
Disclosures
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.