2 chapters · 4 questions

Solana for EVM auditors

If you know Solidity, Solana code will look strange at first. The ideas behind it aren't hard, but they're different from the EVM, and the code only makes sense once you know them.

This lesson has two chapters. The first explains the biggest differences from the EVM in plain words. The second follows one deposit into version 1 of our sample vault, step by step, so you can see each difference at work. Later lessons build on the same vault.

Every new term is explained where it first comes up. You can also hover over or tap an underlined term, or look it up in the dictionary at the end.

This is a first look, not a Rust tutorial. In the next lesson we will further enhance our vault and dive deeper into security aspects.

01The differences

Chapter 1 of 2

The biggest differences from the EVM

Programs hold code, accounts hold data

On Solana, a smart contract is called a program. A program holds only its code. It has no storage of its own.

Everything a program stores lives in separate accounts. An account is a record on the chain with its own address. Your wallet is an account, and so is every program.

Every account has the same four main fields:

The fields of an account

lamports
Its balance of SOL, Solana's native coin, counted in lamports. 1 SOL is 1,000,000,000 lamports, the way 1 ETH is 10¹⁸ wei.
data
The bytes the account stores, for example one user's balance in a vault.
owner
The program that may change the data and take lamports out.
executable
Whether the account is a program.

Only the owner can change an account

Solana enforces the owner rule on every account a transaction changes. Only the owner program can change an account's data or take lamports out of it. Any program can add lamports.

Wallets are owned by the System program. It's built into Solana, and it creates new accounts and moves SOL between wallets.

A transaction lists every account up front

On the EVM, a contract can read any of its own storage slots and call any other contract while it runs, without declaring anything in advance. On Solana, a transaction has to list every account it will read or change before it runs. It also marks each account as writable or read-only.

A transaction holds one or more instructions. An instruction is one call to one program. It names the program, the accounts, and some input data, such as an amount.

Solana needs the list so it can run transactions that use different accounts at the same time.

Whoever builds the transaction picks the accounts

A program gets only the accounts in the list, and whoever builds the transaction chooses them. That person can be an attacker.

So a program has to check that every account it gets is the one it expects. Many Solana bugs come from one of these checks missing.

Every account holds a refundable deposit

On the EVM you pay for storage once, with gas. On Solana, every account has to keep a minimum balance in lamports, and the more bytes it stores, the higher the minimum. This deposit is called rent.

Whoever creates the account pays the deposit. When the account is closed, its lamports go to an address the program picks, usually the one who paid.

Signers instead of msg.sender

In Solidity, a call has one caller, msg.sender. A Solana transaction can carry several signatures. Each account in the list is marked as a signer or not.

A signature only proves that someone holds that account's private key. It doesn't prove they're allowed to act. The program has to check that too.

PDAs: addresses with no private key

A normal Solana address is a public key, and someone holds the private key behind it. A program derived address, or PDA, has no private key.

Solana computes a PDA from a few inputs called seeds and the program's address, a lot like CREATE2 computes an address from a salt. The same inputs always give the same address.

One more input, a single byte called the bump, makes sure the result has no private key.

PDAs replace mappings

A Solidity vault stores balances[user]. A Solana vault stores each user's balance in its own account, at the PDA of the seeds "position" and the user's address.

Anyone can compute where a user's account is. The program can check that the account it gets sits at that address.

Quiz

A vault keeps each user's balance at the PDA of the seeds "position" and the user's address, plus a bump. What would happen if the code dropped the user's address and kept only "position" and the bump?

Programs sign for their PDAs

A program can sign for its own PDAs. Nobody else can, because there's no private key.

That lets a PDA control tokens for the program, the way address(this) holds tokens for a Solidity contract.

One Token program for all tokens

On the EVM, every token is its own ERC-20 contract. On Solana, almost every token uses one shared program, the Token program. Newer tokens may use Token-2022, a second token program with extra features such as transfer fees.

Each token has a mint account, which stores its supply and its decimals. Balances live in token accounts: each one holds one wallet's balance of one token.

Owner and authority are different things

The Token program owns every token account. Inside its data, each token account names an authority: the wallet or PDA allowed to move its tokens.

Keep the two apart. The owner is the program that may change the account. The authority is who may tell the Token program to move the tokens.

Quiz

