Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · October 2026

Blend Contracts V2

for Templar

Guardian's review of Blend Contracts V2 for Templar, published October 2026. The report records 15 findings, including 5 low and 10 informational.

Published
Review window
October 1 to 21, 2026
Language
Rust, Soroban
Chains
Stellar
Sector
Lending
  • 0 Critical
  • 0 High
  • 0 Medium
  • 5 Low
  • 10 Informational

14 resolved · 1 acknowledged

Scope

45 files in scope · 1,091 nSLOC
FilenSLOCLines
backstop/src/backstop/deposit.rs0298
backstop/src/backstop/mod.rs119
backstop/src/backstop/user.rs1774
backstop/src/backstop/withdrawal.rs8792
backstop/src/constants.rs118
backstop/src/contract.rs12319
backstop/src/dependencies/mod.rs213
backstop/src/dependencies/pool.rs1322
backstop/src/emissions/claim.rs29957
backstop/src/emissions/distributor.rs11746
backstop/src/emissions/manager.rs602305
backstop/src/emissions/mod.rs18
backstop/src/errors.rs132
backstop/src/events.rs0174
backstop/src/lib.rs224
backstop/src/storage.rs23518
pool-factory/src/pool_factory.rs1122
pool/src/auctions/auction.rs792923
pool/src/auctions/backstop_interest_auction.rs51325
pool/src/auctions/bad_debt_auction.rs132357
pool/src/auctions/user_liquidation_auction.rs63585
pool/src/constants.rs116
pool/src/contract.rs40584
pool/src/emissions/distributor.rs01667
pool/src/emissions/manager.rs4656
pool/src/errors.rs155
pool/src/events.rs17398
pool/src/lib.rs332
pool/src/math.rs1439
pool/src/pool/actions.rs721920
pool/src/pool/bad_debt.rs1361534
pool/src/pool/config.rs641608
pool/src/pool/gulp.rs18483
pool/src/pool/health_factor.rs0359
pool/src/pool/interest.rs43455
pool/src/pool/mod.rs138
pool/src/pool/pool.rs171051
pool/src/pool/reserve.rs86953
pool/src/pool/status.rs201188
pool/src/pool/submit.rs232674
pool/src/pool/user.rs231252
pool/src/storage.rs29661
shared/backstop_threshold.rs1115
contracts/pool-oracle-adapter/src/lib.rs192226
contracts/pool-oracle-adapter/src/parent.rs78

