Lombard Finance — Smart Contract Security Audit Report
Date: March 10, 2026 Auditor: sc-mega-auditor (Claude Opus 4.6) Methodology: Map-Hunt-Attack (5-phase systematic audit) Mode: Manual-only (no Slither/Aderyn available)Executive Summary
This report presents the findings of a comprehensive smart contract security audit of the Lombard Finance protocol, a Bitcoin liquid staking system with cross-chain bridging capabilities. The audit covered 59 custom Solidity contracts (~13,933 LOC) across three deployment modules: LombardConsortium_6792, LombardTimeLock_055e, and StakedLBTC_21f3.
The audit identified 19 confirmed findings after rigorous Devil's Advocate falsification:
| Severity | Count |
|---|---|
| HIGH | 5 |
| MEDIUM | 7 |
| LOW | 7 |
| Total | 19 |
Key risk areas include: fee approval signature reuse across deposits, missing maximum validator set size caps, BasculeV3 proof context binding, PMM accounting gaps, and oracle configuration guardrails.
Scope
Files Audited
| Module | Contract | LOC (approx) |
|---|---|---|
| LBTC Core | BaseLBTC, NativeLBTC, StakedLBTC, StakedLBTCOracle, AssetRouter, BridgeTokenAdapter | ~3,200 |
| Libraries | Assert, Assets, Redeem, Validation, Actions, BitcoinUtils, EIP1271SignatureUtils, FeeUtils, LChainId, RateLimits | ~1,100 |
| Bridge | Bridge (V1), BridgeV2, AbstractAdapter, CLAdapter, TokenPool (LombardTokenPool) | ~1,800 |
| Bridge OFT | EfficientRateLimiter, EfficientRateLimitedOFTAdapter, LBTCBurnMintOFTAdapter, LBTCOFTAdapter | ~400 |
| Bridge Providers | BridgeTokenPool, LombardTokenPoolV2 | ~350 |
| Consortium | Consortium, LombardConsortium, DepositNotarizationBlacklist | ~600 |
| Bascule | Bascule (V1), BasculeV2, BasculeV3 | ~800 |
| Timelock | LombardTimeLock (×2) | ~50 |
| GMP | Mailbox, GMPUtils, MessagePath | ~1,200 |
| StakeAndBake | StakeAndBake, StakeAndBakeNativeToken, ERC4626Depositor, TellerWithMultiAssetSupportDepositor | ~700 |
| PMM | BTCB (BTCBPMM), CBBTC (CBBTCPMM) | ~400 |
| PoR | PoR | ~350 |
| IBC | IBCVoucher | ~400 |
| Other | ProxyFactory, PartnerVault | ~350 |
Exclusions
@openzeppelin/)@chainlink/)@layerzerolabs/)solidity-bytes-utils/, solmate/TransparentUpgradeableProxy_* modulesFindings
Critical & High Findings
H-1: FeeApproval Signature Not Bound to Specific mintPayload — Reuse Across Multiple Deposits
Severity: HIGH Confidence: Confirmed Category: Signature Replay Affected Files:BaseLBTC.sol, Actions.sol, NativeLBTC.sol, AssetRouter.sol
Description:
The getFeeDigest() function in BaseLBTC.sol:33-48 computes the EIP-712 digest for fee approvals as:
keccak256(abi.encode(
Actions.FEE_APPROVAL_EIP712_ACTION,
block.chainid,
fee,
expiry
))
The type hash FEE_APPROVAL_EIP712_ACTION is defined in Actions.sol:106 as keccak256("feeApproval(uint256 chainId,uint256 fee,uint256 expiry)"). Critically, the digest does not include the mintPayload hash (the specific Bitcoin deposit being claimed). This means a single fee approval signature is valid for all deposits from the same user on the same chain with matching fee and expiry parameters.
The _mintWithFee() flow in both NativeLBTC.sol:435-476 and AssetRouter.sol:705-751 verifies the fee signature via Assert.feeApproval(digest, recipient, userSignature) but does not bind it to the specific deposit payload being processed.
sig = sign(chainId=1, fee=10000, expiry=T+1day).mintV1WithFee(payloadA, proofA, feePayload, sig) — Alice pays 10,000 sat fee on Deposit-A. Expected.mintV1WithFee(payloadB, proofB, feePayload, sig) — same signature passes because the digest is identical. Alice pays 10,000 sat fee on Deposit-B without additional consent.expiry.sha256(mintPayload) in the FEE_APPROVAL_EIP712_ACTION type hash:
keccak256("feeApproval(uint256 chainId,bytes32 payloadHash,uint256 fee,uint256 expiry)")
This binds each fee approval to exactly one deposit payload.
H-2: BasculeV3 Trusted Signer Proof Lacks Chain and Contract Binding — Cross-Chain Replay
Severity: HIGH Confidence: Confirmed Category: Signature Replay Affected Files:BasculeV3.sol
Description:
BasculeV3._checkProof() at line 379 verifies deposit proofs by calling ECDSA.tryRecover(data, sig) where data is the raw depositID (bytes32). The signed message contains only the deposit ID — no chainId, no address(this), no EIP-712 domain separator.
If multiple BasculeV3 instances exist across chains sharing the same trustedSigner, a proof signed for a deposit on Chain A is valid on Chain B. The contract provides no structural guarantee against cross-deployment replay.
trustedSigner (misconfiguration), an attacker submits (depositID, proof) to Chain B's reportDeposits.validateWithdrawal on Chain B passes Bascule validation, enabling minting of unbacked LBTC.trustedSigner is shared across deployments.
Remediation: Include address(this) and block.chainid in the signed data:
bytes32 signedData = keccak256(abi.encode(address(this), block.chainid, depositID));
(address signer, , ) = ECDSA.tryRecover(signedData, sig);
H-3: Consortium Validator Set Has No Maximum Size — Gas-Based Protocol DoS
Severity: HIGH Confidence: Confirmed Category: DoS Affected Files:Consortium.sol
Description:
Consortium._setValidatorSet() (lines 140–158) stores the validator array with no upper-bound check on _validators.length. The _checkProof() function (lines 184–238) iterates over ALL validators with up to 2 ECDSA.tryRecover calls per validator (expensive ecrecover precompile at ~3000 gas each).
With a sufficiently large validator set, checkProof gas cost exceeds the block gas limit, permanently preventing:
mintV1, batchMintV1)deliverAndHandle)publishNewRatio)setNextValidatorSet)setNextValidatorSet payload with 500+ validator addresses.setNextValidatorSet() is called — stores a 500-entry validator set.checkProof call iterates 500 times with up to 1000 ecrecover operations._setValidatorSet():
uint256 constant MAX_VALIDATORS = 100;
require(_validators.length <= MAX_VALIDATORS, "TooManyValidators");
Also consider early-exit in _checkProof once weightSum >= weightThreshold.
H-4: PMM totalStake Never Decrements — Permanent Swap Function DoS
Severity: HIGH
Confidence: Confirmed
Category: State Invariant / DoS
Affected Files: BTCB.sol, CBBTC.sol
Description:
In both PMM contracts, swapBTCBToLBTC / swapCBBTCToLBTC increment $.totalStake += amountLBTC on every swap (BTCB.sol:87, CBBTC.sol:116). However, no function in either contract ever decrements totalStake:
withdrawBTCB() / withdrawCBBTC() — transfer tokens out, no totalStake writewithdrawLBTC() — transfer tokens out, no totalStake writesetStakeLimit() — updates stakeLimit, not totalStakeOnce totalStake >= stakeLimit, all future swaps revert with StakeLimitExceeded. The PMM is permanently bricked until admin raises stakeLimit.
swapBTCBToLBTC repeatedly, accumulating totalStake toward stakeLimit.totalStake reaches stakeLimit; all subsequent swap calls revert.stakeLimit; attacker immediately fills it again.totalStake is unchanged — capacity is permanently consumed.totalStake in withdrawLBTC() by the amount withdrawn, orcurrentOutstanding (minted - burned) instead of cumulative totalStake, orH-5: CEI Violation in AssetRouter._redeem/_deposit — Cross-Chain Message Before Token Burn
Severity: HIGH (structural) Confidence: Confirmed Category: Reentrancy / Design Affected Files:AssetRouter.sol
Description:
In AssetRouter._redeem() (lines 553–618), the execution order is:
Line 608: $.mailbox.send(...) // Dispatches cross-chain message
Line 614: tokenContract.mint(treasury, fee) // Mints fee to treasury
Line 617: tokenContract.burn(fromAddress, amount + fee) // Burns user tokens
In AssetRouter._deposit() (lines 396–430):
Line 423: $.mailbox.send(...) // Dispatches cross-chain message
Line 429: IBaseLBTC($.nativeToken).burn(fromAddress, amount) // Burns user tokens
Both violate Checks-Effects-Interactions: the cross-chain message is dispatched before the on-chain token burn. While Mailbox.send() only emits an event (no immediate cross-chain execution), the MessageSent event persists in logs even if the subsequent burn() reverts (events from reverted internal calls are still included in the outer transaction's receipt only if the outer transaction succeeds — but the event emission before state settlement creates misleading off-chain signals).
The nonReentrant guard on the outer redeem() / deposit() functions prevents reentrancy exploitation in the current implementation, but the structural violation creates fragility for future modifications.
nonReentrant and mailbox being event-only. However, the ordering is a latent risk: if burn() fails (e.g., pausing, future bugs), the GMP event misleads off-chain relayers. Structural fragility for upgrades.
Remediation: Reorder: burn tokens first, then send the cross-chain message:
// _redeem:
tokenContract.burn(fromAddress, amount + fee); // Effects first
if (fee > 0) tokenContract.mint(treasury, fee);
$.mailbox.send(...); // Interactions last
// _deposit:
IBaseLBTC($.nativeToken).burn(fromAddress, amount); // Effects first
$.mailbox.send(...); // Interactions last
Medium Findings
M-1: Oracle maxAheadInterval Has No Upper Bound — Oracle Freeze Vector
Severity: MEDIUM
Confidence: Confirmed
Category: Oracle
Affected Files: StakedLBTCOracle.sol
Description:
changeMaxAheadInterval() (line 108, onlyOwner) calls _changeMaxAheadInterval() (lines 245–248) which accepts any uint256 with no upper bound. A compromised owner can set maxAheadInterval = type(uint256).max, then a consortium-signed ratio update with switchTime = block.timestamp + type(uint256).max would pass _validateRatio() and set $.switchTime to an astronomically far-future value. The oracle would then serve prevRatio forever since block.timestamp >= $.switchTime is never true.
Impact: Oracle permanently frozen at a stale ratio, affecting all external consumers of getRate().
Remediation: Add a hard cap: require(newVal <= MAX_AHEAD_INTERVAL_CAP) where MAX_AHEAD_INTERVAL_CAP is a constant (e.g., 7 days).
M-2: No Oracle Freshness/Staleness Check in AssetRouter
Severity: MEDIUM Confidence: Confirmed Category: Oracle Affected Files:AssetRouter.sol, StakedLBTCOracle.sol
Description:
AssetRouter.ratio() (lines 254–263) and getRate() (lines 265–274) call the oracle with no staleness check — no updatedAt timestamp comparison, no maximum age threshold, no heartbeat guard. If the consortium stops publishing updates, a stale ratio is served indefinitely.
Impact: Stale oracle allows external DeFi integrations to operate at incorrect exchange rates for arbitrarily long periods.
Remediation: Add a lastUpdatedAt timestamp to the oracle, expose it, and have AssetRouter enforce block.timestamp - oracle.lastUpdatedAt() < MAX_ORACLE_AGE.
M-3: BridgeTokenAdapter.spendDeposit() Has No Access Control — Deposit Proof Griefing
Severity: MEDIUM
Confidence: Confirmed
Category: Access Control
Affected Files: BridgeTokenAdapter.sol
Description:
spendDeposit() (lines 302–325) is declared external with no access control modifier. Any address can call it with a valid consortium-signed deposit payload (where recipient == address(this)) and permanently consume the proof without minting tokens. The function marks usedPayloads[sha256(payload)] = true, preventing any subsequent mintV1 from processing that deposit.
The comment on line 298 says /// TODO: remove after used, confirming this is a temporary migration function that should have been removed or access-controlled.
onlyRole(DEFAULT_ADMIN_ROLE) or remove the function entirely post-migration.
M-4: PMM Zero-Output Swap Due to Fee Ceiling Rounding on Dust Amounts
Severity: MEDIUM Confidence: Confirmed Category: Arithmetic Affected Files:BTCB.sol, CBBTC.sol, FeeUtils.sol
Description:
FeeUtils.getRelativeFee() (line 25) uses ceiling rounding via Math.mulDiv(amount, relativeComs, MAX_COMMISSION, Math.Rounding.Ceil). For small amountLBTC values (e.g., 1 satoshi) with any non-zero relativeFee, the fee rounds up to at least 1, equaling 100% of the output. The swap functions then execute lbtc.mint(user, amountLBTC - fee) = mint(user, 0). The user pays BTCB/CBBTC and receives zero LBTC, with the full amount going as fee.
The ZeroAmount check (BTCB.sol:79) only guards amountLBTC == 0, not amountLBTC - fee == 0.
Additionally, FeeUtils.validateCommission() allows fees up to 9999 (99.99%), meaning an admin can set near-100% fee that silently extracts almost all value from users.
require(amountLBTC > fee, "fee exceeds output") before minting. Cap maximum relativeFee well below MAX_COMMISSION (e.g., max 5% = 500 bps).
M-5: Oracle prevRatio Active During Full switchTime Interval — Front-Running Window for External Consumers
Severity: MEDIUM
Confidence: Confirmed
Category: Oracle
Affected Files: StakedLBTCOracle.sol
Description:
After publishNewRatio() is called, _ratio() (lines 221–227) returns prevRatio until block.timestamp >= switchTime. The upcoming currRatio is publicly readable via nextRatio(). This creates a deterministic, publicly visible arbitrage window for external DeFi integrations that consume the oracle's getRate().
AssetRouter._deposit() and _redeem() functions do NOT use the oracle for pricing — they operate at face value. The impact is limited to external consumers (lending protocols, DEX price feeds) that read ratio() or getRate().
Impact: Informed actors can time deposits/withdrawals around the switch boundary in external protocols, extracting risk-free yield.
Remediation: Minimize the switchTime window, or implement linear interpolation between prevRatio and currRatio during the transition period.
M-6: StakedLBTC Pauser Not Set at Initialization — Emergency Pause Unavailable Post-Deploy
Severity: MEDIUM
Confidence: Confirmed
Category: Initialization
Affected Files: StakedLBTC.sol
Description:
StakedLBTC.initialize() (lines 92–112) does not call _changePauser(). The pauser field defaults to address(0). The pause() function (line 162) checks if (pauser() != _msgSender()) revert UnauthorizedAccount — with pauser == address(0), pause() always reverts. The contract cannot be emergency-paused until the owner calls changePauser().
In contrast, NativeLBTC uses AccessControl with PAUSER_ROLE set during initialization.
changePauser() is called. Any exploit during this window proceeds unimpeded.
Remediation: Add a pauser_ parameter to initialize() and call _changePauser(pauser_) during initialization.
M-7: AssetRouter.deposit() Allows Token Contract to Specify Arbitrary fromAddress for Burning
Severity: MEDIUM
Confidence: Confirmed (conditional)
Category: Access Control
Affected Files: AssetRouter.sol
Description:
AssetRouter.deposit() (lines 368–384) checks:
if (sender != fromAddress && sender != toToken) {
revert AssetRouter_Unauthorized();
}
The sender == toToken branch allows a token contract (which has CALLER_ROLE via setRoute()) to call deposit() with an arbitrary fromAddress. The subsequent _deposit() (line 429) calls IBaseLBTC($.nativeToken).burn(fromAddress, amount), burning tokens from the specified address if AssetRouter holds MINTER_ROLE.
The same pattern exists in _redeem() (line 565) where sender == fromToken passes the check.
sender == toToken / sender == fromToken authorization branch, or require that fromAddress has explicitly approved the operation.
Low Findings
L-1: IBCVoucher.leftoverAmount() Division-by-Zero When window == 0
Severity: LOW
Confidence: Confirmed
Category: Arithmetic
Affected Files: IBCVoucher.sol
Description: leftoverAmount() (line 339) computes (block.timestamp - $.rateLimit.startTime) / $.rateLimit.window. If window == 0 (default initial state), this panics with division-by-zero. The _wrap() and _spend() functions properly guard with if ($.rateLimit.window != 0), but leftoverAmount() has no such guard.
Remediation: Add if ($.rateLimit.window == 0) return type(uint64).max; at the start.
L-2: PoR.initialize() Missing _disableInitializers() Constructor
Severity: LOW
Confidence: Confirmed
Category: Initialization
Affected Files: PoR.sol
Description: PoR.sol has no constructor calling _disableInitializers(), unlike every other upgradeable contract in the codebase. The implementation contract can be initialized by an attacker.
Remediation: Add constructor() { _disableInitializers(); }.
L-3: StakedLBTC EIP-712 Domain Name Is a TODO Placeholder
Severity: LOW
Confidence: Confirmed
Category: Initialization
Affected Files: StakedLBTC.sol
Description: Lines 102 and 107–109 contain // TODO: set new name annotations. The EIP-712 domain separator is computed from "Lombard Staked Bitcoin" — if renamed post-deployment via changeNameAndSymbol(), outstanding ERC20Permit signatures become invalid.
Remediation: Finalize the token name before deployment.
L-4: getRate() Uses Ceiling Rounding — Systematic Rate Overpricing
Severity: LOW
Confidence: Confirmed
Category: Arithmetic
Affected Files: StakedLBTCOracle.sol
Description: getRate() (line 155) returns Math.mulDiv(1 ether, 1 ether, _ratio(), Math.Rounding.Ceil). Ceiling rounding means the returned rate is always >= the true rate, systematically overpricing StakedLBTC for external consumers. Over many operations, this creates a small but persistent drain on protocol backing.
Remediation: Review whether Floor rounding is more appropriate for user-facing rate calculations.
L-5: CLAdapter Deprecated Contract Uses Shared Mutable State — Race Condition
Severity: LOW
Confidence: Confirmed
Category: Reentrancy
Affected Files: CLAdapter.sol
Description: CLAdapter stores _lastBurnedAmount and _lastPayload (lines 43–44) as contract-level storage variables shared between deposit() and initiateDeposit(). Two concurrent bridge deposits can overwrite each other's state. The contract NatSpec explicitly states: @dev NOT TESTED AFTER CCIP UPGRADE.
Remediation: Remove or deprecate the contract. If kept, replace shared mutable state with per-tx mappings keyed by nonce.
L-6: Unsafe approve() Pattern in Bridge._deposit() and CLAdapter
Severity: LOW
Confidence: Confirmed
Category: Token Safety
Affected Files: Bridge.sol, CLAdapter.sol, PartnerVault.sol
Description: Multiple contracts use raw IERC20.approve() instead of SafeERC20.forceApprove():
Bridge.sol:552: approve(adapter, amountWithoutFee)CLAdapter.sol:136, 192: approve(bridge, amount), approve(router, _amount)PartnerVault.sol:192: approve(lockedFbtc, amount)This is subject to the classic ERC20 approve race condition if previous approvals are not fully consumed.
Remediation: Replace allapprove() calls with SafeERC20.forceApprove().
L-7: IBCVoucher Rate Limit Uses uint64 for Credit Accounting — Truncation at Scale
Severity: LOW
Confidence: Confirmed
Category: Arithmetic
Affected Files: IBCVoucher.sol
Description: The RateLimit struct uses uint64 for credit, limit, and supplyAtUpdate. In _wrap() (line 214): $.rateLimit.credit += uint64(amountAfterFee) and _spend() (line 258): if (uint64(amount) > $.rateLimit.credit) both cast uint256 to uint64. While amounts exceeding type(uint64).max (~184 billion BTC satoshis) are unreachable given Bitcoin's 21M supply, the structural type mismatch is a latent defect that would become exploitable if the token denomination changed or was used with a higher-supply asset.
Remediation: Use uint256 for all rate limit arithmetic, or add explicit bounds checks: require(amount <= type(uint64).max).
Invariants Verified
The following invariants were tested and confirmed to hold:
| Invariant | Status |
|---|---|
Payload replay uniqueness (usedPayloads monotonically set) | Holds — separate mappings in NativeLBTC, BridgeTokenAdapter, AssetRouter with different payload formats |
| Consortium proof required before every protocol mint | Holds — all mint paths gate on checkProof or MINTER_ROLE |
| Fee strictly less than minted amount | Holds — fee >= amount check in _mintWithFee |
| Mailbox nonce monotonicity | Holds — globalNonce only increments in send() |
| Deposit state transitions unidirectional (UNREPORTED→REPORTED→WITHDRAWN) | Holds — Bascule contracts enforce one-way transitions |
AssetRouter/NativeLBTC independent usedPayloads — no cross-contract double-mint | Holds — different payload structures and selectors prevent collision |
PartnerVault totalStake accounting | Holds — removeWithdrawalRequest correctly does NOT decrement because FBTC remains locked |
| Mailbox delivered payloads retryable | Holds — deliverAndHandle skips proof on retry, re-attempts handler |
| EfficientRateLimiter underflow protection | Holds — ternary guard prevents underflow regardless of unchecked block |
| RateLimits decay rounding direction (conservative) | Holds — rounds down, favoring safety |
False Positives Eliminated
The following suspected issues were investigated and determined to be false positives:
| Suspected Issue | Refutation |
|---|---|
Consortium checkProof cross-contract replay (for mint paths) | Payload content includes toChain (checked against block.chainid) and token (checked against address(this)) — implicit domain separation |
ratioThreshold unbounded cumulative drift | Every publishNewRatio requires valid consortium multisig proof — cannot be chained without multiple signatures |
PartnerVault totalStake desync via removeWithdrawalRequest | initializeBurn never decrements totalStake; FBTC remains locked after rejected redemption; accounting is correct |
Missing nonReentrant on redeemForBtc() | AssetRouter.redeemForBtc() carries nonReentrant at the router level; defense is correctly layered |
| Mailbox permanently stuck payloads | Second call to deliverAndHandle skips proof re-verification and retries handler |
| PMM flash loan composability | CEI followed (state updated before external call); fixed exchange rate means no oracle manipulation |
ERC4626Depositor minimumMint=0 | Slippage check present and enforced; user-controlled 0 is a self-choice |
LombardTimeLock admin=address(0) | Intentional OZ TimelockController hardened pattern — self-administration |
batchStakeAndBake unbounded array | Protected by onlyRole(CLAIMER_ROLE); trusted-party-only concern |
| Consortium signature malleability | OZ ECDSA.tryRecover rejects high-s values; mitigated |
Tools Used
| Tool | Version | Findings |
|---|---|---|
| Manual review | — | All 19 findings |
| Slither | Not available | — |
| Aderyn | Not available | — |
Disclaimer
This audit was performed using the sc-mega-auditor methodology combining systematic vulnerability hunting with adversarial reasoning. While every effort has been made to identify all vulnerabilities, no audit can guarantee the absence of bugs. This report should not be considered an endorsement of the protocol's security. The findings are based on the code as reviewed and may not reflect subsequent modifications.