The Fjord
protocol team allows for users to stake their Sablier streams and make additional gains on their Sablier NFTs. However, whenever a Sablier stream sender decides to cancel()
a recipient's stream, the staked position of the recipient in FjordStaking
will remain active due to a revert in the FjordStaking::onStreamCanceled(...)
hook callback. When a stream is canceled, the Fjord staking contract tries to unstake the recipient's position by invoking _unstakeVested(...)
, however, the unstaking function incorrectly passes the msg.sender
to the FjordPoints::onUnstaked(...)
function, which in the event of a canceled stream will be the Sablier contract address, instead of the Sablier stream recipient.
As per the Sablier Docs:
When creating a Sablier stream, users have the ability to set the stream as cancelable, or uncancelable. If cancelable, the stream can be stopped at any time by the stream creator, with the unstreamed funds being returned over to the stream creator.
From the above, we can see that a recipient's stream can be canceled anytime, and their funds stopped while doing so. This is why Sablier promotes the use of special hooks, allowing stream recipients to execute required actions and update their states accordingly. Fjord
does this as well by inheriting from the ISablierV2LockupRecipient
interface and including the onStreamCanceled
hook. When a stream is canceled, Fjord
tries to unstake the recipient's position and process any rewards applicable up to the cancelation. However, with the current configuration, the unstaking will fail silently, as the protocol passes the incorrect address to the FjordPoints::onUnstaked(...)
function.
This issue is possible because Sablier stream cancelations will not revert if the onStreamCanceled(...)
hook reverts:
As seen from the code above, if a user stakes his/her position in FjordStaking
, and the Sablier stream sender decides to cancel it, any reverts that happen in FjordStaking::onStreamCanceled(...)
will be silenced.
When the cancelation hook is processed in FjordStaking
, the contract correctly passes the streamOwner
address to the _unstakeVested(...) function:
However, later in that function, instead of passing the streamOwner
, the protocol incorrectly passes msg.sender
to the FjordPoints::onUnstaked(...)
function, which will fail as it tries to unstake from an unexistent position.
This, in turn, will revert, as the onUnstaked(...) function will throw UnstakingAmountExceedsStakedAmount
error, and the whole unstaking flow will revert (but the stream cancelation will not). The canceled recipient's position in the FjordStaking
contract will remain intact, allowing the user to later unstake and claim rewards for the full amount of his/her stake, even though there are no active funds in his/her Sablier stream.
Canceled vested stakers will incorrectly receive rewards for staked funds that they do not own. What is worse they can also claim FjordPoints
which they can use in the auctions and benefit even more.
Alice and Bob have Sablier streams containing FjordTokens.
They both stake them in the FjordStaking
contract.
Alice's stream gets canceled shortly after the stake, as she gets fired.
The Fjord protocol tries to unstake Alice's position upon receiving the onStreamCanceled(...)
hook from Sablier, but fails to do so.
Alice patiently waits for the unstaking cycle, unstakes, and afterwards claims her rewards as if she owned the funds for the whole cycle.
I have created a separate testing suit, as the protocol team's one used improper Mocks, which do not invoke the erroneous flow described above
Manual review
Pass the streamOwner
address instead of the msg.sender
to the onUnstaked(...)
function in the FjordStaking.sol
contract:
Indeed the `points.onUnstaked` should use the streamOwner instead of msg.sender as an input parameter. Impact: high - The vested stakers who got their streams canceled will keep on receiving rewards (points included) for the previously staked stream. Likelihood: low - whenever a Sablier stream sender decides to `cancel()` a recipient's stream
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.