A user deposits tokens into a vault. Which program writes the new balance into the vault's token account?

Calling another program

When one program calls another, Solana calls it a cross-program invocation, or CPI. It's the same idea as an external call in Solidity. To move tokens, a program makes a CPI to the Token program.

Signatures carry over. If a user signed the transaction, the called program sees that signature too. A program can also sign for one of its PDAs when it makes a CPI.

No calls back into the caller

A called program can't call back into a program that's already running higher up in the call chain. If A calls B, B can't call A. A program can still call itself directly.

That rules out the classic EVM reentrancy, where a token callback calls back into the contract before it finishes.

On the EVMOn Solana
A contract holds its code and its storage.A program holds only code. Its data lives in accounts the program owns.
A contract can read any storage slot.A transaction lists every account before it runs.
Storage is paid for once, with gas.Every account holds a refundable deposit, called rent.
msg.senderThe accounts that signed. There can be several.
mapping(address => uint256)One account per user, at a PDA derived from the user's address
address(this) holds the tokens.A PDA is the authority over the program's token account.
One ERC-20 contract per tokenOne Token program, a mint per token and token accounts for the balances
External callCPI
A token callback can reenter the caller.A called program can't call back into its caller.
Wei, 10¹⁸ per ETHLamports, 10⁹ per SOL
Who can change what. Pick an account from the sample vault, a program and an action. Solana only checks the owner; anything after that is up to the owner program's own code.
Owner of this account
Solana says

Quiz

During a withdraw, the vault makes a CPI to the Token program. Can the Token program call back into the vault's withdraw before it finishes?

02One deposit

Chapter 2 of 2

One deposit, step by step

Now let's follow one deposit through version 1 of our sample vault. It's as small as a vault can be: users deposit a token and withdraw it again, and nothing else.

Here's the deposit in a Solidity version of the same vault, so you have something to compare against:

function deposit(uint256 amount) external {
    balances[msg.sender] += amount;
    token.safeTransferFrom(msg.sender, address(this), amount);
}

On Solana, the same deposit uses six accounts. We'll follow Alice, who deposits 100 tokens for the first time.

What exists before Alice deposits

The vault was set up earlier. These accounts already exist:

AccountWhat it holdsOwner
The vault programThe vault's codeSolana's program loader
The vault's token account, at the PDA of "vault"The tokens everyone deposited. Its authority is its own address, so only the vault program can sign for it.Token program
The mintThe token's supply and decimalsToken program
Alice's walletHer SOLSystem program
Alice's token accountHer 100 tokens. Its authority is Alice's wallet.Token program

One account is missing: Alice's Position, the account that will record how much she deposited. Her first deposit creates it, at the PDA of "position" and her address.

Step 1: Alice's wallet builds the transaction

Alice clicks deposit in the vault's website, and her wallet app builds a transaction with one instruction. The instruction calls the vault's deposit with the amount 100 and lists six accounts:

AccountSignerWritableWhy it's there
Alice's walletyesyesShe approves the deposit and pays the transaction fee and the new account's rent.
Alice's PositionnoyesThe vault creates it and writes her balance.
Alice's token accountnoyes100 tokens leave it.
The vault's token accountnoyes100 tokens arrive.
Token programnonoThe vault calls it to move the tokens.
System programnonoIt creates the Position account.

Alice signs the transaction with her wallet's private key and sends it.

Step 2: the vault checks every account

Remember that whoever builds the transaction picks the accounts. Before the vault does anything, it has to check that each account is the right one.

Many Solana programs are written in Rust with Anchor, a framework that writes these checks for you. You describe the accounts an instruction takes in a struct, and Anchor checks every account against that description before your function runs.

The shape of the description

Here's the vault's description for deposit, with the rules left out for now:

#[derive(Accounts)]
pub struct Deposit<'info> {
    pub user: Signer<'info>,
    pub position: Account<'info, Position>,
    // ... four more accounts
}

#[derive(Accounts)] tells Anchor to turn the struct into checks. Each pub line is one account, in the same order as the table in Step 1.

You can ignore 'info. It's a Rust detail about how long the data is borrowed.

In the real code, most accounts also get an #[account(...)] line above them with extra rules. Let's go through them one account at a time.

Alice must sign

#[account(mut)]
pub user: Signer<'info>,
  • Signer: the account signed the transaction.
  • mut: the account is marked writable, so the instruction may change it. Alice's wallet pays the rent for her new Position.

