Skip to content

Commit db42e3c

Browse files
authored
Fix orphaned held assets on reversible-transfer recovery (#611)
Make `release_held_funds_with_fee` transactional so a failed delivery rolls back the fee burn, and release funds before mutating metadata in `cancel_transfer`. Prevents recovery from permanently burning fees and desyncing the hold when the recipient cannot receive the asset (#93134).
1 parent 0185987 commit db42e3c

2 files changed

Lines changed: 96 additions & 3 deletions

File tree

pallets/reversible-transfers/src/lib.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,10 @@ pub mod pallet {
911911
(pending.from.clone(), false)
912912
};
913913

914+
// Release funds before mutating metadata so a failure leaves the pending transfer
915+
// intact and retryable.
916+
Self::release_held_funds_with_fee(&pending, &recipient, apply_fee)?;
917+
914918
// Remove from storage
915919
PendingTransfers::<T>::remove(tx_id);
916920
PendingTransfersBySender::<T>::mutate(&pending.from, |list| {
@@ -924,15 +928,17 @@ pub mod pallet {
924928
T::Scheduler::cancel_named(schedule_id)
925929
.defensive_map_err(|_| Error::<T>::CancellationFailed)?;
926930

927-
// Release funds (must succeed for normal cancel)
928-
Self::release_held_funds_with_fee(&pending, &recipient, apply_fee)?;
929-
930931
Self::deposit_event(Event::TransactionCancelled { who: who.clone(), tx_id });
931932
Ok(())
932933
}
933934

934935
/// Releases held funds from a pending transfer, optionally applying volume fee.
935936
/// Burns the fee portion and transfers the remainder to the recipient.
937+
///
938+
/// Wrapped in a storage layer so a partial failure (e.g. the recipient cannot receive
939+
/// the funds) rolls back any fee burn, leaving the original hold intact and the pending
940+
/// transfer retryable.
941+
#[frame_support::transactional]
936942
fn release_held_funds_with_fee(
937943
pending: &PendingTransferOf<T>,
938944
recipient: &T::AccountId,

pallets/reversible-transfers/src/tests/test_reversible_transfers.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,6 +1479,93 @@ fn asset_hold_prevents_spend_over_free() {
14791479
});
14801480
}
14811481

1482+
#[test]
1483+
fn recover_funds_is_atomic_when_release_fails() {
1484+
new_test_ext().execute_with(|| {
1485+
System::set_block_number(1);
1486+
1487+
let protected = alice(); // high-security from genesis, guardian = bob
1488+
let guardian = bob();
1489+
let asset_operator = charlie();
1490+
let recipient = eve();
1491+
let asset_id: u32 = 4_343;
1492+
let amount: Balance = 1_000;
1493+
1494+
// Create an asset and block the guardian's asset account so it cannot receive funds.
1495+
create_asset(asset_id, asset_operator.clone(), Some(10_000));
1496+
assert_ok!(pallet_assets::Pallet::<Test>::mint(
1497+
RuntimeOrigin::signed(asset_operator.clone()),
1498+
codec::Compact(asset_id),
1499+
protected.clone(),
1500+
10_000,
1501+
));
1502+
assert_ok!(pallet_assets::Pallet::<Test>::mint(
1503+
RuntimeOrigin::signed(asset_operator.clone()),
1504+
codec::Compact(asset_id),
1505+
guardian.clone(),
1506+
1,
1507+
));
1508+
assert_ok!(pallet_assets::Pallet::<Test>::block(
1509+
RuntimeOrigin::signed(asset_operator.clone()),
1510+
codec::Compact(asset_id),
1511+
guardian.clone(),
1512+
));
1513+
1514+
let asset_call: RuntimeCall = pallet_assets::Call::<Test>::transfer_keep_alive {
1515+
id: codec::Compact(asset_id),
1516+
target: recipient.clone(),
1517+
amount,
1518+
}
1519+
.into();
1520+
let tx_id = calculate_tx_id::<Test>(protected.clone(), &asset_call);
1521+
1522+
assert_ok!(ReversibleTransfers::schedule_asset_transfer(
1523+
RuntimeOrigin::signed(protected.clone()),
1524+
asset_id,
1525+
recipient,
1526+
amount,
1527+
));
1528+
1529+
let hold_before = asset_holds(asset_id, &protected);
1530+
let issuance_before =
1531+
<pallet_assets::Pallet<Test> as AssetsInspect<_>>::total_issuance(asset_id);
1532+
assert_eq!(hold_before, amount);
1533+
assert_eq!(ReversibleTransfers::pending_dispatches(tx_id).unwrap().amount, amount);
1534+
1535+
// Recovery cannot deliver to the blocked guardian, so the release must roll back fully:
1536+
// no fee is burned and the pending transfer is preserved for retry.
1537+
assert_ok!(ReversibleTransfers::recover_funds(
1538+
RuntimeOrigin::signed(guardian.clone()),
1539+
protected.clone(),
1540+
));
1541+
System::assert_has_event(Event::TransferRecoveryFailed { tx_id }.into());
1542+
assert_eq!(asset_holds(asset_id, &protected), hold_before);
1543+
assert_eq!(
1544+
<pallet_assets::Pallet<Test> as AssetsInspect<_>>::total_issuance(asset_id),
1545+
issuance_before
1546+
);
1547+
let pending_after = ReversibleTransfers::pending_dispatches(tx_id).unwrap();
1548+
assert_eq!(pending_after.amount, asset_holds(asset_id, &protected));
1549+
assert!(ReversibleTransfers::pending_transfers_by_sender(&protected).contains(&tx_id));
1550+
1551+
// Repeated recovery stays idempotent: the hold is never partially burned.
1552+
assert_ok!(Balances::transfer_keep_alive(
1553+
RuntimeOrigin::signed(asset_operator),
1554+
protected.clone(),
1555+
100,
1556+
));
1557+
assert_ok!(ReversibleTransfers::recover_funds(
1558+
RuntimeOrigin::signed(guardian),
1559+
protected.clone(),
1560+
));
1561+
assert_eq!(asset_holds(asset_id, &protected), hold_before);
1562+
assert_eq!(
1563+
<pallet_assets::Pallet<Test> as AssetsInspect<_>>::total_issuance(asset_id),
1564+
issuance_before
1565+
);
1566+
});
1567+
}
1568+
14821569
#[test]
14831570
fn schedule_transfer_with_error_short_delay() {
14841571
new_test_ext().execute_with(|| {

0 commit comments

Comments
 (0)