Severity: High
The hardcoded MAINNET_EID in BridgeL1.sol and BridgeL2.sol will cause a complete denial of service for cross-chain operations as LayerZero network upgrades will render the bridge contract permanently unusable.
In BridgeL1.sol and BridgeL2.sol, the MAINNET_EID is hardcoded as an immutable value 30101, violating LayerZero's integration best practices which explicitly warn against hardcoding endpoint IDs due to potential network upgrades and migrations.
The vulnerable code:
uint32 public immutable MAINNET_EID = 30101; // @audit Hardcoded EIDAccording to LayerZero documentation, hardcoding endpoint IDs violates best practices. The recommendation is to use admin-restricted setters to configure endpoint IDs instead of hardcoding them.
- Contract needs to be deployed with hardcoded
MAINNET_EID = 30101 - LayerZero infrastructure needs to undergo network upgrade changing mainnet endpoint ID
- Bridge operations need to be actively used for cross-chain reserve synchronization
- LayerZero protocol needs to change mainnet endpoint ID from
30101to a different value during network upgrade - LayerZero infrastructure needs to deprecate or disable the old endpoint ID
30101
- LayerZero announces network upgrade and changes mainnet endpoint ID from
30101to30102 - The old endpoint ID
30101becomes deprecated or non-functional - Protocol executor calls
syncMainnetReserves()which uses hardcodedMAINNET_EID = 30101 - The function call to
totalReservesOracle.setCrosschainReserves(MAINNET_EID, rzrReserves, usdReserves)uses invalid endpoint ID - Cross-chain reserve synchronization fails permanently
- RebaseController cannot access accurate total reserves, breaking epoch calculations
The protocol suffers complete loss of cross-chain functionality. Bridge operations become permanently unusable, preventing:
L1 and L2 chains cannot synchronize reserve data, breaking the multi-chain architecture.
Accurate backing ratio calculations for rebases become impossible without cross-chain reserve data.
Cross-chain liquid staking operations become permanently unavailable.
This effectively breaks the multi-chain protocol architecture with no recovery path without full contract redeployment.
The vulnerability manifests through the following scenario:
- LayerZero announces network upgrade and changes mainnet endpoint ID from
30101to30102 - The old endpoint ID
30101becomes deprecated or non-functional - Protocol executor calls
syncMainnetReserves()which uses hardcodedMAINNET_EID = 30101 - The function call to
totalReservesOracle.setCrosschainReserves(MAINNET_EID, rzrReserves, usdReserves)uses invalid endpoint ID - Cross-chain reserve synchronization fails permanently
- RebaseController cannot access accurate total reserves, breaking epoch calculations
Replace the hardcoded immutable MAINNET_EID with a configurable state variable:
uint32 public mainnetEid; // Remove immutable keyword
constructor(
uint32 _mainnetEid, // Add as constructor parameter
uint32 _readChannel,
address _endpoint,
// ... other parameters
) OAppRead(_endpoint, msg.sender) Ownable(msg.sender) {
mainnetEid = _mainnetEid; // Set during deployment
// ... rest of constructor
}
// Add governance function to update EID if needed
function setMainnetEid(uint32 _newEid) external onlyGovernor {
mainnetEid = _newEid;
emit MainnetEidUpdated(_newEid);
}Severity: High
The missing ownership check in increaseAmount() can cause a complete loss of staker utility as an attacker can arbitrarily inflate the declaredValue of any position. This results in:
- Streaming tax griefing — the attacker forces accelerated tax accrual that drains staked funds
- Market lock attack — the attacker inflates
declaredValueto unrealistic values, making the position permanently unbuyable
In AppStaking.sol, the increaseAmount() function lacks ownership or approval checks when updating declaredValue and taxPerSecond. Since declaredValue can only increase (monotonic), any griefing action is irreversible by the victim.
The vulnerable function:
function increaseAmount(uint256 tokenId, uint256 amount, uint256 declaredValue) external {
// Missing: require(msg.sender == owner || msg.sender == approved);
// ... rest of function
}- A user has an active staking position (
amount > 0) - The attacker knows the victim's
tokenId - Victim has not yet withdrawn or exited the position
None
- Attacker calls
increaseAmount(tokenId, 0, veryLargeDeclaredValue) - Victim's
taxPerSecondspikes - Tax accrues automatically and is collected on the next interaction, draining the victim's staked funds
- Attacker calls
increaseAmount(tokenId, 0, astronomicallyLargeDeclaredValue) - Declared value is now stuck at an unreasonably high level (cannot decrease)
- No buyer will ever purchase the position at this inflated price
- Victim's position is effectively locked — they cannot sell or recover utility from it
Stakers suffer either:
Through forced streaming tax that drains staked funds automatically.
Declared value locked at absurd levels, making exit impossible or resulting in complete loss of funds for the staker.
Attackers gain nothing financially but can grief victims at zero cost, causing severe reputational damage to the protocol.
Positions can be griefed into unusability, severely damaging the protocol's reputation and user trust.
function test_IncreaseAmountIssue() public {
vm.startPrank(owner);
// Create initial position
app.mint(owner, STAKE_AMOUNT);
app.approve(address(staking), STAKE_AMOUNT);
(uint256 tokenId,) = staking.createPosition(owner, STAKE_AMOUNT, DECLARED_VALUE, 0);
IAppStaking.Position memory initialPosition = staking.positions(tokenId);
assertEq(initialPosition.amount, STAKE_AMOUNT);
assertEq(initialPosition.declaredValue, DECLARED_VALUE);
assertEq(staking.totalStaked(), STAKE_AMOUNT);
vm.stopPrank();
vm.warp(block.timestamp + 1 days);
address attacker = makeAddr("attacker");
// attacker inflate the declared value without having any funds
vm.startPrank(attacker);
staking.increaseAmount(tokenId, 0, type(uint160).max);
vm.stopPrank();
vm.warp(block.timestamp + 1 days);
staking.claimRewards(tokenId);
// mint DECLARED_VALUE + STAKE_AMOUNT to user to be able to buy
vm.startPrank(owner);
app.mint(user1, DECLARED_VALUE + STAKE_AMOUNT);
vm.stopPrank();
// user1 tries to buy asset, but value not reasonable
vm.startPrank(user1);
app.approve(address(staking), DECLARED_VALUE + STAKE_AMOUNT);
vm.expectRevert();
staking.buyPosition(tokenId);
vm.stopPrank();
}The test demonstrates:
- An attacker can call
increaseAmount()on any position without ownership - The declared value is inflated to an unreasonable level (
type(uint160).max) - The position becomes unbuyable due to the inflated price
- The victim's position is effectively locked
Add an ownership/approval check in increaseAmount() to ensure only the owner or approved operator can raise declaredValue:
function increaseAmount(uint256 tokenId, uint256 amount, uint256 declaredValue) external {
// Add ownership check
require(
msg.sender == ownerOf(tokenId) || msg.sender == getApproved(tokenId),
"Not authorized"
);
// ... rest of function
}Severity: High
The failure of redeem() to subtract previously claimed interest will cause a double interest payout for stakers as an attacker will first claim interest using claimInterest() and then redeem the same position to receive the full accumulated interest again.
In Convertibles.sol, the redeem() function transfers amountStaked + interestAccumulated without subtracting position.fixedInterestClaimed. This results in users being able to withdraw both previously claimed interest and the same interest again during redemption.
The vulnerable function:
function redeem(uint256 tokenId) external nonReentrant onlyOwnerOrAuthorized(tokenId) {
// ... code ...
uint256 totalInterest = _interestAccumulated(...);
// Missing: uint256 alreadyClaimed = position.fixedInterestClaimed;
// Missing: uint256 interestToPay = totalInterest > alreadyClaimed ? totalInterest - alreadyClaimed : 0;
_burn(tokenId);
delete _positions[tokenId];
totalConvertible -= amountConvertible;
asset.transfer(owner, amountStaked + totalInterest); // BUG: pays full interest again
}- A staker must first call
claimInterest(tokenId)to withdraw accrued interest - The
position.fixedInterestClaimedvariable is updated but not accounted for duringredeem() - The same staker later calls
redeem(tokenId)to withdraw principal + interest
- The protocol must hold enough liquidity of the underlying
assetTokento pay both principal and interest
- Attacker stakes 100e18 tokens with a lock duration
- After lock expiry, attacker calls
claimInterest(tokenId)and withdraws X interest - Attacker then calls
redeem(tokenId) redeem()pays back principal + X again, effectively double-paying the interest
Stakers (honest users) and the protocol treasury suffer losses as attackers can drain extra interest. The attacker gains 100% of the interest twice.
Over multiple positions or large stakes, this can lead to a complete loss of interest reserves in the protocol.
The protocol's interest reserves are drained as attackers exploit the double-claim vulnerability.
function test_RedeemDoubleInterest() public {
// --- Setup ---
vm.startPrank(user1);
uint256 amount = 100e18;
uint256 lockDuration = 60 days;
mockLoanToken.approve(address(convertibles), amount);
(uint256 tokenId,,,,,) = convertibles.stake(mockLoanToken, amount, lockDuration, user1);
vm.stopPrank();
// --- Time travel to after lock ---
vm.warp(block.timestamp + lockDuration + 1);
// --- Ensure contract has enough liquidity for redemption ---
vm.prank(owner);
mockLoanToken.mint(address(convertibles), 50e18);
// --- Balances before ---
uint256 balanceBefore = mockLoanToken.balanceOf(user1);
// --- Step 1: Claim interest once ---
vm.startPrank(user1);
(uint256 claimableBefore, ) = convertibles.claimableInterest(tokenId);
convertibles.claimInterest(tokenId);
uint256 balanceAfterClaim = mockLoanToken.balanceOf(user1);
console.log("Interest claimed once:", balanceAfterClaim - balanceBefore);
// --- Step 2: Redeem position (BUG: pays interest again) ---
convertibles.redeem(tokenId);
uint256 balanceAfterRedeem = mockLoanToken.balanceOf(user1);
vm.stopPrank();
// --- Expected vs Actual ---
uint256 expected = balanceBefore + amount + claimableBefore;
console.log("Expected final balance:", expected);
console.log("Actual final balance:", balanceAfterRedeem);
// balanceAfterRedeem have double interest because it has interest from claimInterest and the redeem function
assertGt(balanceAfterRedeem, expected);
}The test demonstrates:
- User claims interest once via
claimInterest() - User then calls
redeem()which pays the full accumulated interest again - The final balance is greater than expected (principal + interest claimed once)
- The attacker receives double the interest
In Convertibles.sol, adjust the payout calculation in redeem() to account for previously claimed interest:
function redeem(uint256 tokenId) external nonReentrant onlyOwnerOrAuthorized(tokenId) {
// .... code..
uint256 totalInterest = _interestAccumulated(...);
uint256 alreadyClaimed = position.fixedInterestClaimed;
uint256 interestToPay = totalInterest > alreadyClaimed
? totalInterest - alreadyClaimed
: 0;
_burn(tokenId);
delete _positions[tokenId];
totalConvertible -= amountConvertible;
asset.transfer(owner, amountStaked + interestToPay);
//.... remain code
}- Original behavior: Transfers
amountStaked + totalInterestwithout checkingfixedInterestClaimed - Fixed behavior: Calculates
interestToPay = totalInterest - alreadyClaimedto only pay unclaimed interest - Result: Users can only claim their interest once, either through
claimInterest()orredeem(), but not both