Building a Simple Token with Rust: A Complete Guide
Rust is becoming increasingly popular in blockchain development due to its memory safety, performance, and growing ecosystem. In this guide, we'll build a simple token using Rust on the Solana blockchain.
Why Rust for Blockchain Development?
Rust offers several advantages for blockchain development:
- Memory Safety: Prevents common bugs like buffer overflows
- Performance: Compiles to efficient machine code
- Concurrency: Excellent support for parallel processing
- Growing Ecosystem: Strong Web3 library support
- Cross-platform: Works on multiple operating systems
Prerequisites
Before we start, make sure you have:
- Rust installed (latest stable version)
- Solana CLI tools
- Basic understanding of Rust syntax
- Familiarity with blockchain concepts
Setting Up the Project
1. Install Rust
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add to PATH
source ~/.cargo/env
# Verify installation
rustc --version
2. Install Solana CLI
# Install Solana CLI
sh -c "$(curl -sSfL https://release.solana.com/v1.17.0/install)"
# Add to PATH
export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
# Verify installation
solana --version
3. Create New Project
# Create new Rust project
cargo new simple_token --lib
cd simple_token
# Add Solana dependencies
cargo add solana_program
cargo add borsh
cargo add thiserror
Building the Token Program
1. Basic Token Structure
Let's start with a simple token that can mint, transfer, and burn tokens:
// src/lib.rs
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint,
entrypoint::ProgramResult,
msg,
program_error::ProgramError,
pubkey::Pubkey,
};
// Define the program's entry point
entrypoint!(process_instruction);
// Token instruction enum
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub enum TokenInstruction {
/// Initialize a new token
/// Accounts expected:
/// 0. [signer] The account that will own the token
/// 1. [writable] The token account to initialize
Initialize {
name: String,
symbol: String,
decimals: u8,
initial_supply: u64,
},
/// Transfer tokens from one account to another
/// Accounts expected:
/// 0. [signer] The account transferring tokens
/// 1. [writable] The source token account
/// 2. [writable] The destination token account
Transfer { amount: u64 },
/// Mint new tokens to an account
/// Accounts expected:
/// 0. [signer] The mint authority
/// 1. [writable] The token account to mint to
/// 2. [writable] The mint account
Mint { amount: u64 },
/// Burn tokens from an account
/// Accounts expected:
/// 0. [signer] The account burning tokens
/// 1. [writable] The token account to burn from
/// 2. [writable] The mint account
Burn { amount: u64 },
}
// Token account data structure
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct TokenAccount {
pub owner: Pubkey,
pub amount: u64,
pub mint: Pubkey,
}
// Mint account data structure
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct MintAccount {
pub supply: u64,
pub decimals: u8,
pub mint_authority: Pubkey,
pub freeze_authority: Option<Pubkey>,
}
// Main program entry point
pub fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let instruction = TokenInstruction::try_from_slice(instruction_data)
.map_err(|_| ProgramError::InvalidInstructionData)?;
match instruction {
TokenInstruction::Initialize {
name,
symbol,
decimals,
initial_supply,
} => {
msg!("Initializing token: {} ({})", name, symbol);
initialize_token(accounts, name, symbol, decimals, initial_supply)
}
TokenInstruction::Transfer { amount } => {
msg!("Transferring {} tokens", amount);
transfer_tokens(accounts, amount)
}
TokenInstruction::Mint { amount } => {
msg!("Minting {} tokens", amount);
mint_tokens(accounts, amount)
}
TokenInstruction::Burn { amount } => {
msg!("Burning {} tokens", amount);
burn_tokens(accounts, amount)
}
}
}
2. Token Implementation Functions
// Initialize a new token
fn initialize_token(
accounts: &[AccountInfo],
name: String,
symbol: String,
decimals: u8,
initial_supply: u64,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let owner = next_account_info(account_info_iter)?;
let token_account = next_account_info(account_info_iter)?;
// Verify the owner is signing
if !owner.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Create token account data
let token_data = TokenAccount {
owner: *owner.key,
amount: initial_supply,
mint: *token_account.key,
};
// Serialize and store the token account data
token_data.serialize(&mut &mut token_account.data.borrow_mut()[..])?;
msg!("Token {} ({}) initialized with supply: {}", name, symbol, initial_supply);
Ok(())
}
// Transfer tokens between accounts
fn transfer_tokens(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let from = next_account_info(account_info_iter)?;
let from_token_account = next_account_info(account_info_iter)?;
let to_token_account = next_account_info(account_info_iter)?;
// Verify the sender is signing
if !from.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Deserialize token accounts
let mut from_data = TokenAccount::try_from_slice(&from_token_account.data.borrow())?;
let mut to_data = TokenAccount::try_from_slice(&to_token_account.data.borrow())?;
// Check if sender has enough tokens
if from_data.amount < amount {
return Err(ProgramError::InsufficientFunds);
}
// Perform the transfer
from_data.amount -= amount;
to_data.amount += amount;
// Update the accounts
from_data.serialize(&mut &mut from_token_account.data.borrow_mut()[..])?;
to_data.serialize(&mut &mut to_token_account.data.borrow_mut()[..])?;
msg!("Transferred {} tokens from {} to {}",
amount, from.key, to_token_account.key);
Ok(())
}
// Mint new tokens
fn mint_tokens(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let mint_authority = next_account_info(account_info_iter)?;
let token_account = next_account_info(account_info_iter)?;
let mint_account = next_account_info(account_info_iter)?;
// Verify the mint authority is signing
if !mint_authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Deserialize accounts
let mut token_data = TokenAccount::try_from_slice(&token_account.data.borrow())?;
let mut mint_data = MintAccount::try_from_slice(&mint_account.data.borrow())?;
// Update token account
token_data.amount += amount;
token_data.serialize(&mut &mut token_account.data.borrow_mut()[..])?;
// Update mint account
mint_data.supply += amount;
mint_data.serialize(&mut &mut mint_account.data.borrow_mut()[..])?;
msg!("Minted {} tokens to {}", amount, token_account.key);
Ok(())
}
// Burn tokens
fn burn_tokens(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let burner = next_account_info(account_info_iter)?;
let token_account = next_account_info(account_info_iter)?;
let mint_account = next_account_info(account_info_iter)?;
// Verify the burner is signing
if !burner.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Deserialize accounts
let mut token_data = TokenAccount::try_from_slice(&token_account.data.borrow())?;
let mut mint_data = MintAccount::try_from_slice(&mint_account.data.borrow())?;
// Check if account has enough tokens to burn
if token_data.amount < amount {
return Err(ProgramError::InsufficientFunds);
}
// Perform the burn
token_data.amount -= amount;
mint_data.supply -= amount;
// Update the accounts
token_data.serialize(&mut &mut token_account.data.borrow_mut()[..])?;
mint_data.serialize(&mut &mut mint_account.data.borrow_mut()[..])?;
msg!("Burned {} tokens from {}", amount, token_account.key);
Ok(())
}
3. Error Handling
// src/error.rs
use thiserror::Error;
#[derive(Error, Debug, Copy, Clone)]
pub enum TokenError {
#[error("Insufficient funds")]
InsufficientFunds,
#[error("Invalid mint authority")]
InvalidMintAuthority,
#[error("Invalid freeze authority")]
InvalidFreezeAuthority,
#[error("Amount must be greater than zero")]
InvalidAmount,
#[error("Token account not initialized")]
UninitializedAccount,
}
impl From<TokenError> for ProgramError {
fn from(e: TokenError) -> Self {
ProgramError::Custom(e as u32)
}
}
Testing the Token
1. Unit Tests
// tests/token_tests.rs
use simple_token::*;
use solana_program_test::*;
use solana_sdk::{
account::Account,
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
signature::Signer,
transaction::Transaction,
};
#[tokio::test]
async fn test_initialize_token() {
let program_id = Pubkey::new_unique();
let mut program_test = ProgramTest::new(
"simple_token",
program_id,
processor!(process_instruction),
);
let (mut banks_client, payer, recent_blockhash) = program_test.start().await;
// Create accounts
let owner = Keypair::new();
let token_account = Keypair::new();
// Create instruction
let instruction = Instruction {
program_id,
accounts: vec![
AccountMeta::new(owner.pubkey(), true),
AccountMeta::new(token_account.pubkey(), false),
],
data: TokenInstruction::Initialize {
name: "My Token".to_string(),
symbol: "MTK".to_string(),
decimals: 9,
initial_supply: 1000000,
}
.try_to_vec()
.unwrap(),
};
// Send transaction
let transaction = Transaction::new_signed_with_payer(
&[instruction],
Some(&payer.pubkey()),
&[&payer, &owner, &token_account],
recent_blockhash,
);
banks_client.process_transaction(transaction).await.unwrap();
// Verify token account was initialized
let account = banks_client
.get_account(token_account.pubkey())
.await
.unwrap()
.unwrap();
let token_data = TokenAccount::try_from_slice(&account.data).unwrap();
assert_eq!(token_data.amount, 1000000);
assert_eq!(token_data.owner, owner.pubkey());
}
2. Integration Tests
#[tokio::test]
async fn test_transfer_tokens() {
// Setup similar to above...
// Initialize token
// ... initialization code ...
// Create transfer instruction
let transfer_instruction = Instruction {
program_id,
accounts: vec![
AccountMeta::new(owner.pubkey(), true),
AccountMeta::new(from_token_account.pubkey(), false),
AccountMeta::new(to_token_account.pubkey(), false),
],
data: TokenInstruction::Transfer { amount: 100 }
.try_to_vec()
.unwrap(),
};
// Execute transfer
let transfer_transaction = Transaction::new_signed_with_payer(
&[transfer_instruction],
Some(&payer.pubkey()),
&[&payer, &owner],
recent_blockhash,
);
banks_client.process_transaction(transfer_transaction).await.unwrap();
// Verify transfer
let from_account = banks_client
.get_account(from_token_account.pubkey())
.await
.unwrap()
.unwrap();
let from_data = TokenAccount::try_from_slice(&from_account.data).unwrap();
assert_eq!(from_data.amount, 999900); // 1000000 - 100
}
Building and Deploying
1. Build the Program
# Build for Solana
cargo build-bpf
# Or using Solana CLI
solana program build
2. Deploy to Localnet
# Start local Solana cluster
solana-test-validator
# Deploy the program
solana program deploy target/deploy/simple_token.so
# Get program ID
solana program show <PROGRAM_ID>
3. Deploy to Devnet
# Set cluster to devnet
solana config set --url devnet
# Airdrop SOL for deployment
solana airdrop 2
# Deploy program
solana program deploy target/deploy/simple_token.so
Frontend Integration
1. JavaScript Client
// client.js
import { Connection, PublicKey, Transaction } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
const connection = new Connection("https://api.devnet.solana.com");
const programId = new PublicKey("YOUR_PROGRAM_ID");
// Create token initialization instruction
async function initializeToken(
owner,
tokenAccount,
name,
symbol,
decimals,
supply
) {
const instruction = new TransactionInstruction({
keys: [
{ pubkey: owner.publicKey, isSigner: true, isWritable: false },
{ pubkey: tokenAccount.publicKey, isSigner: false, isWritable: true },
],
programId: programId,
data: Buffer.from(/* serialized TokenInstruction::Initialize */),
});
const transaction = new Transaction().add(instruction);
return await connection.sendTransaction(transaction, [owner, tokenAccount]);
}
// Transfer tokens
async function transferTokens(from, fromTokenAccount, toTokenAccount, amount) {
const instruction = new TransactionInstruction({
keys: [
{ pubkey: from.publicKey, isSigner: true, isWritable: false },
{ pubkey: fromTokenAccount.publicKey, isSigner: false, isWritable: true },
{ pubkey: toTokenAccount.publicKey, isSigner: false, isWritable: true },
],
programId: programId,
data: Buffer.from(/* serialized TokenInstruction::Transfer */),
});
const transaction = new Transaction().add(instruction);
return await connection.sendTransaction(transaction, [from]);
}
Advanced Features
1. Token Metadata
// Add metadata to token
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct TokenMetadata {
pub name: String,
pub symbol: String,
pub description: String,
pub image: String,
pub decimals: u8,
pub properties: Vec<(String, String)>,
}
2. Access Control
// Add role-based access control
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub enum Role {
Admin,
Minter,
Burner,
User,
}
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct AccessControl {
pub roles: std::collections::HashMap<Pubkey, Role>,
}
3. Events and Logging
// Add event logging
use solana_program::log::sol_log;
fn log_event(event: &str) {
sol_log(event);
}
// Usage
log_event("Token minted successfully");
Best Practices
1. Security
- Always validate input parameters
- Check account ownership and permissions
- Use proper error handling
- Implement reentrancy protection
- Validate account data before deserialization
2. Gas Optimization
- Minimize storage operations
- Use efficient data structures
- Batch operations when possible
- Avoid unnecessary computations
3. Testing
- Write comprehensive unit tests
- Test edge cases and error conditions
- Use integration tests for full workflows
- Test with different account configurations
Common Pitfalls
1. Account Validation
// ❌ Don't forget to validate accounts
fn bad_function(accounts: &[AccountInfo]) -> ProgramResult {
let account = &accounts[0];
// Missing validation!
Ok(())
}
// ✅ Always validate accounts
fn good_function(accounts: &[AccountInfo]) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let account = next_account_info(account_info_iter)?;
if !account.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
Ok(())
}
2. Data Serialization
// ❌ Incorrect serialization
account.data.borrow_mut()[..] = data;
// ✅ Proper serialization
data.serialize(&mut &mut account.data.borrow_mut()[..])?;
Conclusion
Building a token with Rust on Solana is a powerful way to create efficient, secure blockchain applications. The combination of Rust's safety guarantees and Solana's high-performance architecture makes it an excellent choice for token development.
Key takeaways:
- Start Simple: Begin with basic functionality and add complexity gradually
- Test Thoroughly: Comprehensive testing is crucial for blockchain applications
- Security First: Always validate inputs and check permissions
- Optimize Carefully: Balance functionality with gas efficiency
- Document Everything: Clear documentation helps with maintenance and updates
The token we built includes core functionality like minting, transferring, and burning. You can extend it with additional features like metadata, access control, and advanced economics.
Ready to build something amazing? Start with this foundation and gradually add the features your project needs!
Want to learn more about advanced Rust blockchain development? Check out our next guide on building DeFi protocols!