Skip to content
115 changes: 115 additions & 0 deletions text/0150-voting-while-delegating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# RFC-150: Allow Voting While Delegating

| | |
| --------------- | ------------------------------------------------------------------------------------------- |
| **Start Date** | June 5th, 2025 |
| **Description** | Allow voters to simultaneously delegate and vote |
| **Authors** | polka.dom (polkadotdom) |

## Summary

This RFC proposes changes to `pallet-conviction-voting` that allow for simultaneous voting and delegation. For example, Alice could delegate to Bob, then later vote on a referendum while keeping their delegation to Bob intact. It is a strict subset of Leemo's [RFC 35](https://github.com/polkadot-fellows/RFCs/pull/35).

## Motivation

### Backdrop
Under our current voting system, a voter can either vote or delegate. To vote, they must first ensure they have no delegate, and to delegate, they must first clear their current votes.

### The Issue

Empirically, the vast majority of people do not vote on day to day policy. This was foreseen and is the reason governance has delegation. However, more worriedly, it has also been observed that most people do not delegate either, leaving a large percentage of our voting population unrepresented.

### Factors Limiting Delegation

One could think of three major reasons for this lack of delegation.

- The voter does not know of anyone who accurately represents them.
- The voter does not want their right to vote stripped, in consideration of some yet unknown, highly important, referendum.
- The voter does not want to clear their voting data so as to delegate.

This RFC aims to solve the second and third issue and thus more accurately align governance to the true voter preferences.

### An Aside

One may ask, could a voter not just undelegate, vote, then delegate again? Could this just be built into the user interface? Unfortunately, this does not work due to the need to clear their votes before redelegation. In practice the voter would undelegate, vote, wait until the referendum is closed, hope that there's no other referenda they would like to vote on, then redelegate. At best it's a temporally extended friction. At worst the voter goes unrepresented in voting for the duration of the vote clearing period.


## Stakeholders

