pallet_parachain_staking/
delegation_requests.rs

1// Copyright 2019-2025 PureStake Inc.
2// This file is part of Moonbeam.
3
4// Moonbeam is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// Moonbeam is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with Moonbeam.  If not, see <http://www.gnu.org/licenses/>.
16
17//! Scheduled requests functionality for delegators
18
19use crate::pallet::{
20	BalanceOf, CandidateInfo, Config, DelegationScheduledRequests,
21	DelegationScheduledRequestsPerCollator, DelegatorState, Error, Event, Pallet, Round,
22	RoundIndex, Total,
23};
24use crate::weights::WeightInfo;
25use crate::{auto_compound::AutoCompoundDelegations, Delegator};
26use frame_support::dispatch::{DispatchErrorWithPostInfo, DispatchResultWithPostInfo};
27use frame_support::ensure;
28use frame_support::traits::Get;
29use frame_support::BoundedVec;
30use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
31use scale_info::TypeInfo;
32use sp_runtime::{
33	traits::{Saturating, Zero},
34	RuntimeDebug,
35};
36
37/// An action that can be performed upon a delegation
38#[derive(
39	Clone,
40	Eq,
41	PartialEq,
42	Encode,
43	Decode,
44	RuntimeDebug,
45	TypeInfo,
46	PartialOrd,
47	Ord,
48	DecodeWithMemTracking,
49	MaxEncodedLen,
50)]
51pub enum DelegationAction<Balance> {
52	Revoke(Balance),
53	Decrease(Balance),
54}
55
56impl<Balance: Copy> DelegationAction<Balance> {
57	/// Returns the wrapped amount value.
58	pub fn amount(&self) -> Balance {
59		match self {
60			DelegationAction::Revoke(amount) => *amount,
61			DelegationAction::Decrease(amount) => *amount,
62		}
63	}
64}
65
66/// Represents a scheduled request that defines a [`DelegationAction`]. The request is executable
67/// iff the provided [`RoundIndex`] is achieved.
68#[derive(
69	Clone,
70	Eq,
71	PartialEq,
72	Encode,
73	Decode,
74	RuntimeDebug,
75	TypeInfo,
76	PartialOrd,
77	Ord,
78	DecodeWithMemTracking,
79	MaxEncodedLen,
80)]
81pub struct ScheduledRequest<Balance> {
82	pub when_executable: RoundIndex,
83	pub action: DelegationAction<Balance>,
84}
85
86/// Represents a cancelled scheduled request for emitting an event.
87#[derive(Clone, Eq, PartialEq, Encode, Decode, RuntimeDebug, TypeInfo, DecodeWithMemTracking)]
88pub struct CancelledScheduledRequest<Balance> {
89	pub when_executable: RoundIndex,
90	pub action: DelegationAction<Balance>,
91}
92
93impl<B> From<ScheduledRequest<B>> for CancelledScheduledRequest<B> {
94	fn from(request: ScheduledRequest<B>) -> Self {
95		CancelledScheduledRequest {
96			when_executable: request.when_executable,
97			action: request.action,
98		}
99	}
100}
101
102impl<T: Config> Pallet<T> {
103	/// Schedules a [DelegationAction::Revoke] for the delegator, towards a given collator.
104	pub(crate) fn delegation_schedule_revoke(
105		collator: T::AccountId,
106		delegator: T::AccountId,
107	) -> DispatchResultWithPostInfo {
108		let mut state = <DelegatorState<T>>::get(&delegator).ok_or(<Error<T>>::DelegatorDNE)?;
109		let mut scheduled_requests = <DelegationScheduledRequests<T>>::get(&collator, &delegator);
110
111		let actual_weight =
112			<T as Config>::WeightInfo::schedule_revoke_delegation(scheduled_requests.len() as u32);
113
114		let is_new_delegator = scheduled_requests.is_empty();
115
116		ensure!(
117			is_new_delegator,
118			DispatchErrorWithPostInfo {
119				post_info: Some(actual_weight).into(),
120				error: <Error<T>>::PendingDelegationRequestAlreadyExists.into(),
121			},
122		);
123
124		// This is the first scheduled request for this delegator towards this collator,
125		// ensure we do not exceed the maximum number of delegators that can have pending
126		// requests for the collator.
127		let current = <DelegationScheduledRequestsPerCollator<T>>::get(&collator);
128		if current >= Pallet::<T>::max_delegators_per_candidate() {
129			return Err(DispatchErrorWithPostInfo {
130				post_info: Some(actual_weight).into(),
131				error: Error::<T>::ExceedMaxDelegationsPerDelegator.into(),
132			});
133		}
134
135		let bonded_amount = state
136			.get_bond_amount(&collator)
137			.ok_or(<Error<T>>::DelegationDNE)?;
138		let now = <Round<T>>::get().current;
139		let when = now.saturating_add(T::RevokeDelegationDelay::get());
140		scheduled_requests
141			.try_push(ScheduledRequest {
142				action: DelegationAction::Revoke(bonded_amount),
143				when_executable: when,
144			})
145			.map_err(|_| DispatchErrorWithPostInfo {
146				post_info: Some(actual_weight).into(),
147				error: Error::<T>::ExceedMaxDelegationsPerDelegator.into(),
148			})?;
149		state.less_total = state.less_total.saturating_add(bonded_amount);
150
151		<DelegationScheduledRequestsPerCollator<T>>::mutate(&collator, |c| {
152			*c = c.saturating_add(1);
153		});
154
155		<DelegationScheduledRequests<T>>::insert(
156			collator.clone(),
157			delegator.clone(),
158			scheduled_requests,
159		);
160		<DelegatorState<T>>::insert(delegator.clone(), state);
161
162		Self::deposit_event(Event::DelegationRevocationScheduled {
163			round: now,
164			delegator,
165			candidate: collator,
166			scheduled_exit: when,
167		});
168		Ok(().into())
169	}
170
171	/// Schedules a [DelegationAction::Decrease] for the delegator, towards a given collator.
172	pub(crate) fn delegation_schedule_bond_decrease(
173		collator: T::AccountId,
174		delegator: T::AccountId,
175		decrease_amount: BalanceOf<T>,
176	) -> DispatchResultWithPostInfo {
177		let mut state = <DelegatorState<T>>::get(&delegator).ok_or(<Error<T>>::DelegatorDNE)?;
178		let mut scheduled_requests = <DelegationScheduledRequests<T>>::get(&collator, &delegator);
179
180		let actual_weight = <T as Config>::WeightInfo::schedule_delegator_bond_less(
181			scheduled_requests.len() as u32,
182		);
183
184		// If this is the first scheduled request for this delegator towards this collator,
185		// ensure we do not exceed the maximum number of delegators that can have pending
186		// requests for the collator.
187		let is_new_delegator = scheduled_requests.is_empty();
188		if is_new_delegator {
189			let current = <DelegationScheduledRequestsPerCollator<T>>::get(&collator);
190			let max_delegators = Pallet::<T>::max_delegators_per_candidate();
191			if current >= max_delegators {
192				return Err(DispatchErrorWithPostInfo {
193					post_info: Some(actual_weight).into(),
194					error: Error::<T>::ExceedMaxDelegationsPerDelegator.into(),
195				});
196			}
197		}
198
199		ensure!(
200			!scheduled_requests
201				.iter()
202				.any(|req| matches!(req.action, DelegationAction::Revoke(_))),
203			DispatchErrorWithPostInfo {
204				post_info: Some(actual_weight).into(),
205				error: <Error<T>>::PendingDelegationRequestAlreadyExists.into(),
206			},
207		);
208
209		let bonded_amount = state
210			.get_bond_amount(&collator)
211			.ok_or(DispatchErrorWithPostInfo {
212				post_info: Some(actual_weight).into(),
213				error: <Error<T>>::DelegationDNE.into(),
214			})?;
215		// Per-request safety: a single decrease cannot exceed the current delegation
216		// and must leave at least MinDelegation on that delegation.
217		ensure!(
218			bonded_amount > decrease_amount,
219			DispatchErrorWithPostInfo {
220				post_info: Some(actual_weight).into(),
221				error: <Error<T>>::DelegatorBondBelowMin.into(),
222			},
223		);
224
225		// Cumulative safety: multiple pending Decrease requests for the same
226		// (collator, delegator) pair must also respect the MinDelegation
227		// constraint when applied together. Otherwise, snapshots can become
228		// inconsistent even if each request, in isolation, appears valid.
229		let pending_decrease_total: BalanceOf<T> = scheduled_requests
230			.iter()
231			.filter_map(|req| match req.action {
232				DelegationAction::Decrease(amount) => Some(amount),
233				_ => None,
234			})
235			.fold(BalanceOf::<T>::zero(), |acc, amount| {
236				acc.saturating_add(amount)
237			});
238		let total_decrease_after = pending_decrease_total.saturating_add(decrease_amount);
239		let new_amount_after_all = bonded_amount.saturating_sub(total_decrease_after);
240		ensure!(
241			new_amount_after_all >= T::MinDelegation::get(),
242			DispatchErrorWithPostInfo {
243				post_info: Some(actual_weight).into(),
244				error: <Error<T>>::DelegationBelowMin.into(),
245			},
246		);
247
248		// Net Total is total after pending orders are executed
249		let net_total = state.total().saturating_sub(state.less_total);
250		// Net Total is always >= MinDelegation
251		let max_subtracted_amount = net_total.saturating_sub(T::MinDelegation::get().into());
252		ensure!(
253			decrease_amount <= max_subtracted_amount,
254			DispatchErrorWithPostInfo {
255				post_info: Some(actual_weight).into(),
256				error: <Error<T>>::DelegatorBondBelowMin.into(),
257			},
258		);
259
260		let now = <Round<T>>::get().current;
261		let when = now.saturating_add(T::DelegationBondLessDelay::get());
262		scheduled_requests
263			.try_push(ScheduledRequest {
264				action: DelegationAction::Decrease(decrease_amount),
265				when_executable: when,
266			})
267			.map_err(|_| DispatchErrorWithPostInfo {
268				post_info: Some(actual_weight).into(),
269				error: Error::<T>::ExceedMaxDelegationsPerDelegator.into(),
270			})?;
271		state.less_total = state.less_total.saturating_add(decrease_amount);
272		if is_new_delegator {
273			<DelegationScheduledRequestsPerCollator<T>>::mutate(&collator, |c| {
274				*c = c.saturating_add(1);
275			});
276		}
277		<DelegationScheduledRequests<T>>::insert(
278			collator.clone(),
279			delegator.clone(),
280			scheduled_requests,
281		);
282		<DelegatorState<T>>::insert(delegator.clone(), state);
283
284		Self::deposit_event(Event::DelegationDecreaseScheduled {
285			delegator,
286			candidate: collator,
287			amount_to_decrease: decrease_amount,
288			execute_round: when,
289		});
290		Ok(Some(actual_weight).into())
291	}
292
293	/// Cancels the delegator's existing [ScheduledRequest] towards a given collator.
294	pub(crate) fn delegation_cancel_request(
295		collator: T::AccountId,
296		delegator: T::AccountId,
297	) -> DispatchResultWithPostInfo {
298		let mut state = <DelegatorState<T>>::get(&delegator).ok_or(<Error<T>>::DelegatorDNE)?;
299		let mut scheduled_requests = <DelegationScheduledRequests<T>>::get(&collator, &delegator);
300		let actual_weight =
301			<T as Config>::WeightInfo::cancel_delegation_request(scheduled_requests.len() as u32);
302
303		let request = Self::cancel_request_with_state(&mut state, &mut scheduled_requests).ok_or(
304			DispatchErrorWithPostInfo {
305				post_info: Some(actual_weight).into(),
306				error: <Error<T>>::PendingDelegationRequestDNE.into(),
307			},
308		)?;
309
310		if scheduled_requests.is_empty() {
311			<DelegationScheduledRequestsPerCollator<T>>::mutate(&collator, |c| {
312				*c = c.saturating_sub(1);
313			});
314			<DelegationScheduledRequests<T>>::remove(&collator, &delegator);
315		} else {
316			<DelegationScheduledRequests<T>>::insert(
317				collator.clone(),
318				delegator.clone(),
319				scheduled_requests,
320			);
321		}
322		<DelegatorState<T>>::insert(delegator.clone(), state);
323
324		Self::deposit_event(Event::CancelledDelegationRequest {
325			delegator,
326			collator,
327			cancelled_request: request.into(),
328		});
329		Ok(Some(actual_weight).into())
330	}
331
332	fn cancel_request_with_state(
333		state: &mut Delegator<T::AccountId, BalanceOf<T>>,
334		scheduled_requests: &mut BoundedVec<
335			ScheduledRequest<BalanceOf<T>>,
336			T::MaxScheduledRequestsPerDelegator,
337		>,
338	) -> Option<ScheduledRequest<BalanceOf<T>>> {
339		if scheduled_requests.is_empty() {
340			return None;
341		}
342
343		// `BoundedVec::remove` can panic, but we make sure it will not happen by
344		// checking above that `scheduled_requests` is not empty.
345		let request = scheduled_requests.remove(0);
346		let amount = request.action.amount();
347		state.less_total = state.less_total.saturating_sub(amount);
348		Some(request)
349	}
350
351	/// Executes the delegator's existing [ScheduledRequest] towards a given collator.
352	pub(crate) fn delegation_execute_scheduled_request(
353		collator: T::AccountId,
354		delegator: T::AccountId,
355	) -> DispatchResultWithPostInfo {
356		let mut state = <DelegatorState<T>>::get(&delegator).ok_or(<Error<T>>::DelegatorDNE)?;
357		let mut scheduled_requests = <DelegationScheduledRequests<T>>::get(&collator, &delegator);
358		let request = scheduled_requests
359			.first()
360			.ok_or(<Error<T>>::PendingDelegationRequestDNE)?;
361
362		let now = <Round<T>>::get().current;
363		ensure!(
364			request.when_executable <= now,
365			<Error<T>>::PendingDelegationRequestNotDueYet
366		);
367
368		match request.action {
369			DelegationAction::Revoke(amount) => {
370				let actual_weight =
371					<T as Config>::WeightInfo::execute_delegator_revoke_delegation_worst();
372
373				// revoking last delegation => leaving set of delegators
374				let leaving = if state.delegations.0.len() == 1usize {
375					true
376				} else {
377					ensure!(
378						state.total().saturating_sub(T::MinDelegation::get().into()) >= amount,
379						DispatchErrorWithPostInfo {
380							post_info: Some(actual_weight).into(),
381							error: <Error<T>>::DelegatorBondBelowMin.into(),
382						}
383					);
384					false
385				};
386
387				// remove from pending requests
388				// `BoundedVec::remove` can panic, but we make sure it will not happen by checking above that `scheduled_requests` is not empty.
389				let amount = scheduled_requests.remove(0).action.amount();
390				state.less_total = state.less_total.saturating_sub(amount);
391
392				// remove delegation from delegator state
393				state.rm_delegation::<T>(&collator);
394
395				// remove delegation from auto-compounding info
396				<AutoCompoundDelegations<T>>::remove_auto_compound(&collator, &delegator);
397
398				// remove delegation from collator state delegations
399				Self::delegator_leaves_candidate(collator.clone(), delegator.clone(), amount)
400					.map_err(|err| DispatchErrorWithPostInfo {
401						post_info: Some(actual_weight).into(),
402						error: err,
403					})?;
404				Self::deposit_event(Event::DelegationRevoked {
405					delegator: delegator.clone(),
406					candidate: collator.clone(),
407					unstaked_amount: amount,
408				});
409				if scheduled_requests.is_empty() {
410					<DelegationScheduledRequests<T>>::remove(&collator, &delegator);
411					<DelegationScheduledRequestsPerCollator<T>>::mutate(&collator, |c| {
412						*c = c.saturating_sub(1);
413					});
414				} else {
415					<DelegationScheduledRequests<T>>::insert(
416						collator.clone(),
417						delegator.clone(),
418						scheduled_requests,
419					);
420				}
421				if leaving {
422					<DelegatorState<T>>::remove(&delegator);
423					Self::deposit_event(Event::DelegatorLeft {
424						delegator,
425						unstaked_amount: amount,
426					});
427				} else {
428					<DelegatorState<T>>::insert(&delegator, state);
429				}
430				Ok(Some(actual_weight).into())
431			}
432			DelegationAction::Decrease(_) => {
433				let actual_weight =
434					<T as Config>::WeightInfo::execute_delegator_revoke_delegation_worst();
435
436				// remove from pending requests
437				// `BoundedVec::remove` can panic, but we make sure it will not happen by checking above that `scheduled_requests` is not empty.
438				let amount = scheduled_requests.remove(0).action.amount();
439				state.less_total = state.less_total.saturating_sub(amount);
440
441				// decrease delegation
442				for bond in &mut state.delegations.0 {
443					if bond.owner == collator {
444						return if bond.amount > amount {
445							let amount_before: BalanceOf<T> = bond.amount.into();
446							bond.amount = bond.amount.saturating_sub(amount);
447							let mut collator_info = <CandidateInfo<T>>::get(&collator)
448								.ok_or(<Error<T>>::CandidateDNE)
449								.map_err(|err| DispatchErrorWithPostInfo {
450									post_info: Some(actual_weight).into(),
451									error: err.into(),
452								})?;
453
454							state
455								.total_sub_if::<T, _>(amount, |total| {
456									let new_total: BalanceOf<T> = total.into();
457									ensure!(
458										new_total >= T::MinDelegation::get(),
459										<Error<T>>::DelegationBelowMin
460									);
461
462									Ok(())
463								})
464								.map_err(|err| DispatchErrorWithPostInfo {
465									post_info: Some(actual_weight).into(),
466									error: err,
467								})?;
468
469							// need to go into decrease_delegation
470							let in_top = collator_info
471								.decrease_delegation::<T>(
472									&collator,
473									delegator.clone(),
474									amount_before,
475									amount,
476								)
477								.map_err(|err| DispatchErrorWithPostInfo {
478									post_info: Some(actual_weight).into(),
479									error: err,
480								})?;
481							<CandidateInfo<T>>::insert(&collator, collator_info);
482							let new_total_staked = <Total<T>>::get().saturating_sub(amount);
483							<Total<T>>::put(new_total_staked);
484
485							if scheduled_requests.is_empty() {
486								<DelegationScheduledRequests<T>>::remove(&collator, &delegator);
487								<DelegationScheduledRequestsPerCollator<T>>::mutate(
488									&collator,
489									|c| {
490										*c = c.saturating_sub(1);
491									},
492								);
493							} else {
494								<DelegationScheduledRequests<T>>::insert(
495									collator.clone(),
496									delegator.clone(),
497									scheduled_requests,
498								);
499							}
500							<DelegatorState<T>>::insert(delegator.clone(), state);
501							Self::deposit_event(Event::DelegationDecreased {
502								delegator,
503								candidate: collator.clone(),
504								amount,
505								in_top,
506							});
507							Ok(Some(actual_weight).into())
508						} else {
509							// must rm entire delegation if bond.amount <= less or cancel request
510							Err(DispatchErrorWithPostInfo {
511								post_info: Some(actual_weight).into(),
512								error: <Error<T>>::DelegationBelowMin.into(),
513							})
514						};
515					}
516				}
517				Err(DispatchErrorWithPostInfo {
518					post_info: Some(actual_weight).into(),
519					error: <Error<T>>::DelegationDNE.into(),
520				})
521			}
522		}
523	}
524
525	/// Removes the delegator's existing [ScheduledRequest] towards a given collator, if exists.
526	/// The state needs to be persisted by the caller of this function.
527	pub(crate) fn delegation_remove_request_with_state(
528		collator: &T::AccountId,
529		delegator: &T::AccountId,
530		state: &mut Delegator<T::AccountId, BalanceOf<T>>,
531	) {
532		let scheduled_requests = <DelegationScheduledRequests<T>>::get(collator, delegator);
533
534		if scheduled_requests.is_empty() {
535			return;
536		}
537
538		// Calculate total amount across all scheduled requests
539		let total_amount: BalanceOf<T> = scheduled_requests
540			.iter()
541			.map(|request| request.action.amount())
542			.fold(BalanceOf::<T>::zero(), |acc, amount| {
543				acc.saturating_add(amount)
544			});
545
546		state.less_total = state.less_total.saturating_sub(total_amount);
547		<DelegationScheduledRequests<T>>::remove(collator, delegator);
548		<DelegationScheduledRequestsPerCollator<T>>::mutate(collator, |c| {
549			*c = c.saturating_sub(1);
550		});
551	}
552
553	/// Returns true if a [ScheduledRequest] exists for a given delegation
554	pub fn delegation_request_exists(collator: &T::AccountId, delegator: &T::AccountId) -> bool {
555		!<DelegationScheduledRequests<T>>::get(collator, delegator).is_empty()
556	}
557
558	/// Returns true if a [DelegationAction::Revoke] [ScheduledRequest] exists for a given delegation
559	pub fn delegation_request_revoke_exists(
560		collator: &T::AccountId,
561		delegator: &T::AccountId,
562	) -> bool {
563		<DelegationScheduledRequests<T>>::get(collator, delegator)
564			.iter()
565			.any(|req| matches!(req.action, DelegationAction::Revoke(_)))
566	}
567}
568
569#[cfg(test)]
570mod tests {
571	use super::*;
572	use crate::{mock::Test, set::OrderedSet, Bond};
573
574	#[test]
575	fn test_cancel_request_with_state_removes_request_for_correct_delegator_and_updates_state() {
576		let mut state = Delegator {
577			id: 1,
578			delegations: OrderedSet::from(vec![Bond {
579				amount: 100,
580				owner: 2,
581			}]),
582			total: 100,
583			less_total: 150,
584			status: crate::DelegatorStatus::Active,
585		};
586		let mut scheduled_requests = vec![
587			ScheduledRequest {
588				when_executable: 1,
589				action: DelegationAction::Revoke(100),
590			},
591			ScheduledRequest {
592				when_executable: 1,
593				action: DelegationAction::Decrease(50),
594			},
595		]
596		.try_into()
597		.expect("must succeed");
598		let removed_request =
599			<Pallet<Test>>::cancel_request_with_state(&mut state, &mut scheduled_requests);
600
601		assert_eq!(
602			removed_request,
603			Some(ScheduledRequest {
604				when_executable: 1,
605				action: DelegationAction::Revoke(100),
606			})
607		);
608		assert_eq!(
609			scheduled_requests,
610			vec![ScheduledRequest {
611				when_executable: 1,
612				action: DelegationAction::Decrease(50),
613			},]
614		);
615		assert_eq!(
616			state.less_total, 50,
617			"less_total should be reduced by the amount of the cancelled request"
618		);
619	}
620
621	#[test]
622	fn test_cancel_request_with_state_does_nothing_when_request_does_not_exist() {
623		let mut state = Delegator {
624			id: 1,
625			delegations: OrderedSet::from(vec![Bond {
626				amount: 100,
627				owner: 2,
628			}]),
629			total: 100,
630			less_total: 100,
631			status: crate::DelegatorStatus::Active,
632		};
633		let mut scheduled_requests: BoundedVec<
634			ScheduledRequest<u128>,
635			<Test as crate::pallet::Config>::MaxScheduledRequestsPerDelegator,
636		> = BoundedVec::default();
637		let removed_request =
638			<Pallet<Test>>::cancel_request_with_state(&mut state, &mut scheduled_requests);
639
640		assert_eq!(removed_request, None,);
641		assert_eq!(
642			scheduled_requests.len(),
643			0,
644			"scheduled_requests should remain empty"
645		);
646		assert_eq!(
647			state.less_total, 100,
648			"less_total should remain unchanged when there is nothing to cancel"
649		);
650	}
651}