rust-analyzer can be very handy if you are using Visual Studio Code. For example, the analyzer can help download the missing dependencies for you automatically.
Install Additional Dependencies (Optional)
If you are using Linux, you may need to install these tools as well:
If you encounter an insuffficient fund error, you may have to request for an aidrop:
$solanaairdrop1
Say Hello World
First, we need to modify the PROGRAM_PATH in src/client/hello_world.ts:
// In src/client/hello_world.ts// Modify PROGRAM_PATH at Line 43...// const PROGRAM_PATH = path.resolve(__dirname, '../../dist/program');constPROGRAM_PATH=path.resolve(__dirname,'../program-rust/target/deploy');...
Finally, let's make the program say Hello by sending a transaction:
$ npm install -g ts-node
...
$ ts-node ../client/main.ts
Let's say hello to a Solana account...
Connection to cluster established: http://localhost:8899 { 'feature-set': 2037512494, 'solana-core': '1.8.0' }
Using account 4h8EgjxFHnTLshhGWb91MgyN2PXJZ8dmbc8UiTsfatLf containing 499999999.14836293 SOL to pay for fees
Using program 7hV1hUKgY4ZF3J2UYAhvZFhNtr4PB4MLufsuXFU5Usa2
Saying hello to f5nadW1a9e86aaigWfuKPhAKTYCNYUeZ9xm9i3HjS8P
f5nadW1a9e86aaigWfuKPhAKTYCNYUeZ9xm9i3HjS8P has been greeted 2 time(s)
Success
If we take a closer look to function sayHello, we can see how a solana transaction is constructed and sent:
// In hello_world.ts...exportasyncfunctionsayHello():Promise<void> {console.log('Saying hello to',greetedPubkey.toBase58());constinstruction=newTransactionInstruction({ keys: [{pubkey: greetedPubkey, isSigner:false, isWritable:true}], programId, data:Buffer.alloc(0),// All instructions are hellos });awaitsendAndConfirmTransaction( connection,newTransaction().add(instruction), [payer], );}...
2. Escrow Program (using vanilla Rust)
Goal
Learn Solana account model and core concepts such as:
Account model
Program Architecture
Program Derived Address (PDA)
Cross-Program Invocation (CPI)
invoke
invoke_signed
This section is extracted from this awesome tutorial: Programming on Solana - An Introduction (by paulx). Some of the explanations in this doc are more comprehensive and clearer in the original post. I strongly recommend you to read through the post at least once.
Program Architecture
lib.rs: registering modules
entrypoint.rs: entrypoint to the program
instruction.rs: program API, (de)serializing instruction data
Only the account owner may debit an account and adjust its data
All accounts to be written to or read must be passed into the entrypoint
All internal Solana internal account information are saved into fields on the account (opens new window)but never into the data field which is solely meant for user space information
Developers should use the data field to save data inside accounts
Program
Solana programs are stateless
Each program is processed by its BPF Loader and has an entrypoint whose structure depends on which BPF Loader is used
In theory, programs have full autonomy over the accounts they own. It is up to the program's creator to limit this autonomy and up to the users of the program to verify the program's creator has really done so
The flow of a program using this structure looks like this:
Someone calls the entrypoint
The entrypoint forwards the arguments to the processor
The processor asks instruction module to decode the instruction_data argument from the entrypoint function.
Using the decoded data, the processor will now decide which processing function to use to process the request.
The processor may use state module to encode state into or decode the state of an account which has been passed into the entrypoint.
When writing Solana programs, be mindful of the fact that any accounts may be passed into the entrypoint, including different ones than those defined in the API inside instruction.rs. It's the program's responsibility to check that received accounts == expected accounts
Instruction
If you are familiar of Ethereum, think of Solana instructions as Ethereum transcations, while Solana transaction, which can wrap multiple instructions, is anologous to Ethereum multicall
SPL token Program
The token program owns token accounts which inside their data field hold relevant information
the token program also owns token mint accounts with relevant data
each token account holds a reference to their token mint account, thereby stating which token mint they belong to
the token program allows the (user space) owner of a token account to transfer its ownership to another address
All internal Solana internal account information are saved into fields on the account but never into the data field which is solely meant for user space information
PDA
Program Derived Addresses do not lie on the ed25519 curve and therefore have no private key associated with them.
Cross-Program Invocation
When including a signed account in a program call, in all CPIs including that account made by that program inside the current instruction, the account will also be signed, i.e. the signature is extended to the CPIs.
when a program calls invoke_signed, the runtime uses the given seeds and the program id of the calling program to recreate the PDA and if it matches one of the given accounts inside invoke_signed's arguments, that account's signed property will be set to true
To spend Solana SPL, you don't need to approve. Why?
Rent
Rent is deducted from an account's balance according to their space requirements (i.e. the space an account and its fields take up in memory) regularly. An account can, however, be made rent-exempt if its balance is higher than some threshold that depends on the space it's consuming
If an account has no balance left, it will be purged from memory by the runtime after the transaction (you can see this when going navigating to an account that has been closed in the explorer)
"closing" instructions must set the data field properly, even if the intent is to have the account be purged from memory after the transaction
In any call to a program that is of the "close" kind, i.e. where you set an account's lamports to zero so it's removed from memory after the transaction, make sure to either clear the data field or leave the data in a state that would be OK to be recovered by a subsequent transaction.
Solana has sysvars that are parameters of the Solana cluster you are on. These sysvars can be accessed through accounts and store parameters such as what the current fee or rent is. As of solana-program version 1.6.5, sysvars can also be accessed without being passed into the entrypoint as an account.
// lib.rs
pub mod entrypoint;
pub mod error;
pub mod instruction;
pub mod processor;
pub mod state;
Let's begin to implement these modules. First, we define instructions. Instructions are the APIs of program. Copy and paste the following snippet into your local instuction.rs:
// instruction.rs (partially implemented)
use std::convert::TryInto;
use solana_program::program_error::ProgramError;
use crate::error::EscrowError::InvalidInstruction;
pub enum EscrowInstruction {
/// Starts the trade by creating and populating an escrow account and transferring ownership of the given temp token account to the PDA
///
///
/// Accounts expected:
///
/// 0. `[signer]` The account of the person initializing the escrow
/// 1. `[writable]` Temporary token account that should be created prior to this instruction and owned by the initializer
/// 2. `[]` The initializer's token account for the token they will receive should the trade go through
/// 3. `[writable]` The escrow account, it will hold all necessary info about the trade.
/// 4. `[]` The rent sysvar
/// 5. `[]` The token program
InitEscrow {
/// The amount party A expects to receive of token Y
amount: u64
}
}
impl EscrowInstruction {
/// Unpacks a byte buffer into a [EscrowInstruction](enum.EscrowInstruction.html).
pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
let (tag, rest) = input.split_first().ok_or(InvalidInstruction)?;
Ok(match tag {
0 => Self::InitEscrow {
amount: Self::unpack_amount(rest)?,
},
_ => return Err(InvalidInstruction.into()),
})
}
fn unpack_amount(input: &[u8]) -> Result<u64, ProgramError> {
let amount = input
.get(..8)
.and_then(|slice| slice.try_into().ok())
.map(u64::from_le_bytes)
.ok_or(InvalidInstruction)?;
Ok(amount)
}
}
You may notice that there are a few compile warning telling you InvalidInstruction is not resolved. Let's implement it in error.rs.
// error.rs (partially implemented)
use thiserror::Error;
use solana_program::program_error::ProgramError;
#[derive(Error, Debug, Copy, Clone)]
pub enum EscrowError {
/// Invalid instruction
#[error("Invalid Instruction")]
InvalidInstruction,
/// Not Rent Exempt
#[error("Not Rent Exempt")]
NotRentExempt,
}
impl From<EscrowError> for ProgramError {
fn from(e: EscrowError) -> Self {
ProgramError::Custom(e as u32)
}
}
The main business logic locates in processor.rs. There will be two functions corresponding two instructions. Let's implement those one by one. Here we implement the process_init_escrow function which matches EscrowInstruction::InitEscrow case:
// processor.rs (partially implemented)
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
program_error::ProgramError,
msg,
pubkey::Pubkey,
program_pack::{Pack, IsInitialized},
sysvar::{rent::Rent, Sysvar},
program::invoke
};
use crate::{instruction::EscrowInstruction, error::EscrowError, state::Escrow};
pub struct Processor;
impl Processor {
pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult {
let instruction = EscrowInstruction::unpack(instruction_data)?;
match instruction {
EscrowInstruction::InitEscrow { amount } => {
msg!("Instruction: InitEscrow");
Self::process_init_escrow(accounts, amount, program_id)
}
}
}
fn process_init_escrow(
accounts: &[AccountInfo],
amount: u64,
program_id: &Pubkey,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let initializer = next_account_info(account_info_iter)?;
if !initializer.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let temp_token_account = next_account_info(account_info_iter)?;
let token_to_receive_account = next_account_info(account_info_iter)?;
if *token_to_receive_account.owner != spl_token::id() {
return Err(ProgramError::IncorrectProgramId);
}
let escrow_account = next_account_info(account_info_iter)?;
let rent = &Rent::from_account_info(next_account_info(account_info_iter)?)?;
if !rent.is_exempt(escrow_account.lamports(), escrow_account.data_len()) {
return Err(EscrowError::NotRentExempt.into());
}
let mut escrow_info = Escrow::unpack_unchecked(&escrow_account.data.borrow())?;
if escrow_info.is_initialized() {
return Err(ProgramError::AccountAlreadyInitialized);
}
Ok(())
}
}
Part 2
You will notice a warning raised due to unresolved state::Escrow.
What does state.rs do? It basically represents the data structure stored in the account owned by Escrow program. Also, it has the pack/unpack utils to convert the data format.
Next, we can implement another instruction Exchange and its corresponding function process_exchange.
Update instruction.rs:
// instructions.rs (fully implemented)
pub enum EscrowInstruction {
...
/// Accepts a trade
///
///
/// Accounts expected:
///
/// 0. `[signer]` The account of the person taking the trade
/// 1. `[writable]` The taker's token account for the token they send
/// 2. `[writable]` The taker's token account for the token they will receive should the trade go through
/// 3. `[writable]` The PDA's temp token account to get tokens from and eventually close
/// 4. `[writable]` The initializer's main account to send their rent fees to
/// 5. `[writable]` The initializer's token account that will receive tokens
/// 6. `[writable]` The escrow account holding the escrow info
/// 7. `[]` The token program
/// 8. `[]` The PDA account
Exchange {
/// the amount the taker expects to be paid in the other token, as a u64 because that's the max possible supply of a token
amount: u64,
}
}
impl EscrowInstruction {
...
pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
...
Ok(match tag {
...
1 => Self::Exchange {
amount: Self::unpack_amount(rest)?
},
...
})
}
}
Also in processor.rs:
// processor.rs (fully implemented)
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
msg,
program::{invoke, invoke_signed},
program_error::ProgramError,
program_pack::{IsInitialized, Pack},
pubkey::Pubkey,
sysvar::{rent::Rent, Sysvar},
};
use spl_token::state::Account as TokenAccount;
...
impl Processor {
pub fn process(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
...
match instruction {
...
EscrowInstruction::Exchange { amount } => {
msg!("Instruction: Exchange");
Self::process_exchange(accounts, amount, program_id)
}
}
}
fn process_exchange(
accounts: &[AccountInfo],
amount_expected_by_taker: u64,
program_id: &Pubkey,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let taker = next_account_info(account_info_iter)?;
if !taker.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let takers_sending_token_account = next_account_info(account_info_iter)?;
let takers_token_to_receive_account = next_account_info(account_info_iter)?;
let pdas_temp_token_account = next_account_info(account_info_iter)?;
let pdas_temp_token_account_info =
TokenAccount::unpack(&pdas_temp_token_account.data.borrow())?;
let (pda, bump_seed) = Pubkey::find_program_address(&[b"escrow"], program_id);
if amount_expected_by_taker != pdas_temp_token_account_info.amount {
return Err(EscrowError::ExpectedAmountMismatch.into());
}
let initializers_main_account = next_account_info(account_info_iter)?;
let initializers_token_to_receive_account = next_account_info(account_info_iter)?;
let escrow_account = next_account_info(account_info_iter)?;
let escrow_info = Escrow::unpack(&escrow_account.data.borrow())?;
if escrow_info.temp_token_account_pubkey != *pdas_temp_token_account.key {
return Err(ProgramError::InvalidAccountData);
}
if escrow_info.initializer_pubkey != *initializers_main_account.key {
return Err(ProgramError::InvalidAccountData);
}
if escrow_info.initializer_token_to_receive_account_pubkey != *initializers_token_to_receive_account.key {
return Err(ProgramError::InvalidAccountData);
}
let token_program = next_account_info(account_info_iter)?;
let transfer_to_initializer_ix = spl_token::instruction::transfer(
token_program.key,
takers_sending_token_account.key,
initializers_token_to_receive_account.key,
taker.key,
&[&taker.key],
escrow_info.expected_amount,
)?;
msg!("Calling the token program to transfer tokens to the escrow's initializer...");
invoke(
&transfer_to_initializer_ix,
&[
takers_sending_token_account.clone(),
initializers_token_to_receive_account.clone(),
taker.clone(),
token_program.clone(),
],
)?;
let pda_account = next_account_info(account_info_iter)?;
let transfer_to_taker_ix = spl_token::instruction::transfer(
token_program.key,
pdas_temp_token_account.key,
takers_token_to_receive_account.key,
&pda,
&[&pda],
pdas_temp_token_account_info.amount,
)?;
msg!("Calling the token program to transfer tokens to the taker...");
invoke_signed(
&transfer_to_taker_ix,
&[
pdas_temp_token_account.clone(),
takers_token_to_receive_account.clone(),
pda_account.clone(),
token_program.clone(),
],
&[&[&b"escrow"[..], &[bump_seed]]],
)?;
let close_pdas_temp_acc_ix = spl_token::instruction::close_account(
token_program.key,
pdas_temp_token_account.key,
initializers_main_account.key,
&pda,
&[&pda]
)?;
msg!("Calling the token program to close pda's temp account...");
invoke_signed(
&close_pdas_temp_acc_ix,
&[
pdas_temp_token_account.clone(),
initializers_main_account.clone(),
pda_account.clone(),
token_program.clone(),
],
&[&[&b"escrow"[..], &[bump_seed]]],
)?;
msg!("Closing the escrow account...");
**initializers_main_account.lamports.borrow_mut() = initializers_main_account.lamports()
.checked_add(escrow_account.lamports())
.ok_or(EscrowError::AmountOverflow)?;
**escrow_account.lamports.borrow_mut() = 0;
*escrow_account.data.borrow_mut() = &mut [];
Ok(())
}
}
Here we can see that invoke_signed is called with seeds since the owner of escrow account is a PDA.
Next, we need to manually update the public keys for each. Retrieve the address for all of them and paste it to the *_pub.json files accordingly. For example: