How to Build and Deploy a Solana Smart Contract
If you want to learn how to develop Solana smart contracts and programs, then you’ve come to the right place.
Solana is an emerging high-performance, permissionless blockchain that offers fast, cheap, and scalable transactions and supports smart contracts being built with the Rust, C++, and C programming languages.
In this technical article, we’ll go through examples of writing, deploying, and interacting with smart contracts on the Solana Devnet cluster, as well as how to use Chainlink Price Feeds in your Solana smart contracts.
Solana’s Architecture and Programming Model
Solana is a high-performance blockchain capable of thousands of transactions per second and sub-second block times. It achieves this via a Byzantine Fault Tolerant (BFT) consensus mechanism that makes use of a new innovative cryptographic function called Proof of History.
Proof of History
Proof of History (PoH) establishes a cryptographically verifiable order of events (in this case transactions) over time via the use of a high-frequency verifiable delay function, or VDF. Essentially this means PoH is like a cryptographic clock that helps the network agree on time and ordering of events without having to wait to hear from other nodes. Much like how an ancient water clock can record the passage of time by observing rising water levels, Proof of History’s sequential outputs of constant hashed blockchain state give a verifiable order of events over time.
This helps the performance of the network by allowing the ordered events to then be processed in parallel, as opposed to a traditional blockchain scenario where a single process verifies and bundles up all the transactions to be included in the next block.
A simple analogy would be to imagine a large 100-piece puzzle. In a normal scenario it would take one or more people a certain amount of time to complete the puzzle. But imagine if beforehand the puzzle pieces were all stamped with a number corresponding to their position, from top left to bottom right of the puzzle, and laid out in a line in sequential order. Because the exact order of puzzle pieces and their position in the puzzle is known beforehand, the puzzle can be solved quicker by having multiple people focus on a section each. This is the effect that having a verifiable sequence of events over time has on the consensus mechanism; it allows the processing to be broken up into multiple parallel processes.
Smart Contract Architecture
Solana offers a different smart contract model to traditional EVM-based blockchains. In traditional EVM-based chains, contract code/logic and state are combined into a single contract deployed on-chain. With Solana, a smart contract (or program) is read-only or stateless and contains just program logic. Once deployed, smart contracts can be interacted with by external accounts. The accounts that interact with the programs store data related to program interaction. This creates a logical separation of state (accounts) and contract logic (programs). This is the crucial difference between Solana and EVM-based smart contracts. Accounts on Ethereum are not the same as accounts on Solana. Solana accounts can store data (including wallet information) as opposed to Ethereum accounts, which are references to people’s wallets.
In addition to this, Solana offers a CLI and JSON RPC API that can be used by decentralized applications to interact with the Solana blockchain. They can also use one of the existing SDKs, which allow clients to talk to the blockchain and Solana programs.
High-level representation of the Solana development workflow. Source: Solana documentation
Deploying Your First Solana Smart Contract
In this section, you’ll create and deploy your first ‘hello world’ Solana program, written in Rust.
Requirements
The following should be installed before proceeding:
- NodeJS v14 or greater & NPM
- The latest stable Rust build
- Solana CLI v1.7.11 or later
- Git
The HelloWorld Program
The HelloWorld program is a smart contract that prints output to the console and counts the number of times the program has been called for a given account, storing the number on-chain. Let’s break down the code into separate sections.
The first section defines some standard Solana program parameters and defines an entry point for the program (the ‘process_instruction’ function). In addition to this, it uses borsh for serializing and deserializing parameters being passed to and from the deployed program.
use borsh::{BorshDeserialize, BorshSerialize}; use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint, entrypoint::ProgramResult, msg, program_error::ProgramError, pubkey::Pubkey, }; /// Define the type of state stored in accounts #[derive(BorshSerialize, BorshDeserialize, Debug)] pub struct GreetingAccount { /// number of greetings pub counter: u32, } // Declare and export the program's entrypoint entrypoint!(process_instruction);
The process_instruction function accepts the program_id, which is the public key where the program is deployed to, and accountInfo, which is the account to say hello to.
pub fn process_instruction( program_id: &Pubkey, // Public key of the account the hello world program was loaded into accounts: &[AccountInfo], // The account to say hello to _instruction_data: &[u8], // Ignored, all helloworld instructions are hellos
The ProgramResult is where the main logic of the program resides. In this case, it simply prints a message, then selects the accounts by looping through ‘accounts’. However, in our example there will only be one account.
) -> ProgramResult { msg!("Hello World Rust program entrypoint"); // Iterating accounts is safer then indexing let accounts_iter = &mut accounts.iter(); // Get the account to say hello to let account = next_account_info(accounts_iter)?;
Next, the program checks to see if the account has permission to modify the data for the specified account.
// The account must be owned by the program in order to modify its data if account.owner != program_id { msg!("Greeted account does not have the correct program id"); return Err(ProgramError::IncorrectProgramId); }
Finally, the function takes the existing account’s stored number, increases the value by one, writes the result back, and displays a message.
// Increment and store the number of times the account has been greeted let mut greeting_account = GreetingAccount::try_from_slice(&account.data.borrow())?; greeting_account.counter += 1; greeting_account.serialize(&mut &mut account.data.borrow_mut()[..])?; msg!("Greeted {} time(s)!", greeting_account.counter); Ok(())
Deploying the Program
The first step is to clone the repository.
git clone https://github.com/solana-labs/example-helloworld cd example-helloworld
Once this is done, you can then set your current environment to devnet. This is the test network for Solana developers writing and testing smart contracts.
solana config set --url https://api.devnet.solana.com
Next, you need to create a new keypair for your account. This is required to interact with deployed programs (smart contracts) on the Solana devnet. Take note: this is an insecure method for storing keys and should only be used for demo purposes. You will be prompted to enter in a passphrase for security reasons.
solana-keygen new --force
Now that you’ve created an account, you can use the airdrop program to obtain some SOL tokens. You will need some lamports (fractions of SOL tokens) to deploy your smart contract. This command requests SOL tokens into your newly generated account:
solana airdrop 2
You’re now ready to build the hello world program. You can build it by running the following command
npm run build:program-rust
Compiling the program
Once the program has been built, you can then deploy it to devnet. The previous command’s output will give you the command that you need to run, but it should look something similar to the following:
solana program deploy dist/program/helloworld.so
The end result is that you have successfully deployed the hello world program to devnet with an assigned program Id. This can then be checked on the Solana Devnet explorer.
Deploying the program
Viewing the deployed program on the Devnet explorer
Interacting With the Deployed Program
To interact with the deployed program, the hello-world repository contains a simple client. This client is written in Typescript using the Solana web3.js SDK and the Solana RPC API.
The Client
The client entry point is the main.ts file, which performs a number of tasks in a specific order, most of which are contained within the hello_world.ts file.
First, the client establishes a connection with the cluster by calling the ‘establishConnection’ function
export async function establishConnection(): Promise { const rpcUrl = await getRpcUrl(); connection = new Connection(rpcUrl, 'confirmed'); const version = await connection.getVersion(); console.log('Connection to cluster established:', rpcUrl, version); }
It then calls the ‘establishPayer’ function to ensure there is an account available to pay for transactions, and creates one if required.
export async function establishPayer(): Promise { let fees = 0; if (!payer) { const {feeCalculator} = await connection.getRecentBlockhash(); // Calculate the cost to fund the greeter account fees += await connection.getMinimumBalanceForRentExemption(GREETING_SIZE); // Calculate the cost of sending transactions fees += feeCalculator.lamportsPerSignature * 100; // wag payer = await getPayer(); }
The client then calls the ‘checkProgram’ function, which loads the keypair of the deployed program from ./dist/program/helloworld-keypair.json and uses the public key for the keypair to fetch the program account. If the program doesn’t exist, the client stops with an error. If the program does exist, it will create a new account with the program assigned as its owner to store the program state, which in this case is the number of times the program has been executed.
export async function checkProgram(): Promise { // Read program id from keypair file try { const programKeypair = await createKeypairFromFile(PROGRAM_KEYPAIR_PATH); programId = programKeypair.publicKey; } catch (err) { const errMsg = (err as Error).message; throw new Error( `Failed to read program keypair at '${PROGRAM_KEYPAIR_PATH}' due to error: ${errMsg}. Program may need to be deployed with \`solana program deploy dist/program/helloworld.so\``, ); } // Check if the program has been deployed const programInfo = await connection.getAccountInfo(programId); if (programInfo === null) { if (fs.existsSync(PROGRAM_SO_PATH)) { throw new Error( 'Program needs to be deployed with `solana program deploy dist/program/helloworld.so`', ); } else { throw new Error('Program needs to be built and deployed'); } } else if (!programInfo.executable) { throw new Error(`Program is not executable`); } console.log(`Using program ${programId.toBase58()}`); // Derive the address (public key) of a greeting account from the program so that it's easy to find later. const GREETING_SEED = 'hello'; greetedPubkey = await PublicKey.createWithSeed( payer.publicKey, GREETING_SEED, programId, );
The client then builds up and sends a ‘hello’ transaction to the program by calling the ‘sayHello’ function. The transaction contains an instruction that holds the public key of the helloworld program account to call, and the account to which the client wishes to say hello to. Each time the client performs this transaction to an account, the program increments a count in the destination account’s data storage.
export async function sayHello(): Promise { console.log('Saying hello to', greetedPubkey.toBase58()); const instruction = new TransactionInstruction({ keys: [{pubkey: greetedPubkey, isSigner: false, isWritable: true}], programId, data: Buffer.alloc(0), // All instructions are hellos }); await sendAndConfirmTransaction( connection, new Transaction().add(instruction), [payer], ); }
Finally, the client queries the account’s data to retrieve the current number of times the account has had the sayHello transaction called, by calling ‘reportGreetings’
export async function reportGreetings(): Promise { const accountInfo = await connection.getAccountInfo(greetedPubkey); if (accountInfo === null) { throw 'Error: cannot find the greeted account'; } const greeting = borsh.deserialize( GreetingSchema, GreetingAccount, accountInfo.data, ); console.log( greetedPubkey.toBase58(), 'has been greeted', greeting.counter, 'time(s)', );
Running the Client
Before you can run the client to read data from your deployed program, you need to install the client dependencies.
npm install
Once this is done, you can start the client.
npm run start
You should see output showing your program successfully being executed, and it should display the number of times the account has been greeted. Subsequent runs should increase this number.
Starting the Hello World client to interact with the deployed program
Congratulations, you’ve deployed and interacted with a Solana smart contract on the devnet network! Now we’ll dive into another example Solana program and client, except this time we’ll make use of Chainlink Price Feeds.
Chainlink Price Feeds on Solana
The ecosystem of DeFi apps on Solana is growing at an accelerating rate. In order to power basic DeFi mechanisms and execute key on-chain functions, such as issuing loans at fair market prices or liquidating undercollateralized positions, these dApps need access to highly reliable and high-quality market data.
Solana has integrated Chainlink Price Feeds on the Solana Devnet, offering developers highly decentralized, high-quality, high-speed price reference data updates for building hybrid smart contracts.
When combined with Solana’s ability to support up to 65,000 transactions per second and its extremely low transaction fees, Chainlink Price Feeds have the potential to empower DeFi protocol infrastructure that can compete with traditional financial systems in terms of trade execution and risk management quality.
In this next code example, we’ll deploy and interact with a Solana program that makes use of Chainlink Price Feeds on the Solana devnet cluster.
Requirements
- NodeJS v14 or greater & NPM
- The latest stable Rust build
- Solana CLI v1.7.11 or later
- Git
The Chainlink Solana Starter Kit
The Solana Starter Kit is a pre-packaged repository that contains a smart contract that simply connects to a Chainlink Price Feed account on devnet, and retrieves and stores the latest price of the specified price pair into an account, which is then read by an off-chain client. The starter kit uses the Anchor framework, a framework that helps abstract some of the complexities with building smart contracts on Solana.
Chainlink Price Feeds on Solana all utilize a single program, with each individual price feed being a separate account that stores price updates using the program. The way this demo works is an off-chain client passes in an account to the on-chain program, the on-chain program then obtains the latest price for the specified price feed, and stores the value in the passed in account. Finally, the off-chain client then reads the price data from the account.
The first section of the the smart contract consists of normal includes and declaring using the Anchor framework. It also declares the program ID as required by Anchor. In addition to this, a struct is defined for storing price feed answers into the passed in account, as well as a f
m
t
𝑓𝑚𝑡
function for formatting the price data retrieved.
use anchor_lang::prelude::*; use anchor_lang::solana_program::system_program; use chainlink_solana as chainlink; declare_id!("JC16qi56dgcLoaTVe4BvnCoDL6FhH5NtahA7jmWZFdqm"); #[account] pub struct Decimal { pub value: i128, pub decimals: u32, } impl Decimal { pub fn new(value: i128, decimals: u32) -> Self { Decimal { value, decimals } } } impl std::fmt::Display for Decimal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut scaled_val = self.value.to_string(); if scaled_val.len() <= self.decimals as usize { scaled_val.insert_str( 0, &vec!["0"; self.decimals as usize - scaled_val.len()].join(""), ); scaled_val.insert_str(0, "0."); } else { scaled_val.insert(scaled_val.len() - self.decimals as usize, '.'); } f.write_str(&scaled_val) } }
Next, the program declares a c
h
a
∈
l
∈
k
s
o
l
a
n
a
d
e
m
o
𝑐ℎ𝑎∈𝑙∈𝑘𝑠𝑜𝑙𝑎𝑛𝑎𝑑𝑒𝑚𝑜
module to be used for obtaining price data, which contains the e
x
e
c
u
t
e
𝑒𝑥𝑒𝑐𝑢𝑡𝑒
function.
The e
x
e
c
u
t
e
𝑒𝑥𝑒𝑐𝑢𝑡𝑒
function is the part of the program that contains the main logic for the smart contract. It retrieves the latest round data from the specified feed address by calling the l
a
t
e
s
t
r
o
u
n
d
d
a
t
a
𝑙𝑎𝑡𝑒𝑠𝑡𝑟𝑜𝑢𝑛𝑑𝑑𝑎𝑡𝑎
function from the chainlink-solana package, which gets imported from GitHub via the Cargo.toml file. The function finishes by formatting and storing the price against the specified account passed into the program, and writing the result out to the program output.
#[program] pub mod chainlink_solana_demo { use super::*; pub fn execute(ctx: Context) -> ProgramResult { let round = chainlink::latest_round_data( ctx.accounts.chainlink_program.to_account_info(), ctx.accounts.chainlink_feed.to_account_info(), )?; let description = chainlink::description( ctx.accounts.chainlink_program.to_account_info(), ctx.accounts.chainlink_feed.to_account_info(), )?; let decimals = chainlink::decimals( ctx.accounts.chainlink_program.to_account_info(), ctx.accounts.chainlink_feed.to_account_info(), )?; // Set the account value let decimal: &mut Account = &mut ctx.accounts.decimal; decimal.value=round.answer; decimal.decimals=u32::from(decimals); // Also print the value to the program output let decimal_print = Decimal::new(round.answer, u32::from(decimals)); msg!("{} price is {}", description, decimal_print); Ok(()) }
Deploying the Program
The first step is to clone the repository.
git clone https://github.com/smartcontractkit/solana-starter-kit cd solana-starter-kit
Once this is done, you can install the required dependencies, including the Anchor framework.
npm install
Note for Apple M1 chipsets: You will need to perform an extra step to get the Anchor framework installed manually from source, as the NPM package only support x86_64 chipsets currently, please run the following command to install it manually:
cargo install --git https://github.com/project-serum/anchor --tag v0.20.1 anchor-cli --locked
Next, you need to create a new keypair for your account. This is required to interact with deployed programs (smart contracts) on the Solana devnet cluster. Again, this is an insecure method for storing keys, and should only be used for demo purposes. You will be prompted to enter a passphrase for security reasons.
solana-keygen new -o id.json
Now that you’ve created an account, you can use the airdrop program to obtain some SOL tokens. You will need some lamports (fractions of SOL tokens) to deploy your program. This command requests SOL tokens for your newly generated account. We will need to call this twice, because the Devnet faucet is limited to 2 SOL, and we need approximately 4 SOL.
solana airdrop 2 $(solana-keygen pubkey ./id.json) --url https://api.devnet.solana.com && solana airdrop 2 $(solana-keygen pubkey ./id.json) --url https://api.devnet.solana.com
You’re now ready to build the Chainlink Solana Demo program using the Anchor framework!
anchor build
Building the program with Anchor
The build process generates the keypair for your program’s account. Before you deploy your program, you must add this public key to your lib.rs file, as it’s it’s required by programs that make use of Anchor. To do this, you need to get the keypair from the ./target/deploy/chainlink_solana_demo-keypair.json file that Anchor generated using the following command:
solana address -k ./target/deploy/chainlink_solana_demo-keypair.json
The next step is to edit the lib.rs file and replace the keypair in the declare_id!() definition with the value you obtained from the previous step:
declare_id!("JC16qi56dgcLoaTVe4BvnCoDL6FhH5NtahA7jmWZFdqm");
Next, you also need to insert the obtained Program ID value into the Anchor.toml file in the chainlink_solana_demo devnet definition
[programs.devnet] chainlink_solana_demo = "JC16qi56dgcLoaTVe4BvnCoDL6FhH5NtahA7jmWZFdqm"
Finally, because you updated the source code with the generated program ID, you need to rebuild the program again, and then it can be deployed to devnet
anchor build anchor deploy --provider.cluster devnet
Deploying the program with Anchor
Once you have successfully deployed the program, the terminal output will specify the program ID of the program, it should match the value you inserted into the lib.rs file and the Anchor.toml file. Once again, take note of this Program ID, as it will be required when executing the client. This can then be checked on the Solana Devnet explorer.
Viewing the deployed program on the Solana Devnet explorer
Interacting With the Deployed Program
To interact with the deployed program, the Chainlink Solana Demo repository contains a client written in JavaScript using the Anchor framework and the Solana web3 API.
The Client
The client is a JavaScript script that takes two parameters, a deployed program ID, and a Price Feed account address. It then creates a connection to the deployed program and passes in the specified price feed account and an account to store the retrieved price data. Once the on-chain program execution has completed, it simply reads the price data from the account that was passed into the program.
First, the client establishes a connection with the cluster using Anchor and sets up some required parameters, such as pulling in command line arguments and setting of default values:
const args = require('minimist')(process.argv.slice(2)); // Initialize Anchor and provider const anchor = require("@project-serum/anchor"); const provider = anchor.Provider.env(); // Configure the cluster. anchor.setProvider(provider); const CHAINLINK_PROGRAM_ID = "HEvSKofvBgfaexv23kMabbYqxasxU3mQ4ibBMEmJWHny"; const DIVISOR = 100000000; // Data feed account address // Default is SOL / USD const default_feed = "HgTtcbcmp5BeThax5AU8vg4VwK79qAvAKKFMs8txMLW6"; const CHAINLINK_FEED = args['feed'] || default_feed;
After this, the main function of the client is defined. This is the function that does everything described above. First, it connects to our deployed program using the Anchor framework. It then generates a new account and calls the e
x
e
c
u
t
e
𝑒𝑥𝑒𝑐𝑢𝑡𝑒
function in the deployed program, passing in the following details:
- The account created to store the price data
- The account of the user running the client (to pay transaction fees)
- The specified price feed account address
- The program ID of the Chainlink Price Feeds on devnet. This is a static value that doesn’t need to be modified
- The Solana system program account address. Once again, this value is static
async function main() { // Read the generated IDL. const idl = JSON.parse( require("fs").readFileSync("./target/idl/chainlink_solana_demo.json", "utf8") ); // Address of the deployed program. const programId = new anchor.web3.PublicKey(args['program']); // Generate the program client from IDL. const program = new anchor.Program(idl, programId); //create an account to store the price data const priceFeedAccount = anchor.web3.Keypair.generate(); console.log('priceFeedAccount public key: ' + priceFeedAccount.publicKey); console.log('user public key: ' + provider.wallet.publicKey); // Execute the RPC. let tx = await program.rpc.execute({ accounts: { decimal: priceFeedAccount.publicKey, user: provider.wallet.publicKey, chainlinkFeed: CHAINLINK_FEED, chainlinkProgram: CHAINLINK_PROGRAM_ID, systemProgram: anchor.web3.SystemProgram.programId }, options: { commitment: "confirmed" }, signers: [priceFeedAccount], });
The client then waits to get a confirmed transaction and then outputs the program output onto the console. This displays information about the transaction, including the printed output from the on-chain program. Finally, the program reads the price data from the priceFeed account that was passed into the program, and then outputs the value to the console.
console.log("Fetching transaction logs..."); let t = await provider.connection.getConfirmedTransaction(tx, "confirmed"); console.log(t.meta.logMessages); // #endregion main // Fetch the account details of the account containing the price data const latestPrice = await program.account.decimal.fetch(priceFeedAccount.publicKey); console.log('Price Is: ' + latestPrice.value / DIVISOR)
Running the Client
The first step is to set the Anchor environment variables. These are required by the Anchor framework to determine which Provider to use, as well as which Wallet to use for interacting with the deployed program. In this case, we’re setting the value to the default devnet provider URL, and the wallet that we created earlier:
export ANCHOR_PROVIDER_URL='https://api.devnet.solana.com' export ANCHOR_WALLET='./id.json'
Once this is done, we are ready to run the JavaScript client. Be sure to pass the program ID obtained from the previous steps by using the –program flag linking to the json file containing the account that owns the program. We also need to pass in the Chainlink feed address that we wish to query. This can be taken from the Chainlink Solana Feeds page, and the value will be defaulted to the devnet SOL/USD feed address if you don’t specify a value. In this example, we’re explicitly specifying the SOL/USD feed to show how the feed parameter works:
node client.js --program $(solana address -k ./target/deploy/chainlink_solana_demo-keypair.json) -–feed HgTtcbcmp5BeThax5AU8vg4VwK79qAvAKKFMs8txMLW6
You should see output showing the client successfully being executed, and it should display the latest price from the specified price feed, stored in the new account:
Executing the client
Summary
Solana offers a high-speed, low-cost, scalable blockchain for building smart contracts and decentralized applications. By leveraging Solana smart contracts and Chainlink Price Feeds, developers can create fast, scalable DeFi applications, taking advantage of the high-quality data offered by Chainlink Price Feeds and the high-speed updates available on the Solana blockchain.