Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions pallets/crowdloan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,11 @@ pub mod pallet {
ensure!(crowdloan.raised == crowdloan.cap, Error::<T>::CapNotRaised);
ensure!(!crowdloan.finalized, Error::<T>::AlreadyFinalized);

// Mark finalized *before* dispatching the creator-controlled call so re-entrant
// withdraw/refund/dissolve are rejected mid-dispatch. Reverts if the dispatch fails.
crowdloan.finalized = true;
Crowdloans::<T>::insert(crowdloan_id, &crowdloan);
Comment thread
l0r1s marked this conversation as resolved.
Comment on lines +623 to +626

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Failed finalize can persist finalized state and trap funds

This writes crowdloan.finalized = true before several fallible operations: preimage lookup/drop, dispatch of the creator-controlled call, target transfer, and invalid-config return. I do not see #[transactional] on finalize or a production with_storage_layer around the whole body, so a later Err can leave the crowdloan marked finalized while funds were not delivered and contributors can no longer refund/withdraw. The new test only wraps one path in with_storage_layer; the dispatchable itself needs the rollback boundary, or the state write must happen only after all fallible finalization work succeeds while still blocking reentrancy by another mechanism.

Comment on lines +623 to +626

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Failed finalize can persist finalized state and trap funds

finalize writes finalized = true before fallible preimage lookup / creator-call dispatch, but the production dispatchable still has no visible #[transactional], with_transaction, or with_storage_layer boundary. In FRAME, returning Err after storage writes is not enough to undo them unless a transaction layer is active. A missing preimage or failing creator-controlled call can therefore leave the crowdloan marked finalized; in the call path it can also leave CurrentCrowdloanId set because kill() is after the fallible dispatch. The new test manually wraps one case in with_storage_layer, but that does not add the production rollback boundary. Wrap the whole finalization mutation/dispatch section in a transaction layer or make the dispatchable transactional before moving this guard write ahead of the fallible work.


match (&crowdloan.call, &crowdloan.target_address) {
(Some(call), None) => {
// Set the current crowdloan id so the dispatched call
Expand Down Expand Up @@ -659,9 +664,6 @@ pub mod pallet {
}
}

crowdloan.finalized = true;
Crowdloans::<T>::insert(crowdloan_id, &crowdloan);

Self::deposit_event(Event::<T>::Finalized { crowdloan_id });

Ok(())
Expand Down
128 changes: 128 additions & 0 deletions pallets/crowdloan/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1875,6 +1875,134 @@ fn test_finalize_fails_if_call_fails() {
});
}

// The finalize `call` cannot re-enter `withdraw` on the same crowdloan: it is rejected and
// the extrinsic reverts, so no funds move and `raised` stays consistent with the real balance.
#[test]
fn test_finalize_blocks_reentrant_withdraw() {
TestState::default()
.with_balance(U256::from(1), 200.into()) // creator
.with_balance(U256::from(2), 200.into()) // contributor
.build_and_execute(|| {
let creator: AccountOf<Test> = U256::from(1);
let contributor: AccountOf<Test> = U256::from(2);
let deposit: BalanceOf<Test> = 50.into();
let min_contribution: BalanceOf<Test> = 10.into();
let cap: BalanceOf<Test> = 100.into();
let end: BlockNumberFor<Test> = 50;
let crowdloan_id: CrowdloanId = 0;

// The finalize call re-enters `withdraw` on the same crowdloan.
let reentrant_call = Box::new(RuntimeCall::Crowdloan(
pallet_crowdloan::Call::<Test>::withdraw { crowdloan_id },
));

assert_ok!(Crowdloan::create(
RuntimeOrigin::signed(creator),
deposit,
min_contribution,
cap,
end,
Some(reentrant_call),
None,
));
run_to_block(10);

// Creator contributes 30 over the deposit (total 80); contributor fills the cap.
assert_ok!(Crowdloan::contribute(
RuntimeOrigin::signed(creator),
crowdloan_id,
30.into()
));
assert_ok!(Crowdloan::contribute(
RuntimeOrigin::signed(contributor),
crowdloan_id,
20.into()
));

let funds_account = pallet_crowdloan::Pallet::<Test>::funds_account(crowdloan_id);
assert_eq!(Balances::free_balance(funds_account), cap);
let creator_balance_before = Balances::free_balance(creator);

run_to_block(60);

// Finalize dispatches the re-entrant withdraw, which is rejected with
// `AlreadyFinalized`. Wrap in a storage layer to model the per-extrinsic
// transaction the runtime applies in production, so the revert is observable.
let outcome = frame_support::storage::with_storage_layer(|| {
Crowdloan::finalize(RuntimeOrigin::signed(creator), crowdloan_id)
});
assert_err!(outcome, pallet_crowdloan::Error::<Test>::AlreadyFinalized);

// No funds were extracted and accounting is intact.
assert_eq!(Balances::free_balance(creator), creator_balance_before);
assert_eq!(Balances::free_balance(funds_account), cap);
assert_eq!(pallet_crowdloan::CurrentCrowdloanId::<Test>::get(), None);
let crowdloan = pallet_crowdloan::Crowdloans::<Test>::get(crowdloan_id).unwrap();
assert!(!crowdloan.finalized);
assert_eq!(crowdloan.raised, cap);

// Contributor funds are not frozen: the contributor can still withdraw.
assert_ok!(Crowdloan::withdraw(
RuntimeOrigin::signed(contributor),
crowdloan_id
));
assert_eq!(Balances::free_balance(contributor), 200.into());
});
}

// A re-entrant `refund` embedded as the finalize call is likewise rejected before moving funds.
#[test]
fn test_finalize_blocks_reentrant_refund() {
TestState::default()
.with_balance(U256::from(1), 200.into()) // creator
.with_balance(U256::from(2), 200.into()) // contributor
.build_and_execute(|| {
let creator: AccountOf<Test> = U256::from(1);
let contributor: AccountOf<Test> = U256::from(2);
let deposit: BalanceOf<Test> = 50.into();
let min_contribution: BalanceOf<Test> = 10.into();
let cap: BalanceOf<Test> = 100.into();
let end: BlockNumberFor<Test> = 50;
let crowdloan_id: CrowdloanId = 0;

let reentrant_call = Box::new(RuntimeCall::Crowdloan(
pallet_crowdloan::Call::<Test>::refund { crowdloan_id },
));

assert_ok!(Crowdloan::create(
RuntimeOrigin::signed(creator),
deposit,
min_contribution,
cap,
end,
Some(reentrant_call),
None,
));
run_to_block(10);

assert_ok!(Crowdloan::contribute(
RuntimeOrigin::signed(creator),
crowdloan_id,
30.into()
));
assert_ok!(Crowdloan::contribute(
RuntimeOrigin::signed(contributor),
crowdloan_id,
20.into()
));

let funds_account = pallet_crowdloan::Pallet::<Test>::funds_account(crowdloan_id);
run_to_block(60);

// The re-entrant refund hits the `finalized` guard before transferring anything.
assert_err!(
Crowdloan::finalize(RuntimeOrigin::signed(creator), crowdloan_id),
pallet_crowdloan::Error::<Test>::AlreadyFinalized
);
assert_eq!(Balances::free_balance(funds_account), cap);
});
}

#[test]
fn test_refund_succeeds() {
TestState::default()
Expand Down
20 changes: 20 additions & 0 deletions pallets/subtensor/src/benchmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,22 @@ mod pallet_benchmarks {
let hotkey = account::<T::AccountId>("beneficiary_hotkey", 0, 0);
let _ = Subtensor::<T>::create_account_if_non_existent(&beneficiary, &hotkey);

let residual_alpha = AlphaBalance::from(10_000_000_u64);
let lock_amount = AlphaBalance::from(4_000_000_u64);
Subtensor::<T>::increase_stake_for_hotkey_and_coldkey_on_subnet(
&lease.hotkey,
&lease.coldkey,
lease.netuid,
residual_alpha,
);
DecayingLock::<T>::insert(&lease.coldkey, lease.netuid, false);
assert_ok!(Subtensor::<T>::do_lock_stake(
&lease.coldkey,
lease.netuid,
&lease.hotkey,
lock_amount,
));

#[extrinsic_call]
_(
RawOrigin::Signed(beneficiary.clone()),
Expand All @@ -1767,6 +1783,10 @@ mod pallet_benchmarks {
assert_eq!(SubnetLeases::<T>::get(lease_id), None);
assert!(!SubnetLeaseShares::<T>::contains_prefix(lease_id));
assert!(!AccumulatedLeaseDividends::<T>::contains_key(lease_id));
assert_eq!(
Subtensor::<T>::total_coldkey_alpha_on_subnet(&lease.coldkey, lease.netuid),
AlphaBalance::ZERO
);
}

#[benchmark]
Expand Down
7 changes: 5 additions & 2 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1950,7 +1950,10 @@ mod dispatches {
/// * `hotkey` (T::AccountId):
/// - The hotkey of the beneficiary to mark as subnet owner hotkey.
#[pallet::call_index(111)]
#[pallet::weight(<T as crate::pallet::Config>::WeightInfo::terminate_lease(T::MaxContributors::get()))]
#[pallet::weight(
<T as crate::pallet::Config>::WeightInfo::terminate_lease(T::MaxContributors::get())
.saturating_add(<T as crate::pallet::Config>::WeightInfo::swap_hotkey())
)]
Comment on lines +1953 to +1956

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Lease termination weight does not cover the new worst-case work

The dispatch weight is still just generated terminate_lease(k) plus swap_hotkey(), but the PR adds more worst-case work to do_terminate_lease: lock migration, owner-lock aggregate reassignment, stake repatriation, full-lock transfer, proxy removal, currency sweep, and extra storage cleanup. The benchmark scenario was updated, but pallets/subtensor/src/weights.rs is not changed in this PR, so the runtime still charges the old generated weight. Undercharging a runtime extrinsic can let a valid lease termination exceed its declared block weight. Regenerate and commit the weights for the expanded benchmark, or add a conservative explicit weight component that accounts for the added reads/writes and balance/proxy operations.

pub fn terminate_lease(
origin: OriginFor<T>,
lease_id: LeaseId,
Expand Down Expand Up @@ -2575,7 +2578,7 @@ mod dispatches {
netuid: NetUid,
) -> DispatchResult {
let coldkey = ensure_signed(origin)?;
Self::do_move_lock(&coldkey, &destination_hotkey, netuid)
Self::do_move_lock(&coldkey, &destination_hotkey, netuid, false)
}

/// Sets or clears the caller's perpetual lock flag for a subnet.
Expand Down
Loading
Loading