Voting Proposal Program in Solana

In the last week or so, I have been going through Solana docs to understand how Program Derived Addresses (PDAs) work. To get a hang of it, I have been working on a small voting proposal program which utilizes PDAs so that anyone can create a proposal and vote on in. A functionality which is present in DAOs.

Lets go through the functionality of the version 1 of proposal program. We will have an option that any user can connect to the program and create a proposal. Any user can vote once on the proposal. A proposal will have a title (max 100 characters) , up to 4 options and single vote per account. In next version, I am looking to add more features to it like having voting power based on governance token, using the polls to execute another part of contract etc.

Full Code Github Repo

Here is the full code available of the solana voting application

Program Derived Address

A program stores data in accounts. To modify accounts, holder of the account’s private key needs to sign a transaction. This authorizes the program to change the contents else anyone can modify any account. So if you use your wallet to create an account using any solana program, you use your wallet’s private key to sign the transaction.

If we create a proposal account using above method, account’s owner is the wallet keypair and that keypair can only modify the data. We can change the ownership and then it can be modified by program like in escrow and other examples.

The proposal’s options will be storing the voting total e.g. no of votes for option A, no of votes for option B etc. Then for anyone to vote we will have to use owner’s keypair to sign the transaction. We cannot put keypair in public, so we use a private server and host the keypair there. Then, if anyone votes, we can read the request in our server and sign the transaction using keypair of the owner of the proposal.

In this way, there is an issue that the owner of the Keypair controls who votes on the proposal and can take down the voting proposal if owner chooses to. So, it is not quite decentralized.

To solve this issue, we can use a Program Derived Address (PDA). For PDA, there is no private key and program signs the transaction to make changes in the account. For more technical details you can check solana docs here. I will try to explain below what I understood

How Program derived addresses are stored in Solana

All public/private keypairs in Solana lie on ed25519 curve. So if any public key lies on this curve, there will be a corresponding private key available for that public key. Program derived address do not lie on this curve so generating a signature is impossible.

Solana Program Derived Address Curve

Solana has two functions create_program_address & find_program_address. These functions help in getting the PDA We need to provide a seed or array of seeds. Find program address will try to find the account which is not on curve with the provided seeds. If it account lies on the curve, then it starts adding bump seeds start from 255 to 0, till it finds an address not on the curve. So, in addition to the seeds we use to generate a program derived address, we will require the bump seed as well for signing the transaction in Solana program.

So we now have the address where we can write instruction data and only the program can sign for this account.

Note :- We can think of PDAs as hash maps for data with seeds as key and account as storage space. In our case a voter can vote on different proposals. So to keep store of different votes per different proposals we will have PDAs for voter with seeds as voter public key, proposal id, program seed and bump seed. Below is an illustration for it.

solana voting proposal pdas

In our voting proposal program we are using the PDAs in following way.

  • Serial Number of Voting Proposals
  • Voting Proposal storing title, options (text & vote count for each) for the proposal
  • Voters PDA, as same wallet can vote on different voting proposal PDAs.

Poll Count PDA

We will start with our first PDA. This is to add a serial number/id to all the proposals. We can control it from UI as well but we will create a PDA to get proposal id. This will work as a counter. For e.g. if we create a proposal, the id for it will be existing count plus 1.

Below is the struct. We will store the counter and bump seed.

Note :- We can use find program address if we do not have the bump seed and use create program address if we have the bump seed. Find program address can take more computation then create program address, so we store bump address to reduce computation steps in solana program.

#[derive(Debug, Clone)]
pub struct PollCount { 
  pub is_initialized: bool, 
  pub count: u8, 
  pub bump: u8,
}

Poll PDA

We are storing poll details below. Each poll will be a different PDA. Title will have a max length of 100 chars. Options will have a max length of 50 characters. Each option will store the count of votes for that option.

#[derive(Debug, Clone)] 
pub struct Poll {
    pub is_initialized: bool,
    pub id: u8,
    pub title: String,
    pub title_length: u8,
    pub options:  Vec<PollOption>,
    pub options_count: u8,
    pub bump: u8,
}

