diff --git a/Anchor.toml b/Anchor.toml index 7870dcf..d38a3f2 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -5,6 +5,7 @@ solana_version = "1.18.18" seeds = false [programs.localnet] +core-voter = "Gcore62Vw7rfgmXMG8T7B9Ye2smpE35rk12RxkuMNc6a" nft_voter = "GnftV5kLjd67tvHpNGyodwWveEKivz3ZWvvE3Z4xi2iw" gateway = "GgathUhdrCWRHowoRKACjgWhYHfxCEdBi5ViqYN6HVxk" quadratic = "quadCSapU8nTdLg73KHDnmdxKnJQsh7GUbu5tZfnRRr" diff --git a/Cargo.lock b/Cargo.lock index c199562..87456ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -632,6 +632,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -1814,6 +1820,25 @@ dependencies = [ "spl-token", ] +[[package]] +name = "gpl-core-voter" +version = "0.2.2" +dependencies = [ + "anchor-lang", + "anchor-spl", + "arrayref", + "borsh 0.10.3", + "itertools", + "mpl-core", + "mpl-token-metadata", + "solana-program", + "solana-program-test", + "solana-sdk", + "spl-governance", + "spl-governance-tools", + "spl-token", +] + [[package]] name = "gpl-nft-voter" version = "0.2.2" @@ -2546,6 +2571,24 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "mpl-core" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d4f516cd9daae872182a50850ab6d5588667e71152d49b0729a32830036b83" +dependencies = [ + "anchor-lang", + "base64 0.22.1", + "borsh 0.10.3", + "modular-bitfield", + "num-derive 0.3.3", + "num-traits", + "rmp-serde", + "serde_json", + "solana-program", + "thiserror", +] + [[package]] name = "mpl-token-metadata" version = "4.1.2" @@ -3434,6 +3477,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + [[package]] name = "rpassword" version = "7.3.1" diff --git a/programs/core-voter/Cargo.toml b/programs/core-voter/Cargo.toml new file mode 100644 index 0000000..29a439c --- /dev/null +++ b/programs/core-voter/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "gpl-core-voter" +version = "0.2.2" +description = "SPL Governance addin implementing Metaplex Core NFT Asset based governance" +license = "Apache-2.0" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "gpl_core_voter" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + +[dependencies] +arrayref = "0.3.6" +anchor-lang = { version = "0.30.1", features = ["init-if-needed"] } +anchor-spl = { version = "0.30.1", features = ["token"] } +itertools = "0.10.2" +mpl-token-metadata = "^4.1.2" +mpl-core = {version = "0.7.1", features = ["anchor"]} +solana-program = "1.18.18" +spl-governance = { version = "4.0", features = ["no-entrypoint"] } +spl-governance-tools = "0.1.4" +spl-token = { version = "4.0", features = [ "no-entrypoint" ] } + +[dev-dependencies] +borsh = "0.10.3" +solana-sdk = "1.18.18" +solana-program-test = "1.18.18" \ No newline at end of file diff --git a/programs/core-voter/Xargo.toml b/programs/core-voter/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/programs/core-voter/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/core-voter/src/error.rs b/programs/core-voter/src/error.rs new file mode 100644 index 0000000..679199f --- /dev/null +++ b/programs/core-voter/src/error.rs @@ -0,0 +1,82 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum NftVoterError { + // 0 + #[msg("Invalid Realm Authority")] + InvalidRealmAuthority, + + #[msg("Invalid Realm for Registrar")] + InvalidRealmForRegistrar, + + #[msg("Invalid Collection Size")] + InvalidCollectionSize, + + #[msg("Invalid MaxVoterWeightRecord Realm")] + InvalidMaxVoterWeightRecordRealm, + + #[msg("Invalid MaxVoterWeightRecord Mint")] + InvalidMaxVoterWeightRecordMint, + + #[msg("CastVote Is Not Allowed")] + CastVoteIsNotAllowed, + + #[msg("Invalid VoterWeightRecord Realm")] + InvalidVoterWeightRecordRealm, + + #[msg("Invalid VoterWeightRecord Mint")] + InvalidVoterWeightRecordMint, + + #[msg("Invalid TokenOwner for VoterWeightRecord")] + InvalidTokenOwnerForVoterWeightRecord, + + #[msg("Collection must be verified")] + CollectionMustBeVerified, + + //10 + #[msg("Voter does not own NFT")] + VoterDoesNotOwnNft, + + #[msg("Collection not found")] + CollectionNotFound, + + #[msg("Missing Metadata collection")] + MissingMetadataCollection, + + #[msg("Token Metadata doesn't match")] + TokenMetadataDoesNotMatch, + + #[msg("Invalid account owner")] + InvalidAccountOwner, + + #[msg("Invalid token metadata account")] + InvalidTokenMetadataAccount, + + #[msg("Duplicated NFT detected")] + DuplicatedNftDetected, + + #[msg("Invalid NFT amount")] + InvalidNftAmount, + + #[msg("NFT already voted")] + NftAlreadyVoted, + + #[msg("Invalid Proposal for NftVoteRecord")] + InvalidProposalForNftVoteRecord, + + // 20 + #[msg("Invalid TokenOwner for NftVoteRecord")] + InvalidTokenOwnerForNftVoteRecord, + + #[msg("VoteRecord must be withdrawn")] + VoteRecordMustBeWithdrawn, + + #[msg("Invalid VoteRecord for NftVoteRecord")] + InvalidVoteRecordForNftVoteRecord, + + #[msg("VoterWeightRecord must be expired")] + VoterWeightRecordMustBeExpired, + + #[msg("Invalid NFT collection")] + InvalidNftCollection, +} diff --git a/programs/core-voter/src/instructions/cast_nft_vote.rs b/programs/core-voter/src/instructions/cast_nft_vote.rs new file mode 100644 index 0000000..f7566f6 --- /dev/null +++ b/programs/core-voter/src/instructions/cast_nft_vote.rs @@ -0,0 +1,145 @@ +use crate::error::NftVoterError; +use crate::{id, state::*}; +use anchor_lang::prelude::*; +use anchor_lang::Accounts; +use itertools::Itertools; +use mpl_core::accounts::BaseAssetV1; +use spl_governance_tools::account::create_and_serialize_account_signed; + +/// Casts NFT vote. The NFTs used for voting are tracked using NftVoteRecord accounts +/// This instruction updates VoterWeightRecord which is valid for the current Slot and the target Proposal only +/// and hance the instruction has to be executed inside the same transaction as spl-gov.CastVote +/// +/// CastNftVote is accumulative and can be invoked using several transactions if voter owns more than 5 NFTs to calculate total voter_weight +/// In this scenario only the last CastNftVote should be bundled with spl-gov.CastVote in the same transaction +/// +/// CastNftVote instruction and NftVoteRecord are not directional. They don't record vote choice (ex Yes/No) +/// VoteChoice is recorded by spl-gov in VoteRecord and this CastNftVote only tracks voting NFTs +/// +#[derive(Accounts)] +#[instruction(proposal: Pubkey)] +pub struct CastNftVote<'info> { + /// The NFT voting registrar + pub registrar: Account<'info, Registrar>, + + #[account( + mut, + constraint = voter_weight_record.realm == registrar.realm + @ NftVoterError::InvalidVoterWeightRecordRealm, + + constraint = voter_weight_record.governing_token_mint == registrar.governing_token_mint + @ NftVoterError::InvalidVoterWeightRecordMint, + )] + pub voter_weight_record: Account<'info, VoterWeightRecord>, + + /// TokenOwnerRecord of the voter who casts the vote + #[account( + owner = registrar.governance_program_id + )] + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + voter_token_owner_record: UncheckedAccount<'info>, + + /// Authority of the voter who casts the vote + /// It can be either governing_token_owner or its delegate and must sign this instruction + pub voter_authority: Signer<'info>, + + /// The account which pays for the transaction + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +/// Casts vote with the NFT +pub fn cast_nft_vote<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, CastNftVote<'info>>, + proposal: Pubkey, +) -> Result<()> { + let registrar = &ctx.accounts.registrar; + let voter_weight_record = &mut ctx.accounts.voter_weight_record; + + let governing_token_owner = resolve_governing_token_owner( + registrar, + &ctx.accounts.voter_token_owner_record, + &ctx.accounts.voter_authority, + voter_weight_record, + )?; + + let mut voter_weight = 0u64; + + // Ensure all voting nfts in the batch are unique + let mut unique_asset_mints = vec![]; + + let rent = Rent::get()?; + + for (asset, asset_vote_record_info) in + ctx.remaining_accounts.iter().tuples() + { + let (asset_vote_weight, asset_mint) = resolve_nft_vote_weight_and_mint( + registrar, + &governing_token_owner, + asset.key.clone(), + &BaseAssetV1::from_bytes(&asset.data.borrow()).unwrap(), + &mut unique_asset_mints, + )?; + + voter_weight = voter_weight.checked_add(asset_vote_weight as u64).unwrap(); + + // Create NFT vote record to ensure the same NFT hasn't been already used for voting + // Note: The correct PDA of the NftVoteRecord is validated in create_and_serialize_account_signed + // It ensures the NftVoteRecord is for ('nft-vote-record',proposal,nft_mint) seeds + require!( + asset_vote_record_info.data_is_empty(), + NftVoterError::NftAlreadyVoted + ); + + // Note: proposal.governing_token_mint must match voter_weight_record.governing_token_mint + // We don't verify it here because spl-gov does the check in cast_vote + // and it would reject voter_weight_record if governing_token_mint doesn't match + + // Note: Once the NFT plugin is enabled the governing_token_mint is used only as identity + // for the voting population and the tokens of that mint are no longer used + let asset_vote_record = AssetVoteRecord { + account_discriminator: AssetVoteRecord::ACCOUNT_DISCRIMINATOR, + proposal, + asset_mint, + governing_token_owner, + reserved: [0; 8], + }; + + // Anchor doesn't natively support dynamic account creation using remaining_accounts + // and we have to take it on the manual drive + create_and_serialize_account_signed( + &ctx.accounts.payer.to_account_info(), + asset_vote_record_info, + &asset_vote_record, + &get_nft_vote_record_seeds(&proposal, &asset_mint), + &id(), + &ctx.accounts.system_program.to_account_info(), + &rent, + 0, + )?; + } + + if voter_weight_record.weight_action_target == Some(proposal) + && voter_weight_record.weight_action == Some(VoterWeightAction::CastVote) + { + // If cast_nft_vote is called for the same proposal then we keep accumulating the weight + // this way cast_nft_vote can be called multiple times in different transactions to allow voting with any number of NFTs + voter_weight_record.voter_weight = voter_weight_record + .voter_weight + .checked_add(voter_weight) + .unwrap(); + } else { + voter_weight_record.voter_weight = voter_weight; + } + + // The record is only valid as of the current slot + voter_weight_record.voter_weight_expiry = Some(Clock::get()?.slot); + + // The record is only valid for casting vote on the given Proposal + voter_weight_record.weight_action = Some(VoterWeightAction::CastVote); + voter_weight_record.weight_action_target = Some(proposal); + + Ok(()) +} diff --git a/programs/core-voter/src/instructions/configure_collection.rs b/programs/core-voter/src/instructions/configure_collection.rs new file mode 100644 index 0000000..633f8f0 --- /dev/null +++ b/programs/core-voter/src/instructions/configure_collection.rs @@ -0,0 +1,117 @@ +use anchor_lang::{ + account, + prelude::{Context, Signer}, + Accounts, +}; + +use anchor_lang::prelude::*; +use mpl_core::accounts::BaseCollectionV1; +use spl_governance::state::realm; + +use crate::error::NftVoterError; +use crate::state::{max_voter_weight_record::MaxVoterWeightRecord, CollectionConfig, Registrar}; + +/// Configures NFT voting collection which defines what NFTs can be used for governances +/// and what weight they have +/// The instruction updates MaxVoterWeightRecord which is used by spl-gov to determine max voting power +/// used to calculate voting quorum +#[derive(Accounts)] +pub struct ConfigureCollection<'info> { + /// Registrar for which we configure this Collection + #[account(mut)] + pub registrar: Account<'info, Registrar>, + + #[account( + address = registrar.realm @ NftVoterError::InvalidRealmForRegistrar, + owner = registrar.governance_program_id + )] + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + pub realm: UncheckedAccount<'info>, + + /// Authority of the Realm must sign and match Realm.authority + pub realm_authority: Signer<'info>, + + // Collection which is going to be used for voting + pub collection: Account<'info, BaseCollectionV1>, + + #[account( + mut, + constraint = max_voter_weight_record.realm == registrar.realm + @ NftVoterError::InvalidMaxVoterWeightRecordRealm, + + constraint = max_voter_weight_record.governing_token_mint == registrar.governing_token_mint + @ NftVoterError::InvalidMaxVoterWeightRecordMint, + )] + pub max_voter_weight_record: Account<'info, MaxVoterWeightRecord>, +} + +pub fn configure_collection( + ctx: Context, + weight: u64, +) -> Result<()> { + let registrar = &mut ctx.accounts.registrar; + + let realm = realm::get_realm_data_for_governing_token_mint( + ®istrar.governance_program_id, + &ctx.accounts.realm, + ®istrar.governing_token_mint, + )?; + + require!( + realm.authority.unwrap() == ctx.accounts.realm_authority.key(), + NftVoterError::InvalidRealmAuthority + ); + + // spl-gov doesn't track voting_proposal_count any longer and we can't enforce the check here + // It's not ideal but acceptable. The proper solution would require proposal queuing in spl-gov + // + // Changes to the collections config can accidentally tip the scales for outstanding proposals and hence we disallow it + // if realm.voting_proposal_count > 0 { + // return err!(NftVoterError::CannotConfigureCollectionWithVotingProposals); + // } + + let collection = &ctx.accounts.collection; + + let size = collection.current_size; + + msg!("Collection size: {}", size); + + require!(size > 0, NftVoterError::InvalidCollectionSize); + + let collection_config = CollectionConfig { + collection: collection.key(), + weight, + reserved: [0; 8], + size, + }; + + let collection_idx = registrar + .collection_configs + .iter() + .position(|cc| cc.collection == collection.key()); + + if let Some(collection_idx) = collection_idx { + registrar.collection_configs[collection_idx] = collection_config; + } else { + // Note: In the current runtime version push() would throw an error if we exceed + // max_collections specified when the Registrar was created + registrar.collection_configs.push(collection_config); + } + + // TODO: if weight == 0 then remove the collection from config + // Currently if weight is set to 0 then the collection won't be removed but it won't have any governance power + + // Update MaxVoterWeightRecord based on max voting power of the collections + let max_voter_weight_record = &mut ctx.accounts.max_voter_weight_record; + + max_voter_weight_record.max_voter_weight = registrar + .collection_configs + .iter() + .try_fold(0u64, |sum, cc| sum.checked_add(cc.get_max_weight())) + .unwrap(); + + // The weight never expires and only changes when collections are configured + max_voter_weight_record.max_voter_weight_expiry = None; + + Ok(()) +} diff --git a/programs/core-voter/src/instructions/create_max_voter_weight_record.rs b/programs/core-voter/src/instructions/create_max_voter_weight_record.rs new file mode 100644 index 0000000..0749fd4 --- /dev/null +++ b/programs/core-voter/src/instructions/create_max_voter_weight_record.rs @@ -0,0 +1,57 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::Mint; +use spl_governance::state::realm; + +use crate::state::max_voter_weight_record::MaxVoterWeightRecord; + +/// Creates MaxVoterWeightRecord used by spl-gov +/// This instruction should only be executed once per realm/governing_token_mint to create the account +#[derive(Accounts)] +pub struct CreateMaxVoterWeightRecord<'info> { + #[account( + init, + seeds = [ b"max-voter-weight-record".as_ref(), + realm.key().as_ref(), + realm_governing_token_mint.key().as_ref()], + bump, + payer = payer, + space = MaxVoterWeightRecord::get_space() + )] + pub max_voter_weight_record: Account<'info, MaxVoterWeightRecord>, + + /// The program id of the spl-governance program the realm belongs to + /// CHECK: Can be any instance of spl-governance and it's not known at the compilation time + #[account(executable)] + pub governance_program_id: UncheckedAccount<'info>, + + #[account(owner = governance_program_id.key())] + /// CHECK: Owned by spl-governance instance specified in governance_program_id + pub realm: UncheckedAccount<'info>, + + /// Either the realm community mint or the council mint. + pub realm_governing_token_mint: Account<'info, Mint>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn create_max_voter_weight_record(ctx: Context) -> Result<()> { + // Deserialize the Realm to validate it + let _realm = realm::get_realm_data_for_governing_token_mint( + &ctx.accounts.governance_program_id.key(), + &ctx.accounts.realm, + &ctx.accounts.realm_governing_token_mint.key(), + )?; + + let max_voter_weight_record = &mut ctx.accounts.max_voter_weight_record; + + max_voter_weight_record.realm = ctx.accounts.realm.key(); + max_voter_weight_record.governing_token_mint = ctx.accounts.realm_governing_token_mint.key(); + + // Set expiry to expired + max_voter_weight_record.max_voter_weight_expiry = Some(0); + + Ok(()) +} diff --git a/programs/core-voter/src/instructions/create_registrar.rs b/programs/core-voter/src/instructions/create_registrar.rs new file mode 100644 index 0000000..efaa29f --- /dev/null +++ b/programs/core-voter/src/instructions/create_registrar.rs @@ -0,0 +1,81 @@ +use crate::error::NftVoterError; +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_spl::token::Mint; +use spl_governance::state::realm; + +/// Creates Registrar storing NFT governance configuration for spl-gov Realm +/// This instruction should only be executed once per realm/governing_token_mint to create the account +#[derive(Accounts)] +#[instruction(max_collections: u8)] +pub struct CreateRegistrar<'info> { + /// The NFT voting Registrar + /// There can only be a single registrar per governance Realm and governing mint of the Realm + #[account( + init, + seeds = [b"registrar".as_ref(),realm.key().as_ref(), governing_token_mint.key().as_ref()], + bump, + payer = payer, + space = Registrar::get_space(max_collections) + )] + pub registrar: Account<'info, Registrar>, + + /// The program id of the spl-governance program the realm belongs to + /// CHECK: Can be any instance of spl-governance and it's not known at the compilation time + #[account(executable)] + pub governance_program_id: UncheckedAccount<'info>, + + /// An spl-governance Realm + /// + /// Realm is validated in the instruction: + /// - Realm is owned by the governance_program_id + /// - governing_token_mint must be the community or council mint + /// - realm_authority is realm.authority + /// CHECK: Owned by spl-governance instance specified in governance_program_id + #[account(owner = governance_program_id.key())] + pub realm: UncheckedAccount<'info>, + + /// Either the realm community mint or the council mint. + /// It must match Realm.community_mint or Realm.config.council_mint + /// + /// Note: Once the NFT plugin is enabled the governing_token_mint is used only as identity + /// for the voting population and the tokens of that are no longer used + pub governing_token_mint: Account<'info, Mint>, + + /// realm_authority must sign and match Realm.authority + pub realm_authority: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +/// Creates a new Registrar which stores NFT voting configuration for given Realm +/// +/// To use the registrar, call ConfigureCollection to register NFT collections that may be +/// used for governance +/// +/// max_collections is used allocate account size for the maximum number of governing NFT collections +/// Note: Once Solana runtime supports account resizing the max value won't be required +pub fn create_registrar(ctx: Context, _max_collections: u8) -> Result<()> { + let registrar = &mut ctx.accounts.registrar; + registrar.governance_program_id = ctx.accounts.governance_program_id.key(); + registrar.realm = ctx.accounts.realm.key(); + registrar.governing_token_mint = ctx.accounts.governing_token_mint.key(); + + // Verify that realm_authority is the expected authority of the Realm + // and that the mint matches one of the realm mints too + let realm = realm::get_realm_data_for_governing_token_mint( + ®istrar.governance_program_id, + &ctx.accounts.realm, + ®istrar.governing_token_mint, + )?; + + require!( + realm.authority.unwrap() == ctx.accounts.realm_authority.key(), + NftVoterError::InvalidRealmAuthority + ); + + Ok(()) +} diff --git a/programs/core-voter/src/instructions/create_voter_weight_record.rs b/programs/core-voter/src/instructions/create_voter_weight_record.rs new file mode 100644 index 0000000..d1ef66d --- /dev/null +++ b/programs/core-voter/src/instructions/create_voter_weight_record.rs @@ -0,0 +1,63 @@ +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_spl::token::Mint; +use spl_governance::state::realm; + +/// Creates VoterWeightRecord used by spl-gov +/// This instruction should only be executed once per realm/governing_token_mint/governing_token_owner +/// to create the account +#[derive(Accounts)] +#[instruction(governing_token_owner: Pubkey)] +pub struct CreateVoterWeightRecord<'info> { + #[account( + init, + seeds = [ b"voter-weight-record".as_ref(), + realm.key().as_ref(), + realm_governing_token_mint.key().as_ref(), + governing_token_owner.as_ref()], + bump, + payer = payer, + space = VoterWeightRecord::get_space() + )] + pub voter_weight_record: Account<'info, VoterWeightRecord>, + + /// The program id of the spl-governance program the realm belongs to + /// CHECK: Can be any instance of spl-governance and it's not known at the compilation time + #[account(executable)] + pub governance_program_id: UncheckedAccount<'info>, + + /// CHECK: Owned by spl-governance instance specified in governance_program_id + #[account(owner = governance_program_id.key())] + pub realm: UncheckedAccount<'info>, + + /// Either the realm community mint or the council mint. + pub realm_governing_token_mint: Account<'info, Mint>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn create_voter_weight_record( + ctx: Context, + governing_token_owner: Pubkey, +) -> Result<()> { + // Deserialize the Realm to validate it + let _realm = realm::get_realm_data_for_governing_token_mint( + &ctx.accounts.governance_program_id.key(), + &ctx.accounts.realm, + &ctx.accounts.realm_governing_token_mint.key(), + )?; + + let voter_weight_record = &mut ctx.accounts.voter_weight_record; + + voter_weight_record.realm = ctx.accounts.realm.key(); + voter_weight_record.governing_token_mint = ctx.accounts.realm_governing_token_mint.key(); + voter_weight_record.governing_token_owner = governing_token_owner; + + // Set expiry to expired + voter_weight_record.voter_weight_expiry = Some(0); + + Ok(()) +} diff --git a/programs/core-voter/src/instructions/mod.rs b/programs/core-voter/src/instructions/mod.rs new file mode 100644 index 0000000..a2f73d1 --- /dev/null +++ b/programs/core-voter/src/instructions/mod.rs @@ -0,0 +1,23 @@ +pub use configure_collection::*; +mod configure_collection; + +pub use create_registrar::*; +mod create_registrar; + +pub use create_voter_weight_record::*; +mod create_voter_weight_record; + +pub use create_max_voter_weight_record::*; +mod create_max_voter_weight_record; + +pub use update_voter_weight_record::*; +mod update_voter_weight_record; + +pub use relinquish_nft_vote::*; +mod relinquish_nft_vote; + +pub use cast_nft_vote::*; +mod cast_nft_vote; + +pub use udpate_max_voter_weight_record::*; +mod udpate_max_voter_weight_record; diff --git a/programs/core-voter/src/instructions/relinquish_nft_vote.rs b/programs/core-voter/src/instructions/relinquish_nft_vote.rs new file mode 100644 index 0000000..5cb8168 --- /dev/null +++ b/programs/core-voter/src/instructions/relinquish_nft_vote.rs @@ -0,0 +1,152 @@ +use crate::error::NftVoterError; +use crate::state::*; +use crate::state::{get_nft_vote_record_data_for_proposal_and_token_owner, Registrar}; +use crate::tools::governance::get_vote_record_address; +use anchor_lang::prelude::*; +use spl_governance::state::{enums::ProposalState, governance, proposal}; +use spl_governance_tools::account::dispose_account; + +/// Disposes NftVoteRecord and recovers the rent from the accounts +/// It can only be executed when voting on the target Proposal ended or voter withdrew vote from the Proposal +/// +/// Note: If a voter votes with NFT and transfers the token then in the current version of the program the new owner can't withdraw the vote +/// In order to support that scenario a change in spl-governance is needed +/// It would have to support revoke_vote instruction which would take as input VoteWeightRecord with the following values: +/// weight_action: RevokeVote, weight_action_target: VoteRecord, voter_weight: sum(previous owner NFT weight) +/// The instruction would decrease the previous voter total VoteRecord.voter_weight by the provided VoteWeightRecord.voter_weight +/// Once the spl-governance instruction is supported then nft-voter plugin should implement revoke_nft_vote instruction +/// to supply the required VoteWeightRecord and delete relevant NftVoteRecords +#[derive(Accounts)] +pub struct RelinquishNftVote<'info> { + /// The NFT voting Registrar + pub registrar: Account<'info, Registrar>, + + #[account( + mut, + constraint = voter_weight_record.realm == registrar.realm + @ NftVoterError::InvalidVoterWeightRecordRealm, + + constraint = voter_weight_record.governing_token_mint == registrar.governing_token_mint + @ NftVoterError::InvalidVoterWeightRecordMint, + )] + pub voter_weight_record: Account<'info, VoterWeightRecord>, + + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + /// Governance account the Proposal is for + #[account(owner = registrar.governance_program_id)] + pub governance: UncheckedAccount<'info>, + + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + #[account(owner = registrar.governance_program_id)] + pub proposal: UncheckedAccount<'info>, + + /// TokenOwnerRecord of the voter who cast the original vote + #[account( + owner = registrar.governance_program_id + )] + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + voter_token_owner_record: UncheckedAccount<'info>, + + /// Authority of the voter who cast the original vote + /// It can be either governing_token_owner or its delegate and must sign this instruction + pub voter_authority: Signer<'info>, + + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + /// The account is used to validate that it doesn't exist and if it doesn't then Anchor owner check throws error + /// The check is disabled here and performed inside the instruction + /// #[account(owner = registrar.governance_program_id)] + pub vote_record: UncheckedAccount<'info>, + + /// CHECK: The beneficiary who receives lamports from the disposed NftVoterRecord accounts can be any account + #[account(mut)] + pub beneficiary: UncheckedAccount<'info>, +} + +pub fn relinquish_nft_vote(ctx: Context) -> Result<()> { + let registrar = &ctx.accounts.registrar; + let voter_weight_record = &mut ctx.accounts.voter_weight_record; + + let governing_token_owner = resolve_governing_token_owner( + registrar, + &ctx.accounts.voter_token_owner_record, + &ctx.accounts.voter_authority, + voter_weight_record, + )?; + + // Ensure the Governance belongs to Registrar.realm and is owned by Registrar.governance_program_id + let _governance = governance::get_governance_data_for_realm( + ®istrar.governance_program_id, + &ctx.accounts.governance, + ®istrar.realm, + )?; + + // Ensure the Proposal belongs to Governance from Registrar.realm and Registrar.governing_token_mint and is owned by Registrar.governance_program_id + let proposal = proposal::get_proposal_data_for_governance_and_governing_mint( + ®istrar.governance_program_id, + &ctx.accounts.proposal, + &ctx.accounts.governance.key(), + ®istrar.governing_token_mint, + )?; + + // If the Proposal is still in Voting state then we can only Relinquish the NFT votes if the Vote was withdrawn in spl-gov first + // When vote is withdrawn in spl-gov then VoteRecord is disposed and we have to assert it doesn't exist + // + // If the Proposal is in any other state then we can dispose NftVoteRecords without any additional Proposal checks + + msg!("Proposal State: {:?}", proposal.state); + + if proposal.state == ProposalState::Voting { + let vote_record_info = &ctx.accounts.vote_record.to_account_info(); + + // Ensure the given VoteRecord address matches the expected PDA + let vote_record_key = get_vote_record_address( + ®istrar.governance_program_id, + ®istrar.realm, + ®istrar.governing_token_mint, + &governing_token_owner, + &ctx.accounts.proposal.key(), + ); + + require!( + vote_record_key == vote_record_info.key(), + NftVoterError::InvalidVoteRecordForNftVoteRecord + ); + + require!( + // VoteRecord doesn't exist if data is empty or account_type is 0 when the account was disposed in the same Tx + vote_record_info.data_is_empty() || vote_record_info.try_borrow_data().unwrap()[0] == 0, + NftVoterError::VoteRecordMustBeWithdrawn + ); + } + + // Prevent relinquishing NftVoteRecords within the VoterWeightRecord expiration period + // It's needed when multiple stacked voter-weight plugins are used + // Without the assertion the following vector of attack exists + // 1) nft-voter.cast_nft_vote() + // 2) voter-weight-plugin.cast_vote() + // 3) nft-voter.relinquish_nft_vote() + // 4) spl-gov.cast_vote() -> spl-gov uses VoterWeightRecord provided by voter-weight-plugin in step 2) while the nft vote is withdrawn and could be used to vote again + if voter_weight_record.voter_weight_expiry >= Some(Clock::get()?.slot) { + return err!(NftVoterError::VoterWeightRecordMustBeExpired); + } + + // Dispose all NftVoteRecords + for nft_vote_record_info in ctx.remaining_accounts.iter() { + // Ensure NftVoteRecord is for the given Proposal and TokenOwner + let _nft_vote_record = get_nft_vote_record_data_for_proposal_and_token_owner( + nft_vote_record_info, + &ctx.accounts.proposal.key(), + &governing_token_owner, + )?; + + dispose_account(nft_vote_record_info, &ctx.accounts.beneficiary)?; + } + + // Reset VoterWeightRecord and set expiry to expired to prevent it from being used + voter_weight_record.voter_weight = 0; + voter_weight_record.voter_weight_expiry = Some(0); + + voter_weight_record.weight_action_target = None; + + Ok(()) +} diff --git a/programs/core-voter/src/instructions/udpate_max_voter_weight_record.rs b/programs/core-voter/src/instructions/udpate_max_voter_weight_record.rs new file mode 100644 index 0000000..2195bbc --- /dev/null +++ b/programs/core-voter/src/instructions/udpate_max_voter_weight_record.rs @@ -0,0 +1,42 @@ +use crate::error::NftVoterError; +use crate::state::*; +use anchor_lang::prelude::*; +use max_voter_weight_record::MaxVoterWeightRecord; + +// Takes all collections added to `register`, iterates over them and calculates +// the max voter weight +#[derive(Accounts)] +pub struct UpdateMaxVoterWeightRecord<'info> { + /// The NFT voting Registrar + pub registrar: Account<'info, Registrar>, + + #[account( + mut, + constraint = max_voter_weight_record.realm == registrar.realm + @ NftVoterError::InvalidVoterWeightRecordRealm, + + constraint = max_voter_weight_record.governing_token_mint == registrar.governing_token_mint + @ NftVoterError::InvalidVoterWeightRecordMint, + )] + pub max_voter_weight_record: Account<'info, MaxVoterWeightRecord>, +} + +pub fn update_max_voter_weight_record(ctx: Context) -> Result<()> { + let registrar = &ctx.accounts.registrar; + + // Calculate the max voter weight by iterating over all collections and summing + // the max weight of each collection. + ctx.accounts.max_voter_weight_record.max_voter_weight = registrar + .collection_configs + .iter() + .try_fold(0u64, |sum, cc| sum.checked_add(cc.get_max_weight())) + .unwrap(); + + // Record is only valid as of the current slot + let clock = Clock::get()?.slot; + msg!("Clock: {:?}", clock); + + ctx.accounts.max_voter_weight_record.max_voter_weight_expiry = Some(Clock::get()?.slot); + + Ok(()) +} diff --git a/programs/core-voter/src/instructions/update_voter_weight_record.rs b/programs/core-voter/src/instructions/update_voter_weight_record.rs new file mode 100644 index 0000000..e422a81 --- /dev/null +++ b/programs/core-voter/src/instructions/update_voter_weight_record.rs @@ -0,0 +1,78 @@ +use crate::error::NftVoterError; +use crate::state::*; +use anchor_lang::prelude::*; +use mpl_core::accounts::BaseAssetV1; + +/// Updates VoterWeightRecord to evaluate governance power for non voting use cases: CreateProposal, CreateGovernance etc... +/// This instruction updates VoterWeightRecord which is valid for the current Slot and the given target action only +/// and hance the instruction has to be executed inside the same transaction as the corresponding spl-gov instruction +/// +/// Note: UpdateVoterWeight is not cumulative the same way as CastNftVote and hence voter_weight for non voting scenarios +/// can only be used with max 5 NFTs due to Solana transaction size limit +/// It could be supported in future version by introducing bookkeeping accounts to track the NFTs +/// which were already used to calculate the total weight +#[derive(Accounts)] +#[instruction(voter_weight_action:VoterWeightAction)] +pub struct UpdateVoterWeightRecord<'info> { + /// The NFT voting Registrar + pub registrar: Account<'info, Registrar>, + + #[account( + mut, + constraint = voter_weight_record.realm == registrar.realm + @ NftVoterError::InvalidVoterWeightRecordRealm, + + constraint = voter_weight_record.governing_token_mint == registrar.governing_token_mint + @ NftVoterError::InvalidVoterWeightRecordMint, + )] + pub voter_weight_record: Account<'info, VoterWeightRecord>, +} + +pub fn update_voter_weight_record( + ctx: Context, + voter_weight_action: VoterWeightAction, +) -> Result<()> { + let registrar = &ctx.accounts.registrar; + let governing_token_owner = &ctx.accounts.voter_weight_record.governing_token_owner; + + match voter_weight_action { + // voter_weight for CastVote action can't be evaluated using this instruction + VoterWeightAction::CastVote => return err!(NftVoterError::CastVoteIsNotAllowed), + VoterWeightAction::CommentProposal + | VoterWeightAction::CreateGovernance + | VoterWeightAction::CreateProposal + | VoterWeightAction::SignOffProposal => {} + } + + let mut voter_weight = 0u64; + + // Ensure all nfts are unique + let mut unique_nft_mints = vec![]; + + for asset in ctx.remaining_accounts.iter() { + let (nft_vote_weight, _) = resolve_nft_vote_weight_and_mint( + registrar, + governing_token_owner, + asset.key.clone(), + &BaseAssetV1::from_bytes(&asset.data.borrow()).unwrap(), + &mut unique_nft_mints, + )?; + + voter_weight = voter_weight.checked_add(nft_vote_weight as u64).unwrap(); + } + + let voter_weight_record = &mut ctx.accounts.voter_weight_record; + + voter_weight_record.voter_weight = voter_weight; + + // Record is only valid as of the current slot + voter_weight_record.voter_weight_expiry = Some(Clock::get()?.slot); + + // Set the action to make it specific and prevent being used for voting + voter_weight_record.weight_action = Some(voter_weight_action); + voter_weight_record.weight_action_target = None; + + Ok(()) +} + +// takes all collections and adjusts collection weight diff --git a/programs/core-voter/src/lib.rs b/programs/core-voter/src/lib.rs new file mode 100644 index 0000000..a57fe41 --- /dev/null +++ b/programs/core-voter/src/lib.rs @@ -0,0 +1,69 @@ +use anchor_lang::prelude::*; + +pub mod error; + +mod instructions; +use instructions::*; + +pub mod state; + +pub mod tools; + +use crate::state::*; + +declare_id!("Gcore62Vw7rfgmXMG8T7B9Ye2smpE35rk12RxkuMNc6a"); + +#[program] +pub mod core_voter { + + use crate::state::VoterWeightAction; + + use super::*; + pub fn create_registrar(ctx: Context, max_collections: u8) -> Result<()> { + log_version(); + instructions::create_registrar(ctx, max_collections) + } + pub fn create_voter_weight_record( + ctx: Context, + governing_token_owner: Pubkey, + ) -> Result<()> { + log_version(); + instructions::create_voter_weight_record(ctx, governing_token_owner) + } + pub fn create_max_voter_weight_record(ctx: Context) -> Result<()> { + log_version(); + instructions::create_max_voter_weight_record(ctx) + } + pub fn update_voter_weight_record( + ctx: Context, + voter_weight_action: VoterWeightAction, + ) -> Result<()> { + log_version(); + instructions::update_voter_weight_record(ctx, voter_weight_action) + } + pub fn update_max_voter_weight_record(ctx: Context) -> Result<()> { + log_version(); + instructions::update_max_voter_weight_record(ctx) + } + pub fn relinquish_nft_vote(ctx: Context) -> Result<()> { + log_version(); + instructions::relinquish_nft_vote(ctx) + } + pub fn configure_collection(ctx: Context, weight: u64) -> Result<()> { + log_version(); + instructions::configure_collection(ctx, weight) + } + + pub fn cast_nft_vote<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, CastNftVote<'info>>, + proposal: Pubkey, + ) -> Result<()> { + log_version(); + instructions::cast_nft_vote(ctx, proposal) + } +} + +fn log_version() { + // TODO: Check if Anchor allows to log it before instruction is deserialized + msg!("VERSION:{:?}", env!("CARGO_PKG_VERSION")); +} diff --git a/programs/core-voter/src/state/collection_config.rs b/programs/core-voter/src/state/collection_config.rs new file mode 100644 index 0000000..56cd7e4 --- /dev/null +++ b/programs/core-voter/src/state/collection_config.rs @@ -0,0 +1,28 @@ +use anchor_lang::prelude::*; + +/// Configuration of an NFT collection used for governance power +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq, Default)] +pub struct CollectionConfig { + /// The NFT collection used for governance + pub collection: Pubkey, + + /// The size of the NFT collection used to calculate max voter weight + /// Note: At the moment the size is not captured on Metaplex accounts + /// and it has to be manually updated on the Registrar + pub size: u32, + + /// Governance power weight of the collection + /// Each NFT in the collection has governance power = 1 * weight + /// Note: The weight is scaled accordingly to the governing_token_mint decimals + /// Ex: if the the mint has 2 decimal places then weight of 1 should be stored as 100 + pub weight: u64, + + /// Reserved for future upgrades + pub reserved: [u8; 8], +} + +impl CollectionConfig { + pub fn get_max_weight(&self) -> u64 { + (self.size as u64).checked_mul(self.weight).unwrap() + } +} diff --git a/programs/core-voter/src/state/idl_types.rs b/programs/core-voter/src/state/idl_types.rs new file mode 100644 index 0000000..06a57aa --- /dev/null +++ b/programs/core-voter/src/state/idl_types.rs @@ -0,0 +1,17 @@ +//! IDL only types which are required in IDL but not exported automatically by Anchor +use anchor_lang::prelude::*; + +/// NftVoteRecord exported to IDL without account_discriminator +/// TODO: Once we can support these accounts in Anchor via remaining_accounts then it should be possible to remove it +#[account] +pub struct NftVoteRecord { + /// Proposal which was voted on + pub proposal: Pubkey, + + /// The mint of the NFT which was used for the vote + pub nft_mint: Pubkey, + + /// The voter who casted this vote + /// It's a Realm member pubkey corresponding to TokenOwnerRecord.governing_token_owner + pub governing_token_owner: Pubkey, +} diff --git a/programs/core-voter/src/state/max_voter_weight_record.rs b/programs/core-voter/src/state/max_voter_weight_record.rs new file mode 100644 index 0000000..66ac8da --- /dev/null +++ b/programs/core-voter/src/state/max_voter_weight_record.rs @@ -0,0 +1,96 @@ +use crate::id; +use crate::tools::anchor::DISCRIMINATOR_SIZE; +use anchor_lang::prelude::Pubkey; +use anchor_lang::prelude::*; +use solana_program::pubkey::PUBKEY_BYTES; + +/// MaxVoterWeightRecord account as defined in spl-governance-addin-api +/// It's redefined here without account_discriminator for Anchor to treat it as native account +/// +/// The account is used as an api interface to provide max voting power to the governance program from external addin contracts +#[account] +#[derive(Debug, PartialEq)] +pub struct MaxVoterWeightRecord { + /// The Realm the MaxVoterWeightRecord belongs to + pub realm: Pubkey, + + /// Governing Token Mint the MaxVoterWeightRecord is associated with + /// Note: The addin can take deposits of any tokens and is not restricted to the community or council tokens only + // The mint here is to link the record to either community or council mint of the realm + pub governing_token_mint: Pubkey, + + /// Max voter weight + /// The max voter weight provided by the addin for the given realm and governing_token_mint + pub max_voter_weight: u64, + + /// The slot when the max voting weight expires + /// It should be set to None if the weight never expires + /// If the max vote weight decays with time, for example for time locked based weights, then the expiry must be set + /// As a pattern Revise instruction to update the max weight should be invoked before governance instruction within the same transaction + /// and the expiry set to the current slot to provide up to date weight + pub max_voter_weight_expiry: Option, + + /// Reserved space for future versions + pub reserved: [u8; 8], +} + +impl Default for MaxVoterWeightRecord { + fn default() -> Self { + Self { + realm: Default::default(), + governing_token_mint: Default::default(), + max_voter_weight: Default::default(), + max_voter_weight_expiry: Some(0), + reserved: Default::default(), + } + } +} + +impl MaxVoterWeightRecord { + pub fn get_space() -> usize { + DISCRIMINATOR_SIZE + PUBKEY_BYTES * 2 + 8 + 1 + 8 + 8 + } +} + +/// Returns MaxVoterWeightRecord PDA seeds +pub fn get_max_voter_weight_record_seeds<'a>( + realm: &'a Pubkey, + governing_token_mint: &'a Pubkey, +) -> [&'a [u8]; 3] { + [ + b"max-voter-weight-record", + realm.as_ref(), + governing_token_mint.as_ref(), + ] +} + +/// Returns MaxVoterWeightRecord PDA address +pub fn get_max_voter_weight_record_address( + realm: &Pubkey, + governing_token_mint: &Pubkey, +) -> Pubkey { + Pubkey::find_program_address( + &get_max_voter_weight_record_seeds(realm, governing_token_mint), + &id(), + ) + .0 +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn test_get_space() { + // Arrange + let expected_space = MaxVoterWeightRecord::get_space(); + + // Act + let actual_space = + DISCRIMINATOR_SIZE + MaxVoterWeightRecord::default().try_to_vec().unwrap().len(); + + // Assert + assert_eq!(expected_space, actual_space); + } +} diff --git a/programs/core-voter/src/state/mod.rs b/programs/core-voter/src/state/mod.rs new file mode 100644 index 0000000..4084a58 --- /dev/null +++ b/programs/core-voter/src/state/mod.rs @@ -0,0 +1,15 @@ +pub use registrar::*; +pub mod registrar; + +pub use collection_config::*; +pub mod collection_config; + +pub use nft_vote_record::*; +pub mod nft_vote_record; + +pub mod max_voter_weight_record; + +pub use voter_weight_record::*; +pub mod voter_weight_record; + +pub mod idl_types; diff --git a/programs/core-voter/src/state/nft_vote_record.rs b/programs/core-voter/src/state/nft_vote_record.rs new file mode 100644 index 0000000..04c5a66 --- /dev/null +++ b/programs/core-voter/src/state/nft_vote_record.rs @@ -0,0 +1,84 @@ +use anchor_lang::prelude::*; +use borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; +use solana_program::program_pack::IsInitialized; + +use spl_governance_tools::account::{get_account_data, AccountMaxSize}; + +use crate::{error::NftVoterError, id}; + +/// Vote record indicating the given NFT voted on the Proposal +/// The PDA of the record is ["nft-vote-record",proposal,asset_mint] +/// It guarantees uniques and ensures the same NFT can't vote twice +#[derive(Clone, Debug, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub struct AssetVoteRecord { + /// AssetVoteRecord discriminator sha256("account:AssetVoteRecord")[..8] + /// Note: The discriminator is used explicitly because AssetVoteRecords + /// are created and consumed dynamically using remaining_accounts + /// and Anchor doesn't really support this scenario without going through lots of hoops + /// Once Anchor has better support for the scenario it shouldn't be necessary + pub account_discriminator: [u8; 8], + + /// Proposal which was voted on + pub proposal: Pubkey, + + /// The mint of the NFT which was used for the vote + pub asset_mint: Pubkey, + + /// The voter who casted this vote + /// It's a Realm member pubkey corresponding to TokenOwnerRecord.governing_token_owner + pub governing_token_owner: Pubkey, + + /// Reserved for future upgrades + pub reserved: [u8; 8], +} + +impl AssetVoteRecord { + /// sha256("account:AssetVoteRecord")[..8] + pub const ACCOUNT_DISCRIMINATOR: [u8; 8] = [14, 166, 191, 239, 186, 156, 140, 83]; +} + +impl AccountMaxSize for AssetVoteRecord {} + +impl IsInitialized for AssetVoteRecord { + fn is_initialized(&self) -> bool { + self.account_discriminator == AssetVoteRecord::ACCOUNT_DISCRIMINATOR + } +} + +/// Returns AssetVoteRecord PDA seeds +pub fn get_nft_vote_record_seeds<'a>(proposal: &'a Pubkey, asset_mint: &'a Pubkey) -> [&'a [u8]; 3] { + [b"nft-vote-record", proposal.as_ref(), asset_mint.as_ref()] +} + +/// Returns AssetVoteRecord PDA address +pub fn get_nft_vote_record_address(proposal: &Pubkey, asset_mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&get_nft_vote_record_seeds(proposal, asset_mint), &id()).0 +} + +/// Deserializes account and checks owner program +pub fn get_nft_vote_record_data(nft_vote_record_info: &AccountInfo) -> Result { + Ok(get_account_data::( + &id(), + nft_vote_record_info, + )?) +} + +pub fn get_nft_vote_record_data_for_proposal_and_token_owner( + nft_vote_record_info: &AccountInfo, + proposal: &Pubkey, + governing_token_owner: &Pubkey, +) -> Result { + let nft_vote_record = get_nft_vote_record_data(nft_vote_record_info)?; + + require!( + nft_vote_record.proposal == *proposal, + NftVoterError::InvalidProposalForNftVoteRecord + ); + + require!( + nft_vote_record.governing_token_owner == *governing_token_owner, + NftVoterError::InvalidTokenOwnerForNftVoteRecord + ); + + Ok(nft_vote_record) +} diff --git a/programs/core-voter/src/state/registrar.rs b/programs/core-voter/src/state/registrar.rs new file mode 100644 index 0000000..64767b3 --- /dev/null +++ b/programs/core-voter/src/state/registrar.rs @@ -0,0 +1,165 @@ +use crate::{ + error::NftVoterError, + id, + state::{CollectionConfig, VoterWeightRecord}, + tools::anchor::DISCRIMINATOR_SIZE, +}; +use anchor_lang::prelude::*; +use mpl_core::{accounts::BaseAssetV1, types::UpdateAuthority}; +use solana_program::pubkey::PUBKEY_BYTES; +use spl_governance::state::token_owner_record; + +/// Registrar which stores NFT voting configuration for the given Realm +#[account] +#[derive(Debug, PartialEq)] +pub struct Registrar { + /// spl-governance program the Realm belongs to + pub governance_program_id: Pubkey, + + /// Realm of the Registrar + pub realm: Pubkey, + + /// Governing token mint the Registrar is for + /// It can either be the Community or the Council mint of the Realm + /// When the plugin is used the mint is only used as identity of the governing power (voting population) + /// and the actual token of the mint is not used + pub governing_token_mint: Pubkey, + + /// Core Collection used for voting + pub collection_configs: Vec, + + /// Reserved for future upgrades + pub reserved: [u8; 128], +} + +impl Registrar { + pub fn get_space(max_collections: u8) -> usize { + DISCRIMINATOR_SIZE + + PUBKEY_BYTES * 3 + + 4 + + max_collections as usize * (PUBKEY_BYTES + 4 + 8 + 8) + + 128 + } +} + +/// Returns Registrar PDA seeds +pub fn get_registrar_seeds<'a>( + realm: &'a Pubkey, + governing_token_mint: &'a Pubkey, +) -> [&'a [u8]; 3] { + [b"registrar", realm.as_ref(), governing_token_mint.as_ref()] +} + +/// Returns Registrar PDA address +pub fn get_registrar_address(realm: &Pubkey, governing_token_mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&get_registrar_seeds(realm, governing_token_mint), &id()).0 +} + +impl Registrar { + pub fn get_collection_config(&self, collection: Pubkey) -> Result<&CollectionConfig> { + return self + .collection_configs + .iter() + .find(|cc| cc.collection == collection) + .ok_or_else(|| NftVoterError::CollectionNotFound.into()); + } +} + +// Resolves governing_token_owner from voter TokenOwnerRecord and +// 1) asserts it matches the given Registrar and VoterWeightRecord +// 2) asserts governing_token_owner or its delegate is a signer +pub fn resolve_governing_token_owner( + registrar: &Registrar, + voter_token_owner_record_info: &AccountInfo, + voter_authority_info: &AccountInfo, + voter_weight_record: &VoterWeightRecord, +) -> Result { + let voter_token_owner_record = + token_owner_record::get_token_owner_record_data_for_realm_and_governing_mint( + ®istrar.governance_program_id, + voter_token_owner_record_info, + ®istrar.realm, + ®istrar.governing_token_mint, + )?; + + voter_token_owner_record.assert_token_owner_or_delegate_is_signer(voter_authority_info)?; + + // Assert voter TokenOwnerRecord and VoterWeightRecord are for the same governing_token_owner + require_eq!( + voter_token_owner_record.governing_token_owner, + voter_weight_record.governing_token_owner, + NftVoterError::InvalidTokenOwnerForVoterWeightRecord + ); + + Ok(voter_token_owner_record.governing_token_owner) +} + +/// Resolves vote weight and voting mint for the given NFT +pub fn resolve_nft_vote_weight_and_mint( + registrar: &Registrar, + governing_token_owner: &Pubkey, + asset_key: Pubkey, + asset: &BaseAssetV1, + // nft_metadata_info: &AccountInfo, + unique_nft_mints: &mut Vec, +) -> Result<(u64, Pubkey)> { + let nft_owner = asset.owner; + + // voter_weight_record.governing_token_owner must be the owner of the NFT + require!( + nft_owner == *governing_token_owner, + NftVoterError::VoterDoesNotOwnNft + ); + + let nft_mint = asset_key; + + // Ensure the same NFT was not provided more than once + if unique_nft_mints.contains(&nft_mint) { + return Err(NftVoterError::DuplicatedNftDetected.into()); + } + unique_nft_mints.push(nft_mint); + + // The Core NFT must have a collection and the collection must be verified + let collection = match asset.update_authority { + UpdateAuthority::Collection(collection) => { + collection + }, + _ => return Err(NftVoterError::InvalidNftCollection.into()) + }; + + let collection_config = registrar.get_collection_config(collection)?; + + require!(collection_config.collection == collection, NftVoterError::InvalidNftCollection); + + Ok((collection_config.weight, nft_mint)) +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn test_get_space() { + // Arrange + let expected_space = Registrar::get_space(3); + + let registrar = Registrar { + governance_program_id: Pubkey::default(), + realm: Pubkey::default(), + governing_token_mint: Pubkey::default(), + collection_configs: vec![ + CollectionConfig::default(), + CollectionConfig::default(), + CollectionConfig::default(), + ], + reserved: [0; 128], + }; + + // Act + let actual_space = DISCRIMINATOR_SIZE + registrar.try_to_vec().unwrap().len(); + + // Assert + assert_eq!(expected_space, actual_space); + } +} diff --git a/programs/core-voter/src/state/voter_weight_record.rs b/programs/core-voter/src/state/voter_weight_record.rs new file mode 100644 index 0000000..0d0e24c --- /dev/null +++ b/programs/core-voter/src/state/voter_weight_record.rs @@ -0,0 +1,110 @@ +use anchor_lang::prelude::*; +use solana_program::pubkey::PUBKEY_BYTES; + +use crate::tools::anchor::DISCRIMINATOR_SIZE; + +/// VoterWeightAction enum as defined in spl-governance-addin-api +/// It's redefined here for Anchor to export it to IDL +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy, PartialEq)] +pub enum VoterWeightAction { + /// Cast vote for a proposal. Target: Proposal + CastVote, + + /// Comment a proposal. Target: Proposal + CommentProposal, + + /// Create Governance within a realm. Target: Realm + CreateGovernance, + + /// Create a proposal for a governance. Target: Governance + CreateProposal, + + /// Signs off a proposal for a governance. Target: Proposal + /// Note: SignOffProposal is not supported in the current version + SignOffProposal, +} + +/// VoterWeightRecord account as defined in spl-governance-addin-api +/// It's redefined here without account_discriminator for Anchor to treat it as native account +/// +/// The account is used as an api interface to provide voting power to the governance program from external addin contracts +#[account] +#[derive(Debug, PartialEq)] +pub struct VoterWeightRecord { + /// The Realm the VoterWeightRecord belongs to + pub realm: Pubkey, + + /// Governing Token Mint the VoterWeightRecord is associated with + /// Note: The addin can take deposits of any tokens and is not restricted to the community or council tokens only + // The mint here is to link the record to either community or council mint of the realm + pub governing_token_mint: Pubkey, + + /// The owner of the governing token and voter + /// This is the actual owner (voter) and corresponds to TokenOwnerRecord.governing_token_owner + pub governing_token_owner: Pubkey, + + /// Voter's weight + /// The weight of the voter provided by the addin for the given realm, governing_token_mint and governing_token_owner (voter) + pub voter_weight: u64, + + /// The slot when the voting weight expires + /// It should be set to None if the weight never expires + /// If the voter weight decays with time, for example for time locked based weights, then the expiry must be set + /// As a common pattern Revise instruction to update the weight should be invoked before governance instruction within the same transaction + /// and the expiry set to the current slot to provide up to date weight + pub voter_weight_expiry: Option, + + /// The governance action the voter's weight pertains to + /// It allows to provided voter's weight specific to the particular action the weight is evaluated for + /// When the action is provided then the governance program asserts the executing action is the same as specified by the addin + pub weight_action: Option, + + /// The target the voter's weight action pertains to + /// It allows to provided voter's weight specific to the target the weight is evaluated for + /// For example when addin supplies weight to vote on a particular proposal then it must specify the proposal as the action target + /// When the target is provided then the governance program asserts the target is the same as specified by the addin + pub weight_action_target: Option, + + /// Reserved space for future versions + pub reserved: [u8; 8], +} + +impl VoterWeightRecord { + pub fn get_space() -> usize { + DISCRIMINATOR_SIZE + PUBKEY_BYTES * 4 + 8 + 1 + 8 + 1 + 1 + 1 + 8 + } +} + +impl Default for VoterWeightRecord { + fn default() -> Self { + Self { + realm: Default::default(), + governing_token_mint: Default::default(), + governing_token_owner: Default::default(), + voter_weight: Default::default(), + voter_weight_expiry: Some(0), + weight_action: Some(VoterWeightAction::CastVote), + weight_action_target: Some(Default::default()), + reserved: Default::default(), + } + } +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn test_get_space() { + // Arrange + let expected_space = VoterWeightRecord::get_space(); + + // Act + let actual_space = + DISCRIMINATOR_SIZE + VoterWeightRecord::default().try_to_vec().unwrap().len(); + + // Assert + assert_eq!(expected_space, actual_space); + } +} diff --git a/programs/core-voter/src/tools/anchor.rs b/programs/core-voter/src/tools/anchor.rs new file mode 100644 index 0000000..abc1dd5 --- /dev/null +++ b/programs/core-voter/src/tools/anchor.rs @@ -0,0 +1 @@ +pub const DISCRIMINATOR_SIZE: usize = 8; diff --git a/programs/core-voter/src/tools/governance.rs b/programs/core-voter/src/tools/governance.rs new file mode 100644 index 0000000..ccb2573 --- /dev/null +++ b/programs/core-voter/src/tools/governance.rs @@ -0,0 +1,19 @@ +use anchor_lang::prelude::Pubkey; +use spl_governance::state::{token_owner_record, vote_record}; + +pub fn get_vote_record_address( + program_id: &Pubkey, + realm: &Pubkey, + governing_token_mint: &Pubkey, + governing_token_owner: &Pubkey, + proposal: &Pubkey, +) -> Pubkey { + let token_owner_record_key = token_owner_record::get_token_owner_record_address( + program_id, + realm, + governing_token_mint, + governing_token_owner, + ); + + vote_record::get_vote_record_address(program_id, proposal, &token_owner_record_key) +} diff --git a/programs/core-voter/src/tools/mod.rs b/programs/core-voter/src/tools/mod.rs new file mode 100644 index 0000000..aecc39e --- /dev/null +++ b/programs/core-voter/src/tools/mod.rs @@ -0,0 +1,2 @@ +pub mod anchor; +pub mod governance; diff --git a/programs/core-voter/tests/cast_nft_vote.rs b/programs/core-voter/tests/cast_nft_vote.rs new file mode 100644 index 0000000..e84ce01 --- /dev/null +++ b/programs/core-voter/tests/cast_nft_vote.rs @@ -0,0 +1,1109 @@ +use crate::program_test::core_voter_test::ConfigureCollectionArgs; +use gpl_core_voter::error::NftVoterError; +use gpl_core_voter::state::*; +use program_test::{ + core_voter_test::*, + tools::{assert_gov_err, assert_nft_voter_err}, +}; + +use solana_program_test::*; +use solana_sdk::transport::TransportError; +use spl_governance::error::GovernanceError; + +mod program_test; + +#[tokio::test] +async fn test_cast_asset_vote() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + let clock = core_voter_test.bench.get_clock().await; + + // Act + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + // Assert + let asset_vote_record = core_voter_test + .get_asset_vote_record_account(&asset_vote_record_cookies[0].address) + .await; + + assert_eq!(asset_vote_record_cookies[0].account, asset_vote_record); + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 10); + assert_eq!(voter_weight_record.voter_weight_expiry, Some(clock.slot)); + assert_eq!( + voter_weight_record.weight_action, + Some(VoterWeightAction::CastVote.into()) + ); + assert_eq!( + voter_weight_record.weight_action_target, + Some(proposal_cookie.address) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_multiple_nfts() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + let asset_cookie2 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + let clock = core_voter_test.bench.get_clock().await; + + // Act + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1, &asset_cookie2], + None, + ) + .await?; + + // Assert + let asset_vote_record1 = core_voter_test + .get_asset_vote_record_account(&asset_vote_record_cookies[0].address) + .await; + + assert_eq!(asset_vote_record_cookies[0].account, asset_vote_record1); + + let asset_vote_record2 = core_voter_test + .get_asset_vote_record_account(&asset_vote_record_cookies[1].address) + .await; + + assert_eq!(asset_vote_record_cookies[1].account, asset_vote_record2); + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 20); + assert_eq!(voter_weight_record.voter_weight_expiry, Some(clock.slot)); + assert_eq!( + voter_weight_record.weight_action, + Some(VoterWeightAction::CastVote.into()) + ); + assert_eq!( + voter_weight_record.weight_action_target, + Some(proposal_cookie.address) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_nft_already_voted_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie: program_test::program_test_bench::WalletCookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie,) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + core_voter_test.bench.advance_clock().await; + + // Act + let err = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::NftAlreadyVoted); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_invalid_voter_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let voter_cookie2 = core_voter_test.bench.with_wallet().await; + + // Act + + let err = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie2, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_gov_err(err, GovernanceError::GoverningTokenOwnerOrDelegateMustSign); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_invalid_owner_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let voter_cookie2 = core_voter_test.bench.with_wallet().await; + + let asset_cookie = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie2) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + // Act + let err = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::VoterDoesNotOwnNft); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_invalid_collection_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie: program_test::governance_test::RealmCookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let collection_cookie2 = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie: program_test::program_test_bench::WalletCookie = core_voter_test.bench.with_wallet().await; + + let _random_asset_cookie = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + let asset_cookie = core_voter_test + .core.create_asset(&collection_cookie2, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Act + let err = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::CollectionNotFound); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_same_nft_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Act + let err = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie, &asset_cookie], + None, + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::DuplicatedNftDetected); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_max_5_nfts() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let mut asset_cookies = vec![]; + + for _ in 0..5 { + core_voter_test.bench.advance_clock().await; + let asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + asset_cookies.push(asset_cookie) + } + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + let clock = core_voter_test.bench.get_clock().await; + + // Act + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &asset_cookies.iter().collect::>(), + None, + ) + .await?; + + // Assert + let asset_vote_record1 = core_voter_test + .get_asset_vote_record_account(&asset_vote_record_cookies[0].address) + .await; + + assert_eq!(asset_vote_record_cookies[0].account, asset_vote_record1); + + let asset_vote_record2 = core_voter_test + .get_asset_vote_record_account(&asset_vote_record_cookies[1].address) + .await; + + assert_eq!(asset_vote_record_cookies[1].account, asset_vote_record2); + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 50); + assert_eq!(voter_weight_record.voter_weight_expiry, Some(clock.slot)); + assert_eq!( + voter_weight_record.weight_action, + Some(VoterWeightAction::CastVote.into()) + ); + assert_eq!( + voter_weight_record.weight_action_target, + Some(proposal_cookie.address) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_using_multiple_instructions() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + let asset_cookie2 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + let clock = core_voter_test.bench.get_clock().await; + + let args = CastAssetVoteArgs { + cast_spl_gov_vote: false, + }; + + core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + Some(args), + ) + .await?; + + // Act + core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie2], + None, + ) + .await?; + + // Assert + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 20); + assert_eq!(voter_weight_record.voter_weight_expiry, Some(clock.slot)); + assert_eq!( + voter_weight_record.weight_action, + Some(VoterWeightAction::CastVote.into()) + ); + assert_eq!( + voter_weight_record.weight_action_target, + Some(proposal_cookie.address) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_using_multiple_instructions_with_nft_already_voted_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let args = CastAssetVoteArgs { + cast_spl_gov_vote: false, + }; + + core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + Some(args), + ) + .await?; + + // Act + let err = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::NftAlreadyVoted); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_using_multiple_instructions_with_attempted_sandwiched_relinquish() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let args = CastAssetVoteArgs { + cast_spl_gov_vote: false, + }; + + // Cast vote with NFT + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + Some(args), + ) + .await?; + + core_voter_test.bench.advance_clock().await; + + // Try relinquish NftVoteRecords to accumulate vote + core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await?; + + // Act + + core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + // Assert + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 10); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_using_delegate() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + + let delegate_cookie = core_voter_test.bench.with_wallet().await; + core_voter_test + .governance + .set_governance_delegate( + &realm_cookie, + &voter_token_owner_record_cookie, + &voter_cookie, + &Some(delegate_cookie.address), + ) + .await; + + // Act + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &delegate_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + // Assert + let asset_vote_record = core_voter_test + .get_asset_vote_record_account(&asset_vote_record_cookies[0].address) + .await; + + assert_eq!(asset_vote_record_cookies[0].account, asset_vote_record); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_asset_vote_with_invalid_voter_weight_token_owner_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + // Try to update VoterWeightRecord for different governing_token_owner + let voter_cookie2 = core_voter_test.bench.with_wallet().await; + + let voter_weight_record_cookie2 = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie2) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + // Act + + let err = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie2, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::InvalidTokenOwnerForVoterWeightRecord); + + Ok(()) +} diff --git a/programs/core-voter/tests/configure_collection.rs b/programs/core-voter/tests/configure_collection.rs new file mode 100644 index 0000000..13d3b0a --- /dev/null +++ b/programs/core-voter/tests/configure_collection.rs @@ -0,0 +1,470 @@ +use gpl_core_voter::error::NftVoterError; +use program_test::{ + core_voter_test::CoreVoterTest, + tools::{assert_anchor_err, assert_nft_voter_err}, +}; + +use solana_program_test::*; +use solana_sdk::{signature::Keypair, signer::Signer, transport::TransportError}; + +use crate::program_test::core_voter_test::ConfigureCollectionArgs; + +mod program_test; + +#[tokio::test] +async fn test_configure_collection() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let _asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Act + let collection_config_cookie = core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1 }), + ) + .await?; + + // Assert + let registrar = core_voter_test + .get_registrar_account(®istrar_cookie.address) + .await; + + assert_eq!(registrar.collection_configs.len(), 1); + + assert_eq!( + registrar.collection_configs[0], + collection_config_cookie.collection_config + ); + + let max_voter_weight_record = core_voter_test + .get_max_voter_weight_record(&max_voter_weight_record_cookie.address) + .await; + + assert_eq!(max_voter_weight_record.max_voter_weight_expiry, None); + assert_eq!( + max_voter_weight_record.max_voter_weight, + (registrar.collection_configs[0].weight as u32 * registrar.collection_configs[0].size) + as u64 + ); + + Ok(()) +} + +#[tokio::test] +async fn test_configure_multiple_collections() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie1 = core_voter_test.core.create_collection(None).await?; + let collection_cookie2 = core_voter_test.core.create_collection(None).await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + for _ in 0..5 { + let _asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie1, &voter_cookie) + .await?; + } + + for _ in 0..10 { + let _asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie2, &voter_cookie) + .await?; + } + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Act + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie1, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1 }), + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie2, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 2 }), + ) + .await?; + + // Assert + let registrar = core_voter_test + .get_registrar_account(®istrar_cookie.address) + .await; + + assert_eq!(registrar.collection_configs.len(), 2); + + let max_voter_weight_record = core_voter_test + .get_max_voter_weight_record(&max_voter_weight_record_cookie.address) + .await; + + assert_eq!(max_voter_weight_record.max_voter_weight_expiry, None); + assert_eq!(max_voter_weight_record.max_voter_weight, 25); + + Ok(()) +} + +#[tokio::test] +async fn test_configure_max_collections() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + + let _voter_cookie = core_voter_test.bench.with_wallet().await; + + for _ in 0..registrar_cookie.max_collections { + let collection_cookie = core_voter_test.core.create_collection(Some(3)).await?; + + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1 }), + ) + .await?; + } + + // Assert + let registrar = core_voter_test + .get_registrar_account(®istrar_cookie.address) + .await; + + assert_eq!( + registrar.collection_configs.len() as u8, + registrar_cookie.max_collections + ); + + let max_voter_weight_record = core_voter_test + .get_max_voter_weight_record(&max_voter_weight_record_cookie.address) + .await; + + assert_eq!(max_voter_weight_record.max_voter_weight_expiry, None); + assert_eq!(max_voter_weight_record.max_voter_weight, 30); + + Ok(()) +} + +#[tokio::test] +async fn test_configure_existing_collection() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(Some(10)).await?; + + let _voter_cookie = core_voter_test.bench.with_wallet().await; + + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + // Act + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 2 }), + ) + .await?; + + // Assert + let registrar = core_voter_test + .get_registrar_account(®istrar_cookie.address) + .await; + + assert_eq!(registrar.collection_configs.len(), 1); + + let max_voter_weight_record = core_voter_test + .get_max_voter_weight_record(&max_voter_weight_record_cookie.address) + .await; + + assert_eq!(max_voter_weight_record.max_voter_weight_expiry, None); + assert_eq!(max_voter_weight_record.max_voter_weight, 20); + + Ok(()) +} + +// TODO: Remove collection test + +#[tokio::test] +async fn test_configure_collection_with_invalid_realm_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let _asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Try to use a different Realm + let realm_cookie2 = core_voter_test.governance.with_realm().await?; + + // Act + let err = core_voter_test + .with_collection_using_ix( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + |i| i.accounts[1].pubkey = realm_cookie2.address, // realm + None, + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::InvalidRealmForRegistrar); + + Ok(()) +} + +#[tokio::test] +async fn test_configure_collection_with_realm_authority_must_sign_error( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let _asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Act + let err = core_voter_test + .with_collection_using_ix( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + |i| i.accounts[2].is_signer = false, // realm_authority + Some(&[]), + ) + .await + .err() + .unwrap(); + + // Assert + + assert_anchor_err(err, anchor_lang::error::ErrorCode::AccountNotSigner); + + Ok(()) +} + +#[tokio::test] +async fn test_configure_collection_with_invalid_realm_authority_error() -> Result<(), TransportError> +{ + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(Some(1)).await?; + + // let voter_cookie = core_voter_test.bench.with_wallet().await; + + // let _asset_cookie = core_voter_test + // .core + // .create_asset(&collection_cookie, &voter_cookie) + // .await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let realm_authority = Keypair::new(); + + // Act + let err = core_voter_test + .with_collection_using_ix( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + |i| i.accounts[2].pubkey = realm_authority.pubkey(), // realm_authority + Some(&[&realm_authority]), + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::InvalidRealmAuthority); + + Ok(()) +} + +#[tokio::test] +async fn test_configure_collection_with_invalid_max_voter_weight_realm_error( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(Some(1)).await?; + + // let voter_cookie = core_voter_test.bench.with_wallet().await; + + // let _asset_cookie = core_voter_test + // .core + // .create_asset(&collection_cookie, &voter_cookie) + // .await?; + + let realm_cookie2 = core_voter_test.governance.with_realm().await?; + let registrar_cookie2 = core_voter_test.with_registrar(&realm_cookie2).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie2) + .await?; + + // Act + let err = core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::InvalidMaxVoterWeightRecordRealm); + + Ok(()) +} + +#[tokio::test] +async fn test_configure_collection_with_invalid_max_voter_weight_mint_error( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let mut realm_cookie = core_voter_test.governance.with_realm().await?; + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(Some(1)).await?; + + // let voter_cookie = core_voter_test.bench.with_wallet().await; + + // let _asset_cookie = core_voter_test + // .core + // .create_asset(&collection_cookie, &voter_cookie) + // .await?; + + // Create Registrar for council mint + realm_cookie.account.community_mint = realm_cookie.account.config.council_mint.unwrap(); + let registrar_cookie2 = core_voter_test.with_registrar(&realm_cookie).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie2) + .await?; + + // Act + let err = core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::InvalidMaxVoterWeightRecordMint); + + Ok(()) +} diff --git a/programs/core-voter/tests/create_max_voter_weight_record.rs b/programs/core-voter/tests/create_max_voter_weight_record.rs new file mode 100644 index 0000000..d6bb2dc --- /dev/null +++ b/programs/core-voter/tests/create_max_voter_weight_record.rs @@ -0,0 +1,127 @@ +use anchor_lang::prelude::ErrorCode; +use program_test::{ + core_voter_test::CoreVoterTest, + tools::{assert_anchor_err, assert_ix_err}, +}; +use solana_program::instruction::InstructionError; +use solana_program_test::*; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_create_max_voter_weight_record() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + // Act + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Assert + + let max_voter_weight_record = core_voter_test + .get_max_voter_weight_record(&max_voter_weight_record_cookie.address) + .await; + + assert_eq!( + max_voter_weight_record_cookie.account, + max_voter_weight_record + ); + + Ok(()) +} + +#[tokio::test] +async fn test_create_max_voter_weight_record_with_invalid_realm_error() -> Result<(), TransportError> +{ + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = core_voter_test.governance.with_realm().await?; + + // Act + let err = core_voter_test + .with_max_voter_weight_record_using_ix(®istrar_cookie, |i| { + i.accounts[2].pubkey = realm_cookie2.address // Realm + }) + .await + .err() + .unwrap(); + + // Assert + + // PDA doesn't match and hence the error is ConstraintSeeds + assert_anchor_err(err, ErrorCode::ConstraintSeeds); + + Ok(()) +} + +#[tokio::test] +async fn test_create_max_voter_weight_record_with_invalid_mint_error() -> Result<(), TransportError> +{ + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = core_voter_test.governance.with_realm().await?; + + // Act + let err = core_voter_test + .with_max_voter_weight_record_using_ix(®istrar_cookie, |i| { + i.accounts[2].pubkey = realm_cookie2.address // Mint + }) + .await + .err() + .unwrap(); + + // Assert + + // PDA doesn't match and hence the error is ConstraintSeeds + assert_anchor_err(err, ErrorCode::ConstraintSeeds); + + Ok(()) +} + +#[tokio::test] +async fn test_create_max_voter_weight_record_with_already_exists_error( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + + // Act + let err = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await + .err() + .unwrap(); + + // Assert + + // InstructionError::Custom(0) is returned for TransactionError::AccountInUse + assert_ix_err(err, InstructionError::Custom(0)); + + Ok(()) +} diff --git a/programs/core-voter/tests/create_registrar.rs b/programs/core-voter/tests/create_registrar.rs new file mode 100644 index 0000000..7803a3d --- /dev/null +++ b/programs/core-voter/tests/create_registrar.rs @@ -0,0 +1,156 @@ +mod program_test; + +use anchor_lang::prelude::{ErrorCode, Pubkey}; +use gpl_core_voter::error::NftVoterError; +use program_test::core_voter_test::CoreVoterTest; + +use solana_program_test::*; +use solana_sdk::{signature::Keypair, transport::TransportError}; + +use program_test::tools::{assert_anchor_err, assert_nft_voter_err}; + +#[tokio::test] +async fn test_create_registrar() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + // Act + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + // Assert + let registrar = core_voter_test + .get_registrar_account(®istrar_cookie.address) + .await; + + assert_eq!(registrar, registrar_cookie.account); + + Ok(()) +} + +#[tokio::test] +async fn test_create_registrar_with_invalid_realm_authority_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let mut realm_cookie = core_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Act + let err = core_voter_test + .with_registrar(&realm_cookie) + .await + .err() + .unwrap(); + + assert_nft_voter_err(err, NftVoterError::InvalidRealmAuthority); + + Ok(()) +} + +#[tokio::test] +async fn test_create_registrar_with_realm_authority_must_sign_error() -> Result<(), TransportError> +{ + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let mut realm_cookie = core_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Act + let err = core_voter_test + .with_registrar_using_ix( + &realm_cookie, + |i| i.accounts[4].is_signer = false, // realm_authority + Some(&[]), + ) + .await + .err() + .unwrap(); + + assert_anchor_err(err, anchor_lang::error::ErrorCode::AccountNotSigner); + + Ok(()) +} + +#[tokio::test] +async fn test_create_registrar_with_invalid_spl_gov_program_id_error() -> Result<(), TransportError> +{ + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let mut realm_cookie = core_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Try to use a different program id + let governance_program_id = core_voter_test.program_id; + + // Act + let err = core_voter_test + .with_registrar_using_ix( + &realm_cookie, + |i| i.accounts[1].pubkey = governance_program_id, //governance_program_id + None, + ) + .await + .err() + .unwrap(); + + assert_anchor_err(err, anchor_lang::error::ErrorCode::ConstraintOwner); + + Ok(()) +} + +#[tokio::test] +async fn test_create_registrar_with_invalid_realm_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let mut realm_cookie = core_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Act + let err = core_voter_test + .with_registrar_using_ix( + &realm_cookie, + |i| i.accounts[2].pubkey = Pubkey::new_unique(), // realm + None, + ) + .await + .err() + .unwrap(); + + // PDA doesn't match and hence the error is ConstraintSeeds + assert_anchor_err(err, ErrorCode::ConstraintSeeds); + + Ok(()) +} + +#[tokio::test] +async fn test_create_registrar_with_invalid_governing_token_mint_error( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let mut realm_cookie = core_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + let mint_cookie = core_voter_test.bench.with_mint().await?; + + // Act + let err = core_voter_test + .with_registrar_using_ix( + &realm_cookie, + |i| i.accounts[3].pubkey = mint_cookie.address, // governing_token_mint + None, + ) + .await + .err() + .unwrap(); + + // PDA doesn't match and hence the error is ConstraintSeeds + assert_anchor_err(err, ErrorCode::ConstraintSeeds); + + Ok(()) +} diff --git a/programs/core-voter/tests/create_voter_weight_record.rs b/programs/core-voter/tests/create_voter_weight_record.rs new file mode 100644 index 0000000..6a6c75d --- /dev/null +++ b/programs/core-voter/tests/create_voter_weight_record.rs @@ -0,0 +1,127 @@ +use anchor_lang::prelude::ErrorCode; +use program_test::core_voter_test::CoreVoterTest; +use program_test::tools::{assert_anchor_err, assert_ix_err}; +use solana_program::instruction::InstructionError; +use solana_program_test::*; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_create_voter_weight_record() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + // Act + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Assert + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record_cookie.account, voter_weight_record); + + Ok(()) +} + +#[tokio::test] +async fn test_create_voter_weight_record_with_invalid_realm_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = core_voter_test.governance.with_realm().await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + // Act + let err = core_voter_test + .with_voter_weight_record_using_ix(®istrar_cookie, &voter_cookie, |i| { + i.accounts[2].pubkey = realm_cookie2.address // Realm + }) + .await + .err() + .unwrap(); + + // Assert + + // PDA doesn't match and hence the error is ConstraintSeeds + assert_anchor_err(err, ErrorCode::ConstraintSeeds); + + Ok(()) +} + +#[tokio::test] +async fn test_create_voter_weight_record_with_invalid_mint_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = core_voter_test.governance.with_realm().await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + // Act + let err = core_voter_test + .with_voter_weight_record_using_ix(®istrar_cookie, &voter_cookie, |i| { + i.accounts[2].pubkey = realm_cookie2.address // Mint + }) + .await + .err() + .unwrap(); + + // Assert + + // PDA doesn't match and hence the error is ConstraintSeeds + assert_anchor_err(err, ErrorCode::ConstraintSeeds); + + Ok(()) +} + +#[tokio::test] +async fn test_create_voter_weight_record_with_already_exists_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + + // Act + let err = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await + .err() + .unwrap(); + + // Assert + + // InstructionError::Custom(0) is returned for TransactionError::AccountInUse + assert_ix_err(err, InstructionError::Custom(0)); + + Ok(()) +} diff --git a/programs/core-voter/tests/fixtures/mpl_core.so b/programs/core-voter/tests/fixtures/mpl_core.so new file mode 100644 index 0000000..7491bb4 Binary files /dev/null and b/programs/core-voter/tests/fixtures/mpl_core.so differ diff --git a/programs/core-voter/tests/fixtures/spl_governance.so b/programs/core-voter/tests/fixtures/spl_governance.so new file mode 100755 index 0000000..84786e9 Binary files /dev/null and b/programs/core-voter/tests/fixtures/spl_governance.so differ diff --git a/programs/core-voter/tests/program_test/core_test.rs b/programs/core-voter/tests/program_test/core_test.rs new file mode 100644 index 0000000..860cf75 --- /dev/null +++ b/programs/core-voter/tests/program_test/core_test.rs @@ -0,0 +1,153 @@ +use std::{str::FromStr, sync::Arc}; + +use anchor_lang::prelude::Pubkey; +use solana_program_test::ProgramTest; +use solana_sdk::{signature::Keypair, signer::Signer, system_program, transport::TransportError}; + +use crate::program_test::program_test_bench::{ProgramTestBench, WalletCookie}; + +pub struct AssetCookie { + pub asset: Pubkey, +} + +pub struct CollectionCookie { + pub collection: Pubkey, + pub authority: Keypair, +} + +pub struct CoreTest { + pub bench: Arc, + pub program_id: Pubkey, +} + +impl CoreTest { + pub fn program_id() -> Pubkey { + Pubkey::from_str("CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d").unwrap() + } + + #[allow(dead_code)] + pub fn add_program(program_test: &mut ProgramTest) { + program_test.add_program("mpl_core", Self::program_id(), None); + } + + #[allow(dead_code)] + pub fn new(bench: Arc) -> Self { + CoreTest { + bench, + program_id: Self::program_id(), + } + } + + #[allow(dead_code)] + pub async fn create_collection( + &self, + collection_size: Option, + ) -> Result { + let update_authority = self.bench.context.borrow().payer.pubkey(); + let payer = self.bench.context.borrow().payer.pubkey(); + + // Create collection + let coll_keypair = Keypair::new(); + let coll_authority = Keypair::new(); + + let coll_name = "NFT_C".to_string(); + let coll_uri = "URI".to_string(); + + // instruction args + let args = mpl_core::instructions::CreateCollectionV2InstructionArgs { + name: coll_name, + uri: coll_uri, + plugins: None, + external_plugin_adapters: None, + }; + + // instruction accounts + let create_coll_ix_accounts = mpl_core::instructions::CreateCollectionV2 { + collection: coll_keypair.pubkey(), + update_authority: Some(update_authority), + payer, + system_program: system_program::ID, + }; + + // creates the instruction + let create_coll_ix = create_coll_ix_accounts.instruction(args); + + self.bench + .process_transaction(&[create_coll_ix], Some(&[&coll_keypair])) + .await?; + + println!("Minting {} assets to collection", collection_size.unwrap()); + if collection_size.is_some() { + self.mint_assets_to_collection( + &CollectionCookie { + collection: coll_keypair.pubkey(), + authority: coll_authority.insecure_clone(), + }, + collection_size.unwrap(), + ) + .await; + } + + Ok(CollectionCookie { + collection: coll_keypair.pubkey(), + authority: coll_authority, + }) + } + + #[allow(dead_code)] + pub async fn create_asset( + &self, + collection_cookie: &CollectionCookie, + asset_owner_cookie: &WalletCookie, + // collection: Option, + ) -> Result { + let collection_authority = self.bench.context.borrow().payer.pubkey(); + let payer = self.bench.context.borrow().payer.pubkey(); + + // Create Asset + let asset_keypair = Keypair::new(); + + let name = "TestAsset".to_string(); + let uri = "URI".to_string(); + + // instruction args + let args = mpl_core::instructions::CreateV2InstructionArgs { + data_state: mpl_core::types::DataState::AccountState, + name, + uri, + plugins: None, + external_plugin_adapters: None, + }; + + // instruction accounts + let create_accounts = mpl_core::instructions::CreateV2 { + asset: asset_keypair.pubkey(), + collection: Some(collection_cookie.collection), + authority: Some(collection_authority), + payer, + owner: Some(asset_owner_cookie.address), + update_authority: None, + system_program: system_program::ID, + log_wrapper: None, + }; + + // creates the instruction + let create_ix = create_accounts.instruction(args); + + self.bench + .process_transaction(&[create_ix], Some(&[&asset_keypair])) + .await?; + + Ok(AssetCookie { + asset: asset_keypair.pubkey(), + }) + } + + pub async fn mint_assets_to_collection(&self, collection_cookie: &CollectionCookie, size: u64) { + let asset_owner = self.bench.with_wallet().await; + + for _ in 0..size { + let _ = self.create_asset(&collection_cookie, &asset_owner).await; + } + } +} diff --git a/programs/core-voter/tests/program_test/core_voter_test.rs b/programs/core-voter/tests/program_test/core_voter_test.rs new file mode 100644 index 0000000..3a21859 --- /dev/null +++ b/programs/core-voter/tests/program_test/core_voter_test.rs @@ -0,0 +1,684 @@ +use std::sync::Arc; + +use anchor_lang::prelude::{AccountMeta, Pubkey}; + +use anchor_lang::system_program; +use gpl_core_voter::state::max_voter_weight_record::{ + get_max_voter_weight_record_address, MaxVoterWeightRecord, +}; +use gpl_core_voter::state::*; + +use mpl_core::accounts::BaseCollectionV1; +use solana_sdk::transport::TransportError; +use spl_governance::instruction::cast_vote; +use spl_governance::state::vote_record::{self, Vote, VoteChoice}; + +use gpl_core_voter::state::{ + get_nft_vote_record_address, get_registrar_address, AssetVoteRecord, CollectionConfig, + Registrar, +}; + +use solana_program_test::{BanksClientError, ProgramTest}; +use solana_sdk::instruction::Instruction; +use solana_sdk::signature::Keypair; +use solana_sdk::signer::Signer; + +use crate::program_test::governance_test::GovernanceTest; +use crate::program_test::program_test_bench::ProgramTestBench; + +use crate::program_test::core_test::{AssetCookie, CollectionCookie, CoreTest}; +use crate::program_test::governance_test::{ProposalCookie, RealmCookie, TokenOwnerRecordCookie}; +use crate::program_test::program_test_bench::WalletCookie; +use crate::program_test::tools::NopOverride; + +use super::core_test; + +#[derive(Debug, PartialEq)] +pub struct RegistrarCookie { + pub address: Pubkey, + pub account: Registrar, + + pub realm_authority: Keypair, + pub max_collections: u8, +} + + +pub struct VoterWeightRecordCookie { + pub address: Pubkey, + pub account: VoterWeightRecord, +} + +pub struct MaxVoterWeightRecordCookie { + pub address: Pubkey, + pub account: MaxVoterWeightRecord, +} + +pub struct CollectionConfigCookie { + pub collection_config: CollectionConfig, +} + +pub struct ConfigureCollectionArgs { + pub weight: u64, +} + +impl Default for ConfigureCollectionArgs { + fn default() -> Self { + Self { weight: 1 } + } +} + +#[derive(Debug, PartialEq)] +pub struct AssetVoteRecordCookie { + pub address: Pubkey, + pub account: AssetVoteRecord, +} + +pub struct CastAssetVoteArgs { + pub cast_spl_gov_vote: bool, +} + +impl Default for CastAssetVoteArgs { + fn default() -> Self { + Self { + cast_spl_gov_vote: true, + } + } +} + +pub struct CoreVoterTest { + pub program_id: Pubkey, + pub bench: Arc, + pub governance: GovernanceTest, + pub core: CoreTest, +} + +impl CoreVoterTest { + #[allow(dead_code)] + pub fn add_program(program_test: &mut ProgramTest) { + program_test.add_program("gpl_core_voter", gpl_core_voter::id(), None); + } + + #[allow(dead_code)] + pub async fn start_new() -> Self { + let mut program_test = ProgramTest::default(); + + CoreVoterTest::add_program(&mut program_test); + GovernanceTest::add_program(&mut program_test); + CoreTest::add_program(&mut program_test); + + let program_id = gpl_core_voter::id(); + + let bench = ProgramTestBench::start_new(program_test).await; + let bench_rc = Arc::new(bench); + + let governance_bench = + GovernanceTest::new(bench_rc.clone(), Some(program_id), Some(program_id)); + let core_bench = CoreTest::new(bench_rc.clone()); + + Self { + program_id, + bench: bench_rc, + governance: governance_bench, + core: core_bench, + } + } + + #[allow(dead_code)] + pub async fn with_asset( + &self, + collection_cookie: &CollectionCookie, + asset_owner_cookie: &WalletCookie, + ) -> Result { + let collection_authority = self.bench.context.borrow().payer.pubkey(); + let payer = self.bench.context.borrow().payer.pubkey(); + + // Create Asset + let asset_keypair = Keypair::new(); + + let name = "TestAsset".to_string(); + let uri = "URI".to_string(); + + // instruction args + let args = mpl_core::instructions::CreateV2InstructionArgs { + data_state: mpl_core::types::DataState::AccountState, + name, + uri, + plugins: None, + external_plugin_adapters: None, + }; + + // instruction accounts + let create_accounts = mpl_core::instructions::CreateV2 { + asset: asset_keypair.pubkey(), + collection: Some(collection_cookie.collection), + authority: Some(collection_authority), + payer, + owner: Some(asset_owner_cookie.address), + update_authority: None, + system_program: system_program::ID, + log_wrapper: None, + }; + + // creates the instruction + let create_ix = create_accounts.instruction(args); + + self.bench + .process_transaction(&[create_ix], Some(&[&asset_keypair])) + .await?; + + Ok(AssetCookie { + asset: asset_keypair.pubkey(), + }) + } + + #[allow(dead_code)] + pub async fn with_registrar( + &mut self, + realm_cookie: &RealmCookie, + ) -> Result { + self.with_registrar_using_ix(realm_cookie, NopOverride, None) + .await + } + + #[allow(dead_code)] + pub async fn with_registrar_using_ix( + &mut self, + realm_cookie: &RealmCookie, + instruction_override: F, + signers_override: Option<&[&Keypair]>, + ) -> Result { + let registrar_key = + get_registrar_address(&realm_cookie.address, &realm_cookie.account.community_mint); + + let max_collections = 10; + + let data = + anchor_lang::InstructionData::data(&gpl_core_voter::instruction::CreateRegistrar { + max_collections, + }); + + let accounts = anchor_lang::ToAccountMetas::to_account_metas( + &gpl_core_voter::accounts::CreateRegistrar { + registrar: registrar_key, + realm: realm_cookie.address, + governance_program_id: self.governance.program_id, + governing_token_mint: realm_cookie.account.community_mint, + realm_authority: realm_cookie.get_realm_authority().pubkey(), + payer: self.bench.payer.pubkey(), + system_program: solana_sdk::system_program::id(), + }, + None, + ); + + let mut create_registrar_ix = Instruction { + program_id: gpl_core_voter::id(), + accounts, + data, + }; + + instruction_override(&mut create_registrar_ix); + + let default_signers = &[&realm_cookie.realm_authority]; + let signers = signers_override.unwrap_or(default_signers); + + self.bench + .process_transaction(&[create_registrar_ix], Some(signers)) + .await?; + + let account = Registrar { + governance_program_id: self.governance.program_id, + realm: realm_cookie.address, + governing_token_mint: realm_cookie.account.community_mint, + collection_configs: vec![], + reserved: [0; 128], + }; + + Ok(RegistrarCookie { + address: registrar_key, + account, + realm_authority: realm_cookie.get_realm_authority(), + max_collections, + }) + } + + #[allow(dead_code)] + pub async fn with_voter_weight_record( + &self, + registrar_cookie: &RegistrarCookie, + voter_cookie: &WalletCookie, + ) -> Result { + self.with_voter_weight_record_using_ix(registrar_cookie, voter_cookie, NopOverride) + .await + } + + #[allow(dead_code)] + pub async fn with_voter_weight_record_using_ix( + &self, + registrar_cookie: &RegistrarCookie, + voter_cookie: &WalletCookie, + instruction_override: F, + ) -> Result { + let governing_token_owner = voter_cookie.address; + + let (voter_weight_record_key, _) = Pubkey::find_program_address( + &[ + b"voter-weight-record".as_ref(), + registrar_cookie.account.realm.as_ref(), + registrar_cookie.account.governing_token_mint.as_ref(), + governing_token_owner.as_ref(), + ], + &gpl_core_voter::id(), + ); + + let data = anchor_lang::InstructionData::data( + &gpl_core_voter::instruction::CreateVoterWeightRecord { + governing_token_owner, + }, + ); + + let accounts = gpl_core_voter::accounts::CreateVoterWeightRecord { + governance_program_id: self.governance.program_id, + realm: registrar_cookie.account.realm, + realm_governing_token_mint: registrar_cookie.account.governing_token_mint, + voter_weight_record: voter_weight_record_key, + payer: self.bench.payer.pubkey(), + system_program: solana_sdk::system_program::id(), + }; + + let mut create_voter_weight_record_ix = Instruction { + program_id: gpl_core_voter::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas(&accounts, None), + data, + }; + + instruction_override(&mut create_voter_weight_record_ix); + + self.bench + .process_transaction(&[create_voter_weight_record_ix], None) + .await?; + + let account = VoterWeightRecord { + realm: registrar_cookie.account.realm, + governing_token_mint: registrar_cookie.account.governing_token_mint, + governing_token_owner, + voter_weight: 0, + voter_weight_expiry: Some(0), + weight_action: None, + weight_action_target: None, + reserved: [0; 8], + }; + + Ok(VoterWeightRecordCookie { + address: voter_weight_record_key, + account, + }) + } + + #[allow(dead_code)] + pub async fn with_max_voter_weight_record( + &mut self, + registrar_cookie: &RegistrarCookie, + ) -> Result { + self.with_max_voter_weight_record_using_ix(registrar_cookie, NopOverride) + .await + } + + #[allow(dead_code)] + pub async fn with_max_voter_weight_record_using_ix( + &mut self, + registrar_cookie: &RegistrarCookie, + instruction_override: F, + ) -> Result { + let max_voter_weight_record_key = get_max_voter_weight_record_address( + ®istrar_cookie.account.realm, + ®istrar_cookie.account.governing_token_mint, + ); + + let data = anchor_lang::InstructionData::data( + &gpl_core_voter::instruction::CreateMaxVoterWeightRecord {}, + ); + + let accounts = gpl_core_voter::accounts::CreateMaxVoterWeightRecord { + governance_program_id: self.governance.program_id, + realm: registrar_cookie.account.realm, + realm_governing_token_mint: registrar_cookie.account.governing_token_mint, + max_voter_weight_record: max_voter_weight_record_key, + payer: self.bench.payer.pubkey(), + system_program: solana_sdk::system_program::id(), + }; + + let mut create_max_voter_weight_record_ix = Instruction { + program_id: gpl_core_voter::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas(&accounts, None), + data, + }; + + instruction_override(&mut create_max_voter_weight_record_ix); + + self.bench + .process_transaction(&[create_max_voter_weight_record_ix], None) + .await?; + + let account = MaxVoterWeightRecord { + realm: registrar_cookie.account.realm, + governing_token_mint: registrar_cookie.account.governing_token_mint, + max_voter_weight: 0, + max_voter_weight_expiry: Some(0), + reserved: [0; 8], + }; + + Ok(MaxVoterWeightRecordCookie { + account, + address: max_voter_weight_record_key, + }) + } + + #[allow(dead_code)] + pub async fn update_voter_weight_record( + &self, + registrar_cookie: &RegistrarCookie, + voter_weight_record_cookie: &mut VoterWeightRecordCookie, + voter_weight_action: VoterWeightAction, + asset_cookies: &[&AssetCookie], + ) -> Result<(), BanksClientError> { + let data = anchor_lang::InstructionData::data( + &gpl_core_voter::instruction::UpdateVoterWeightRecord { + voter_weight_action, + }, + ); + + let accounts = gpl_core_voter::accounts::UpdateVoterWeightRecord { + registrar: registrar_cookie.address, + voter_weight_record: voter_weight_record_cookie.address, + }; + + let mut account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + + for asset_cookie in asset_cookies { + account_metas.push(AccountMeta::new_readonly(asset_cookie.asset, false)); + } + + let instructions = vec![Instruction { + program_id: gpl_core_voter::id(), + accounts: account_metas, + data, + }]; + + self.bench.process_transaction(&instructions, None).await + } + + #[allow(dead_code)] + pub async fn update_max_voter_weight_record( + &self, + registrar_cookie: &RegistrarCookie, + max_voter_weight_record_cookie: &mut MaxVoterWeightRecordCookie, + ) -> Result<(), BanksClientError> { + let data = anchor_lang::InstructionData::data( + &gpl_core_voter::instruction::UpdateMaxVoterWeightRecord {} + ); + + let accounts = gpl_core_voter::accounts::UpdateMaxVoterWeightRecord { + registrar: registrar_cookie.address, + max_voter_weight_record: max_voter_weight_record_cookie.address, + }; + + let account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + + let instructions = vec![Instruction { + program_id: gpl_core_voter::id(), + accounts: account_metas, + data, + }]; + + self.bench.process_transaction(&instructions, None).await + } + + #[allow(dead_code)] + pub async fn relinquish_nft_vote( + &mut self, + registrar_cookie: &RegistrarCookie, + voter_weight_record_cookie: &VoterWeightRecordCookie, + proposal_cookie: &ProposalCookie, + voter_cookie: &WalletCookie, + voter_token_owner_record_cookie: &TokenOwnerRecordCookie, + asset_vote_record_cookies: &Vec, + ) -> Result<(), BanksClientError> { + let data = + anchor_lang::InstructionData::data(&gpl_core_voter::instruction::RelinquishNftVote {}); + + let vote_record_key = vote_record::get_vote_record_address( + &self.governance.program_id, + &proposal_cookie.address, + &voter_token_owner_record_cookie.address, + ); + + let accounts = gpl_core_voter::accounts::RelinquishNftVote { + registrar: registrar_cookie.address, + voter_weight_record: voter_weight_record_cookie.address, + governance: proposal_cookie.account.governance, + proposal: proposal_cookie.address, + vote_record: vote_record_key, + beneficiary: self.bench.payer.pubkey(), + voter_token_owner_record: voter_token_owner_record_cookie.address, + voter_authority: voter_cookie.address, + }; + + let mut account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + + for asset_vote_record_cookie in asset_vote_record_cookies { + account_metas.push(AccountMeta::new(asset_vote_record_cookie.address, false)); + } + + let relinquish_nft_vote_ix = Instruction { + program_id: gpl_core_voter::id(), + accounts: account_metas, + data, + }; + + self.bench + .process_transaction(&[relinquish_nft_vote_ix], Some(&[&voter_cookie.signer])) + .await?; + + Ok(()) + } + + #[allow(dead_code)] + pub async fn with_collection( + &mut self, + registrar_cookie: &RegistrarCookie, + nft_collection_cookie: &CollectionCookie, + max_voter_weight_record_cookie: &MaxVoterWeightRecordCookie, + args: Option, + ) -> Result { + self.with_collection_using_ix( + registrar_cookie, + nft_collection_cookie, + max_voter_weight_record_cookie, + args, + NopOverride, + None, + ) + .await + } + + #[allow(dead_code)] + pub async fn with_collection_using_ix( + &mut self, + registrar_cookie: &RegistrarCookie, + collection_cookie: &CollectionCookie, + max_voter_weight_record_cookie: &MaxVoterWeightRecordCookie, + args: Option, + instruction_override: F, + signers_override: Option<&[&Keypair]>, + ) -> Result { + let args = args.unwrap_or_default(); + + let data = + anchor_lang::InstructionData::data(&gpl_core_voter::instruction::ConfigureCollection { + weight: args.weight, + }); + + let accounts = gpl_core_voter::accounts::ConfigureCollection { + registrar: registrar_cookie.address, + realm: registrar_cookie.account.realm, + realm_authority: registrar_cookie.realm_authority.pubkey(), + collection: collection_cookie.collection, + max_voter_weight_record: max_voter_weight_record_cookie.address, + }; + + let mut configure_collection_ix = Instruction { + program_id: gpl_core_voter::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas(&accounts, None), + data, + }; + + instruction_override(&mut configure_collection_ix); + + let default_signers = &[®istrar_cookie.realm_authority]; + let signers = signers_override.unwrap_or(default_signers); + + self.bench + .process_transaction(&[configure_collection_ix], Some(signers)) + .await?; + + let collection_account = self + .get_collection_account(&collection_cookie.collection) + .await; + + let collection_config = CollectionConfig { + collection: collection_cookie.collection, + size: collection_account.current_size, + weight: args.weight, + reserved: [0; 8], + }; + + Ok(CollectionConfigCookie { collection_config }) + } + + /// Casts NFT Vote and spl-gov Vote + #[allow(dead_code)] + pub async fn cast_asset_vote( + &mut self, + registrar_cookie: &RegistrarCookie, + voter_weight_record_cookie: &VoterWeightRecordCookie, + max_voter_weight_record_cookie: &MaxVoterWeightRecordCookie, + proposal_cookie: &ProposalCookie, + asset_voter_cookie: &WalletCookie, + voter_token_owner_record_cookie: &TokenOwnerRecordCookie, + asset_cookies: &[&AssetCookie], + args: Option, + ) -> Result, BanksClientError> { + let args = args.unwrap_or_default(); + + let data = anchor_lang::InstructionData::data(&gpl_core_voter::instruction::CastNftVote { + proposal: proposal_cookie.address, + }); + + let accounts = gpl_core_voter::accounts::CastNftVote { + registrar: registrar_cookie.address, + voter_weight_record: voter_weight_record_cookie.address, + voter_token_owner_record: voter_token_owner_record_cookie.address, + voter_authority: asset_voter_cookie.address, + payer: self.bench.payer.pubkey(), + system_program: solana_sdk::system_program::id(), + }; + + let mut account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + let mut asset_vote_record_cookies = vec![]; + + for asset_cookie in asset_cookies { + account_metas.push(AccountMeta::new_readonly(asset_cookie.asset, false)); + + let nft_vote_record_key = + get_nft_vote_record_address(&proposal_cookie.address, &asset_cookie.asset); + account_metas.push(AccountMeta::new(nft_vote_record_key, false)); + + let account = AssetVoteRecord { + proposal: proposal_cookie.address, + asset_mint: asset_cookie.asset, + governing_token_owner: voter_weight_record_cookie.account.governing_token_owner, + account_discriminator: AssetVoteRecord::ACCOUNT_DISCRIMINATOR, + reserved: [0; 8], + }; + + asset_vote_record_cookies.push(AssetVoteRecordCookie { + address: nft_vote_record_key, + account, + }) + } + + let cast_nft_vote_ix = Instruction { + program_id: gpl_core_voter::id(), + accounts: account_metas, + data, + }; + + let mut instruction = vec![cast_nft_vote_ix]; + + if args.cast_spl_gov_vote { + // spl-gov cast vote + let vote = Vote::Approve(vec![VoteChoice { + rank: 0, + weight_percentage: 100, + }]); + + let cast_vote_ix = cast_vote( + &self.governance.program_id, + ®istrar_cookie.account.realm, + &proposal_cookie.account.governance, + &proposal_cookie.address, + &proposal_cookie.account.token_owner_record, + &voter_token_owner_record_cookie.address, + &asset_voter_cookie.address, + &proposal_cookie.account.governing_token_mint, + &self.bench.payer.pubkey(), + Some(voter_weight_record_cookie.address), + Some(max_voter_weight_record_cookie.address), + vote, + ); + + instruction.push(cast_vote_ix); + } + + self.bench + .process_transaction(&instruction, Some(&[&asset_voter_cookie.signer])) + .await?; + + Ok(asset_vote_record_cookies) + } + + #[allow(dead_code)] + pub async fn get_registrar_account(&mut self, registrar: &Pubkey) -> Registrar { + self.bench.get_anchor_account::(*registrar).await + } + + #[allow(dead_code)] + pub async fn get_asset_vote_record_account( + &mut self, + nft_vote_record: &Pubkey, + ) -> AssetVoteRecord { + self.bench + .get_borsh_account::(nft_vote_record) + .await + } + + #[allow(dead_code)] + pub async fn get_max_voter_weight_record( + &self, + max_voter_weight_record: &Pubkey, + ) -> MaxVoterWeightRecord { + self.bench + .get_anchor_account(*max_voter_weight_record) + .await + } + + #[allow(dead_code)] + pub async fn get_voter_weight_record(&self, voter_weight_record: &Pubkey) -> VoterWeightRecord { + self.bench.get_anchor_account(*voter_weight_record).await + } + + #[allow(dead_code)] + pub async fn get_collection_account(&self, voter_weight_record: &Pubkey) -> BaseCollectionV1 { + self.bench.get_anchor_account(*voter_weight_record).await + } +} diff --git a/programs/core-voter/tests/program_test/governance_test.rs b/programs/core-voter/tests/program_test/governance_test.rs new file mode 100644 index 0000000..31f86fd --- /dev/null +++ b/programs/core-voter/tests/program_test/governance_test.rs @@ -0,0 +1,432 @@ +use std::{str::FromStr, sync::Arc}; + +use anchor_lang::prelude::Pubkey; +use solana_program_test::ProgramTest; +use solana_sdk::{signature::Keypair, signer::Signer, transport::TransportError}; +use spl_governance::{ + instruction::{ + create_governance, create_proposal, create_realm, create_token_owner_record, + deposit_governing_tokens, relinquish_vote, set_governance_delegate, sign_off_proposal, + }, + state::{ + enums::{ + GovernanceAccountType, MintMaxVoterWeightSource, ProposalState, VoteThreshold, + VoteTipping, + }, + governance::get_governance_address, + proposal::{get_proposal_address, ProposalV2}, + realm::{get_realm_address, GoverningTokenConfigAccountArgs, RealmConfig, RealmV2}, + realm_config::GoverningTokenType, + token_owner_record::{ + get_token_owner_record_address, TokenOwnerRecordV2, TOKEN_OWNER_RECORD_LAYOUT_VERSION, + }, + }, +}; + +use crate::program_test::{ + program_test_bench::{MintCookie, ProgramTestBench, WalletCookie}, + tools::clone_keypair, +}; + +pub struct RealmCookie { + pub address: Pubkey, + pub account: RealmV2, + pub realm_authority: Keypair, + pub community_mint_cookie: MintCookie, + pub council_mint_cookie: Option, +} + +impl RealmCookie { + pub fn get_realm_authority(&self) -> Keypair { + clone_keypair(&self.realm_authority) + } +} + +pub struct ProposalCookie { + pub address: Pubkey, + pub account: ProposalV2, +} + +pub struct TokenOwnerRecordCookie { + pub address: Pubkey, + pub account: TokenOwnerRecordV2, +} + +pub struct GovernanceTest { + pub program_id: Pubkey, + pub bench: Arc, + pub next_id: u8, + pub community_voter_weight_addin: Option, + pub max_community_voter_weight_addin: Option, +} + +impl GovernanceTest { + pub fn program_id() -> Pubkey { + Pubkey::from_str("Governance111111111111111111111111111111111").unwrap() + } + + #[allow(dead_code)] + pub fn add_program(program_test: &mut ProgramTest) { + program_test.add_program("spl_governance", Self::program_id(), None); + } + + #[allow(dead_code)] + pub fn new( + bench: Arc, + community_voter_weight_addin: Option, + max_community_voter_weight_addin: Option, + ) -> Self { + GovernanceTest { + bench, + program_id: Self::program_id(), + next_id: 0, + community_voter_weight_addin, + max_community_voter_weight_addin, + } + } + + #[allow(dead_code)] + pub async fn with_realm(&mut self) -> Result { + let realm_authority = Keypair::new(); + + let community_mint_cookie = self.bench.with_mint().await?; + let council_mint_cookie = self.bench.with_mint().await?; + + self.next_id += 1; + let realm_name = format!("Realm #{}", self.next_id).to_string(); + + let min_community_weight_to_create_governance = 1; + let community_mint_max_voter_weight_source = MintMaxVoterWeightSource::FULL_SUPPLY_FRACTION; + + let realm_key = get_realm_address(&self.program_id, &realm_name); + + let community_token_config_args = GoverningTokenConfigAccountArgs { + voter_weight_addin: self.community_voter_weight_addin, + max_voter_weight_addin: self.max_community_voter_weight_addin, + token_type: GoverningTokenType::default(), + }; + + let create_realm_ix = create_realm( + &self.program_id, + &realm_authority.pubkey(), + &community_mint_cookie.address, + &self.bench.payer.pubkey(), + Some(council_mint_cookie.address), + Some(community_token_config_args), + None, + realm_name.clone(), + min_community_weight_to_create_governance, + community_mint_max_voter_weight_source.clone(), + ); + + self.bench + .process_transaction(&[create_realm_ix], None) + .await?; + + let account = RealmV2 { + account_type: GovernanceAccountType::RealmV2, + community_mint: community_mint_cookie.address, + + name: realm_name, + reserved: [0; 6], + authority: Some(realm_authority.pubkey()), + config: RealmConfig { + council_mint: Some(council_mint_cookie.address), + reserved: [0; 6], + min_community_weight_to_create_governance, + community_mint_max_voter_weight_source, + legacy1: 0, + legacy2: 0, + }, + reserved_v2: [0; 128], + legacy1: 0, + }; + + Ok(RealmCookie { + address: realm_key, + account, + realm_authority, + community_mint_cookie, + council_mint_cookie: Some(council_mint_cookie), + }) + } + + #[allow(dead_code)] + pub async fn with_proposal( + &mut self, + realm_cookie: &RealmCookie, + ) -> Result { + let token_account_cookie = self + .bench + .with_token_account(&realm_cookie.account.community_mint) + .await?; + + let token_owner = self.bench.payer.pubkey(); + let council_mint_cookie = realm_cookie.council_mint_cookie.as_ref().unwrap(); + let governing_token_mint = council_mint_cookie.address; + + let governing_token_account_cookie = self + .bench + .with_tokens(council_mint_cookie, &token_owner, 1) + .await?; + + let proposal_owner_record_key = get_token_owner_record_address( + &self.program_id, + &realm_cookie.address, + &governing_token_mint, + &token_owner, + ); + + let create_tor_ix = create_token_owner_record( + &self.program_id, + &realm_cookie.address, + &self.bench.payer.pubkey(), + &governing_token_mint, + &self.bench.payer.pubkey(), + ); + + self.bench + .process_transaction(&[create_tor_ix], None) + .await?; + + let deposit_ix = deposit_governing_tokens( + &self.program_id, + &realm_cookie.address, + &governing_token_account_cookie.address, + &token_owner, + &token_owner, + &self.bench.payer.pubkey(), + 1, + &governing_token_mint, + ); + + self.bench.process_transaction(&[deposit_ix], None).await?; + + let governance_key = get_governance_address( + &self.program_id, + &realm_cookie.address, + &token_account_cookie.address, + ); + + let create_governance_ix = create_governance( + &self.program_id, + &realm_cookie.address, + Some(&token_account_cookie.address), + &proposal_owner_record_key, + &self.bench.payer.pubkey(), + &realm_cookie.realm_authority.pubkey(), + None, + spl_governance::state::governance::GovernanceConfig { + min_community_weight_to_create_proposal: 1, + min_transaction_hold_up_time: 0, + + min_council_weight_to_create_proposal: 1, + community_vote_threshold: VoteThreshold::YesVotePercentage(60), + voting_base_time: 600, + community_vote_tipping: VoteTipping::Strict, + council_vote_threshold: VoteThreshold::YesVotePercentage(60), + council_veto_vote_threshold: VoteThreshold::Disabled, + council_vote_tipping: VoteTipping::Disabled, + community_veto_vote_threshold: VoteThreshold::Disabled, + voting_cool_off_time: 0, + deposit_exempt_proposal_count: 10, + }, + ); + + self.bench + .process_transaction( + &[create_governance_ix], + Some(&[&realm_cookie.realm_authority]), + ) + .await?; + + let proposal_governing_token_mint = realm_cookie.account.community_mint; + let proposal_seed = Pubkey::new_unique(); + + let proposal_key = get_proposal_address( + &self.program_id, + &governance_key, + &proposal_governing_token_mint, + &proposal_seed, + ); + + let create_proposal_ix = create_proposal( + &self.program_id, + &governance_key, + &proposal_owner_record_key, + &token_owner, + &self.bench.payer.pubkey(), + None, + &realm_cookie.address, + String::from("Proposal #1"), + String::from("Proposal #1 link"), + &proposal_governing_token_mint, + spl_governance::state::proposal::VoteType::SingleChoice, + vec!["Yes".to_string()], + true, + &proposal_seed, + ); + + let sign_off_proposal_ix = sign_off_proposal( + &self.program_id, + &realm_cookie.address, + &governance_key, + &proposal_key, + &token_owner, + Some(&proposal_owner_record_key), + ); + + self.bench + .process_transaction(&[create_proposal_ix, sign_off_proposal_ix], None) + .await?; + + let account = ProposalV2 { + account_type: GovernanceAccountType::GovernanceV2, + governing_token_mint: proposal_governing_token_mint, + state: ProposalState::Voting, + governance: governance_key, + token_owner_record: proposal_owner_record_key, + signatories_count: 1, + signatories_signed_off_count: 1, + vote_type: spl_governance::state::proposal::VoteType::SingleChoice, + options: vec![], + deny_vote_weight: Some(1), + veto_vote_weight: 0, + abstain_vote_weight: None, + start_voting_at: None, + draft_at: 1, + signing_off_at: None, + voting_at: None, + voting_at_slot: None, + voting_completed_at: None, + executing_at: None, + closed_at: None, + execution_flags: spl_governance::state::enums::InstructionExecutionFlags::None, + max_vote_weight: None, + max_voting_time: None, + reserved: [0; 64], + name: String::from("Proposal #1"), + description_link: String::from("Proposal #1 link"), + reserved1: 0, + vote_threshold: None, + }; + + Ok(ProposalCookie { + address: proposal_key, + account, + }) + } + + #[allow(dead_code)] + pub async fn with_token_owner_record( + &mut self, + realm_cookie: &RealmCookie, + token_owner_cookie: &WalletCookie, + ) -> Result { + let token_owner_record_key = get_token_owner_record_address( + &self.program_id, + &realm_cookie.address, + &realm_cookie.account.community_mint, + &token_owner_cookie.address, + ); + + let create_tor_ix = create_token_owner_record( + &self.program_id, + &realm_cookie.address, + &token_owner_cookie.address, + &realm_cookie.account.community_mint, + &self.bench.payer.pubkey(), + ); + + self.bench + .process_transaction(&[create_tor_ix], None) + .await?; + + let account = TokenOwnerRecordV2 { + account_type: GovernanceAccountType::TokenOwnerRecordV2, + realm: realm_cookie.address, + governing_token_mint: realm_cookie.account.community_mint, + governing_token_owner: token_owner_cookie.address, + governing_token_deposit_amount: 0, + unrelinquished_votes_count: 0, + outstanding_proposal_count: 0, + reserved: [0; 6], + governance_delegate: None, + reserved_v2: [0; 128], + version: TOKEN_OWNER_RECORD_LAYOUT_VERSION, + }; + + Ok(TokenOwnerRecordCookie { + address: token_owner_record_key, + account, + }) + } + + #[allow(dead_code)] + pub async fn relinquish_vote( + &mut self, + proposal_cookie: &ProposalCookie, + token_owner_cookie: &WalletCookie, + token_owner_record_cookie: &TokenOwnerRecordCookie, + ) -> Result<(), TransportError> { + let relinquish_vote_ix = relinquish_vote( + &self.program_id, + &token_owner_record_cookie.account.realm, + &proposal_cookie.account.governance, + &proposal_cookie.address, + &token_owner_record_cookie.address, + &proposal_cookie.account.governing_token_mint, + Some(token_owner_record_cookie.account.governing_token_owner), + Some(self.bench.payer.pubkey()), + ); + + self.bench + .process_transaction(&[relinquish_vote_ix], Some(&[&token_owner_cookie.signer])) + .await?; + + Ok(()) + } + + #[allow(dead_code)] + pub async fn set_governance_delegate( + &mut self, + realm_cookie: &RealmCookie, + token_owner_record_cookie: &TokenOwnerRecordCookie, + token_owner_authority_cookie: &WalletCookie, + new_governance_delegate: &Option, + ) { + let set_governance_delegate_ix = set_governance_delegate( + &self.program_id, + &token_owner_authority_cookie.address, + &realm_cookie.address, + &token_owner_record_cookie.account.governing_token_mint, + &token_owner_record_cookie.account.governing_token_owner, + new_governance_delegate, + ); + + self.bench + .process_transaction( + &[set_governance_delegate_ix], + Some(&[&token_owner_authority_cookie.signer]), + ) + .await + .unwrap(); + } + + #[allow(dead_code)] + pub async fn get_proposal(&mut self, proposal_key: &Pubkey) -> ProposalV2 { + self.bench + .get_borsh_account::(proposal_key) + .await + } + + #[allow(dead_code)] + pub async fn get_token_owner_record( + &mut self, + token_owner_record_key: &Pubkey, + ) -> TokenOwnerRecordV2 { + self.bench + .get_borsh_account::(token_owner_record_key) + .await + } +} diff --git a/programs/core-voter/tests/program_test/mod.rs b/programs/core-voter/tests/program_test/mod.rs new file mode 100644 index 0000000..bfe2667 --- /dev/null +++ b/programs/core-voter/tests/program_test/mod.rs @@ -0,0 +1,5 @@ +pub mod governance_test; +pub mod core_voter_test; +pub mod program_test_bench; +pub mod core_test; +pub mod tools; diff --git a/programs/core-voter/tests/program_test/program_test_bench.rs b/programs/core-voter/tests/program_test/program_test_bench.rs new file mode 100644 index 0000000..7d0f455 --- /dev/null +++ b/programs/core-voter/tests/program_test/program_test_bench.rs @@ -0,0 +1,328 @@ +use std::cell::RefCell; + +use anchor_lang::{ + prelude::{Pubkey, Rent}, + AccountDeserialize, +}; + +use solana_program::{borsh::try_from_slice_unchecked, system_program}; +use solana_program_test::{BanksClientError, ProgramTest, ProgramTestContext}; +use solana_sdk::{ + account::{Account, ReadableAccount}, + instruction::Instruction, + program_pack::Pack, + signature::Keypair, + signer::Signer, + system_instruction, + transaction::Transaction, + transport::TransportError, +}; + +use borsh::BorshDeserialize; + +use crate::program_test::tools::clone_keypair; + +pub struct MintCookie { + pub address: Pubkey, + pub mint_authority: Keypair, + pub freeze_authority: Option, +} +pub struct TokenAccountCookie { + pub address: Pubkey, +} + +#[derive(Debug)] +pub struct WalletCookie { + pub address: Pubkey, + pub account: Account, + + pub signer: Keypair, +} + +pub struct ProgramTestBench { + pub context: RefCell, + pub payer: Keypair, + pub rent: Rent, +} + +impl ProgramTestBench { + /// Create new bench given a ProgramTest instance populated with all of the + /// desired programs. + pub async fn start_new(program_test: ProgramTest) -> Self { + let mut context = program_test.start_with_context().await; + + let payer = clone_keypair(&context.payer); + + let rent = context.banks_client.get_rent().await.unwrap(); + + Self { + payer, + context: RefCell::new(context), + rent, + } + } + + #[allow(dead_code)] + pub async fn process_transaction( + &self, + instructions: &[Instruction], + signers: Option<&[&Keypair]>, + ) -> Result<(), BanksClientError> { + let mut context = self.context.borrow_mut(); + + let mut transaction = + Transaction::new_with_payer(&instructions, Some(&context.payer.pubkey())); + + let mut all_signers = vec![&context.payer]; + + if let Some(signers) = signers { + all_signers.extend_from_slice(signers); + } + + transaction.sign(&all_signers, context.last_blockhash); + + context + .banks_client + .process_transaction_with_commitment( + transaction, + solana_sdk::commitment_config::CommitmentLevel::Processed, + ) + .await + } + + pub async fn get_clock(&self) -> solana_program::clock::Clock { + self.context + .borrow_mut() + .banks_client + .get_sysvar::() + .await + .unwrap() + } + + #[allow(dead_code)] + pub async fn advance_clock(&self) { + let clock = self.get_clock().await; + self.context + .borrow_mut() + .warp_to_slot(clock.slot + 2) + .unwrap(); + } + + pub async fn with_mint(&self) -> Result { + let mint_keypair = Keypair::new(); + let mint_authority = Keypair::new(); + let freeze_authority = clone_keypair(&mint_authority); + + self.create_mint( + &mint_keypair, + &mint_authority.pubkey(), + Some(&freeze_authority.pubkey()), + ) + .await?; + + Ok(MintCookie { + address: mint_keypair.pubkey(), + mint_authority, + freeze_authority: Some(freeze_authority), + }) + } + + #[allow(dead_code)] + pub async fn create_mint( + &self, + mint_keypair: &Keypair, + mint_authority: &Pubkey, + freeze_authority: Option<&Pubkey>, + ) -> Result<(), BanksClientError> { + let mint_rent = self.rent.minimum_balance(spl_token::state::Mint::LEN); + + let instructions = [ + system_instruction::create_account( + &self.context.borrow().payer.pubkey(), + &mint_keypair.pubkey(), + mint_rent, + spl_token::state::Mint::LEN as u64, + &spl_token::id(), + ), + spl_token::instruction::initialize_mint( + &spl_token::id(), + &mint_keypair.pubkey(), + mint_authority, + freeze_authority, + 0, + ) + .unwrap(), + ]; + + self.process_transaction(&instructions, Some(&[mint_keypair])) + .await + } + + #[allow(dead_code)] + pub async fn with_token_account( + &self, + token_mint: &Pubkey, + ) -> Result { + let token_account_keypair = Keypair::new(); + self.create_token_account(&token_account_keypair, token_mint, &self.payer.pubkey()) + .await?; + + Ok(TokenAccountCookie { + address: token_account_keypair.pubkey(), + }) + } + + #[allow(dead_code)] + pub async fn with_tokens( + &self, + mint_cookie: &MintCookie, + owner: &Pubkey, + amount: u64, + ) -> Result { + let token_account_keypair = Keypair::new(); + + self.create_token_account(&token_account_keypair, &mint_cookie.address, owner) + .await?; + + self.mint_tokens( + &mint_cookie.address, + &mint_cookie.mint_authority, + &token_account_keypair.pubkey(), + amount, + ) + .await?; + + Ok(TokenAccountCookie { + address: token_account_keypair.pubkey(), + }) + } + + pub async fn mint_tokens( + &self, + token_mint: &Pubkey, + token_mint_authority: &Keypair, + token_account: &Pubkey, + amount: u64, + ) -> Result<(), BanksClientError> { + let mint_instruction = spl_token::instruction::mint_to( + &spl_token::id(), + token_mint, + token_account, + &token_mint_authority.pubkey(), + &[], + amount, + ) + .unwrap(); + + self.process_transaction(&[mint_instruction], Some(&[token_mint_authority])) + .await + } + + #[allow(dead_code)] + pub async fn create_token_account( + &self, + token_account_keypair: &Keypair, + token_mint: &Pubkey, + owner: &Pubkey, + ) -> Result<(), BanksClientError> { + let rent = self + .context + .borrow_mut() + .banks_client + .get_rent() + .await + .unwrap(); + + let create_account_instruction = system_instruction::create_account( + &self.context.borrow().payer.pubkey(), + &token_account_keypair.pubkey(), + rent.minimum_balance(spl_token::state::Account::get_packed_len()), + spl_token::state::Account::get_packed_len() as u64, + &spl_token::id(), + ); + + let initialize_account_instruction = spl_token::instruction::initialize_account( + &spl_token::id(), + &token_account_keypair.pubkey(), + token_mint, + owner, + ) + .unwrap(); + + self.process_transaction( + &[create_account_instruction, initialize_account_instruction], + Some(&[token_account_keypair]), + ) + .await + } + + #[allow(dead_code)] + pub async fn with_wallet(&self) -> WalletCookie { + let account_rent = self.rent.minimum_balance(0); + let account_keypair = Keypair::new(); + + let create_account_ix = system_instruction::create_account( + &self.context.borrow().payer.pubkey(), + &account_keypair.pubkey(), + account_rent, + 0, + &system_program::id(), + ); + + self.process_transaction(&[create_account_ix], Some(&[&account_keypair])) + .await + .unwrap(); + + let account = Account { + lamports: account_rent, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }; + + WalletCookie { + address: account_keypair.pubkey(), + account, + signer: account_keypair, + } + } + + #[allow(dead_code)] + pub async fn get_account(&self, address: &Pubkey) -> Option { + self.context + .borrow_mut() + .banks_client + .get_account(*address) + .await + .unwrap() + } + + #[allow(dead_code)] + pub async fn get_borsh_account(&self, address: &Pubkey) -> T { + self.get_account(address) + .await + .map(|a| try_from_slice_unchecked(&a.data).unwrap()) + .unwrap_or_else(|| panic!("GET-TEST-ACCOUNT-ERROR: Account {} not found", address)) + } + + #[allow(dead_code)] + pub async fn get_account_data(&self, address: Pubkey) -> Vec { + self.context + .borrow_mut() + .banks_client + .get_account(address) + .await + .unwrap() + .unwrap() + .data() + .to_vec() + } + + #[allow(dead_code)] + pub async fn get_anchor_account(&self, address: Pubkey) -> T { + let data = self.get_account_data(address).await; + let mut data_slice: &[u8] = &data; + AccountDeserialize::try_deserialize(&mut data_slice).unwrap() + } +} diff --git a/programs/core-voter/tests/program_test/tools.rs b/programs/core-voter/tests/program_test/tools.rs new file mode 100644 index 0000000..e3c1784 --- /dev/null +++ b/programs/core-voter/tests/program_test/tools.rs @@ -0,0 +1,98 @@ +use anchor_lang::prelude::ERROR_CODE_OFFSET; +use gpl_core_voter::error::NftVoterError; +use solana_program::instruction::InstructionError; +use solana_program_test::BanksClientError; +use solana_sdk::{signature::Keypair, transaction::TransactionError, transport::TransportError}; +use spl_governance::error::GovernanceError; +use spl_governance_tools::error::GovernanceToolsError; + +pub fn clone_keypair(source: &Keypair) -> Keypair { + Keypair::from_bytes(&source.to_bytes()).unwrap() +} + +/// NOP (No Operation) Override function +#[allow(non_snake_case)] +pub fn NopOverride(_: &mut T) {} + +#[allow(dead_code)] +pub fn assert_nft_voter_err(banks_client_error: BanksClientError, nft_locker_error: NftVoterError) { + let tx_error = banks_client_error.unwrap(); + + println!("banks client error: {:?}", tx_error); + println!("nft locker error: {:?}", nft_locker_error); + println!("error code offset: {:?}", ERROR_CODE_OFFSET); + println!("nft locker error as u32: {:?}", nft_locker_error as u32 + ERROR_CODE_OFFSET); + + match tx_error { + TransactionError::InstructionError(_, instruction_error) => match instruction_error { + InstructionError::Custom(e) => { + assert_eq!(e, nft_locker_error as u32 + ERROR_CODE_OFFSET) + } + _ => panic!("{:?} Is not InstructionError::Custom()", instruction_error), + }, + _ => panic!("{:?} Is not InstructionError", tx_error), + }; +} + +#[allow(dead_code)] +pub fn assert_gov_tools_err( + banks_client_error: TransportError, + gov_tools_error: GovernanceToolsError, +) { + let tx_error = banks_client_error.unwrap(); + + match tx_error { + TransactionError::InstructionError(_, instruction_error) => match instruction_error { + InstructionError::Custom(e) => { + assert_eq!(e, gov_tools_error as u32) + } + _ => panic!("{:?} Is not InstructionError::Custom()", instruction_error), + }, + _ => panic!("{:?} Is not InstructionError", tx_error), + }; +} + +#[allow(dead_code)] +pub fn assert_gov_err(banks_client_error: BanksClientError, gov_error: GovernanceError) { + let tx_error = banks_client_error.unwrap(); + + match tx_error { + TransactionError::InstructionError(_, instruction_error) => match instruction_error { + InstructionError::Custom(e) => { + assert_eq!(e, gov_error as u32) + } + _ => panic!("{:?} Is not InstructionError::Custom()", instruction_error), + }, + _ => panic!("{:?} Is not InstructionError", tx_error), + }; +} + +#[allow(dead_code)] +pub fn assert_anchor_err( + banks_client_error: BanksClientError, + anchor_error: anchor_lang::error::ErrorCode, +) { + let tx_error = banks_client_error.unwrap(); + + match tx_error { + TransactionError::InstructionError(_, instruction_error) => match instruction_error { + InstructionError::Custom(e) => { + assert_eq!(e, anchor_error as u32) + } + _ => panic!("{:?} Is not InstructionError::Custom()", instruction_error), + }, + _ => panic!("{:?} Is not InstructionError", tx_error), + }; +} + +#[allow(dead_code)] +pub fn assert_ix_err(banks_client_error: BanksClientError, ix_error: InstructionError) { + let tx_error = banks_client_error.unwrap(); + + match tx_error { + TransactionError::InstructionError(_, instruction_error) => { + assert_eq!(instruction_error, ix_error); + } + _ => panic!("{:?} Is not InstructionError", tx_error), + }; +} diff --git a/programs/core-voter/tests/relinquish_nft_vote.rs b/programs/core-voter/tests/relinquish_nft_vote.rs new file mode 100644 index 0000000..d1e2be9 --- /dev/null +++ b/programs/core-voter/tests/relinquish_nft_vote.rs @@ -0,0 +1,662 @@ +use crate::program_test::core_voter_test::ConfigureCollectionArgs; +use gpl_core_voter::error::NftVoterError; +use program_test::core_voter_test::{CastAssetVoteArgs, CoreVoterTest}; +use program_test::tools::{assert_gov_err, assert_nft_voter_err}; +use solana_program_test::*; +use solana_sdk::transport::TransportError; +use spl_governance::error::GovernanceError; + +mod program_test; + +#[tokio::test] +async fn test_relinquish_nft_vote() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset( + &collection_cookie, + &voter_cookie + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1}), // Set Size == 1 to complete voting with just one vote + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + core_voter_test.bench.advance_clock().await; + + // Act + core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await?; + + // Assert + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight_expiry, Some(0)); + assert_eq!(voter_weight_record.voter_weight, 0); + + // Check NftVoteRecord was disposed + let asset_vote_record = core_voter_test + .bench + .get_account(&asset_vote_record_cookies[0].address) + .await; + + assert_eq!(None, asset_vote_record); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_for_proposal_in_voting_state() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset( + &collection_cookie, + &voter_cookie, + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + // Relinquish Vote from spl-gov + core_voter_test + .governance + .relinquish_vote( + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + ) + .await?; + + core_voter_test.bench.advance_clock().await; + + // Act + core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await?; + + // Assert + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight_expiry, Some(0)); + assert_eq!(voter_weight_record.voter_weight, 0); + + // Check NftVoteRecord was disposed + let asset_vote_record = core_voter_test + .bench + .get_account(&asset_vote_record_cookies[0].address) + .await; + + assert_eq!(None, asset_vote_record); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_for_proposal_in_voting_state_and_vote_record_exists_error( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(Some(5)).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + // Create and store 6th asset. This will be used for the vote. + let asset_cookie1 = core_voter_test + .core.create_asset( + &collection_cookie, + &voter_cookie + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None + + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + // Act + let err = core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await + .err() + .unwrap(); + + println!("{:?}", err); + + // Assert + assert_nft_voter_err(err, NftVoterError::VoteRecordMustBeWithdrawn); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_with_invalid_voter_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset( + &collection_cookie, + &voter_cookie, + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1 }), // Set Size == 1 to complete voting with just one vote + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + // Try to use a different voter + let voter_cookie2 = core_voter_test.bench.with_wallet().await; + + // Act + + let err = core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie2, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await + .err() + .unwrap(); + + // Assert + + assert_gov_err(err, GovernanceError::GoverningTokenOwnerOrDelegateMustSign); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_with_unexpired_vote_weight_record() -> Result<(), TransportError> +{ + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset( + &collection_cookie, + &voter_cookie + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + }), + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let args = CastAssetVoteArgs { + cast_spl_gov_vote: false, + }; + + // Cast vote with NFT + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + Some(args), + ) + .await?; + + // Act + + let err = core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::VoterWeightRecordMustBeExpired); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_with_invalid_voter_weight_token_owner_error( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset( + &collection_cookie, + &voter_cookie + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + // Try to update VoterWeightRecord for different governing_token_owner + let voter_cookie2 = core_voter_test.bench.with_wallet().await; + let voter_weight_record_cookie2 = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie2) + .await?; + + // Act + + let err = core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie2, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::InvalidTokenOwnerForVoterWeightRecord); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_using_delegate() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core.create_asset( + &collection_cookie, + &voter_cookie + ) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1 }), // Set Size == 1 to complete voting with just one vote + ) + .await?; + + let voter_token_owner_record_cookie = core_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = core_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let asset_vote_record_cookies = core_voter_test + .cast_asset_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&asset_cookie1], + None, + ) + .await?; + + core_voter_test.bench.advance_clock().await; + + // Setup delegate + let delegate_cookie = core_voter_test.bench.with_wallet().await; + core_voter_test + .governance + .set_governance_delegate( + &realm_cookie, + &voter_token_owner_record_cookie, + &voter_cookie, + &Some(delegate_cookie.address), + ) + .await; + + // Act + + core_voter_test + .relinquish_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &delegate_cookie, + &voter_token_owner_record_cookie, + &asset_vote_record_cookies, + ) + .await?; + + // Assert + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight_expiry, Some(0)); + assert_eq!(voter_weight_record.voter_weight, 0); + + // Check NftVoteRecord was disposed + let asset_vote_record = core_voter_test + .bench + .get_account(&asset_vote_record_cookies[0].address) + .await; + + assert_eq!(None, asset_vote_record); + + Ok(()) +} diff --git a/programs/core-voter/tests/update_max_voter_weight_record.rs b/programs/core-voter/tests/update_max_voter_weight_record.rs new file mode 100644 index 0000000..7e09598 --- /dev/null +++ b/programs/core-voter/tests/update_max_voter_weight_record.rs @@ -0,0 +1,188 @@ +use crate::program_test::core_voter_test::ConfigureCollectionArgs; +use program_test::core_voter_test::CoreVoterTest; +use solana_program_test::*; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_update_collection_config_invalidates_max_voter_weight_record_expirey( +) -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let _voter_cookie = core_voter_test.bench.with_wallet().await; + + let mut max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let collection_1_size = 7; + let collection_1_weight = 5; + + let collection_cookie_1 = core_voter_test + .core + .create_collection(Some(collection_1_size)) + .await?; + + // Register collection_1 to registrar + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie_1, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: collection_1_weight, + }), + ) + .await?; + + // Generate an updated new max voter weight record + core_voter_test + .update_max_voter_weight_record(®istrar_cookie, &mut max_voter_weight_record_cookie) + .await?; + + let collection_2_size = 10; + let collection_2_weight = 2; + + // Generate a new collection and update the registrar with the additional collection + // while invalidating max voter weight. + let collection_cookie_2 = core_voter_test + .core + .create_collection(Some(collection_2_size)) + .await?; + + // Register collection_2 to registrar + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie_2, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: collection_2_weight, + }), + ) + .await?; + + // Fetch registrar account and assert that collection was added to the registrars collection_configs + let registrar = core_voter_test + .get_registrar_account(®istrar_cookie.address) + .await; + + let max_voter_weight_record = core_voter_test + .get_max_voter_weight_record(&max_voter_weight_record_cookie.address) + .await; + + core_voter_test.bench.advance_clock().await; + let _clock = core_voter_test.bench.get_clock().await; + + // Assert + let max_voter_weight_total = + (collection_1_weight * collection_1_size) + (collection_2_weight * collection_2_size); + + assert!(registrar.collection_configs.len() == 2); + assert!(max_voter_weight_record.max_voter_weight_expiry.is_none()); + assert!(max_voter_weight_record.max_voter_weight == max_voter_weight_total as u64); + + Ok(()) +} + +#[tokio::test] +async fn test_update_max_voter_weight_record_provides_valid_expirey() -> Result<(), TransportError> +{ + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let mut max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let _voter_cookie = core_voter_test.bench.with_wallet().await; + + // Set collection sizes and weights for collection_1 + let collection_1_size = 11; + let collection_1_weight = 4; + + let collection_cookie_1 = core_voter_test + .core + .create_collection(Some(collection_1_size)) + .await?; + + // Register collection_1 to registrar + let _collection_config_cookie = core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie_1, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: collection_1_weight, + }), + ) + .await?; + + // Generate an updated new max voter weight record + core_voter_test + .update_max_voter_weight_record(®istrar_cookie, &mut max_voter_weight_record_cookie) + .await?; + + // Advance clock so that second `update_max_voter_weight_record`` can be made without a duplicate + // transaction submission which causes transaction to "pass" but not actually update the account. + + core_voter_test.bench.advance_clock().await; + let _clock = core_voter_test.bench.get_clock().await; + + // Generate a new collection and update the registrar with the additional collection + // which also invalidates max_voter_weight_expirey. + + let collection_2_size = 9; + let collection_2_weight = 3; + let collection_cookie_2 = core_voter_test + .core + .create_collection(Some(collection_2_size)) + .await?; + + // Register collection_2 to registrar + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie_2, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: collection_2_weight, + }), + ) + .await?; + + // Revalidate max voter weight record by calling the update + let _update_max_voter_weight_record_2 = core_voter_test + .update_max_voter_weight_record(®istrar_cookie, &mut max_voter_weight_record_cookie) + .await?; + + // Fetch registrar account and assert that collection was added to the registrars collection_configs + let registrar = core_voter_test + .get_registrar_account(®istrar_cookie.address) + .await; + + // Fetch max voter weight record and assert that max voter weight expiry is set + let max_voter_weight_record = core_voter_test + .get_max_voter_weight_record(&max_voter_weight_record_cookie.address) + .await; + + // Assert + let max_voter_weight_total = + (collection_1_weight * collection_1_size) + (collection_2_weight * collection_2_size); + + assert!(registrar.collection_configs.len() == 2); + assert!(max_voter_weight_record.max_voter_weight == max_voter_weight_total as u64); + assert!(max_voter_weight_record.max_voter_weight_expiry.is_some()); + + Ok(()) +} diff --git a/programs/core-voter/tests/update_voter_weight_record.rs b/programs/core-voter/tests/update_voter_weight_record.rs new file mode 100644 index 0000000..85b721d --- /dev/null +++ b/programs/core-voter/tests/update_voter_weight_record.rs @@ -0,0 +1,370 @@ +use crate::program_test::core_voter_test::ConfigureCollectionArgs; +use gpl_core_voter::error::NftVoterError; +use gpl_core_voter::state::*; +use program_test::core_voter_test::CoreVoterTest; +use program_test::tools::*; +use solana_program_test::*; +use solana_sdk::msg; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_update_voter_weight_record() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + msg!("Register the collection to the registrar"); + // Register the collection to the registrar + let _collection_config_cookie = core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 10 }), + ) + .await?; + + let mut voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + let clock = core_voter_test.bench.get_clock().await; + + // Act + core_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CreateProposal, + &[&asset_cookie1], + ) + .await?; + + // Assert + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 10); + assert_eq!(voter_weight_record.voter_weight_expiry, Some(clock.slot)); + + assert_eq!( + voter_weight_record.weight_action, + Some(VoterWeightAction::CreateProposal.into()) + ); + assert_eq!(voter_weight_record.weight_action_target, None); + + Ok(()) +} + +#[tokio::test] +async fn test_update_voter_weight_with_multiple_nfts() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + let asset_cookie2 = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + let _collection_config_cookie = core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 10 }), + ) + .await?; + + let mut voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + core_voter_test.bench.advance_clock().await; + let clock = core_voter_test.bench.get_clock().await; + + // Act + core_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CreateProposal, + &[&asset_cookie1, &asset_cookie2], + ) + .await?; + + // Assert + + let voter_weight_record = core_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 20); + assert_eq!(voter_weight_record.voter_weight_expiry, Some(clock.slot)); + assert_eq!( + voter_weight_record.weight_action, + Some(VoterWeightAction::CreateProposal.into()) + ); + assert_eq!(voter_weight_record.weight_action_target, None); + + Ok(()) +} + +#[tokio::test] +async fn test_update_voter_weight_with_cast_vote_not_allowed_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 10 }), + ) + .await?; + + let mut voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Act + let err = core_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CastVote, + &[&asset_cookie1], + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::CastVoteIsNotAllowed); + + Ok(()) +} + +#[tokio::test] +async fn test_update_voter_weight_with_invalid_owner_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let voter_cookie2 = core_voter_test.bench.with_wallet().await; + + let asset_cookie1 = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie2) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 10 }), + ) + .await?; + + let mut voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Act + let err = core_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CreateGovernance, + &[&asset_cookie1], + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::VoterDoesNotOwnNft); + + Ok(()) +} + +#[tokio::test] +async fn test_update_voter_weight_with_invalid_collection_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let collection_cookie2 = core_voter_test.core.create_collection(None).await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let _random_asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie2, &voter_cookie) + .await?; + + let asset_cookie1 = core_voter_test + .core + .create_asset(&collection_cookie2, &voter_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 10 }), + ) + .await?; + + let mut voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Act + let err = core_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CreateGovernance, + &[&asset_cookie1], + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::CollectionNotFound); + + Ok(()) +} + +#[tokio::test] +async fn test_update_voter_weight_with_same_nft_error() -> Result<(), TransportError> { + // Arrange + let mut core_voter_test = CoreVoterTest::start_new().await; + + let realm_cookie = core_voter_test.governance.with_realm().await?; + + let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?; + + let collection_cookie = core_voter_test.core.create_collection(None).await?; + + let voter_cookie = core_voter_test.bench.with_wallet().await; + + let asset_cookie = core_voter_test + .core + .create_asset(&collection_cookie, &voter_cookie) + .await?; + + let max_voter_weight_record_cookie = core_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + core_voter_test + .with_collection( + ®istrar_cookie, + &collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let mut voter_weight_record_cookie = core_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Act + let err = core_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CreateProposal, + &[&asset_cookie, &asset_cookie], + ) + .await + .err() + .unwrap(); + + // Assert + + assert_nft_voter_err(err, NftVoterError::DuplicatedNftDetected); + + Ok(()) +}