Security audit of AvaProtocol by Slow Mist

Findings:

title: Calculate inaccurate risk
reported_impact: Suggestion
vulnerability_class_audit: Integer Overflow
description: [N1] [Suggestion] Calculate inaccurate risk
Category: Integer Overflow Audit
Content
pallets/automation-time/src/lib.rs
There are some risks of value overflow.
saturating_mul, saturating_sub, saturating_add and +-*/, +=, -=
saturating at the numeric bounds instead of overflowing, The returned result is inaccurate.
Solution
Use checked_add/checked_sub/checked_mul/checked_div instead of
saturating_add/saturating_sub/saturating_mul/saturating_div and +-*/, +=, -= .
Status
Fixed

title: User balance is not checked before transfer
reported_impact: Suggestion
vulnerability_class_audit: Others
description: [N2] [Suggestion] User balance is not checked
Category: Others
Content
pallets/automation-time/src/lib.rs
The amount transferred by the user is not compared with the user's balance here, and the user may not have enough NativeToken.
pub fn schedule_native_transfer_task(
origin: OriginFor<T>,
provided_id: Vec<u8>,
execution_times: Vec<UnixTime>,
recipient_id: T::AccountId,
#[pallet::compact] amount: BalanceOf<T>,
) -> DispatchResult {
let who = ensure_signed(origin)?;
// check for greater than existential deposit
if amount < T::NativeTokenExchange::minimum_balance() {
Err(<Error<T>>::InvalidAmount)?
}
// check not sent to self
if who == recipient_id {
Err(<Error<T>>::TransferToSelf)?
}
let action =
Action::NativeTransfer { sender: who.clone(), recipient: recipient_id, amount };
Self::validate_and_schedule_task(action, who, provided_id, execution_times)?;
Ok(().into())
}
Solution
Compare the user's transfer amount with the balance
Status
Ignored; This is expected behaviour.

title: Return value not checked
reported_impact: Low
vulnerability_class_audit: Others
description: [N3] [Low] Return Value Not Checked
Category: Others
Content
pallets/parachain-staking/src/lib.rs
//#L962
T::Currency::unreserve(&bond.owner, bond.amount);
//#L1004
T::Currency::unreserve(&candidate, state.bond);
//#L1390
T::Currency::unreserve(&delegator, amount);
pallets/parachain-staking/src/types.rs
//#L463
T::Currency::unreserve(&who, request.amount.into());
//#L648:651
T::Currency::unreserve(&lowest_bottom_to_be_kicked.owner, lowest_bottom_to_be_kicked.amount);
pallets/parachain-staking/src/delegation_requests.rs
//#L279
T::Currency::unreserve(&delegator, amount);
pallets/parachain-staking/src/migrations.rs
//#L404
T::Currency::unreserve(&owner, *amount);
The return value of unreserve needs to be checked.
Solution
Check the return value.
Status
Ignored; If the account has less than that locked up. Not only is this unlikely to happen, there's nothing for parachain-staking to do if it occurs.

title: Calculate inaccurate risk
reported_impact: Suggestion
vulnerability_class_audit: Integer Overflow
description: [N4] [Suggestion] Calculate inaccurate risk
Category: Integer Overflow Audit
Content
pallets/parachain-staking/src/types.rs
pallets/parachain-staking/src/lib.rs
pallets/parachain-staking/src/delegation_requests.rs
There are some risks of value overflow.
saturating_mul, saturating_sub, saturating_add and +-*/, +=, -=
saturating at the numeric bounds instead of overflowing, The returned result is inaccurate.
Solution
Use checked_add/checked_sub/checked_mul/checked_div instead of
saturating_add/saturating_sub/saturating_mul/saturating_div and +-*/, +=, -= .
Status
Ignored; These functions are performed on the Balance type. Since the same type is used for total_issuance I don't think we need to be worried about a portion of the issuance overflowing the data type.