`Runtime developers`: If runtime developers are relying on the previous assumptions for their [VotingHooks](https://github.com/paritytech/polkadot-sdk/blob/939fc198daaf5e8ae319419f112dacbc1ea7aefe/substrate/frame/conviction-voting/src/lib.rs#L159) implementations, they will need to rethink their approach. In addition, a runtime migration is needed. Lastly, it is a serious change in governance that requires some consideration beyond the technical.

`App developers`: Apps like Subsquare and Polkassembly would need to update their user interface logic. They will also need to handle the new error.

`Users`: We will want users to be aware of the new functionality, though not required.

`Technical Writers`: This change will require rewrites of documentation and tutorials.

## Explanation

### New Data & Runtime Logic
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please rephrase this entire section. It should explain how the algorithm is working and not how the data structures are looking. It is right now possible to come up with this already, but the RFC is not laying out exactly how it works.

It would be nice to have the logic layed out for the different states, aka delegator voting first, delegate voting first, delegator removing votes etc.

Copy link
Author

Choose a reason for hiding this comment

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

Okay I've updated the explanation section. Let me know what you think 🙌


The [Voting Enum](https://github.com/paritytech/polkadot-sdk/blob/939fc198daaf5e8ae319419f112dacbc1ea7aefe/substrate/frame/conviction-voting/src/vote.rs#L256-L264) is first collapsed, as there's no longer a distinction between the variants. Then a `(poll index -> retracted votes count)` data item would be added to the user's voting data stored in [VotingFor](https://github.com/paritytech/polkadot-sdk/blob/939fc198daaf5e8ae319419f112dacbc1ea7aefe/substrate/frame/conviction-voting/src/lib.rs#L165). This would keep track of the per poll balance that has been clawed back from the user by those delegating to them.
Copy link
Contributor

@josepot josepot Jul 6, 2025

Choose a reason for hiding this comment

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

The Voting Enum is first collapsed, as there's no longer a distinction between the variants.

Then how do we know who someone is delegating towards? Wouldn't a new storage entry be needed for keeping track of the delegations?

Then a (poll index -> retracted votes count) data item would be added to the user's voting data stored in VotingFor. This would keep track of the per poll balance that has been clawed back from the user by those delegating to them.

I think that it doesn't make sense to have this inside the VotingFor data-structure. I think this belongs into the new storage that previously mentioned (the one that defines the delegations). Otherwise, if I'm delegating to Bob and then I cast my vote to ref X before Bob casts their vote to that same ref, then my vote will be computed twice b/c the storage entry for Bob's vote on that ref didn't exist when I voted.

Copy link
Contributor

@josepot josepot Jul 6, 2025

Choose a reason for hiding this comment

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

Oh, wait, I _think _ that I get it now!

I think that what you are saying is that instead of having an Enum, there would be a struct, where one fields would be votes, and the other field would be delegation, and the type of the delegation would be an Enum more or less like this?

enum Delegation<Balance, AccountId, ReferendumIndex, MaxVotes: Get<u32>> {
  Delegating(Option<(AccountId, Balance, Conviction)>),
  Receiving {
    delegations: Balance,
    clawback: BoundedVec<(ReferendumIndex, Balance), MaxVotes>
  },
}

and then the votes would probably be something more or less like this?

struct Votes {
  votes: BoundedVec<(ReferendumIndex, AccountVote<Balance>), MaxVotes>,
  prior: PriorLock<BlockNumber, Balance>,
}

Ok, in that case, that makes sense... Also, I think that with this change then we could consolidate the prior, so that it's only a property on the Votes.

If what you are proposing is something similar to this, then would you mind explaining this a bit better? 🙏

Copy link
Author

@PolkadotDom PolkadotDom Jul 6, 2025

Choose a reason for hiding this comment

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

Yes looking through it again I do believe it was missing some needed clarity. Thank you for the feedback on this. I've updated the RFC, let me know if it still follows poorly.

And yes! Something like that, where the underlying fields from the enums are consolidated into one struct. Though, in the end, mine looked like this:

pub struct Voting<Balance, AccountId, BlockNumber, PollIndex, MaxVotes>
where
	MaxVotes: Get<u32>,
{
	/// The current voting data of the account.
	pub votes: BoundedVec<VoteRecord<PollIndex, Balance>, MaxVotes>,
	/// The amount of balance delegated to some voting power.
	pub delegated_balance: Balance,
	/// A possible account to which the voting power is delegating.
	pub maybe_delegate: Option<AccountId>,
	/// The possible conviction with which the voting power is delegating. When this gets
	/// undelegated, the relevant lock begins.
	pub maybe_conviction: Option<Conviction>,
	/// The total amount of delegations that this account has received, post-conviction-weighting.
	pub delegations: Delegations<Balance>,
	/// Any pre-existing locks from past voting/delegating activity.
	pub prior: PriorLock<BlockNumber, Balance>,
}

With the retracted votes held in the vote record:

pub struct VoteRecord<PollIndex, Balance> {
	/// The poll index this information concerns
	pub poll_index: PollIndex,
	/// The vote this account has cast. Can be none if only retracted_votes info is needed
	pub maybe_vote: Option<AccountVote<Balance>>,
	/// The amount of votes retracted from this user for this poll. Can't be more than is
	/// delegated to them. Votes are retracted when a delegator votes in stead of their delegate.
	pub retracted_votes: RetractedVotes<Balance>,
}

I would think the final implementation would be more of a PR conversation though.


The implementation must allow for the `(poll index -> retracted votes)` data to exist even if the user does not currently have a vote for that poll. A simple example that highlights the necessity is as follows: A delegator votes first, then the delegate does. If the delegator is not allowed to create the retracted votes data on the delegate, the tally count would be corrupted when the delegate votes.
Copy link
Contributor

Choose a reason for hiding this comment

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

The implementation must allow for the (poll index -> retracted votes) data to exist even if the user does not currently have a vote for that poll.

This is IMO too vague. How would that be accomplished without a new storage on this pallet?

Copy link
Author

Choose a reason for hiding this comment

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

I believe answered by the conversation above, but it would be held in the new voting data struct. So it would slot in with the pre-existing VotingFor storage item.


It follows then that the delegator must also handle clean up of that data when their vote is removed. Otherwise, the delegate has no immediate monetary incentive to clean the retracted votes state.

All changes to pallet-conviction-voting's STF would follow those simple changes. For example, when a user votes standard, the final amount added to the poll's tally will be `balance + (amount delegated to user - retracted votes)`. Then, if they are delegating, it will update their delegate's vote data with the newly retracted votes.

The retracted amount is always the full delegated amount. For example, if Alice delegates 10 UNITS to Bob and then votes with 5 UNITS, the full 10 UNITS is still added as a clawback to Bob for that poll. This is both for simplicity and to ensure we don't make unnecessary assumptions about what Alice wants.

Because you need to add the clawback, a delegator's vote can affect a delegate's voting data. If a delegator's vote or delegation makes the delegate's voting data exceed [MaxVotes](https://github.com/paritytech/polkadot-sdk/blob/939fc198daaf5e8ae319419f112dacbc1ea7aefe/substrate/frame/conviction-voting/src/lib.rs#L138), the transaction will fail. In practice, this means this new system is somewhere between the old and the ideal. However, this will incentivize delegates to stay on top of voting data clearance. And given our current referenda rates and MaxVotes set to [512](https://github.com/polkadot-fellows/runtimes/blob/34ecb949660704ccf139a06afb075c6a729b1295/relay/polkadot/src/governance/mod.rs#L43), it would be difficult to hit this limit.
Copy link
Contributor

Choose a reason for hiding this comment

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

However, this will incentivize delegates to stay on top of voting data clearance

In which way is the delegate incentivized? It could prevent someone from voting for a proposal this way.

Copy link
Contributor

Choose a reason for hiding this comment

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

It is more that teh delegator would be incentivized to call removeOtherVote.

Copy link
Author

@PolkadotDom PolkadotDom Sep 18, 2025

Choose a reason for hiding this comment

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

It could prevent someone from voting for a proposal this way.

Luckily the same property that allows for voting while delegating also allows for undelegating / delegating while having a voting record. So if this attack was attempted, the user could just undelegate. Presumably after a removeOtherVote attempt. Really the only person who can lose in this situation is the delegate.

With that in mind, I believe there is sufficient political power gained from being a delegate that the delegate will attempt to retain as many delegators as possible (by keeping their voting record clean).


A new error is to be introduced that signals MaxVotes was reached specifically for the delegate's voting data.

### Locked Balance

A user's locked balance will be the greater of the delegation lock and the voting lock.

### Migrations

A runtime migration is necessary, though simple considering voting and delegation are currently separate.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think that the RFC is missing a detailed explanation on how the migration from the old to the new system would take place.

Copy link
Author

Choose a reason for hiding this comment

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

Fair 👍 I've updated this section. The migration itself is fairly simple, so still not a lot to say about it, but let me know if the new version is not detailed/clear enough.


## Drawbacks

There are two potential drawbacks to this system -

### An unbounded rate of change of the voter preferences function

If implemented, there will be no friction in delegating, undelegating, and voting. Therefore, there could be large and immediate shifts in the voter preferences function. In other voting systems we see bounds added to the rate of change (voting cycles, etc). That said, it is unclear whether this is desired or advantageous. Additionally, there are more easily parameterized and analytically tractable ways to handle this than what we currently have. See future directions.

### Lessened value in becoming a delegate

If a delegate's voting power can be stripped from them at any point, then there is necessarily a reduction in their power within the system. This provides less incentive to become a delegate. But again, there are more customizable ways to handle this if it proves necessary.

## Testing, Security, and Privacy

This change would mean a more complicated STF for voting, which would increase difficulty of hardening. Though sufficient unit testing should handle this with ease.

## Performance, Ergonomics, and Compatibility

### Performance

The proposed changes would increase both the compute and storage requirements by about 2x for all voting functions. No change in complexity.

### Ergonomics

Voting and delegation will both become more ergonomic for users, as there are no longer hard constraints affecting what you can do and when you can do it.

### Compatibility

Runtime developers will need to add the migration and ensure their hooks still work.

App developers will need to update their user interfaces to accommodate the new functionality. They will need to handle the new error as well.

## Prior Art and References

A current implementation can be found [here](https://github.com/paritytech/polkadot-sdk/pull/9026).

## Unresolved Questions

None

## Future Directions and Related Material

It is possible we would like to add a system parameter for the rate of change of the voting/delegation system. This could prevent wild swings in the voter preferences function and motivate/shield delegates by solidifying their positions over some amount of time. However, it's unclear that this would be valuable or even desirable.