Solana Simplified 02: Writing Your First Solana Smart Contract from Scratch

Master writing Solana programs with just this one article! Covering everything from environment setup, contract logic, to program testing.

Solana Simplified 02: Writing Your First Solana Smart Contract from Scratch

Introduction

In 2024, Solana surged to prominence, becoming the fourth-ranked public blockchain by Total Value Locked (TVL), capturing the interest of investors and developers.

BlockSec has curated the "Solana Simplified" series, which includes articles on Solana's basic concepts, tutorials on writing Solana smart contracts and guides for analyzing Solana transactions. The goal is to help readers understand the Solana ecosystem and master essential skills for developing projects and conducting transactions on Solana.

In the first article of this series, we delved into key concepts of the Solana network, including its operating mechanism, account model, and transactions, laying a solid foundation for writing correct and high-performance Solana contracts.

In this article, we will guide you through writing a Solana program (i.e., a Solana smart contract) for posting and displaying articles. This will help solidify the concepts from the first article and introduce some features about Solana we haven't yet discussed. The relevant program and test code have been published on GitHub.

Set Up the Environment

Note: The following commands only covers the Ubuntu system. Some commands may not work under Windows and macOS systems.

👉 You can use alternative commands or refer to Setup local development and install the Solana CLI to solve this.

We will compile and deploy Solana programs in the local environment. Before diving into Solana program development, we need to install some commands and dependencies.

Rust

Solana programs are predominantly written in the Rust programming language, so we need to execute the following command to install Rust:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

node.js & TypeScript

The test script is wirrten in TypeScript, so we need to install Node.js and TypeScript toolchain:

curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs npm
npm install -g typescript
npm install -g ts-node

Solana CLI

Next, we need to install the Solana CLI tool, which provides commands for tasks such as creating wallets, deploying contracts:

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

Run the following command to apply the changes to the current terminal:

source ~/.profile

You can execute solana --version to verify that the Solana CLI has been successfully installed:

$ solana --version
solana-cli 1.17.25 (src:d0ed878d; feat:3580551090, client:SolanaLabs)

Local Wallet

Next, execute the following command to generate a local file system wallet:

solana-keygen new

By default, this command creates a private key file at ~/.config/solana/id.json, which we will use in the test script later. You can use the solana address command to see the address generated by it:

$ solana address
9FcGDHvzs8Czo4B1pQq8GPdXfdGHqNqayvh6bu4hVGfy

Devnet

The program will be deployed on devnet, so we need to execute the following command to switch the Solana CLI to devnet:

solana config set --url https://api.devnet.solana.com

SOL Tokens

Since deploying contracts and sending testing transactions are not free, we need to request some SOL tokens. You can execute solana airdrop 2 or directly request tokens from the public web faucet.

Write the Program

The program we are going to introduce allows users to post articles and list all currently posted articles. It handles three types of instructions:

  • init instruction: Initializes the program by creating a data account to store the current maximum index of articles.
  • post instruction: Stores a posted article in a new data account.
  • list instruction: Prints all posted articles in the log.

Below is the structure of the program:

$ tree program/
program/
├── Cargo.lock
├── Cargo.toml
└── src
    ├── instructions
    │   ├── init.rs
    │   ├── list.rs
    │   ├── mod.rs
    │   └── post.rs
    ├── lib.rs
    └── processor.rs

Cargo.toml specifies the external libraries for the project:

[dependencies]
borsh = "0.10.0"
borsh-derive = "0.10.0"
solana-program = "=1.17.25"

Note: The version of solana-program should match the version of Solana CLI installed (you can check this using solana --version). Otherwise, you may encounter errors during compilation.

The entry function of the contract is defined in processor.rs. The file starts by importing required dependencies (we will skip similar parts in other Rust files):

Next follows the definition of the contract's entry function:

In line 10 of the program, the macro entrypoint! from the Solana library specifies that the entry function is process_instruction. This function takes three parameters:

  • program_id: The deployed address of the current program.
  • accounts: All accounts involved in the instruction.
  • instruction_data: The byte array used for processing instructions.

process_instruction extracts the first byte of instruction_data to identify the type of instruction and calls corresponding function to handle it. Next, we will look at how these three functions are implemented.

init

Each of the three functions is defined in a file with the same name under the program/src/instructions directory. Let's start with init.rs.

Since the init instruction does not require an additional byte array, it only accepts two parameters: program_id and accounts.

Between lines 12 and 15, the program extracts the required account information sequentially, then calls the find_program_address function to compute the address of the data account used to store the current maximum article index, index_pda_key. Then the program asserts that index_pda_key equals to the address of index_pda.

Here, The address of index_pda is a special address called PDAs (Program Derived Addresses). Unlike "wallet" account addresses generated from public keys, PDAs are derived from an optional byte array, a byte called "bump", and the address of a program. The byte array and program address are explicitly provided by the caller, while the bump is automatically generated and returned by the find_program_address function. The bump ensures that addresses created in this way cannot collide with addresses generated by public key.

After confirming that the address of index_pda is expected, we use the create_account instruction provided by the SystemProgram to create index_pda. In line 22, the program creates a variable of type IndexAccountData, which records the current maximum index of articles (initialized to 0). As shown in the figure below, this type implements the BorshSerialize and BorshDeserialize traits, allowing it to be serialized and deserialized in Borsh format:

