Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Erc721 votes and general Votes component #1114

Merged
merged 49 commits into from
Oct 15, 2024

Conversation

ggonzalez94
Copy link
Collaborator

@ggonzalez94 ggonzalez94 commented Aug 22, 2024

Fixes #984

This PR introduces a generic Votes component that contains reusable logic for other token voting mechanisms. When we implemented ERC20Votes we did it in a single component that used storage variable names specific to ERC20 and it also uses StorageArray behind the scenes(Vec wasn't available at that moment), so the design is suboptimal and we would like to move away from it at least for ERC721Votes and future voting contracts.

This PR contains:

  • A proposed design that has a Votes component that holds all the common logic for voting.
  • An impl inside that component that has logic specific to ERC721Votes and ERC20Votes. If we want to add another form of voting we just need to add a new impl block to the same component that implements the get_voting_units function from the TokenVotesTrait trait.
  • Moves all interfaces and utils for voting and delegation to the governance module.
  • Remove ERC20Votes.
  • Use Vec for Trace and remove StorageArray
  • Anotra Docs

PR Checklist

  • Tests
  • Documentation
  • Added entry to CHANGELOG.md
  • Tried the feature on a public network

@ericnordelo
Copy link
Member

Hey @ggonzalez94. I have some small comments, but since the design can still change, I won't submit those to avoid unnecessary noise. I will leave my thoughts on this comment:

TLDR:

Using traits to pass custom functionality to component implementations is a powerful feature, that we've been using to provide some capabilities hard to implement otherwise, like Hooks for token transfers. With this said, abusing this feature is an antipattern IMO, since users of the library need to define/import extra code that is called in a different context, which can be both confusing and easy to misuse.

In this context, requiring an extra implementation (ERC721Votes in the example) for a logic that could be component-provided is an antipattern, since users won't ever need to modify this implementation (different from Hooks). In this case it is better to provide two different components, to relay on them to provide the full implementation of the logic.

Regarding the proposed design, I'm concerned about the UX for users of the library. The flow looks like this:

Current design:

  • The contract needs to add the ERC20Component
  • The contract needs to add the VotesComponent
  • The contract needs to add an ERC20Hooks impl with the after_update logic
  • The contract needs to add an extra ERC721Votes implementation with the get_voting_units (which name may be a bit confusing btw).

This can get quite verbose and somehow not as simple as it could be, I think we should favor having two separate components even if it includes some code repetition, just to improve the UX, since the user would only need to:

  • Add the ERC20Component
  • Add the ERC20VotesComponent
  • Add an ERC20Hooks impl with the after_update logic

With this said, I think we should be able to reuse code in a separate module (not a component), where we can have the common storage and the common embeddable implementation defined using storage_node and StoragePath, with the corresponding extra traits and implementations, but this would be the first time we use this in the library, and there's no clear documentation on how to work with this at least in components yet.

I recommend we separate the components now even if we have to reuse the Storage struct and the common logic in the embeddable implementation.

PD: When proposed to use two different implementations of the same component, I was thinking of something like Ownable, where the only thing that needs to change in the contract is the embedded implementations

@ggonzalez94
Copy link
Collaborator Author

Hey @ericnordelo! Thanks for the detailed feedback. I see your point, so I've been playing a bit with this and I don't think the DEX is that different between two different components vs single component with two different impl (unless there's something big I'm missing - if that's the case let me know).
This is how a contract that uses the proposed design for ERC721Votes would look like(removing some usings, etc to make the example shorter)

#[starknet::contract]
pub mod ERC721VotesMock {
    component!(path: VotesComponent, storage: erc721_votes, event: ERC721VotesEvent);
    component!(path: ERC721Component, storage: erc721, event: ERC721Event);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);
    component!(path: NoncesComponent, storage: nonces, event: NoncesEvent);

    //Votes and ERC721Votes
    // Here's the only major difference - where instead of having two `impl`(internal and external)
    // We have three(common internal and external) + ERC721 specific
    #[abi(embed_v0)]
    impl VotesImpl = VotesComponent::VotesImpl<ContractState>;
    impl VotesInternalImpl = VotesComponent::InternalImpl<ContractState>;
    impl ERC721VotesImpl = VotesComponent::ERC721VotesImpl<ContractState>;

    // ERC721
    #[abi(embed_v0)]
    impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl<ContractState>;
    impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;

    // Nonces
    #[abi(embed_v0)]
    impl NoncesImpl = NoncesComponent::NoncesImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        erc721_votes: VotesComponent::Storage,
        #[substorage(v0)]
        erc721: ERC721Component::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage,
        #[substorage(v0)]
        nonces: NoncesComponent::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC721VotesEvent: VotesComponent::Event,
        #[flat]
        ERC721Event: ERC721Component::Event,
        #[flat]
        SRC5Event: SRC5Component::Event,
        #[flat]
        NoncesEvent: NoncesComponent::Event
    }

    /// Required for hash computation.
    pub(crate) impl SNIP12MetadataImpl of SNIP12Metadata {
        fn name() -> felt252 {
            'DAPP_NAME'
        }
        fn version() -> felt252 {
            'DAPP_VERSION'
        }
    }

    //
    // Hooks and ctor not included to make the example shorter
    //
}

As you can see the only big difference vs how this looks on a contract that uses ERC20Votes is only one impl(actually I think this one is missing refering to the internal impl). In my opinion it is a tradeoff that is worth taking or at least analyzing more carefully for the amount of code repetition we are saving, which makes our code more auditable and less error prone(we only need to change one place).
Regarding wether people could misuse the component I haven't tested that much, but I imagine that is solvable with dependencies and trait bounds? (e.g. you can only use the ERC721Votes impl or ERC20Votes impl if your contract has implementations for the common trait).
Let me know if you have thoughts!

@ericnordelo
Copy link
Member

My main concern was the extra impl, because while it is true we want to avoid code repetition, I'm hesitant regarding adding trait requirements in component impls, when the implementation of the trait doesn't need to be modified by the user. With this said, I realized now that with the approach you propose users won't even need to explicitly add the extra ERC721VotesImpl in the contract, because as you mentioned, one implementation will depend on the ERC721Component, and the other one on the ERC20Component, then the compiler can automatically get the right impl as long as the parent module (VotesComponent) is in scope.

The preset can look like this:

#[starknet::contract]
pub mod ERC721Votes {
    (...)
    component!(path: ERC721Component, storage: erc721, event: ERC721Event);
    component!(path: VotesComponent, storage: votes, event: VotesEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);
    component!(path: NoncesComponent, storage: nonces, event: NoncesEvent);

    // ERC721 Mixin
    #[abi(embed_v0)]
    impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl<ContractState>;
    impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;

    // Votes
    #[abi(embed_v0)]
    impl VotesImpl = VotesComponent::VotesImpl<ContractState>;

    // Nonces
    #[abi(embed_v0)]
    impl NoncesImpl = NoncesComponent::NoncesImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        erc721: ERC721Component::Storage,
        #[substorage(v0)]
        votes: VotesComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage,
        #[substorage(v0)]
        nonces: NoncesComponent::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC721Event: ERC721Component::Event,
        #[flat]
        VotesEvent: VotesComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event,
        #[flat]
        NoncesEvent: NoncesComponent::Event
    }
    
    impl ERC721HooksImpl of ERC721Component::ERC721HooksTrait<ContractState> {
        fn after_update(
            ref self: ERC721Component::ComponentState<ContractState>,
            to: ContractAddress,
            token_id: u256,
            auth: ContractAddress,
        ) {
            let mut contract_state = ERC721Component::HasComponent::get_contract_mut(ref self);
            contract_state.votes.transfer_voting_units(from, to, 1);
        }
    }

    /// Required for hash computation.
    pub(crate) impl SNIP12MetadataImpl of SNIP12Metadata {
        fn name() -> felt252 {
            'DAPP_NAME'
        }
        fn version() -> felt252 {
            'DAPP_VERSION'
        }
    }

    #[constructor]
    fn constructor(
        ref self: ContractState,
        name: ByteArray,
        symbol: ByteArray,
        fixed_supply: u256,
        recipient: ContractAddress,
    ) {
        self.erc721.initializer(name, symbol, "");
    }
}

Notice that it is enough to add just the VotesImpl as long as VotesComponent is in scope.

And an ERC20Votes preset would only need to change the ERC721Component, the VotesImpl would work out of the box if the ERC20Votes impl is defined correctly in the VotesComponent.

I have nothing against this approach of one component then. Let's add the ERC20Votes impl to the PR too. Even if we are not removing the Legacy ERC20Votes yet, we can mark it in the CHANGELOG as deprecated, and we can start favoring the new VotesComponent.

@andrew-fleming
Copy link
Collaborator

I think the overall design looks really good! Some notes:

the compiler can automatically get the right impl as long as the parent module (VotesComponent) is in scope.

Great point

Regarding wether people could misuse the component I haven't tested that much, but I imagine that is solvable with dependencies and trait bounds? (e.g. you can only use the ERC721Votes impl or ERC20Votes impl if your contract has implementations for the common trait).

One case to consider is if a contract wanted to declare both ERC20 and ERC721 components. The contract can't expose both interfaces, but they could have the both InternalImpls in scope. The current design can't support that, but maybe we could leverage negative traits in the token votes impls?


This was briefly discussed offline, but I'll repeat it here: I think we should remove the IVotesToken interface because its contents (get_voting_units) isn't meant to be externally called. Instead, we can define it as a basic trait. Something like:

