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
Scope
- Templar-Protocol/blend-contracts-v2 7a09b0e3d2b385dfb5cf
- Templar-Protocol/precept caeab1e4c8
- Templar-Protocol/precept 324fc5b155
45 files in scope · 1,091 nSLOC
| File | nSLOC | Lines |
|---|---|---|
backstop/src/backstop/deposit.rs | 0 | 298 |
backstop/src/backstop/mod.rs | 1 | 19 |
backstop/src/backstop/user.rs | 1 | 774 |
backstop/src/backstop/withdrawal.rs | 8 | 792 |
backstop/src/constants.rs | 1 | 18 |
backstop/src/contract.rs | 12 | 319 |
backstop/src/dependencies/mod.rs | 2 | 13 |
backstop/src/dependencies/pool.rs | 13 | 22 |
backstop/src/emissions/claim.rs | 29 | 957 |
backstop/src/emissions/distributor.rs | 11 | 746 |
backstop/src/emissions/manager.rs | 60 | 2305 |
backstop/src/emissions/mod.rs | 1 | 8 |
backstop/src/errors.rs | 1 | 32 |
backstop/src/events.rs | 0 | 174 |
backstop/src/lib.rs | 2 | 24 |
backstop/src/storage.rs | 23 | 518 |
pool-factory/src/pool_factory.rs | 1 | 122 |
pool/src/auctions/auction.rs | 79 | 2923 |
pool/src/auctions/backstop_interest_auction.rs | 5 | 1325 |
pool/src/auctions/bad_debt_auction.rs | 13 | 2357 |
pool/src/auctions/user_liquidation_auction.rs | 6 | 3585 |
pool/src/constants.rs | 1 | 16 |
pool/src/contract.rs | 40 | 584 |
pool/src/emissions/distributor.rs | 0 | 1667 |
pool/src/emissions/manager.rs | 4 | 656 |
pool/src/errors.rs | 1 | 55 |
pool/src/events.rs | 17 | 398 |
pool/src/lib.rs | 3 | 32 |
pool/src/math.rs | 14 | 39 |
pool/src/pool/actions.rs | 72 | 1920 |
pool/src/pool/bad_debt.rs | 136 | 1534 |
pool/src/pool/config.rs | 64 | 1608 |
pool/src/pool/gulp.rs | 18 | 483 |
pool/src/pool/health_factor.rs | 0 | 359 |
pool/src/pool/interest.rs | 43 | 455 |
pool/src/pool/mod.rs | 1 | 38 |
pool/src/pool/pool.rs | 17 | 1051 |
pool/src/pool/reserve.rs | 86 | 953 |
pool/src/pool/status.rs | 20 | 1188 |
pool/src/pool/submit.rs | 23 | 2674 |
pool/src/pool/user.rs | 23 | 1252 |
pool/src/storage.rs | 29 | 661 |
shared/backstop_threshold.rs | 11 | 15 |
contracts/pool-oracle-adapter/src/lib.rs | 192 | 226 |
contracts/pool-oracle-adapter/src/parent.rs | 7 | 8 |
Findings 15
-
L-01 Low Oracle failures block liquidation settlement Oracle Resolved
Description
When the borrower still has liabilities,
check_and_handle_user_bad_debtcalculates 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_auctioncalls 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.
-
L-02 Low A borrower can block liquidation by adding dust collateral in a reserve whose price is unavailable Oracle Resolved
Description
apply_supply_collateralloads no price and runs no health check.calculate_from_positionsloads a price for every reserve in which the user holds collateral or debt.load_pricereverts withInvalidPrice(#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_datavalues 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_auctionagainst 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. -
L-03 Low Admin proposal TTL can be extended Access Control Resolved
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_adminfunction 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 usingExtendFootprintTTLOp, 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_admininstead of relying on the storage entry's TTL. -
L-04 Low Withdrawing with i128::MAX reverts after a default Business logic errors Resolved
Description
plan_withdrawaccepts 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_upcomputesceil(amount * 1e12 / b_rate). When the product overflows i128,fixed_mul_ceilretries in I256 and traps if the result does not fit back into i128. Withamount = i128::MAX, the result only fits whileb_rate >= 1e12.b_ratestarts at1e12and grows with interest, but every default lowers it throughdefault_b_rate. Once total defaults exceed the interest accrued so far,b_ratedrops below1e12. From then on, everyWithdraworWithdrawCollateralrequest fori128::MAXin that reserve reverts, until interest bringsb_rateback above1e12.Users, vaults and scripts that close a position by passing
i128::MAXcannot 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.
-
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_pricerejects 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'slastpricerounds 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_secsahead 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, andrefresh, 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 withInvalidHf. Both revert withInvalidPriceif 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
lastpricewould also restore the pool's check, but on its own it blocks pricing for that asset until ledger time catches up. -
I-01 Informational Stale ABI documentation advertises unsupported bad-debt and interest auction creation Documentation Resolved
Description
The fork intentionally rejects auction types 1 and 2 before their constructors, as required by ADR-0008, but the public
new_auctionABI 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
Templar Team: Resolved in https://github.com/Templar-Protocol/blend-contracts-v2/pull/13
-
I-02 Informational Positive balance changes remain unclaimable Documentation Resolved
Description
The scoped diff replaces surplus accounting in
execute_gulpwith 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
Templar Team: Resolved in https://github.com/Templar-Protocol/blend-contracts-v2/pull/13
-
I-03 Informational Freeze docs imply the pool can be reopened Documentation Resolved
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
-
I-04 Informational Bad debt docs misstate loss allocation Documentation Resolved
Description
The public documentation for
bad_debtstill 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
-
I-05 Informational Submit docs omit auction fill restrictions Documentation Resolved
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
-
I-06 Informational Activation accepts an incompatible oracle Configuration Resolved
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.
-
I-07 Informational Factory accepts undeployable take rates Validation Resolved
Description
The factory accepts any
backstop_take_ratebelow 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
1throughSCALAR_7 - 1pass 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_rateto equal zero in the factory and updating its interface documentation to reflect the pool's restriction. -
I-08 Informational
plan_repaycomment says the refund can be negative Documentation ResolvedDescription
The doc comment on
plan_repaysays 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, soamount * 1e12 >= (cur_d_tokens + 1) * d_rate. d_ratestarts at1e12and only grows, soamount >= cur_d_tokens * d_rate / 1e12 + 1.- The amount charged is
ceil(cur_d_tokens * d_rate / 1e12), which is less thancur_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_nonnegativecheck can stay. - The branch runs when
-
I-09 Informational
del_auctiondocs say only unfilled auctions become stale Documentation ResolvedDescription
The
del_auctiondocs 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_auctionkeeps the originalblockon the remaining auction, anddelete_stale_auctiononly checksauction.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.
-
I-10 Informational Stale comment in the pool factory Documentation Resolved
Description
deployhas two copies of the comment "verify max positions is at least 2 and less than 64". The first, above themax_positionscheck, was updated to say pools have at most 30 reserves and 60 positions. The second, above themin_collateralcheck, 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_RESERVESis 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_collateralcheck.
No findings match.
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.
