diff --git a/Cargo.lock b/Cargo.lock index e28bcf39..4ddfe5b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1058,6 +1058,23 @@ dependencies = [ "scroll", ] +[[package]] +name = "gpl-boilerplate" +version = "0.1.1" +dependencies = [ + "anchor-lang", + "anchor-spl", + "arrayref", + "borsh", + "itertools", + "solana-program", + "solana-program-test", + "solana-sdk", + "spl-governance", + "spl-governance-tools", + "spl-token", +] + [[package]] name = "gpl-nft-voter" version = "0.1.1" diff --git a/programs/boilerplate/Cargo.toml b/programs/boilerplate/Cargo.toml new file mode 100644 index 00000000..c8e2d853 --- /dev/null +++ b/programs/boilerplate/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "gpl-boilerplate" +version = "0.1.1" +description = "SPL Governance addin boilerplate" +license = "Apache-2.0" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "gpl_boilerplate" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] + +[dependencies] +arrayref = "0.3.6" +anchor-lang = { version = "0.24.2", features = ["init-if-needed"] } +anchor-spl = "0.24.2" +itertools = "0.10.2" +solana-program = "1.9.13" +spl-governance = { version = "2.2.2", features = ["no-entrypoint"] } +spl-governance-tools= "0.1.2" +spl-token = { version = "3.3", features = [ "no-entrypoint" ] } + +[dev-dependencies] +borsh = "0.9.1" +solana-sdk = "1.9.5" +solana-program-test = "1.9.13" \ No newline at end of file diff --git a/programs/boilerplate/Xargo.toml b/programs/boilerplate/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/programs/boilerplate/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/boilerplate/src/error.rs b/programs/boilerplate/src/error.rs new file mode 100644 index 00000000..966f678c --- /dev/null +++ b/programs/boilerplate/src/error.rs @@ -0,0 +1,31 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum BoilerplateError { + #[msg("Invalid Realm Authority")] + InvalidRealmAuthority, + + #[msg("Invalid Realm for Registrar")] + InvalidRealmForRegistrar, + + #[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("Invalid account owner")] + InvalidAccountOwner, +} diff --git a/programs/boilerplate/src/instructions/cast_vote.rs b/programs/boilerplate/src/instructions/cast_vote.rs new file mode 100644 index 00000000..b78f1cd7 --- /dev/null +++ b/programs/boilerplate/src/instructions/cast_vote.rs @@ -0,0 +1,80 @@ +use crate::error::BoilerplateError; +use crate::{state::*}; +use anchor_lang::prelude::*; +use anchor_lang::Accounts; + +/// Casts a vote using the voter weight record. +/// 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 +/// +/// CastVote is accumulative and can be invoked using several transactions +/// In this scenario only the last CastVote should be bundled with spl-gov.CastVote in the same transaction +/// +/// NOTE - Boilerplate: All implementations of this boilerplate should prevent multiple voting +/// with the same tokens - this is not added by the boilerplate because it is use-case-specific +/// +/// CastVote instruction is not directional. It does not record vote choice (ex Yes/No) +/// VoteChoice is recorded by spl-gov in VoteRecord +/// +#[derive(Accounts)] +#[instruction(proposal: Pubkey)] +pub struct CastVote<'info> { + /// The voting registrar + pub registrar: Account<'info, Registrar>, + + #[account( + mut, + constraint = voter_weight_record.realm == registrar.realm + @ BoilerplateError::InvalidVoterWeightRecordRealm, + + constraint = voter_weight_record.governing_token_mint == registrar.governing_token_mint + @ BoilerplateError::InvalidVoterWeightRecordMint, + )] + pub voter_weight_record: Account<'info, VoterWeightRecord>, + + /// The token owner who casts the vote + #[account( + address = voter_weight_record.governing_token_owner @ BoilerplateError::InvalidTokenOwnerForVoterWeightRecord + )] + pub governing_token_owner: Signer<'info>, + + /// The account which pays for the transaction + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +/// Casts vote using a dummy voter weight of 1 +pub fn cast_vote<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, CastVote<'info>>, + proposal: Pubkey, +) -> Result<()> { + // Boilerplate: your logic here + let voter_weight = 1; + let voter_weight_record = &mut ctx.accounts.voter_weight_record; + + if voter_weight_record.weight_action_target == Some(proposal) + && voter_weight_record.weight_action == Some(VoterWeightAction::CastVote) + { + // If cast_vote is called for the same proposal then we keep accumulating the weight + // this way cast_vote can be called multiple times in different transactions + // NOTE - Boilerplate: All implementations of this boilerplate should prevent multiple voting + // with the same tokens - this is not added by the boilerplate because it is use-case-specific + 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/boilerplate/src/instructions/create_max_voter_weight_record.rs b/programs/boilerplate/src/instructions/create_max_voter_weight_record.rs new file mode 100644 index 00000000..96b8d54b --- /dev/null +++ b/programs/boilerplate/src/instructions/create_max_voter_weight_record.rs @@ -0,0 +1,58 @@ +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(); + + // In the boilerplate, the max_voter_weight never expires + // Boilerplate: your logic here + max_voter_weight_record.max_voter_weight_expiry = None; + + Ok(()) +} diff --git a/programs/boilerplate/src/instructions/create_registrar.rs b/programs/boilerplate/src/instructions/create_registrar.rs new file mode 100644 index 00000000..9fbf02e7 --- /dev/null +++ b/programs/boilerplate/src/instructions/create_registrar.rs @@ -0,0 +1,81 @@ +use crate::error::BoilerplateError; +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_spl::token::Mint; +use spl_governance::state::realm; + +/// Creates an add-in Registrar 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 Boilerplate 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() + )] + 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(), + BoilerplateError::InvalidRealmAuthority + ); + + Ok(()) +} diff --git a/programs/boilerplate/src/instructions/create_voter_weight_record.rs b/programs/boilerplate/src/instructions/create_voter_weight_record.rs new file mode 100644 index 00000000..d1ef66db --- /dev/null +++ b/programs/boilerplate/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/boilerplate/src/instructions/mod.rs b/programs/boilerplate/src/instructions/mod.rs new file mode 100644 index 00000000..f1a14855 --- /dev/null +++ b/programs/boilerplate/src/instructions/mod.rs @@ -0,0 +1,14 @@ +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 cast_vote::*; +mod cast_vote; \ No newline at end of file diff --git a/programs/boilerplate/src/instructions/update_voter_weight_record.rs b/programs/boilerplate/src/instructions/update_voter_weight_record.rs new file mode 100644 index 00000000..123c319d --- /dev/null +++ b/programs/boilerplate/src/instructions/update_voter_weight_record.rs @@ -0,0 +1,55 @@ +use crate::error::BoilerplateError; +use crate::state::*; +use anchor_lang::prelude::*; + +/// 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 + @ BoilerplateError::InvalidVoterWeightRecordRealm, + + constraint = voter_weight_record.governing_token_mint == registrar.governing_token_mint + @ BoilerplateError::InvalidVoterWeightRecordMint, + )] + pub voter_weight_record: Account<'info, VoterWeightRecord>, +} + +pub fn update_voter_weight_record( + ctx: Context, + voter_weight_action: VoterWeightAction, +) -> Result<()> { + // CastVote can't be evaluated using this instruction + require!( + voter_weight_action != VoterWeightAction::CastVote, + BoilerplateError::CastVoteIsNotAllowed + ); + + // Boilerplate: your logic here + let voter_weight = 1; + + 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(()) +} diff --git a/programs/boilerplate/src/lib.rs b/programs/boilerplate/src/lib.rs new file mode 100644 index 00000000..09bd0ba2 --- /dev/null +++ b/programs/boilerplate/src/lib.rs @@ -0,0 +1,56 @@ +use anchor_lang::prelude::*; + +pub mod error; + +mod instructions; +use instructions::*; + +pub mod state; + +pub mod tools; + +use crate::state::*; + +declare_id!("GnftV5kLjd67tvHpNGyodwWveEKivz3ZWvvE3Z4xi2iw"); + +#[program] +pub mod boilerplate { + + 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 cast_vote<'a, 'b, 'c, 'info>( + ctx: Context<'a, 'b, 'c, 'info, CastVote<'info>>, + proposal: Pubkey, + ) -> Result<()> { + log_version(); + instructions::cast_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/boilerplate/src/state/idl_types.rs b/programs/boilerplate/src/state/idl_types.rs new file mode 100644 index 00000000..06a57aa7 --- /dev/null +++ b/programs/boilerplate/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/boilerplate/src/state/max_voter_weight_record.rs b/programs/boilerplate/src/state/max_voter_weight_record.rs new file mode 100644 index 00000000..a4c94a60 --- /dev/null +++ b/programs/boilerplate/src/state/max_voter_weight_record.rs @@ -0,0 +1,95 @@ +use crate::id; +use crate::tools::anchor::{DISCRIMINATOR_SIZE, PUBKEY_SIZE}; +use anchor_lang::prelude::Pubkey; +use anchor_lang::prelude::*; + +/// 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_SIZE * 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/boilerplate/src/state/mod.rs b/programs/boilerplate/src/state/mod.rs new file mode 100644 index 00000000..60e47693 --- /dev/null +++ b/programs/boilerplate/src/state/mod.rs @@ -0,0 +1,9 @@ +pub use registrar::*; +pub mod registrar; + +pub mod max_voter_weight_record; + +pub use voter_weight_record::*; +pub mod voter_weight_record; + +pub mod idl_types; diff --git a/programs/boilerplate/src/state/registrar.rs b/programs/boilerplate/src/state/registrar.rs new file mode 100644 index 00000000..25bc4643 --- /dev/null +++ b/programs/boilerplate/src/state/registrar.rs @@ -0,0 +1,75 @@ +use crate::{ + id, + tools::{ + anchor::{DISCRIMINATOR_SIZE, PUBKEY_SIZE}, + }, +}; +use anchor_lang::prelude::*; + +/// 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, + + // Boilerplate: Add your fields here + + /// Reserved for future upgrades + pub reserved: [u8; 128], +} + +impl Registrar { + pub fn get_space() -> usize { + DISCRIMINATOR_SIZE + + PUBKEY_SIZE * 3 + + 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 +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn test_get_space() { + // Arrange + let expected_space = Registrar::get_space(); + + let registrar = Registrar { + governance_program_id: Pubkey::default(), + realm: Pubkey::default(), + governing_token_mint: Pubkey::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/boilerplate/src/state/voter_weight_record.rs b/programs/boilerplate/src/state/voter_weight_record.rs new file mode 100644 index 00000000..ae6a3095 --- /dev/null +++ b/programs/boilerplate/src/state/voter_weight_record.rs @@ -0,0 +1,107 @@ +use anchor_lang::prelude::*; + +use crate::tools::anchor::{DISCRIMINATOR_SIZE, PUBKEY_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_SIZE * 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/boilerplate/src/tools/anchor.rs b/programs/boilerplate/src/tools/anchor.rs new file mode 100644 index 00000000..461f8871 --- /dev/null +++ b/programs/boilerplate/src/tools/anchor.rs @@ -0,0 +1,2 @@ +pub const DISCRIMINATOR_SIZE: usize = 8; +pub const PUBKEY_SIZE: usize = 32; diff --git a/programs/boilerplate/src/tools/governance.rs b/programs/boilerplate/src/tools/governance.rs new file mode 100644 index 00000000..ccb25732 --- /dev/null +++ b/programs/boilerplate/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/boilerplate/src/tools/mod.rs b/programs/boilerplate/src/tools/mod.rs new file mode 100644 index 00000000..b91903e2 --- /dev/null +++ b/programs/boilerplate/src/tools/mod.rs @@ -0,0 +1,2 @@ +pub mod anchor; +pub mod governance; \ No newline at end of file diff --git a/programs/boilerplate/tests/cast_vote.rs b/programs/boilerplate/tests/cast_vote.rs new file mode 100644 index 00000000..b2bb3730 --- /dev/null +++ b/programs/boilerplate/tests/cast_vote.rs @@ -0,0 +1,208 @@ +use gpl_boilerplate::error::BoilerplateError; +use gpl_boilerplate::state::*; +use program_test::{dummy_voter_test::*, tools::assert_boilerplate_err}; + +use solana_program_test::*; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_cast_vote() -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let max_voter_weight_record_cookie = dummy_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = dummy_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = dummy_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = dummy_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + + dummy_voter_test.bench.advance_clock().await; + let clock = dummy_voter_test.bench.get_clock().await; + + // Act + dummy_voter_test + .cast_dummy_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + None, + ) + .await?; + + // Assert + let voter_weight_record = dummy_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 1); + 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_vote_invalid_voter_error() -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let max_voter_weight_record_cookie = dummy_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = dummy_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = dummy_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = dummy_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let voter_cookie2 = dummy_voter_test.bench.with_wallet().await; + + // Act + + let err = dummy_voter_test + .cast_dummy_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie2, + &voter_token_owner_record_cookie, + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_boilerplate_err(err, BoilerplateError::InvalidTokenOwnerForVoterWeightRecord); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_vote_using_multiple_instructions() -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let max_voter_weight_record_cookie = dummy_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = dummy_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = dummy_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = dummy_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + dummy_voter_test.bench.advance_clock().await; + let clock = dummy_voter_test.bench.get_clock().await; + + let args = CastVoteArgs { + cast_spl_gov_vote: false, + }; + + dummy_voter_test + .cast_dummy_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + Some(args), + ) + .await?; + + // Act + + dummy_voter_test + .cast_dummy_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + None, + ) + .await?; + + // Assert + + let voter_weight_record = dummy_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 2); + 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(()) +} diff --git a/programs/boilerplate/tests/create_max_voter_weight_record.rs b/programs/boilerplate/tests/create_max_voter_weight_record.rs new file mode 100644 index 00000000..1e829b33 --- /dev/null +++ b/programs/boilerplate/tests/create_max_voter_weight_record.rs @@ -0,0 +1,123 @@ +use program_test::{dummy_voter_test::DummyVoterTest, tools::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 dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + // Act + let max_voter_weight_record_cookie = dummy_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Assert + + let max_voter_weight_record = dummy_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 dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = dummy_voter_test.governance.with_realm().await?; + + // Act + let err = dummy_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 PrivilegeEscalation + assert_ix_err(err, InstructionError::PrivilegeEscalation); + + Ok(()) +} + +#[tokio::test] +async fn test_create_max_voter_weight_record_with_invalid_mint_error() -> Result<(), TransportError> +{ + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = dummy_voter_test.governance.with_realm().await?; + + // Act + let err = dummy_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 PrivilegeEscalation + assert_ix_err(err, InstructionError::PrivilegeEscalation); + + Ok(()) +} + +#[tokio::test] +async fn test_create_max_voter_weight_record_with_already_exists_error( +) -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + dummy_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + dummy_voter_test.bench.advance_clock().await; + + // Act + let err = dummy_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/boilerplate/tests/create_registrar.rs b/programs/boilerplate/tests/create_registrar.rs new file mode 100644 index 00000000..cb0a5e50 --- /dev/null +++ b/programs/boilerplate/tests/create_registrar.rs @@ -0,0 +1,157 @@ +mod program_test; + +use anchor_lang::prelude::Pubkey; +use program_test::dummy_voter_test::DummyVoterTest; + +use solana_program::instruction::InstructionError; +use solana_program_test::*; +use solana_sdk::{signature::Keypair, transport::TransportError}; +use gpl_boilerplate::error::BoilerplateError; + +use program_test::tools::{assert_anchor_err, assert_ix_err, assert_boilerplate_err}; + +#[tokio::test] +async fn test_create_registrar() -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + // Act + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + // Assert + let registrar = dummy_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 dummy_voter_test = DummyVoterTest::start_new().await; + + let mut realm_cookie = dummy_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Act + let err = dummy_voter_test + .with_registrar(&realm_cookie) + .await + .err() + .unwrap(); + + assert_boilerplate_err(err, BoilerplateError::InvalidRealmAuthority); + + Ok(()) +} + +#[tokio::test] +async fn test_create_registrar_with_realm_authority_must_sign_error() -> Result<(), TransportError> +{ + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let mut realm_cookie = dummy_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Act + let err = dummy_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 dummy_voter_test = DummyVoterTest::start_new().await; + + let mut realm_cookie = dummy_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Try to use a different program id + let governance_program_id = dummy_voter_test.program_id; + + // Act + let err = dummy_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 dummy_voter_test = DummyVoterTest::start_new().await; + + let mut realm_cookie = dummy_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + // Act + let err = dummy_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 PrivilegeEscalation + assert_ix_err(err, InstructionError::PrivilegeEscalation); + + Ok(()) +} + +#[tokio::test] +async fn test_create_registrar_with_invalid_governing_token_mint_error( +) -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let mut realm_cookie = dummy_voter_test.governance.with_realm().await?; + realm_cookie.realm_authority = Keypair::new(); + + let mint_cookie = dummy_voter_test.bench.with_mint().await?; + + // Act + let err = dummy_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 PrivilegeEscalation + assert_ix_err(err, InstructionError::PrivilegeEscalation); + + Ok(()) +} diff --git a/programs/boilerplate/tests/create_voter_weight_record.rs b/programs/boilerplate/tests/create_voter_weight_record.rs new file mode 100644 index 00000000..b30941ca --- /dev/null +++ b/programs/boilerplate/tests/create_voter_weight_record.rs @@ -0,0 +1,126 @@ +use program_test::dummy_voter_test::DummyVoterTest; +use program_test::tools::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 dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + // Act + let voter_weight_record_cookie = dummy_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Assert + + let voter_weight_record = dummy_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 dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = dummy_voter_test.governance.with_realm().await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + // Act + let err = dummy_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 PrivilegeEscalation + assert_ix_err(err, InstructionError::PrivilegeEscalation); + + Ok(()) +} + +#[tokio::test] +async fn test_create_voter_weight_record_with_invalid_mint_error() -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let realm_cookie2 = dummy_voter_test.governance.with_realm().await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + // Act + let err = dummy_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 PrivilegeEscalation + assert_ix_err(err, InstructionError::PrivilegeEscalation); + + Ok(()) +} + +#[tokio::test] +async fn test_create_voter_weight_record_with_already_exists_error() -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + dummy_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + dummy_voter_test.bench.advance_clock().await; + + // Act + let err = dummy_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/boilerplate/tests/fixtures/spl_governance.so b/programs/boilerplate/tests/fixtures/spl_governance.so new file mode 100755 index 00000000..9a6f5962 Binary files /dev/null and b/programs/boilerplate/tests/fixtures/spl_governance.so differ diff --git a/programs/boilerplate/tests/program_test/dummy_voter_test.rs b/programs/boilerplate/tests/program_test/dummy_voter_test.rs new file mode 100644 index 00000000..a3446691 --- /dev/null +++ b/programs/boilerplate/tests/program_test/dummy_voter_test.rs @@ -0,0 +1,411 @@ +use std::sync::Arc; + +use anchor_lang::prelude::{Pubkey}; + +use gpl_boilerplate::state::max_voter_weight_record::{ + get_max_voter_weight_record_address, MaxVoterWeightRecord, +}; +use gpl_boilerplate::state::*; +use solana_sdk::transport::TransportError; +use spl_governance::instruction::cast_vote; +use spl_governance::state::vote_record::{Vote, VoteChoice}; + +use gpl_boilerplate::state::{ + get_registrar_address, Registrar, +}; + +use solana_program_test::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::governance_test::{ProposalCookie, RealmCookie, TokenOwnerRecordCookie}; +use crate::program_test::program_test_bench::WalletCookie; +use crate::program_test::tools::NopOverride; + +#[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 CastVoteArgs { + pub cast_spl_gov_vote: bool, +} + +impl Default for CastVoteArgs { + fn default() -> Self { + Self { + cast_spl_gov_vote: true, + } + } +} + +pub struct DummyVoterTest { + pub program_id: Pubkey, + pub bench: Arc, + pub governance: GovernanceTest, +} + +impl DummyVoterTest { + #[allow(dead_code)] + pub fn add_program(program_test: &mut ProgramTest) { + program_test.add_program("gpl_boilerplate", gpl_boilerplate::id(), None); + } + + #[allow(dead_code)] + pub async fn start_new() -> Self { + let mut program_test = ProgramTest::default(); + + DummyVoterTest::add_program(&mut program_test); + GovernanceTest::add_program(&mut program_test); + + let program_id = gpl_boilerplate::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)); + + Self { + program_id, + bench: bench_rc, + governance: governance_bench, + } + } + + #[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_boilerplate::instruction::CreateRegistrar { + max_collections, + }); + + let accounts = anchor_lang::ToAccountMetas::to_account_metas( + &gpl_boilerplate::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_boilerplate::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, + 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_boilerplate::id(), + ); + + let data = anchor_lang::InstructionData::data( + &gpl_boilerplate::instruction::CreateVoterWeightRecord { + governing_token_owner, + }, + ); + + let accounts = gpl_boilerplate::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_boilerplate::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_boilerplate::instruction::CreateMaxVoterWeightRecord {}, + ); + + let accounts = gpl_boilerplate::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_boilerplate::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: None, + 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, + ) -> Result<(), TransportError> { + let data = anchor_lang::InstructionData::data( + &gpl_boilerplate::instruction::UpdateVoterWeightRecord { + voter_weight_action, + }, + ); + + let accounts = gpl_boilerplate::accounts::UpdateVoterWeightRecord { + registrar: registrar_cookie.address, + voter_weight_record: voter_weight_record_cookie.address, + }; + + let account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + + let instructions = vec![Instruction { + program_id: gpl_boilerplate::id(), + accounts: account_metas, + data, + }]; + + self.bench.process_transaction(&instructions, None).await + } + + /// Casts a vote + #[allow(dead_code)] + pub async fn cast_dummy_vote( + &mut self, + registrar_cookie: &RegistrarCookie, + voter_weight_record_cookie: &VoterWeightRecordCookie, + max_voter_weight_record_cookie: &MaxVoterWeightRecordCookie, + proposal_cookie: &ProposalCookie, + boilerplate_cookie: &WalletCookie, + voter_token_owner_record_cookie: &TokenOwnerRecordCookie, + args: Option, + ) -> Result<(), TransportError> { + let args = args.unwrap_or_default(); + + let data = anchor_lang::InstructionData::data(&gpl_boilerplate::instruction::CastVote { + proposal: proposal_cookie.address, + }); + + let accounts = gpl_boilerplate::accounts::CastVote { + registrar: registrar_cookie.address, + voter_weight_record: voter_weight_record_cookie.address, + governing_token_owner: boilerplate_cookie.address, + payer: self.bench.payer.pubkey(), + system_program: solana_sdk::system_program::id(), + }; + + let account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + + let cast_vote_ix = Instruction { + program_id: gpl_boilerplate::id(), + accounts: account_metas, + data, + }; + + let mut instruction = vec![cast_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, + &boilerplate_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(&[&boilerplate_cookie.signer])) + .await?; + + Ok(()) + } + + #[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_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 + } +} diff --git a/programs/boilerplate/tests/program_test/governance_test.rs b/programs/boilerplate/tests/program_test/governance_test.rs new file mode 100644 index 00000000..1e560ba6 --- /dev/null +++ b/programs/boilerplate/tests/program_test/governance_test.rs @@ -0,0 +1,389 @@ +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, sign_off_proposal, + }, + state::{ + enums::{ + GovernanceAccountType, MintMaxVoteWeightSource, ProposalState, VoteThresholdPercentage, + VoteTipping, + }, + governance::get_governance_address, + proposal::{get_proposal_address, ProposalV2}, + realm::{get_realm_address, RealmConfig, RealmV2}, + token_owner_record::{get_token_owner_record_address, TokenOwnerRecordV2}, + }, +}; + +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_vote_weight_source = MintMaxVoteWeightSource::FULL_SUPPLY_FRACTION; + + let realm_key = get_realm_address(&self.program_id, &realm_name); + + 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), + self.community_voter_weight_addin, + self.max_community_voter_weight_addin, + realm_name.clone(), + min_community_weight_to_create_governance, + community_mint_max_vote_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_vote_weight_source, + use_community_voter_weight_addin: false, + use_max_community_voter_weight_addin: false, + }, + voting_proposal_count: 0, + reserved_v2: [0; 128], + }; + + 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 { + vote_threshold_percentage: VoteThresholdPercentage::YesVote(60), + min_community_weight_to_create_proposal: 1, + min_transaction_hold_up_time: 0, + max_voting_time: 600, + vote_tipping: VoteTipping::Disabled, + proposal_cool_off_time: 0, + min_council_weight_to_create_proposal: 1, + }, + ); + + self.bench + .process_transaction( + &[create_governance_ix], + Some(&[&realm_cookie.realm_authority]), + ) + .await?; + + let proposal_index: u32 = 0; + let proposal_governing_token_mint = realm_cookie.account.community_mint; + + let proposal_key = get_proposal_address( + &self.program_id, + &governance_key, + &proposal_governing_token_mint, + &proposal_index.to_le_bytes(), + ); + + 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, + 0_u32, + ); + + 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: None, + 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, + vote_threshold_percentage: None, + reserved: [0; 64], + name: String::from("Proposal #1"), + description_link: String::from("Proposal #1 link"), + }; + + 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, + total_votes_count: 0, + outstanding_proposal_count: 0, + reserved: [0; 7], + governance_delegate: None, + reserved_v2: [0; 128], + }; + + 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, + &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 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/boilerplate/tests/program_test/mod.rs b/programs/boilerplate/tests/program_test/mod.rs new file mode 100644 index 00000000..b7ba0db6 --- /dev/null +++ b/programs/boilerplate/tests/program_test/mod.rs @@ -0,0 +1,4 @@ +pub mod governance_test; +pub mod dummy_voter_test; +pub mod program_test_bench; +pub mod tools; diff --git a/programs/boilerplate/tests/program_test/program_test_bench.rs b/programs/boilerplate/tests/program_test/program_test_bench.rs new file mode 100644 index 00000000..150a6009 --- /dev/null +++ b/programs/boilerplate/tests/program_test/program_test_bench.rs @@ -0,0 +1,324 @@ +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::{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<(), TransportError> { + 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 = Keypair::new(); + + self.create_mint(&mint_keypair, &mint_authority.pubkey(), None) + .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<(), TransportError> { + 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<(), TransportError> { + 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<(), TransportError> { + 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/boilerplate/tests/program_test/tools.rs b/programs/boilerplate/tests/program_test/tools.rs new file mode 100644 index 00000000..42e196ea --- /dev/null +++ b/programs/boilerplate/tests/program_test/tools.rs @@ -0,0 +1,76 @@ +use anchor_lang::prelude::ERROR_CODE_OFFSET; +use solana_program::instruction::InstructionError; +use solana_sdk::{signature::Keypair, transaction::TransactionError, transport::TransportError}; +use spl_governance_tools::error::GovernanceToolsError; +use gpl_boilerplate::error::BoilerplateError; + +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_boilerplate_err(banks_client_error: TransportError, boilerplate_error: BoilerplateError) { + let tx_error = banks_client_error.unwrap(); + + match tx_error { + TransactionError::InstructionError(_, instruction_error) => match instruction_error { + InstructionError::Custom(e) => { + assert_eq!(e, boilerplate_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_anchor_err( + banks_client_error: TransportError, + 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: TransportError, 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/boilerplate/tests/update_voter_weight_record.rs b/programs/boilerplate/tests/update_voter_weight_record.rs new file mode 100644 index 00000000..1a5b54db --- /dev/null +++ b/programs/boilerplate/tests/update_voter_weight_record.rs @@ -0,0 +1,92 @@ +use gpl_boilerplate::error::BoilerplateError; +use gpl_boilerplate::state::*; +use program_test::dummy_voter_test::DummyVoterTest; +use program_test::tools::*; +use solana_program_test::*; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_update_voter_weight_record() -> Result<(), TransportError> { + // Arrange + let mut dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + dummy_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + let mut voter_weight_record_cookie = dummy_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + dummy_voter_test.bench.advance_clock().await; + let clock = dummy_voter_test.bench.get_clock().await; + + // Act + dummy_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CreateProposal, + ) + .await?; + + // Assert + + let voter_weight_record = dummy_voter_test + .get_voter_weight_record(&voter_weight_record_cookie.address) + .await; + + assert_eq!(voter_weight_record.voter_weight, 1); + 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 dummy_voter_test = DummyVoterTest::start_new().await; + + let realm_cookie = dummy_voter_test.governance.with_realm().await?; + + let registrar_cookie = dummy_voter_test.with_registrar(&realm_cookie).await?; + + dummy_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + let voter_cookie = dummy_voter_test.bench.with_wallet().await; + + let mut voter_weight_record_cookie = dummy_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Act + let err = dummy_voter_test + .update_voter_weight_record( + ®istrar_cookie, + &mut voter_weight_record_cookie, + VoterWeightAction::CastVote, + ) + .await + .err() + .unwrap(); + + // Assert + assert_boilerplate_err(err, BoilerplateError::CastVoteIsNotAllowed); + + Ok(()) +} \ No newline at end of file