Findings 15

  1. L-01 Low Oracle failures block liquidation settlement Oracle Resolved

    Description

    When the borrower still has liabilities, check_and_handle_user_bad_debt calculates the borrower’s full position value to check whether any collateral value remains. This calculation also fetches prices for outstanding debt assets, even when the borrower has no collateral. Those prices are unnecessary for using same reserve supply to repay debt or defaulting the remaining debt.

    This affects liquidation completion because fill_user_liq_auction calls the helper on every full fill, after transferring the auction positions. If the borrower still has debt and a required oracle call fails or returns unusable prices, the entire transaction reverts. The position transfers are rolled back, and execution never reaches auction deletion. An already created auction therefore remains open despite the attempted full fill.

    Standalone bad-debt settlement uses the same helper and encounters the same dependency.

    An oracle outage can block affected full liquidation fills and prevent settlement of debt with no remaining collateral, delaying loss recognition and leaving debt outstanding.

    Recommendation

    Consider skipping oracle valuation when the borrower has no positive collateral balances. When collateral remains, calculate only its raw value without fetching debt asset prices.

  2. L-02 Low A borrower can block liquidation by adding dust collateral in a reserve whose price is unavailable Oracle Resolved

    Description

    apply_supply_collateral loads no price and runs no health check. calculate_from_positions loads a price for every reserve in which the user holds collateral or debt. load_price reverts with InvalidPrice (#1210) when the oracle returns no price, a price that is not positive, a price older than 24 hours, or a timestamp in the future. create_user_liq_auction_data values the whole position before it reads the requested lot and bid.

    While one listed reserve's price is unavailable, a borrower with debt can still supply a few stroops of collateral in that reserve. Every new_auction against the borrower then reverts with #1210 until the price returns, whatever lot, bid or percentage is requested. The shortfall can grow during the outage, and can fall on the debt reserve's lenders if the debt is later defaulted.

    Borrowers who already held collateral in that reserve before the outage are blocked the same way. This finding is about a borrower adding the dependency after the outage has started, to keep an unhealthy position out of liquidation. It concerns auction creation, not the oracle calls made when a liquidation settles, and it lasts as long as the outage.

    Recommendation

    In apply_supply_collateral, load the reserve's price when the user has liabilities, so the call reverts while that price is unusable. Do not value unpriced collateral at zero when creating auctions, because that would make healthy positions liquidatable during an outage.

  3. L-03 Low Admin proposal TTL can be extended Access Control Resolved
    Location
    pool/src/storage.rs:239-247; pool/src/contract.rs:377-389

    Description

    The pool stores the proposed administrator in temporary storage and assigns the entry a TTL of 10 days:

    e.storage().temporary().extend_ttl(
        &Symbol::new(e, PROPOSED_ADMIN_KEY),
        10 * ONE_DAY_LEDGERS,
        10 * ONE_DAY_LEDGERS,
    );
    

    The accept_admin function does not enforce an explicit expiration ledger or timestamp; it only checks whether the temporary entry still exists. Any account can include the entry in a transaction's read-only footprint and extend its lifetime using ExtendFootprintTTLOp, whose behavior is described in Stellar's state-archival documentation. Stellar's storage guidance also warns that entry expiration is not a safe security boundary because anyone can extend the TTL.

    Consequently, if the 10-day period is intended as an acceptance deadline, the proposed administrator can keep the entry alive and accept the role after that period. The stale proposal remains valid until the current administrator explicitly replaces it.

    Recommendation

    If the proposal must expire after ten days, store an explicit expiration timestamp or ledger and enforce it in accept_admin instead of relying on the storage entry's TTL.

  4. L-04 Low Withdrawing with i128::MAX reverts after a default Business logic errors Resolved

    Description

    plan_withdraw accepts a request larger than the position. If the b-tokens needed exceed the user's balance, it burns the whole position and pays out its full value. The b-token conversion runs before this cap:

    let mut to_burn = data.to_b_token_up(math, amount);
    let mut tokens_out = amount;
    if to_burn > cur_b_tokens {
        to_burn = cur_b_tokens;
        tokens_out = data.to_asset_from_b_token(math, cur_b_tokens);
    }
    

    to_b_token_up computes ceil(amount * 1e12 / b_rate). When the product overflows i128, fixed_mul_ceil retries in I256 and traps if the result does not fit back into i128. With amount = i128::MAX, the result only fits while b_rate >= 1e12.

    b_rate starts at 1e12 and grows with interest, but every default lowers it through default_b_rate. Once total defaults exceed the interest accrued so far, b_rate drops below 1e12. From then on, every Withdraw or WithdrawCollateral request for i128::MAX in that reserve reverts, until interest brings b_rate back above 1e12.

    Users, vaults and scripts that close a position by passing i128::MAX cannot withdraw right after a loss event, when they are most likely to exit.

    Recommendation

    Check the request against the position value before converting it to b-tokens:

    let position_value = data.to_asset_from_b_token(math, cur_b_tokens);
    if data.b_rate > 0 && amount > position_value {
        return (position_value, cur_b_tokens);
    }
    (amount, data.to_b_token_up(math, amount))
    

    This returns the same result as the current code for every request that does not overflow, and removes the revert for oversized requests.

  5. L-05 Low Adapter timestamp flooring defeats the pool's future-dated price rejection Oracle Acknowledged

    Description

    The pool reads every price through the SEP-40 adapter, which forwards to the Templar proxy oracle (the parent). load_price rejects a price whose timestamp is ahead of ledger time, which is what enforces ADR-0008's requirement that the pool never prices against a future-dated observation. Before the pool sees it, the adapter's lastprice rounds the parent's timestamp down to a multiple of the configured resolution, and applies no future-dated check of its own:

    fn bucket_timestamp(timestamp: u64, resolution: u32) -> u64 {
        timestamp - timestamp % u64::from(resolution)
    }
    

    A timestamp that is ahead of ledger time but inside the current bucket rounds down to at or below the current time, so the pool's check never fires.

    The parent accepts a source sample published up to max_clock_drift_secs ahead of ledger time. That setting is mandatory and capped at one hour. While such a sample is the newest history entry, a later sample with an equal or lower timestamp does not replace it, the parent reads its age as zero for as long as it is ahead of ledger time, and refresh, which anyone can call, keeps marking the cache current. The pool keeps pricing against that one sample as the market moves.

    The pool can therefore act on a price up to an hour old. Take a borrower holding 12.5 WETH against 19,200 USD of debt. At a true price of 2,060 USD the collateral is worth 25,750 USD and the position is solvent, with a health factor of 1.02. If the parent freezes on a 3% dip to 2,000 USD, the pool liquidates that borrower and about 1,280 USD, roughly 5% of the position, moves to the liquidator. At the true price the same call is rejected as InvalidLiquidation. The same frozen price also lets 10,000 USD of own-reserve supply leave an underwater position that the true price blocks with InvalidHf. Both revert with InvalidPrice if the parent's timestamp reaches the pool unmodified.

    Recommendation

    In the parent, let an observation inside the freshness window replace a history head that is ahead of ledger time, so the head cannot outlast the clock and the age gate stops reading it as zero seconds old. Passing the parent's timestamp through unfloored in lastprice would also restore the pool's check, but on its own it blocks pricing for that asset until ledger time catches up.

  6. I-01 Informational Stale ABI documentation advertises unsupported bad-debt and interest auction creation Documentation Resolved
    Location
    packages/blend-contracts-v2/pool/src/auctions/auction.rs:create_auction:L84-L94

    Description

    The fork intentionally rejects auction types 1 and 2 before their constructors, as required by ADR-0008, but the public new_auction ABI comments still describe all three types as supported and the unreachable constructors/match arms remain in the shipped code. Calls receive the mandated BadRequest and no state is changed.

    The stale ABI and retained dead paths can mislead integrators and increase maintenance/review risk only.

    Recommendation

    Document at the ABI boundary that only UserLiquidation (type 0) is supported, or remove/mark unsupported enum arms and constructors while preserving the intentional rejection. Update new_auction/get_auction/del_auction comments consistently.

    Resolution

  7. I-02 Informational Positive balance changes remain unclaimable Documentation Resolved
    Location
    pool/src/pool/gulp.rs:7-30; pool/src/contract.rs:197-208

    Description

    The scoped diff replaces surplus accounting in execute_gulp with settlement of pool-owned orphan shares. Previously, the function compared the pool’s actual token balance with its recorded balance:

    let reserve_token_balance =
        reserve.total_supply(e) + reserve.data.backstop_credit
            - reserve.total_liabilities(e);
    let token_balance_delta = pool_token_balance - reserve_token_balance;
    

    Positive differences were allocated to supplier claims and backstop credit. The replacement only burns orphan supply shares once reserve debt is zero and returns 0. It does not reconcile the underlying token balance or allocate surplus.

    Direct token transfers and positive balance adjustments therefore do not increase anyone’s withdrawable claim.

    Ignoring surplus is intentional and enforced by the fork’s tests. However, the public interface documentation still promises recovery of unaccounted tokens, including positive rebases.

    Surplus remains unclaimable. If positive rebasing assets are supported, their additional value is not allocated to suppliers or the backstop.

    Recommendation

    Document the surplus policy and update the interface documentation to match the implemented behavior. If positive balance gains should remain recoverable, introduce a separate reconciliation function that allocates verified surplus to the intended beneficiaries while preserving orphan-settlement semantics.

    Resolution

  8. I-03 Informational Freeze docs imply the pool can be reopened Documentation Resolved
    Location
    pool/src/contract.rs:178-195

    Description

    The public interface documentation says the admin can update a frozen pool’s status through set_status. However, the fork makes status 4 irreversible. Setting status 4 again is allowed, but every attempt to leave it reverts:

    if pool_config.status == 4 {
        panic_with_error!(e, PoolError::StatusNotAllowed);
    }
    

    Operators relying on these docs may treat the freeze as a temporary pause, although borrowing and new deposits cannot resume through a status change.

    Recommendation

    Update the interface documentation to state that status 4 is permanent and that even the admin cannot reactivate the pool after freezing it.

    Resolution

  9. I-04 Informational Bad debt docs misstate loss allocation Documentation Resolved
    Location
    pool/src/contract.rs:310-313

    Description

    The public documentation for bad_debt still states that the backstop takes over a borrower’s bad debt. It also describes passing the backstop address to check its health and default its remaining debt.

    The fork instead rejects calls targeting the backstop. When a borrower has no remaining collateral value, it uses their ordinary supply in the same reserve to repay debt, then writes off the remainder against suppliers.

    The implementation is intentional, but the interface documentation describes protection the code does not provide. Integrators and lenders may misunderstand who absorbs losses, while keepers may attempt unsupported backstop settlement calls.

    Recommendation

    Update the docs to describe the actual settlement process and clearly state that backstop settlement calls are unsupported.

    Resolution

  10. I-05 Informational Submit docs omit auction fill restrictions Documentation Resolved
    Location
    packages/blend-contracts-v2/pool/src/auctions/auction.rs:fill:L151-L155

    Description

    Request types 7 and 8 are retained in the public RequestType ABI and dispatched to fill, but the fork intentionally rejects both before reading auction storage, as required by ADR-0008. The corresponding fill implementations remain unreachable and submit documentation does not warn callers that these request types are unsupported.

    Because creation of these auction types is also intentionally blocked and direct residual-debt handling replaces them, no valid auction or required keeper path is stranded. The only effect is integrator confusion and dead-code maintenance risk.

    Recommendation

    Keep the atomic rejection required by ADR-0008, but document request types 7/8 as unsupported (or remove/mark them at the ABI boundary) and remove or clearly annotate unreachable fill implementations.

    Resolution

  11. I-06 Informational Activation accepts an incompatible oracle Configuration Resolved
    Location
    pool/src/pool/config.rs:16; pool/src/pool/pool.rs:99

    Description

    Pool initialization stores the supplied oracle without validating its decimal precision.

    The pool only enforces the required seven-decimal precision when a health-dependent operation later loads the oracle metadata:

    let decimals = oracle_client.decimals();
    if decimals != 7 {
        panic_with_error!(e, PoolError::InvalidPrice);
    }
    

    Neither the administrative status transition nor the permissionless activation process performs this validation. Supply and collateral-supply operations also do not access the oracle. Consequently, a pool configured with an incompatible oracle can leave setup, become active, and accept deposits before the configuration error is detected.

    The oracle address cannot be changed after deployment because no setter exists and the retained pool-update entry point always rejects. This can expose depositors to an unusable lending market and force an operationally expensive pool replacement after funds have entered the pool.

    Recommendation

    Consider validating that the oracle uses exactly seven decimals before permitting the pool to leave setup status.

  12. I-07 Informational Factory accepts undeployable take rates Validation Resolved
    Location
    pool-factory/src/pool_factory.rs:75-78; pool/src/pool/config.rs:16-29

    Description

    The factory accepts any backstop_take_rate below 100%:

    if backstop_take_rate >= SCALAR_7 {
        panic_with_error!(&e, PoolFactoryError::InvalidPoolInitArgs);
    }
    

    It then forwards the value to the pool constructor. However, pool initialization only permits a zero rate:

    if *bstop_rate != 0 {
        panic_with_error!(e, PoolError::InvalidPoolConfigArgs);
    }
    

    Consequently, values from 1 through SCALAR_7 - 1 pass the factory's validation but cause pool deployment to revert during construction.

    Because deployment is atomic, this does not create a malformed pool and its impact is limited to failed deployments, wasted transaction fees, and misleading factory validation.

    Recommendation

    Consider enforcing backstop_take_rate to equal zero in the factory and updating its interface documentation to reflect the pool's restriction.

  13. I-08 Informational plan_repay comment says the refund can be negative Documentation Resolved

    Description

    The doc comment on plan_repay says the refund "may be negative on the capped branch; production re-checks it is nonnegative before transferring". The capped branch cannot produce a negative refund:

    • The branch runs when floor(amount * 1e12 / d_rate) > cur_d_tokens, so amount * 1e12 >= (cur_d_tokens + 1) * d_rate.
    • d_rate starts at 1e12 and only grows, so amount >= cur_d_tokens * d_rate / 1e12 + 1.
    • The amount charged is ceil(cur_d_tokens * d_rate / 1e12), which is less than cur_d_tokens * d_rate / 1e12 + 1.

    The refund on the capped branch is therefore always at least 1. The comment describes a state that cannot happen and can mislead anyone reviewing repay rounding.

    Recommendation

    Update the comment to say the refund is always positive on the capped branch. The require_nonnegative check can stay.

  14. I-09 Informational del_auction docs say only unfilled auctions become stale Documentation Resolved

    Description

    The del_auction docs define a stale auction as "one that has been running for 500 blocks without being filled". Partially filled auctions can be deleted too. After a partial fill, scale_auction keeps the original block on the remaining auction, and delete_stale_auction only checks auction.block + 500 > sequence. Once 500 blocks have passed since the auction started, anyone can delete the remainder, even right after a partial fill.

    Keepers relying on the docs can expect a partially filled auction to stay open, while its remainder can be deleted at any time and the liquidation then has to be created again.

    Recommendation

    Describe a stale auction as one that started at least 500 blocks ago, whether or not it has been partially filled.

  15. I-10 Informational Stale comment in the pool factory Documentation Resolved

    Description

    deploy has two copies of the comment "verify max positions is at least 2 and less than 64". The first, above the max_positions check, was updated to say pools have at most 30 reserves and 60 positions. The second, above the min_collateral check, still says:

    // verify max positions is at least 2 and less than 64
    // pools have a max of 50 reserves, so 100 is the max number of positions
    if min_collateral < 0 {
    

    It does not describe the check below it, and the limits are wrong: MAX_RESERVES is 30 and the factory allows at most 60 positions. This gives wrong limits to anyone reviewing or changing the deployment checks.

    Recommendation

    Replace the second comment with one that describes the min_collateral check.

Put your code through the same review.

This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.

Get a quote