From 148d2215349e073baca240853157d0ac7bbadaf9 Mon Sep 17 00:00:00 2001 From: crypt0miester Date: Mon, 2 Mar 2026 23:31:14 +0400 Subject: [PATCH] add sponsored nft-voter with updates --- Cargo.lock | 2 +- programs/nft-voter/Cargo.toml | 2 +- programs/nft-voter/README.md | 66 ++ programs/nft-voter/src/error.rs | 9 + .../instructions/cast_nft_vote_sponsored.rs | 178 ++++ .../src/instructions/create_sponsor.rs | 82 ++ programs/nft-voter/src/instructions/mod.rs | 13 + .../relinquish_nft_vote_sponsored.rs | 147 +++ .../src/instructions/withdraw_sponsor.rs | 94 ++ programs/nft-voter/src/lib.rs | 23 + programs/nft-voter/src/state/idl_types.rs | 16 + programs/nft-voter/src/state/mod.rs | 6 + .../src/state/nft_vote_record_sponsored.rs | 110 +++ programs/nft-voter/src/state/sponsor.rs | 14 + programs/nft-voter/src/tools/account.rs | 69 ++ programs/nft-voter/src/tools/mod.rs | 1 + .../tests/cast_nft_vote_sponsored.rs | 889 ++++++++++++++++++ programs/nft-voter/tests/create_sponsor.rs | 63 ++ .../tests/program_test/nft_voter_test.rs | 293 +++++- .../tests/relinquish_nft_vote_sponsored.rs | 609 ++++++++++++ programs/nft-voter/tests/withdraw_sponsor.rs | 157 ++++ 21 files changed, 2840 insertions(+), 3 deletions(-) create mode 100644 programs/nft-voter/README.md create mode 100644 programs/nft-voter/src/instructions/cast_nft_vote_sponsored.rs create mode 100644 programs/nft-voter/src/instructions/create_sponsor.rs create mode 100644 programs/nft-voter/src/instructions/relinquish_nft_vote_sponsored.rs create mode 100644 programs/nft-voter/src/instructions/withdraw_sponsor.rs create mode 100644 programs/nft-voter/src/state/nft_vote_record_sponsored.rs create mode 100644 programs/nft-voter/src/state/sponsor.rs create mode 100644 programs/nft-voter/src/tools/account.rs create mode 100644 programs/nft-voter/tests/cast_nft_vote_sponsored.rs create mode 100644 programs/nft-voter/tests/create_sponsor.rs create mode 100644 programs/nft-voter/tests/relinquish_nft_vote_sponsored.rs create mode 100644 programs/nft-voter/tests/withdraw_sponsor.rs diff --git a/Cargo.lock b/Cargo.lock index f3e83b2..0227288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1913,7 +1913,7 @@ dependencies = [ [[package]] name = "gpl-nft-voter" -version = "0.2.3" +version = "0.3.0" dependencies = [ "anchor-lang", "anchor-spl", diff --git a/programs/nft-voter/Cargo.toml b/programs/nft-voter/Cargo.toml index 2755a7c..ca4462f 100644 --- a/programs/nft-voter/Cargo.toml +++ b/programs/nft-voter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gpl-nft-voter" -version = "0.2.3" +version = "0.3.0" description = "SPL Governance addin implementing NFT based governance" license = "Apache-2.0" edition = "2018" diff --git a/programs/nft-voter/README.md b/programs/nft-voter/README.md new file mode 100644 index 0000000..1986273 --- /dev/null +++ b/programs/nft-voter/README.md @@ -0,0 +1,66 @@ +# NFT Voter + +SPL Governance addin implementing Metaplex Token Metadata NFT-based governance. Allows NFT holders to participate in DAO governance using their NFTs as voting power. + +Program ID: `GnftV5kLjd67tvHpNGyodwWveEKivz3ZWvvE3Z4xi2iw` + +## Overview + +The NFT Voter plugin integrates with spl-governance as a voter weight addin. It enables DAOs to use NFT collections as the basis for voting power instead of (or in addition to) fungible tokens. + +### Instructions + +| Instruction | Description | +|---|---| +| `create_registrar` | Creates the NFT voting registrar for a realm | +| `configure_collection` | Configures an NFT collection with a vote weight and size | +| `create_voter_weight_record` | Creates a voter weight record for a governing token owner | +| `create_max_voter_weight_record` | Creates the max voter weight record for the registrar | +| `update_voter_weight_record` | Updates voter weight for non-CastVote actions | +| `cast_nft_vote` | Casts an NFT vote (voter pays rent for vote records) | +| `relinquish_nft_vote` | Disposes NFT vote records and returns rent to a beneficiary | +| `create_sponsor` | Creates and funds a Sponsor PDA for sponsored voting | +| `withdraw_sponsor` | Withdraws SOL from the Sponsor PDA (realm authority only) | +| `cast_nft_vote_sponsored` | Casts an NFT vote with rent paid by the Sponsor PDA | +| `relinquish_nft_vote_sponsored` | Disposes sponsored vote records and returns rent to the Sponsor PDA | + +### Accounts + +| Account | Description | +|---|---| +| `Registrar` | Stores NFT voting configuration (realm, governance program, collection configs) | +| `VoterWeightRecord` | Stores computed voter weight for spl-governance consumption | +| `MaxVoterWeightRecord` | Stores the maximum possible voter weight for the registrar | +| `NftVoteRecord` | Tracks that an NFT was used to vote on a proposal (voter-paid rent) | +| `NftVoteRecordSponsored` | Tracks that an NFT was used to vote on a proposal (sponsor-paid rent) | +| `Sponsor` | SystemAccount PDA that holds SOL to fund sponsored vote records | + +## Changelog + +### 0.3.0 - Sponsored Voting + +Adds sponsored voting support, allowing DAOs to subsidize the rent costs of NFT vote records so voters don't need SOL to participate. + +#### New Instructions + +- **`create_sponsor`** - Creates a Sponsor PDA (`seeds = ["sponsor", registrar]`) and funds it with the rent-exempt minimum from the payer. Requires the realm authority to sign, validated against the on-chain Realm account. + +- **`withdraw_sponsor`** - Withdraws SOL from the Sponsor PDA to any destination account. Requires realm authority signature. Enforces a floor at `rent.minimum_balance(0)` to keep the account alive. + +- **`cast_nft_vote_sponsored`** - Same validation as `cast_nft_vote` (NFT ownership, collection membership, duplicate detection) but rent for `NftVoteRecordSponsored` accounts is paid from the Sponsor PDA instead of the voter. Pre-checks that the sponsor has sufficient funds before creating any records. Supports accumulative voting across multiple transactions. + +- **`relinquish_nft_vote_sponsored`** - Disposes `NftVoteRecordSponsored` accounts and returns rent to the Sponsor PDA (not an arbitrary beneficiary). Validates that each record's stored `sponsor` field matches the passed Sponsor account, ensuring rent flows back to the correct source. Includes the same anti-sandwich and vote-record-withdrawal checks as `relinquish_nft_vote`. + +#### New Accounts + +- **`NftVoteRecordSponsored`** - Similar to `NftVoteRecord` but includes a `sponsor: Pubkey` field that records which Sponsor PDA paid the rent. Uses a distinct discriminator (`sha256("account:NftVoteRecordSponsored")[..8]`) to distinguish it from `NftVoteRecord`. + +- **Sponsor PDA** - A SystemAccount PDA derived from `["sponsor", registrar]`. Being system-owned (no program data) allows it to use `system_instruction::create_account` and `system_instruction::transfer` via `invoke_signed`. Anyone can send SOL to it; only the program can spend from it via CPI. + +#### Design Decisions + +- **SystemAccount PDA pattern**: The Sponsor is a system-owned PDA rather than a program-owned account with data. This avoids the Solana runtime restriction that `system_instruction::transfer` requires `from` to be system-owned. The nft-voter program can still sign for the PDA via `invoke_signed` since it derives the address. + +- **Shared PDA namespace**: `NftVoteRecordSponsored` uses the same PDA seeds as `NftVoteRecord` (`["nft-vote-record", proposal, nft_mint]`). This prevents cross-instruction double voting -- if a voter casts via `cast_nft_vote`, they cannot also cast via `cast_nft_vote_sponsored` with the same NFT on the same proposal (and vice versa), because the PDA is already occupied. Discriminators ensure each record type can only be relinquished through its corresponding instruction. + +- **Realm authority gating**: Both `create_sponsor` and `withdraw_sponsor` require the realm authority to sign, validated by deserializing the on-chain Realm account and comparing `realm.authority`. This ensures only the DAO's authority can manage sponsor funds. diff --git a/programs/nft-voter/src/error.rs b/programs/nft-voter/src/error.rs index ab01160..73ab80f 100644 --- a/programs/nft-voter/src/error.rs +++ b/programs/nft-voter/src/error.rs @@ -73,4 +73,13 @@ pub enum NftVoterError { #[msg("VoterWeightRecord must be expired")] VoterWeightRecordMustBeExpired, + + #[msg("Invalid Sponsor for NftVoteRecord")] + InvalidSponsorForNftVoteRecord, + + #[msg("Insufficient sponsor funds")] + InsufficientSponsorFunds, + + #[msg("Invalid Sponsor Authority")] + InvalidSponsorAuthority, } diff --git a/programs/nft-voter/src/instructions/cast_nft_vote_sponsored.rs b/programs/nft-voter/src/instructions/cast_nft_vote_sponsored.rs new file mode 100644 index 0000000..51c9468 --- /dev/null +++ b/programs/nft-voter/src/instructions/cast_nft_vote_sponsored.rs @@ -0,0 +1,178 @@ +use crate::error::NftVoterError; +use crate::state::*; +use crate::tools::account::create_and_serialize_account_from_pda; +use crate::{id, state::get_sponsor_seeds}; +use anchor_lang::prelude::*; +use anchor_lang::Accounts; +use itertools::Itertools; + +/// Casts NFT vote with rent sponsored by the DAO +/// The NFTs used for voting are tracked using NftVoteRecordSponsored accounts +/// The rent for these accounts is paid from the Sponsor PDA instead of the voter +/// +/// This instruction updates VoterWeightRecord which is valid for the current Slot and the target Proposal only +/// and hence the instruction has to be executed inside the same transaction as spl-gov.CastVote +/// +/// CastNftVoteSponsored is accumulative and can be invoked using several transactions if voter owns more than 5 NFTs +/// In this scenario only the last CastNftVoteSponsored should be bundled with spl-gov.CastVote in the same transaction +#[derive(Accounts)] +pub struct CastNftVoteSponsored<'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>, + + /// The Sponsor PDA that pays for NftVoteRecordSponsored rent + #[account( + mut, + seeds = [b"sponsor".as_ref(), registrar.key().as_ref()], + bump, + )] + pub sponsor: SystemAccount<'info>, + + /// 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>, + + pub system_program: Program<'info, System>, +} + +/// Casts vote with the NFT, with rent sponsored by the DAO +/// +/// remaining_accounts are passed in tuples of 3: +/// - nft_info: The NFT token account +/// - nft_metadata_info: The NFT metadata account +/// - nft_vote_record_sponsored_info: The NftVoteRecordSponsored PDA to create +pub fn cast_nft_vote_sponsored<'info>( + ctx: Context<'_, '_, '_, 'info, CastNftVoteSponsored<'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_nft_mints = vec![]; + + let rent = Rent::get()?; + + // Calculate rent needed per NftVoteRecordSponsored account + let nft_vote_record_size = std::mem::size_of::(); + let rent_per_record = rent.minimum_balance(nft_vote_record_size); + + // Count how many NFTs we're processing + let nft_count = ctx.remaining_accounts.len() / 3; + + // Verify sponsor has enough funds + let total_rent_needed = rent_per_record + .checked_mul(nft_count as u64) + .ok_or(NftVoterError::InsufficientSponsorFunds)?; + + let sponsor_min_balance = rent.minimum_balance(0); + let sponsor_available = ctx + .accounts + .sponsor + .lamports() + .saturating_sub(sponsor_min_balance); + + require!( + sponsor_available >= total_rent_needed, + NftVoterError::InsufficientSponsorFunds + ); + + // Build sponsor seeds for signing + let registrar_key = registrar.key(); + let sponsor_seeds = get_sponsor_seeds(®istrar_key); + let (_, sponsor_bump) = Pubkey::find_program_address(&sponsor_seeds, &id()); + let sponsor_seeds_with_bump: &[&[u8]] = &[b"sponsor", registrar_key.as_ref(), &[sponsor_bump]]; + + let sponsor_info = ctx.accounts.sponsor.to_account_info(); + + for (nft_info, nft_metadata_info, nft_vote_record_sponsored_info) in + ctx.remaining_accounts.iter().tuples() + { + let (nft_vote_weight, nft_mint) = resolve_nft_vote_weight_and_mint( + registrar, + &governing_token_owner, + nft_info, + nft_metadata_info, + &mut unique_nft_mints, + )?; + + voter_weight = voter_weight.checked_add(nft_vote_weight).unwrap(); + + // Ensure the NftVoteRecordSponsored doesn't already exist + require!( + nft_vote_record_sponsored_info.data_is_empty(), + NftVoterError::NftAlreadyVoted + ); + + let nft_vote_record_sponsored = NftVoteRecordSponsored { + account_discriminator: NftVoteRecordSponsored::ACCOUNT_DISCRIMINATOR, + proposal, + nft_mint, + governing_token_owner, + sponsor: ctx.accounts.sponsor.key(), + reserved: [0; 8], + }; + + // Create NftVoteRecordSponsored funded by the sponsor PDA + let nft_vote_record_seeds = + get_nft_vote_record_sponsored_seeds(&proposal, &nft_mint); + + create_and_serialize_account_from_pda( + &sponsor_info, + sponsor_seeds_with_bump, + nft_vote_record_sponsored_info, + &nft_vote_record_seeds, + &nft_vote_record_sponsored, + &id(), + &ctx.accounts.system_program.to_account_info(), + &rent, + )?; + } + + if voter_weight_record.weight_action_target == Some(proposal) + && voter_weight_record.weight_action == Some(VoterWeightAction::CastVote) + { + // If cast_nft_vote_sponsored is called for the same proposal then we keep accumulating the weight + 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/nft-voter/src/instructions/create_sponsor.rs b/programs/nft-voter/src/instructions/create_sponsor.rs new file mode 100644 index 0000000..6e6fc4f --- /dev/null +++ b/programs/nft-voter/src/instructions/create_sponsor.rs @@ -0,0 +1,82 @@ +use crate::error::NftVoterError; +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{program::invoke, system_instruction}; +use spl_governance::state::realm; + +/// Creates/validates a Sponsor PDA for holding SOL to pay NFT vote record rent on behalf of voters +/// The sponsor is a SystemAccount PDA - this instruction validates realm authority +#[derive(Accounts)] +pub struct CreateSponsor<'info> { + /// The NFT voting Registrar + pub registrar: Account<'info, Registrar>, + + /// The Sponsor PDA - a system-owned account that holds SOL for sponsored voting + #[account( + mut, + seeds = [b"sponsor".as_ref(), registrar.key().as_ref()], + bump, + )] + pub sponsor: SystemAccount<'info>, + + /// An spl-governance Realm + /// + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + #[account( + owner = registrar.governance_program_id, + constraint = registrar.realm == realm.key() @ NftVoterError::InvalidRealmForRegistrar + )] + pub realm: UncheckedAccount<'info>, + + /// 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 the Sponsor PDA by funding it with the rent-exempt minimum +/// The sponsor is a SystemAccount PDA that holds SOL for sponsored voting +pub fn create_sponsor(ctx: Context) -> Result<()> { + let registrar = &ctx.accounts.registrar; + + // Verify that realm_authority is the expected authority of the Realm + let realm = realm::get_realm_data_for_governing_token_mint( + ®istrar.governance_program_id, + &ctx.accounts.realm, + ®istrar.governing_token_mint, + )?; + + let realm_authority = realm + .authority + .ok_or(NftVoterError::InvalidRealmAuthority)?; + + require!( + realm_authority == ctx.accounts.realm_authority.key(), + NftVoterError::InvalidRealmAuthority + ); + + // Fund the sponsor PDA with rent-exempt minimum so it exists on-chain + let rent = Rent::get()?; + let min_balance = rent.minimum_balance(0); + + if ctx.accounts.sponsor.lamports() < min_balance { + let needed = min_balance.saturating_sub(ctx.accounts.sponsor.lamports()); + invoke( + &system_instruction::transfer( + ctx.accounts.payer.key, + ctx.accounts.sponsor.key, + needed, + ), + &[ + ctx.accounts.payer.to_account_info(), + ctx.accounts.sponsor.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + )?; + } + + Ok(()) +} diff --git a/programs/nft-voter/src/instructions/mod.rs b/programs/nft-voter/src/instructions/mod.rs index 518677c..fa6a81a 100644 --- a/programs/nft-voter/src/instructions/mod.rs +++ b/programs/nft-voter/src/instructions/mod.rs @@ -18,3 +18,16 @@ mod relinquish_nft_vote; pub use cast_nft_vote::*; mod cast_nft_vote; + +// Sponsored voting instructions +pub use create_sponsor::*; +mod create_sponsor; + +pub use withdraw_sponsor::*; +mod withdraw_sponsor; + +pub use cast_nft_vote_sponsored::*; +mod cast_nft_vote_sponsored; + +pub use relinquish_nft_vote_sponsored::*; +mod relinquish_nft_vote_sponsored; diff --git a/programs/nft-voter/src/instructions/relinquish_nft_vote_sponsored.rs b/programs/nft-voter/src/instructions/relinquish_nft_vote_sponsored.rs new file mode 100644 index 0000000..9e35dc6 --- /dev/null +++ b/programs/nft-voter/src/instructions/relinquish_nft_vote_sponsored.rs @@ -0,0 +1,147 @@ +use crate::error::NftVoterError; +use crate::state::*; +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 NftVoteRecordSponsored accounts and returns rent to the Sponsor PDA +/// It can only be executed when voting on the target Proposal ended or voter withdrew vote from the Proposal +/// +/// Unlike relinquish_nft_vote, this instruction enforces that lamports are returned to the +/// sponsor PDA that originally paid for the records, not an arbitrary beneficiary +#[derive(Accounts)] +pub struct RelinquishNftVoteSponsored<'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>, + + /// The Sponsor PDA that receives the returned rent + /// This MUST match the sponsor stored in each NftVoteRecordSponsored + #[account( + mut, + seeds = [b"sponsor".as_ref(), registrar.key().as_ref()], + bump, + )] + pub sponsor: SystemAccount<'info>, + + /// 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 + pub vote_record: UncheckedAccount<'info>, +} + +/// Disposes NftVoteRecordSponsored accounts and returns lamports to the sponsor +/// +/// remaining_accounts should be the NftVoteRecordSponsored PDAs to dispose +pub fn relinquish_nft_vote_sponsored(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 + 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 + 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 NftVoteRecordSponsored within the VoterWeightRecord expiration period + // This prevents sandwich attacks when multiple voter-weight plugins are stacked + if voter_weight_record.voter_weight_expiry >= Some(Clock::get()?.slot) { + return err!(NftVoterError::VoterWeightRecordMustBeExpired); + } + + let sponsor_key = ctx.accounts.sponsor.key(); + + // Dispose all NftVoteRecordSponsored and return lamports to sponsor + for nft_vote_record_info in ctx.remaining_accounts.iter() { + // Ensure NftVoteRecordSponsored is for the correct proposal, token owner, AND sponsor + // This is critical for security - ensures lamports return to the right sponsor + let _nft_vote_record = get_nft_vote_record_sponsored_data_for_proposal_and_token_owner_and_sponsor( + nft_vote_record_info, + &ctx.accounts.proposal.key(), + &governing_token_owner, + &sponsor_key, + )?; + + // Dispose the account and return lamports to the sponsor + dispose_account(nft_vote_record_info, &ctx.accounts.sponsor.to_account_info())?; + } + + // 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/nft-voter/src/instructions/withdraw_sponsor.rs b/programs/nft-voter/src/instructions/withdraw_sponsor.rs new file mode 100644 index 0000000..8bdad52 --- /dev/null +++ b/programs/nft-voter/src/instructions/withdraw_sponsor.rs @@ -0,0 +1,94 @@ +use crate::error::NftVoterError; +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{program::invoke_signed, system_instruction}; +use spl_governance::state::realm; + +/// Withdraws SOL from the Sponsor PDA +/// Only the realm authority can withdraw funds +#[derive(Accounts)] +pub struct WithdrawSponsor<'info> { + /// The NFT voting Registrar + pub registrar: Account<'info, Registrar>, + + /// The Sponsor PDA to withdraw from + #[account( + mut, + seeds = [b"sponsor".as_ref(), registrar.key().as_ref()], + bump, + )] + pub sponsor: SystemAccount<'info>, + + /// An spl-governance Realm + /// + /// CHECK: Owned by spl-governance instance specified in registrar.governance_program_id + #[account( + owner = registrar.governance_program_id, + constraint = registrar.realm == realm.key() @ NftVoterError::InvalidRealmForRegistrar + )] + pub realm: UncheckedAccount<'info>, + + /// realm_authority must sign and match Realm.authority + pub realm_authority: Signer<'info>, + + /// The account to receive the withdrawn funds + /// CHECK: Can be any account to receive the lamports + #[account(mut)] + pub destination: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +/// Withdraws SOL from the sponsor PDA to a destination +/// Only the realm authority can withdraw +pub fn withdraw_sponsor(ctx: Context, amount: u64) -> Result<()> { + let registrar = &ctx.accounts.registrar; + + // Verify that realm_authority is the expected authority of the Realm + let realm = realm::get_realm_data_for_governing_token_mint( + ®istrar.governance_program_id, + &ctx.accounts.realm, + ®istrar.governing_token_mint, + )?; + + let realm_authority = realm + .authority + .ok_or(NftVoterError::InvalidRealmAuthority)?; + + require!( + realm_authority == ctx.accounts.realm_authority.key(), + NftVoterError::InvalidSponsorAuthority + ); + + // Check sponsor has enough funds + let rent = Rent::get()?; + let min_balance = rent.minimum_balance(0); + let available = ctx.accounts.sponsor.lamports().saturating_sub(min_balance); + + require!( + amount <= available, + NftVoterError::InsufficientSponsorFunds + ); + + // Transfer via system program CPI with PDA signer + let registrar_key = registrar.key(); + let sponsor_seeds = get_sponsor_seeds(®istrar_key); + let (_, sponsor_bump) = Pubkey::find_program_address(&sponsor_seeds, &crate::id()); + let signer_seeds: &[&[u8]] = &[b"sponsor", registrar_key.as_ref(), &[sponsor_bump]]; + + invoke_signed( + &system_instruction::transfer( + ctx.accounts.sponsor.key, + ctx.accounts.destination.key, + amount, + ), + &[ + ctx.accounts.sponsor.to_account_info(), + ctx.accounts.destination.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + &[signer_seeds], + )?; + + Ok(()) +} diff --git a/programs/nft-voter/src/lib.rs b/programs/nft-voter/src/lib.rs index b1c9b95..8e016c8 100644 --- a/programs/nft-voter/src/lib.rs +++ b/programs/nft-voter/src/lib.rs @@ -61,6 +61,29 @@ pub mod nft_voter { log_version(); instructions::cast_nft_vote(ctx, proposal) } + + pub fn create_sponsor(ctx: Context) -> Result<()> { + log_version(); + instructions::create_sponsor(ctx) + } + + pub fn withdraw_sponsor(ctx: Context, amount: u64) -> Result<()> { + log_version(); + instructions::withdraw_sponsor(ctx, amount) + } + + pub fn cast_nft_vote_sponsored<'info>( + ctx: Context<'_, '_, '_, 'info, CastNftVoteSponsored<'info>>, + proposal: Pubkey, + ) -> Result<()> { + log_version(); + instructions::cast_nft_vote_sponsored(ctx, proposal) + } + + pub fn relinquish_nft_vote_sponsored(ctx: Context) -> Result<()> { + log_version(); + instructions::relinquish_nft_vote_sponsored(ctx) + } } fn log_version() { diff --git a/programs/nft-voter/src/state/idl_types.rs b/programs/nft-voter/src/state/idl_types.rs index 06a57aa..244c072 100644 --- a/programs/nft-voter/src/state/idl_types.rs +++ b/programs/nft-voter/src/state/idl_types.rs @@ -15,3 +15,19 @@ pub struct NftVoteRecord { /// It's a Realm member pubkey corresponding to TokenOwnerRecord.governing_token_owner pub governing_token_owner: Pubkey, } + +/// NftVoteRecordSponsored exported to IDL without account_discriminator +#[account] +pub struct NftVoteRecordSponsored { + /// 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 + pub governing_token_owner: Pubkey, + + /// The sponsor account that paid for this record's rent + pub sponsor: Pubkey, +} diff --git a/programs/nft-voter/src/state/mod.rs b/programs/nft-voter/src/state/mod.rs index 4084a58..e98c89f 100644 --- a/programs/nft-voter/src/state/mod.rs +++ b/programs/nft-voter/src/state/mod.rs @@ -13,3 +13,9 @@ pub use voter_weight_record::*; pub mod voter_weight_record; pub mod idl_types; + +pub use nft_vote_record_sponsored::*; +pub mod nft_vote_record_sponsored; + +pub use sponsor::*; +pub mod sponsor; diff --git a/programs/nft-voter/src/state/nft_vote_record_sponsored.rs b/programs/nft-voter/src/state/nft_vote_record_sponsored.rs new file mode 100644 index 0000000..7e23f84 --- /dev/null +++ b/programs/nft-voter/src/state/nft_vote_record_sponsored.rs @@ -0,0 +1,110 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program_pack::IsInitialized; + +use spl_governance_tools::account::{get_account_data, AccountMaxSize}; + +use crate::{error::NftVoterError, id}; + +/// Sponsored vote record indicating the given NFT voted on the Proposal +/// The rent was paid by a Sponsor account and must be returned there on relinquish +/// +/// PDA seeds: ["nft-vote-record", proposal, nft_mint] +/// Shares the same PDA namespace as NftVoteRecord to prevent double-voting +/// across sponsored and non-sponsored paths. Discriminators distinguish the types. +#[derive(Clone, Debug, PartialEq, borsh_1::BorshDeserialize, borsh_1::BorshSerialize)] +#[borsh(crate = "borsh_1")] +pub struct NftVoteRecordSponsored { + /// NftVoteRecordSponsored discriminator sha256("account:NftVoteRecordSponsored")[..8] + /// Note: The discriminator is used explicitly because NftVoteRecordSponsored + /// are created and consumed dynamically using remaining_accounts + 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 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, + + /// The sponsor account that paid for this record's rent + /// Lamports MUST be returned here on relinquish + pub sponsor: Pubkey, + + /// Reserved for future upgrades + pub reserved: [u8; 8], +} + +impl NftVoteRecordSponsored { + /// sha256("account:NftVoteRecordSponsored")[..8] + /// Computed: python -c "import hashlib; print(list(hashlib.sha256(b'account:NftVoteRecordSponsored').digest()[:8]))" + pub const ACCOUNT_DISCRIMINATOR: [u8; 8] = [78, 213, 79, 243, 142, 82, 85, 174]; +} + +impl AccountMaxSize for NftVoteRecordSponsored {} + +impl IsInitialized for NftVoteRecordSponsored { + fn is_initialized(&self) -> bool { + self.account_discriminator == NftVoteRecordSponsored::ACCOUNT_DISCRIMINATOR + } +} + +/// Returns NftVoteRecordSponsored PDA seeds +pub fn get_nft_vote_record_sponsored_seeds<'a>( + proposal: &'a Pubkey, + nft_mint: &'a Pubkey, +) -> [&'a [u8]; 3] { + [ + b"nft-vote-record", + proposal.as_ref(), + nft_mint.as_ref(), + ] +} + +/// Returns NftVoteRecordSponsored PDA address +pub fn get_nft_vote_record_sponsored_address(proposal: &Pubkey, nft_mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &get_nft_vote_record_sponsored_seeds(proposal, nft_mint), + &id(), + ) + .0 +} + +/// Deserializes account and checks owner program +pub fn get_nft_vote_record_sponsored_data( + nft_vote_record_info: &AccountInfo, +) -> Result { + Ok(get_account_data::( + &id(), + nft_vote_record_info, + )?) +} + +/// Deserializes and validates NftVoteRecordSponsored for the given proposal, token owner, and sponsor +pub fn get_nft_vote_record_sponsored_data_for_proposal_and_token_owner_and_sponsor( + nft_vote_record_info: &AccountInfo, + proposal: &Pubkey, + governing_token_owner: &Pubkey, + sponsor: &Pubkey, +) -> Result { + let nft_vote_record = get_nft_vote_record_sponsored_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 + ); + + require!( + nft_vote_record.sponsor == *sponsor, + NftVoterError::InvalidSponsorForNftVoteRecord + ); + + Ok(nft_vote_record) +} diff --git a/programs/nft-voter/src/state/sponsor.rs b/programs/nft-voter/src/state/sponsor.rs new file mode 100644 index 0000000..4157843 --- /dev/null +++ b/programs/nft-voter/src/state/sponsor.rs @@ -0,0 +1,14 @@ +use anchor_lang::prelude::*; + +use crate::id; + +/// Returns Sponsor PDA seeds +/// The sponsor is a SystemAccount PDA that holds SOL to pay for NFT vote record creation +pub fn get_sponsor_seeds<'a>(registrar: &'a Pubkey) -> [&'a [u8]; 2] { + [b"sponsor", registrar.as_ref()] +} + +/// Returns Sponsor PDA address +pub fn get_sponsor_address(registrar: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&get_sponsor_seeds(registrar), &id()).0 +} diff --git a/programs/nft-voter/src/tools/account.rs b/programs/nft-voter/src/tools/account.rs new file mode 100644 index 0000000..58a99dd --- /dev/null +++ b/programs/nft-voter/src/tools/account.rs @@ -0,0 +1,69 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{ + program::invoke_signed, + system_instruction, +}; +use borsh_1::BorshSerialize; + +/// Creates and serializes a PDA account funded by a SystemAccount PDA +/// +/// The payer PDA must be system-owned (no data) so that system_instruction::create_account works. +/// Both the payer PDA and new account PDA sign via invoke_signed. +/// +/// # Arguments +/// * `payer_pda` - The system-owned PDA that will fund the new account +/// * `payer_seeds` - Seeds (with bump) for the payer PDA +/// * `new_account` - The new account to create (must be a PDA) +/// * `new_account_seeds` - Seeds (without bump) for the new account PDA +/// * `data` - Data to serialize into the new account +/// * `owner` - Program that will own the new account +/// * `system_program` - System program +/// * `rent` - Rent sysvar +pub fn create_and_serialize_account_from_pda<'a, T: BorshSerialize>( + payer_pda: &AccountInfo<'a>, + payer_seeds: &[&[u8]], + new_account: &AccountInfo<'a>, + new_account_seeds: &[&[u8]], + data: &T, + owner: &Pubkey, + system_program: &AccountInfo<'a>, + rent: &Rent, +) -> Result<()> { + let serialized_data = borsh_1::to_vec(data).map_err(|e| ProgramError::BorshIoError(e.to_string()))?; + let data_len = serialized_data.len(); + let lamports = rent.minimum_balance(data_len); + + // Verify PDA address + let (expected_new_account, new_account_bump) = + Pubkey::find_program_address(new_account_seeds, owner); + require_keys_eq!( + expected_new_account, + *new_account.key, + ErrorCode::ConstraintSeeds + ); + + // Create signer seeds with bump for new account + let mut new_account_seeds_with_bump = new_account_seeds.to_vec(); + let bump_slice = &[new_account_bump]; + new_account_seeds_with_bump.push(bump_slice); + + // Create account via system program CPI, signed by both the payer PDA and new account PDA + invoke_signed( + &system_instruction::create_account( + payer_pda.key, + new_account.key, + lamports, + data_len as u64, + owner, + ), + &[payer_pda.clone(), new_account.clone(), system_program.clone()], + &[payer_seeds, &new_account_seeds_with_bump], + )?; + + // Write data to the account + new_account + .try_borrow_mut_data()? + .copy_from_slice(&serialized_data); + + Ok(()) +} diff --git a/programs/nft-voter/src/tools/mod.rs b/programs/nft-voter/src/tools/mod.rs index 13516db..32b6eb5 100644 --- a/programs/nft-voter/src/tools/mod.rs +++ b/programs/nft-voter/src/tools/mod.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod anchor; pub mod governance; pub mod spl_token; diff --git a/programs/nft-voter/tests/cast_nft_vote_sponsored.rs b/programs/nft-voter/tests/cast_nft_vote_sponsored.rs new file mode 100644 index 0000000..041996c --- /dev/null +++ b/programs/nft-voter/tests/cast_nft_vote_sponsored.rs @@ -0,0 +1,889 @@ +use crate::program_test::nft_voter_test::ConfigureCollectionArgs; +use gpl_nft_voter::error::NftVoterError; +use gpl_nft_voter::state::*; +use program_test::nft_voter_test::*; +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_cast_nft_vote_sponsored() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + size: 20, + }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + // Fund sponsor with enough SOL for vote records + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + nft_voter_test.bench.advance_clock().await; + let clock = nft_voter_test.bench.get_clock().await; + + // Act + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, + ) + .await?; + + // Assert + let nft_vote_record = nft_voter_test + .get_nft_vote_record_sponsored_account(&nft_vote_record_sponsored_cookies[0].address) + .await; + + assert_eq!( + nft_vote_record_sponsored_cookies[0].account, + nft_vote_record + ); + assert_eq!(nft_vote_record.proposal, proposal_cookie.address); + assert_eq!( + nft_vote_record.nft_mint, + nft_cookie1.mint_cookie.address + ); + assert_eq!( + nft_vote_record.governing_token_owner, + voter_cookie.address + ); + assert_eq!(nft_vote_record.sponsor, sponsor_cookie.address); + + let voter_weight_record = nft_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_nft_vote_sponsored_with_multiple_nfts() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + size: 20, + }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + let nft_cookie2 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + nft_voter_test.bench.advance_clock().await; + let clock = nft_voter_test.bench.get_clock().await; + + // Act + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1, &nft_cookie2], + None, + ) + .await?; + + // Assert + let nft_vote_record1 = nft_voter_test + .get_nft_vote_record_sponsored_account(&nft_vote_record_sponsored_cookies[0].address) + .await; + + assert_eq!( + nft_vote_record_sponsored_cookies[0].account, + nft_vote_record1 + ); + + let nft_vote_record2 = nft_voter_test + .get_nft_vote_record_sponsored_account(&nft_vote_record_sponsored_cookies[1].address) + .await; + + assert_eq!( + nft_vote_record_sponsored_cookies[1].account, + nft_vote_record2 + ); + + let voter_weight_record = nft_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_nft_vote_sponsored_accumulative() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + size: 20, + }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Cast first vote without spl-gov vote + let args = CastNftVoteSponsoredArgs { + cast_spl_gov_vote: false, + }; + + nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + Some(args), + ) + .await?; + + let nft_cookie2 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + // Act - cast second vote with spl-gov vote, should accumulate + nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie2], + None, + ) + .await?; + + // Assert - weight should be accumulated (10 + 10 = 20) + let voter_weight_record = nft_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.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_nft_vote_sponsored_with_nft_already_voted_error() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, + ) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Act - try to vote again with same NFT + let err = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::NftAlreadyVoted); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_nft_vote_sponsored_with_invalid_voter_error() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + // Use a different voter + let voter_cookie2 = nft_voter_test.bench.with_wallet().await; + + // Act + let err = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie2, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_gov_err(err, GovernanceError::GoverningTokenOwnerOrDelegateMustSign); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_nft_vote_sponsored_with_invalid_owner_error() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + size: 20, + }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + // Create NFT owned by a different wallet + let voter_cookie2 = nft_voter_test.bench.with_wallet().await; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie2, None) + .await?; + + // Act + let err = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie], + None, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::VoterDoesNotOwnNft); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_nft_vote_sponsored_with_insufficient_sponsor_funds_error( +) -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + // Don't fund the sponsor - it only has rent-exempt minimum + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Act + let err = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + Some(CastNftVoteSponsoredArgs { + cast_spl_gov_vote: false, + }), + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::InsufficientSponsorFunds); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_nft_vote_sponsored_after_cast_nft_vote_with_same_nft_error( +) -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + size: 20, + }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + // First: cast vote via non-sponsored path + nft_voter_test + .cast_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + Some(CastNftVoteArgs { + cast_spl_gov_vote: false, + }), + ) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Act: try to cast again via sponsored path with same NFT + let err = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + Some(CastNftVoteSponsoredArgs { + cast_spl_gov_vote: false, + }), + ) + .await + .err() + .unwrap(); + + // Assert - should fail because the NftVoteRecord PDA already exists + // (shared PDA namespace prevents cross-instruction double voting) + assert_nft_voter_err(err, NftVoterError::NftAlreadyVoted); + + Ok(()) +} + +#[tokio::test] +async fn test_cast_nft_vote_after_cast_nft_vote_sponsored_with_same_nft_error( +) -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + size: 20, + }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + // First: cast vote via sponsored path + nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + Some(CastNftVoteSponsoredArgs { + cast_spl_gov_vote: false, + }), + ) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Act: try to cast again via non-sponsored path with same NFT + let err = nft_voter_test + .cast_nft_vote( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + Some(CastNftVoteArgs { + cast_spl_gov_vote: false, + }), + ) + .await + .err() + .unwrap(); + + // Assert - should fail because the NftVoteRecordSponsored PDA already exists + // at the same address as NftVoteRecord would use (shared PDA namespace) + assert_nft_voter_err(err, NftVoterError::NftAlreadyVoted); + + Ok(()) +} diff --git a/programs/nft-voter/tests/create_sponsor.rs b/programs/nft-voter/tests/create_sponsor.rs new file mode 100644 index 0000000..bea5c59 --- /dev/null +++ b/programs/nft-voter/tests/create_sponsor.rs @@ -0,0 +1,63 @@ +use gpl_nft_voter::error::NftVoterError; +use gpl_nft_voter::state::get_sponsor_address; +use program_test::nft_voter_test::NftVoterTest; +use program_test::tools::assert_nft_voter_err; + +use solana_program_test::*; +use solana_sdk::signature::Keypair; +use solana_sdk::signer::Signer; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_create_sponsor() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + // Act + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + // Assert - verify the PDA address is correct + let expected_address = get_sponsor_address(®istrar_cookie.address); + assert_eq!(sponsor_cookie.address, expected_address); + + Ok(()) +} + +#[tokio::test] +async fn test_create_sponsor_with_invalid_realm_authority_error() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let fake_authority = Keypair::new(); + + // Act + let err = nft_voter_test + .with_sponsor_using_ix( + ®istrar_cookie, + &realm_cookie, + |i| { + i.accounts[3].pubkey = fake_authority.pubkey(); // realm_authority + }, + Some(&[&fake_authority]), + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::InvalidRealmAuthority); + + Ok(()) +} diff --git a/programs/nft-voter/tests/program_test/nft_voter_test.rs b/programs/nft-voter/tests/program_test/nft_voter_test.rs index 309bdf7..2907522 100644 --- a/programs/nft-voter/tests/program_test/nft_voter_test.rs +++ b/programs/nft-voter/tests/program_test/nft_voter_test.rs @@ -11,13 +11,15 @@ use spl_governance::instruction::cast_vote; use spl_governance::state::vote_record::{self, Vote, VoteChoice}; use gpl_nft_voter::state::{ - get_nft_vote_record_address, get_registrar_address, CollectionConfig, NftVoteRecord, Registrar, + get_nft_vote_record_address, get_nft_vote_record_sponsored_address, get_registrar_address, + get_sponsor_address, CollectionConfig, NftVoteRecord, NftVoteRecordSponsored, Registrar, }; use solana_program_test::{BanksClientError, ProgramTest}; use solana_sdk::instruction::Instruction; use solana_sdk::signature::Keypair; use solana_sdk::signer::Signer; +use solana_sdk::system_instruction; use crate::program_test::governance_test::GovernanceTest; use crate::program_test::program_test_bench::ProgramTestBench; @@ -79,6 +81,28 @@ impl Default for CastNftVoteArgs { } } +pub struct SponsorCookie { + pub address: Pubkey, +} + +#[derive(Debug, PartialEq)] +pub struct NftVoteRecordSponsoredCookie { + pub address: Pubkey, + pub account: NftVoteRecordSponsored, +} + +pub struct CastNftVoteSponsoredArgs { + pub cast_spl_gov_vote: bool, +} + +impl Default for CastNftVoteSponsoredArgs { + fn default() -> Self { + Self { + cast_spl_gov_vote: true, + } + } +} + pub struct NftVoterTest { pub program_id: Pubkey, pub bench: Arc, @@ -594,4 +618,271 @@ impl NftVoterTest { pub async fn get_voter_weight_record(&self, voter_weight_record: &Pubkey) -> VoterWeightRecord { self.bench.get_anchor_account(*voter_weight_record).await } + + // ---- Sponsored voting helpers ---- + + #[allow(dead_code)] + pub async fn with_sponsor( + &mut self, + registrar_cookie: &RegistrarCookie, + realm_cookie: &RealmCookie, + ) -> Result { + self.with_sponsor_using_ix(registrar_cookie, realm_cookie, NopOverride, None) + .await + } + + #[allow(dead_code)] + pub async fn with_sponsor_using_ix( + &mut self, + registrar_cookie: &RegistrarCookie, + realm_cookie: &RealmCookie, + instruction_override: F, + signers_override: Option<&[&Keypair]>, + ) -> Result { + let sponsor_key = get_sponsor_address(®istrar_cookie.address); + + let data = + anchor_lang::InstructionData::data(&gpl_nft_voter::instruction::CreateSponsor {}); + + let accounts = gpl_nft_voter::accounts::CreateSponsor { + registrar: registrar_cookie.address, + sponsor: sponsor_key, + realm: realm_cookie.address, + realm_authority: registrar_cookie.realm_authority.pubkey(), + payer: self.bench.payer.pubkey(), + system_program: solana_sdk::system_program::id(), + }; + + let mut create_sponsor_ix = Instruction { + program_id: gpl_nft_voter::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas(&accounts, None), + data, + }; + + instruction_override(&mut create_sponsor_ix); + + let default_signers = &[®istrar_cookie.realm_authority]; + let signers = signers_override.unwrap_or(default_signers); + + self.bench + .process_transaction(&[create_sponsor_ix], Some(signers)) + .await?; + + Ok(SponsorCookie { + address: sponsor_key, + }) + } + + #[allow(dead_code)] + pub async fn fund_sponsor( + &self, + sponsor_cookie: &SponsorCookie, + amount: u64, + ) -> Result<(), BanksClientError> { + let transfer_ix = system_instruction::transfer( + &self.bench.payer.pubkey(), + &sponsor_cookie.address, + amount, + ); + + self.bench + .process_transaction(&[transfer_ix], None) + .await + } + + #[allow(dead_code)] + pub async fn withdraw_sponsor( + &self, + sponsor_cookie: &SponsorCookie, + registrar_cookie: &RegistrarCookie, + destination: &Pubkey, + amount: u64, + ) -> Result<(), BanksClientError> { + let data = anchor_lang::InstructionData::data( + &gpl_nft_voter::instruction::WithdrawSponsor { amount }, + ); + + let accounts = gpl_nft_voter::accounts::WithdrawSponsor { + registrar: registrar_cookie.address, + sponsor: sponsor_cookie.address, + realm: registrar_cookie.account.realm, + realm_authority: registrar_cookie.realm_authority.pubkey(), + destination: *destination, + system_program: solana_sdk::system_program::id(), + }; + + let withdraw_ix = Instruction { + program_id: gpl_nft_voter::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas(&accounts, None), + data, + }; + + self.bench + .process_transaction(&[withdraw_ix], Some(&[®istrar_cookie.realm_authority])) + .await + } + + /// Casts NFT Vote with rent sponsored by the Sponsor account + #[allow(dead_code)] + pub async fn cast_nft_vote_sponsored( + &mut self, + registrar_cookie: &RegistrarCookie, + voter_weight_record_cookie: &VoterWeightRecordCookie, + max_voter_weight_record_cookie: &MaxVoterWeightRecordCookie, + proposal_cookie: &ProposalCookie, + sponsor_cookie: &SponsorCookie, + nft_voter_cookie: &WalletCookie, + voter_token_owner_record_cookie: &TokenOwnerRecordCookie, + nft_cookies: &[&NftCookie], + args: Option, + ) -> Result, BanksClientError> { + let args = args.unwrap_or_default(); + + let data = anchor_lang::InstructionData::data( + &gpl_nft_voter::instruction::CastNftVoteSponsored { + proposal: proposal_cookie.address, + }, + ); + + let accounts = gpl_nft_voter::accounts::CastNftVoteSponsored { + registrar: registrar_cookie.address, + voter_weight_record: voter_weight_record_cookie.address, + sponsor: sponsor_cookie.address, + voter_token_owner_record: voter_token_owner_record_cookie.address, + voter_authority: nft_voter_cookie.address, + system_program: solana_sdk::system_program::id(), + }; + + let mut account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + let mut nft_vote_record_sponsored_cookies = vec![]; + + for nft_cookie in nft_cookies { + account_metas.push(AccountMeta::new_readonly(nft_cookie.address, false)); + account_metas.push(AccountMeta::new_readonly(nft_cookie.metadata, false)); + + let nft_vote_record_sponsored_key = get_nft_vote_record_sponsored_address( + &proposal_cookie.address, + &nft_cookie.mint_cookie.address, + ); + account_metas.push(AccountMeta::new(nft_vote_record_sponsored_key, false)); + + let account = NftVoteRecordSponsored { + proposal: proposal_cookie.address, + nft_mint: nft_cookie.mint_cookie.address, + governing_token_owner: voter_weight_record_cookie + .account + .governing_token_owner, + sponsor: sponsor_cookie.address, + account_discriminator: NftVoteRecordSponsored::ACCOUNT_DISCRIMINATOR, + reserved: [0; 8], + }; + + nft_vote_record_sponsored_cookies.push(NftVoteRecordSponsoredCookie { + address: nft_vote_record_sponsored_key, + account, + }); + } + + let cast_nft_vote_sponsored_ix = Instruction { + program_id: gpl_nft_voter::id(), + accounts: account_metas, + data, + }; + + let mut instructions = vec![cast_nft_vote_sponsored_ix]; + + if args.cast_spl_gov_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, + &nft_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, + ); + + instructions.push(cast_vote_ix); + } + + self.bench + .process_transaction(&instructions, Some(&[&nft_voter_cookie.signer])) + .await?; + + Ok(nft_vote_record_sponsored_cookies) + } + + #[allow(dead_code)] + pub async fn relinquish_nft_vote_sponsored( + &mut self, + registrar_cookie: &RegistrarCookie, + voter_weight_record_cookie: &VoterWeightRecordCookie, + proposal_cookie: &ProposalCookie, + sponsor_cookie: &SponsorCookie, + voter_cookie: &WalletCookie, + voter_token_owner_record_cookie: &TokenOwnerRecordCookie, + nft_vote_record_sponsored_cookies: &Vec, + ) -> Result<(), BanksClientError> { + let data = anchor_lang::InstructionData::data( + &gpl_nft_voter::instruction::RelinquishNftVoteSponsored {}, + ); + + 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_nft_voter::accounts::RelinquishNftVoteSponsored { + registrar: registrar_cookie.address, + voter_weight_record: voter_weight_record_cookie.address, + sponsor: sponsor_cookie.address, + governance: proposal_cookie.account.governance, + proposal: proposal_cookie.address, + voter_token_owner_record: voter_token_owner_record_cookie.address, + voter_authority: voter_cookie.address, + vote_record: vote_record_key, + }; + + let mut account_metas = anchor_lang::ToAccountMetas::to_account_metas(&accounts, None); + + for nft_vote_record_sponsored_cookie in nft_vote_record_sponsored_cookies { + account_metas.push(AccountMeta::new( + nft_vote_record_sponsored_cookie.address, + false, + )); + } + + let relinquish_ix = Instruction { + program_id: gpl_nft_voter::id(), + accounts: account_metas, + data, + }; + + self.bench + .process_transaction(&[relinquish_ix], Some(&[&voter_cookie.signer])) + .await?; + + Ok(()) + } + + #[allow(dead_code)] + pub async fn get_nft_vote_record_sponsored_account( + &self, + nft_vote_record_sponsored: &Pubkey, + ) -> NftVoteRecordSponsored { + self.bench + .get_borsh_account::(nft_vote_record_sponsored) + .await + } } diff --git a/programs/nft-voter/tests/relinquish_nft_vote_sponsored.rs b/programs/nft-voter/tests/relinquish_nft_vote_sponsored.rs new file mode 100644 index 0000000..f07ecf2 --- /dev/null +++ b/programs/nft-voter/tests/relinquish_nft_vote_sponsored.rs @@ -0,0 +1,609 @@ +use crate::program_test::nft_voter_test::ConfigureCollectionArgs; +use gpl_nft_voter::error::NftVoterError; +use program_test::nft_voter_test::*; +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_sponsored() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Set Size == 1 to complete voting with just one vote + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1, size: 1 }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, // cast spl-gov vote = true + ) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Act + nft_voter_test + .relinquish_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &nft_vote_record_sponsored_cookies, + ) + .await?; + + // Assert + let voter_weight_record = nft_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 NftVoteRecordSponsored was disposed + let nft_vote_record = nft_voter_test + .bench + .get_account(&nft_vote_record_sponsored_cookies[0].address) + .await; + + assert_eq!(None, nft_vote_record); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_sponsored_returns_rent_to_sponsor() -> Result<(), TransportError> +{ + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Set Size == 1 to complete voting with just one vote + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1, size: 1 }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, + ) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Get sponsor balance after casting vote (rent was deducted) + let sponsor_balance_after_cast = nft_voter_test + .bench + .get_account(&sponsor_cookie.address) + .await + .unwrap() + .lamports; + + // Act + nft_voter_test + .relinquish_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &nft_vote_record_sponsored_cookies, + ) + .await?; + + // Assert - sponsor should have received rent back + let sponsor_balance_after_relinquish = nft_voter_test + .bench + .get_account(&sponsor_cookie.address) + .await + .unwrap() + .lamports; + + assert!(sponsor_balance_after_relinquish > sponsor_balance_after_cast); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_sponsored_for_proposal_in_voting_state( +) -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, + ) + .await?; + + // Relinquish Vote from spl-gov first + nft_voter_test + .governance + .relinquish_vote( + &proposal_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + ) + .await?; + + nft_voter_test.bench.advance_clock().await; + + // Act + nft_voter_test + .relinquish_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &nft_vote_record_sponsored_cookies, + ) + .await?; + + // Assert + let voter_weight_record = nft_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 NftVoteRecordSponsored was disposed + let nft_vote_record = nft_voter_test + .bench + .get_account(&nft_vote_record_sponsored_cookies[0].address) + .await; + + assert_eq!(None, nft_vote_record); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_sponsored_with_vote_record_exists_error( +) -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + None, + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, // This casts the spl-gov vote + ) + .await?; + + // Act - try to relinquish without first relinquishing the spl-gov vote + // The spl-gov VoteRecord still exists, so this should fail + let err = nft_voter_test + .relinquish_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &nft_vote_record_sponsored_cookies, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::VoteRecordMustBeWithdrawn); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_sponsored_with_unexpired_voter_weight_error( +) -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { + weight: 10, + size: 20, + }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + // Cast vote without spl-gov vote so VoterWeightRecord is not expired by spl-gov + let args = CastNftVoteSponsoredArgs { + cast_spl_gov_vote: false, + }; + + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + Some(args), + ) + .await?; + + // Act - try to relinquish immediately (VoterWeightRecord not yet expired) + let err = nft_voter_test + .relinquish_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &nft_vote_record_sponsored_cookies, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::VoterWeightRecordMustBeExpired); + + Ok(()) +} + +#[tokio::test] +async fn test_relinquish_nft_vote_sponsored_with_invalid_voter_error() -> Result<(), TransportError> +{ + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let nft_collection_cookie = nft_voter_test.token_metadata.with_nft_collection().await?; + + let max_voter_weight_record_cookie = nft_voter_test + .with_max_voter_weight_record(®istrar_cookie) + .await?; + + // Set Size == 1 to complete voting with just one vote + nft_voter_test + .with_collection( + ®istrar_cookie, + &nft_collection_cookie, + &max_voter_weight_record_cookie, + Some(ConfigureCollectionArgs { weight: 1, size: 1 }), + ) + .await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let voter_cookie = nft_voter_test.bench.with_wallet().await; + + let voter_token_owner_record_cookie = nft_voter_test + .governance + .with_token_owner_record(&realm_cookie, &voter_cookie) + .await?; + + let voter_weight_record_cookie = nft_voter_test + .with_voter_weight_record(®istrar_cookie, &voter_cookie) + .await?; + + let proposal_cookie = nft_voter_test + .governance + .with_proposal(&realm_cookie) + .await?; + + let nft_cookie1 = nft_voter_test + .token_metadata + .with_nft_v2(&nft_collection_cookie, &voter_cookie, None) + .await?; + + let nft_vote_record_sponsored_cookies = nft_voter_test + .cast_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &max_voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie, + &voter_token_owner_record_cookie, + &[&nft_cookie1], + None, + ) + .await?; + + // Try to use a different voter + let voter_cookie2 = nft_voter_test.bench.with_wallet().await; + + // Act + let err = nft_voter_test + .relinquish_nft_vote_sponsored( + ®istrar_cookie, + &voter_weight_record_cookie, + &proposal_cookie, + &sponsor_cookie, + &voter_cookie2, + &voter_token_owner_record_cookie, + &nft_vote_record_sponsored_cookies, + ) + .await + .err() + .unwrap(); + + // Assert + assert_gov_err(err, GovernanceError::GoverningTokenOwnerOrDelegateMustSign); + + Ok(()) +} diff --git a/programs/nft-voter/tests/withdraw_sponsor.rs b/programs/nft-voter/tests/withdraw_sponsor.rs new file mode 100644 index 0000000..f44df49 --- /dev/null +++ b/programs/nft-voter/tests/withdraw_sponsor.rs @@ -0,0 +1,157 @@ +use gpl_nft_voter::error::NftVoterError; +use program_test::nft_voter_test::NftVoterTest; +use program_test::tools::assert_nft_voter_err; + +use solana_program_test::*; +use solana_sdk::transport::TransportError; + +mod program_test; + +#[tokio::test] +async fn test_withdraw_sponsor() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + // Fund the sponsor with 10 SOL + let fund_amount = 10_000_000_000u64; + nft_voter_test + .fund_sponsor(&sponsor_cookie, fund_amount) + .await?; + + let destination = nft_voter_test.bench.with_wallet().await; + let destination_before = nft_voter_test + .bench + .get_account(&destination.address) + .await + .unwrap() + .lamports; + + let withdraw_amount = 5_000_000_000u64; + + // Act + nft_voter_test + .withdraw_sponsor( + &sponsor_cookie, + ®istrar_cookie, + &destination.address, + withdraw_amount, + ) + .await?; + + // Assert + let destination_after = nft_voter_test + .bench + .get_account(&destination.address) + .await + .unwrap() + .lamports; + + assert_eq!(destination_after - destination_before, withdraw_amount); + + Ok(()) +} + +#[tokio::test] +async fn test_withdraw_sponsor_with_insufficient_funds_error() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + // Fund with a small amount + nft_voter_test + .fund_sponsor(&sponsor_cookie, 1_000_000) + .await?; + + let destination = nft_voter_test.bench.with_wallet().await; + + // Try to withdraw more than available (beyond rent-exempt minimum) + let withdraw_amount = 100_000_000_000u64; + + // Act + let err = nft_voter_test + .withdraw_sponsor( + &sponsor_cookie, + ®istrar_cookie, + &destination.address, + withdraw_amount, + ) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::InsufficientSponsorFunds); + + Ok(()) +} + +#[tokio::test] +async fn test_withdraw_sponsor_with_invalid_authority_error() -> Result<(), TransportError> { + // Arrange + let mut nft_voter_test = NftVoterTest::start_new().await; + + let realm_cookie = nft_voter_test.governance.with_realm().await?; + + let registrar_cookie = nft_voter_test.with_registrar(&realm_cookie).await?; + + let sponsor_cookie = nft_voter_test + .with_sponsor(®istrar_cookie, &realm_cookie) + .await?; + + nft_voter_test + .fund_sponsor(&sponsor_cookie, 10_000_000_000) + .await?; + + let destination = nft_voter_test.bench.with_wallet().await; + let fake_authority = nft_voter_test.bench.with_wallet().await; + + // Build instruction manually with wrong authority + let data = anchor_lang::InstructionData::data( + &gpl_nft_voter::instruction::WithdrawSponsor { + amount: 1_000_000_000, + }, + ); + + let accounts = gpl_nft_voter::accounts::WithdrawSponsor { + registrar: registrar_cookie.address, + sponsor: sponsor_cookie.address, + realm: registrar_cookie.account.realm, + realm_authority: fake_authority.address, + destination: destination.address, + system_program: solana_sdk::system_program::id(), + }; + + let withdraw_ix = solana_sdk::instruction::Instruction { + program_id: gpl_nft_voter::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas(&accounts, None), + data, + }; + + // Act + let err = nft_voter_test + .bench + .process_transaction(&[withdraw_ix], Some(&[&fake_authority.signer])) + .await + .err() + .unwrap(); + + // Assert + assert_nft_voter_err(err, NftVoterError::InvalidSponsorAuthority); + + Ok(()) +}