Skip to content

Commit 342f46a

Browse files
ndkazubkchr
andauthored
Voting hook (#7703)
# Description This is a continuation of the following [PR](#5735 (comment)) --------- Co-authored-by: Bastian Köcher <[email protected]>
1 parent d13f1f6 commit 342f46a

File tree

9 files changed

+385
-9
lines changed

9 files changed

+385
-9
lines changed

polkadot/runtime/rococo/src/governance/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ impl pallet_conviction_voting::Config for Runtime {
4848
frame_support::traits::tokens::currency::ActiveIssuanceOf<Balances, Self::AccountId>;
4949
type Polls = Referenda;
5050
type BlockNumberProvider = System;
51+
type VotingHooks = ();
5152
}
5253

5354
parameter_types! {

polkadot/runtime/westend/src/governance/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ impl pallet_conviction_voting::Config for Runtime {
4545
frame_support::traits::tokens::currency::ActiveIssuanceOf<Balances, Self::AccountId>;
4646
type Polls = Referenda;
4747
type BlockNumberProvider = System;
48+
type VotingHooks = ();
4849
}
4950

5051
parameter_types! {

prdoc/pr_7703.prdoc

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
title: 'Add voting hooks to Conviction_Voting'
2+
doc:
3+
- audience: Runtime Dev
4+
description: |
5+
This change introduces voting hooks to the conviction-voting pallet, enabling developers to
6+
customize behavior during various stages of the voting process. These hooks provide a mechanism
7+
to execute specific logic before a vote is recorded, before a vote is removed, or when a vote
8+
fails to be recorded, while maintaining compatibility with the existing conviction-voting pallet.
9+
10+
The key hooks include:
11+
- `on_vote`: Called before a vote is recorded. This hook allows developers to validate or
12+
perform actions based on the vote. If it returns an error, the voting operation is reverted.
13+
However, any storage modifications made by this hook will persist even if the vote fails later.
14+
- `on_remove_vote`: Called before a vote is removed. This hook cannot fail and is useful for
15+
cleanup or additional logic when a vote is removed.
16+
- `lock_balance_on_unsuccessful_vote`: Called when a vote fails to be recorded, such as due to
17+
insufficient balance. It allows locking a specific balance amount as part of the failure handling.
18+
19+
Advantages of using voting hooks:
20+
- Flexibility: Developers can implement custom logic to extend or modify the behavior of the
21+
conviction-voting pallet.
22+
- Control: Hooks provide fine-grained control over different stages of the voting process.
23+
- Error Handling: The `on_vote` hook enables early validation, preventing (to some extent) invalid votes from
24+
being recorded.
25+
26+
How to use:
27+
- Implement the `VotingHooks` trait in your runtime or custom module.
28+
- Define the desired behavior for each hook method, such as validation logic in `on_vote` or
29+
cleanup actions in `on_remove_vote`.
30+
- Integrate the implementation with the conviction-voting pallet to enable the hooks.
31+
crates:
32+
- name: pallet-conviction-voting
33+
bump: major
34+
- name: rococo-runtime
35+
bump: minor
36+
- name: westend-runtime
37+
bump: minor

substrate/bin/node/runtime/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,7 @@ impl pallet_conviction_voting::Config for Runtime {
10151015
type MaxTurnout = frame_support::traits::TotalIssuanceOf<Balances, Self::AccountId>;
10161016
type Polls = Referenda;
10171017
type BlockNumberProvider = System;
1018+
type VotingHooks = ();
10181019
}
10191020

10201021
parameter_types! {

substrate/frame/conviction-voting/src/benchmarking.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ benchmarks_instance_pallet! {
7373
whitelist_account!(caller);
7474
let account_vote = account_vote::<T, I>(100u32.into());
7575

76+
T::VotingHooks::on_vote_worst_case(&caller);
77+
7678
let (class, all_polls) = fill_voting::<T, I>();
7779
let polls = &all_polls[&class];
7880
let r = polls.len() - 1;
@@ -100,6 +102,8 @@ benchmarks_instance_pallet! {
100102
whitelist_account!(caller);
101103
let old_account_vote = account_vote::<T, I>(100u32.into());
102104

105+
T::VotingHooks::on_vote_worst_case(&caller);
106+
103107
let (class, all_polls) = fill_voting::<T, I>();
104108
let polls = &all_polls[&class];
105109
let r = polls.len();
@@ -128,6 +132,8 @@ benchmarks_instance_pallet! {
128132
whitelist_account!(caller);
129133
let old_account_vote = account_vote::<T, I>(100u32.into());
130134

135+
T::VotingHooks::on_vote_worst_case(&caller);
136+
131137
let (class, all_polls) = fill_voting::<T, I>();
132138
let polls = &all_polls[&class];
133139
let r = polls.len();
@@ -157,6 +163,8 @@ benchmarks_instance_pallet! {
157163
whitelist_account!(caller);
158164
let old_account_vote = account_vote::<T, I>(100u32.into());
159165

166+
T::VotingHooks::on_vote_worst_case(&caller);
167+
160168
let (class, all_polls) = fill_voting::<T, I>();
161169
let polls = &all_polls[&class];
162170
let r = polls.len();

substrate/frame/conviction-voting/src/lib.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ use sp_runtime::{
4343
};
4444

4545
mod conviction;
46+
mod traits;
4647
mod types;
4748
mod vote;
4849
pub mod weights;
4950

5051
pub use self::{
5152
conviction::Conviction,
5253
pallet::*,
54+
traits::{Status, VotingHooks},
5355
types::{Delegations, Tally, UnvoteScope},
5456
vote::{AccountVote, Casting, Delegating, Vote, Voting},
5557
weights::WeightInfo,
@@ -142,6 +144,18 @@ pub mod pallet {
142144
type VoteLockingPeriod: Get<BlockNumberFor<Self, I>>;
143145
/// Provider for the block number. Normally this is the `frame_system` pallet.
144146
type BlockNumberProvider: BlockNumberProvider;
147+
/// Hooks are called when a new vote is registered or an existing vote is removed.
148+
///
149+
/// The trait does not expose weight information.
150+
/// The weight of each hook is assumed to be benchmarked as part of the function that calls
151+
/// it. Hooks should never recursively call into functions that called,
152+
/// directly or indirectly, the function that called them.
153+
/// This could lead to infinite recursion and stack overflow.
154+
/// Note that this also means to not call into other generic functionality like batch or
155+
/// similar. Also, anything that a hook did will be subject to the transactional semantics
156+
/// of the calling function. This means that if the calling function fails, the hook will
157+
/// be rolled back without further notice.
158+
type VotingHooks: VotingHooks<Self::AccountId, PollIndexOf<Self, I>, BalanceOf<Self, I>>;
145159
}
146160

147161
/// All voting for a particular voter in a particular voting class. We store the balance for the
@@ -410,6 +424,9 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
410424
vote.balance() <= T::Currency::total_balance(who),
411425
Error::<T, I>::InsufficientFunds
412426
);
427+
// Call on_vote hook
428+
T::VotingHooks::on_before_vote(who, poll_index, vote)?;
429+
413430
T::Polls::try_access_poll(poll_index, |poll_status| {
414431
let (tally, class) = poll_status.ensure_ongoing().ok_or(Error::<T, I>::NotOngoing)?;
415432
VotingFor::<T, I>::try_mutate(who, &class, |voting| {
@@ -435,7 +452,7 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
435452
tally.increase(approve, *delegations);
436453
}
437454
} else {
438-
return Err(Error::<T, I>::AlreadyDelegating.into())
455+
return Err(Error::<T, I>::AlreadyDelegating.into());
439456
}
440457
// Extend the lock to `balance` (rather than setting it) since we don't know what
441458
// other votes are in place.
@@ -477,10 +494,13 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
477494
tally.reduce(approve, *delegations);
478495
}
479496
Self::deposit_event(Event::VoteRemoved { who: who.clone(), vote: v.1 });
497+
T::VotingHooks::on_remove_vote(who, poll_index, Status::Ongoing);
480498
Ok(())
481499
},
482500
PollStatus::Completed(end, approved) => {
483-
if let Some((lock_periods, balance)) = v.1.locked_if(approved) {
501+
if let Some((lock_periods, balance)) =
502+
v.1.locked_if(vote::LockedIf::Status(approved))
503+
{
484504
let unlock_at = end.saturating_add(
485505
T::VoteLockingPeriod::get().saturating_mul(lock_periods.into()),
486506
);
@@ -492,10 +512,37 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
492512
);
493513
prior.accumulate(unlock_at, balance)
494514
}
515+
} else if v.1.as_standard().is_some_and(|vote| vote != approved) {
516+
// Unsuccessful vote, use special hook to lock the funds too in case of
517+
// conviction.
518+
if let Some(to_lock) =
519+
T::VotingHooks::lock_balance_on_unsuccessful_vote(who, poll_index)
520+
{
521+
if let AccountVote::Standard { vote, .. } = v.1 {
522+
let unlock_at = end.saturating_add(
523+
T::VoteLockingPeriod::get()
524+
.saturating_mul(vote.conviction.lock_periods().into()),
525+
);
526+
let now = T::BlockNumberProvider::current_block_number();
527+
if now < unlock_at {
528+
ensure!(
529+
matches!(scope, UnvoteScope::Any),
530+
Error::<T, I>::NoPermissionYet
531+
);
532+
prior.accumulate(unlock_at, to_lock)
533+
}
534+
}
535+
}
495536
}
537+
// Call on_remove_vote hook
538+
T::VotingHooks::on_remove_vote(who, poll_index, Status::Completed);
539+
Ok(())
540+
},
541+
PollStatus::None => {
542+
// Poll was cancelled.
543+
T::VotingHooks::on_remove_vote(who, poll_index, Status::None);
496544
Ok(())
497545
},
498-
PollStatus::None => Ok(()), // Poll was cancelled.
499546
})
500547
} else {
501548
Ok(())

substrate/frame/conviction-voting/src/tests.rs

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
//! The crate's tests.
1919
20-
use std::collections::BTreeMap;
20+
use std::{cell::RefCell, collections::BTreeMap};
2121

2222
use frame_support::{
2323
assert_noop, assert_ok, derive_impl, parameter_types,
@@ -155,6 +155,7 @@ impl Config for Test {
155155
type MaxTurnout = frame_support::traits::TotalIssuanceOf<Balances, Self::AccountId>;
156156
type Polls = TestPolls;
157157
type BlockNumberProvider = System;
158+
type VotingHooks = HooksHandler;
158159
}
159160

160161
pub fn new_test_ext() -> sp_io::TestExternalities {
@@ -913,3 +914,140 @@ fn errors_with_remove_vote_work() {
913914
);
914915
});
915916
}
917+
918+
thread_local! {
919+
static LAST_ON_VOTE_DATA: RefCell<Option<(u64, u8, AccountVote<u64>)>> = RefCell::new(None);
920+
static LAST_ON_REMOVE_VOTE_DATA: RefCell<Option<(u64, u8, Status)>> = RefCell::new(None);
921+
static LAST_LOCKED_IF_UNSUCCESSFUL_VOTE_DATA: RefCell<Option<(u64, u8)>> = RefCell::new(None);
922+
static REMOVE_VOTE_LOCKED_AMOUNT: RefCell<Option<u64>> = RefCell::new(None);
923+
}
924+
925+
pub struct HooksHandler;
926+
927+
impl HooksHandler {
928+
fn last_on_vote_data() -> Option<(u64, u8, AccountVote<u64>)> {
929+
LAST_ON_VOTE_DATA.with(|data| *data.borrow())
930+
}
931+
932+
fn last_on_remove_vote_data() -> Option<(u64, u8, Status)> {
933+
LAST_ON_REMOVE_VOTE_DATA.with(|data| *data.borrow())
934+
}
935+
936+
fn last_locked_if_unsuccessful_vote_data() -> Option<(u64, u8)> {
937+
LAST_LOCKED_IF_UNSUCCESSFUL_VOTE_DATA.with(|data| *data.borrow())
938+
}
939+
940+
fn reset() {
941+
LAST_ON_VOTE_DATA.with(|data| *data.borrow_mut() = None);
942+
LAST_ON_REMOVE_VOTE_DATA.with(|data| *data.borrow_mut() = None);
943+
LAST_LOCKED_IF_UNSUCCESSFUL_VOTE_DATA.with(|data| *data.borrow_mut() = None);
944+
REMOVE_VOTE_LOCKED_AMOUNT.with(|data| *data.borrow_mut() = None);
945+
}
946+
947+
fn with_remove_locked_amount(v: u64) {
948+
REMOVE_VOTE_LOCKED_AMOUNT.with(|data| *data.borrow_mut() = Some(v));
949+
}
950+
}
951+
952+
impl VotingHooks<u64, u8, u64> for HooksHandler {
953+
fn on_before_vote(who: &u64, ref_index: u8, vote: AccountVote<u64>) -> DispatchResult {
954+
LAST_ON_VOTE_DATA.with(|data| {
955+
*data.borrow_mut() = Some((*who, ref_index, vote));
956+
});
957+
Ok(())
958+
}
959+
960+
fn on_remove_vote(who: &u64, ref_index: u8, ongoing: Status) {
961+
LAST_ON_REMOVE_VOTE_DATA.with(|data| {
962+
*data.borrow_mut() = Some((*who, ref_index, ongoing));
963+
});
964+
}
965+
966+
fn lock_balance_on_unsuccessful_vote(who: &u64, ref_index: u8) -> Option<u64> {
967+
LAST_LOCKED_IF_UNSUCCESSFUL_VOTE_DATA.with(|data| {
968+
*data.borrow_mut() = Some((*who, ref_index));
969+
970+
REMOVE_VOTE_LOCKED_AMOUNT.with(|data| *data.borrow())
971+
})
972+
}
973+
974+
#[cfg(feature = "runtime-benchmarks")]
975+
fn on_vote_worst_case(_who: &u64) {}
976+
977+
#[cfg(feature = "runtime-benchmarks")]
978+
fn on_remove_vote_worst_case(_who: &u64) {}
979+
}
980+
981+
#[test]
982+
fn voting_hooks_are_called_correctly() {
983+
new_test_ext().execute_with(|| {
984+
let c = class(3);
985+
986+
let usable_balance_1 = Balances::usable_balance(1);
987+
dbg!(usable_balance_1);
988+
989+
// Voting
990+
assert_ok!(Voting::vote(RuntimeOrigin::signed(1), 3, aye(1, 1)));
991+
assert_eq!(
992+
HooksHandler::last_on_vote_data(),
993+
Some((
994+
1,
995+
3,
996+
AccountVote::Standard {
997+
vote: Vote { aye: true, conviction: Conviction::Locked1x },
998+
balance: 1
999+
}
1000+
))
1001+
);
1002+
assert_ok!(Voting::vote(RuntimeOrigin::signed(2), 3, nay(20, 2)));
1003+
assert_eq!(
1004+
HooksHandler::last_on_vote_data(),
1005+
Some((
1006+
2,
1007+
3,
1008+
AccountVote::Standard {
1009+
vote: Vote { aye: false, conviction: Conviction::Locked2x },
1010+
balance: 20
1011+
}
1012+
))
1013+
);
1014+
HooksHandler::reset();
1015+
1016+
// removing vote while ongoing
1017+
assert_ok!(Voting::vote(RuntimeOrigin::signed(3), 3, nay(20, 0)));
1018+
assert_eq!(
1019+
HooksHandler::last_on_vote_data(),
1020+
Some((
1021+
3,
1022+
3,
1023+
AccountVote::Standard {
1024+
vote: Vote { aye: false, conviction: Conviction::None },
1025+
balance: 20
1026+
}
1027+
))
1028+
);
1029+
assert_ok!(Voting::remove_vote(RuntimeOrigin::signed(3), Some(c), 3));
1030+
assert_eq!(HooksHandler::last_on_remove_vote_data(), Some((3, 3, Status::Ongoing)));
1031+
HooksHandler::reset();
1032+
1033+
Polls::set(vec![(3, Completed(3, false))].into_iter().collect());
1034+
1035+
// removing successful vote while completed
1036+
assert_ok!(Voting::remove_vote(RuntimeOrigin::signed(2), Some(c), 3));
1037+
assert_eq!(HooksHandler::last_on_remove_vote_data(), Some((2, 3, Status::Completed)));
1038+
assert_eq!(HooksHandler::last_locked_if_unsuccessful_vote_data(), None);
1039+
1040+
HooksHandler::reset();
1041+
1042+
HooksHandler::with_remove_locked_amount(5);
1043+
1044+
// removing unsuccessful vote when completed
1045+
assert_ok!(Voting::remove_vote(RuntimeOrigin::signed(1), Some(c), 3));
1046+
assert_eq!(HooksHandler::last_on_remove_vote_data(), Some((1, 3, Status::Completed)));
1047+
assert_eq!(HooksHandler::last_locked_if_unsuccessful_vote_data(), Some((1, 3)));
1048+
1049+
// Removing unsuccessful vote when completed should lock if given amount from the hook
1050+
assert_ok!(Voting::unlock(RuntimeOrigin::signed(1), c, 1));
1051+
assert_eq!(Balances::usable_balance(1), 5);
1052+
});
1053+
}

0 commit comments

Comments
 (0)