Summary
The Fjord Staking contract does not correctly process the cancellation of Sablier vesting NFTs, enabling users to indefinitely earn rewards on refunded assets from canceled streams. This occurs due to a silent revert in the unstaking process, caused by an incorrect call to FjordPoints.onUnstake, allowing rewards to accumulate on funds that have already been refunded.
Vulnerability Details
The stakeVested function in the Fjord Staking contract allows users to stake their Sablier vesting NFTs, which hold FJORD tokens, to earn rewards. When a stream is canceled, the onStreamCanceled hook is supposed to handle the cancellation by unstaking the refunded assets to stream sender (the unstreamed amount). However, the current implementation always reverts silently due to an incorrect call to FjordPoints within _unstakeVested execution.
function onStreamCanceled(uint256 streamId, address sender, uint128 senderAmount, uint128
) external override onlySablier checkEpochRollover {
uint256 amount = uint256(senderAmount) > nftData.amount ? nftData.amount : uint256(senderAmount);
>>> _unstakeVested(streamOwner, streamId, amount);
emit SablierCanceled(streamOwner, streamId, sender, amount);
}
After the onStreamCanceled function handles necessary checks, it calls _unstakeVested to unstake the amount of assets being refunded to the stream's sender and reduct that amount from the staked balance. The function _unstakeVested calls points.onUnstaked, which triggers an underflow revert due to an incorrect msg.sender being passed, as shown below:
function _unstakeVested(address streamOwner, uint256 _streamID, uint256 amount) internal {
>>> points.onUnstaked(msg.sender, amount);
emit VestedUnstaked(streamOwner, epoch, amount, _streamID);
}
The msg.sender passed to points.onUnstaked refers to Sablier and this is incorrect because the original staker was not the Sablier contract. Since the Sablier contract does not have any stakes, the call reverts with UnstakingAmountExceedsStakedAmount:
function onUnstaked(address user, uint256 amount) external onlyStaking checkDistribution updatePendingPoints(user) {
UserInfo storage userInfo = users[user];
if (amount > userInfo.stakedAmount) {
>>> revert UnstakingAmountExceedsStakedAmount();
}
}
However, the transaction as a whole does not revert because the Sablier version used (v1.0.2) gracefully handles hook failures
function _cancel(uint256 streamId) internal override {
if (msg.sender == sender) {
if (recipient.code.length > 0) {
try ISablierV2LockupRecipient(recipient).onStreamCanceled({
streamId: streamId,
sender: sender,
senderAmount: senderAmount,
recipientAmount: recipientAmount
>>> }) { } catch { }
}
}
}
As a result, the stream's sender successfully cancels the stream and is refunded, while the staker (recipient) continues earning rewards for the refunded amount indefinitely because his stakes states were not updated.
Proof Of Concept
The following test case demonstrates how canceling streams will always silently revert the unstaking process on the protocol. Copy and paste the following test case into test/unit/stakeVested.t.sol:
function test_cancelStreamsNotHandledProperly() public {
FjordPoints fjordPoints = new FjordPoints();
fjordStaking = new FjordStaking(address(token), address(minter), address(SABLIER), address(this), address(fjordPoints));
fjordPoints.setStakingContract(address(fjordStaking));
vm.prank(minter);
token.approve(address(fjordStaking), 100 ether);
uint amount = 1000 ether;
deal(address(token), address(this), amount);
token.approve(address(SABLIER), amount);
LockupLinear.CreateWithRange memory params;
params.sender = address(this);
params.recipient = alice;
params.totalAmount = uint128(amount);
params.asset = IERC20(address(token));
params.cancelable = true;
params.range = LockupLinear.Range({
start: uint40(vm.getBlockTimestamp()),
cliff: uint40(vm.getBlockTimestamp()),
end: uint40(vm.getBlockTimestamp() + 20 days)
});
params.broker = Broker(address(0), ud60x18(0));
uint streamID = SABLIER.createWithRange(params);
vm.startPrank(alice);
SABLIER.approve(address(fjordStaking), streamID);
fjordStaking.stakeVested(streamID);
vm.stopPrank();
vm.prank(address(this));
SABLIER.cancel(streamID);
_addRewardAndEpochRollover(10 ether, 3);
vm.startPrank(alice);
fjordStaking.claimReward(false);
(,uint256 claimAmount) = fjordStaking.claimReceipts(address(alice));
assertEq(30 ether, claimAmount);
_addRewardAndEpochRollover(10 ether, 4);
vm.startPrank(alice);
fjordStaking.completeClaimRequest();
fjordStaking.claimReward(false);
(, claimAmount) = fjordStaking.claimReceipts(address(alice));
assertEq(40 ether, claimAmount);
}
Note: The contracts were reinitialized at the start of the test case because FjordStakingBase only mocks calls to the staking contract
To see the silent function revert trace, run the test with forge test -vvvv --mt test_cancelStreamsNotHandledProperly:
[45799] FjordStaking::onStreamCanceled(1402, StakeVestedTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1000000000000000000000 [1e21], 0)
│ │ ├─ [24515] 0xB10daee1FCF62243aE27776D7a92D39dC8740f95::transferFrom(FjordStaking: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 1402)
│ │ │ ├─ emit Transfer(from: FjordStaking: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], to: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], tokenId: 1402)
│ │ │ └─ ← ()
>>> │ │ ├─ [8365] FjordPoints::onUnstaked(0xB10daee1FCF62243aE27776D7a92D39dC8740f95, 1000000000000000000000 [1e21])
>>> │ │ │ └─ ← UnstakingAmountExceedsStakedAmount()
│ │ └─ ← UnstakingAmountExceedsStakedAmount()
│ ├─ emit CancelLockupStream(streamId: 1402, sender: StakeVestedTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], recipient: FjordStaking: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], senderAmount: 1000000000000000000000 [1e21], recipientAmount: 0)
Impact
Canceled streams are not handled correctly, allowing stakers to indefinitely earn rewards on refunded assets of canceled Sablier NFTs
Tools Used
Manual Review
Recommendations
Modify the _unstakeVested function to pass the streamOwner to the points.onUnstake instead of msg.sender:
function _unstakeVested(address streamOwner, uint256 _streamID, uint256 amount) internal {
// --SNIP
- points.onUnstaked(msg.sender, amount);
+ points.onUnstaked(streamOwner, amount);
emit VestedUnstaked(streamOwner, epoch, amount, _streamID);
}