Reports

Smart Contract Security Assessment

Fira - Lending Market

Fira is a fixed-rate lending protocol combining time-tranching yield markets with lending infrastructure. The system allows users to borrow against collateral at fixed rates determined by Fira markets.

5
Issues
0
C/H/M
Period
Mar 09, 2026 - Apr 03, 2026
Auditors
Watermelon, Panda

Review Summary

Protocol Overview

Fira is a fixed-rate lending protocol combining time-tranching yield markets with lending infrastructure. The system allows users to borrow against collateral at fixed rates determined by Fira markets.

Protocol
Fira
Timeline
Mar 09, 2026 - Apr 03, 2026
Audit Team
Watermelon, Panda

Scope

This audit covers the lending market smart contracts totaling approximately 4150 lines of code across 20 days of review.

Overall Assessment

The lending market is a well-designed and implemented protocol. The code is clear and easy to understand. The code has been forked from existing codebases with minimal changes, the code that has been added is well-designed.

Evaluation Matrix

access control
Good

Access control is explicit and conventional, with ownership and pauser roles clearly separated across the router, FW wrapper, and factories. No access-control finding was identified, though the router owner's ability to remap selectors remains a significant privileged capability.

mathematics
Good

The protocol contains non-trivial pricing, expiry, oracle, and share-accounting logic, but the review did not uncover material mathematical flaws. Identified issues were limited to small consistency and implementation details rather than core accounting correctness.

complexity
Good

The system spans several interacting modules, including lending, AMM trading, yield tokenization, oracles, rehypothecation, and a facet-based router. Even so, the contracts are modular and readable, and the low-severity issue set suggests the implementation complexity is being managed competently.

libraries
Excellent

The codebase relies primarily on established patterns, standard token primitives, and forked components from mature codebases. No material weakness related to external library selection or integration surfaced during the review.

decentralization
Average

The protocol retains meaningful trusted roles over router upgrades, fee parameters, pausing, and rehypothecation configuration. No governance bug was identified, but users still depend on responsible operator behavior and key management.

code stability
Excellent

Only low-severity, informational, and gas findings were identified, which points to a relatively stable codebase. Remaining issues were mostly cleanup items, minor invariant mismatches, or UX-level inconsistencies rather than signs of deeper instability.

documentation
Excellent

Project documentation and inline comments were sufficient to review the system, and the code is generally easy to follow.

monitoring
Excellent

The protocol emits events across user actions, market operations, configuration changes, and pause state transitions.

testing
Good

Coverage appears strong, with unit, integration, fuzz, PoC, and invariant tests across major modules.

Key Findings

Findings Summary

0
Critical
0
High
0
Medium
1
Low
3
Informational
1
Gas
L-1 Finding

L-1: Redundant return value check in `LiquidityInjector.injectLiquidity`

Low

Description:

In LiquidityInjector.injectLiquidity, the return value of lendingMarket.supply is checked to ensure assetsSupplied == amountToMintAndInject:

(uint256 assetsSupplied,) = lendingMarket.supply(params, amountToMintAndInject, 0, address(this), "");
require(assetsSupplied == amountToMintAndInject, "LI: partial inject");

This check is unnecessary. When LendingMarket.supply is called with a non-zero assets argument and shares == 0, the function computes shares from the given assets but never modifies the assets value itself. The returned assets is always equal to the input assets parameter:

if (assets > 0) {
    shares = assets.toSharesDown(market[id].totalSupplyAssets, market[id].totalSupplyShares);
} else {
    assets = shares.toAssetsUp(market[id].totalSupplyAssets, market[id].totalSupplyShares);
}
// ...
return (assets, shares);

Since injectLiquidity always passes amountToMintAndInject as the assets argument and 0 as shares, the assets > 0 branch is taken and assets is returned unchanged. The require statement can therefore never fail.

A similar pattern exists in LiquidityInjector.withdrawLiquidity, where the return value of lendingMarket.withdraw is checked in the same manner. The same reasoning applies: when LendingMarket.withdraw is called with a non-zero assets argument, the returned value is identical to the input.

Impact:

Low. The check wastes gas but has no functional consequence.

Recommendation:

Remove the redundant checks in both injectLiquidity and withdrawLiquidity:

- (uint256 assetsSupplied,) = lendingMarket.supply(params, amountToMintAndInject, 0, address(this), "");
- require(assetsSupplied == amountToMintAndInject, "LI: partial inject");
+ lendingMarket.supply(params, amountToMintAndInject, 0, address(this), "");
- (uint256 assetsWithdrawn,) =
-     lendingMarket.withdraw(params, amountToWithdrawAndBurn, 0, address(this), address(this));
- require(assetsWithdrawn == amountToWithdrawAndBurn, "LI: partial withdraw");
+ (uint256 assetsWithdrawn,) =
+     lendingMarket.withdraw(params, amountToWithdrawAndBurn, 0, address(this), address(this));

