EscrowedEXA::onStreamCanceled callback will never execute due to incorrect recipient address
Summary
EscrowedEXA contains functionality for vesting EXA tokens, and allows the recipient to cancel the vesting stream. When cancelling a vesting steam, EscrowedEXA contract implements onStreamCanceled callback to mint esEXA to the recipient with the remaining EXA received from the canceled stream.
The problem is that this callback will always fail silently due to incorrect recipient address, and the caller will not receive the remaining esEXA tokens.
Vulnerability Detail
When cancelling a vesting stream the following function is initiated:
When sablier.cancel(streamId) is called, the stream is cancelled via the Sablier external contract, which initiates the onStreamCanceled callback. This callback is optional and will fail silently if not implemented, as stated in the Sablier docs:
interface ISablierV2LockupRecipient {
/// @dev Notes:
/// - This function may revert, but the Sablier contract will ignore the revert.
function onStreamCanceled(
uint256 streamId,
address sender,
uint128 senderAmount,
uint128 recipientAmount
)
external;
}
EscrowedEXA contract implements this function to mint esEXA to the recipient with the remaining EXA received from the cancelled stream.
/// @notice Hook called when a recipient cancels a stream.
/// @notice Mints esEXA to the recipient with the remaining EXA received from the canceled stream.
/// @param streamId streamId of the cancelled stream.
/// @param recipient recipient of the cancelled stream.
/// @param senderAmount amount of EXA received back from the stream cancelling.
function onStreamCanceled(uint256 streamId, address recipient, uint128 senderAmount, uint128) external {
assert(msg.sender == address(sablier));
checkStream(streamId);
_mint(recipient, senderAmount);
returnReserve(streamId, recipient);
}
However, there is an issue here. Let's see what happens when a vesting stream is created:
function _cancel(uint256 streamId) internal override {
.
.
.
// @audit sender is address of `EscrowedEXA` contract
address sender = _streams[streamId].sender;
// @audit recipient is address of recipient cancelling the stream
address recipient = _ownerOf(streamId);
// @audit recipient address is the address of `recipient` defined in `createWithDurations`
// It is NOT the address of the `EscrowedEXA` contract
if (recipient.code.length > 0) {
try ISablierV2LockupRecipient(recipient).onStreamCanceled({
streamId: streamId,
sender: sender,
senderAmount: senderAmount,
recipientAmount: recipientAmount
}) { } catch { }
}
}
As we can see, sender is the address of the EscrowedEXA contract, however ISablierV2LockupRecipient(recipient).onStreamCanceled is called on the recipient address which is the initial to address from when the vesting stream was created. Therefore, the onStreamCanceled of the EscrowedEXA contract will not be executed, and due to the try/catch block, it will fail silently.
Impact
onStreamCanceled callback will silently fail, recipient will not receive the esEXA tokens from the remaining EXA received by cancelling the stream as intended by the callback.
Perhaps redesign how the sender and recipient addresses are set upon creating new vesting streams, but that may also have some implications, so be very careful when implementing the solution.
This issue is invalid because the callback onStreamCanceled will only be called if the stream was canceled directly through Sablier. When a user calls EscrowedEXA::cancel, it will receive the exEXA tokens here.
cryptic
medium
EscrowedEXA::onStreamCanceled
callback will never execute due to incorrect recipient addressSummary
EscrowedEXA
contains functionality for vestingEXA
tokens, and allows the recipient to cancel thevesting stream
. When cancelling avesting steam
,EscrowedEXA
contract implementsonStreamCanceled
callback to mintesEXA
to the recipient with the remainingEXA
received from the canceled stream.The problem is that this callback will always fail silently due to incorrect recipient address, and the caller will not receive the remaining
esEXA
tokens.Vulnerability Detail
When cancelling a
vesting stream
the following function is initiated:EscrowedEXA::cancel
https://github.com/sherlock-audit/2024-04-interest-rate-model/blob/main/protocol/contracts/periphery/EscrowedEXA.sol#L133When
sablier.cancel(streamId)
is called, the stream is cancelled via theSablier
external contract, which initiates theonStreamCanceled
callback. This callback is optional and will fail silently if not implemented, as stated in theSablier
docs:ISablierV2LockupRecipient
https://etherscan.io/address/0xafb979d9afad1ad27c5eff4e27226e3ab9e5dcc9#codeEscrowedEXA
contract implements this function to mintesEXA
to the recipient with the remainingEXA
received from the cancelled stream.EscrowedEXA::onStreamCanceled
https://github.com/sherlock-audit/2024-04-interest-rate-model/blob/main/protocol/contracts/periphery/EscrowedEXA.sol#L188However, there is an issue here. Let's see what happens when a
vesting stream
is created:EscrowedEXA::vest
https://github.com/sherlock-audit/2024-04-interest-rate-model/blob/main/protocol/contracts/periphery/EscrowedEXA.sol#L89Note that
sender
= address ofEscrowedEXA contract
andrecipient
is theto
address passed in.When we cancel via
sablier.cancel(streamId)
, the external function is called:SablierV2LockupLinear::_cancel
https://etherscan.io/address/0xafb979d9afad1ad27c5eff4e27226e3ab9e5dcc9#codeAs we can see,
sender
is the address of theEscrowedEXA
contract, howeverISablierV2LockupRecipient(recipient).onStreamCanceled
is called on therecipient
address which is the initialto
address from when thevesting stream
was created. Therefore, theonStreamCanceled
of theEscrowedEXA
contract will not be executed, and due to thetry/catch
block, it will fail silently.Impact
onStreamCanceled
callback will silently fail, recipient will not receive theesEXA
tokens from the remainingEXA
received by cancelling the stream as intended by the callback.Code Snippet
https://github.com/sherlock-audit/2024-04-interest-rate-model/blob/main/protocol/contracts/periphery/EscrowedEXA.sol#L133
Tool used
Manual Review
Recommendation
Perhaps redesign how the
sender
andrecipient
addresses are set upon creating new vesting streams, but that may also have some implications, so be very careful when implementing the solution.