#[derive(Debug, Clone)]
pub struct PollOption {
    pub id: u8,
    pub title: String,
    pub title_length: u8,
    pub votes: u64,
}

Poll Voter PDA

#[derive(Debug, Clone)] 
pub struct PollVoter {
    pub is_initialized: bool,
    pub poll_id: u8,
    pub option_selected: u8,
    pub bump: u8,
}

Rest of the state.rs file has code to serialize/deserialize instruction data into the above structs. Full code is in github repository here. The branch for this code is voting-proposal-v1.

Program Instruction

Next we will go through the instruction which will store data in these accounts. For our case, we have two instructions. One to create a new proposal, other to vote on the proposal.

#[derive(BorshDeserialize, BorshSerialize, Debug, Clone)]
pub enum PollInstruction {
    ///
    /// 0, init poll
    ///   create poll count, if it does not exist
    ///   get poll count
    ///   create poll
    ///   create poll options
    ///  accounts
    ///  -poll num account
    ///  -poll account
    ///  -system account
    ///  -payer account
    /// 1, vote poll
    ///  - poll pda account
    ///  - voter pda account
    ///  - voter fee payer account
    ///  - system account
    ///   user votes in poll
    CreatePoll {
        title_length: u8,
        title: String,
        options_count: u8,
        options_size: Vec <u8>,
        options: Vec <String>,
    },
    VotePoll {
        id: u8,
        option_id: u8,
    },
}

For creating the poll, we will be passing poll title, length of the title in characters, count of options (max 4), array of size of options and option text. As we need to give a size to an account in Solana on initialization, we cannot have variable length strings. So we store title with length of the title string. We will pad the rest of the string with empty space.

Apart from the above, instruction.rs will have methods to serialize and deserialize the instructions.

Create Proposal Instruction

Here, we will have the code to read the instructions and update the accounts accordingly. We will go step by step. There are some security checks missing here.

Note :- We need to be very careful before putting the program on mainnet as in Solana all account data comes in unchecked and it is responsibility of the program to check for all the boundary cases. Usually in solana rust contracts, half of the code is security checks to stop any malicious account updating the blockchain.

Initially we iterate through all the accounts present in the instruction to create a poll. We pass in public key of poll count pda, poll account pda, system program and payer account. In javascript, Solana web3 library provides find_program_address function to get program derived address for the specific program and seeds. So we can pass them as inputs to the program.

let accounts_iter = &mut _acccounts.iter();
//get poll count pda
let poll_count_account_iter = next_account_info(accounts_iter)?;
//get poll pda
let poll_account_iter = next_account_info(accounts_iter)?;
//get system program
let system_program_account = next_account_info(accounts_iter)?;
//get system program
let payer_account_iter = next_account_info(accounts_iter)?;

We will create poll count pda if program is running for the first time. Account is initialized and poll count value is set to 0. We will store bump seed in the account as well. As a security check we can now use the bump and create the account using create_program_address function. We can then match key passed in the instruction with the account returned from create_program_address. If it does not match then we can panic the program. Another check we did was the poll count. As the poll count is u8, the maximum value it can hold is 255. If we add 1 to 255 for a u8 variable it will reset to 0.

Note :- The seeds passed in create_program_address and find_program_address are reference of array of array of u8s. The different seeds get concatenated in the end, so a seed of [[b”ab”],[b”cd”],[b”ef”]] is equivalent to [[b”abcdef”]].

if poll_count_account_iter.data_is_empty() {
	let (_, bump) = Pubkey::find_program_address(&[POLL_COUNT_SEED], _program_id);
	//create new account
	// payer
	// pda key
	// system program
	invoke_signed(
		&system_instruction::create_account(
			payer_account_iter.key,
			poll_count_account_iter.key,
			Rent::get()?.minimum_balance(PollCount::SIZE),
			PollCount::SIZE as u64,
			_program_id,
		),
		&[
			payer_account_iter.clone(),
			poll_count_account_iter.clone(),
			system_program_account.clone(),
		],
		&[&[POLL_COUNT_SEED, &[bump]]],
	)?;
}