Note that in withdrawLiquidity, the assetsWithdrawn variable is still needed for the subsequent burnByLI call, so it should be retained but the require can be removed.

Developer Response:

Acknowledged.

I-1 Finding

I-1: FWBase previews ignore deposit/redemption pause state

Informational

Description:

FWBase.previewDeposit() and FWBase.previewRedeem() ignore depositsPaused and redemptionsPaused. Unlike deposit() and redeem(), they still return quotes while the corresponding action is paused.

Impact:

Informational. This is a low-severity UX/integration issue. Frontends or off-chain systems may show valid previews for actions that will revert when executed.

Recommendation:

Make previews respect the same pause checks as execution, or clearly document that preview functions do not reflect pause state.

Developer Response:

Acknowledged.

I-2 Finding

I-2: `CouponToken.notExpired` modifier is defined but never used

Informational

Description:

The CouponToken contract defines a notExpired modifier that reverts with Errors.YCExpired() when the contract has passed its expiry timestamp:

However, this modifier is not applied to any function in the CouponToken contract. By contrast, the analogous notExpired modifier in FiraMarket is actively used to guard swapExactBTForFw, swapFwForExactBT, and addLiquidity.

Impact:

Informational. The unused modifier represents dead code.

Recommendation:

Remove the unused notExpired modifier to reduce code size and avoid confusion.

Developer Response:

Acknowledged, not worth redeploying for now.

I-3 Finding

I-3: `ActionBorrow` does not enforce `limitOrderData` to be empty despite limit orders being disabled

Informational

Description:

The supplyAndBorrowSingleToken and borrowSingleToken functions accept a LimitOrderData calldata limitOrderData parameter that is documented as disabled in the current version:

"LimitOrderData is kept for interface compatibility but orderbook is not used in this version."

However, neither function validates that the provided limitOrderData is actually empty. If a caller supplies non-empty limitOrderData, the code will route the borrowed BT through _fillLimit, which calls fill() on the user-supplied limitRouter address and grants it token approvals via _safeApproveInf.

This contradicts the documented invariant that limit orders are disabled and creates a discrepancy between intended and actual behavior.

Impact:

Informational.

Recommendation:

Add a guard at the top of both functions to enforce the documented invariant, reusing the existing _isEmptyLimit helper:

 function supplyAndBorrowSingleToken(
     MarketParams memory marketParams,
     ILendingMarket lendingMarket,
     IPMarketV3 firaMarket,
     uint256 collateralAmount,
     uint256 tokensToBorrow,
     TokenOutput calldata output,
     LimitOrderData calldata limitOrderData,
     address receiver
 ) external returns (uint256 btBorrowed, uint256 tokenBorrowed) {
+    require(_isEmptyLimit(limitOrderData), "AB: limit orders disabled");
     require(collateralAmount > 0, "AB: zero collateral");

Apply the same guard to borrowSingleToken.

Developer Response:

Acknowledged, we have no order book so shall be good.

G-1 Finding

G-1: Unnecessary intermediate transfer in `ActionBorrow` borrow functions

Gas

Description:

Both ActionBorrow.supplyAndBorrowSingleToken and ActionBorrow.borrowSingleToken redeem FW tokens to the router contract itself via _redeemFwToToken, then perform a separate _transferOut to send the output tokens to the receiver:

tokenBorrowed = _redeemFwToToken(address(this), address(FW), fwOut, output, false);
_transferOut(output.tokenOut, receiver, tokenBorrowed);

The _redeemFwToToken function in ActionBase already supports sending tokens directly to an arbitrary receiver. When SwapType.NONE is used, it calls __redeemFw(receiver, ...) which redeems directly to the specified address. When SwapType.ETH_WETH or an aggregator swap is used, it performs the conversion and then calls _transferOut(out.tokenOut, receiver, netTokenOut) internally.

By passing receiver directly instead of address(this), the final _transferOut call in both borrow functions becomes unnecessary. This eliminates one external ERC20 transfer call per borrow operation.

Impact:

Gas optimization.

Recommendation:

Pass receiver directly to _redeemFwToToken and remove the subsequent _transferOut call in both functions:

-   tokenBorrowed = _redeemFwToToken(address(this), address(FW), fwOut, output, false);
-   // 5. Transfer the borrowed token to receiver
-   _transferOut(output.tokenOut, receiver, tokenBorrowed);
+   tokenBorrowed = _redeemFwToToken(receiver, address(FW), fwOut, output, false);

Developer Response:

Acknowledged.

Methodology

Severity Classification

Critical

Immediate threat to user funds or protocol integrity

Direct loss of funds, protocol compromise

High

Significant security risk requiring urgent attention

Potential fund loss, major functionality disruption

Medium

Important issue that should be addressed

Limited fund risk, functionality concerns

Low

Minor issue with minimal impact

Best practice violations, minor inefficiencies

Gas

Findings that improve gas efficiency

Increased transaction costs

Informational

Code quality and best practice recommendations

Reduced maintainability and readability