Alice's Position

#[account(init_if_needed, payer = user, space = 8 + 8,
    seeds = [b"position", user.key().as_ref()], bump)]
pub position: Account<'info, Position>,
  • Account<'info, Position>: the vault program owns the account, and its data is a Position. Anchor writes an 8-byte tag at the start of every account it creates, naming the type, and compares that tag here.
  • init_if_needed: if the account doesn't exist yet, create it. Anchor only allows this with its init-if-needed feature turned on, and warns about it: the instruction also runs on an account that already exists, so the code must not reset data that's already there. Here it only adds to the amount, which is safe.
  • payer = user: Alice pays its rent.
  • space: its size in bytes, the 8-byte tag plus the 8-byte amount.
  • seeds = [b"position", user.key().as_ref()]: the Position sits at the PDA of "position" and Alice's address, so it's hers. b"position" is the bytes of the text "position", and user.key().as_ref() is Alice's address as bytes.
  • bump: the extra byte from chapter 1. Anchor finds it for these seeds.

The two token accounts

#[account(mut)]
pub user_tokens: Account<'info, TokenAccount>,
#[account(mut, seeds = [b"vault"], bump)]
pub vault_tokens: Account<'info, TokenAccount>,
  • Account<'info, TokenAccount>: the Token program owns the account, and its data is a valid token account. Token accounts don't carry Anchor's 8-byte tag, so Anchor checks them this way instead.
  • Alice's token account needs no more rules. In Step 5 the Token program checks that she signed for it and that it holds the same token as the vault's.
  • seeds = [b"vault"]: the vault's token account is the one at the PDA of "vault".
  • Both are mut, because tokens leave one and arrive in the other.

The two programs

pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,

Program<'info, Token> checks that the account is the real Token program, not some other program with the same interface. The same goes for the System program. It accepts only the original Token program, so this vault can't take Token-2022 tokens. Programs that support both use Interface<'info, TokenInterface> and InterfaceAccount, and then have to handle Token-2022's extra features.

If any check in this step fails, the whole transaction fails and nothing changes. Plain programs written without Anchor have to write each of these checks by hand.

Step 3: the vault creates Alice's Position

Alice has no Position yet, so init_if_needed creates one. The vault makes a CPI to the System program, which creates a new account at her Position PDA. The vault signs for that PDA, the System program takes the rent from Alice's wallet, and the vault program becomes the new account's owner.

Step 4: the vault's code runs

Only now does the vault's own deposit function run. It writes Alice's balance.

pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    let position = &mut ctx.accounts.position;
    position.amount = position.amount.checked_add(amount).ok_or(VaultError::Overflow)?;

ctx.accounts holds the six accounts, already checked. amount is the 100 from Alice's instruction.

The other two lines add 100 to the amount in her Position. &mut means the code may change the Position.

checked_add fails on overflow instead of wrapping around. Solidity 0.8 does the same by default. ok_or(...)? turns that failure into the error Overflow and stops the function.

The vault may write to the Position because it owns it.

Step 5: a CPI moves the tokens

The vault can't change token balances itself, because the Token program owns both token accounts. So it asks the Token program to move them.

Building the call

    let cpi = CpiContext::new(ctx.accounts.token_program.to_account_info(), Transfer {
        from: ctx.accounts.user_tokens.to_account_info(),
        to: ctx.accounts.vault_tokens.to_account_info(),
        authority: ctx.accounts.user.to_account_info(),
    });

CpiContext::new names the program to call, the Token program, and the accounts to pass it. Transfer lists them: where the tokens come from, where they go, and who authorizes it.

Making the call

    token::transfer(cpi, amount)
}

token::transfer makes the CPI. The Token program checks its own rules: both token accounts hold the same token, and the authority of the account the tokens leave signed. That's Alice.

Her signature on the transaction carries over into the CPI, so the check passes. 100 tokens move from her token account to the vault's.

After the transaction

AccountBeforeAfter
Alice's walletHer SOLLess the Position's rent and the transaction fee
Alice's PositionDoesn't existExists, owned by the vault program, amount 100
Alice's token account100 tokens0 tokens
The vault's token accountEveryone else's deposits100 tokens more

Withdrawing: the vault signs for its PDA

Withdraw runs the same way in reverse. The vault lowers Alice's amount, then moves tokens from its token account back to hers.

Its accounts are the same as deposit's with two differences. Alice's Position must already exist, so there's no init_if_needed and no System program. Alice pays no rent, so her wallet isn't mut.

One thing is new. The tokens now leave the vault's token account, and its authority is its own address, the PDA of "vault". Nobody has a private key for a PDA, so the vault signs for it.

The seeds

let seeds: &[&[u8]] = &[b"vault", &[ctx.bumps.vault_tokens]];
let signer = &[seeds];

These lines collect the PDA's seeds, "vault", and the bump Anchor found while checking the accounts. A CPI can sign for several PDAs at once, so signer wraps the seeds in a list.

The signed call

let cpi = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(), Transfer {
    from: ctx.accounts.vault_tokens.to_account_info(),
    to: ctx.accounts.user_tokens.to_account_info(),
    authority: ctx.accounts.vault_tokens.to_account_info(),
}, signer);
token::transfer(cpi, amount)

new_with_signer passes the seeds along with the call. Solana computes the PDA from these seeds and the vault program's address. The result is the address of the vault's token account, so Solana treats that PDA as a signer in this CPI.

Only the vault program can do this, because the PDA is computed from its own address.

Quiz

Suppose the vault dropped the rule seeds = [b"vault"] from vault_tokens in deposit. What could an attacker do?

{ }

Every snippet from chapter 2 in one place

The whole vault

Here's the sample vault as one Anchor program, with deposit and withdraw from chapter 2 in one place. The instruction that creates the vault's token account is left out. Click any part with a bar on its left to jump to its explanation.

use anchor_lang::prelude::*;use anchor_spl::token::{self, Token, TokenAccount, Transfer};declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BmZ7FEfcYkg476zPFsLnS");#[program]pub mod vault {    use super::*;    pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
        let position = &mut ctx.accounts.position;
        position.amount = position.amount.checked_add(amount).ok_or(VaultError::Overflow)?;        let cpi = CpiContext::new(ctx.accounts.token_program.to_account_info(), Transfer {
            from: ctx.accounts.user_tokens.to_account_info(),
            to: ctx.accounts.vault_tokens.to_account_info(),
            authority: ctx.accounts.user.to_account_info(),
        });
        token::transfer(cpi, amount)    }    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        let position = &mut ctx.accounts.position;
        position.amount = position.amount.checked_sub(amount).ok_or(VaultError::InsufficientBalance)?;        let seeds: &[&[u8]] = &[b"vault", &[ctx.bumps.vault_tokens]];
        let signer = &[seeds];        let cpi = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(), Transfer {
            from: ctx.accounts.vault_tokens.to_account_info(),
            to: ctx.accounts.user_tokens.to_account_info(),
            authority: ctx.accounts.vault_tokens.to_account_info(),
        }, signer);
        token::transfer(cpi, amount)    }}#[derive(Accounts)]
pub struct Deposit<'info> {    #[account(mut)]
    pub user: Signer<'info>,    #[account(init_if_needed, payer = user, space = 8 + 8,
        seeds = [b"position", user.key().as_ref()], bump)]
    pub position: Account<'info, Position>,    #[account(mut)]
    pub user_tokens: Account<'info, TokenAccount>,
    #[account(mut, seeds = [b"vault"], bump)]
    pub vault_tokens: Account<'info, TokenAccount>,    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,}#[derive(Accounts)]
pub struct Withdraw<'info> {
    pub user: Signer<'info>,
    #[account(mut, seeds = [b"position", user.key().as_ref()], bump)]
    pub position: Account<'info, Position>,
    #[account(mut)]
    pub user_tokens: Account<'info, TokenAccount>,
    #[account(mut, seeds = [b"vault"], bump)]
    pub vault_tokens: Account<'info, TokenAccount>,
    pub token_program: Program<'info, Token>,
}#[account]
pub struct Position {
    pub amount: u64,
}#[error_code]
pub enum VaultError {
    #[msg("Amount overflows")]
    Overflow,
    #[msg("Not enough deposited")]
    InsufficientBalance,
}

What comes next

This is version 1 of the vault. In the next lesson we will further enhance our vault and dive deeper into security aspects.

A–Z

Every term in this lesson

Dictionary