Keeping a Record of Who has Voted
This section is about extending the contract to prevent duplicate voting, demonstrating pack and unpack, custom errors. if you are not interested in this, move ahead to the next section where we create a web interface to the voting system. I’ll be using the docker local node for this section, but really you can develop against any network as you wish.
So far our Solana Rust contract looks pretty similar to an Ethereum Solidity contract, but you will have already noticed that in Solana you control things “at a lower level”, much less is taken care of for you. For example, the contract program and the fixed size contract storage were created in separate steps; you wouldn’t do that in Solidity, the contract storage is automatically and dynamically allocated.
If we wanted to prevent double voting in Solidity, we’d do something like this:
mapping(address => bool) public voters;function vote(uint candidateId) {
require(!voters[msg.sender]);
voters[msg.sender] = true; // increment candidate count
}
Pretty simple, we just create a “mapping” up front, then set the calling account’s entry to true (default entry on map creation is false) when they vote, we require the calling account’s entry is false. I don’t know how the Solidity compiler is taking care of that under the covers, but for the developer it’s very straightforward.
In Solana we could, if we knew the number of voters, create a fixed size array (or something) in the contract’s data account, which we could record votes in, but this would almost certainly be the wrong approach. What we need is an expanding mapping similar to what Solidity has. To do this in Solana we need to create a new account for every voter. The account needs to be created by the client and passed into the contract. This VoterCheckAccount must be somehow related to the voter’s account, so that the relationship is 1:1 and deterministic. The contract will use that same deterministic logic to locate the VoterCheckAccount and read/write to it. It’s not simple.
This is what needs to happen when the user votes:
- Client creates VoterCheckAccount at an address, derived deterministically from user’s account, to store an integer.
- Client makes contract call, passing in VoterCheckAccount.
- Contract checks address of VoterCheckAccount passed in is correct as per voter’s account.
- Contract requires the VoterCheckAccount contains 0
- Usual voting logic happens.
- Contract writes integer 1 or 2 (candidate number) into the VoterCheckAccount.
And that’s the simplified version, more things will have to be done to make the contract secure, as you will see later.
Note that the same address derivation needs to happen in the client (JavaScript) and in the contract (Rust).
To keep things easy for this tutorial, I have created a separate rust program and separate scripts. The npm scripts are similar, but instead of the _simplest postfix, they have a _rejectdups postfix. And there is a rust directory called rejectdups-rust, that way we can preserve both versions of code and see what changed.
Client Changes
In the client we can use this function from the web3.js library:
let newAccountPK = PublicKey.createWithSeed(basePublicKey, seed, programId)
This will gets us a public key (address) in a deterministic way, we then use:
SystemProgram.createAccountWithSeed( { fromPubKey, lamports, space, basePK, seed, programId, newAccountPK } )
Which will create a transaction which will create an account, owned by the program, at that location. Neither we, nor anyone else, will ever know the secret key for that account.
To demonstrate the concept programatically, I have made a script demo_caws.js that creates a new account based on our account, a seed ‘somestring’, and the programId.
Ensure the program has been deployed (npm run deploy_simplest), then run the demo like this:
npm run demo_caws
[...]
CreateWithSeed (
Base: Ddd6MnERv91irEk5vnQZVJ915fijQLvA6RE8MscatuA2
Seed: somestring
ProgramId: 4GLki9QUgmyJdzKpK9ScJdkQWX5XhatAakN56Ugj4Y97
)
-> derived account address will be: 8wZEuBfrM7Xqk2wysUmuQWbW5DS2R9uBKpgSniUr5mT8Derived account created at 8wZEuBfrM7Xqk2wysUmuQWbW5DS2R9uBKpgSniUr5mT8 cost was: 923720 lamports ( 0.00092372 )
From the output take the new account address (the pubkey), inspect the account like this:
npm run dump -- 8wZEuBfrM7Xqk2wysUmuQWbW5DS2R9uBKpgSniUr5mT8
[...]
{
executable: false,
owner: PublicKey {
_bn: <BN: 30811bbfad4f968ce00bed5a68557ef7249e5f1a498fa65c2ddbf78328f55eea>
},
lamports: 918720,
data: <Buffer 00 00 00 00>
}
Owner PubKey: 4GLki9QUgmyJdzKpK9ScJdkQWX5XhatAakN56Ugj4Y97
You will see the account is owned by the program, and has 4 bytes of zero initialised storage.
If you run the demo again, it will fail, because the account already exists. If you do a clean, and deploy the program again to a new address, then you can run the demo and it will work, but only once. For any account, seed and program — there is exactly one corresponding address.
The base public key must be the public key of the transaction signer, otherwise anyone could ‘steal’ the account, the node enforces this.
Contract Changes
Unlike in our previous simple example, we will be implementing traits program_pack::{Pack, Sealed} for reading and writing data. Here is the implementation for the instruction data. We have a Vote struct that contains an 8 bit unsigned integer. The implementation requires a unpack_from_slice and pack_from_slice function, these create a Vote from data and create data from a Vote respectively. We don’t define pack_from_slice as we will only be reading the instruction data. The implementation also requires the data be exactly 1 bytes long. And that the integer which represents the candidate to vote for be 1 or 2 only.
pub struct Vote {
pub candidate: u8,
}impl Sealed for Vote {}impl Pack for Vote {
const LEN: usize = 1; fn unpack_from_slice(src: &[u8]) -> Result<Self, ProgramError> {
let candidate = src[0]; if candidate != 1 && candidate != 2 {
info!("Vote must be for candidate 1 or 2");
return Err(VoteError::UnexpectedCandidate.into());
}
Ok(Vote { candidate })
} fn pack_into_slice(&self, _dst: &mut [u8]) {}
}
We use the above to get the candidate our user is voting for:
let candidate = Vote::unpack_unchecked(&instruction_data)?
.candidate;
The first account (remains) the contract’s data account, which holds the total vote count. It must be owned by the program:
let accounts_iter = &mut accounts.iter();let count_account = next_account_info(accounts_iter)?;if count_account.owner != program_id {
info!("Vote count account not owned by program");
return Err(VoteError::IncorrectOwner.into());
}
The second account will be the VoterCheckAccount. It must be owned by the program:
let check_account = next_account_info(accounts_iter)?;if check_account.owner != program_id {
info!("Check account not owned by program");
return Err(VoteError::IncorrectOwner.into());
}
We need to ascertain if the VoterCheckAccount will live forever, i.e. it must have been created with a sufficient balance to be rent-exempt. If the account has less than the rent exemption, its balance will be gradually depleted due to rent collection, until it hits zero and is deleted. At that point the voter could create the account again, and vote a second time!
To do the rent calculation, we need to pass in a special system account, the third account is that account. We verify that it is the system rent account, and that the check-account is rent exempt:
let sysvar_account = next_account_info(accounts_iter)?;
let rent = &Rent::from_account_info(sysvar_account)?;
if !sysvar::rent::check_id(sysvar_account.key) {
info!("Rent system account is not rent system account");
return Err(ProgramError::InvalidAccountData);
}
if !rent.is_exempt( check_account.lamports(),
check_account.data_len()) {
info!("Check account is not rent exempt");
return Err(VoteError::AccountNotRentExempt.into());
}
The forth account is the voter’s account (VoterAccount), which must be a signer of the transaction, proving they are making the call:
let voter_account = next_account_info(accounts_iter)?;if !voter_account.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
We now check that the VoteCheckAccount passed in, is indeed that belonging to the VoterAccount, and not some other account:
let expected_check_account_pubkey = Pubkey::create_with_seed(
voter_account.key,
"checkvote",
program_id)?;if expected_check_account_pubkey != *check_account.key {
info!("Voter fraud! not the correct check_account");
return Err(VoteError::AccountNotCheckAccount.into());
}
We get a pointer to the VoterCheckAccount data:
let mut check_data = check_account.try_borrow_mut_data()?;
Like the instruction data we will use Pack to deserialise the data:
pub struct VoterCheck {
pub voted_for: u32,
}impl Sealed for VoterCheck {}impl Pack for VoterCheck {
const LEN: usize = 4;fn unpack_from_slice(src: &[u8]) -> Result<Self, ProgramError> {
Ok(VoterCheck {
voted_for: LittleEndian::read_u32(&src[0..4]),
})
}fn pack_into_slice(&self, dst: &mut [u8]) {
LittleEndian::write_u32(&mut dst[0..4], self.voted_for);
}
}
Used here:
let mut vote_check = VoterCheck::unpack_unchecked(&check_data)
.expect("Failed to read VoterCheck");
We then check that the voter has not already voted:
if vote_check.voted_for != 0 {
info!("Voter fraud! You already voted");
return Err(VoteError::AlreadyVoted.into());
}
We now need to read, increment and write the candidate count data. Again we implement Pack to deserialise/serialise the data. I won’t show the implementation here, look at the code if you want to see it. As you can see the last two statements are the writing of the new counts and the candidate voted for to the VoterCheckAccount.
let mut count_data = count_account.try_borrow_mut_data()?;let mut vote_count = VoteCount::unpack_unchecked(&count_data)
.expect("Failed to read VoteCount");match candidate {
1 => {
vote_count.candidate1 += 1;
vote_check.voted_for = 1;
info!("Voting for candidate1!");
}
2 => {
vote_count.candidate2 += 1;
vote_check.voted_for = 2;
info!("Voting for candidate2!");
}
_ => {
info!("Unknown candidate");
return Err(ProgramError::InvalidInstructionData);
}
}VoteCount::pack(vote_count, &mut count_data)
.expect("Failed to write VoteCount");VoterCheck::pack(vote_check, &mut check_data)
.expect("Failed to write VoterCheck");
One last thing, you may have noticed that some of the errors the contract program returns look a little different, for example:
if count_account.owner != program_id {
info!("Vote count account not owned by program");
return Err(VoteError::IncorrectOwner.into());
}
The provided system errors don’t always exactly fit, so I’ve made some custom errors, you can see them defined near the top of the contract program:
#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)]
pub enum VoteError {
#[error("Unexpected Candidate")]
UnexpectedCandidate,
#[error("Incorrect Owner")]
IncorrectOwner,
#[error("Account Not Rent Exempt")]
AccountNotRentExempt,
#[error("Account Not Check Account")]
AccountNotCheckAccount,
#[error("Already Voted")]
AlreadyVoted,
}
impl From<VoteError> for ProgramError {
fn from(e: VoteError) -> Self {
ProgramError::Custom(e as u32)
}
}
impl<T> DecodeError<T> for VoteError {
fn type_of() -> &'static str {
"Vote Error"
}
}
That really is everything, so we can now build the code as follows:
npm run build_rejectdups
Note: I got a build error here, for reasons unclear. If you do too, try: cd /src/rejectdups-rust; cargo update; npm run bpf-sdk:update
Having built the code, the dist/program/ directory will contain rejectdups.so deployment is basically identical to how it was with the previous program:
npm run deploy_rejectdups
And vote:
npm run vote_rejectdups -- 2
Voting for candidate: 2 ProgramId: 8Sq1huHDekgzvU7g5nuW1EPugF3A9HsXJcMs18eBBBXz
DataAccount: G6SgELo1wtQmZEBsfyiuLWMe3jpGDh8p6b9eKR62rPY4
Vote check-account created at: 68fWwPEkoiJ2wkNirutuYCGZE5Ebd9ieTpcPvy35Shhx for voter: Ddd6MnERv91irEk5vnQZVJ915fijQLvA6RE8MscatuA2
Cost of voting: 928720 lamports ( 0.00092872 )
Vote counts, candidate1: 0 candidate2: 1
Although this looks exactly the same as vote_simplest, the script does a bit more, it creates the voter’s check-account and passes it into the call, as well as passing the special system rent account and the voter account into the call. You probably noticed that voting just got a little more expensive. Check out the script for details.
Try voting again, the client script checks the voter’s check-account and can see they already voted… but even if it didn’t, the on-chain program would reject the vote:
npm run vote_rejectdups -- 1
[...]
Voting for candidate: 1 ProgramId: 8Sq1huHDekgzvU7g5nuW1EPugF3A9HsXJcMs18eBBBXz DataAccount: G6SgELo1wtQmZEBsfyiuLWMe3jpGDh8p6b9eKR62rPY4Dude, you already voted for 2 !!!!
If you dump out the voter’s check account, you can see who the candidate voted for, it’s stored forever on the blockchain:
{
executable: false,
owner: PublicKey {
_bn: <BN: 6e9f9dc162e1f51d5f72a12556cd6441a7dae2a1882d200b3d7b0ade32fb0c7d>
},
lamports: 918720,
data: <Buffer 02 00 00 00>
}
What we basically did here is implement a mapping. I never really thought about how Solidity worked under the covers, and I honestly still don’t know, but now that I have worked though a low level implementation on Solana, I do kind of wonder if it’s similar?
Let’s recap what we did to make a mapping:
- For each voter, create a new account at a ‘fixed offset’ from the base account using a seed.
- In the contract, a) check that this new account is rent exempt using the system rent account and b) that the system rent account is the system rent account.
- In the contract a) check that the new account is at the ‘fixed offset’ from the base account per the seed and b) the base account is a signer on the transaction
If any of those in-contract checks are missing, the contract is insecure.
We could keep enhancing the contract in various ways, but let’s stop now and make a web frontend.