let mut poll_count_account =
	PollCount::unpack_unchecked(&poll_count_account_iter.try_borrow_data()?)?;
	
if !poll_count_account.is_initialized() {
	//ini poll account
	let (_, bump) = Pubkey::find_program_address(&[POLL_COUNT_SEED], _program_id);

	poll_count_account.is_initialized = true;
	poll_count_account.count = 0;
	poll_count_account.bump = bump;
}

let poll_count_pda = Pubkey::create_program_address(
	&[POLL_COUNT_SEED, &[poll_count_account.bump]],
	_program_id,
)?;

//check if pda matches the account
assert_true(
	poll_count_pda == *poll_count_account_iter.key,
	ProgramError::from(PollError::PdaNotMatched),
	"Poll Count account's pda does not match the account passed",
)?;

//get next poll number
poll_count_account.count = poll_count_account.count + 1;
assert_true(
	poll_count_account.count != 0,
	ProgramError::from(PollError::PollsOverflow),
	"Only 255 polls allowed as of now!",
)?;

After we have the new poll counter, we create a voting proposal. First we check if poll account PDA is empty then create poll account. If it is not empty then it will throw an error in assert part where program checks if account is_initialized().

If account is empty and pda matches the account passed in instruction then we can deserialize the instruction and update the polling account data.

if poll_account_iter.data_is_empty() {
	let (_, bump) = Pubkey::find_program_address(
		&[POLL_SEED, &[poll_count_account.count]],
		_program_id,
	);

	//create pda by invoke
	invoke_signed(
		&system_instruction::create_account(
			payer_account_iter.key,
			poll_account_iter.key,
			Rent::get()?.minimum_balance(Poll::SIZE),
			Poll::SIZE as u64,
			_program_id,
		),
		&[
			payer_account_iter.clone(),
			poll_account_iter.clone(),
			system_program_account.clone(),
		],
		&[&[POLL_SEED, &[poll_count_account.count], &[bump]]],
	)?;
}
let mut poll_account = Poll::unpack_unchecked(&poll_account_iter.try_borrow_data()?)?;


assert_true(
	!poll_account.is_initialized(),
	ProgramError::from(PollError::PollAlreadyCreated),
	"Poll already created for this id!",
)?;

let (pda, bump) =
	Pubkey::find_program_address(&[POLL_SEED, &[poll_count_account.count]], _program_id);

if !poll_account.is_initialized() {
	poll_account.is_initialized = true;
	poll_account.id = poll_count_account.count;
	poll_account.title = format!("{:<width$}", title, width = POLL_TITLE_SIZE);
	poll_account.title_length = title_length;
	poll_account.options_count = options_count;
	poll_account.bump = bump;
	poll_account.options = Vec::new();
	//fill empty text
	for i in 0..options_count as usize {
		let sz = *options_size.get(i).unwrap();
		let st = options.get(i).unwrap();
		let po = PollOption::new(
			i as u8,
			format!("{:<width$}", &st, width = POLL_OPTION_SIZE),
			sz,
		);
		poll_account.options.push(po);
	}
	for i in options_count as usize..Poll::OPTIONS_COINT as usize {
		poll_account.options.push(PollOption::new(
			i as u8,
			format!("{:<width$}", "", width = POLL_OPTION_SIZE),
			0,
		));
	}
	Poll::pack(poll_account, &mut poll_account_iter.try_borrow_mut_data()?)?;
}

assert_true(
	*poll_account_iter.key == pda,
	ProgramError::from(PollError::PdaNotMatched),
	"Poll pdas do not match",
)?;

PollCount::pack(
	poll_count_account,
	&mut poll_count_account_iter.try_borrow_mut_data()?,
)?;

Voting Instruction

Above part of program will create a proposal. The person who creates the proposal will pay the account fees. So using the PDA, there is no admin who is creating voting proposal on behalf of a person.

After a proposal is created, anyone can vote on it. Voter will pay for the voting account and transaction fees. First we will get the accounts like for create instruction.

