Crown: BRL
Cantina Security Report
Organization
- @crown
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Medium Risk
2 findings
2 fixed
0 acknowledged
Low Risk
5 findings
3 fixed
2 acknowledged
Informational
2 findings
0 fixed
2 acknowledged
Medium Risk2 findings
Batched cross-chain operations fail due to transaction hash collision in replay protection
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
devtooligan
Summary
The bridged BRLV system uses transaction hashes as the sole replay-protection key, which prevents processing multiple bridge events emitted within a single source-chain transaction. When privileged operators batch multiple mint or burn requests into one transaction, only the first event can be processed on the destination chain while subsequent events revert, creating stuck bridge requests and accounting mismatches.
Description
The replay-protection mechanism in
bridgedBRLV.mint()andBRLVCanonicalGateway.bridgedBurn()keys idempotency checks solely on transaction hash. InbridgedBRLV.sol, themint()function stores_processedTxHashes[canonicalTxHash]and reverts withBridgedBRLVTxHashAlreadyProcessedon duplicate calls:function mint( bytes32 canonicalTxHash, bytes32 canonicalDomain_, uint256 amount, address to, uint256 rewardMultiplier) external onlyRole(MINTER_ROLE) { // Check for replay attack if (_processedTxHashes[canonicalTxHash]) { revert BridgedBRLVTxHashAlreadyProcessed(canonicalTxHash); } // Mark transaction as processed _processedTxHashes[canonicalTxHash] = true; // ...}Similarly,
BRLVCanonicalGateway.bridgedBurn()uses_processedRemoteTxHashes[remoteTxHash]as the idempotency key.When an operator with
MINTER_ROLEbatches multiplebridgedMint()calls in a single canonical-chain transaction, the gateway emits multipleBridgedMintRequestedevents that all share the same transaction hash. The relayer then attempts to complete each mint on the remote chain by callingbridgedBRLV.mint()with the samecanonicalTxHashparameter for each event. The first call succeeds and marks the hash as processed; subsequent calls for the remaining events revert because the contract cannot distinguish between them using only the transaction hash.A symmetric scenario occurs for burns: if an account with
BURNER_ROLEon the remote chain burns tokens multiple times in one transaction, theBridgedBurnRequestedevents share the same remote transaction hash. When the relayer attempts to finalize all burns on the canonical chain viaBRLVCanonicalGateway.bridgedBurn(), only the first completion succeeds.Recommendation
Consider implementing per-event unique identifiers for replay protection by adding a
uint256 logIndexparameter to both functions and modifying the replay-protection mappings to use a composite key:// In bridgedBRLV.sol- mapping(bytes32 => bool) private _processedTxHashes;+ mapping(bytes32 => mapping(uint256 => bool)) private _processedTxHashes; function mint( bytes32 canonicalTxHash,+ uint256 logIndex, bytes32 canonicalDomain_, uint256 amount, address to, uint256 rewardMultiplier ) external onlyRole(MINTER_ROLE) { if (canonicalDomain_ != canonicalDomain) { revert BridgedBRLVInvalidCanonicalDomain(canonicalDomain_, canonicalDomain); }- if (_processedTxHashes[canonicalTxHash]) {+ if (_processedTxHashes[canonicalTxHash][logIndex]) { revert BridgedBRLVTxHashAlreadyProcessed(canonicalTxHash); }- _processedTxHashes[canonicalTxHash] = true;+ _processedTxHashes[canonicalTxHash][logIndex] = true; // ... rest of function }Apply the same pattern to
BRLVCanonicalGateway.bridgedBurn(). The off-chain relayer should extract thelogIndexfrom transaction receipts when processing bridge events. Alternatively, consider introducing an explicit incrementingrequestIdemitted from the source chain that serves as a globally unique identifier, though this approach requires additional state management on the source chain.Inconsistent blocklist implementation
Summary
The blocklist implementation is inconsistent and contain few flaws that may allow blocked users to still interact with the protocol.
Finding Description
There are two misconfigurations of the blocklist:
- Missing spender check in
transferFromin (wBRLY,BRLV,bridgedBRLV): All three contracts inherit the standardERC20Upgradeable.transferFromwithout checking ifmsg.senderis blocked. In contrast,BRLY.solexplicitly overridestransferFromto validate the spender:
function transferFrom(address from, address to, uint256 amount) external returns (bool) { address spender = _msgSender(); if (isBlocked(spender)) { revert BRLYBlockedSender(spender); } [...]A blocked address with pre-existing approvals can still execute
transferFromcalls as the spender. This may happen for instance, if an phishing is executed against users, and the phisher will still be able to move funds around since only target or origin addresses can be blocked.- Missing receiver check in
wBRLY: Unlike BRLV and bridgedBRLV which check both from and to against the blocklist,wBRLYonly checks the sender.
function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override { // Each blocklist check is an SLOAD, which is gas intensive. // We only block sender not receiver, so we don't tax every user if (BRLY.isBlocked(from)) { revert wBRLYBlockedSender(from); }This allows tokens to be transferred to blocked addresses in
wBRLY.Impact Explanation
Blocked addresses can bypass restrictions by using existing approvals as spenders, and can receive wBRLY tokens directly.
Recommendation (optional)
Override
transferFromin all three contracts to checkisBlocked(msg.sender). Add receiver blocklist check towBRLY._beforeTokenTransfer.- Missing spender check in
Low Risk5 findings
BRLY allows zero-share mints leading to accounting inconsistency in BRLV
State
- Fixed
PR #46
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
devtooligan
Summary
The BRLY contract's share-based accounting allows minting operations that result in zero shares when small token amounts round down during conversion. This can cause accounting inconsistencies in the BRLV wrapper contract where BRLV shares are issued without corresponding BRLY backing, breaking vault invariants.
Description
When BRLY's
rewardMultiplierexceeds 1e18 (after positive rebases), theconvertToShares()function can round small token amounts down to zero shares. This occurs whenamount < rewardMultiplier / 1e18.In BRLV's
mint()function, which is restricted to MINTER_ROLE holders, callingBRLY.mint(address(this), amount)with such small amounts results in zero BRLY shares being minted to the vault, while BRLV still proceeds to mint the requested amount of BRLV shares to the recipient.This edge case breaks the vault's fundamental invariant that
totalAssets >= totalSupply. When this invariant is violated, surplus-dependent operations likeclaimRewardsV2()andclaimTax()will fail due to arithmetic underflow when calculatingtotalAssets() - totalSupply().While this requires MINTER_ROLE access (which is only granted to trusted protocol components), the contract should prevent such edge cases to maintain consistent accounting and avoid potential integration issues.
Recommendation
Consider adding validation in the BRLY contract to prevent mints and burns that would result in zero shares:
function _mint(address to, uint256 amount) private { if (to == address(0)) { revert BRLYInvalidMintReceiver(to); } _beforeTokenTransfer(address(0), to, amount); uint256 shares = convertToShares(amount);+ if (shares == 0) {+ revert BRLYZeroShareMint(amount);+ } _totalShares += shares; unchecked { _shares[to] += shares; } _afterTokenTransfer(address(0), to, amount);} function _burn(address account, uint256 amount) private { if (account == address(0)) { revert BRLYInvalidBurnSender(account); } _beforeTokenTransfer(account, address(0), amount); uint256 shares = convertToSharesRoundUp(amount);+ if (shares == 0) {+ revert BRLYZeroShareBurn(amount);+ } uint256 accountShares = sharesOf(account); if (accountShares < shares) { revert BRLYInsufficientBurnBalance(account, accountShares, shares); } // ... rest of function}Inconsistent pause mechanism allows operations to bypass emergency stop
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
devtooligan
Summary
The BRLV protocol implements a pause mechanism intended as an emergency stop for all token operations. However, several critical functions across BRLV, BRLVCanonicalGateway, and BRLVRewards contracts do not respect the pause state, allowing minting, burning, and cross-chain operations to continue during emergencies. This undermines the effectiveness of the pause functionality as a security control.
Description
The BRLV contract inherits from
PausableUpgradeableand implements pause checks in its_beforeTokenTransfer()hook, which affectstransfer(),transferFrom(),mint(),burn(),mintShares(), andburnShares()functions. However, two permissioned functions bypass this protection:// BRLV.sol - These functions lack pause checksfunction mintAssets(uint256 amount) external onlyRole(MINTER_ROLE) { _mint(address(this), amount); // This will be paused _totalAssets += amount; emit AssetsMinted(amount);} function burnAssets(uint256 amount) external onlyRole(BURNER_ROLE) { _burn(address(this), amount); // This will be paused _totalAssets -= amount; emit AssetsBurned(amount);}Note: Upon closer inspection,
mintAssets()andburnAssets()call_mint()and_burn()respectively, which do trigger_beforeTokenTransfer()and will be paused. However, theBRLVCanonicalGateway.bridgedSharesMint()function presents a genuine gap:// BRLVCanonicalGateway.solfunction bridgedSharesMint( uint256 amount, bytes32 domain, address to) external onlyRole(MINTER_ROLE) whenNotPaused { _validateBridgedMintParams(amount, domain, to); // No collateral minting - uses existing surplus // Track bridged supply totalBridgedSupply += amount; // Get current reward multiplier uint256 rewardMultiplier = brly.rewardMultiplier(); // Emit event for relayer - NO BRLV INTERACTION, NO PAUSE CHECK emit BridgedMintRequested(amount, domain, to, rewardMultiplier);}This function only checks its own pause state via
whenNotPaused, but does not verify whether BRLV itself is paused. When BRLV is paused during an emergency but BRLVCanonicalGateway is not, reward claims to remote chains can still proceed throughBRLVRewards.claimRewards():// BRLVRewards.solfunction claimRewards(...) external onlyRole(MINTER_ROLE) whenNotPaused { // ... if (domain == canonicalDomain) { brlv.mintShares(to, amount); // Will revert if BRLV paused } else { gateway.bridgedSharesMint(amount, domain, to); // Succeeds even if BRLV paused } // ...}The inconsistency creates a situation where:
- Canonical chain operations are properly blocked when BRLV is paused
- Remote chain reward claims can still proceed, creating an asymmetric emergency response
Contract Function Respects BRLV Pause? BRLV transfer()/transferFrom() Yes BRLV mint()/burn() Yes BRLV mintShares()/burnShares() Yes BRLVCanonicalGateway bridgedMint() Yes (uses mintAssets) BRLVCanonicalGateway bridgedBurn() Yes (uses burnAssets) BRLVCanonicalGateway bridgedSharesMint() No BRLVRewards claimRewards() (canonical) Yes BRLVRewards claimRewards() (remote) No Impact Explanation
During an emergency that requires pausing the BRLV system, cross-chain reward minting operations can continue to function. This could allow the protocol to continue emitting
BridgedMintRequestedevents that the off-chain relayer might process, potentially minting tokens on remote chains when the canonical chain is in an emergency state. The partial effectiveness of the pause mechanism reduces confidence in the protocol's ability to halt operations during critical situations.Likelihood Explanation
This issue requires an emergency scenario where BRLV needs to be paused but the operator either forgets to pause all related contracts or intentionally wants asymmetric behavior. While the likelihood of exploitation is low given the permissioned nature of the affected functions, the inconsistency represents a design flaw that could lead to unexpected behavior during high-stress emergency situations.
Recommendation
Consider adding a check for BRLV's pause state in
BRLVCanonicalGateway.bridgedSharesMint():function bridgedSharesMint( uint256 amount, bytes32 domain, address to ) external onlyRole(MINTER_ROLE) whenNotPaused {+ if (brlv.paused()) {+ revert GatewayBRLVPaused();+ } _validateBridgedMintParams(amount, domain, to); // ... rest of function }Additionally, consider implementing a coordinated emergency pause mechanism that can halt operations across all related contracts simultaneously. This could be achieved through:
- A shared pause registry contract that all protocol contracts check
- A multi-contract pause function callable by emergency administrators
- An off-chain script that can pause multiple contracts
Gateway bridgedSharesMint() allows unbacked remote supply inflation by skipping surplus validation
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
devtooligan
Summary
The
bridgedSharesMint()function inBRLVCanonicalGatewayincrementstotalBridgedSupplywithout verifying that sufficient surplus collateral exists. WhileBRLVRewards.claimRewards()performs this validation before calling the gateway, any account withMINTER_ROLEon the gateway can bypass this check by callingbridgedSharesMint()directly, potentially creating unbacked remote supply that breaks the protocol's collateralization invariant.Description
The
bridgedSharesMint()function is designed to mint shares on remote chains against existing surplus collateral without creating new assets. However, the function does not enforce that this surplus actually exists:function bridgedSharesMint( uint256 amount, bytes32 domain, address to) external onlyRole(MINTER_ROLE) whenNotPaused { _validateBridgedMintParams(amount, domain, to); // No collateral minting - uses existing surplus // Track bridged supply totalBridgedSupply += amount; // No surplus validation uint256 rewardMultiplier = brly.rewardMultiplier(); emit BridgedMintRequested(amount, domain, to, rewardMultiplier);}While
BRLVRewards.claimRewards()correctly validates available surplus before callingbridgedSharesMint(), this check can be bypassed by any gateway minter calling the function directly.When
totalBridgedSupplyis inflated without corresponding collateral,availableSurplus()returns a reduced or zero value since it computestotalAssets - (canonicalSupply + totalBridgedSupply). BecausetotalBridgedSupplyis a single global scalar, this inflation affects all domains simultaneously. Remote holders who later attempt to burn their tokens triggerbridgedBurn()calls that invokebrlv.burnAssets(), which may revert if the BRLV contract lacks sufficient BRLY balance.Impact Explanation
Unbacked remote supply breaks the core collateralization invariant, leaving remote BRLV partially or fully unbacked. Remote burn and redemption flows may become uncompletable because
brlv.burnAssets()will revert when insufficient BRLY exists. The reduced globalavailableSurplus()can also block legitimate reward claims across all domains, not just the one targeted.Likelihood Explanation
Exploitation requires
MINTER_ROLEon the gateway, which is privileged but may be granted to operational components or integrations. The attack is a single function call with no external dependencies, though it relies on privileged access or misconfiguration.Recommendation
Consider adding surplus validation directly to
bridgedSharesMint()to ensure enforcement regardless of the call path:function bridgedSharesMint( uint256 amount, bytes32 domain, address to ) external onlyRole(MINTER_ROLE) whenNotPaused { _validateBridgedMintParams(amount, domain, to);+ // Ensure sufficient surplus exists+ uint256 currentSurplus = availableSurplus();+ if (amount > currentSurplus) {+ revert GatewayInsufficientSurplus(amount, currentSurplus);+ } // No collateral minting - uses existing surplus totalBridgedSupply += amount; uint256 rewardMultiplier = brly.rewardMultiplier(); emit BridgedMintRequested(amount, domain, to, rewardMultiplier); }With this check in place, the redundant validation in
BRLVRewards.claimRewards()could be removed. Additionally, consider introducing a dedicated role (e.g.,SHARES_MINTER_ROLE) granted only to the Rewards contract, providing defense-in-depth by preventing arbitrary gateway minters from bypassing the intended surplus checks.BRLY rounding mismatch causes bridged burn transactions to revert
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
devtooligan
Summary
The BRLY token uses asymmetric rounding in its share-to-token conversions, where
mint()rounds down andburn()rounds up. After any yield accrual shifts therewardMultiplierabove 1e18, bridged burn operations through the gateway will revert because BRLV holds slightly fewer shares than required to burn the originally minted token amount. This is a low severity issue as transactions can be retried with a 1 wei adjustment and no funds are at risk.Description
BRLY implements a share-based rebasing model where token balances are calculated by multiplying shares by a
rewardMultiplier. The_mint()function converts token amounts to shares usingconvertToShares(), which rounds down:function convertToShares(uint256 amount) public view returns (uint256) { return (amount * _BASE) / rewardMultiplier;}The
_burn()function usesconvertToSharesRoundUp(), which rounds up:function convertToSharesRoundUp(uint256 amount) public view returns (uint256) { return (amount * _BASE + rewardMultiplier - 1) / rewardMultiplier;}When
BRLVCanonicalGateway.bridgedMint()mints collateral viabrlv.mintAssets(amount), the underlying BRLY mint rounds shares down. WhenbridgedBurn()later attempts to burn that sameamount, BRLY's burn logic rounds shares up, requiring one more share than BRLV possesses.For example, minting 100 tokens when
rewardMultiplier = 1.001e18creditsfloor(100 * 1e18 / 1.001e18) = 99900099900099900099shares to BRLV. A subsequent burn of 100 tokens requiresceil(100 * 1e18 / 1.001e18) = 99900099900099900100shares—one more than available—causing the transaction to revert withBRLYInsufficientBurnBalance.This affects nearly every transaction once
rewardMultiplierdeviates from 1e18. Only amounts that are exact multiples of therewardMultiplierwould work, which is effectively random and not something operators would control.Impact Explanation
The impact is low. No funds are at risk as transactions simply revert and can be retried. The economic impact is negligible—only 1 wei of dust per operation. Since these are permissioned operations requiring
MINTER_ROLEorBURNER_ROLE, operators can adapt their tooling to account for the adjustment.Likelihood Explanation
The likelihood is high as this occurs on every bridge operation after the first yield accrual shifts
rewardMultiplierabove 1e18. No attacker action is required; the mismatch is deterministic.Proof of Concept
it('blocks bridged burn because minted collateral is short due to rounding', async () => { // Step 1: accrue yield so rewardMultiplier > 1e18 const increment = parseUnits('0.001'); // +0.1% await brly.connect(oracle).addRewardMultiplier(increment); // Step 2: Request bridged mint of 100 BRLV const bridgedAmount = parseUnits('100'); await gateway.connect(minter).bridgedMint(bridgedAmount, remoteDomain, remoteUser.address); // Collateral actually minted is rounded down in share space const brlvShares = await brly.sharesOf(await brlv.getAddress()); const collateralTokens = await brly.convertToTokens(brlvShares); expect(collateralTokens).to.be.lt(bridgedAmount); // collateral < liabilities // Step 3: Remote burn completion tries to burn the full 100 tokens - reverts const requiredShares = await brly.convertToSharesRoundUp(bridgedAmount); await expect( gateway.connect(relayer).bridgedBurn(remoteTxHash, remoteDomain, remoteUser.address, bridgedAmount) ).to.be.revertedWithCustomError(brly, 'BRLYInsufficientBurnBalance') .withArgs(await brlv.getAddress(), brlvShares, requiredShares);});Recommendation
The simplest fix is to document this behavior and have operators burn
amount - 1instead ofamountwhen completing bridged burns. However, this creates a minor accounting mismatch where 1 wei of collateral per operation remains locked in BRLV.For a cleaner solution, consider converting gateway operations to share-based accounting:
function bridgedMint(uint256 amount, bytes32 domain, address to) external onlyRole(MINTER_ROLE) whenNotPaused { _validateBridgedMintParams(amount, domain, to); + // Compute actual collateral based on share rounding+ uint256 shares = brly.convertToShares(amount);+ uint256 actualCollateral = brly.convertToTokens(shares); - brlv.mintAssets(amount);- totalBridgedSupply += amount;+ brlv.mintAssets(actualCollateral);+ totalBridgedSupply += actualCollateral; uint256 rewardMultiplier = brly.rewardMultiplier();- emit BridgedMintRequested(amount, domain, to, rewardMultiplier);+ emit BridgedMintRequested(actualCollateral, domain, to, rewardMultiplier); }This ensures the recorded liability matches the actual collateral credited, eliminating the mismatch at the source.
New chain configurations are enabled by default
Finding Description
The
setChainConfigfunction automatically enables newly configured chains, which could lead to premature activation of bridge routes before full operational readiness is confirmed.When adding a new remote chain configuration via
setChainConfig, the enabled flag is automatically set to true. Administrators have to explicitly disable the chain if not ready for operation, rather than following a safer default-disabled approach.If configuration is performed before the remote chain deployment is fully operational, there is a window where the bridge route may be active but potentially causing failed bridging operations.
Recommendation
Initialize new chain configurations with
enabled: falseby default and require explicit enablement viaenableChain().
Informational2 findings
Bridge integrity hardening
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xluk3
Finding Description
The mint function accepts arbitrary parameters from the
MINTER_ROLEwithout on-chain verification against the canonical chain transaction, relying entirely on relayer integrity.While the
MINTER_ROLEis trusted, the logic could be strengthened. A compromised relayer or misconfigured backend could mint arbitrary amounts without on-chain constraints. ThecanonicalTxHashis used only for replay protection. Theamount,to, andrewardMultiplierparameters are not verified to match the original transaction.Recommendation (optional)
Consider implementing additional verification such as
keccak256(abi.encode(amount, to, rewardMultiplier, nonce)which could allow to binding specific amounts and parameter values to original request.Chain reorganization resilience depends on relayer finality handling
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xluk3
Finding Description
Once a transaction is marked as processed, there is no mechanism to revert this state. If the relayer prematurely or incorrectly marks a transaction as processed (e.g., a blockchain reorg happens), it cannot be reprocessed.
Recommendation (optional)
Since the relayer is out of scope, ensure it waits for sufficient block confirmations before submitting transactions.