Skip to content

Crowdloan optional per-contributor maximum contribution and exclusive call/target address.#2697

Merged
l0r1s merged 12 commits into
devnet-readyfrom
crowdloan-fixes
May 29, 2026
Merged

Crowdloan optional per-contributor maximum contribution and exclusive call/target address.#2697
l0r1s merged 12 commits into
devnet-readyfrom
crowdloan-fixes

Conversation

@l0r1s
Copy link
Copy Markdown
Collaborator

@l0r1s l0r1s commented May 26, 2026

Description

This PR tightens crowdloan finalization configuration and adds an optional per-contributor maximum contribution.

Crowdloans now require exactly one finalization route: either a call or a target_address. Creating a crowdloan with both, or with neither, is rejected. finalize also enforces the same invariant before any transfer or dispatch, so invalid legacy or manually-written storage state cannot execute both paths.

This also adds set_maximum_contribution, a creator-only extrinsic for non-finalized crowdloans. The setting is stored separately from CrowdloanInfo, so the existing storage struct layout is preserved. Contributions are capped by both the remaining crowdloan cap and the contributor's remaining maximum contribution. The requested contribution must still satisfy min_contribution, but the accepted amount can be lower when filling the remaining cap or remaining per-contributor allowance.

Additional changes:

  • Enforces min_contribution <= max_contribution from both update directions.
  • Clears max contribution storage when a crowdloan is dissolved.
  • Updates crowdloan benchmarks, weights, mock weight info, and README docs.
  • Fixes benchmark_all.sh variable naming around discovered pallet output paths.
  • Bumps runtime spec_version to 410.

Breaking Change

pallet_crowdloan::create now rejects configurations where both call and target_address are provided, or where neither is provided. Clients must provide exactly one finalization route.

finalize also rejects stored crowdloans that do not satisfy this invariant before performing side effects. Any existing crowdloan storage with both fields set, or neither field set, must be corrected before it can be finalized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment thread pallets/crowdloan/src/lib.rs Outdated
/// - `crowdloan_id`: The id of the crowdloan to update the maximum contribution of.
/// - `new_max_contribution`: The new optional maximum contribution.
#[pallet::call_index(9)]
#[pallet::weight(T::WeightInfo::set_maximum_contribution())]
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.

[MEDIUM] Regenerate weights for the new max-contribution storage paths

set_maximum_contribution is wired into runtime dispatch, but the PR does not update pallets/crowdloan/src/weights.rs for this new extrinsic or for the extra MaxContributions reads/writes added to contribute, update_min_contribution, and dissolve. In a runtime pallet, under-accounted database reads/writes are a block-resource accounting issue: users can execute more state access than the declared weight permits. Regenerate and commit the crowdloan weights so the new extrinsic exists in WeightInfo and all modified extrinsics account for MaxContributions.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

🛡️ AI Review — Skeptic (security review)

VERDICT: VULNERABLE

BASELINE scrutiny: l0r1s has repo write permission, an established 2018 account, substantial prior opentensor PR history, and same-author commits; branch crowdloan-fixes -> devnet-ready.

