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
12 changes: 9 additions & 3 deletions pallets/crowdloan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ pub mod pallet {
InvalidOrigin,
/// The crowdloan has already been finalized.
AlreadyFinalized,
/// A crowdloan finalization is already in progress.
AlreadyFinalizing,
/// The crowdloan contribution period has not ended yet.
ContributionPeriodNotEnded,
/// The contributor has no contribution for this crowdloan.
Expand Down Expand Up @@ -619,6 +621,13 @@ pub mod pallet {
ensure!(who == crowdloan.creator, Error::<T>::InvalidOrigin);
ensure!(crowdloan.raised == crowdloan.cap, Error::<T>::CapNotRaised);
ensure!(!crowdloan.finalized, Error::<T>::AlreadyFinalized);
ensure!(
CurrentCrowdloanId::<T>::get().is_none(),
Error::<T>::AlreadyFinalizing
);

crowdloan.finalized = true;

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] Make finalize transactional before setting finalized early

This write now happens before several fallible operations: preimage lookup/drop, dispatched call execution, balance transfer, and invalid finalization config. Because finalize itself is not annotated #[transactional] or wrapped in an equivalent storage transaction, a later Err can leave crowdloan.finalized = true persisted without emitting Finalized or moving funds. That turns transient failures, including a re-entrant AlreadyFinalized, into a permanent lock that also blocks withdraw, refund, updates, and dissolve paths.

The new test_finalize_blocks_reentrant_withdraw wraps the call in frame_support::storage::with_storage_layer, but that only changes the test harness; production finalize still has no transaction boundary. Add use frame_support::transactional; and #[transactional] to finalize, or keep the early guard in a transactional helper, so any post-marking error rolls this storage write back.

Comment thread
l0r1s marked this conversation as resolved.
Crowdloans::<T>::insert(crowdloan_id, &crowdloan);
Comment thread
l0r1s marked this conversation as resolved.
Comment thread
l0r1s marked this conversation as resolved.

match (&crowdloan.call, &crowdloan.target_address) {
(Some(call), None) => {
Expand Down Expand Up @@ -659,9 +668,6 @@ pub mod pallet {
}
}

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

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

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

#[test]
fn test_finalize_fails_if_another_finalize_is_in_progress() {
TestState::default()
.with_balance(U256::from(1), 300.into())
.with_balance(U256::from(2), 300.into())
.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 first_crowdloan_id: CrowdloanId = 0;
let second_crowdloan_id: CrowdloanId = 1;

let nested_finalize_call = Box::new(RuntimeCall::Crowdloan(pallet_crowdloan::Call::<
Test,
>::finalize {
crowdloan_id: second_crowdloan_id,
}));

assert_ok!(Crowdloan::create(
RuntimeOrigin::signed(creator),
deposit,
min_contribution,
cap,
end,
Some(nested_finalize_call),
None,
));
assert_ok!(Crowdloan::create(
RuntimeOrigin::signed(creator),
deposit,
min_contribution,
cap,
end,
Some(noop_call()),
None,
));

run_to_block(10);

assert_ok!(Crowdloan::contribute(
RuntimeOrigin::signed(contributor),
first_crowdloan_id,
50.into()
));
assert_ok!(Crowdloan::contribute(
RuntimeOrigin::signed(contributor),
second_crowdloan_id,
50.into()
));

run_to_block(60);

assert_err!(
Crowdloan::finalize(RuntimeOrigin::signed(creator), first_crowdloan_id),
pallet_crowdloan::Error::<Test>::AlreadyFinalizing
);

assert_eq!(pallet_crowdloan::CurrentCrowdloanId::<Test>::get(), None);
assert!(
pallet_crowdloan::Crowdloans::<Test>::get(first_crowdloan_id)
.is_some_and(|c| !c.finalized)
);
assert!(
pallet_crowdloan::Crowdloans::<Test>::get(second_crowdloan_id)
.is_some_and(|c| !c.finalized)
);
});
}

// 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
Loading