stake.link

stake.link
DeFiHardhatBridge
27,500 USDC
View results
Submission Details
Severity: high
Invalid

`SDLPoolSecondary` can be in a deadlock situation, preventing any outgoing updates to the primary chain from happening

Summary

During an outgoing update from Secondary pool to the primary pool, no other update can take place until the primary pool returns the mintStartIndex. If the transaction on the primary chain reverts, the secondary pool will be set in a deadlock situation.

Vulnerability Details

Actions on the secondary pool such as staking, locking, withdrawing, or initiating a withdrawal do not take effect immediately, they get queued and sent periodically to the primary pool to keep accounting consistent between primary and secondary pools. Chainlink bots call performUpkeep on SDLPoolCCIPControllerSecondary to initiate updates from secondary to primary chain.

function performUpkeep(bytes calldata) external {
if (!shouldUpdate) revert UpdateConditionsNotMet();
shouldUpdate = false;
_initiateUpdate(primaryChainSelector, primaryChainDestination, extraArgs);
}
function _initiateUpdate(uint64 _destinationChainSelector, address _destination, bytes memory _extraArgs)
internal
{
// calls secondary pool to get new ResdlTokens and total supply change
>> (uint256 numNewRESDLTokens, int256 totalRESDLSupplyChange) = ISDLPoolSecondary(sdlPool).handleOutgoingUpdate();
Client.EVM2AnyMessage memory evm2AnyMessage =
_buildCCIPMessage(_destination, numNewRESDLTokens, totalRESDLSupplyChange, _extraArgs);
IRouterClient router = IRouterClient(this.getRouter());
uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);
if (fees > maxLINKFee) revert FeeExceedsLimit(fees);
// send cross-chain message to the primary
>> bytes32 messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);
emit MessageSent(messageId, _destinationChainSelector, fees);
}

One crucial thing happens in the handleOutgoingUpdate is that updateInProgress flag is set to 1 whenever a new update is sent to primary chain, and no more updates can be sent until the current in-progress update is resolved:

function handleOutgoingUpdate() external onlyCCIPController returns (uint256, int256) {
>> if (updateInProgress == 1) revert UpdateInProgress();
...
>> updateInProgress = 1;
...
return (numNewQueuedLocks, reSDLSupplyChange);
}

On the primary chain, the SDLPoolCCIPControllerPrimary receives the message and verifies that the sender, then calls handleIncomingUpdates in the SDLPoolPrimary, which calculates the mintStartIndex. This index is then sent back to the secondary chain by the SDLPoolCCIPControllerPrimary in another cross-chain transaction.

/// SDLPoolCCIPControllerPrimary
function ccipReceive(Client.Any2EVMMessage calldata _message) external override onlyRouter {
// verify sender
_verifyCCIPSender(_message);
if (_message.destTokenAmounts.length == 1 && _message.destTokenAmounts[0].token == address(sdlToken)) {
IRESDLTokenBridge(reSDLTokenBridge).ccipReceive(_message);
} else {
// confirms the updates and send the message to secondary pool
>> _ccipReceive(_message);
}
}
function _ccipReceive(Client.Any2EVMMessage memory _message) internal override {
uint64 sourceChainSelector = _message.sourceChainSelector;
(uint256 numNewRESDLTokens, int256 totalRESDLSupplyChange) = abi.decode(_message.data, (uint256, int256));
if (totalRESDLSupplyChange > 0) {
reSDLSupplyByChain[sourceChainSelector] += uint256(totalRESDLSupplyChange);
} else if (totalRESDLSupplyChange < 0) {
reSDLSupplyByChain[sourceChainSelector] -= uint256(-1 * totalRESDLSupplyChange);
}
>> uint256 mintStartIndex =ISDLPoolPrimary(sdlPool).handleIncomingUpdate(numNewRESDLTokens, totalRESDLSupplyChange);
>> _ccipSendUpdate(sourceChainSelector, mintStartIndex);
emit MessageReceived(_message.messageId, sourceChainSelector);
}

The secondary pool is expecting an update from the primary pool to know the _mintStartIndex via handleIncomingUpdate in which updateInProgress is set 0, finalizing the update in progress:

// SDLPoolSecondary.sol
function handleIncomingUpdate(uint256 _mintStartIndex) external onlyCCIPController {
if (updateInProgress == 0) revert NoUpdateInProgress();
if (_mintStartIndex != 0) {
// the _mintStartIndex will be minted the first then increament the by one for the next id ..
uint256 newLastLockId = _mintStartIndex + queuedNewLocks[updateBatchIndex - 1].length - 1;
if (newLastLockId > lastLockId) lastLockId = newLastLockId;
}
currentMintLockIdByBatch.push(_mintStartIndex);
>> updateInProgress = 0;
emit IncomingUpdate(updateBatchIndex - 1, _mintStartIndex);
}

The issue is if the callback transaction that sends mintStartIndex from primary to secondary chain reverts for whatever reason (not enough LINK token to pay for the gas, etc), this will lead to not triggering the function handleIncomingUpdate on the Secondary pool, thus not finalizing the update in-progress, making the SDLPoolSecondary in a dead lock situation where no further updates can be sent. There is no recovery mechanism to send back updates from the primary pool to the secondary pool if such situation happens as sending updates from the primary pool to the secondary pool is ONLY made when an update from the secondary pool comes.

PoC

Consider the following example:

  • There are several queued updates on SDLPoolSecondary

  • Chainlink Bots initiate updates from the Secondary pool to the primary pool by calling performUpKeep

  • handleOutgoingUpdate is triggered on SDLPoolSecondary and updateInProgress flag is set to 1

  • updates are sent from the secondary pool to the primary pool via cross-chain communication using CCIP.

  • SDLPoolCCIPControllerPrimary receives the incoming update, _ccipReceive is triggered:

    • It calculates the mintStartIndex and sends back an update to the secondary pool via a cross-chain communication using CCIP by invoking _ccipSendUpdate

    • The last transaction reverts for whatever reason (not enough LINK token to pay for gas, etc.)

  • SDLPoolSecondary is still waiting for an update from the primary pool (which is reverted). Thus, handleIncomingUpdate will never be triggered, keeping the updateInProgress flag to 1 forever

  • There is no mechanism in SDLPoolPrimary/SDLPoolCCIPControllerPrimary to re-send the updates to the secondary pool

  • The SDLPoolSecondary is now in a deadlock situation as no further updates can be handled.

Impact

  • This issue will put the SDLPoolSecondary pool in a deadlock situation putting users' actions in limbo.

Tools Used

Manual Review

Recommendations

Consider implementing a recovery mechanism that will re-send updates from the primary pool to the secondary pool if needed.

Updates

Lead Judging Commences

0kage Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.