|
| 1 | +// SPDX-License-Identifier: UNLICENSED |
| 2 | +pragma solidity ^0.8.9; |
| 3 | + |
| 4 | +import "ds-test/test.sol"; |
| 5 | +import "./base/GovernorBaseTest.sol"; |
| 6 | +import "forge-std/console.sol"; |
| 7 | + |
| 8 | +import "contracts/bonding/BondingManager.sol"; |
| 9 | +import "contracts/rounds/RoundsManager.sol"; |
| 10 | +import "contracts/pm/TicketBroker.sol"; |
| 11 | +import "contracts/token/LivepeerToken.sol"; |
| 12 | + |
| 13 | +struct DelegatorData { |
| 14 | + uint256 bondedAmount; |
| 15 | + uint256 fees; |
| 16 | + address delegateAddress; |
| 17 | + uint256 delegatedAmount; |
| 18 | + uint256 startRound; |
| 19 | + uint256 lastClaimRound; |
| 20 | + uint256 nextUnbondingLockId; |
| 21 | +} |
| 22 | + |
| 23 | +struct TranscoderEarningsPoolData { |
| 24 | + uint256 totalStake; |
| 25 | + uint256 transcoderRewardCut; |
| 26 | + uint256 transcoderFeeShare; |
| 27 | + uint256 cumulativeRewardFactor; |
| 28 | + uint256 cumulativeFeeFactor; |
| 29 | +} |
| 30 | + |
| 31 | +struct TranscoderData { |
| 32 | + uint256 lastRewardRound; |
| 33 | + uint256 rewardCut; |
| 34 | + uint256 feeShare; |
| 35 | + uint256 lastActiveStakeUpdateRound; |
| 36 | + uint256 activationRound; |
| 37 | + uint256 deactivationRound; |
| 38 | + uint256 activeCumulativeRewards; |
| 39 | + uint256 cumulativeRewards; |
| 40 | + uint256 cumulativeFees; |
| 41 | + uint256 lastFeeRound; |
| 42 | +} |
| 43 | + |
| 44 | +interface IBondingManagerHelper { |
| 45 | + function getTranscoderEarningsPoolForRound(address _transcoder, uint256 _round) |
| 46 | + external |
| 47 | + view |
| 48 | + returns (TranscoderEarningsPoolData memory); |
| 49 | + |
| 50 | + function getTranscoder(address _transcoder) external view returns (TranscoderData memory); |
| 51 | + |
| 52 | + function getDelegator(address _delegator) external view returns (DelegatorData memory); |
| 53 | +} |
| 54 | + |
| 55 | +interface IRoundManager { |
| 56 | + function roundLength() external returns (uint256); |
| 57 | + |
| 58 | + function currentRound() external view returns (uint256); |
| 59 | + |
| 60 | + function initializeRound() external; |
| 61 | +} |
| 62 | + |
| 63 | +// forge test --match-contract BondingManagerFeeOverclaimFix --fork-url https://arbitrum-mainnet.infura.io/v3/$INFURA_KEY -vvv --fork-block-number 371152514 |
| 64 | +contract BondingManagerFeeOverclaimFix is GovernorBaseTest { |
| 65 | + LivepeerToken public constant TOKEN = LivepeerToken(0x289ba1701C2F088cf0faf8B3705246331cB8A839); |
| 66 | + IMinter public constant MINTER = IMinter(0xc20DE37170B45774e6CD3d2304017fc962f27252); |
| 67 | + BondingManager public constant BONDING_MANAGER = BondingManager(0x35Bcf3c30594191d53231E4FF333E8A770453e40); |
| 68 | + RoundsManager public constant ROUNDS_MANAGER = RoundsManager(0xdd6f56DcC28D3F5f27084381fE8Df634985cc39f); |
| 69 | + TicketBroker public constant TICKET_BROKER = TicketBroker(0xa8bB618B1520E284046F3dFc448851A1Ff26e41B); |
| 70 | + |
| 71 | + uint256 public constant ATTACKER_KEY = 3171; |
| 72 | + uint256 public constant TICKET_SENDER_KEY = 31337; |
| 73 | + |
| 74 | + uint256 public constant ATTACK_GRANULARITY = 1000 wei; |
| 75 | + |
| 76 | + uint256 roundLength; |
| 77 | + address ticketSender; |
| 78 | + |
| 79 | + bytes32 public constant BONDING_MANAGER_TARGET_ID = keccak256("BondingManagerTarget"); |
| 80 | + BondingManager public newBondingManagerTarget; |
| 81 | + |
| 82 | + address attacker; |
| 83 | + address someone; |
| 84 | + |
| 85 | + function setUp() public { |
| 86 | + CHEATS.roll(23198137); |
| 87 | + |
| 88 | + newBondingManagerTarget = new BondingManager(address(CONTROLLER)); |
| 89 | + (, gitCommitHash) = CONTROLLER.getContractInfo(BONDING_MANAGER_TARGET_ID); |
| 90 | + stageAndExecuteOne( |
| 91 | + address(CONTROLLER), |
| 92 | + 0, |
| 93 | + abi.encodeWithSelector( |
| 94 | + CONTROLLER.setContractInfo.selector, |
| 95 | + BONDING_MANAGER_TARGET_ID, |
| 96 | + address(newBondingManagerTarget), |
| 97 | + gitCommitHash |
| 98 | + ) |
| 99 | + ); |
| 100 | + |
| 101 | + attacker = CHEATS.addr(ATTACKER_KEY); |
| 102 | + someone = newAddr(); |
| 103 | + ticketSender = CHEATS.addr(TICKET_SENDER_KEY); |
| 104 | + roundLength = ROUNDS_MANAGER.roundLength(); |
| 105 | + |
| 106 | + // Fund the ticket sender on ticket broker |
| 107 | + payable(ticketSender).transfer(1 ether); |
| 108 | + CHEATS.prank(ticketSender); |
| 109 | + TICKET_BROKER.fundDeposit{ value: 1 ether }(); |
| 110 | + |
| 111 | + /// attacker need 2000 lpt to execute the attack |
| 112 | + CHEATS.startPrank(address(MINTER)); |
| 113 | + TOKEN.mint(attacker, 4000 * 1e18 + 2); |
| 114 | + TOKEN.mint(someone, 2000 * 1e18 + 2); |
| 115 | + CHEATS.stopPrank(); |
| 116 | + |
| 117 | + /// ---------------------- ROUND = 3902 ---------------------- |
| 118 | + _skipToNextRound(3902); |
| 119 | + |
| 120 | + /// hacker bond for themself to make their status in the next round become "Bonded" |
| 121 | + CHEATS.startPrank(attacker); |
| 122 | + TOKEN.approve(address(BONDING_MANAGER), type(uint256).max); |
| 123 | + BONDING_MANAGER.bond(1, attacker); |
| 124 | + BONDING_MANAGER.transcoder(1000000, 0); |
| 125 | + CHEATS.stopPrank(); |
| 126 | + |
| 127 | + CHEATS.startPrank(someone); |
| 128 | + TOKEN.approve(address(BONDING_MANAGER), type(uint256).max); |
| 129 | + BONDING_MANAGER.bond(1, someone); |
| 130 | + BONDING_MANAGER.transcoder(1000000, 1000000); |
| 131 | + CHEATS.stopPrank(); |
| 132 | + |
| 133 | + /// ---------------------- ROUND = 3903 ---------------------- |
| 134 | + _skipToNextRound(3903); |
| 135 | + |
| 136 | + // attacker bond more than the last transcoder and kick them out of the `transcoderPool` |
| 137 | + CHEATS.startPrank(attacker); |
| 138 | + BONDING_MANAGER.bond(1200 * 1e18, attacker); |
| 139 | + assertEq(attacker, _getLastTranscoder()); |
| 140 | + CHEATS.stopPrank(); |
| 141 | + |
| 142 | + // unbond all but 1 "wei" of LPT to inflate rewards to the maximum |
| 143 | + CHEATS.startPrank(attacker); |
| 144 | + BONDING_MANAGER.unbond(1200 * 1e18); |
| 145 | + assertEq(_getDelegatorData(attacker).delegatedAmount, 1); |
| 146 | + assertEq(attacker, _getLastTranscoder()); |
| 147 | + CHEATS.stopPrank(); |
| 148 | + |
| 149 | + /// ---------------------- ROUND = 3904 ---------------------- |
| 150 | + _skipToNextRound(3904); |
| 151 | + |
| 152 | + // someone bond more than the attacker to kick attacker out of the `transcoderPool` |
| 153 | + CHEATS.startPrank(someone); |
| 154 | + BONDING_MANAGER.bond(1200 * 1e18, someone); |
| 155 | + assertEq(someone, _getLastTranscoder()); |
| 156 | + CHEATS.stopPrank(); |
| 157 | + |
| 158 | + /// ---------------------- ROUND = 3905 ---------------------- |
| 159 | + _skipToNextRound(3905); |
| 160 | + |
| 161 | + // attacker is inactive in this round |
| 162 | + assertTrue(!BONDING_MANAGER.isActiveTranscoder(attacker)); |
| 163 | + |
| 164 | + uint256 inflateAmount = address(MINTER).balance / ATTACK_GRANULARITY; |
| 165 | + |
| 166 | + // attacker bond more tokens to themselves, but the lastActiveStakeUpdateRound remain unchange (3904) |
| 167 | + CHEATS.startPrank(attacker); |
| 168 | + BONDING_MANAGER.bond(inflateAmount - 1, attacker); |
| 169 | + CHEATS.stopPrank(); |
| 170 | + |
| 171 | + assertEq(someone, _getLastTranscoder()); |
| 172 | + assertEq(_getTranscoderData(attacker).lastActiveStakeUpdateRound, 3904); |
| 173 | + |
| 174 | + // the delegated amount is bigger than the lastActiveStakeUpdateRound's total stake now |
| 175 | + assertGt(_getDelegatorData(attacker).delegatedAmount, _getTransoderEarningPoolData(attacker, 3641).totalStake); |
| 176 | + } |
| 177 | + |
| 178 | + function test_feeOverclaimFix() public { |
| 179 | + /// ---------------------- ROUND = 3906 ---------------------- |
| 180 | + _skipToNextRound(3906); |
| 181 | + |
| 182 | + uint256 prevFees = _getDelegatorData(attacker).fees; |
| 183 | + assertEq(prevFees, 0); |
| 184 | + |
| 185 | + uint256 ticketAmount = ATTACK_GRANULARITY; |
| 186 | + |
| 187 | + // Sign the winning ticket to the attacker |
| 188 | + (MTicketBrokerCore.Ticket memory ticket, bytes memory signature, uint256 rand) = signWinningTicket( |
| 189 | + ticketSender, |
| 190 | + attacker, |
| 191 | + ticketAmount |
| 192 | + ); |
| 193 | + |
| 194 | + uint256 prevMinterBalance = address(MINTER).balance; |
| 195 | + |
| 196 | + CHEATS.startPrank(address(attacker)); |
| 197 | + |
| 198 | + CHEATS.expectRevert("transcoder must be active"); |
| 199 | + TICKET_BROKER.redeemWinningTicket(ticket, signature, rand); |
| 200 | + CHEATS.stopPrank(); |
| 201 | + |
| 202 | + uint256 receivedFees = _getDelegatorData(attacker).fees; |
| 203 | + console.log("received fees =", receivedFees); |
| 204 | + assertEq(receivedFees, 0); |
| 205 | + |
| 206 | + uint256 minterBalance = address(MINTER).balance; |
| 207 | + console.log("starting minter balance=", prevMinterBalance, "wei"); |
| 208 | + console.log("remaining minter balance=", minterBalance, "wei"); |
| 209 | + assertEq(prevMinterBalance, minterBalance); |
| 210 | + } |
| 211 | + |
| 212 | + function test_feeOverclaimFix_activating_transcoder() public { |
| 213 | + /// ---------------------- ROUND = 3906 ---------------------- |
| 214 | + _skipToNextRound(3906); |
| 215 | + |
| 216 | + // Become active again |
| 217 | + CHEATS.startPrank(attacker); |
| 218 | + BONDING_MANAGER.bond(1500 * 1e18, attacker); |
| 219 | + CHEATS.stopPrank(); |
| 220 | + |
| 221 | + assertEq(attacker, _getLastTranscoder()); |
| 222 | + assertTrue(!BONDING_MANAGER.isActiveTranscoder(attacker)); |
| 223 | + |
| 224 | + uint256 prevFees = _getDelegatorData(attacker).fees; |
| 225 | + assertEq(prevFees, 0); |
| 226 | + |
| 227 | + uint256 ticketAmount = ATTACK_GRANULARITY; |
| 228 | + |
| 229 | + // Sign the winning ticket to the attacker |
| 230 | + (MTicketBrokerCore.Ticket memory ticket, bytes memory signature, uint256 rand) = signWinningTicket( |
| 231 | + ticketSender, |
| 232 | + attacker, |
| 233 | + ticketAmount |
| 234 | + ); |
| 235 | + |
| 236 | + CHEATS.startPrank(address(attacker)); |
| 237 | + |
| 238 | + CHEATS.expectRevert("transcoder must be active"); |
| 239 | + TICKET_BROKER.redeemWinningTicket(ticket, signature, rand); |
| 240 | + CHEATS.stopPrank(); |
| 241 | + |
| 242 | + uint256 receivedFees = _getDelegatorData(attacker).fees; |
| 243 | + console.log("received fees =", receivedFees); |
| 244 | + assertEq(receivedFees, 0); |
| 245 | + } |
| 246 | + |
| 247 | + function test_feeOverclaimFix_reactivated_transcoder() public { |
| 248 | + /// ---------------------- ROUND = 3906 ---------------------- |
| 249 | + _skipToNextRound(3906); |
| 250 | + |
| 251 | + // Become active again |
| 252 | + CHEATS.startPrank(attacker); |
| 253 | + BONDING_MANAGER.bond(1500 * 1e18, attacker); |
| 254 | + CHEATS.stopPrank(); |
| 255 | + |
| 256 | + assertEq(attacker, _getLastTranscoder()); |
| 257 | + assertTrue(!BONDING_MANAGER.isActiveTranscoder(attacker)); |
| 258 | + |
| 259 | + /// ---------------------- ROUND = 3907 ---------------------- |
| 260 | + _skipToNextRound(3907); |
| 261 | + assertTrue(BONDING_MANAGER.isActiveTranscoder(attacker)); |
| 262 | + |
| 263 | + uint256 prevFees = _getDelegatorData(attacker).fees; |
| 264 | + assertEq(prevFees, 0); |
| 265 | + |
| 266 | + uint256 ticketAmount = ATTACK_GRANULARITY; |
| 267 | + |
| 268 | + // Sign the winning ticket to the attacker |
| 269 | + (MTicketBrokerCore.Ticket memory ticket, bytes memory signature, uint256 rand) = signWinningTicket( |
| 270 | + ticketSender, |
| 271 | + attacker, |
| 272 | + ticketAmount |
| 273 | + ); |
| 274 | + |
| 275 | + uint256 prevMinterBalance = address(MINTER).balance; |
| 276 | + |
| 277 | + CHEATS.startPrank(address(attacker)); |
| 278 | + |
| 279 | + TICKET_BROKER.redeemWinningTicket(ticket, signature, rand); |
| 280 | + BONDING_MANAGER.claimEarnings(ROUNDS_MANAGER.currentRound()); |
| 281 | + uint256 fees = _getDelegatorData(attacker).fees; |
| 282 | + uint256 receivedFees = fees - prevFees; |
| 283 | + BONDING_MANAGER.withdrawFees(payable(attacker), receivedFees); |
| 284 | + |
| 285 | + CHEATS.stopPrank(); |
| 286 | + |
| 287 | + // Ticket redemption works in this case, but the value should be exactly the ticket amount |
| 288 | + console.log("received fees =", receivedFees); |
| 289 | + assertEq(receivedFees, ticketAmount); |
| 290 | + |
| 291 | + uint256 minterBalance = address(MINTER).balance; |
| 292 | + console.log("starting minter balance=", prevMinterBalance, "wei"); |
| 293 | + console.log("remaining minter balance=", minterBalance, "wei"); |
| 294 | + assertEq(prevMinterBalance - minterBalance, ticketAmount); |
| 295 | + } |
| 296 | + |
| 297 | + receive() external payable {} |
| 298 | + |
| 299 | + function signWinningTicket( |
| 300 | + address sender, |
| 301 | + address recipient, |
| 302 | + uint256 faceValue |
| 303 | + ) |
| 304 | + public |
| 305 | + returns ( |
| 306 | + MTicketBrokerCore.Ticket memory ticket, |
| 307 | + bytes memory sig, |
| 308 | + uint256 rand |
| 309 | + ) |
| 310 | + { |
| 311 | + // Prepare a always-winning ticket of 1 ETH to the main attacker contract |
| 312 | + ticket = MTicketBrokerCore.Ticket({ |
| 313 | + recipient: recipient, |
| 314 | + sender: sender, |
| 315 | + faceValue: faceValue, |
| 316 | + winProb: type(uint256).max, |
| 317 | + senderNonce: 1, |
| 318 | + recipientRandHash: keccak256(abi.encodePacked(uint256(1337))), |
| 319 | + auxData: abi.encodePacked( |
| 320 | + ROUNDS_MANAGER.currentRound(), |
| 321 | + ROUNDS_MANAGER.blockHashForRound(ROUNDS_MANAGER.currentRound()) |
| 322 | + ) |
| 323 | + }); |
| 324 | + |
| 325 | + // Sign it |
| 326 | + bytes32 ticketHash = keccak256( |
| 327 | + abi.encodePacked( |
| 328 | + ticket.recipient, |
| 329 | + ticket.sender, |
| 330 | + ticket.faceValue, |
| 331 | + ticket.winProb, |
| 332 | + ticket.senderNonce, |
| 333 | + ticket.recipientRandHash, |
| 334 | + ticket.auxData |
| 335 | + ) |
| 336 | + ); |
| 337 | + bytes32 signHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", ticketHash)); |
| 338 | + (uint8 v, bytes32 r, bytes32 s) = CHEATS.sign(TICKET_SENDER_KEY, signHash); |
| 339 | + |
| 340 | + return (ticket, abi.encodePacked(r, s, v), 1337); |
| 341 | + } |
| 342 | + |
| 343 | + /// ------------------ HELPER FUNCTION ------------------ |
| 344 | + |
| 345 | + function _skipToNextRound(uint256 expectedRound) internal { |
| 346 | + CHEATS.roll(block.number + roundLength); |
| 347 | + ROUNDS_MANAGER.initializeRound(); |
| 348 | + |
| 349 | + uint256 currentRound = ROUNDS_MANAGER.currentRound(); |
| 350 | + assertEq(currentRound, expectedRound); |
| 351 | + console.log("\n---------------------- ROUND = %s ----------------------", currentRound); |
| 352 | + } |
| 353 | + |
| 354 | + function _getDelegatorData(address del) internal view returns (DelegatorData memory) { |
| 355 | + return IBondingManagerHelper(address(BONDING_MANAGER)).getDelegator(del); |
| 356 | + } |
| 357 | + |
| 358 | + function _getTransoderEarningPoolData(address del, uint256 round) |
| 359 | + internal |
| 360 | + view |
| 361 | + returns (TranscoderEarningsPoolData memory) |
| 362 | + { |
| 363 | + return IBondingManagerHelper(address(BONDING_MANAGER)).getTranscoderEarningsPoolForRound(del, round); |
| 364 | + } |
| 365 | + |
| 366 | + function _getTranscoderData(address del) internal view returns (TranscoderData memory) { |
| 367 | + return IBondingManagerHelper(address(BONDING_MANAGER)).getTranscoder(del); |
| 368 | + } |
| 369 | + |
| 370 | + function _getTranscoderAtIndex(uint256 index) internal view returns (address lastTranscoder) { |
| 371 | + lastTranscoder = BONDING_MANAGER.getFirstTranscoderInPool(); |
| 372 | + for (uint256 i = 1; i < index; ++i) { |
| 373 | + lastTranscoder = BONDING_MANAGER.getNextTranscoderInPool(lastTranscoder); |
| 374 | + } |
| 375 | + } |
| 376 | + |
| 377 | + function _getLastTranscoder() internal view returns (address lastTranscoder) { |
| 378 | + return _getTranscoderAtIndex(100); |
| 379 | + } |
| 380 | +} |
0 commit comments