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		ensure!(
216			bonded_amount > decrease_amount,
217			DispatchErrorWithPostInfo {
218				post_info: Some(actual_weight).into(),
219				error: <Error<T>>::DelegatorBondBelowMin.into(),
220			},
221		);
222		let new_amount: BalanceOf<T> = (bonded_amount - decrease_amount).into();
223		ensure!(
224			new_amount >= T::MinDelegation::get(),
225			DispatchErrorWithPostInfo {
226				post_info: Some(actual_weight).into(),
227				error: <Error<T>>::DelegationBelowMin.into(),
228			},
229		);
230
231		// Net Total is total after pending orders are executed
232		let net_total = state.total().saturating_sub(state.less_total);
233		// Net Total is always >= MinDelegation
234		let max_subtracted_amount = net_total.saturating_sub(T::MinDelegation::get().into());
235		ensure!(
236			decrease_amount <= max_subtracted_amount,
237			DispatchErrorWithPostInfo {
238				post_info: Some(actual_weight).into(),
239				error: <Error<T>>::DelegatorBondBelowMin.into(),
240			},
241		);
242
243		let now = <Round<T>>::get().current;
244		let when = now.saturating_add(T::DelegationBondLessDelay::get());
245		scheduled_requests
246			.try_push(ScheduledRequest {
247				action: DelegationAction::Decrease(decrease_amount),
248				when_executable: when,
249			})
250			.map_err(|_| DispatchErrorWithPostInfo {
251				post_info: Some(actual_weight).into(),
252				error: Error::<T>::ExceedMaxDelegationsPerDelegator.into(),
253			})?;
254		state.less_total = state.less_total.saturating_add(decrease_amount);
255		if is_new_delegator {
256			<DelegationScheduledRequestsPerCollator<T>>::mutate(&collator, |c| {
257				*c = c.saturating_add(1);
258			});
259		}
260		<DelegationScheduledRequests<T>>::insert(
261			collator.clone(),
262			delegator.clone(),
263			scheduled_requests,
264		);
265		<DelegatorState<T>>::insert(delegator.clone(), state);
266
267		Self::deposit_event(Event::DelegationDecreaseScheduled {
268			delegator,
269			candidate: collator,
270			amount_to_decrease: decrease_amount,
271			execute_round: when,
272		});
273		Ok(Some(actual_weight).into())
274	}
275
276	/// Cancels the delegator's existing [ScheduledRequest] towards a given collator.
277	pub(crate) fn delegation_cancel_request(
278		collator: T::AccountId,
279		delegator: T::AccountId,
280	) -> DispatchResultWithPostInfo {
281		let mut state = <DelegatorState<T>>::get(&delegator).ok_or(<Error<T>>::DelegatorDNE)?;
282		let mut scheduled_requests = <DelegationScheduledRequests<T>>::get(&collator, &delegator);
283		let actual_weight =
284			<T as Config>::WeightInfo::cancel_delegation_request(scheduled_requests.len() as u32);
285
286		let request = Self::cancel_request_with_state(&mut state, &mut scheduled_requests).ok_or(
287			DispatchErrorWithPostInfo {
288				post_info: Some(actual_weight).into(),
289				error: <Error<T>>::PendingDelegationRequestDNE.into(),
290			},
291		)?;
292
293		if scheduled_requests.is_empty() {
294			<DelegationScheduledRequestsPerCollator<T>>::mutate(&collator, |c| {
295				*c = c.saturating_sub(1);
296			});
297			<DelegationScheduledRequests<T>>::remove(&collator, &delegator);
298		} else {
299			<DelegationScheduledRequests<T>>::insert(
300				collator.clone(),
301				delegator.clone(),
302				scheduled_requests,
303			);
304		}
305		<DelegatorState<T>>::insert(delegator.clone(), state);
306
307		Self::deposit_event(Event::CancelledDelegationRequest {
308			delegator,
309			collator,
310			cancelled_request: request.into(),
311		});
312		Ok(Some(actual_weight).into())
313	}
314
315	fn cancel_request_with_state(
316		state: &mut Delegator<T::AccountId, BalanceOf<T>>,
317		scheduled_requests: &mut BoundedVec<
318			ScheduledRequest<BalanceOf<T>>,
319			T::MaxScheduledRequestsPerDelegator,
320		>,
321	) -> Option<ScheduledRequest<BalanceOf<T>>> {
322		if scheduled_requests.is_empty() {
323			return None;
324		}
325
326		// `BoundedVec::remove` can panic, but we make sure it will not happen by
327		// checking above that `scheduled_requests` is not empty.
328		let request = scheduled_requests.remove(0);
329		let amount = request.action.amount();
330		state.less_total = state.less_total.saturating_sub(amount);
331		Some(request)
332	}
333
334	/// Executes the delegator's existing [ScheduledRequest] towards a given collator.
335	pub(crate) fn delegation_execute_scheduled_request(
336		collator: T::AccountId,
337		delegator: T::AccountId,
338	) -> DispatchResultWithPostInfo {
339		let mut state = <DelegatorState<T>>::get(&delegator).ok_or(<Error<T>>::DelegatorDNE)?;
340		let mut scheduled_requests = <DelegationScheduledRequests<T>>::get(&collator, &delegator);
341		let request = scheduled_requests
342			.first()
343			.ok_or(<Error<T>>::PendingDelegationRequestDNE)?;
344
345		let now = <Round<T>>::get().current;
346		ensure!(
347			request.when_executable <= now,
348			<Error<T>>::PendingDelegationRequestNotDueYet
349		);
350
351		match request.action {
352			DelegationAction::Revoke(amount) => {
353				let actual_weight =
354					<T as Config>::WeightInfo::execute_delegator_revoke_delegation_worst();
355
356				// revoking last delegation => leaving set of delegators
357				let leaving = if state.delegations.0.len() == 1usize {
358					true
359				} else {
360					ensure!(
361						state.total().saturating_sub(T::MinDelegation::get().into()) >= amount,
362						DispatchErrorWithPostInfo {
363							post_info: Some(actual_weight).into(),
364							error: <Error<T>>::DelegatorBondBelowMin.into(),
365						}
366					);
367					false
368				};
369
370				// remove from pending requests
371				// `BoundedVec::remove` can panic, but we make sure it will not happen by checking above that `scheduled_requests` is not empty.
372				let amount = scheduled_requests.remove(0).action.amount();
373				state.less_total = state.less_total.saturating_sub(amount);
374
375				// remove delegation from delegator state
376				state.rm_delegation::<T>(&collator);
377
378				// remove delegation from auto-compounding info
379				<AutoCompoundDelegations<T>>::remove_auto_compound(&collator, &delegator);
380
381				// remove delegation from collator state delegations
382				Self::delegator_leaves_candidate(collator.clone(), delegator.clone(), amount)
383					.map_err(|err| DispatchErrorWithPostInfo {
384						post_info: Some(actual_weight).into(),
385						error: err,
386					})?;
387				Self::deposit_event(Event::DelegationRevoked {
388					delegator: delegator.clone(),
389					candidate: collator.clone(),
390					unstaked_amount: amount,
391				});
392				if scheduled_requests.is_empty() {
393					<DelegationScheduledRequests<T>>::remove(&collator, &delegator);
394					<DelegationScheduledRequestsPerCollator<T>>::mutate(&collator, |c| {
395						*c = c.saturating_sub(1);
396					});
397				} else {
398					<DelegationScheduledRequests<T>>::insert(
399						collator.clone(),
400						delegator.clone(),
401						scheduled_requests,
402					);
403				}
404				if leaving {
405					<DelegatorState<T>>::remove(&delegator);
406					Self::deposit_event(Event::DelegatorLeft {
407						delegator,
408						unstaked_amount: amount,
409					});
410				} else {
411					<DelegatorState<T>>::insert(&delegator, state);
412				}
413				Ok(Some(actual_weight).into())
414			}
415			DelegationAction::Decrease(_) => {
416				let actual_weight =
417					<T as Config>::WeightInfo::execute_delegator_revoke_delegation_worst();
418
419				// remove from pending requests
420				// `BoundedVec::remove` can panic, but we make sure it will not happen by checking above that `scheduled_requests` is not empty.
421				let amount = scheduled_requests.remove(0).action.amount();
422				state.less_total = state.less_total.saturating_sub(amount);
423
424				// decrease delegation
425				for bond in &mut state.delegations.0 {
426					if bond.owner == collator {
427						return if bond.amount > amount {
428							let amount_before: BalanceOf<T> = bond.amount.into();
429							bond.amount = bond.amount.saturating_sub(amount);
430							let mut collator_info = <CandidateInfo<T>>::get(&collator)
431								.ok_or(<Error<T>>::CandidateDNE)
432								.map_err(|err| DispatchErrorWithPostInfo {
433									post_info: Some(actual_weight).into(),
434									error: err.into(),
435								})?;
436
437							state
438								.total_sub_if::<T, _>(amount, |total| {
439									let new_total: BalanceOf<T> = total.into();
440									ensure!(
441										new_total >= T::MinDelegation::get(),
442										<Error<T>>::DelegationBelowMin
443									);
444
445									Ok(())
446								})
447								.map_err(|err| DispatchErrorWithPostInfo {
448									post_info: Some(actual_weight).into(),
449									error: err,
450								})?;
451
452							// need to go into decrease_delegation
453							let in_top = collator_info
454								.decrease_delegation::<T>(
455									&collator,
456									delegator.clone(),
457									amount_before,
458									amount,
459								)
460								.map_err(|err| DispatchErrorWithPostInfo {
461									post_info: Some(actual_weight).into(),
462									error: err,
463								})?;
464							<CandidateInfo<T>>::insert(&collator, collator_info);
465							let new_total_staked = <Total<T>>::get().saturating_sub(amount);
466							<Total<T>>::put(new_total_staked);
467
468							if scheduled_requests.is_empty() {
469								<DelegationScheduledRequests<T>>::remove(&collator, &delegator);
470								<DelegationScheduledRequestsPerCollator<T>>::mutate(
471									&collator,
472									|c| {
473										*c = c.saturating_sub(1);
474									},
475								);
476							} else {
477								<DelegationScheduledRequests<T>>::insert(
478									collator.clone(),
479									delegator.clone(),
480									scheduled_requests,
481								);
482							}
483							<DelegatorState<T>>::insert(delegator.clone(), state);
484							Self::deposit_event(Event::DelegationDecreased {
485								delegator,
486								candidate: collator.clone(),
487								amount,
488								in_top,
489							});
490							Ok(Some(actual_weight).into())
491						} else {
492							// must rm entire delegation if bond.amount <= less or cancel request
493							Err(DispatchErrorWithPostInfo {
494								post_info: Some(actual_weight).into(),
495								error: <Error<T>>::DelegationBelowMin.into(),
496							})
497						};
498					}
499				}
500				Err(DispatchErrorWithPostInfo {
501					post_info: Some(actual_weight).into(),
502					error: <Error<T>>::DelegationDNE.into(),
503				})
504			}
505		}
506	}
507
508	/// Removes the delegator's existing [ScheduledRequest] towards a given collator, if exists.
509	/// The state needs to be persisted by the caller of this function.
510	pub(crate) fn delegation_remove_request_with_state(
511		collator: &T::AccountId,
512		delegator: &T::AccountId,
513		state: &mut Delegator<T::AccountId, BalanceOf<T>>,
514	) {
515		let scheduled_requests = <DelegationScheduledRequests<T>>::get(collator, delegator);
516
517		if scheduled_requests.is_empty() {
518			return;
519		}
520
521		// Calculate total amount across all scheduled requests
522		let total_amount: BalanceOf<T> = scheduled_requests
523			.iter()
524			.map(|request| request.action.amount())
525			.fold(BalanceOf::<T>::zero(), |acc, amount| {
526				acc.saturating_add(amount)
527			});
528
529		state.less_total = state.less_total.saturating_sub(total_amount);
530		<DelegationScheduledRequests<T>>::remove(collator, delegator);
531		<DelegationScheduledRequestsPerCollator<T>>::mutate(collator, |c| {
532			*c = c.saturating_sub(1);
533		});
534	}
535
536	/// Returns true if a [ScheduledRequest] exists for a given delegation
537	pub fn delegation_request_exists(collator: &T::AccountId, delegator: &T::AccountId) -> bool {
538		!<DelegationScheduledRequests<T>>::get(collator, delegator).is_empty()
539	}
540
541	/// Returns true if a [DelegationAction::Revoke] [ScheduledRequest] exists for a given delegation
542	pub fn delegation_request_revoke_exists(
543		collator: &T::AccountId,
544		delegator: &T::AccountId,
545	) -> bool {
546		<DelegationScheduledRequests<T>>::get(collator, delegator)
547			.iter()
548			.any(|req| matches!(req.action, DelegationAction::Revoke(_)))
549	}
550}
551
552#[cfg(test)]
553mod tests {
554	use super::*;
555	use crate::{mock::Test, set::OrderedSet, Bond};
556
557	#[test]
558	fn test_cancel_request_with_state_removes_request_for_correct_delegator_and_updates_state() {
559		let mut state = Delegator {
560			id: 1,
561			delegations: OrderedSet::from(vec![Bond {
562				amount: 100,
563				owner: 2,
564			}]),
565			total: 100,
566			less_total: 150,
567			status: crate::DelegatorStatus::Active,
568		};
569		let mut scheduled_requests = vec![
570			ScheduledRequest {
571				when_executable: 1,
572				action: DelegationAction::Revoke(100),
573			},
574			ScheduledRequest {
575				when_executable: 1,
576				action: DelegationAction::Decrease(50),
577			},
578		]
579		.try_into()
580		.expect("must succeed");
581		let removed_request =
582			<Pallet<Test>>::cancel_request_with_state(&mut state, &mut scheduled_requests);
583
584		assert_eq!(
585			removed_request,
586			Some(ScheduledRequest {
587				when_executable: 1,
588				action: DelegationAction::Revoke(100),
589			})
590		);
591		assert_eq!(
592			scheduled_requests,
593			vec![ScheduledRequest {
594				when_executable: 1,
595				action: DelegationAction::Decrease(50),
596			},]
597		);
598		assert_eq!(
599			state.less_total, 50,
600			"less_total should be reduced by the amount of the cancelled request"
601		);
602	}
603
604	#[test]
605	fn test_cancel_request_with_state_does_nothing_when_request_does_not_exist() {
606		let mut state = Delegator {
607			id: 1,
608			delegations: OrderedSet::from(vec![Bond {
609				amount: 100,
610				owner: 2,
611			}]),
612			total: 100,
613			less_total: 100,
614			status: crate::DelegatorStatus::Active,
615		};
616		let mut scheduled_requests: BoundedVec<
617			ScheduledRequest<u128>,
618			<Test as crate::pallet::Config>::MaxScheduledRequestsPerDelegator,
619		> = BoundedVec::default();
620		let removed_request =
621			<Pallet<Test>>::cancel_request_with_state(&mut state, &mut scheduled_requests);
622
623		assert_eq!(removed_request, None,);
624		assert_eq!(
625			scheduled_requests.len(),
626			0,
627			"scheduled_requests should remain empty"
628		);
629		assert_eq!(
630			state.less_total, 100,
631			"less_total should remain unchanged when there is nothing to cancel"
632		);
633	}
634}