title: Missing logic
reported_impact: Low
vulnerability_class_audit: Others
description: [N5] [Low] Missing logic
Category: Others
Content
pallets/parachain-staking/src/lib.rs
It is necessary to make a judgment in the case of deposit_into_existing failure. If the transfer fails, the entire transaction needs to be rolled back.
fn prepare_staking_payouts(now: RoundIndex) {
// payout is now - delay rounds ago => now - delay > 0 else return early
let delay = T::RewardPaymentDelay::get();
if now <= delay {
return;
}
let round_to_payout = now.saturating_sub(delay);
let total_points = <Points<T>>::get(round_to_payout);
if total_points.is_zero() {
return;
}
let total_staked = <Staked<T>>::take(round_to_payout);
let total_issuance = Self::compute_issuance(total_staked);
let mut left_issuance = total_issuance;
// reserve portion of issuance for parachain bond account
let bond_config = <ParachainBondInfo<T>>::get();
let parachain_bond_reserve = bond_config.percent * total_issuance;
if let Ok(imb) = T::Currency::deposit_into_existing(&bond_config.account, parachain_bond_reserve) {
// update round issuance iff transfer succeeds
left_issuance = left_issuance.saturating_sub(imb.peek());
Self::deposit_event(Event::ReservedForParachainBond {
account: bond_config.account,
value: imb.peek(),
});
}
let payout = DelayedPayout {
round_issuance: total_issuance,
total_staking_reward: left_issuance,
collator_commission: <CollatorCommission<T>>::get(),
};
<DelayedPayouts<T>>::insert(round_to_payout, payout);
}
pub(crate) fn pay_one_collator_reward(paid_for_round: RoundIndex, payout_info: DelayedPayout<BalanceOf<T>>, ) -> (Option<(T::AccountId, BalanceOf<T>)>, Weight) {
// TODO: it would probably be optimal to roll Points into the DelayedPayouts storage
// item so that we do fewer reads each block
let total_points = <Points<T>>::get(paid_for_round);
if total_points.is_zero() {
// TODO: this case is obnoxious... it's a value query, so it could mean one
// of two different logic errors:
// 1. we removed it before we should have
// 2. we called pay_one_collator_reward when we were actually done with deferred payouts
log::warn!("pay_one_collator_reward called with no <Points<T>> for the round!");
return (None, 0u64.into());
}
let mint = |amt: BalanceOf<T>, to: T::AccountId| {
if let Ok(amount_transferred) = T::Currency::deposit_into_existing(&to, amt) {
Self::deposit_event(Event::Rewarded {
account: to.clone(),
rewards: amount_transferred.peek(),
});
}
};
let collator_fee = payout_info.collator_commission;
let collator_issuance = collator_fee * payout_info.round_issuance;
if let Some((collator, pts)) = <AwardedPts<T>>::iter_prefix(paid_for_round).drain().next() {
let mut extra_weight = 0;
let pct_due = Perbill::from_rational(pts, total_points);
let total_paid = pct_due * payout_info.total_staking_reward;
let mut amt_due = total_paid;
// Take the snapshot of block author and delegations
let state = <AtStake<T>>::take(paid_for_round, &collator);
let num_delegators = state.delegations.len();
if state.delegations.is_empty() {
// solo collator with no delegators
mint(amt_due, collator.clone());
extra_weight += T::OnCollatorPayout::on_collator_payout(paid_for_round, collator.clone(), amt_due, );
} else {
// pay collator first; commission + due_portion
let collator_pct = Perbill::from_rational(state.bond, state.total);
let commission = pct_due * collator_issuance;
amt_due = amt_due.saturating_sub(commission);
let collator_reward = (collator_pct * amt_due).saturating_add(commission);
mint(collator_reward, collator.clone());
extra_weight += T::OnCollatorPayout::on_collator_payout(paid_for_round, collator.clone(), collator_reward, );
// pay delegators due portion
for Bond { owner, amount } in state.delegations {
let percent = Perbill::from_rational(amount, state.total);
let due = percent * amt_due;
if !due.is_zero() {
mint(due, owner.clone());
}
}
}
(Some((collator, total_paid)), T::WeightInfo::pay_one_collator_reward(num_delegators as u32) + extra_weight, )
} else {
// Note that we don't clean up storage here; it is cleaned up in handle_delayed_payouts()
(None, 0u64.into())
}
}
Solution
If the transfer fails, the transaction should be rolled back.
Status
Ignored; The project party considers that it will be updated in subsequent versions.

title: Missing error message
reported_impact: Low
vulnerability_class_audit: Malicious Event Log
description: [N6] [Suggestion] Missing error message
Category: Malicious Event Log Audit
Content
pallets/parachain-staking/src/types.rs
let new_bottom_delegation = top_delegations.delegations.pop().expect(""); missing error message.
pub fn add_top_delegation<T: Config>(&mut self, candidate: &T::AccountId, delegation: Bond<T::AccountId, BalanceOf<T>>) -> Option<Balance> where BalanceOf<T>: Into<Balance> + From<Balance>,
{
let mut less_total_staked = None;
let mut top_delegations = <TopDelegations<T>>::get(candidate).expect("CandidateInfo existence => TopDelegations existence");
let max_top_delegations_per_candidate = T::MaxTopDelegationsPerCandidate::get();
if top_delegations.delegations.len() as u32 == max_top_delegations_per_candidate {
// pop lowest top delegation
let new_bottom_delegation = top_delegations.delegations.pop().expect("");
top_delegations.total = top_delegations.total.saturating_sub(new_bottom_delegation.amount);
if matches!(self.bottom_capacity, CapacityStatus::Full) {
less_total_staked = Some(self.lowest_bottom_delegation_amount);
}
self.add_bottom_delegation::<T>(true, candidate, new_bottom_delegation);
}
// insert into top
top_delegations.insert_sorted_greatest_to_least(delegation);
// update candidate info
self.reset_top_data::<T>(candidate.clone(), &top_delegations);
if less_total_staked.is_none() {
// only increment delegation count if we are not kicking a bottom delegation
self.delegation_count = self.delegation_count.saturating_add(1u32);
}
<TopDelegations<T>>::insert(&candidate, top_delegations);
less_total_staked
}
Solution
Record the corresponding error message.
Status
Ignored; The project party considers that it will be updated in subsequent versions.