Skip to content

Conversation

@PolkadotDom
Copy link

@PolkadotDom PolkadotDom commented Jun 5, 2025

Rendered

WIP (STF + Unit Tests Complete)

Draft PR

Copy link
Contributor

@bkchr bkchr left a comment

Choose a reason for hiding this comment

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

Thank you! Generally a highly requested feature and I think the general idea looks good. I wrote up some edge cases, for which I already have some ideas how to solve them (probably), but would be really good to have them included.


### New Data & Runtime Logic

The [Voting Enum](https://github.com/paritytech/polkadot-sdk/blob/master/substrate/frame/conviction-voting/src/vote.rs#L225) 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/master/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

Choose a reason for hiding this comment

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

I would like to have the following edge cases covered here in the document:

  • The delegate votes, the delegator votes as well, the delegate removes their vote, the delegator removes their delegation, the delegate votes again
  • Generally the case of delegator voting on their own, delegate removing their vote and adding it back
  • Delegator voting first and then the delegate

There are probably some other edge cases we need to think of.

Copy link
Author

@PolkadotDom PolkadotDom Jun 8, 2025

Choose a reason for hiding this comment

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

Thank you for the comment. I'll address here and also update the document.

In general these edge cases are handled by the persistence of the poll_index -> retracted_votes state even when the delegate doesn't have a recorded vote for that poll_index. To showcase, let's take a look at the second edge case: The delegator votes -> their vote is added, the retracted vote count is added to their delegate, tally is updated accordingly. Next, the delegate removes their vote -> The vote is removed, tally is updated in accordance with retracted votes, and crucially retracted votes data persists because it is not yet 0. Finally, the delegate adds their vote back -> The new vote can take into account the retracted votes as it has persisted.

The other two cases are similar and I leave up to the reader.

An important note: Because poll_index -> retracted_votes data can persist when there is no vote, it's important that vote removal by delegators also cleans up the delegate's voting state (when retracted votes is 0 and vote is None), otherwise the delegate has no immediate monetary incentive to clean their voting state as no lock is incurred by that data item. Will add this to document as well.

Both of these are handled in the current WIP. The way I've chosen to handle poll_index -> retracted_votes persistence is by bundling that data with the pre-existing vote data tuple and making the actual vote an Option. old vs new. Then the logic just checks that the vote is None and the retracted_votes is 0 before fully removing a vote.

Edit: RFC is updated

Edit 2: Unit tests are up. The cases pointed out by Basti above can be found here

@PolkadotDom PolkadotDom requested a review from bkchr June 8, 2025 14:40
@anaelleltd anaelleltd added the Proposed Is awaiting 3 formal reviews. label Jun 11, 2025

### New Data & Runtime Logic

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 [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.

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.


### 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.

@PolkadotDom PolkadotDom requested a review from josepot July 6, 2025 17:35
@PolkadotDom
Copy link
Author

PolkadotDom commented Sep 4, 2025

As there have been no further comments, I will now begin the proposal process.

/rfc propose

@paritytech-rfc-bot
Copy link
Contributor

Hey @PolkadotDom, here is a link you can use to create the referendum aiming to approve this RFC number 0150.

Instructions
  1. Open the link.

  2. Switch to the Submission tab.

  1. Adjust the transaction if needed (for example, the proposal Origin).

  2. Submit the Transaction


It is based on commit hash 544d610a8a976f4d81d23ffb5a80ca9955c440f4.

The proposed remark text is: RFC_APPROVE(0150,c6ea7e8972e0cbf75592c57206daf133c34ec6b1151970930886239692fe34d4).

@bkchr
Copy link
Contributor

bkchr commented Sep 4, 2025

@PolkadotDom I know that you pinged me, but maybe I have missed any pings in the polkadot fellowship channel?

@PolkadotDom
Copy link
Author

PolkadotDom commented Sep 6, 2025

@bkchr I had talked about it/called for reviews during the last OpenDev fellowship call. I wrote about it in the fellowship channel a while back, but that would have been a couple months ago now.

Full transparency-- I'm still learning the best ways to navigate code changes as a non-parity fellow. If you feel I did something out of turn here, just let me know and I'll fix it moving forward.

Copy link
Member

@ggwpez ggwpez left a comment

Choose a reason for hiding this comment

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

Looks good, thanks for the details.

@anaelleltd anaelleltd added Reviewed Is ready for on-chain voting. and removed Proposed Is awaiting 3 formal reviews. labels Sep 8, 2025
Copy link
Contributor

@bkchr bkchr left a comment

Choose a reason for hiding this comment

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

Still reading better, but it would be really nice to get a proper explanation of the algorithm and not just some "guessing" on how it will work.


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).


## 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 🙌

@PolkadotDom PolkadotDom requested a review from bkchr September 19, 2025 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Reviewed Is ready for on-chain voting.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants