A Simple Solana Dapp Tutorial

Smith | MCF
33 min readAug 28, 2020

--

NOTICE: this was the first ever developer tutorial for Solana, things have moved on — you should probably be using Anchor and contract checking tools like Soteria — the tutorial remains for the record.

This tutorial explains step-by-step how to write, deploy, and interact with a smart contract on the Solana Blockchain. When I was learning Ethereum, the first tutorial I looked at, was for a simple voting program. I’ve taken it and translated it into Solana, aiming at exact functional compatibility. I wanted to see the similarities and differences between Ethereum and Solana from a developer’s perspective.

At the end of this tutorial we will have deployed a voting contract on Solana mainnet, with a website frontend, it will look like this.

Topical, right?

Check out the completed Solana Dapp Tutorial and the code.

This tutorial does require decent shell and programming knowledge. Solana is a very new blockchain and is being rapidly developed, the tutorial was written 30th Aug 2020 and has been tested on Ubuntu 18.04, invariably thing will change/break. Find me on the Solana Discord.

Setting up the development environment

Solana on-chain programs (contracts) can be written in C, C++ and Rust, although the expectation is that most people will use Rust, and this example is written in Rust.

Initially we will get the program running in a local Solana node, then we will deploy to devnet, testnet and finally mainnet.

The off-chain programs we write will interface with the Solana node using a JavaScript API, this can be used for stand alone scripts executing on nodejs, or in webpages.

Let’s start by checking we have all the bits we need:

git --version
node --version
npm --version
docker -v
wget --version
rustup --version
rustc --version
cargo --version

Please follow these instructions for installing Rust and Docker. Node has to be version 10.0.0 or higher, these installation instructions may help you, I installed v12.18.3 of node.

Install Solana cli tools, the install will add the Solana cli binaries to your PATH via your .profile:

sh -c "$(curl -sSfL https://release.solana.com/v1.6.1/install)"

Check it worked by querying the block height on the various public networks:

solana slot --url https://devnet.solana.com
solana slot --url https://testnet.solana.com
solana slot --url https://api.mainnet-beta.solana.com

Clone the github:

git clone https://github.com/mcf-rocks/simple-vote-tutorial.git
cd simple-vote-tutorial

Install the required modules, the modules and versions are defined in package.json, in particular the version of the Solana JavaScript web3 API we will be using, defined by “@solana/web3.js”:

npm installnpm list | grep solana
├─┬ @solana/web3.js@0.78.2

Get the docker image, containing the Solana node. This is our ‘local blockchain’ similar to Ganache on Ethereum. The particular image it grabs is defined in package.json “testnetDefaultChannel”, in this case v1.3.9:

. update_docker
[...]
> v1.3.9: Pulling from solanalabs/solana
> Status: Image is up to date for solanalabs/solana:v1.3.9

Start it (you can stop it later with; . stop_docker):

. start_docker

If you want you can tail the log:

. log_docker

The node listens on port 8899, the docker machine forwards to the node, you should see the machine if you do this “ps auxww | grep 8899”. Check you can talk to the node.

If you have the Solana client installed as mentioned above, you can run commands against the node directly:

solana slot --url http://127.0.0.1:8899

Or you can do it via a script, I have done almost everything with scripts, so you can see how to do things programatically:

npm run slot
[...]
> Node software version is 1.3.15 acb992c3
> Cluster total supply is 1000059186730605300 lamports, which is 1000059186.7306054 Sol
> Cluster current slot is 40381

This script will return some details about the “cluster”, which in this case is just our local node. If you run the script again, the slot number will have increased, you can think of a slot like a block in Bitcoin or Ethereum.

By the way, the scripts we are running are defined in package.json, if you want to look at them they are plain JavaScript in the ./src/client/ directory. The script executed by “npm run slot” is ./src/client/slot.js

We need a wallet, which is just a public-private keypair:

npm run keypair
[...]
> SK in bytes: Uint8Array(32) [
202, 219, 149, 130, 21, 221, 149, 92,
227, 154, 133, 29, 36, 175, 231, 79,
136, 224, 21, 63, 71, 173, 27, 182,
253, 39, 211, 83, 48, 105, 225, 140
]
> PK in bytes: Uint8Array(32) [
187, 173, 246, 71, 180, 197, 93,
95, 83, 101, 214, 227, 221, 252,
41, 190, 116, 121, 21, 23, 228,
250, 163, 80, 42, 237, 14, 48,
140, 23, 162, 195
]
> PK as hex str: <BN: bbadf647b4c55d5f5365d6e3ddfc29be74791517e4faa3502aed0e308c17a2c3>
> PK as base58 ('the address'): Ddd6MnERv91irEk5vnQZVJ915fijQLvA6RE8MscatuA2
> Wallet keypair is in root of project: ./keypair.json

There will now be a keypair.json file in the project directory, the file contains a sequence of bytes as decimals, the first 32 are the secret key, the last 32 the public key. For convenience, when being used and communicated, the public key is encoded as a base58 string, you can consider this the ‘address’ of the wallet. Creating a keypair is an off-chain operation. As the file is the only record of the secret key, if you delete it, that wallet is gone and unrecoverable.

We are using my scripts in this tutorial so you can see in detail how things work, if you are creating a wallet on mainnet to hold non-trivial amounts, please use the official Solana programs.

If you look at the script, you will see that we didn’t chose a private key, we just called the JavaScript web3 Account api, which used entropy from the operating system to create a random private key.

Let’s check the balance on our new wallet:

npm run balance
> Balance of Ddd6MnERv91irEk5vnQZVJ915fijQLvA6RE8MscatuA2 is 0 ( 0 )

This will show your account public key (like an Ethereum address) and a balance of zero. We need some funds on the wallet, so that we can deploy our smart contract and do other on-chain stuff.

npm run airdrop

This will drop 1 Sol onto the account, airdrop is only available on our local node, devnet and testnet.

Actually our account didn’t exist until just now, the action of sending funds to an address is what calls the account into existence. In Solana, accounts are charged a very tiny amount of ‘rent’ every epoch, when the balance of an account goes to zero, it returns to the void... There is an exception to this rule, above a certain balance, an account is rent exempt and lives forever. More on this later.

If you want to see a bit more about your account, or any account, I’ve written a little script to dump out the contents, try it:

npm run dump -- <your account public key>npm run dump -- Ddd6MnERv91irEk5vnQZVJ915fijQLvA6RE8MscatuA2
[...]
{
executable: false,
owner: PublicKey { _bn: <BN: 0> },
lamports: 10000000000,
data: <Buffer >
}
Owner PubKey: 11111111111111111111111111111111

You see here: the balance; that the account is not executable; that it is ‘owned’ by the System Program (long string of 1s); and there is no data within it. All accounts are ‘owned’, obviously we control this account, but it’s owned by the System Program.

Writing and Testing a Really Simple Smart Contract

Now that we are all set up, let’s make a smart contract. First we will construct the simplest possible working version, then revisit and extend it.

If you are looking for a contract program to clone to get started, don’t use this one, use the next one. This version is intentionally over simplistic, to make certain points.

The way programs work in Solana is a little different from a Solidity contract on Ethereum, where code and data ‘live’ at the same place (at least conceptually). In Solana, the compiled program code is loaded into a special account, which can never be changed. If the program needs any storage on the chain, this must be held in a separate account(s), owned by the program. These accounts are then passed into the program in the transaction call, allowing the program to read and write the storage.

Our program allows only two actions: vote for candidate A or B, and it does only one thing: keep count of the votes!

You can find the complete code in /src/simplest-rust/src/lib.rs and you can build it using:

npm run build_simplest

The Solana virtual machine is a Berkeley Packet Filter VM, various output of the build can be found in the /src/simplest-rust/target/bpfel-unknown-unknown/release directory. The all important file simplest.so is a eBPF (magic 0xf7) shared object in /dist/program/, this is the contract program we will deploy to the chain.

file ./dist/program/simplest.so
> ./dist/program/simplest.so: ELF 64-bit LSB shared object, *unknown arch 0xf7* version 1 (SYSV), dynamically linked, stripped

The program is very simple, before we deploy it, let’s go through the code looking at the important bits. Hopefully you know some Rust, because before making this tutorial, I didn’t know Rust and it is a little different to Solidity 😉

Here is the entry point of the program, the node will invoke the program with these parameters:

fn process_instruction<'a>(
program_id: &Pubkey, // Public key of program account
accounts: &'a [AccountInfo<'a>], // data accounts
instruction_data: &[u8], // 1 = vote for A, 2 = vote for B
) -> ProgramResult {

The invocation occurs when we make a transaction, therefore the transaction includes this information. The program_id is the public key (32 bytes) of the account the program is in, this is required because the same program could be deployed to different accounts, and it may need to know which instance is executing. Next an array of account structures are passed, each contains the public key of an account and various information about the account. Finally an array of bytes is passed, this is how we pass arbitrary arguments and data to the program.

For the super simple version of our voting program, users will pass the account to hold the vote count of the two candidates, and a single byte of instruction data to indicate which candidate they are voting for.

The accounts structure looks like this:

/**
* Keyed Account
*/
typedef struct {
SolPubkey *key; /** Public key of the account */
uint64_t *lamports; /** Number of lamports owned by this account */
uint64_t data_len; /** Length of data in bytes */
uint8_t *data; /** On-chain data within this account */
SolPubkey *owner; /** Program that owns this account */
uint64_t rent_epoch; /** epoch when account will next owe rent */
bool is_signer; /** Tran was signed by this account's key? */
bool is_writable; /** Is the account writable? */
bool executable; /** contains a loaded program (is RO) */
} SolAccountInfo;

In our program the following lines extract the first account passed:

let accounts_iter = &mut accounts.iter();
let account = next_account_info(accounts_iter)?;

We then perform a check that the account is owned by the program, this is necessary for the account to be modified by the program. You might be wondering where this account is… it doesn’t exist yet! We will create the account after we deploy the program, and it will be created as owned by the program.

if account.owner != program_id {
info!("Vote account does not have the correct program id");
return Err(ProgramError::IncorrectProgramId);
}

Accounts can be thought of as bits of on-chain storage, this account needs to hold two 32bit unsigned integers, representing the vote counts of the candidates. The first 4 bytes will be the vote count of candidate 1, the next 4 bytes will be the vote count of candidate 2.

if account.try_data_len()? < 2 * mem::size_of::<u32>() {
info!("Vote account data length too small for u32");
return Err(ProgramError::InvalidAccountData);
}

Now the important bit, we read the account data from the chain, change it as appropriate, and write it back.

In the next contract program, we will use a nicer way to pack and unpack from the blockchain. For now, just realise that nothing magical is going on, we are just reading some bytes from the blockchain, interpreting them in a certain way, changing them, then writing the bytes back to the blockchain.

let mut data = account.try_borrow_mut_data()?;if 1 == instruction_data[0] {
let mut vc = LittleEndian::read_u32(&data[0..4]);
vc += 1;
LittleEndian::write_u32(&mut data[0..4], vc);
info!("Voted for candidate1!");
}
if 2 == instruction_data[0] {
let mut vc = LittleEndian::read_u32(&data[4..8]);
vc += 1;
LittleEndian::write_u32(&mut data[4..8], vc);
info!("Voted for candidate2!");
}

If you haven’t already, at this point build the program, make sure everything compiles and the solana_bpf_simplest.so file is generated.

npm run build_simplest
[...]
> Compiling solana-bpf-simplest v0.0.1 (/root/simple-vote-tutorial/src/simplest-rust)
> Finished release [optimized] target(s) in 1m 20s

We will test the program on-chain later, but at this point we can write some off-chain tests in Rust to make sure everything is as expected. You will find the test module in the same file, directly beneath our program, the comments explain what is happening. Execute the tests like this:

npm run test_simplest

If for some reason you want to want to start again, you can do so like this:

npm run clean_simplest

It’s time to deploy the program and its associated data account on our local node. Check the node is still running and that our wallet has tokens on it. If necessary, start the node and fund your account as previously described:

npm run balance

Deploy the program:

npm run deploy_simplest
[...]
> Estimated cost to program load: 124497760 lamports ( 0.12449776 ) Sol
> Program loaded to: B3chrT7jzh4TdTsvvUt1ZktJGn6AVTeRVrLxm474Acjh
cost was: 124497760 lamports ( 0.12449776 ) Sol
> Estimated cost to make account: 956560 lamports ( 0.00095656 ) Sol
> New account at: Bb5UyhJzcGjkaJVDKqfKymGm2dZ8E8UNyryTCzotchMS
cost was: 956560 lamports ( 0.00095656 ) Sol

Hopefully the script executed without error and you have a bunch of output, including two public keys (the program account and the data account), you can think of the public keys like addresses. These new accounts (and therefore their public keys) were generated just like our own account; off-chain, from entropy, i.e. randomly.

There is quite a lot going on here, so we should break it down. It may help to also look at the script we just executed.

From the output on your screen, find the public key of the program, then look at the account like this:

npm run dump -- B3chrT7jzh4TdTsvvUt1ZktJGn6AVTeRVrLxm474Acjh
[...]
{
executable: true,
owner: PublicKey {
_bn: <BN: 2a8f6914e88a16e395ae128948ffa695693376818dd47435221f3c600000000>
},
lamports: 124277760,
data: <Buffer 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 03 00 f7 00 01 00 00 00 18 02 00 00 00 00 00 00 40 00 00 00 00 00 00 00 40 42 00 00 00 00 00 00 00 00 ... 17678 more bytes>
}
Owner PubKey: BPFLoader1111111111111111111111111111111111

We see the program account is executable, it is owned by the BPFLoader, it has a balance, and it has a ton of data; the program bytecode.

All program accounts are created by the BPFLoader, which is also the owner, the data in the account are the bytes read from the binary .so file, program accounts are created once and can never be changed.

You might be surprised that the program account has a balance. The reason is that Solana charges ‘rent’ on accounts, periodically (every epoch) a little bit of the balance is taken, at zero the account is deleted. There is an exception, above a certain balance, an account is rent exempt. The BPFLoader creates the program account with the required rent exemption balance for the size of the program.

In case you want to programatically compute the cost of a load before invoking BPFLoader, in deploy.js I made a function estCostLoadProgram which you can look at.

After loading the program, we created the data account. We created a transaction to the System Program to make this account on-chain sized at 8 bytes (at the time of writing accounts are fixed size forever, but this may change in the future) and transfer the required rent exemption to it (which we had to calculate) and the owner of the new account was to be the program. You can check the program ownership using dump, you will also notice the 8 bytes (zeros) of account data.

npm run dump -- Bb5UyhJzcGjkaJVDKqfKymGm2dZ8E8UNyryTCzotchMS
[...]
{
executable: false,
owner: PublicKey {
_bn: <BN: 954066e487c9726ac4d91327c057121dad3774e7078fca5369c1b8338aaf33b0>
},
lamports: 946560,
data: <Buffer 00 00 00 00 00 00 00 00>
}
Owner PubKey: B3chrT7jzh4TdTsvvUt1ZktJGn6AVTeRVrLxm474Acjh

NB: The deploy script also keeps a record of the program account public key and the program data account public key in store/simplest.json, this is not essential to what we are doing, it’s just convenience. If you want to start over, doing a clean_simplest will remove this config.

Small thing to mention; new data accounts always have all their data initialised to zero, for us that is a happy coincidence as our vote counts should also start at zero, but what if you needed the new account initialised differently? As the program must be the owner of the account, you would need to initialise through the program using instruction_data. Not only that, you would need the program to require the new account was a signer on the transaction, to prevent it being spoiled by a bad actor.

Let’s call the program to see if it works. Vote for the second candidate like this:

npm run vote_simplest -- 2
[...]
Voting for candidate: 2 ProgramId: B3chrT7jzh4TdTsvvUt1ZktJGn6AVTeRVrLxm474Acjh AccountId: Bb5UyhJzcGjkaJVDKqfKymGm2dZ8E8UNyryTCzotchMS
Cost of voting: 5000 lamports ( 0.000005 )
Vote counts, candidate1: 0 candidate2: 1

Our vote cost 0.000005 Sol, you can vote as often as you like, so you should.

The vote_simplest script creates and sends a transaction to call the program with the candidate to vote for, the program updates the blockchain, then reads back the data and displays it.

You can also use dump on the data account to see the changes to the data. I voted for 2 twice and 1 once:

npm run dump -- Bb5UyhJzcGjkaJVDKqfKymGm2dZ8E8UNyryTCzotchMS
[...]
data: <Buffer 01 00 00 00 02 00 00 00>

That’s the end of this section, we developed a very simple contract program, deployed it to our local node, called it, and read the data from the blockchain.

This version of the voting contract is called “simplest” for a reason, it’s far too simple. For example there is nothing to stop the same person voting multiple times. We will come back to the voting contract and make changes to prevent multiple voting. But first let’s talk about debugging and how to deploy to “real’’ networks.

Debugging and Troubleshooting

If you need to debug, dump the local node’s log to a file (see previous) and search for the program account address. Beware, that (for reasons I don’t understand) the docker node’s time is just totally wrong, it’s not UTC, it’s not local time, it’s not any timezone. When you find the BPF program execution, you’ll find your error, just for example:

BPF program CeNF6f1nVNXu2Sp9Hx7Bfxh9ReAjPRbtvQBo1hnydDDA failed: out of bounds memory load (insn #525), addr 0xf56e9f2d918b5ce8/8

If you need to stop and restart the docker image: . stop_docker works on my Mac, but on Ubuntu I had to list the containers “docker container ls” then “docker container stop <theSolanaContainer>”. The start_docker script seems to work for both.

In terms of configuration, version control and changes, there are a few moving parts here. The Solana Rust SDK is being developed, the JavaScript SDK is being developed, The BPF SDK is being developed, the Solana node on the docker image will be changing. At any given time, you need these components to be compatible. Additionally, Rust itself is relatively new and is being developed. With so many moving parts, It is very easy for things to break. If things don’t work the following may help:

  • To get a BPF SDK version use: npm run bpf-sdk:update (remember to rebuild afterward), this component is what translates your rust code into BPF byte code, the version it gets is driven by the package.json field “testnetDefaultChannel”. Any problems building rust, you might want to try this.
  • To get a docker image version use: . update_docker (remember to stop & restart afterward), the version it gets is driven by package.json field “testnetDefaultChannel”.
  • The version of the Solana Rust SDK crate “solana_sdk” being used is controlled by Cargo (Rust’s crate manager) look at Cargo.toml. This is the Solana specific code our program uses, like AccountInfo, etc. NB: if the version is specified like this: version = “1.3.14” it uses that version or newer, but version = “=1.3.14” means use that exact version.
  • The version of npm modules, including the JS SDK is driven by package-lock.json and package.json look for solana/web3.js under dependencies. NB: if a version number is preceded with a ^ character, it means that version or greater, without the ^ character, it means that exact version.
  • When changing the Rust SDK or JS SDK, your program / JavaScript code may need to change.
  • To get the latest rust build use: rustup toolchain install nightly.
  • To try to determine the ‘break’ you can also download a reference implementation supported by the team, such as “hello world”, then play spot the difference. The “hello world” implementation is guaranteed to always work, if it doesn’t let them know in the discord.

12th Oct 2020, the following works for local node:
- testnetDefaultChannel: v1.3.15
- solana_sdk: 1.3.14
- solana/web3.js: 0.78.2
- rustc version: 1.45.2
- use
BPF_LOADER_DEPRECATED_PROGRAM_ID in client/deploy.js
- use
entrypoint_deprecated in contract code to match the above
^ Probably, by the time you read this, deprecated will no longer be used, in both of the files I have the lines next to each other with one commented, so it is simple to switch

Deploying on devnet, testnet, mainnet

Solana has three networks: devnet, testnet and mainnet. See the documentation for latest information. The connection details (the node IP and RPC Port) are in nodeConnection.js but these may change over time.

Devnet is currently just a single node run by the Solana team. This is not really much different from running the local docker node; it does save you running your own, and it is accessible by everyone so you can develop with others. It has airdrops enabled for tokens.

Testnet (“TDS”) is an independent test network run by 100s of nodes globally. Ask in the discord for tokens if you want to deploy to it, someone will give you. This network often runs a newer code branch to mainnet, unless you are developing ahead of mainnet functionality, you may want to skip this network.

Mainnet is the actual network run by 100s of nodes globally. Don’t be misled by the “mainnet-beta” label, this is real. Tokens have value, you can buy Sol on Binance.

devnet

To deploy to devnet, switch to that network and check it is running:

npm run cluster_devnet
npm run slot
> Connection to cluster established: http://devnet.solana.com { 'solana-core': '1.3.16 857e44c1' }
> Cluster current slot is 2115852

The cluster_devnet script copies the relevant file from the /env directory, which is read in the nodeConnection.js script, causing the url of devnet to be set. The script also removes all store config, so if you switch back to cluster_local, you will need to redeploy the contract.

Previously your account had a balance, but that was on your local node, on devnet it doesn’t. Airdrop to your account:

npm run airdrop

Deploy the contract and it’s data account:

npm run deploy_simplest

Vote for a candidate:

npm run vote_simplest -- 1

That’s pretty much it, but because the devnet node probably isn’t on the same code point that your local node was on, you may need to change some of your configuration. At you can see above, the devnet node is on 1.3.16 whereas our local node and the BPF SDK were on release 1.3.15, nevertheless, everything worked without changes. If you have problems, see the “Debugging and Troubleshooting” section previously.

testnet

Same thing… testnet.solana.com:8899 is currently servicing requests, if that will continue I don’t know. There are no pay-to-RPC services on Solana like Infura on Ethereum, the team just run that node with the RPC port open, you could also run your own node and use that.

npm run cluster_testnet
[ get some tokens ]
npm run deploy_simplest
npm run vote_simplest -- 1
npm run vote_simplest -- 2

Typically devnet and mainnet are the same, but testnet maybe on a different branch and require different config in terms of versions. I usually go straight from devnet to mainnet.

mainnet

Same story for mainnet, right now the following works:

solana slot --url https://api.mainnet-beta.solana.com

If that’s no longer available, you can run your own node or perhaps convince someone to open their RPC port for you. Although on mainnet people will probably not want to do that, as it may degrade their node’s performance which has financial implications.

npm run cluster_mainnet
[ as above ]
// switch back to local node
npm run cluster_local

And that’s it, we’ve deployed to mainnet. Next we will make changes so that the same account cannot vote more than once.

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:

  1. Client creates VoterCheckAccount at an address, derived deterministically from user’s account, to store an integer.
  2. Client makes contract call, passing in VoterCheckAccount.
  3. Contract checks address of VoterCheckAccount passed in is correct as per voter’s account.
  4. Contract requires the VoterCheckAccount contains 0
  5. Usual voting logic happens.
  6. 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: 8wZEuBfrM7Xqk2wysUmuQWbW5DS2R9uBKpgSniUr5mT8
Derived 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: G6SgELo1wtQmZEBsfyiuLWMe3jpGDh8p6b9eKR62rPY4
Dude, 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:

  1. For each voter, create a new account at a ‘fixed offset’ from the base account using a seed.
  2. 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.
  3. 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.

Making a Web Frontend

So far we have been interacting with our contract via scripts, it’s time to create a web frontend, so that people from all over the world can vote. I’ll do all the following on mainnet, if you want to follow along in another environment, it should be should be straightforward.

We will need a wallet on the browser, so that users can transfer the voting fee to it and create the vote transaction. But first let’s create a super simple example of a webpage interacting with Solana.

You will notice all my examples use a local file:

solana/web3.js/lib/index.iife.js 

This is a local copy of the web3 sdk that will be used on our webpages. The way I got this file (0.71.9) is like this:

git clone https://github.com/solana-labs/solana-web3.js 
npm install
# then just keep the lib directory

There will be a nicer way of getting this in the future, but right now that works; but of course you can just use mine.

Let’s use a simple local web server initially:

npm install --global http-server

Very Simple Browser Example

I’ve made a directory called frontend-simplest/ containing one file index.html which contains this code:

<html>
<head>
<script src="./solana-web3.js/lib/index.iife.js"></script>
<script>
async function latest() {
const url = 'https://api.mainnet-beta.solana.com'
const connection = await new solanaWeb3.Connection(url, 'recent')
let count = await connection.getTransactionCount()
var myDiv = document.getElementById('myDiv')
myDiv.innerHTML = count
setTimeout(latest,1000)
}
</script>
</head>
<body onload="latest()">
<div id="myDiv"> *** replace me *** </div>
</body>
</html>

In that directory run http-server, then in an internet browser go to http://127.0.0.1:8080

You should see the transaction count of mainnet updating every second. This code is running in the user’s browser, making a direct connection to the RPC node.

A Simple Wallet

Let’s make a wallet that a user can transfer some coins to, so that they can cast their vote. There are various web wallets being developed for Solana, but to make things simple, we will just create an account and stick it in a cookie.

The code is in the frontend-wallet/index.html file, and can be seen below. The code below creates a wallet, displays the address and balance. Go to the directory and run http-server, then navigate to http://127.0.0.1:8080 in your browser. If you want, you can deposit a very small amount (0.01 Sol) to the address, it should show up within a few seconds (refresh the page). The “Show Secret Key” button shows you the contents of the cookie. The “Destroy Wallet” nukes the cookie and creates a new wallet (funds gone!).

<html>
<head>
<script src="./solana-web3.js/lib/index.iife.js"></script><script>const url = 'https://api.mainnet-beta.solana.com'function skStringToAccount(skString) {
return new solanaWeb3.Account(
Uint8Array.from(skString.split(',')) )
}
async function pageload() {

if ( document.cookie == null || document.cookie == 'NONE' ) {
const account = new solanaWeb3.Account()
document.cookie = account.secretKey
}

const account = skStringToAccount(document.cookie)
const address = account.publicKey.toString()
document.getElementById('address').innerHTML = address
const connection = await new solanaWeb3.Connection(url, 'recent')
const balance = await connection.getBalance(account.publicKey)
const displayBal = balance +
" lamports (" +
balance/solanaWeb3.LAMPORTS_PER_SOL +
" SOL)"
document.getElementById('balance').innerHTML = displayBal
}
</script></head>
<body onload="pageload()">
<div style="float: left">Address:</div> <div id="address"> </div>
<div style="float: left">Balance:</div> <div id="balance"> </div>
<button onclick="document.cookie='NONE';
location.reload();">
Destroy Wallet
</button>
<button onclick="alert('['+document.cookie+']');">
Show Secret Key
</button>
</body>
</html>

Adding Voting Functionality

Now let’s add the ability to cast a vote.

At this point, we are going to have to change direction a little. Both the previous examples make a direct connection between the browser and the Solana node. There are two reasons why we won’t continue doing it that way.

  1. The part of the JS SDK we are going to use next calls sha256, which most browsers block if the connection is not https, there is a (deceptive) exception for localhost, but if we want to deploy as a real website we will need to run https. Easy enough, but browsers get funny when https sites have mixed content, our content is mixed because we are serving things coming from our server but also things from the Solana node, and the browser knows it and flags it as insecure.
  2. In the future it may not be possible to make anonymous calls directly to a Solana node, RPC calls cost compute cycles, it’s kind of unrealistic that it should be free to the public. If you use a pay-as-you-query service like Infura for Ethereum, you are given a secret token to make RPC calls, which you will be billed for, so you do those calls from your server. Anticipating this model, we will make the RPC calls from our server.

Any call we can make from the browser, we can also make from the server. Indeed if you wanted to, you could make some calls from the browser and others from the server. However, we will be making ALL calls from the server, the browser will still use the JS SDK for some things, but RPC calls will be ‘passed back’ to the server. We won’t be able to use our simple http server anymore. We will write a small server using node. The good news is that we already know how to make all the calls we need from the server; our npm scripts. We just need to make the functionality ‘callable’ from the browser.

NB: For this example, I started up the smallest possible cloud server on Digital Ocean — Ubuntu 18.04

The code is in frontend-vote/ there are two files: index.html and server.js. As in the previous examples, the index.html file runs in the browser, the server.js is our web server.

For this example you need to deploy the rejectdups contract to Solana mainnet, as previously demonstrated, plus we need the address of the contract and its data account. Here is my deployment with the important details in bold, it will cost about 0.9 Sol. Or if you don’t want your own instance, you can just use mine.

npm run cluster_mainnet# check I have enough coins
npm run balance
> Balance of A3xomYVxhn2TNMnVNrd9ewT891FJYbt3Mzp4vPhmeDfM is 1031714800 ( 1.0317148 )
npm run deploy_rejectdups
[...]
Estimated cost to program load: 969795200 lamports ( 0.9697952 ) Sol
Program loaded to: CkFsUJDJXWatS8yLL3WZfyoKbTAQGtRNXpswBcfZsbi7 cost was: 969795200 lamports ( 0.9697952 ) Sol
Estimated cost to make account: 956560 lamports ( 0.00095656 ) Sol
New account at: 6VPjctSji8E2hNEh5r3Gkm3kP6EhDjHkuUaeiwoxWhTp cost was: 956560 lamports ( 0.00095656 ) Sol

It will be easiest to start with server.js which uses some packages (if these don’t already exist on your system, install using npm), the Solana RPC endpoint to use is defined, then:

app.get('*', async function (request, response, next) {

This function is called for each request made from a browser to our web server. But before the server starts listening, it does the bits after this function definition. Go to the end of the file and you will see the first thing it does is connect with the Solana node, it then creates the https server using key.pem and cert.pem files, after that it listens for requests.

For now make a self signed certificate like this:

openssl req -new > cert.csr //some passphrase, accept defaults
openssl rsa -in privkey.pem -out key.pem // the passphrase
openssl x509 -in cert.csr -out cert.pem -req -signkey key.pem
cat key.pem>>cert.pem

The browser knows it’s a self-sign, there will be a warning, go to the page anyway (Chrome won’t let you, but FireFox will, so use that) — to actually deploy for real, we’ll need a proper certificate.

At this point you can start the server like this:


node server.js
> Connection to cluster established: https://api.mainnet-beta.solana.com { 'solana-core': '1.3.16 9e459f00' }
> 'waiting for you on https://165.22.86.204:443'

The URL will be whatever the IP of your server is, you may need to allow 443 traffic.

If you look at the code again, within the app.get function, there are various sections. The first one gets the balance for an address, the browser passes back an address in the URL, the server queries the Solana node for the balance and returns it. The important line is:

const balance = await connection.getBalance(...

It’s identical to the npm scripts we used initially. The server deals with all the requests from the front end: it reads the vote counts, gets a recent blockhash (more later), reads the voter check-account, calculates vote costs and rent exemption. It also submits signed transactions, the transaction is made and signed in the browser (the secret key is never passed back to the server), the transaction is then turned into bytes and passed to the server as a JSON string. The server just sends it to the node:

tSig = await connection.sendRawTransaction( bytes )

And waits until the entire cluster has confirmed the transaction by 1 block:

await connection.confirmTransaction(tSig, { confirmations: 1 })

The rest of server.js just serves up files, like index.html

We can now look at index.html to see what is running in the browser. You will notice the IP of the node server is defined, you must change that to be your server. The contract address and the vote account address are also defined as per our mainnet deployment. All the wallet functionality is there as in the previous example. There are a couple of small functions that fetch a URL from our server, one you wait for, the other takes a callback. There is a pageload() function that takes no parameters, and a vote(c) function that takes the candidate as the only parameter.

The pageload function gets the wallet balance, vote counts, voter status, etc, by doing a fetch from the server. The URL contains whatever information the server requires, for example, to get the balance of an account, the address is passed back. It’s very simple, and becomes clear just by reading the code.

The vote function is a little more complicated. It makes two transactions, the first to make the voter’s check-account, the second to vote by calling the program. You will notice the code looks very similar to the vote_rejectdups.js script. But there are some differences, because we are making and signing the transaction in the browser but broadcasting it from the backend, we cannot use the utility function as we did in the script, we have to do things at a lower level. Looking just at the first transaction, it is constituted in the normal way:

solanaWeb3.SystemProgram.createAccountWithSeed(...

But then we get a ‘recent blockhash’ (via the server) and assign it to the transaction. This blockhash is valid for around 2 minutes, if the submitted transaction has not been mined by that time, it is guaranteed to be dead, forever.

transaction2.recentBlockhash = (await fetchWait( 
ourServer + '/recentBlockhash'
)).blockhash

We then sign the transaction. We then serialize the transaction into an array of bytes. We encode the bytes as a JSON string and pass this back to the server, which broadcasts the transaction to the Solana node. The second transaction happens exactly the same way.

transaction2.sign(account)
const bytesT2 = transaction2.serialize()
const jsonT2 = JSON.stringify( bytesT2 )

And that’s pretty much it. There is one small refinement we can make, we can combine the two transactions into one. The index2.html file has everything the same, except it combines the check-account creation and the vote transaction:

transaction = transaction.add(instruction)

Right after creating the transaction for the check-account, you can just add the TransactionInstruction for the vote, then assign the ‘recent blockhash’, sign and serialize as before. No other changes are required, you can just replace index.html with index2.html.

Some Lipstick on the Pig

As far as functionality goes, that is everything I am going to do. It’s now time to make a beautified version of the webpage and deploy it to the real internet. Take a look the finished Solana dapp tutorial hosted on my website.

All I’ve done is add a bunch of HTML, CSS and JavaScript — there is zero functional difference between this final version and the version we just made, but it does look nicer! If you want to check out the fancy formatting, it’s in the frontend-final/ directory.

I also had to get a proper certificate, which means giving it a subdomain and using certbot; didn’t cost anything, but did involve a few steps — outside the scope of this tutorial.

Contemplations

I set out to translate an existing tutorial from Ethereum to Solana, and in the process determine some of the similarities and differences.

What are the similarities? Ethereum has a ‘local blockchain’ Ganache, Solana has a node on a docker image. Ethereum has various testnets (Goerli, Ropsten, etc), Solana has two; a centralised (foundation maintained) devnet and a distributed (volunteer maintained) testnet. The web3 JS SDK for Ethereum and Solana are super similar.

What are the differences? The virtual machines are different, EVM vs BPF. The contract languages are different, Solidity vs Rust. But more than just different, Solana has to be programmed at a ‘lower level’, to construct a mapping is trivial in Solidity, but required quite a bit of work in Solana. On the plus side, Solana is much faster, from the UX perspective transactions are ‘mined’ almost instantly & because the capacity of Solana is huge (50Ktps), transactions are very cheap and will be for the foreseeable future.

I did everything to keep this tutorial as simple as possible. Now that you understand what is going on, you may want to look at a more sophisticated dapp (using React) check out break and the code.

If you are a hodler, please consider delegating some of your stash to our Solana validator, it helps keep the show on the road.

--

--

Responses (7)