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.
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.
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?
The seeds decide the address. Without the user's address there's only one possible address, so every deposit lands in the same account, and whoever withdraws first can take everything recorded there.
- Why not b
- The bump is what keeps a PDA's address free of a private key, and the code still uses it. Dropping the user's address only changes which address comes out.
- Why not c
- The same seeds and the same program always give the same address. Nothing about it is random.
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.
A user deposits tokens into a vault. Which program writes the new balance into the vault's token account?
Only an account's owner can change its data, and the Token program owns every token account. The vault asks the Token program to move the tokens with a CPI.
- Why not a
- The vault program doesn't own the token account, so Solana would reject any write from it.
- Why not c
- The System program moves SOL between wallets it owns. It can't change a token account's data.
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 EVM | On 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.sender | The 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 token | One Token program, a mint per token and token accounts for the balances |
| External call | CPI |
| A token callback can reenter the caller. | A called program can't call back into its caller. |
| Wei, 10¹⁸ per ETH | Lamports, 10⁹ per SOL |
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?
The vault is already running when it calls the Token program, so a call back into the vault fails. This removes the classic EVM reentrancy.
- Why not a
- That's how the EVM works. Solana blocks A → B → A.
- Why not c
- Signatures have nothing to do with it. The rule is about which programs are already running.
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:
| Account | What it holds | Owner |
|---|---|---|
| The vault program | The vault's code | Solana'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 mint | The token's supply and decimals | Token program |
| Alice's wallet | Her SOL | System program |
| Alice's token account | Her 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:
| Account | Signer | Writable | Why it's there |
|---|---|---|---|
| Alice's wallet | yes | yes | She approves the deposit and pays the transaction fee and the new account's rent. |
| Alice's Position | no | yes | The vault creates it and writes her balance. |
| Alice's token account | no | yes | 100 tokens leave it. |
| The vault's token account | no | yes | 100 tokens arrive. |
| Token program | no | no | The vault calls it to move the tokens. |
| System program | no | no | It 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 itsinit-if-neededfeature 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", anduser.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
| Account | Before | After |
|---|---|---|
| Alice's wallet | Her SOL | Less the Position's rent and the transaction fee |
| Alice's Position | Doesn't exist | Exists, owned by the vault program, amount 100 |
| Alice's token account | 100 tokens | 0 tokens |
| The vault's token account | Everyone else's deposits | 100 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.
Suppose the vault dropped the rule seeds = [b"vault"] from vault_tokens in deposit. What could an attacker do?
The tokens move from the attacker's token account to another account of theirs, and the vault still adds the amount to their Position. withdraw checks the real vault token account, so it pays the attacker out of everyone else's deposits.
- Why not b
- The Token program checks that the source account's authority signed. It doesn't know which account the vault meant to receive the tokens.
- Why not c
- The attacker builds and signs their own transaction. Alice isn't involved.
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.
Every term in this lesson