CertiK reviewed Hawksight on 28 Sep 2022: 30 findings, 0 critical; 25 marked fixed, 5 open.
Report hosted on skynet.certik.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 2 | 0 | 2 |
| Medium | 0 | 0 | 3 |
| Low | 3 | 0 | 20 |
The `create_state` function is designed to initialize a globally unique `state` account. This `state` account will be used to identify the project owner and to ensure that only the owner can create a `strategy` and set the `token_per_second` of the farm. ```rust pub struct CreateState<'info> { #[account( init, seeds = [b"state".as_ref()], bump, payer = authority, space = 8 + size_of::<StateAccount>() )] pub state: Account<'info, StateAccount>, ... } ``` ```rust pub fn create_state(&mut self, bump: u8) -> Result<()> { let state = &mut self.state; state.authority = self.authority.key(); state.bump = bump; state.reward_mint = self.reward_mint.key(); state.reward_vault = self.reward_vault.key(); emit!(StateCreated {}); Ok(()) } ``` Any given program_id can only have one `state` account. This may allow malicious users to pre-empt the creation of `state` by posting `create_state` transactions first, thus making the program unavailable. Additionally, if a malicious user manages to call `create_state` prior to admin of the program, the malicious attacker would then have a parallel running program who's state is controlled by them. The user could use this to impersonate the real program to exploit users.
The unit tests here are not enough. Testing programs are a very important aspect of proving program correctness, preventing regressions, and release engineering. Without tests, there is no way to know if the program works as expected. The fact that code was shipped for review with such major flaw, is considered a signal of some problems with release engineering procedures, which may lead to shipping not intended changes to production.
In the `farm` account, the `asset_infos` array is initialized by `weights` and `remaining_accounts`. - `mints` vector is initialized by iterating over `ctx.remaining_accounts`. ```rust for mint in _ctx.remaining_accounts.iter(){ require!(*mint.owner == token::ID, ErrorCode::InvalidMint); Mint::unpack(&mint.to_account_info().try_borrow_data()?)?; mints.push(mint.key()); } ``` - `asset_size` is defined by the length of `weights`. ```rust let asset_size = weights.len(); let asset_count = asset_size.try_into().unwrap(); ``` - `mints` is used to calculate the `farm`'s `seed`. ```rust for mint in mints.iter() { seeds.push(mint.as_ref()); } ``` - `farm.asset_infos` is filled by iterating over the elements of `mints` and `weights` up to `asset_size`. ```rust for i in 0 .. asset_size{ total_weight += u128::from(weights[i]); farm.asset_infos.push(FarmAssetInfo{ weight: weights[i], mint: mints[i] }) } ``` However, the length of `weights` and `remaining_accounts`/`mints` are not checked during this initialization process. This makes it possible that the farm's `seed` and its `asset_infos` to not match, making the service unusable.
The function `change_token_per_second` updates the farms `token_per_second` parameter. The function provides no validation for when the function should be called. ```rust impl<'info> ChangeTokenPerSecondMulti<'info>{ pub fn change_token_per_second(&mut self, token_per_seconds: Vec<u128> ) -> Result<()> { let farm = &mut self.farm; let farm_key = farm.key(); farm.validate_address(farm_key)?; let asset_count:u8 = token_per_seconds.len().try_into().unwrap(); require!( asset_count == farm.asset_count, ErrorCode::InvalidAssetCount); let mut index:usize = 0; for token_per_second in token_per_seconds.iter() { farm.reward_infos[index].token_per_second = *token_per_second; index += 1; } Ok(()) } } ``` In the `state.rs`, the `update()` function will calculate the rewards that have been generated for a farm. ```rust pub fn update<'info>(&mut self, clock: &Sysvar<'info, Clock>) -> Result<()> { let seconds = u128::try_from( clock .unix_timestamp .checked_sub(self.last_reward_time) .unwrap(), ) .unwrap(); let mut reward_per_share: u128 = 0; if self.amount > 0 && seconds > 0 { reward_per_share = u128::from(self.token_per_second) .checked_mul(seconds) .unwrap() .checked_mul(ACC_PRECISION) .unwrap() .checked_div(u128::from(self.amount)) .unwrap(); } self.acc_reward_per_share = self .acc_reward_per_share .checked_add(reward_per_share) .unwrap(); self.last_reward_time = clock.unix_timestamp; Ok(()) } ``` Neither `update` nor `change_token_per_second` have any validation for _when_ `token_per_second` should be changed. In practice, this could mean someone calling `change_token_per_second` prior to when they are supposed to, which would result in unexpected results when calling `update`. The logic of when `token_per_second` should change is totally off-chain and in the hands of the owner, without any validation. Both human error and malicious intent are risks with the current architecture of this function.
Note that with the `UST` price now below $0.05, and no longer has the role of a stable coin. This may easily affect functionality of UST in the project. ```rust pub const UST_MAINNET: Pubkey = static_pubkey::static_pubkey!("9vMJfxuKxXBoEa7rM12mYLMwTacLMLDJqHozw96WQL8i"); pub const STABLE_MINTS: [Pubkey; 2] = [USDC_MAINNET, UST_MAINNET]; ```
After withdrawing reward, the user's `last_action` is set as `FinishAction`, however the users `last_amount` is not reset to 0 after processing. For comparison, `stake_to_farm()` and `redeem_stable_token()`, after a `FinishAction` reset the `last_amount` to zero. This could cause undefined behavior in the future. For example ```rust=919 pub fn withdraw_reward(&mut self) -> Result<()> { ... for asset_index in 0..usize::from(farm.asset_count) { ... user_asset.calculate_reward_debt(&farm_reward)?; user_asset.last_action = Action::FinishAction as u8; user_asset.last_amount = 0; // reset as zero before FinishAction } ... } ```
`total_weight` will be used in the future to assign `amount` to different `asset_info.last_amount`. ```rust user.asset_infos[index].last_amount = u128::from(new_amount) .checked_mul(u128::from(asset_info.weight)) .unwrap() .checked_div(farm.total_weight) .unwrap() .try_into() .unwrap(); ``` But the instruction doesn't have a validation over the weights and the total weight. It is important to make sure that `total_weight` is not equal to 0 in order to prevent the construction of unavailable `farm` accounts. ```rust pub fn create_farm(&mut self, ... ) -> Result<()> { for i in 0 .. asset_size{ total_weight += u128::from(weights[i]); ... } farm.total_weight = total_weight; } ```
By adding an empty `bump` constraint to the `#[account(...)]` macro, you signal Anchor to find the canonical bump on its own for the initialization of the account. The user defined `bump` will be written to storage, but will not be used for account initialization. ```rust = 21 pub fn create_state(&mut self, bump: u8, ) -> Result<()> { ... state.bump = bump; ... } ``` Anywhere that the storage `state.bump` is used will result in incorrect validation of account addresses. Reference material on bump creation can be found [here](https://docs.rs/anchor-lang/latest/anchor_lang/context/struct.Context.html#structfield.bumps) and [here](https://www.anchor-lang.com/docs/pdas)
`MAX_ASSET_COUNT` is used to determine the total size that is to be allocated for accounts in `create_or_allocate_account_raw`. ```rust pub const MAX_ASSET_COUNT:usize = 10; ``` However, when this upper limit is not checked when allocating `asset_infos` in `farm`. This may result in the allocated space of the account being depleted. ```rust for i in 0..asset_size { total_weight += u128::from(weights[i]); farm.asset_infos.push(FarmAssetInfo { weight: weights[i], mint: mints[i], }) } ```
`Mint` is the only basis for identifying different types of tokens in Solana, and it is important to make sure that the mint in the a given Token Accounts data, which is input by the user, is correct. The current program only checks whether the address of the Token Account is constructed by the right seeds and program id, via the `check_token_account` function. It is best practice to include mint and ownership checks when interacting with the SPL Token program. For example, a validation that guarantees the Token Accounts mint address is the same as the one expected in the `strategy`. ```rust require!(token_account.mint == strategy.src_mint, ErrorCode::CustomErrorCode); ``` This would guarantee the token account was not created incorrectly.
According to the logic, **Hawksight** program forms a complete process in strict accordance with the `Action` steps. The project strictly controls the process execution through the inspection of `Action`. For example, in `supply_liquidity`, the function can only be run if the `last_action` was a `SwapAction` ```rust pub fn supply_liquidity(&mut self, min_amount_out: u64) -> Result<()> { ... require!( (user_asset_info.last_action == Action::SwapAction as u8) || ((user_asset_info.last_action == Action::FundAction as u8) && (strategy.token_mint == USDC_MAINNET) && ((strategy.src_mint == USDC_MAINNET) || (strategy.dst_mint == USDC_MAINNET))), ErrorCode::InvalidOperationOrder ); ... ``` However, `Action` is not verified in `stake_to_farm()` and `unstake_from_farm()` function, which may lead to unexpected errors in cross-step operations.
The current computation of `creator_fee` does not conform to the multiply first, divide later specification. This will result in a loss of precision in the final value. ```rust let fee_amount = u128::from(input_amount) .checked_mul(fee_pct) .unwrap() .checked_div(FEE_DENOMINATOR) .unwrap(); let creator_fee = fee_amount .checked_mul(CREATOR_FEE_WEIGHT) .unwrap() .checked_div(...).unwrap() .unwrap(); ```
[`mem::size_of<T>()`](https://doc.rust-lang.org/std/mem/fn.size_of.html) should be used for size calculations with caution. Borsch will always serialize an option as 1 byte for the variant identifier and then additional x bytes for the content if it's Some. However, Rust uses null-pointer optimization to make Option's variant identifier 0 bytes when it can, so an option is sometimes just as big as its contents, such as with `Sign`.
There are some unnecessary checks in the current code. For example: - **Already checked by `check_sub`** ```rust = 341 user.asset_infos[index].last_amount = u128::from(new_amount) .checked_mul(u128::from(asset_info.weight)) .unwrap() .checked_div(farm.total_weight) .unwrap() .try_into() .unwrap(); user.asset_infos[index].last_action = Action::FundAction as u8; require!( rest_amount >= user.asset_infos[index].last_amount, ErrorCode::IntegerUnderflow );//Duplicate check rest_amount = rest_amount .checked_sub(user.asset_infos[index].last_amount) .unwrap(); index += 1; ``` - **Already checked by `#[account(mut,seeds = [..], bump)]`** ```rust check_token_account( self.user_pda_stable_token.key(), farm.stable_mint, farm_key, user_key, )?; ``` - **Already checked by `Program<'info, Token>`** ```rust #[account(constraint = token_program.key == &token::ID)] ```
In `require!` macro, checking the `x == true` expression is redundant. ```rust require!(found == true, ErrorCode::AssetNotFound); ```
The code in the following loop uses `index`, but it can be further simplified. ```rust=341 for asset_info in farm.asset_infos.iter(){ user.asset_infos[index].last_amount = u128::from(new_amount) .checked_mul(u128::from(asset_info.weight)).unwrap() .checked_div(farm.total_weight).unwrap() .try_into().unwrap(); user.asset_infos[index].last_action = Action::FundAction as u8; require!(rest_amount >= user.asset_infos[index].last_amount, ErrorCode::IntegerUnderflow); rest_amount = rest_amount.checked_sub(user.asset_infos[index].last_amount).unwrap(); index += 1; } ``` ```rust = 170 for token_per_second in token_per_seconds.iter() { farm.reward_infos[index].token_per_second = *token_per_second; index += 1; } ```
In the `stake_to_farm()` function, the user can stake the LP obtained by adding liquidity to `saber_farm` to obtain rewards. ```rust stake_lp_token_pda( self.saber_farm_program.to_account_info(), self.token_program.to_account_info(), user.to_account_info(), &self.saber_farm, self.user_pda_lp_token.to_account_info(), self.saber_farm_rewarder.to_account_info(), // Stake full LP token balance of pass through account self.user_pda_lp_token.amount, // user.asset_infos[asset_index].last_amount, authority_seeds, )?; ``` However, the stake amount is `user_pda_lp_token.amount`, but the billed amount is `user_asset.last_amount`. ```rust user_asset.amount = user_asset .amount .checked_add(user_asset.last_amount) .unwrap(); ``` If there is an external transaction that transfers the additional LPs to the `user_pda_lp_token`, this will cause the actual stake amount to be inconsistent with the billed amount, users cannot get back all staked LPs when unstaking from the farm.
The comment includes a spelling mistake of `further`.
The references on the linked lines would be dereferenced immediately by the compiler, so the borrow operations are unnecessary.
There is an unnecessary `return` statement in the current code base. ```rust = 620 if exp_token_address != address { return Err(Error::ProgramError(ProgramErrorWithOrigin::from(ProgramError::InvalidArgument))); } ```
The `CreateUserToken` struct declares a `strategy` account, however the only field used in that account is the token mint. Passing around unnecessary data can increase complexity of the program, as well as computational requirements. ```rust pub struct CreateUserToken<'info> { #[account( seeds = [ b"saber-farm-strategy".as_ref(), strategy.token_mint.as_ref(), strategy.src_mint.as_ref(), strategy.dst_mint.as_ref(), strategy.lp_mint.as_ref(), strategy.spl_swap.as_ref(), strategy.saber_swap.as_ref(), strategy.quarry.as_ref(), ], bump, )] pub strategy: Account<'info, SaberFarmStrategy>, // ... } ``` ```rust pub fn create_user_token(&mut self) -> Result<()> { let farm = &mut self.farm; let strategy = &mut self.strategy; let farm_key = farm.key(); farm.validate_address(farm_key)?; farm.find_asset(strategy.token_mint)?; // Used here Ok(()) } ```
The references on the linked lines make unnecessary conversions to the same type.
In the `utils.rs`, `authority_seeds` was re-sliced, which was unnecessary: ```rust 393 with_signer(&[&authority_seeds[..]]) ```
The variable `_res` is declared but is not used in the code logic. ```rust=550 let _res = redeem_all_tokens_from_mint_proxy(CpiContext::new(...)); ```
The QuarryProtocol provides [`create_miner`](https://github.com/QuarryProtocol/quarry/blob/f41219dd9e60fbabe5494a6dffbbc17dc3778a46/programs/quarry-mine/src/lib.rs#L232) to initialize the miner. However, since the current `create_miner` still requires `bump` for initialization, QuarryProtocol provides a more standardized [`create_miner_v2`](https://github.com/QuarryProtocol/quarry/blob/f41219dd9e60fbabe5494a6dffbbc17dc3778a46/programs/quarry-mine/src/lib.rs#L242) to replace `create_miner`. ```rust create_miner(CpiContext::new( self.quarry_mine_program.to_account_info(), CreateMiner{ authority: self.user.to_account_info(), miner: self.miner.to_account_info(), quarry: self.quarry.to_account_info(), rewarder: self.rewarder.to_account_info(), system_program: self.system_program.to_account_info(), payer: self.authority.to_account_info(), token_mint: self.lp_mint.to_account_info(), miner_vault: self.miner_vault.to_account_info(), token_program: self.token_program.to_account_info(), }).with_signer(&[&authority_seeds[..]]), miner_bump)?; ```
The report names a repository or files. Open the report to see its scope.
1,044 projects reviewed · 1,375 reports · latest May 2025
See CertiK’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us