Lines 23 to 24 calculate the required space for storing the account data and the minimum rent for creating the account.

Lines 27 to 37 create a create_account instruction from the SystemProgram and invoke it using the invoke_signed method. The action of invoking an instruction to another program within one program is referred to as CPI (Cross-Program Invocation). In Solana, you can use two functions to perform CPI: invoke and invoke_signed.

The invoke function directly processes the given instruction, and its function signature is as follows:

pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>

The only difference between invoke_signed and invoke is that invoke_signed provides a way to sign for PDA accounts. PDA accounts do not have public-private keys and therefore cannot provide signatures directly. invoke_signed solves this problem. This function accepts an additional parameter called signers_seeds. Its function signature is as follows.

Similar to how the find_program_address function works, invoke_signed computes a set of PDA addresses based on the provided signers_seeds and the caller's program_id. If account_infos contains these PDA addresses, they will be marked as signers, as if they have provided signatures.

pub fn invoke_signed(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>],
    signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>

Here, since the create_account instruction requires the account being created to be a signer, we have to use invoke_signed to provide a signature for index_pda.

At the end of the program, the contract calls the serialize method on index_data to serialize the data and write it into the data field of index_pda.

post_article

Next let's look at the implementation of the post instruction. Unlike the init instruction, the post instruction accepts a posted article, so it has an additional parameter called instruction_data_inner, which stores the serialized data of the article.

As usual, the program begins with extracting the account information required by the instruction. Since we generate a separate PDA account for each article, the program accepts an additional account, new_article_pda, on line 23.

Similar to the init instruction, lines 27 to 30 use find_program_address to verify that the passed index_pda's address is correct. We then deserialize the data in its data field to read the current maximum index allocated.

Lines 33 to 40 also verify the address of new_article_pda. The program uses the string “ARTICLE_PDA” and the current index to generate a new address. The index increments with each article upload, which ensures the generated address is unique.

Next, the program deserializes instruction_data_inner into article data, which is of type PostArticleData:

This structure has only two fields: title and content. On lines 45 to 49, the program performs a check to ensure that the article is not too large to be contained within a single account.

The following steps are also similar to the init instruction: The program first calculates the required space and rent for the account, then it invokes the create_account instruction of the SystemProgram to create the PDA account for storing the article. Finally, the posted article is serialized into the data field of new_article_pda, and the current maximum index is incremented by one and serialized into the data field of index_pda.

list_articles

Finally, let's see the implementation of the list instruction. Because this instruction needs to list all articles, a vector is used on line 15 to represent all the PDA accounts storing the articles, and the index_pda account's address is also validated.

After that, the program verifies that the vector's size is the same as the current maximum index and checks the correctness of each account's address.

At the end of the program, it iterates over each account and uses the msg! macro from solana-program to output the deserialized article content one by one.

Transaction Testing

To test our contract, we use a TypeScript script located at client/main.ts in the repository.

At the top of the script, we import all the necessary libraries and define three global constants. KEYPAIR_PATH indicates the path to the private key file generated using solana-keygen new, PROGRAM_ID is the address of the deployed program we have written in the last section, and POST_ARTICLE_SCHEMA is the object used for article serialization.

In the main body of the script, it first calls the loadKeyFromFile function to parse the private key file and obtain a Keypair object as the payer for transaction fees. Then, it uses the findProgramAddressSync method provided by @solana/web3.js to calculate the address of indexPda. This method uses the same algorithm as the Rust contract, ensuring that it computes the same address with the same parameters. The connection object specifies that we will use the devnet for testing.

Next, we send the first initialization transaction. We create a transaction that contains a single instruction. The data field of this instruction contains only one byte "0", indicating that this is an invocation to the init instruction. The transaction is then sent and confirmed using sendAndConfirmTransaction.

The second transaction invokes the post instruction to post some articles. From lines 56 to 63, the script serializes two articles and calculates the corresponding PDA addresses. Then two post instructions are added to a single transaction to create these articles.

The final transaction invokes the list instruction, and then the logs are printed out after the transaction is confirmed.

To test the program with the script, replace the KEYPAIR_PATH with the path to your private key (if it is not the default path). Next, compile and deploy the Rust smart contract by executing the following commands:

cd program
cargo build-bpf
solana program deploy ./target/deploy/hello_solana.so

Then, copy the deployed address and paste it into the PROGRAM_ID field. In the root directory of the project, run npm install to install the dependencies, and then run npm start to execute the script.

After that, we can use Solscan to view the execution details of the testing transactions.

Let's directly look at the second transaction for publishing articles. It contains two instructions, each with the first byte of the Instruction Data Raw being 0x01. Additionally, each instruction includes an internal instruction, which is an invocation to the CreateAccount instruction of the System Program.

Similarly, we can inspect the third transaction for listing all articles. In the Program Logs section, we can find out that the indices, titles, and contents of the two articles are printed out.

Conclusion

In this article, we first explained how to set up the Solana programming and runtime environment locally. Then, we detailed the implementation logic of a Solana contract. Finally, we tested the contract functionalities using a TypeScript script. With this step-by-step tutorial, we believe you have learned how to write a simple Solana smart contract 🥳.

In the next article, we will provide a detailed guide on how to view and analyze Solana transactions using Phalcon Explorer (Phalcon Explorer now supports Solana). Stay tuned!

Read other articles in this series:

Sign up for the latest updates