code-423n4 / 2024-01-salty-findings

11 stars 6 forks source link

0.03% of the total supply of SALT can get stuck forever in the Airdrop contract #554

Closed c4-bot-6 closed 9 months ago

c4-bot-6 commented 9 months ago

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/launch/Airdrop.sol#L74-L85

Vulnerability details

Impact

0.03% of the total supply of SALT can get stuck in the Airdrop contract at launch when Upkeep.performUpkeep() is called before claiming an airdrop using Airdrop.claimAirdrop(). This is because the first user in the staking pool gets all the SALT rewards up to that point (which at launch will include the 1% of 3,000,000 SALT sent initially from InitialDistribution) and the Airdrop contract stakes SALT for users before transferring it to them, in the process claiming the SALT rewards.

Proof of Concept

This is the Airdrop.claimAirdrop() function:

function claimAirdrop() external nonReentrant
  {
  require( claimingAllowed, "Claiming is not allowed yet" );
  require( isAuthorized(msg.sender), "Wallet is not authorized for airdrop" );
  require( ! claimed[msg.sender], "Wallet already claimed the airdrop" );

// Have the Airdrop contract stake a specified amount of SALT and then transfer it to the user
staking.stakeSALT( saltAmountForEachUser );
staking.transferStakedSaltFromAirdropToUser( msg.sender, saltAmountForEachUser );

  claimed[msg.sender] = true;
  }

As can be seen, to distribute the airdrop to participants the Airdrop contract first stakes the SALT and then transfers that stake to the airdrop claimer. However, in the process because the first staker gets all the rewards up to that point (highlighted in one of my other issues) when Upkeep.performUpkeep() is called before users start claiming their airdrop, the Airdrop contract will unwillingly claim the rewards (about 1% of 3,000,000 SALT) which will get stuck in the contract forever.

Clone the github repo and run forge build then paste the following test file in /src/scenario_tests/ and run forge test --mt test_zeroPointZeroThreePercentOfTotalSupplyStuckInAidropPOC:

