Organization
- @Infrared-Finance
Engagement Type
Cantina Reviews
Period
-
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Medium Risk
4 findings
4 fixed
0 acknowledged
Low Risk
5 findings
3 fixed
2 acknowledged
Informational
15 findings
4 fixed
11 acknowledged
High Risk1 finding
Potential storage collision in new InfraredBERAWithdrawor contract
Severity
- Severity: High
Submitted by
r0bert
Description
When upgrading from
InfraredBERAWithdraworLitetoInfraredBERAWithdrawor, the first new implementation writes over slot 26 (minActivationBalance) but leaves slots 27 and 28 untouched. In the oldInfraredBERAWithdraworLitecontract those slots held thenonceRequest,nonceSubmitandnonceProcesscounters each initialized to 1. After you callinitializeV2, slot 26 will be correctly set to the new minimum activation balance, but slots 27 and 28 remain at 1 even though the new contract expects them to be part of its__gap(reserved) region.If a future upgrade ever re-uses those gap slots for real state variables, they will start out with the stale value 1 instead of 0, causing a storage collision and potentially breaking invariants, opening unexpected behavior or corrupting accounting.
Recommendation
Update the
InfraredBERAWithdrawor.initializeV2function to explicitly clear those two slots to zero. For example:function initializeV2(uint256 _minActivationBalance) external initializer { InfraredBERAWithdrawor__AccessControl_init(); minActivationBalance = _minActivationBalance;+ // clear legacy nonce values left behind in slots 27 & 28+ __gap[0] = 0;+ __gap[1] = 0; }This ensures all reserved slots start at zero and prevents any future collision or unintended state.
Infrared Finance
Fixed in 4a7d997ec805d8069c4f8b18b131616daf716bc3 by reseting the already initialized state variables to 0.
Cantina
Verified.
Medium Risk4 findings
Lack of staleness checks on nextBlockTimestamp used in every Beacon‑proof verification
Severity
- Severity: Medium
Submitted by
r0bert
Description
Every call to the
BeaconRootsVerifylibrary that performs a balance or withdrawal proof with:BeaconRootsVerify.verifyValidatorBalance( header, balanceMerkleWitness, validatorIndex, stake, balanceLeaf, nextBlockTimestamp );trusts the external caller, in this case the keeper, to choose
nextBlockTimestamp. Because the contract ultimately checks the supplied root against the EIP‑4788 ring buffer, any root whose timestamp is at mostHISTORY_BUFFER_LENGTH = 8191slots old passes the guard. On Berachain’s 2‑second slot time this window spans around 4.5 hours. Therefore, the keeper can always pick any root in that window.If the validator’s effective_balance drops inside that window, because it was forced to exit due a higher‑priority validator filling the cap, the proof built against the old header still passes
BeaconRootsVerify. The subsequent call to the withdrawal request precompile succeeds, but when the consensus layer later processes the request it silently discards it as invalid. The execution‑layer transaction has already completed, so Infrared’s internal accounting decrements stake and issues a withdrawal ticket. At this point, the accounting between the Execution Layer and the Consensus Layer is broken.Proof of concept
- IBera tokens are burnt and therefore multiple withdrawal requests are queued in the
InfraredBERAWithdraworcontract. - Keeper calls
InfraredBERAWithdrawor.executeto pull liquidity to be able to process those withdrawals and choose a timestamp that is 1 hour old to verify the chosen validator balance (BeaconRootsVerify.verifyValidatorBalance). Let's imagine that the amount withdrawn is 100k Bera and belongs to validator #12. (1 hour ago, the actual balance of the validator was 400, 400 - 100 = 300 > 250). - However just 1 minute before the
InfraredBERAWithdrawor.executecall, this validator #12 was forced to exit due to a higher‑priority validator joining the active pool and filling the cap. InfraredBERAWithdrawor.executecall is succesfull as the call to the precompile does not revert. However, that withdrawal request is ignored at the Consensus Layer as the validator is already exited. As theInfraredBERAWithdrawor.executecall did not revert, there is a valid withdrawal ticket that the user can unfairly claim. Moreover, theInfraredBERAV2contract already registered the decreased stake of the 100k Bera.
IInfraredBERAV2(InfraredBERA).register(validator.pubkey, -int256(amount));- Once the full exited Bera arrives to the
InfraredBERAWithdrawor, the keeper will callsweepForcedExitbut only a part of the exited funds will be sent to theInfraredBERADepositorV2, as the 100k Bera was already decreased in theInfraredBERAV2contract from the_staked[pubkeyHash]mapping.
Recommendation
Consider introducing an explicit freshness bound for all Beacon proof verifications. For example:
uint256 age = block.timestamp - nextBlockTimestamp;if (age > MAX_ROOT_AGE) revert Errors.StaleBeaconRoot(age);On the other hand, keepers should always choose the validators with the highest stake from the active pool when executing a withdrawal to avoid this scenario.
Infrared Finance
Fixed in bc92c8cbcd0508d42bc59d88861f9d64bcbd9efc by implementing the recommended solution. A timestamp older than 10 minutes will not be accepted.
Cantina
Verified.
msg.value is misaccounted as user reserves within InfraredBERAWithdrawor.execute
Severity
- Severity: Medium
Submitted by
r0bert
Description
InfraredBERAWithdrawor.executeis a payable function. The kepper must attach somemsg.value(a flat fee) that will later be forwarded to the withdrawal precompile. The function immediately measures the contract’s Bera balance via reserves()and uses it to assert the relationship between funds on hand and the queued ticket obligations:uint256 queuedAmount = getQueuedAmount();uint256 _reserves = reserves(); // includes msg.value sent by the keeper to pay the withdrawal precompile feeif (queuedAmount < _reserves) { revert Errors.ProcessReserves(); // reserve > queue → impossible by design} if (amount > (queuedAmount - _reserves + 1 gwei)) { revert Errors.InvalidAmount();}Because
_reservesalready includesmsg.value, the balance is inflated by the very fee that will be consumed moments later. As the excess fee will be always refunded to the keeper, this could be abused to withdraw from the validators an amount way higher than the needed to back all the pending withdrawal tickets.Recommendation
Exclude the fee from the reserve calculation:
uint256 _reserves = reserves() - msg.value;Infrared Finance
Fixed in e953f9b12791fe53e0b6bf27444ad6fe33724d23 by implementing the recommended solution.
Cantina
Verified.
State tracking vulnerability in withdrawal processing
Severity
- Severity: Medium
Submitted by
Cryptara
Description
The
InfraredBERAWithdraworcontract contains a design issue in theclaimandclaimBatchfunctions where the processing state validation relies on comparing the ticket receiver address against the current depositor address. This approach creates a potential vulnerability when the depositor address changes between the time a withdrawal request is queued and when it is processed.The current implementation uses a single
PROCESSEDstate for all finalized withdrawal requests, then determines whether a ticket should be claimable by checking if the receiver matches the current depositor address. However, if the depositor address is updated after tickets have been queued but before they are processed, tickets originally intended for the old depositor may become claimable by users, or tickets intended for users may become unrecoverable if the old depositor contract is no longer accessible.This issue is particularly problematic in upgradeable systems where the depositor contract address might change during protocol upgrades. The lack of explicit state tracking for the processing method means that the contract cannot distinguish between tickets that should be claimed by users versus those that should be handled by the depositor for rebalancing purposes.
The same vulnerability exists in the
claimBatchfunction, where the address comparison logic could lead to incorrect claim processing if the depositor address has changed since the tickets were originally created.Recommendation
Introduce two distinct processed states:
PROCESSED_CLAIM: for user claimsPROCESSED_DEPOSITOR: for depositor-driven rebalancing
Set this state deterministically during request processing and validate it during claims, eliminating reliance on address comparisons that can change over time.
Infrared Finance
Fixed in b0cf5ae2719cd343663a858cd06405ca692ba35a by introducing the
CLAIMEDstate whenticket.receiver == depositor, this effectively prevent callingclaimfor none inPROCESSEDstate requests. Thedepositorcheck is now removed and will only rely on the state.Cantina
Verified.
Full exit blocked by minimum activation balance check
Severity
- Severity: Medium
Submitted by
Cryptara
Description
The
InfraredBERAWithdrawor.solcontract contains a logical flaw in theexecutefunction where validators with stakes below the minimum activation balance cannot perform full exits. The current implementation applies the minimum activation balance check to all withdrawal amounts, including full exits indicated byamount == 0.When a validator's stake falls below the
minActivationBalancethreshold, any withdrawal attempt will revert withErrors.WithdrawMustLeaveMoreThanMinActivationBalance(), even for full exits. This creates a problematic scenario where validators with insufficient stakes cannot exit the system entirely, potentially leaving them in a state where they cannot recover their remaining funds.The issue is particularly concerning because full exits (indicated by
amount == 0) represent the complete withdrawal of a validator's stake, making the minimum activation balance requirement irrelevant. The check should not apply to full exits since the validator is exiting completely and will no longer need to maintain the minimum activation balance.This restriction could prevent validators from exiting when their stakes have been reduced below the minimum threshold due to slashing, penalties, or other consensus layer mechanisms, potentially trapping funds in the system.
Recommendation
Add logic to bypass the
minActivationBalancecheck whenamount == 0. This allows validators to exit entirely, regardless of stake size, while preserving the existing guardrails for partial withdrawals.Infrared Finance
Fixed in 3e3afab6f999cf0e98ba7050b770cefaf10c4291 by skipping the check when we do a full withdrawal.
Cantina
Verified.
Low Risk5 findings
Potential griefing and DoS vector in claim/claimBatch functions
State
Severity
- Severity: Low
Submitted by
r0bert
Description
InfraredBERAWithdraworexposes two public entry points for withdrawing processed tickets:function claim(uint256 requestId) external whenNotPaused { … }function claimBatch(uint256[] calldata requestIds) external whenNotPaused { … }Both functions perform no authentication check on the
msg.senderwith respect to the ownership of the ticket(s) identified byrequestIdorrequestIds. This means anyone can invoke, for example,claim(2)seconds before another user attempts a more gas‑efficientclaimBatch([1,2,3,4])call. Becauseclaimconsumes and deletes the ticket record, the subsequentclaimBatchreverts on ticket #2, forcing theclaimBatchtransaction to revert.Furthermore, if the designated receiver is a smart contract, its
receiveorfunctioncould deliberately revert, turning every batch claim into a denial-of-service against that user.Recommendation
Require that
msg.senderequals the ticket’s receiver unlessmsg.senderholds theKEEPER_ROLE. A minimal patch looks like:if (!hasRole(KEEPER_ROLE, msg.sender) && msg.sender != tickets[requestId].receiver) { revert Errors.UnauthorisedClaimer();}On the other hand, to mitigate the DoS risk when transferring ETH to a contract receiver, replace any raw transfer or
safeTransferETHcalls with Solady’s forceSafeTransferETH, which ensures delivery without executing the recipient’s fallback and avoids reverts.Infrared Finance
Acknowledged.
Cantina
Acknowledged.
Change in previewBurn return can brick downstream integrations
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
In
InfraredBERAV2thepreviewBurn()view function was refactored from:// V1function previewBurn(uint256 shares) external view returns (uint256 beraAmount, uint256 fee);to
// V2function previewBurn(uint256 shares) external view returns (uint256 beraAmount);The second return value (fee) was removed in the new V2 version. Contracts and off‑chain services that were compiled against the V1 interface will still attempt to decode two 32‑byte stack slots from the returndata. Because the new implementation only returns one value now, any previous integrator will revert when calling the
previewBurnfunction.Recommendation
Ensure that all the integrators are aware of this update and they are upgraded accordingly.
Infrared Finance
Acknowledged.
Cantina
Acknowledged.
sweepUnaccountedForFunds can drain Bera that is already earmarked for outstanding tickets
Severity
- Severity: Low
Submitted by
r0bert
Description
InfraredBERAWithdrawor.sweepUnaccountedForFundslets the governor transfer “excess” Bera to the protocol’s revenue receiver. The guard only verifies that the requested amount does not exceedreserves():if (amount > reserves()) { revert Errors.InvalidAmount();}reserves()returns the contract’s total Bera balance, which includes idle reserves that genuinely belong to governance and funds that have already been committed to users through withdrawal tickets still sitting in the queue (getQueuedAmount()). Nothing prevents the governor from sweeping an amount that is smaller thanreserves()yet larger thanreserves() − getQueuedAmount(), or which is the same, part of the Bera needed to honour pending withdrawal tickets.Recommendation
Consider treating queued tickets as liabilities and make them ineligible for sweeping. A straightforward fix is:
uint256 freeReserves = reserves() - getQueuedAmount();if (amount > freeReserves) { revert Errors.InvalidAmount();}Infrared Finance
Fixed in 9ffd45939b429f77f94a4888e42bdd6d7f26a399 by implementing the recommended solution.
Cantina
Verified.
Merkle tree incomplete root calculation
Severity
- Severity: Low
Submitted by
Cryptara
Description
The
MerkleTreelibrary contains a flaw in its root calculation logic when processing datasets with odd numbers of leaves. The current implementation in thepushfunction processes only the first ⌊count/2⌋ pairs during each level of tree construction, effectively dropping the last leaf when the total number of leaves is odd.This behavior creates a fundamental inconsistency where the calculated Merkle root corresponds to a different dataset than the one provided. The root represents a tree that is missing the final element, which violates the core principle that a Merkle root should uniquely represent the complete set of input data.
In standard Merkle tree implementations, when a level has an odd number of nodes, the last node is typically duplicated to maintain an even number of nodes at each level, ensuring the tree remains complete and the root accurately represents all input data. The current implementation fails to implement this standard practice, leading to incorrect root calculations.
While this issue may not be directly exploitable in the current codebase due to the specific use case of always passing arrays with length 8 (a power of 2), it represents a significant design flaw that could cause issues if the library is used with datasets of arbitrary sizes in the future.
Recommendation
Update the implementation to either:
- Revert when an odd number of leaves is provided (explicit error handling), or
- Duplicate the last node during tree construction to ensure complete levels.
The former is more robust for security-sensitive contexts, while the latter aligns with standard practices in many Merkle tree implementations.
Infrared Finance
Fixed in 5d645e8b21c2c95a2310807cf7da76605bb33c81 by reverting when length is odd.
Cantina
Verified.
Full exit bypass of withdrawal amount validation
Severity
- Severity: Low
Submitted by
Cryptara
Description
The
InfraredBERAWithdraworcontract contains a flaw in theexecutefunction where full exit withdrawals (indicated byamount == 0) can bypass the withdrawal amount validation logic. The validation check compares the withdrawal amount against the available queued amount minus reserves, but whenamountis zero, this check becomes ineffective.The problematic validation occurs in the
executefunction where the code checks if the withdrawal amount exceeds the available queued amount minus reserves plus a 1 gwei tolerance. However, whenamount == 0(indicating a full exit), the condition0 > (queuedAmount - _reserves + 1 gwei)will always be false, allowing the execution to proceed regardless of the actual stake amount being withdrawn.The issue creates a scenario where keepers could execute full exits even when the contract lacks sufficient reserves to cover the actual stake amount, potentially leading to liquidity issues or incorrect accounting within the withdrawal system.
Recommendation
Modify the validation logic to properly handle full exit scenarios by checking the actual stake amount instead of the zero amount parameter. The validation should compare the validator's total stake against the available queued amount minus reserves when
amount == 0, ensuring that full exits are subject to the same reserve requirements as partial withdrawals.Alternatively, implement a separate validation path for full exits that explicitly checks if the validator's stake amount can be accommodated within the available reserves, preventing the bypass of the withdrawal amount validation.
Infrared Finance
Fixed in 035bfb17ce7a3a02272ecf829169a96efa6fc0cc by using the full stake amount during the reserves check instead of amount being zero.
Cantina
Verified.
Informational15 findings
Flat share‑denominated burn fee can become ineffective as the exchange rate drifts
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
InfraredBERAV2imposes a burn charge that is hard‑coded as an absolute number of shares. Currently the fee is applied as:function burn(address receiver, uint256 shares) external returns (uint256 nonce, uint256 amount){ if (!withdrawalsEnabled) revert Errors.WithdrawalsNotEnabled(); // check min exit fee is met in ibera uint256 fee = burnFee; if (shares < fee) revert Errors.MinExitFeeNotMet(); uint256 netShares = shares - fee; // <-------------------- ...}Because the fee is denominated in shares, its economic weight is entirely governed by the protocol’s internal exchange rate (
1 share ≈ assets / totalShares). If the share price appreciates, the fixed fee can become too high, discouraging legitimate exits. Conversely, if the share price depreciates, the fee collapses to negligible value and no longer deters spam‑sizedburn()calls, re‑opening the very DoS vector the flat amount was meant to block.Recommendation
Consider monitoring the exchange rate and adjust the flat burn fee accordingly.
Infrared Finance
Acknowledged.
Cantina
Acknowledged.
Static minActivationDeposit can become stale
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
InfraredBERADepositorV2hard‑codes the minimum second‑stage stake that must be supplied inexecute():// InfraredBERADepositorV2.sol// needs to be enough to guarentee activation (250k) + inclusion in active set (depends on competition)minActivationDeposit = 500_000 ether;...if (stake == InfraredBERAConstants.INITIAL_DEPOSIT) { if (amount < minActivationDeposit) { revert Errors.DepositMustBeGreaterThanMinActivationBalance(); }}The
minActivationDepositvalue is calibrated off‑chain under the assumption that 500k Bera comfortably exceeds the lowest stake in the current active validator set. The active set, however, is dynamic: another participant (or even the validator with the lowest stake in the active set) can front‑run or simply outbid with a deposit of, say, 510k Bera in the same block. The depositor’sexecute()transaction will still succeed because the contract never re‑evaluates the required threshold on‑chain, but the resulting validator will fail to enter the active set. Operators would then have to send a third deposit transaction to top‑up the validator.Recommendation
Constantly monitor the lowest stake in the current active validator set with a housekeeping script and adjust the
minActivationDepositvalue accordingly.Infrared Finance
Acknowledged. Added as an operational check: https://github.com/infrared-dao/infrared-contracts/issues/607.
Cantina
Acknowledged.
Broken accounting between EL and CL if PENDING_PARTIAL_WITHDRAWALS_LIMIT is reached
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
When
PENDING_PARTIAL_WITHDRAWALS_LIMITis reached in the consensus layer, any new partial‐withdrawal request submitted via the withdraw precompile is silently ignored, yet the execution‐layer transaction still succeeds. InInfraredBERAWithdrawor.execute, immediately after calling the precompile, the contract invokes a call toIInfraredBERAV2(InfraredBERA).register(pubkey, -int256(amount))which decrements the recorded stake and enqueues a withdrawal ticket.Because the consensus layer never actually enqueues the withdrawal, no funds are ever released back to the
InfraredBERAWithdraworcontract. The contract’s internal state now believes that stake has been withdrawn, even though on‐chain (beacon chain) the validator’s balance remains intact. This would break the accounting between the Execution Layer and the Consensus Layer.Recommendation
Consider monitoring the consensus layer and ensure that the
PENDING_PARTIAL_WITHDRAWALS_LIMITwas not reached or is close to be reached before triggering a partial withdraw through aInfraredBERAWithdrawor.executecall.Infrared Finance
Acknowledged. Added as an operational check: https://github.com/infrared-dao/infrared-contracts/issues/607.
Cantina
Acknowledged.
previewBurn does not respect withdrawalsEnabled flag
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The
previewBurn(uint256 shareAmount)function inInfraredBERAV2computes how many assets would be returned for a given share amount, but it never checks whether withdrawals are currently enabled. Under EIP-4626, “preview” methods should mirror the conditions under which the corresponding action would succeed or revert. As written:function previewBurn(uint256 shareAmount) public view returns (uint256) { uint256 assets = convertToAssets(shareAmount); uint256 fee = previewFee(shareAmount); return assets > fee ? assets - fee : 0;}If
withdrawalsEnabledisfalse, an actual call toburnwould revert or disallow the operation, yetpreviewBurnwill still return a nonzero asset estimate. This mismatch can mislead integrators into believing aburnis possible when it will fail at execution time, leading to confusing user experiences or failed transactions.Recommendation
Align
previewBurnwith the contract’s withdrawal gating logic by checkingwithdrawalsEnabledat the top of the function. If withdrawals are disabled, it should revert or return zero. For example:function previewBurn(uint256 shareAmount) public view returns (uint256) {+ if (!withdrawalsEnabled) {+ return 0;+ } uint256 assets = convertToAssets(shareAmount); uint256 fee = previewFee(shareAmount); return assets > fee ? assets - fee : 0; }Infrared Finance
Acknowledged.
Cantina
Acknowledged.
Missing zero‐address check for receiver in InfraredBERAV2.burn function
Severity
- Severity: Informational
Submitted by
r0bert
Description
The
InfraredBERAV2.burnfunction does not guard againstreceiver == address(0). While currently a user would normally pass their own address, allowingaddress(0)opens a future problem: ifburn(address(0), ...)were ever used in conjunction with theInfraredBERAWithdrawor.claimBatchflow, claims intended for the zero address could effectively be “stolen” by any caller.Recommendation
Insert an explicit check at the start of burn to reject the zero address:
function burn(address receiver, uint256 shares) external returns (uint256) {+ if (receiver == address(0)) {+ revert Errors.InvalidReceiver();+ } ... }Infrared Finance
Fixed in 147fcd1d243e074153bd1d3b2bcd50ce3ea580a7 by implementing the recommended solution.
Cantina
Verified.
No cap on dynamic withdrawal‐request fee in execute
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
In the
InfraredBERAWithdraworcontract theexecutefunction is marked aspayableso that it can forward whatever Bera was sent as the dynamic fee to the EIP-7002 withdrawal precompile. However, there is no guard against an abnormally large withdrawal fee. Under heavy‐use or deliberate griefing, the fee formula in the precompile can spike exponentially.Recommendation
Introduce an explicit upper bound on the fee the contract will accept and forward. For example, define a sane maximum in the contract (e.g.
uint256 constant MAX_WITHDRAWAL_FEE = 1 ether;) and then in execute before calling the precompile:function execute(/*…*/) external payable onlyKeeper whenNotPaused {+ uint256 feePayable = getFee();+ require(feePayable <= MAX_WITHDRAWAL_FEE, Errors.FeeTooHigh()); // existing stake‐ and proof‐validation logic… WITHDRAW_PRECOMPILE.call{ value: feePayable }(/*…*/); // … }Infrared Finance
Acknowledged. Added as an operational check: https://github.com/infrared-dao/infrared-contracts/issues/607.
Cantina
Acknowledged.
Unused imports
Severity
- Severity: Informational
Submitted by
r0bert
Description
The
InfraredBERADepositorV2imports modules that aren’t referenced anywhere in the contract:import {IInfraredBERADepositor} from "src/interfaces/IInfraredBERADepositor.sol";import {SafeTransferLib} from "@solmate/utils/SafeTransferLib.sol";Recommendation
Remove both import statements.
Infrared Finance
Fixed in 9b49d9b9d66d9c7b116169c7ec6ef9facc8987ae by implementing the recommended solution.
Cantina
Verified.
Missing upper-bound validation on minActivationDeposit setter
Severity
- Severity: Informational
Submitted by
r0bert
Description
In
InfraredBERADepositorV2.setMinActivationDepositfunction, the governor can setminActivationDepositto any value:minActivationDeposit = _minActivationDeposit;However, elsewhere the contract enforces that a second deposit plus the existing stake must not exceed
MAX_EFFECTIVE_BALANCE(10.000.000 Bera):// The validator balance + amount must not surpass MaxEffectiveBalance of 10 million BERA.if (stake + amount > InfraredBERAConstants.MAX_EFFECTIVE_BALANCE) { revert Errors.ExceedsMaxEffectiveBalance();}If the governor sets
minActivationDeposithigher thanMAX_EFFECTIVE_BALANCE - INITIAL_DEPOSIT, then any call to execute for a fresh validator (withstake == INITIAL_DEPOSIT) will always revert.Recommendation
Add a require check in the
initializev2and in the setter function to ensure that_minActivationDepositcannot exceed the available headroom:- minActivationDeposit = _minActivationDeposit;+ require(+ _minActivationDeposit <= InfraredBERAConstants.MAX_EFFECTIVE_BALANCE+ - InfraredBERAConstants.INITIAL_DEPOSIT,+ "minActivationDeposit: exceeds max effective balance"+ );+ minActivationDeposit = _minActivationDeposit;Infrared Finance
Fixed in 3225b4780a0a81993a28a63ea1213ababe97d2dd by implementing the recommended solution.
Cantina
Verified.
Unnecessary request ID check in accumulated amount calculation
Severity
- Severity: Informational
Submitted by
Cryptara
Description
The
InfraredBERAWithdraworcontract contains an unnecessary conditional check in thequeuefunction when calculating the accumulated amount for withdrawal requests. The current implementation uses a ternary operator to handle the special case whenrequestId == 1, but this check is redundant due to the default behavior of Solidity's mapping access.When
requestIdis 1, accessingrequests[0].accumulatedAmountwill return the default value of 0 for theuint128type, since no request with ID 0 has been stored in the mapping. This means the calculationrequests[0].accumulatedAmount + amountwill correctly result in0 + amount = amount, which is exactly what the current conditional logic achieves.Recommendation
Remove the conditional check and simplify the accumulated amount calculation to directly use
requests[requestId - 1].accumulatedAmount + amount. This approach leverages Solidity's built-in behavior where accessing a non-existent mapping key returns the default value, eliminating the need for explicit boundary condition handling.Infrared Finance
The check was removed in 145bfb5511831e3f4543e54450021191cb14fa34.
Cantina
Verified.
Redundant state check in withdrawal processing
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Cryptara
Description
The
InfraredBERAWithdrawor.solcontract contains an unnecessary state validation check in theprocessfunction. The code verifies that each request is inQUEUEDstate before processing it, but this check appears redundant given the contract's request management system.The contract uses a sequential request ID system where
requestsFinalisedUntiltracks the highest processed request ID. All requests fromrequestsFinalisedUntil + 1torequestLengthshould logically be inQUEUEDstate, as theprocessfunction is the only mechanism that transitions requests fromQUEUEDtoPROCESSEDstate. TherequestsFinalisedUntilvariable ensures that requests are processed in order and prevents double-processing.The current implementation also performs a balance check using the total delta amount before processing individual requests. If the state check is necessary due to potential edge cases where requests might not be in
QUEUEDstate, then the balance validation should also account for only theQUEUEDrequests rather than the total delta between the finalised indices.The state check adds unnecessary gas overhead and complexity without providing clear functional benefits, as the request management system should maintain invariant states based on the
requestsFinalisedUntiltracking mechanism.Recommendation
Remove the redundant state check unless there is a specific justification for requests potentially being in non-
QUEUEDstates within the valid processing range. If the check is removed, the balance validation can remain as is since all requests in the processing range should be inQUEUEDstate.If the state check is necessary due to edge cases not apparent in the current codebase, consider adding documentation explaining why requests might not be in
QUEUEDstate and adjust the balance validation to only considerQUEUEDrequests when calculating the required reserves.Infrared Finance
Acknowledged.
Cantina
Acknowledged.
InfraredBERAWithdrawor.execute can unintentionally trigger an immediate forced exit
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
InfraredBERAWithdrawor.executepermits a keeper to withdraw any amount provided that the post‑withdrawal balancestays ≥ minActivationBalance(250k Bera):if (stake - amount < minActivationBalance) { revert Errors.WithdrawMustLeaveMoreThanMinActivationBalance();}Yet Berachain enforces a hard validator‑set cap of 69 entries. At the end of every epoch
processValidatorSetCapsorts the projected next‑epoch set byeffective_balanceand callsInitiateValidatorExiton the lowest‑stake validators until the cap is met, see state_processor_validators.go.If a partial withdrawal leaves a validator only slightly above
minActivationBalanceit may still be the smallest stake in the set. As soon as a new validator with≥ minActivationBalancetries to join, the sorter will place the freshly topped‑up entrant ahead of the depleted validator. Then the cap logic will force‑exit the latter in the next epoch even though it met the contract’sminActivationBalance.Recommendation
When calling the
InfraredBERAWithdrawor.executefunction, ensure that the validator is never left with the lowesteffective_balanceof the active set. Consider leaving that validator with a safety buffer so that it is not exited in the short term.Infrared Finance
Acknowledged. Added as an operational check: https://github.com/infrared-dao/infrared-contracts/issues/607.
Cantina
Acknowledged.
Inefficient withdrawal processing
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The current implementation of the
InfraredBERADepositorV2andInfraredBERAWithdraworcontracts does not allow rebalancing Bera from the depositor queue to the withdrawor. This restriction compels the system to handle withdrawal requests by executing multiple partial withdrawals from active validators, with each withdrawal incurring a withdrawal fee. This approach is both inefficient and expensive, particularly when sufficient funds are available in the depositor queue to directly fulfill withdrawal requests. For instance, imagine a situation where 10000 Bera are queued for withdrawal and the depositor queue holds 12000 Bera. Rather than transferring 10000 Bera directly from the depositor queue to the withdrawor, the system must for example process 20 separate partial withdrawals of 500 Bera each from active validators, each carrying its own fee. This unnecessarily increases costs and complicates the process.Implementing the ability to transfer Bera from the depositor queue to the withdrawor offers several advantages. First, it reduces fees by eliminating the need for multiple partial withdrawal transactions from active validators. Second, it improves efficiency by simplifying the withdrawal process, allowing requests to be satisfied directly from available queued funds. Third, it enhances validator stability by reducing the frequency and volume of withdrawals from active validators, which could otherwise lead to validator exits if large partial withdrawals are frequent.
Recommendation
To resolve this issue, the
InfraredBERADepositorV2contract should be updated to enable Bera transfers from the depositor queue to the withdrawor. This modification would require adding a specific function, only callable by the keepers, that:- Decreases
InfraredBERADepositorV2reserves. - Transfers the respective amount of Bera to the
InfraredBERAWithdraworcontract.
Infrared Finance
Acknowledged. Yes, pulling from depositor queue for withdrawal tickets would be more efficient. We had previously considered this here: https://github.com/infrared-dao/infrared-contracts/pull/584. It was our design choice to keep deposit and withdraw channels separate for simplicity in security, accounting and operations at the expense of some efficiency. We might consider to add this feature in the future.
Cantina
Acknowledged.
EIP 7002 withdrawals are rate‑limited by the consensus constant MaxPendingPartialsPerWithdrawalsSweep
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
Every partial‑withdrawal requested through the EIP‑7002 precompile(
WITHDRAW_PRECOMPILE) is first accepted byInfraredBERAWithdrawor.execute()and immediately deducted from Infrared’s internal stake accounting:// InfraredBERAWithdrawor.executeIInfraredBERAV2(InfraredBERA).register(pubkey, -int256(amount));Once the on‑chain call succeeds the request lives in the beacon state’s
pendingPartialWithdrawals[]queue, it is not yet a real withdrawal and no Bera has been credited to the Withdrawor’s balance.The consensus engine subsequently materialises at most
MaxPendingPartialsPerWithdrawalsSweepentries from that queue in each block. The throttling point is withinbeacon-kit-1.2.0/state-transition/core/state/statedb.go - consumePendingPartialWithdrawalsfunction:for _, withdrawal := range ppWithdrawals { if withdrawal.WithdrawableEpoch > epoch || len(withdrawals) == constants.MaxPendingPartialsPerWithdrawalsSweep { break // ← hard stop after N items } … append to block’s Withdrawal list …}MaxPendingPartialsPerWithdrawalsSweepis a chain constant defined inprimitives/constants(Berachain main‑net value = 8):// Withdrawals processing:// https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#withdrawals-processingconst ( // MaxPendingPartialsPerWithdrawalsSweep is the maximum number of pending partial withdrawals // per sweep. MaxPendingPartialsPerWithdrawalsSweep = 8)Because it is evaluated once per block, a backlog of
kpartial requests will takeceil(k / 10)blocks before the corresponding Bera is forwarded to theInfraredBERAWithdraworcontract. While the protocol remains correct, the funds will eventually arrive, this rate‑limit has some operational side‑effects:InfraredBERAWithdrawor.process()can only finalize user tickets whenaddress(this).balance – totalClaimableis large enough. Large bursts of exits therefore sit queued for multiple blocks even though sufficient liquidity already exists on the consensus layer.- An adversary able to spam partial withdrawals (e.g. by splitting a full exit into > 256 partials) can deterministically depress Withdrawor liquidity for
> 25blocks, creating a temporary DoS window on user redemptions.
Recommendation
Mitigate the throughput bottleneck rather than trying to bypass the consensus rule as it is part of the fork logic and cannot be disabled. Three complementary measures are suggested:
- Introduce a Depositor → Withdrawor fast‑path: Provide a
rebalanceToWithdrawor(uint256 amount)function in InfraredBERADepositorV2 callable by a keeper/governor. Moving idlereservesdirectly into the Withdrawor allowsprocess()to settle tickets immediately, side‑stepping the consensus throttle and avoiding many precompile calls. - Integrate queue-depth awareness into keeper logic: Enhance the off-chain keeper to monitor
pendingPartialWithdrawals.length, either via light-client proof or RPC, and dynamically adjust its behavior based on current queue saturation. The keeper’s algorithm responsible for selecting the validator and amount for withdrawal precompile calls should incorporate current queue depth as a factor in its optimization strategy.
Infrared Finance
Acknowledged.
Cantina
Acknowledged.
Staking is rate‑limited by the consensus constant MaxDepositsPerBlock
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
Every time a user mints IBera, the Bera is pushed into
InfraredBERADepositor.queue{value: …}(). The amount is then forwarded to the BerachainDepositcontract by a keeper viaInfraredBERADepositor.executecall.Once that Execution Layer transaction is executed the event becomes part of the deposit log and must be “ingested” by the beacon chain. That ingestion is performed, block‑by‑block, inside the state‑transition function
processOperations. The consensus rules enforce a strict upper bound on how manyDepositobjects can appear in a single beacon block:// state-transition/core/state_processor_staking.godeposits := blk.GetBody().GetDeposits()if uint64(len(deposits)) > sp.cs.MaxDepositsPerBlock() { return errors.Wrapf( ErrExceedsBlockDepositLimit, "expected ≤ %d, got %d", sp.cs.MaxDepositsPerBlock(), len(deposits), )}MaxDepositsPerBlockis defined in the chain specification (e.g.16on the current Berachain network). When more than 16 new deposit events exist in the log, the proposer is forced to carry only the first 16, the remainder must wait for subsequent blocks. Unlike the withdrawal path, there is no bounded in‑state queue that can overflow or drop entries, excess deposits simply accumulate back‑pressure until cleared at a constant rate of≤ 16per block.The implication for Infrared is that during periods of very high inflow
InfraredBERADepositor.reservescould remain positive for many blocks. While the contractual promise “one IBera = one Bera staked” is ultimately preserved, an operational effect emerge: Newly minted IBera begins accruing staking yield only after the corresponding deposit is confirmed on the beacon chain. A protracted log backlog therefore reduces APY for all Ibera holders. Thedepositsfield insideInfraredBERAV2continues to rise immediately, because_deposit()is executed at mint time. Until the beacon state catches up,convertToAssets()and related accounting over‑estimate the on‑chain validator balance, albeit temporarily.Recommendation
Merely an informational issue. A possible mitigation against this would be to update
InfraredBERADepositorV2with a function allowing idlereservesto be shifted to the Withdrawor contract (rebalanceToWithdrawor).Infrared Finance
Acknowledged.
Cantina
Acknowledged.
Minting below flat burn fee will block a future withdrawal
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The
InfraredBERAV2contract applies a fixed “burn fee” in iBERA shares whenever a user callsburn:uint256 fee = burnFee; // flat fee in shares uint256 netShares = shares - fee; // underflows or reverts if shares ≤ feeHowever, there is no corresponding minimum enforced during
mint. This means a user canmintan amount of BERA that converts to fewer iBERA shares than the flatburnFee. Such users will then be unable to ever exit (unless they purchase more iBERA), because any subsequent call toburn(shares)will revert (or underflow), locking their entire position.In practice, a user who mints e.g. 1 iBERA when the flat burn fee is 5 iBERA will be "stuck": they cannot redeem those shares, and their funds are irrecoverable.
Recommendation
Prevent this dead-end scenario by enforcing a minimum mintable/shareable amount equal to the burn fee. For example, in your
mintimplementation:function mint(uint256 assets, address receiver) external returns (uint256 shares) { shares = convertToShares(assets);+ if (shares <= burnFee) {+ revert Errors.AmountTooSmallForBurnFee();+ } _mint(receiver, shares); emit Mint(msg.sender, receiver, assets, shares); }Similarly, update
previewMintto return zero (or revert) for any asset amount that would yield≤ burnFeeshares, so integrators and UIs can prevent users from creating non‐exitable positions.Infrared Finance
Acknowledged. Our front-end integration will direct all low burns to swaps instead, which works fine as long as liquidity remains. Should liquidity become too low (eg. if iBERA is wound down) we can drop the burn fee to close to zero. If we add a minimum mint, it will not apply to shares already minted.
Cantina
Acknowledged.