Doors or Wheels? A Solana voting app in Anchor, using PDAs and SOL transfers
How I used PDAs to enforce single vote per account and also require a small fee in SOL for voting
As many other developers, I felt the desire/need/obsession of developing a blockchain project, so I started reading lots of blogs, listening to podcasts and trying to figure out what was going on and where to start.
Every thing I read, led me to two or three new questions (probably way more tbh…) and the thing went like that for many weeks or even months, until I realized I was immersed in an infinite recursion — Yes, I know what you’re thinking, I should have realized earlier but ey, I never said I was the smartest guy! — so, next thing was to find my base condition to get out of that endless looping. Spoiler, that wasn’t easy.
I was very amazed of solana’s performance and I knew i wanted to build something on that ecosystem, but I felt I couldn’t do anything, I was just a web2 developer that had never even read a single line of Rust code nor written any smart contract, so Solana wasn’t probably the best option for my very first baby steps, but as I already told you, not a smart guy here.
After listening Brian Friel’s story at The Solana Podcast, (which I highly recommend, listen it here) I encouraged myself to just go through his code and implement the Potential Improvements he listed in this amazing tutorial Learning how to build on Solana, so that’s the long story of what you’ll find here and why.
We’ll build a Solana program to vote and help decide whether there are more doors or wheels in the world. The program will contain only two functions which are, initialize and vote (names are self-explanatory).
This tutorial will cover:
- Using PDAs in Anchor (v0.24.2).
- Requiring a fixed SOL amount to use a function.
- Testing the program with multiple users.
This tutorial won’t cover things as setting up the anchor project, folder structure or deployment as I found it’s already very well explained in the Brian’s tutorial I mentioned before. Same thing goes for the frontend, I won’t explain it but you can still find the source code here, and check it live at doorsvswheels.xyz — Please note I just adapted the frontend from this repository.
Using PDAs in Anchor
Program Derived Addresses are accounts that belong to a program, so they can only be modified through the program’s methods. PDAs are calculated by the program’s id and an array of seeds, which allows us to use them as foreign keys to relate the program with data. Another key feature is that, as the rest of accounts in solana, they can’t only be initialized once, which is very helpful as we won’t need to build any security checks as the transaction will always fail if the PDA was already initialized.
All this makes PDAs a great choice to store de voting counter and also to enforce single vote per user, without having to implement complex/expensive checks that might also introduce security vulnerabilities.
Initialize (voting counter)
Below is the code for our method named initialize, which creates the account that will store the data of the number of votes for “wheels” or “doors”. For fair elections, both counters must start at 0.
Important thing here is the definition of the Initialize struct, which contains the validation for our instruction and defines the creation of the account named votes_counter.
Creating the PDA in anchor using the #[account()] macro takes 5 arguments:
- init: this first argument tells the program that needs to create an account
- payer: when using init, someone has to pay for the account creation, in our case will be the user that called the instruction.
- space: the space in bytes needed to allocate the data to be stored in the account. You can find the account space reference here.
- seeds: a list of seeds that are used to find a PDA, in our case it’s a single static seed “votes_counter” so there can only be one PDA that will store the counter, as this value is hardcoded in the program and it won’t change (unless we update and redeploy it).
- bump: passing the word bump instead of a value passed as an argument tells the program to use the canonical bump number, which means the first bump that (together with programId and seeds) generates a valid PDA.
Remember that we don’t need to do anything else to ensure that this instruction can’t be called again (to prevent our votes counter reseting to zero) because Initialize struct already validates that the account must be created for the instruction to proceed, and this account’s address is calculated using a hardcoded seed, which makes it unique for this program. Therefore in our initialize method we just need to set the counters to zero and return that everything is Ok(()).
Now let’s implement the vote function which allows a user to emit a single vote, either for wheels or doors, and increase the respective counter.
Same as before, we will use PDAs to easily enforce uniqueness, but instead of having a single static seed, we’ll need to use a dynamic seed so we can create as many accounts as users voting.
We named UserVote to the account that stores what was the user’s vote. As you can see in UserVote struct , we want to persist the vote, that must be either Doors or Wheels as defined in our enum VoteOptions and the bump used for generating this PDA (we won’t use the bump anywhere, I left it here as an example).
Now let’s break into the Vote validating struct:
- Line 48: we define that it expects a param named vote of type VoteOptions
- Line 56: here we use the user’s public key as a seed together with the string “user-vote”. Using the user’s key allows us to ensure that this function can only be successfully executed once per user, as the user vote account will already exist on the other calls from the same user, raising a transaction error.
- Line 60–61: we need to define the account votes_counter as mutable to persist the changes after the instruction finished (increasing the counter for the given vote).
Finally the vote instruction itself is very straightforward, it accepts an argument vote, of type VoteOptions and it increases the counter for the given option voted. Again, no need to check anything to prevent a user from voting more than once or changing his vote as this is already enforced by the PDA being created or not. Using the VoteOption enum also prevents the function to run with any value that is not doors or wheels, so we don’t need to write any other check for that neither.
Requiring a fixed SOL amount to use a function
At this point we have a voting program that enforces that accounts can only vote once, but we are still far from being safe from malicious users.
No need to be Elon Musk to know what happens when something is free and can be easily automated… Yes, you’re right, bots!
One way to prevent bots from manipulating the vote’s results is implementing a voting fee high enough so the attack is not worth — It sounds very nice but this was more of an excuse to learn how to do a SOL transfer between a user’s wallet to a program owned account.
Find below the changes to charge a SOL amount for using the vote function
- Lines 19–23: define the transfer instruction
- Lines 27–34: call system program to execute the transfer instruction passing all accounts used in the transaction (remember Solana always requires to know what accounts will be needed in advance).
Testing the program with multiple users
Anchor framework already gives us a tests folder in the root of the project and the script to run it , defined in the Anchor.toml file.
Below there’s the reduced version of the tests I implemented for the client side of our solana program. You can find the source code file with all tests here.
I just wanted to share the way I tested the program with different users/wallet interactions as it took me time to figure it out.
As you’ll remember our voting program doesn’t let a user vote more than once, therefore to test that voting for “wheels” for “doors” do work, we’ll need two users.
I managed to do this based on a StackOverflow answer by implementing the helper method named getNewProgramInteraction which returns the variables needed for using the program with a new user. Note that as the new user is created, we’ll need to request airdrops to fund the account’s balance so it can pay the transaction fees and also our own voting fee, this is also helpful to test cases like not enough funds error.
For tests that need to be on same user, I just stored the variables globally to reuse them as needed (line 15).
- Program Derived Addresses from Solana cookbook
- Brian Friel’s Learning How to Build on Solana and Understanding Program Derived Addresses
- Daniel Pyrathon on Using PDAs and SPL Token in Anchor
- Rahul Srivastava Voting Proposal Program in Solana
- The Anchor Book