Audit finding: transfer_share_and_rewards can be used to transfer out shares without transferring reward debt due to rounding
Project: Acala
Auditor: Code4Arena
Location: src/orml/rewards/src/lib.rs:341-343
Impact: High
Vulnerability class (audit): Math
Vulnerability class (Scout): Arithmetic
Status: Just want to highlight that transfer_share_and_rewards is not currently used. We will choose to not fix this issue as the impact are relatively small and a complete fix is non-trivial. I don’t think it is possible to make profit that is more than transaction fee anyway.

[H-03] transfer_share_and_rewards can be used to transfer out shares without transferring reward debt due to rounding
Submitted by carrotsmuggler, also found by AM
The function transfer_share_and_rewards can be used to split up the position in a single account into multiple accounts. The contract sends some of the shares to be held by the second account, and similarly also updates the reward debt of the receiving account so that the receiver cannot take out more rewards than they deserve.
This is calculated in the following snippet. move_balance is the amount of the reward debt that is to be transferred to the receiver.
let move_balance = U256::from(balance.to_owned().saturated_into) * U256::from(move_share.to_owned().saturated_into::<u128>) / U256::from(share.to_owned().saturated_into::<u128>());
Here we see the calculation is simple and by default is rounded down. So if balance * move_share is lower than share, move_balance evaluates to 0. So the receiving account’s reward debt is not increased at all!
increased_rewards.entry(*reward_currency).and_modify(|increased_reward| {
  *increased_reward = increased_reward.saturating_add
});
Since move_balance is 0, the increased_reward is not updated. This means the new account now has shares, but no reward debt. So the receiving account can claim rewards that were already claimed.
This can be done multiple times to drain the reward pool.
The criteria is that balance * move_share has to be lower than share. This can be achieved by sending a small fraction of the funds to the receiving account, such that move_share is much lower than share. Also, if balance, the reward debt of the sender, is low, this facilitates the attack more.
Proof of Concept
A short POC is shown here demonstrating the issue. The attacker sends to the receiver a small share of their total. The receiver is shown to have no reward debt, while the sender does have reward debt. This shows that the receiver can claim rewards already claimed by the sender.
#[test]
fn test_rounding() {
  ExtBuilder::default().build().execute_with(|| {
    RewardsModule::add_share(&ALICE, &DOT_POOL, 1000);
    assert_ok!(RewardsModule::accumulate_reward(&DOT_POOL, NATIVE_COIN));
    RewardsModule::claim_rewards(&ALICE, &DOT_POOL);
    let user_stat = RewardsModule::shares_and_withdrawn_rewards;
    println!("ALICE stat before transfer: {:?}", user_stat);
    let user_stat = RewardsModule::shares_and_withdrawn_rewards;
    println!("BOB stat before transfer: {:?}", user_stat);
    assert_ok!(RewardsModule::transfer_share_and_rewards(&ALICE, &DOT_POOL));
    let user_stat = RewardsModule::shares_and_withdrawn_rewards;
    println!("ALICE stat after transfer: {:?}", user_stat);
    let user_stat = RewardsModule::shares_and_withdrawn_rewards;
    println!("BOB stat after transfer: {:?}", user_stat);
  });
}
Output:
# Output is of the form ( ${share_balance}, {0: ${reward_debt}} )
ALICE stat before transfer: (1000, {0: 100})
BOB stat before transfer: (0, {})
ALICE stat after transfer: (995, {0: 100})
BOB stat after transfer: (5, {0: 0})
test tests::test_rounding ... ok
The output shows that ALICE has 1000 shares and 100 reward debt, since ALICE just claimed her rewards. Alice sends BOB 5 shares. BOB ends up with 5 shares and 0 reward debt. So BOB can claim rewards again, even though it’s the same money!
Tools Used
Substrate
Recommended Mitigation Steps
The calculation of move_balance should be changed to saturated round up instead of rounding down. This will ensure that the receiving account’s reward debt is updated correctly. The saturated rounding up is important since the reward debt should never be larger than the reward pool, or it will cause underflow errors when subtracting.
Another option is to revert transfer_share_and_rewards operations if the reward debt of the receiving account is calculated to be 0, unless the sending account ALSO has a reward debt of 0.
Assessed type
Math
xlc (Acala) confirmed and commented:
Just want to highlight that transfer_share_and_rewards is not currently used.
We will choose to not fix this issue as the impact is relatively small and a complete fix is non-trivial. I don’t think it is possible to make a profit that is more than the transaction fee anyway.

Summary: The transfer_share_and_rewards function can be exploited to transfer shares without updating reward debt, allowing users to drain the reward pool.