POC Test File ```solidity // SPDX-License-Identifier: UNLICENSED pragma solidity =0.8.22; import "../dev/Deployment.sol"; import "forge-std/Test.sol"; import "forge-std/console.sol"; import "../rewards/RewardsConfig.sol"; import "../staking/StakingConfig.sol"; import "../price_feed/tests/ForcedPriceFeed.sol"; contract MockAccessManager { function walletHasAccess(address wallet) external pure returns (bool) { return wallet == wallet; } } contract MockInitialDistribution { address public bootstrapBallot; constructor(address _bootstrapBallot) { bootstrapBallot = _bootstrapBallot; } } contract ZeroPointZeroThreePercentOfTotalSupplyStuckInAidropPOC is Test { using SafeERC20 for ISalt; using SafeERC20 for IERC20; IExchangeConfig public exchangeConfig; IBootstrapBallot public bootstrapBallot; IAirdrop public airdrop; IStaking public staking; IDAO public dao; ILiquidizer public liquidizer; IPoolsConfig public poolsConfig; IStakingConfig public stakingConfig; IRewardsConfig public rewardsConfig; IStableConfig public stableConfig; ISaltRewards public saltRewards; IPools public pools; IInitialDistribution public initialDistribution; IRewardsEmitter public stakingRewardsEmitter; IRewardsEmitter public liquidityRewardsEmitter; IEmissions public emissions; ISalt public salt; IERC20 public dai; USDS public usds; IERC20 public wbtc; IERC20 public weth; CollateralAndLiquidity public collateralAndLiquidity; MockAccessManager public accessManager; IForcedPriceFeed public priceFeed1; IForcedPriceFeed public priceFeed2; IForcedPriceFeed public priceFeed3; IPriceAggregator public priceAggregator; IUpkeep public upkeep; IDAOConfig public daoConfig; bytes aliceVotingSignature = hex"291f777bcf554105b4067f14d2bb3da27f778af49fe2f008e718328a91cae2f81eceb0b4ed1d65c546bf0603c6c35567a69c8cb371cf4880a2964df8f6d1c0601c"; bytes bobVotingSignature = hex"a08a0612b60d9c911d357664de578cd8e17c5f0ee10b82b829e35a999fa3f5e11a33e5f3d06c6b2b6f3ef3066cee3b47285a57cfc85f2c3e166f831a285aebcd1c"; address alice = address(0x1111); address bob = address(0x2222); uint256 constant public MILLION_ETHER = 1000000 ether; function setUp() public { vm.startPrank(address(1)); priceFeed1 = new ForcedPriceFeed(30000 ether, 3000 ether); priceFeed2 = new ForcedPriceFeed(30100 ether, 3050 ether); priceFeed3 = new ForcedPriceFeed(30500 ether, 3010 ether); priceAggregator = new PriceAggregator(); priceAggregator.setInitialFeeds( IPriceFeed(address(priceFeed1)), IPriceFeed(address(priceFeed2)), IPriceFeed(address(priceFeed3)) ); salt = new Salt(); dai = new TestERC20("DAI", 18); weth = new TestERC20("WETH", 18); wbtc = new TestERC20("WBTC", 8); usds = new USDS(); rewardsConfig = new RewardsConfig(); poolsConfig = new PoolsConfig(); stakingConfig = new StakingConfig(); stableConfig = new StableConfig(); daoConfig = new DAOConfig(); exchangeConfig = new ExchangeConfig( salt, wbtc, weth, dai, usds, IManagedWallet(address(0)) ); liquidizer = new Liquidizer(exchangeConfig, poolsConfig); accessManager = new MockAccessManager(); exchangeConfig.setAccessManager(IAccessManager(address(accessManager))); collateralAndLiquidity = new CollateralAndLiquidity( pools, exchangeConfig, poolsConfig, stakingConfig, stableConfig, priceAggregator, liquidizer ); liquidityRewardsEmitter = new RewardsEmitter( collateralAndLiquidity, exchangeConfig, poolsConfig, rewardsConfig, true ); pools = new Pools(exchangeConfig, poolsConfig); staking = new Staking( exchangeConfig, poolsConfig, stakingConfig ); stakingRewardsEmitter = new RewardsEmitter( staking, exchangeConfig, poolsConfig, rewardsConfig, false ); saltRewards = new SaltRewards( stakingRewardsEmitter, liquidityRewardsEmitter, exchangeConfig, rewardsConfig ); airdrop = new Airdrop(exchangeConfig, staking); bootstrapBallot = new BootstrapBallot(exchangeConfig, airdrop, 60 * 60 * 24 * 5 ); initialDistribution = new InitialDistribution(salt, poolsConfig, IEmissions(makeAddr("emissions")), bootstrapBallot, dao, VestingWallet(payable(makeAddr("daoVestingWallet"))), VestingWallet(payable(makeAddr("teamVestingWallet"))), airdrop, saltRewards, ICollateralAndLiquidity(address(0))); poolsConfig.whitelistPool(pools, salt, wbtc); poolsConfig.whitelistPool(pools, salt, weth); poolsConfig.whitelistPool(pools, salt, usds); poolsConfig.whitelistPool(pools, wbtc, usds); poolsConfig.whitelistPool(pools, weth, usds); poolsConfig.whitelistPool(pools, wbtc, dai); poolsConfig.whitelistPool(pools, weth, dai); poolsConfig.whitelistPool(pools, usds, dai); poolsConfig.whitelistPool(pools, wbtc, weth); usds.setCollateralAndLiquidity(collateralAndLiquidity); dao = new DAO( pools, IProposals(address(0)), exchangeConfig, poolsConfig, IStakingConfig(address(0)), IRewardsConfig(address(0)), IStableConfig(address(0)), IDAOConfig(address(0)), IPriceAggregator(address(0)), liquidityRewardsEmitter, ICollateralAndLiquidity(address(collateralAndLiquidity)) ); pools.setContracts( dao, collateralAndLiquidity ); liquidizer.setContracts(collateralAndLiquidity, pools, dao); vm.stopPrank(); vm.startPrank(address(1)); upkeep = new Upkeep(pools, exchangeConfig, poolsConfig, daoConfig, stableConfig, priceAggregator, saltRewards, collateralAndLiquidity, emissions, dao); salt.transfer(address(initialDistribution), 100 * MILLION_ETHER); exchangeConfig.setContracts( dao, upkeep, initialDistribution, airdrop, VestingWallet(payable(address(0))), VestingWallet(payable(address(0))) ); vm.stopPrank(); bytes memory sig1 = abi.encodePacked(aliceVotingSignature); vm.startPrank(alice); vm.chainId(11155111); bootstrapBallot.vote(true, sig1); vm.stopPrank(); bytes memory sig2 = abi.encodePacked(bobVotingSignature); vm.startPrank(bob); vm.chainId(11155111); bootstrapBallot.vote(true, sig2); vm.stopPrank(); // Increase current blocktime to be greater than completionTimestamp vm.warp( bootstrapBallot.completionTimestamp() + 1); // Call finalizeBallot() bootstrapBallot.finalizeBallot(); assert(bootstrapBallot.ballotFinalized() == true); } function test_zeroPointZeroThreePercentOfTotalSupplyStuckInAidropPOC() external { vm.prank(makeAddr("frontrunner")); upkeep.performUpkeep(); vm.prank(alice); airdrop.claimAirdrop(); vm.prank(bob); airdrop.claimAirdrop(); assert(salt.balanceOf(address(airdrop)) == ((1 * 3000000 ether) / 100)); } } ```

As can be seen, as a result of calling Upkeep.performUpkeep() before users start claiming their airdrop 30,000 SALT gets stuck in the Airdrop contract.

Tools Used

Manual Review.

Recommended Mitigation Steps

A potential mitigation for this issue is to have an additional function in the Airdrop contract which only after everyone has claimed, is the owner of the contract able to withdraw the excess tokens that are left in the contract.

Assessed type

Token-Transfer

c4-judge commented 9 months ago

Picodes marked the issue as duplicate of #986

c4-judge commented 8 months ago

Picodes changed the severity to QA (Quality Assurance)