let accounts_iter = &mut _accounts.iter();
//poll pda
let poll_pda_account_iter = next_account_info(accounts_iter)?;
//voter pda
let voter_pda_account_iter = next_account_info(accounts_iter)?;
//voter account
let voter_iter = next_account_info(accounts_iter)?;
//system program
let system_program_account = next_account_info(accounts_iter)?;

In next part, we check if polling account pda is correct and matches the poll id of the proposal we want to vote in.

//poll pda
let mut poll_pda = Poll::unpack_unchecked(&poll_pda_account_iter.try_borrow_data()?)?;

//get poll account
let poll_pda_account = Pubkey::create_program_address(
	&[POLL_SEED, &[poll_pda.id], &[poll_pda.bump.clone()]],
	_program_id,
)?;

assert_true(
	poll_pda_account == *poll_pda_account_iter.key,
	ProgramError::from(PollError::PdaNotMatched),
	"Pda does not match",
)?;

assert_true(
	poll_pda.id == poll_id,
	ProgramError::from(PollError::PollMismatch),
	"Poll account does not match",
)?;

assert_true(
	 option_id > 0 && option_id <= poll_pda.options_count,
	ProgramError::from(PollError::PollMismatch),
	"Selected option is not present in poll options",
)?;

We will now check if voter pda is created or not. We create the account if it empty otherwise check if account is initialized. If yes, then it will throw an error as we are not giving option to vote again for the same proposal. If all checks succeed then we will store account data for the voter. Now, voter cannot vote on this proposal again.

if voter_pda_account_iter.data_is_empty() {
	let (pda, bump) = Pubkey::find_program_address(
		&[
			POLL_SEED,
			&[poll_pda.id],
			&[poll_pda.bump.clone()],
			voter_iter.key.as_ref(),
		],
		_program_id,
	);

	msg!("pda={:?}", pda);
	msg!(
		"voter_pda_account_iter.key={:?}",
		voter_pda_account_iter.key
	);

	assert_true(
		pda == *voter_pda_account_iter.key,
		ProgramError::from(PollError::PdaNotMatched),
		"Poll account does not match",
	)?;

	invoke_signed(
		&system_instruction::create_account(
			voter_iter.key,
			voter_pda_account_iter.key,
			Rent::get()?.minimum_balance(PollVoter::SIZE),
			PollVoter::SIZE as u64,
			_program_id,
		),
		&[
			voter_iter.clone(),
			voter_pda_account_iter.clone(),
			system_program_account.clone(),
		],
		&[&[
			POLL_SEED,
			&[poll_pda.id],
			&[poll_pda.bump.clone()],
			voter_iter.key.as_ref(),
			&[bump],
		]],
	)?;
}
//Create Voter Account
let mut voter_account =
	PollVoter::unpack_unchecked(&voter_pda_account_iter.try_borrow_data()?)?;
 

assert_true(
	!voter_account.is_initialized(),
	ProgramError::from(PollError::AlreadyVoted),
	"Already voted for this poll",
)?;

voter_account.is_initialized = true;
voter_account.poll_id = poll_id;
voter_account.option_selected = option_id;
voter_account.bump = bump;

assert_true(
	voter_pda == *voter_pda_account_iter.key,
	ProgramError::from(PollError::PdaNotMatched),
	"Pda does not match",
)?;

PollVoter::pack(
	voter_account,
	&mut voter_pda_account_iter.try_borrow_mut_data()?,
)?;

Finally we will update total votes in poll pda account.

poll_pda.add_vote(option_id - 1, 1);
Poll::pack(poll_pda, &mut poll_pda_account_iter.try_borrow_mut_data()?)?;

If I get some more time, I will like to add a token mint and vault to this and create a simple DAO application.

Below are few references which I found helpful while understanding PDAs in Solana
https://paulx.dev/blog/2021/01/14/programming-on-solana-an-introduction/
https://www.brianfriel.xyz/understanding-program-derived-addresses/

Tagged with: , , , , , , ,

Leave a Reply