This tutorial will take you from in building on the . I’ll guide you through the entire process of developing on Solana by building an on-chain program using Rust and deploying it to the Solana test net. We’ll also interact with the on-chain program using the Solana/web3js Javascript API. zero to one Solana network Unlike most Solana tutorials, I won’t tell you to go learn Rust on your own. I’ll walk you through various Rust concepts that are necessary to understand the code and also point you to the best resources. Prerequisites Basic familiarity with a command-line interface Basic programming knowledge in any language. Requirements You'll need the following installed before we proceed: version 14+ node git Github account Rust - install ; it's pretty straightforward. from here Solana tool suite - follow to install. this simple guide Programming on Solana - Something You Should Know Before we start coding our program, we must have an overview of what's building on Solana is like. Unlike other blockchains, in Solana, smart contracts are called Programs. Solana programs are compiled to a variation of bytecode known as . Solana uses BPF because it allows compilation, which is great for performance. Berkley Packet Filter (BPF) just-in-time(JIT) When called, a program must be passed to something called a which is responsible for loading and executing BPF programs. All programs export an entrypoint that the runtime looks up and calls when invoking a program. BPF loader Because programs are written to target the BPF loader, they can be written in any language that compiles to BPF bytecode. Solana currently supports writing programs in Rust and C/C++. Our Solana Program Solana has a nice that shows us how to build a Rust program on Solana from scratch and interact with it using a typescript SDK. hello-world example The example comprises of: An on-chain hello world program A client that can send a "hello" to an account and get back the number of times "hello" has been sent. We'll leverage this example to learn how to build our programs. Open your CLI and run the following command to clone the repo. git clone https://github.com/solana-labs/example-helloworld.git then: cd example-helloworld Open the project in your IDE. In the src folder, you'll find two ways to build the program. One uses the C language while the other uses Rust. Since we are building with Rust, go ahead and open the program-rust folder and ignore program-c. There is also a client folder, but we'll get to it later. For now, we are interested in inside the program-rust . The code looks like this: lib.rs src 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); // Program entrypoint's implementation 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 ) -> 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)?; // 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); } // 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(()) } //Minus the tests. There's a lot of awesome things going on in the above code. Let's go through it line by line, as I promised. Rust allows us to build on code written by others using . A crate can contain several modules, and we specify the modules we want to bring into scope. First, we bring the crates we need via a declaration. is like in JS or in C. This is our first use declaration: crates use Use import includes use borsh::{BorshDeserialize, BorshSerialize}; We specify we'll need **and from the crate . The double-colon is the path separator. Borsh is a binary serialization format. It is designed to serialize any objects to a canonical and deterministic set of bytes. is used for converting data(structs, ints, enums, etc.) into bytecode while reconstructs the bytecode into data. Serializing is necessary because the programs must be parsed in BPF format. BorshDeserialize BorshSerialize borsh :: BorsheSerialize BorsheDeserialize The next declaration brings the crate into the scope. This crate contains a bunch of Solana source code that we'll leverage to write on-chain programs. use solana_program use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint, entrypoint::ProgramResult, msg, program_error::ProgramError, pubkey::Pubkey, }; Let's discuss what each item we brought from the crate does: solana_program contains which is a public function that returns the next or a error. is a public struct(we'll discuss structs in a few) that contains the account's information - like the Pubkey and owner. You can view the source code . account_info next_account_info AccountInfo NotEnoughAccountKeys AccountInfo here In , we have an that we'll later use to call our program. Macros are a way of writing code that writes other code. They reduce the amount of code you need to write! We have different forms of macros, the we just brought into scope is known as a because it allows us to define syntax extension in a declarative way. entrypoint entrypoint! macro entrypoint declarative macro We then bring which also lives in the as the entrypoint macro. It's a type that returns if the program runs well or if the program fails. is an enum in Rust which is defined as having two variants, and . We use it for error handling. ProgramResult same file Result Ok ProgramError Result Ok Err is a macro that's used for logging in Solana. If you have programmed in Rust before, you may be used to the macro but Solana considers it computationally expensive. msg println! allows you to implement program-specific error types and see them returned by the Solana runtime. ProgramError Lastly, we bring in the struct from . We'll use it to pass the public keys of our accounts. Pubkey pubkey One more thing I would like you to note is that whenever we bring a crate, we must also specify so in cargo.toml like so: [dependencies] borsh = "0.9.1" borsh-derive = "0.9.1" solana-program = "1.7.9" is Rust's package manager, like npm in JS. In that line of thought, cargo.toml is analogous to package.json. Cargo After the use declarations, here is what we have next: #[derive(BorshSerialize, BorshDeserialize, Debug)] pub struct GreetingAccount { /// number of greetings pub counter: u32, } belongs to another group of macros known as procedural macros. Deriving tells the compiler to provide some basic implementations for some traits. Besides the serialize and deserializing traits, we also derive the trait. In Rust, traits allow us to share behavior across non-abstract types like structs and facilitates code reuse. They are like interfaces in other languages. trait makes types like structs and enums printable. #[derive] Debug Debug Next, we declare the using the keyword, which makes it accessible so other programs can use it. By default, everything in Rust is private, with two exceptions: Associated items in a pub Trait and Enum variants in a pub enum. A or structure is a custom data type that allows us to package related values. Each field defined within a struct has a name and a type. has only one field: with a type of , an unsigned(positive) 32-bit integer. GreetingAccount struct pub publicly struct GreetingAccount counter u32 Program Entrypoint. All Solana programs must have an that the runtime looks up and calls when invoking a program. The macro declares as the entry to our program. entrypoint entrypoint! process_instruction An instruction specifies which program it is calling, which accounts it wants to read or modify, and additional data. // Declare and export the program's entrypoint entrypoint!(process_instruction); We implement via a function with visibility set to public: process_instruction // Program entrypoint's implementation 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 ) -> ProgramResult { //snip } You may have noticed that each parameter has an ampersand operator . This is because Solana programs do not store data; data is stored in accounts. The ampersand tells Rust that we do not own this data; we're just borrowing it; we call this . & referencing is the public key of the currently executing program accounts. When you want to call a program, you must also pass this id, so that Solana knows which program is to be executed. program_id is a reference to an array of accounts to say hello to. It is the list of accounts that will be operated upon in this code. accounts - any additional data passed as a u8 array. In this program, we won't be consuming this data because it's just hellos, so we add the _underscore to tell the compiler to chill. _instruction_data pub fn process_instruction( //params ) -> 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)?; // snip } The function returns which we imported earlier. ProgramResult is of Result type, which is an Enum with two variants: Ok representing success and containing a value, and Err representing error and containing an error value. ProgramResult will give as an Ok() as a success if our is processed or a ProgramError if it fails. ProgramResult instruction We use the macro for printing messages on the program log. msg! We create a new variable using the let keyword. We over each account using the method and bind them to the variable as . Rust are immutable by default, so we have to specify that we want to be able to write to each account by adding the keyword. As I mentioned, will return the account we want to say hello to or an error if it doesn't find an account. It's able to do this because the function returns the type we talked of earlier. The hides some of the boilerplate of propagating errors. accounts_iter iterate iter() mutable references references mut next_account_info Result question mark operator ? Only the program that owns the account should be able to modify its data. This check ensures that if the public key does not equal the we will return an error. account.owner program_id IncorrectProgramId // 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); } Lastly, here's what we have... // 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(()) Rust variables are immutable by default, even when declared with the keyword. Therefore, to create a variable that we'll modify, we have to add the mut keyword, just like we did with references. try_from_slice() is a method from the borsh crate that we use to deserialize an instance from slice of bytes to actual data our program can work with. Under the hood, it looks like this: let fn try_from_slice(v: &[u8]) -> Result<Self> Try_from_slice could also return an error if the deserialization fails - note the operator because it implements the type. We use the actual account data we borrowed to get the counter value and increment it by one and send it back to the runtime in serialized format. ? Result We then print in the Program Log how many times the count has been incremented by using the msg!() macro. Configuring Solana CLI First, make sure you have Solana installed: solana --version solana-cli 1.7.11 (src:bdb77b0c; feat:1140394761) In Solana, a set of validators make up a cluster. We've three clusters: mainnet, devnet, and localhost. For our purposes, we'll use the local cluster. Let's set the CLI config to the localhost cluster using the command. config set solana config set --url localhost The output should resemble this: solana config set --url localhost Config File: /home/kelvin/.config/solana/cli/config.yml RPC URL: http://localhost:8899 WebSocket URL: ws://localhost:8900/ (computed) Keypair Path: /home/kelvin/.config/solana/id.json Commitment: confirmed Create CLI Keypair If this is your first time using the Solana CLI, you will need to generate a new keypair: solana-keygen new This is the expected output: Generating a new keypair For added security, enter a BIP39 passphrase NOTE! This passphrase improves security of the recovery seed phrase NOT the keypair file itself, which is stored as insecure plain text BIP39 Passphrase (empty for none): Wrote new keypair to /home/kelvin/.config/solana/id.json ============================================================================ pubkey: 2ab2mQwRzTYCoXThK4mi8M7fTfGzV48ftpE3xNJvKem3 ============================================================================ Save this seed phrase and your BIP39 passphrase to recover your new keypair: make museum conduct seven dose glide recipe bring film differ excite chapter ============================================================================ Note that you should publish your seed phrase to the internet. I am doing this for educational purposes only and will never use this keypair again. never Start Local Solana Cluster This example connects to a local Solana cluster by default. Start a local Solana cluster: solana-test-validator Expected output: solana-test-validator Ledger location: test-ledger Log: test-ledger/validator.log Identity: GZr7zHFUxA7kjGgzUsUuRfQtNASBCGurynEg7yUDcfvP Genesis Hash: F945qQyeHDUXN58eUWuLHLogAZ7Qgkpucc7xe8LisQnR Version: 1.6.9 Shred Version: 54687 Gossip Address: 127.0.0.1:1025 TPU Address: 127.0.0.1:1027 JSON RPC URL: http://127.0.0.1:8899 ⠒ 00:00:08 | Processed Slot: 16 | Confirmed Slot: 16 | Finalized Slot: 0 | Snapshot Slot: And the log monitor in another terminal: $ solana logs Streaming transaction logs. Confirmed commitment We don't see any logs yet because we have not deployed our program. Build the On-Chain Program Open a third terminal to build our Rust version of the on-chain program: npm run build:program-rust If the build is successful, you'll get a message informing that you should now deploy your program. To deploy this program: $ solana program deploy /dist/program/helloworld.so Deploy the On-Chain Program solana program deploy dist/program/helloworld.so You should see something like this: solana program deploy dist/program/helloworld.so Program Id: CixWRTY8QcWP6F2maA9uhPLcJuch7njckQswwP5dTx9z Now, if you go back to the log terminal, you should see a stream of transaction logs. That's it with the on-chain program. Let's see how to interact with it and send transactions from the clientside! Building the Clientside To interact with a Solana node inside a JavaScript application, we use the Solana-web3.js library, which gives a convenient interface for the RPC methods. Now that we have an onchain program, let's see how we can make calls to the blockchain. In the helloworld program folder, run the following command to install the dependencies the client needs cd helloworld-example npm install The client has three typescript files: , , . We define our functions in and export them to , which is the client's entrypoint while is mostly for configurations. Here's how utils.ts looks like, without the imports. hello_world.ts main.ts utils.ts hello_world.ts main.ts utils.ts async function getConfig(): Promise<any> { // Path to Solana CLI config file const CONFIG_FILE_PATH = path.resolve( os.homedir(), '.config', 'solana', 'cli', 'config.yml', ); const configYml = await fs.readFile(CONFIG_FILE_PATH, {encoding: 'utf8'}); return yaml.parse(configYml); } //Load and parse the Solana CLI config file to determine which RPC url to use export async function getRpcUrl(): Promise<string> { try { const config = await getConfig(); if (!config.json_rpc_url) throw new Error('Missing RPC URL'); return config.json_rpc_url; } catch (err) { console.warn( 'Failed to read RPC url from CLI config file, falling back to localhost', ); return 'http://localhost:8899'; } } //Load and parse the Solana CLI config file to determine which payer to use export async function getPayer(): Promise<Keypair> { try { const config = await getConfig(); if (!config.keypair_path) throw new Error('Missing keypair path'); return await createKeypairFromFile(config.keypair_path); } catch (err) { console.warn( 'Failed to create keypair from CLI config file, falling back to new random keypair', ); return Keypair.generate(); } } // Create a Keypair from a secret key stored in file as bytes' array export async function createKeypairFromFile( filePath: string, ): Promise<Keypair> { const secretKeyString = await fs.readFile(filePath, {encoding: 'utf8'}); const secretKey = Uint8Array.from(JSON.parse(secretKeyString)); return Keypair.fromSecretKey(secretKey); } The function returns the path to the Solana CLI configuration file. In , we determine which RPC we're using, and since we're on the local cluster, we return our localhost URL, which is Solana's default 8899. getConfig getRpcUrl Whenever you are sending transactions to Solana asking it to execute your instructions, like saying hello to another account - you must pay some lamports for execution. Lamport refers to the smallest denominational unit of Sol tokens(like wei in ethereum). If the lamports are in your account, you need to sign the transaction with your private key so no one else can spend your lamports. This private key is stored in your local filesystem as an array of bytes. The function decodes this array and returns it as a Keypair using the method to us by the JSON rpc API. The function returns a Keypair that is debited everytime we make a transaction. createKeypairFromFile fromSecretKey provided getPayer Now that we have our configurations in place, let's look at the code in hello_world.ts, which is where the juicier stuff is. I have not included the imports and some declarations because the comments in the code are sufficient explanations. // The state of a greeting account managed by the hello world program class GreetingAccount { counter = 0; constructor(fields: {counter: number} | undefined = undefined) { if (fields) { this.counter = fields.counter; } } } //Borsh schema definition for greeting accounts const GreetingSchema = new Map([ [GreetingAccount, {kind: 'struct', fields: [['counter', 'u32']]}], ]); //The expected size of each greeting account. const GREETING_SIZE = borsh.serialize( GreetingSchema, new GreetingAccount(), ).length; We create a typescript class that we'll use to represent our account's data. Remember the struct we created in Rust? pub struct GreetingAccount { pub counter: u32, } The GreetingSchema constant maps the GreetingAccount from the client-side to the struct in our Rust program. tell the schema that we are mapping GreetingAccount to a type struct. refers to to the name of the elements in the struct and it's type. We need to pass fields as an array because we could have multiple elements. kind fields We serialize the data in GreetingAccount into an array of bytes and calculate its size using . We store the data in and we'll later use it to calculate the rent amount we have to pay for storing data on the blockchain.(more of this in a few). .length GREETING_SIZE Establish a Connection to the Cluster //Establish a connection to the cluster export async function establishConnection(): Promise<void> { const rpcUrl = await getRpcUrl(); connection = new Connection(rpcUrl, 'confirmed'); const version = await connection.getVersion(); console.log('Connection to cluster established:', rpcUrl, version); } Above, we establish a connection to the cluster using the establishConnection() function. Notice how we get the rpc url from the function we created in util.js? getRpcUrl() Paying for Rent and Transactions In , we established an account keypair, that is debited every time we make a transaction. In Solana, we also have for the storage cost of keeping the account alive. However, an account can be made entirely exempt from rent collection by depositing at least 2 years worth of rent. The API to get the minimum balance required for a particular account. Notice how we pass the GREETING_SIZE constant we declared earlier? The RPC also with the function that returns a fee schedule that can be used to compute the cost of submitting a transaction. utils.js getPayer to pay rent getMinimumBalanceForRentExemption can be used provides us getRecentBlockhash Validators charge a fee in case the network is congested. Because we are on a testnet and don't care about money, we multiply the fee by 1OO to make sure our transactions never get rejected, at least not for lack of money. lamportsPerSignature //Establish an account to pay for everything export async function establishPayer(): Promise<void> { 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(); } let lamports = await connection.getBalance(payer.publicKey); if (lamports < fees) { // If current balance is not enough to pay for fees, request an airdrop const sig = await connection.requestAirdrop( payer.publicKey, fees - lamports, ); await connection.confirmTransaction(sig); lamports = await connection.getBalance(payer.publicKey); } console.log( 'Using account', payer.publicKey.toBase58(), 'containing', lamports / LAMPORTS_PER_SOL, 'SOL to pay for fees', ); } Check if the Hello World BPF Program Has Been Deployed The client loads the keypair of the deployed program from the file whose path we defined in constant and then read the programId from file. If the program isn't found, we return an error. PROGRAM_KEYPAIR_PATH export async function checkProgram(): Promise<void> { // 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 rogram/helloworld.so\``, ); } Below, we use the method to retrieve programId. We perform the following checks: getAccountInfo from the API If the programId is not found, check to see if there is a compiled binary in the filesystem. In case there is a compiled binary, we throw an error asking the user to deploy the program. If a binary is not found, we throw an error asking the user to build and deploy the program. Lastly, we check to see if the account is . An executable account is one that has been successfully deployed and is owned by the BPF loader. executable If all the checks are successful, we log the programId as in the console in string format. // 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()}`); Using the method from , we derive a public key from another key, a seed, and a program ID. The program ID will also serve as the owner of the public key, giving it permission to write data to the account. createWithSeed web3js We check to see if the account does not already exist, and if so, make a transaction to create the account using the SystemProgram's createAccountFromSeed property. In Solana, the system program is responsible for creating new accounts. . We have to pass the RPC endpoint, transaction we just created, and the signer as parameters. sendAndConfirmTransaction does what it says // 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, ); // Check if the greeting account has already been created const greetedAccount = await connection.getAccountInfo(greetedPubkey); if (greetedAccount === null) { console.log( 'Creating account', greetedPubkey.toBase58(), 'to say hello to', ); const lamports = await connection.getMinimumBalanceForRentExemption( GREETING_SIZE, ); const transaction = new Transaction().add( SystemProgram.createAccountWithSeed({ fromPubkey: payer.publicKey, basePubkey: payer.publicKey, seed: GREETING_SEED, newAccountPubkey: greetedPubkey, lamports, space: GREETING_SIZE, programId, }), ); await sendAndConfirmTransaction(connection, transaction, [payer]); } } In the sayHello function below, we create an instruction using the web3 API. The keys is the account metadata which takes the following format: TransactionInstruction class from AccountMeta: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean; } In our example, the pubkey we pass as metadata is that of the greetedAccountwe'ree saying hello to. We also state that the transaction doesn't need a signer, and it's both read and write. We allocate 0 bytes data size because we didn't pass any data while creating our program. We said it just , remember? hellos We send and confirm just like we did in the other transaction. // Say hello export async function sayHello(): Promise<void> { 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], ); } Every time a client says hello, increments counter by one. We deserialize GreetingAccount to get how many times the account we've created has been greeted from the counter and log the number to the console. GreetingAccount // Report the number of times the greeted account has been said hello to export async function reportGreetings(): Promise<void> { 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)', ); } If you go ahead and run , you should have the following output: npm start Let's say hello to a Solana account... Connection to cluster established: http://localhost:8899 { 'feature-set': 1140394761, 'solana-core': '1.7.11' } Using account 2ab2mQwRzTYCoXThK4mi8M7fTfGzV48ftpE3xNJvKem3 containing 499999999.1441591 SOL to pay for fees Using program CixWRTY8QcWP6F2maA9uhPLcJuch7njckQswwP5dTx9z Creating account 9wtyFcYpzTnP6JgJow7KtFn7KbRymEfq5L2uqvEvq9cS to say hello to Saying hello to 9wtyFcYpzTnP6JgJow7KtFn7KbRymEfq5L2uqvEvq9cS 9wtyFcYpzTnP6JgJow7KtFn7KbRymEfq5L2uqvEvq9cS has been greeted 1 time(s) Success Run npm start again, and you'll see the times we say hello increases by 1 each time. Ever seen a hello world tutorial longer this before? Conclusion Congrats! We just created a Solana program, deployed it on a local cluster, and interacted with it from the client-side using a JSON RPC API. You can use this tutorial as a reference on various Solana and Rust concepts as you build your own programs. About the Author This tutorial was created by . Kelvin is a programmer excited about decentralization and working to build an open internet and open communities. Kelvin Kirima Also Published At: https://kirima.vercel.app/post/gentleintrosolana