#[starknet::component]
pub mod VotesComponent {
    (...)

    trait TokenVotesTrait<TContractState> {
        fn get_voting_units(
            self: @TContractState,
            account: ContractAddress,
        ) -> u256;
    }

    impl ERC20Votes<...> of TokenVotesTrait<...> { }

    impl ERC721Votes<...> of TokenVotesTrait<...> { }
}

@ggonzalez94 ggonzalez94 changed the title Erc721 votes Erc721 votes and general Votes component Sep 5, 2024
@ggonzalez94
Copy link
Collaborator Author

Now that we settled on the design, I cleaned up the component and added a bunch of tests. I still need to finish the tests, add documentation and implement checkpoints using Vec(so far I'm reusing the current implementation that uses StorageArray).
But I think this is ready for another round of review as I work on the rest of the things

Copy link
Collaborator

@andrew-fleming andrew-fleming left a comment

Choose a reason for hiding this comment

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

Looking good, sir! I left some comments and questions

packages/governance/src/votes/votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/votes/interface.cairo Outdated Show resolved Hide resolved
packages/governance/src/votes/votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/tests/test_votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/tests/test_votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/tests/test_votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/tests/test_votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/tests/test_votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/tests/mocks/votes_mocks.cairo Outdated Show resolved Hide resolved
packages/governance/src/votes/votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/votes/votes.cairo Outdated Show resolved Hide resolved
packages/governance/src/votes/votes.cairo Outdated Show resolved Hide resolved
Copy link

codecov bot commented Oct 4, 2024

Codecov Report

Attention: Patch coverage is 96.15385% with 2 lines in your changes missing coverage. Please review.

Project coverage is 91.89%. Comparing base (57e6049) to head (1f1d549).
Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
packages/governance/src/votes/delegation.cairo 50.00% 1 Missing ⚠️
packages/utils/src/structs/checkpoint.cairo 95.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1114      +/-   ##
==========================================
+ Coverage   91.76%   91.89%   +0.13%     
==========================================
  Files          48       47       -1     
  Lines        1202     1197       -5     
==========================================
- Hits         1103     1100       -3     
+ Misses         99       97       -2     
Files with missing lines Coverage Δ
packages/governance/src/votes/votes.cairo 100.00% <100.00%> (ø)
packages/testing/src/constants.cairo 100.00% <100.00%> (ø)
packages/governance/src/votes/delegation.cairo 50.00% <50.00%> (ø)
packages/utils/src/structs/checkpoint.cairo 91.07% <95.00%> (-0.93%) ⬇️

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 57e6049...1f1d549. Read the comment docs.

Copy link
Collaborator

@andrew-fleming andrew-fleming left a comment

Choose a reason for hiding this comment

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

The improvements look great and we're def almost there! I left a few minor comments, questions, and suggestions

docs/modules/ROOT/pages/governance.adoc Outdated Show resolved Hide resolved
docs/modules/ROOT/pages/api/governance.adoc Show resolved Hide resolved
docs/modules/ROOT/pages/api/governance.adoc Outdated Show resolved Hide resolved
packages/governance/src/votes/votes.cairo Show resolved Hide resolved
packages/governance/Scarb.toml Outdated Show resolved Hide resolved
docs/modules/ROOT/pages/api/governance.adoc Outdated Show resolved Hide resolved
docs/modules/ROOT/pages/api/governance.adoc Outdated Show resolved Hide resolved
Comment on lines 155 to 156
#[test]
fn test_delegate_to_recipient_updates_votes() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Were these added? Forgive me if I'm missing them

Copy link
Member

@ericnordelo ericnordelo left a comment

Choose a reason for hiding this comment

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

One extra small suggestion and it looks good to me!

docs/modules/ROOT/pages/api/governance.adoc Show resolved Hide resolved
CHANGELOG.md Show resolved Hide resolved
Copy link
Collaborator

@andrew-fleming andrew-fleming left a comment

Choose a reason for hiding this comment

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

Phew! I left one final suggestion. Otherwise, this looks good to me 🎉🎉 great work!

docs/modules/ROOT/pages/api/governance.adoc Outdated Show resolved Hide resolved
@andrew-fleming
Copy link
Collaborator

Also, we're 2 comments away from tying the comment record (ERC1155 @ 197 comments)!!!

@ggonzalez94
Copy link
Collaborator Author

ggonzalez94 commented Oct 15, 2024

@ericnordelo @andrew-fleming last few suggestions have been incorporated. Thanks for your feedback during the process!

Also, we're 2 comments away from tying the comment record (#896 @ 197 comments)!!!

Looks like we stayed one comment away from the record 😮‍💨

@ggonzalez94 ggonzalez94 merged commit 6e60ba9 into OpenZeppelin:main Oct 15, 2024
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add ERC721Votes
5 participants