No .github/ai-review/*, Copilot-instruction, dependency manifest, lockfile, or build-script security changes are present. The prior Skeptic concerns still reproduce in the current diff.

Findings

Sev File Finding
HIGH pallets/crowdloan/src/lib.rs:647 Clear CurrentCrowdloanId on all finalize errors inline
MEDIUM pallets/crowdloan/src/benchmarking.rs:270 Finalize weight still covers only one supported route inline

Prior-comment reconciliation

  • c571ab97: not addressedCurrentCrowdloanId is still set before fallible preimage and dispatch operations, and error exits still bypass the later kill() call.
  • 3775c6f0: not addressed — The benchmark now forces the stored-call route, but finalize still has one constant weight across the transfer and stored-call routes and does not charge the stored call's dispatch weight at finalization time.

Conclusion

The PR remains vulnerable because call-route finalization can leave stale CurrentCrowdloanId storage on error, and finalize still uses one static benchmarked weight for materially different finalization routes.


📜 Previous run (superseded)
Sev File Finding Status
HIGH pallets/crowdloan/src/lib.rs:627 Clear CurrentCrowdloanId on all finalize errors ➡️ Carried forward to current findings
CurrentCrowdloanId is still set before fallible preimage and dispatch operations, and error exits still bypass the later kill() call.
MEDIUM pallets/crowdloan/src/benchmarking.rs:245 Finalize weight still covers only one supported route ➡️ Carried forward to current findings
The benchmark now forces the stored-call route, but finalize still has one constant weight across the transfer and stored-call routes and does not charge the stored call's dispatch weight at finalization time.

# 🔍 AI Review — Auditor (domain review) has not yet run on this PR.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

cap,
end,
Some(call),
None,
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.

[MEDIUM] Finalize weight only benchmarks the transfer route

This benchmark now creates the finalized crowdloan with call = None and target_address = Some(...), so generated WeightInfo::finalize() only accounts for the transfer route. Runtime finalize still supports (Some(call), None) and then writes/kills CurrentCrowdloanId, looks up the preimage, and dispatches the stored call under the same static #[pallet::weight(T::WeightInfo::finalize())]. That lets call-route finalization consume unaccounted runtime work in the block. Regenerate or restructure the weight so finalization covers the worst supported route, including the dispatched call weight and extra storage I/O, or split the routes into separately weighted calls.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@l0r1s l0r1s added the skip-cargo-audit This PR fails cargo audit but needs to be merged anyway label May 27, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +627 to +647
CurrentCrowdloanId::<T>::put(crowdloan_id);

// Retrieve the call from the preimage storage
let stored_call = match T::Preimages::peek(call) {
Ok((call, _)) => call,
Err(_) => {
// If the call is not found, we drop it from the preimage storage
// because it's not needed anymore
T::Preimages::drop(call);
return Err(Error::<T>::CallUnavailable)?;
}
};

// Dispatch the call with creator origin
stored_call
.dispatch(frame_system::RawOrigin::Signed(who).into())
.map(|_| ())
.map_err(|e| e.error)?;

// Clear the current crowdloan id
CurrentCrowdloanId::<T>::kill();
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] Clear CurrentCrowdloanId on all finalize errors

finalize writes CurrentCrowdloanId before both preimage lookup and dispatch, but the CallUnavailable branch and any dispatched-call error return before CurrentCrowdloanId::<T>::kill() runs. Runtime storage changes are not automatically rolled back on DispatchError, so a creator can leave a stale crowdloan id set after a failing finalization. That id gates register_leased_network, which would let the creator later execute leasing outside the immutable stored finalization call and choose fresh parameters. Retrieve the preimage before exposing the id, then always clear the id before propagating the dispatch result.

Suggested change
CurrentCrowdloanId::<T>::put(crowdloan_id);
// Retrieve the call from the preimage storage
let stored_call = match T::Preimages::peek(call) {
Ok((call, _)) => call,
Err(_) => {
// If the call is not found, we drop it from the preimage storage
// because it's not needed anymore
T::Preimages::drop(call);
return Err(Error::<T>::CallUnavailable)?;
}
};
// Dispatch the call with creator origin
stored_call
.dispatch(frame_system::RawOrigin::Signed(who).into())
.map(|_| ())
.map_err(|e| e.error)?;
// Clear the current crowdloan id
CurrentCrowdloanId::<T>::kill();
// Retrieve the call from the preimage storage before exposing the
// crowdloan id to the dispatched call.
let stored_call = match T::Preimages::peek(call) {
Ok((call, _)) => call,
Err(_) => {
// If the call is not found, we drop it from the preimage storage
// because it's not needed anymore
T::Preimages::drop(call);
return Err(Error::<T>::CallUnavailable)?;
}
};
// Set the current crowdloan id only while the call is being dispatched.
CurrentCrowdloanId::<T>::put(crowdloan_id);
let dispatch_result = stored_call
.dispatch(frame_system::RawOrigin::Signed(who).into())
.map(|_| ())
.map_err(|e| e.error);
CurrentCrowdloanId::<T>::kill();
dispatch_result?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Runtime storage changes are not automatically rolled back on DispatchError. It is.

Comment on lines 238 to +245
@@ -238,11 +242,17 @@ mod benchmarks {
cap,
end,
Some(call),
Some(target_address.clone()),
None,
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.

[MEDIUM] Finalize weight still covers only one supported route

The benchmark now creates only a call-route crowdloan (Some(call), None), while finalize still supports the target-address route that performs a balance transfer. The committed finalize weight is a single constant, so it must cover the worst supported path: preimage lookup plus dispatched-call accounting, and the target transfer path's account reads/writes. As written, one supported route remains unmeasured, and the stored call's own dispatch weight is not added at finalization time.

Copy link
Copy Markdown
Collaborator Author

@l0r1s l0r1s May 27, 2026

Choose a reason for hiding this comment

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

We are benchmarking worst case here, not all cases.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@l0r1s l0r1s force-pushed the crowdloan-fixes branch from a15315b to abe1688 Compare May 27, 2026 19:42
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

(Some(call), None) => {
// Set the current crowdloan id so the dispatched call
// can access it temporarily
CurrentCrowdloanId::<T>::put(crowdloan_id);
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] Clear CurrentCrowdloanId on all finalize errors

CurrentCrowdloanId is set before preimage lookup and inner dispatch, but it is only cleared after a successful dispatch. If T::Preimages::peek(call) fails or stored_call.dispatch(...) returns an error, finalize exits through ?/return Err with CurrentCrowdloanId still set. Any later runtime logic that reads this temporary storage can observe stale finalization context for a crowdloan that did not finalize. Clear the storage on every exit path after it is set, e.g. wrap the call-route work so CurrentCrowdloanId::<T>::kill() runs before returning both success and error.

Comment on lines 230 to 245
@@ -238,11 +243,17 @@ mod benchmarks {
cap,
end,
Some(call),
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.

[MEDIUM] Finalize weight still covers only one supported route

This benchmark now exercises the stored-call route, but finalize still exposes one constant T::WeightInfo::finalize() while supporting two materially different paths: a balances transfer route and an arbitrary stored-call dispatch route. The stored call's own dispatch weight is also not charged at finalization time, even though that is when the work executes. A creator can therefore finalize a heavy stored call under the fixed finalize weight. Split the weights by route or add the stored call's dispatch weight to the finalization weight before dispatching it.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

Comment on lines +625 to +647
// Set the current crowdloan id so the dispatched call
// can access it temporarily
CurrentCrowdloanId::<T>::put(crowdloan_id);

// Retrieve the call from the preimage storage
let stored_call = match T::Preimages::peek(call) {
Ok((call, _)) => call,
Err(_) => {
// If the call is not found, we drop it from the preimage storage
// because it's not needed anymore
T::Preimages::drop(call);
return Err(Error::<T>::CallUnavailable)?;
}
};

// Dispatch the call with creator origin
stored_call
.dispatch(frame_system::RawOrigin::Signed(who).into())
.map(|_| ())
.map_err(|e| e.error)?;

// Clear the current crowdloan id
CurrentCrowdloanId::<T>::kill();
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] Clear CurrentCrowdloanId on all finalize errors

CurrentCrowdloanId is written before fallible operations and is only cleared on the success path. If the preimage lookup/drop path returns CallUnavailable, or if the stored call dispatch returns an error, this storage item remains set after finalize exits. Any later logic that trusts CurrentCrowdloanId as proof that it is executing inside a crowdloan finalization can then observe a stale crowdloan id outside the intended dispatch window.

Move the put until after the preimage lookup and always clear it before propagating the inner dispatch result.

Suggested change
// Set the current crowdloan id so the dispatched call
// can access it temporarily
CurrentCrowdloanId::<T>::put(crowdloan_id);
// Retrieve the call from the preimage storage
let stored_call = match T::Preimages::peek(call) {
Ok((call, _)) => call,
Err(_) => {
// If the call is not found, we drop it from the preimage storage
// because it's not needed anymore
T::Preimages::drop(call);
return Err(Error::<T>::CallUnavailable)?;
}
};
// Dispatch the call with creator origin
stored_call
.dispatch(frame_system::RawOrigin::Signed(who).into())
.map(|_| ())
.map_err(|e| e.error)?;
// Clear the current crowdloan id
CurrentCrowdloanId::<T>::kill();
// Retrieve the call from the preimage storage
let stored_call = match T::Preimages::peek(call) {
Ok((call, _)) => call,
Err(_) => {
// If the call is not found, we drop it from the preimage storage
// because it's not needed anymore
T::Preimages::drop(call);
return Err(Error::<T>::CallUnavailable)?;
}
};
// Set the current crowdloan id so the dispatched call
// can access it temporarily
CurrentCrowdloanId::<T>::put(crowdloan_id);
// Dispatch the call with creator origin
let dispatch_result = stored_call
.dispatch(frame_system::RawOrigin::Signed(who).into())
.map(|_| ())
.map_err(|e| e.error);
// Clear the current crowdloan id before propagating dispatch errors
CurrentCrowdloanId::<T>::kill();
dispatch_result?;

@@ -258,10 +269,10 @@ mod benchmarks {
#[extrinsic_call]
_(RawOrigin::Signed(creator.clone()), crowdloan_id);
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.

[MEDIUM] Finalize weight still covers only one supported route

finalize now supports two materially different routes: a stored-call dispatch route and a balance-transfer route. This benchmark exercises only the stored-call route, and the extrinsic still charges a single T::WeightInfo::finalize() value. The generated weight therefore does not prove the target-address transfer path is covered, and it also does not charge the actual stored call's dispatch weight at finalization time. A user can select whichever route is more expensive than the measured one while paying the same constant weight.

Split the benchmark/weight by route or make finalize charge the maximum of the supported paths, including the dispatched call's own weight when using the call route.

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@l0r1s l0r1s merged commit 6e4fb80 into devnet-ready May 29, 2026
210 of 213 checks passed
@sam0x17 sam0x17 mentioned this pull request Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-cargo-audit This PR fails cargo audit but needs to be merged anyway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants