Skip to content

Commit 2fb9260

Browse files
paritytech-release-backport-bot[bot]clangenbgithub-actions[bot]
authored
[stable2603] Backport #11441 (#11488)
Backport #11441 into `stable2603` from clangenb. See the [documentation](https://github.com/paritytech/polkadot-sdk/blob/master/docs/BACKPORT.md) on how to use this bot. <!-- # To be used by other automation, do not modify: original-pr-number: #${pull_number} --> Co-authored-by: clangenb <37865735+clangenb@users.noreply.github.com> Co-authored-by: clangenb <clangenb@users.noreply.github.com> Co-authored-by: cmd[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 2f9a3d8 commit 2fb9260

File tree

5 files changed

+62
-5
lines changed

5 files changed

+62
-5
lines changed

prdoc/pr_11441.prdoc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
title: '[pallet-assets] fix: decrement supply when refund burns balance'
2+
doc:
3+
- audience: Runtime Dev
4+
description: |-
5+
When a user calls `refund` with `allow_burn = true`, their token balance is destroyed, but the asset's total supply was never updated. This caused `total_issuance()` to overcount. The fix decrements supply and emits a `Burned` event, consistent with how every other burn path works.
6+
7+
In production, burning path is rarely triggered. The fungibles trait interface always passes `allow_burn = false`, so only users manually submitting the refund extrinsic with the burn flag would hit it.
8+
9+
Follow-up issue for migrating the discrepancy (observed on Westend): https://github.com/paritytech/polkadot-sdk/issues/11443.
10+
11+
Fixes #10412
12+
crates:
13+
- name: pallet-assets
14+
bump: patch

substrate/frame/assets/src/functions.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,10 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
384384
}
385385

386386
if let Remove = Self::dead_account(&who, &mut details, &account.reason, false) {
387+
if !account.balance.is_zero() {
388+
debug_assert!(details.supply >= account.balance, "supply < balance; qed");
389+
details.supply = details.supply.saturating_sub(account.balance);
390+
}
387391
Account::<T, I>::remove(&id, &who);
388392
} else {
389393
debug_assert!(false, "refund did not result in dead account?!");
@@ -392,6 +396,14 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
392396
return Ok(());
393397
}
394398

399+
if !account.balance.is_zero() {
400+
Self::deposit_event(Event::Burned {
401+
asset_id: id.clone(),
402+
owner: who.clone(),
403+
balance: account.balance,
404+
});
405+
}
406+
395407
Asset::<T, I>::insert(&id, details);
396408
// Executing a hook here is safe, since it is not in a `mutate`.
397409
T::Freezer::died(id.clone(), &who);

substrate/frame/assets/src/lib.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2024,10 +2024,11 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
20242024
}
20252025
}
20262026

2027-
// Using >= instead of == because the provided `do_refund` implementation destroys
2028-
// the account balance without decrementing the asset supply. This causes the
2029-
// tracked supply to permanently exceed the actual sum of balances + holds
2030-
// whenever a refund occurs.
2027+
// Using >= instead of == because the provided `do_refund` implementation
2028+
// historically destroyed the account balance without decrementing the asset
2029+
// supply. Although this has been fixed, existing on-chain state may still
2030+
// contain overcounted supply from prior refunds.
2031+
// TODO: add a migration to recalculate supply, then tighten this to `==`.
20312032
ensure!(details.supply >= calculated_supply, "Asset supply mismatch");
20322033
ensure!(details.accounts == calculated_accounts, "Asset account count mismatch");
20332034
ensure!(

substrate/frame/assets/src/mock.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ pub(crate) fn set_balance_on_hold(asset: u32, who: u64, amount: u64) {
173173

174174
pub(crate) fn clear_balance_on_hold(asset: u32, who: u64) {
175175
OnHold::mutate(|v| {
176-
v.remove(&(asset, who));
176+
if let Some(amount) = v.remove(&(asset, who)) {
177+
if amount > 0 {
178+
assert_ok!(Assets::increase_balance(asset, &who, amount, |_| Ok(())));
179+
}
180+
}
177181
});
178182
}
179183
pub struct TestFreezer;

substrate/frame/assets/src/tests.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,35 @@ fn refunding_asset_deposit_with_burn_should_work() {
164164
Balances::make_free_balance_be(&1, 100);
165165
assert_ok!(Assets::touch(RuntimeOrigin::signed(1), 0));
166166
assert_ok!(Assets::mint(RuntimeOrigin::signed(1), 0, 1, 100));
167+
assert_eq!(Assets::total_supply(0), 100);
167168
assert_ok!(Assets::refund(RuntimeOrigin::signed(1), 0, true));
168169
assert_eq!(Balances::reserved_balance(&1), 0);
169170
assert_eq!(Assets::balance(1, 0), 0);
171+
assert_eq!(Assets::total_supply(0), 0);
172+
System::assert_last_event(RuntimeEvent::Assets(crate::Event::Burned {
173+
asset_id: 0,
174+
owner: 1,
175+
balance: 100,
176+
}));
177+
});
178+
}
179+
180+
#[test]
181+
fn refund_with_zero_balance_does_not_emit_burned() {
182+
build_and_execute(|| {
183+
assert_ok!(Assets::force_create(RuntimeOrigin::root(), 0, 1, false, 1));
184+
Balances::make_free_balance_be(&1, 100);
185+
assert_ok!(Assets::touch(RuntimeOrigin::signed(1), 0));
186+
assert_ok!(Assets::mint(RuntimeOrigin::signed(1), 0, 1, 100));
187+
assert_ok!(Assets::burn(RuntimeOrigin::signed(1), 0, 1, 100));
188+
assert_eq!(Assets::total_supply(0), 0);
189+
190+
System::reset_events();
191+
assert_ok!(Assets::refund(RuntimeOrigin::signed(1), 0, false));
192+
assert_eq!(Assets::total_supply(0), 0);
193+
assert!(System::events()
194+
.iter()
195+
.all(|e| !matches!(e.event, RuntimeEvent::Assets(crate::Event::Burned { .. }))));
170196
});
171197
}
172198

0 commit comments

Comments
 (0)