VotingEscrow::_findWhatCheckpointToWrite returns an incorrect index in some cases since Checkpoint timestamp is never set
Summary
Users can delegate their veNFT to anyone, which transfers the balance of their locked veNFT amount to them. This is done via a call to _moveTokenDelegates.
_moveTokenDelegates utilizes another function _findWhatCheckpointToWrite, which determines the index of the checkpoint to update. Each checkpoint stores the state of the user's veNFTs at that point in time.
_findWhatCheckpointToWrite is supposed to return the index of the previous checkpoint index when the checkpoint timestamp is equal to block.timestamp.
However, the checkpoint timestamp is never initialized, causing _findWhatCheckpointToWrite to incorrectly return a new index each time. This will impact all transfer/mint/burn/delegate calls.
Vulnerability Detail
The following function is executed when NFTs are delegated:
We can see if the latest checkpoint's timestamp is the same as the current block.timestamp, then the old checkpoint index is returned. However, the timestamp for checkpoints are never updated, therefore this will always return a new checkpoint index instead of the old one.
Proof of Concept
Coded PoC
Accessing the `checkpoints` mapping caused some issues, so to avoid them I added the following to `VotingEscrow`:
```javascript
function getCheckpoint(address owner, uint32 index) public view returns (Checkpoint memory) {
return checkpoints[owner][index];
}
```
Add the following to `test/VotingEscrow.t.sol` and run `forge test --mt testTimestampNotSet -vv`
```javascript
function testTimestampNotSet() public {
uint256 maxtime = 52 * 7 * 24 * 3600; // 52 weeks
FLOW.mint(address(owner), TOKEN_1M);
DAI.mint(address(owner), TOKEN_1M);
FLOW.approve(address(router), TOKEN_1M);
DAI.approve(address(router), TOKEN_1M);
router.addLiquidity(address(FLOW), address(DAI), false, TOKEN_1M, TOKEN_1M, 0, 0, address(owner), block.timestamp);
address alice = vm.addr(1);
vm.startPrank(address(owner));
flowDaiPair.transfer(alice, 2 ether);
flowDaiPair.approve(address(escrow), type(uint256).max);
uint tokenId = escrow.create_lock(TOKEN_1, maxtime);
vm.roll(block.number + 1);
vm.warp(block.timestamp + 2);
vm.stopPrank();
vm.startPrank(address(alice));
flowDaiPair.approve(address(escrow), type(uint256).max);
uint tokenId2 = escrow.create_lock(1 ether, maxtime);
vm.roll(block.number + 1);
vm.warp(block.timestamp + 2);
uint tokenId3 = escrow.create_lock(1 ether, maxtime);
vm.roll(block.number + 1);
vm.warp(block.timestamp + 2);
escrow.approve(address(owner), tokenId2);
escrow.approve(address(owner), tokenId3);
vm.stopPrank();
escrow.transferFrom(address(alice), address(owner), tokenId2);
vm.warp(block.timestamp + 2);
uint32 nCheckpoints = escrow.numCheckpoints(address(owner));
VotingEscrow.Checkpoint memory cp = escrow.getCheckpoint(address(owner), nCheckpoints - 1);
console.log("Current block.timestamp: ", uint(block.timestamp));
console.log("Timestamp of checkpoint: ", cp.timestamp);
}
```
Console Output
Running 1 test for test/VotingEscrow.t.sol:VotingEscrowTest
[PASS] testTimestampNotSet() (gas: 1596642)
Logs:
Current block.timestamp: 9
Timestamp of checkpoint: 0
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.25ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Impact
Incorrect checkpoint when previous checkpoint == block.timestamp, impacting transfer/mint/burn/delegate functions and possibly storing incorrect voting power (i.e., lower power) for specific checkpoints.
cryptic
Medium
VotingEscrow::_findWhatCheckpointToWrite
returns an incorrect index in some cases sinceCheckpoint timestamp
is never setSummary
Users can delegate their
veNFT
to anyone, which transfers the balance of their lockedveNFT
amount to them. This is done via a call to_moveTokenDelegates
._moveTokenDelegates
utilizes another function_findWhatCheckpointToWrite
, which determines the index of thecheckpoint
to update. Eachcheckpoint
stores the state of the user'sveNFTs
at that point in time._findWhatCheckpointToWrite
is supposed to return the index of the previouscheckpoint index
when thecheckpoint timestamp
is equal toblock.timestamp
.However, the
checkpoint timestamp
is never initialized, causing_findWhatCheckpointToWrite
to incorrectly return a new index each time. This will impact all transfer/mint/burn/delegate calls.Vulnerability Detail
The following function is executed when NFTs are delegated:
VotingEscrow.sol#L1362-L1411
VotingEscrow.sol#L1413-L1429
We can see if the latest checkpoint's timestamp is the same as the current
block.timestamp
, then the old checkpoint index is returned. However, thetimestamp
for checkpoints are never updated, therefore this will always return a new checkpoint index instead of the old one.Proof of Concept
Coded PoC
Accessing the `checkpoints` mapping caused some issues, so to avoid them I added the following to `VotingEscrow`: ```javascript function getCheckpoint(address owner, uint32 index) public view returns (Checkpoint memory) { return checkpoints[owner][index]; } ``` Add the following to `test/VotingEscrow.t.sol` and run `forge test --mt testTimestampNotSet -vv` ```javascript function testTimestampNotSet() public { uint256 maxtime = 52 * 7 * 24 * 3600; // 52 weeks FLOW.mint(address(owner), TOKEN_1M); DAI.mint(address(owner), TOKEN_1M); FLOW.approve(address(router), TOKEN_1M); DAI.approve(address(router), TOKEN_1M); router.addLiquidity(address(FLOW), address(DAI), false, TOKEN_1M, TOKEN_1M, 0, 0, address(owner), block.timestamp); address alice = vm.addr(1); vm.startPrank(address(owner)); flowDaiPair.transfer(alice, 2 ether); flowDaiPair.approve(address(escrow), type(uint256).max); uint tokenId = escrow.create_lock(TOKEN_1, maxtime); vm.roll(block.number + 1); vm.warp(block.timestamp + 2); vm.stopPrank(); vm.startPrank(address(alice)); flowDaiPair.approve(address(escrow), type(uint256).max); uint tokenId2 = escrow.create_lock(1 ether, maxtime); vm.roll(block.number + 1); vm.warp(block.timestamp + 2); uint tokenId3 = escrow.create_lock(1 ether, maxtime); vm.roll(block.number + 1); vm.warp(block.timestamp + 2); escrow.approve(address(owner), tokenId2); escrow.approve(address(owner), tokenId3); vm.stopPrank(); escrow.transferFrom(address(alice), address(owner), tokenId2); vm.warp(block.timestamp + 2); uint32 nCheckpoints = escrow.numCheckpoints(address(owner)); VotingEscrow.Checkpoint memory cp = escrow.getCheckpoint(address(owner), nCheckpoints - 1); console.log("Current block.timestamp: ", uint(block.timestamp)); console.log("Timestamp of checkpoint: ", cp.timestamp); } ```
Console Output
Impact
Incorrect checkpoint when previous checkpoint == block.timestamp, impacting transfer/mint/burn/delegate functions and possibly storing incorrect voting power (i.e., lower power) for specific checkpoints.
Code Snippet
https://github.com/sherlock-audit/2024-06-velocimeter/blob/main/v4-contracts/contracts/VotingEscrow.sol#L1362-L1411
https://github.com/sherlock-audit/2024-06-velocimeter/blob/main/v4-contracts/contracts/VotingEscrow.sol#L1413-L1429
Tool used
Manual Review
Recommendation
Update the timestamp of the checkpoint when it is updated.
Duplicate of #288