the steel framework for writing solana smart contracts
/Development

How to Write Solana Programs with Steel

15 min read

Steel is a lightweight, modular framework for writing native Solana programs with minimal boilerplate and maximum control. Built by Hardhat Chad (of Ore), Steel is designed for developers who want the performance of native Rust without sacrificing developer experience.

In this article, you’ll learn:

  • What Steel is and how it relates to Anchor and Pinocchio
  • How to define instructions and structure a Steel project
  • How to create a custom SPL token using Steel
  • How to test your program with solana-program-test

Prerequisites

This guide assumes you’re familiar with:

  • Basic Rust syntax and toolchain
  • Solana development fundamentals (accounts, instructions, programs)
  • CLI usage (e.g., cargo, solana, curl)

If you’re comfortable writing basic Solana or Rust programs, you’re ready to build with Steel.

What is Steel?

Steel is a new modular framework for building programs on Solana that allows developers to write programs with less boilerplate and is less opinionated compared to Anchor.

Steel offers macros and Cross-Program Invocation (CPI) helpers that help fast-track development of Solana programs in a native-like manner (without a framework), meaning you get native-like performance with a better developer experience.

Let’s explore some of the macros and helpers that Steel offers.

Steel Macros

Some of the macros Steel offers include:

account!

The account! macro defines Account types in Steel and also gives them access to the AccountValidation trait, which provides helpers for validating the state of accounts during development.

instruction!

The instruction! macro defines Instruction types in Steel and also gives them access to a to_bytes function, which will be used in api/src/sdk.

Other macros in Steel include error and event ; as their names suggest, they’re used for errors and events, respectively.

Steel CPI Helpers

Steel provides helper functions that most Cross-Program Invocations (CPIs) developers need while developing their program, such as instructions from the system_program, which include create_account, transfer, and more. 

It also includes instructions from the spl_token_program / spl_associated_token_program, which include mint_to , burn , create_associated_token_account and more.

CU optimizations

You might expect Steel to be CU-efficient because of what it does—but it’s actually efficient because of what it doesn’t do. Since the Steel framework is lightweight and adds little to no overhead to Solana programs, it's as optimal as Solana programs written in native Rust, and even more so with its use of bytemuck as its default data serializer.

Steel vs. Anchor

Anchor is an opinionated and powerful framework designed to build secure Solana programs quickly. It streamlines the development process by reducing boilerplate for areas such as account (de)serialization and instruction data, conducting essential security checks, generating client libraries automatically, and providing an extensive test environment.

What is the main difference between Steel and Anchor?

Anchor is a beginner-friendly smart contract framework that allows Solana developers of any level of expertise to write Solana programs quickly. Anchor focuses on an intuitive and friendly developer experience, which is why so many Solana developers rely on it.

However, this simplicity comes at a price.

Anchor has accumulated overhead that makes Solana program binaries more bloated, which negatively impacts their on-chain performance. For example, by increasing the cost of deploying Solana programs and calling instructions.

Due to Solana’s speed and efficiency, even with the overhead Anchor adds to Solana programs, most people don’t notice it, except for the few who develop more complex programs like Ore and Code-vm where the overhead would make them unusable on-chain.

Typically, Solana programs like these would be built with Native Rust, but their maintainers understand how daunting that would be and need to use a more friendly framework, comparable to Anchor, that is also highly performant like Native Rust.

Benefits and Tradeoffs between Steel and Anchor

Anchor, even with the overhead it adds to Solana programs, has the best developer experience in the Solana ecosystem, and is still the recommended framework for new Solana developers.

Anchor’s syntax is easy to understand, and it provides Interface Definition Languages (IDLs), which makes it easy to test Solana programs in other languages, such as JavaScript, and also develop client-side applications that communicate with Solana programs.

Anchor IDLs are so powerful that they can be used by tools like Codama to automatically generate clients, command line interfaces (CLIs), and documentation for Solana programs.

IDLs are a feature that the Steel framework does not currently have, and while its syntax is developer-friendly, Steel requires the developer to possess a good level of familiarity with Rust.

Although Anchor is recommended for new developers, it may limit more technical developers because it masks the inner workings of Solana program development in macros and its syntax. 

Steel, on the other hand, gives the developer access to all there is to a Solana program at its most primitive level. This level of granularity is especially helpful during the testing process, as tests are written in Rust by default, providing a one-on-one debugging experience.

Steel is a great smart contract framework because of what it is (i.e., a minimal wrapper around Native Rust) and what it isn’t (i.e., additional syntax that causes overhead).

Simply put, Steel is a more developer-friendly version of Native Rust, which retains its power without compromising its efficiency.

Steel vs. Pinocchio

Pinocchio is a zero-dependency library to create Solana programs in Rust. It was written by Febo as a side project, and later became a full-on Anza project. It leverages the way SVM loaders serialize program input parameters into a byte array, which is then passed to the program's entry point to define zero-copy types for reading the input.

Simply put, Pinocchio is a leaner version of solana_program that does not depend on any external crates and avoids the use of dynamic types.

Since Pinocchio was released, there have been a lot of misconceptions about what it is. The Pinocchio library is meant to replace the solana_program library—it’s not a competitor to Anchor or Steel. It complements these frameworks because it makes them lighter.

What most people regard as a Pinocchio program is just native Rust code that depends on pinocchio instead of solana_program.

How to Create a Token using Steel

To demonstrate how Steel works, we’re going to write a simple Solana program that creates an SPL token. If you’re a visual learner, you can watch the following video.

Prerequisites

  • Rust/Cargo
  • Solana
  • Steel

Install Rust

Rust can be installed from the official Rust website or via the CLI:

Code
curl --proto '=https' --tlsv1.2 -sSf <https://sh.rustup.rs> | sh

Install the Solana Tool Suite

Steel also requires the Solana Tool Suite. The latest release (i.e., 2.2.15, at the time of writing this article) can be installed with the following command for macOS and Linux:

Code
sh -c "$(curl -sSfL <https://release.anza.xyz/v2.2.14/install>)"

For Windows users, it is possible to install the Solana Tool Suite using the following command:

Code
cmd /c "curl <https://release.anza.xyz/v2.2.14/agave-install-init-x86_64-pc-windows-msvc.exe> --output C:\\agave-install-tmp\\agave-install-init.exe --create-dirs"

However, it is strongly recommended that you use Windows Subsystem for Linux (WSL) instead. This will enable you to run a Linux environment on your Windows machine without requiring dual-booting or setting up a separate virtual machine. By taking this route, refer back to the installation instructions for Linux (i.e., the curl command).

Developers can replace v2.2.15 with a release tag of the desired version to download, or use the stable, beta, or edge channel names. 

Once installed, run solana –-version to confirm the desired version of solana is installed.

Install Steel

We can install Steel with Cargo by running:

Code
cargo install steel-cli

Create a Steel Project

Creating a Steel project is as easy as running:

Code
// creates a new Steel project named `create-token`
steel new token

// enter directory
cd create-token

Our token directory should look like this:

Code
Cargo.toml (workspace)
 api
 Cargo.toml
 src
 consts.rs
 error.rs
 instruction.rs
 lib.rs
 sdk.rs
 state
 mod.rs
 account_1.rs
 account_2.rs
 program
 Cargo.toml
 src
 lib.rs
 instruction_1.rs
 instruction_2.rs

The default layout of a Steel project contains two folders named api and program.

api contains types like state, errors that we’re going to be using while implementing our Solana program, and the program folder contains the program logic. 

When developing programs with Steel, it’s preferable to start with the api folder since the program folder is dependent on it.

Removing state, const, and error Modules

In the api folder, there are some modules we’ll not be using for our create-token project, like state, const, and error, so let’s remove them. 

We can remove Steel modules by running the following commands:

Code
# you should be at the root of the `create-token` project

# enter the api/src directory
cd api/src

# delete the modules we don't need 
rm -rf state [consts.rs](<http://consts.rs/>) [error.rs](<http://error.rs/>)

After deleting the modules, we must update our api/src/lib.rs file since it calls those modules.

Update api/src/lib.rs to look like this:

Code
pub mod instruction;
pub mod sdk;

pub mod prelude {
    pub use crate::instruction::*;
    pub use crate::sdk::*;
}

use steel::*;

// TODO Set program id
declare_id!("z7msBPQHDJjTvdQRoEcKyENgXDhSRYeHieN1ZMTqo35");

Defining Instructions in Steel

In Steel, instructions are defined in api/src/instructions.rs. All the instructions of a Steel program are defined in an enum, and each instruction is a struct.

The enum containing all the instructions looks like this:

Code
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, TryFromPrimitive)]
pub enum CreateTokenInstruction {
    Initialize = 0,
    Add = 1
}
While each instruction typically looks like this:
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
pub struct Initialize {}

#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
pub struct Add {
    pub amount: [u8; 8]
}

If an instruction doesn’t require arguments, like Initialize, it has no fields. 

Instructions that do require data use a byte representation. For example, Add::amount is [u8; 8], which maps to a u64.

After defining our instructions enum and instruction struct, we have to pass them in the instruction! macro, with our first argument being the instructions enum and the second argument being the instruction struct:

Code
instruction!(CreateTokenInstruction, Initialize);
instruction!(CreateTokenInstruction, Add);

Our create-token program has one instruction, which takes four arguments, so api/src/instructions should look like this:

Code
use steel::*;

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, TryFromPrimitive)]
pub enum CreateTokenInstruction {
    Create = 0,
}

#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
pub struct Create {
    pub name: [u8; 32],
    pub symbol: [u8; 8],
    pub uri: [u8; 128],
    pub decimals: u8,
}

instruction!(CreateTokenInstruction, Create);

In Create, the name, symbol, and uri fields are strings represented as fixed-size byte arrays:

  • name: [u8; 16] — for names up to 16 bytes
  • symbol: [u8; 8] — symbols are usually short
  • uri: [u8; 128] — URIs are typically longer

These sizes depend on the expected maximum length in bytes, not characters (e.g., multibyte UTF-8 chars may require more).

decimals is simply a u8, since the number of decimals for the token will fit in one byte.

Updating SDK

In api/src, we have a file called sdk.rs, which we don’t use while implementing our program logic, but which we will use to run tests or Rust client code. It contains functions that build all the instructions in a Steel program individually. Since we have only one instruction in this program, we’re going to need only one SDK function, so our api/src/sdk.rs should look like this:

Code
use steel::*;

use crate::prelude::*;

pub fn create(
    user: Pubkey,
    mint: Pubkey,
    name: [u8; 32],
    symbol: [u8; 8],
    uri: [u8; 128],
    decimals: u8,
) -> Instruction {
    let metadata = Pubkey::find_program_address(
        &[
            "metadata".as_bytes(),
            mpl_token_metadata::ID.as_ref(),
            mint.as_ref(),
        ],
        &mpl_token_metadata::ID,
    )
    .0;

    Instruction {
        program_id: crate::ID,
        accounts: vec![
            AccountMeta::new(user, true),
            AccountMeta::new(mint, true),
            AccountMeta::new(metadata, false),
            AccountMeta::new_readonly(spl_token::ID, false),
            AccountMeta::new_readonly(mpl_token_metadata::ID, false),
            AccountMeta::new_readonly(system_program::ID, false),
            AccountMeta::new_readonly(sysvar::rent::ID, false),
        ],
        data: Create {
            name,
            symbol,
            uri,
            decimals,
        }
        .to_bytes(),
    }
}

We have one function called create which takes in five arguments: user is the public key of the account that’s going to be calling this instruction, mint is public key of the account that’s going to represent the token mint while name , symbol , uri and decimals are all data that’ll be used while implementing the program logic which we defined in api/src/instructions::Create.

We need to store our token’s metadata, and we will be using the Metaplex Metadata program to do this. First, we’re going to add:

Code
let metadata = Pubkey::find_program_address(
        &[
            "metadata".as_bytes(),
            mpl_token_metadata::ID.as_ref(),
            mint.as_ref(),
        ],
        &mpl_token_metadata::ID,
    )
    .0;

In this code block, we’re trying to get the Program Derived Address (PDA) where we’re going to be storing our token metadata. To derive the address we need, we’ll need the following seeds:

  • The string “metadata” as bytes (i.e., "metadata".as_bytes())
  • The program ID of the metadata program as a slice (i.e., mpl_token_metadata::ID.as_ref())
  • The mint public key as a slice (i.e., mint.as_ref())

All these inputs together make the seeds, and for the second argument of Pubkey::find_program::address, we just need the program ID of the Metadata program.

In the final code block, we return the Instruction type that represents this instruction.

The Instruction type looks like this:

Code
 Instruction {
        program_id: crate::ID,
        accounts: vec![
            AccountMeta::new(user, true),
            AccountMeta::new(mint, true),
            AccountMeta::new(metadata, false),
            AccountMeta::new_readonly(spl_token::ID, false),
            AccountMeta::new_readonly(mpl_token_metadata::ID, false),
            AccountMeta::new_readonly(system_program::ID, false),
            AccountMeta::new_readonly(sysvar::rent::ID, false),
        ],
        data: Create {
            name,
            symbol,
            uri,
            decimals,
        }
        .to_bytes(),
    }

 The Instruction type is a struct with three fields:

  • program_id 
  • accounts
  • data

In this block, we declare an instance of Instruction that fits the instruction of our program.

To get program_id from api/src/lib.rs use:

Code
program_id: crate::ID 

The accounts field is a vector of Account metadata (i.e., Vec<AccountMeta>), so we have to declare all the accounts that’ll be used in this instruction:

Code
accounts: vec![
            AccountMeta::new(user, true),
            AccountMeta::new(mint, true),
            AccountMeta::new(metadata, false),
            AccountMeta::new_readonly(spl_token::ID, false),
            AccountMeta::new_readonly(mpl_token_metadata::ID, false),
            AccountMeta::new_readonly(system_program::ID, false),
            AccountMeta::new_readonly(sysvar::rent::ID, false),
        ],

Finally, the data field represents the arguments we’ll use for these instructions as bytes:

Code
data: Create {
            name,
            symbol,
            uri,
            decimals,
        }
        .to_bytes(),

Now, we’re done with the api folder.

Next, let’s add the necessary dependencies, and then proceed to the program folder.

Adding Steel Dependencies

Right now, if you run steel build to compile your program, it should fail with these errors:

Code
error[E0433]: failed to resolve: use of undeclared crate or module `mpl_token_metadata`
  --> api/src/sdk.rs:16:13
   |
16 |             mpl_token_metadata::ID.as_ref(),
   |             ^^^^^^^^^^^^^^^^^^ use of undeclared crate or module `mpl_token_metadata`

error[E0433]: failed to resolve: use of undeclared crate or module `mpl_token_metadata`
  --> api/src/sdk.rs:19:10
   |
19 |         &mpl_token_metadata::ID,
   |          ^^^^^^^^^^^^^^^^^^ use of undeclared crate or module `mpl_token_metadata`

error[E0433]: failed to resolve: use of undeclared crate or module `spl_token`
  --> api/src/sdk.rs:29:39
   |
29 |             AccountMeta::new_readonly(spl_token::ID, false),
   |                                       ^^^^^^^^^ use of undeclared crate or module `spl_token`

error[E0433]: failed to resolve: use of undeclared crate or module `mpl_token_metadata`
  --> api/src/sdk.rs:30:39
   |
30 |             AccountMeta::new_readonly(mpl_token_metadata::ID, false),
   |                                       ^^^^^^^^^^^^^^^^^^ use of undeclared crate or module `mpl_token_metadata`

This indicates that we’re missing the spl_token and mpl_token_metadata crates, which are required for our program.

To add missing crates, add this to the /Cargo.toml file:

Code
// /Cargo.toml

[workspace.dependencies]
...
...
mpl-token-metadata = "5.1.0"
spl-token = { version = "8.0.0", features = ["no-entrypoint"] }
In /api/Cargo.toml add: 
// /api/Cargo.toml

[dependencies]
...
...
mpl-token-metadata.workspace = true
spl-token.workspace = true

In /api/Cargo.toml add: 

Code
// /api/Cargo.toml

[dependencies]
...
...
mpl-token-metadata.workspace = true
spl-token.workspace = true

Now, if we run steel build our dependency errors should be gone.

However, because we deleted code in the api folder that the program folder depends on, we’ll continue to see some errors that look like this:

Code
error[E0599]: no variant or associated item named `Initialize` found for enum `create_token_api::instruction::CreateTokenInstruction` in the current scope
  --> program/src/lib.rs:18:33
   |
18 |         CreateTokenInstruction::Initialize => process_initialize(accounts, data)?,
   |                                 ^^^^^^^^^^ variant or associated item not found in `CreateTokenInstruction`

error[E0599]: no variant or associated item named `Add` found for enum `create_token_api::instruction::CreateTokenInstruction` in the current scope
  --> program/src/lib.rs:19:33
   |
19 |         CreateTokenInstruction::Add => process_add(accounts, data)?,
   |                                 ^^^ variant or associated item not found in `CreateTokenInstruction`

Don’t worry, we’ll fix these errors in the next section.

Implementing Program Logic with Steel

Steel projects come with two folders by default: api and program. We just defined the types we need in our program in the api folder, and now we must implement our program logic in the program folder.

To start, update /program/lib.rs with:

Code
mod create;

use create::*;

use create_token_api::prelude::*;
use steel::*;

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    data: &[u8],
) -> ProgramResult {
    let (ix, data) = parse_instruction(&create_token_api::ID, program_id, data)?;

    match ix {
        CreateTokenInstruction::Create => process_create(accounts, data)?,
    }

    Ok(())
}

entrypoint!(process_instruction);

In this file, we define our main process_instruction function, which we pass to the entrypoint! macro. The macro generates the boilerplate needed for the Solana runtime to call our program logic.

Inside the process_instruction function, there are two important code blocks we need to talk about.

Code
 let (ix, data) = parse_instruction(&create_token_api::ID, program_id, data)?;

parse_instruction parses an instruction from the instruction data. This means that with the data passed to our program, we can determine which instruction to invoke. 

It returns a tuple of instruction(ix) and instruction data(data) in an Ok() case.

Code
match ix {
        CreateTokenInstruction::Create => process_create(accounts, data)?,
    }

Once we’ve gotten our instruction(ix) from parse_instruction, we use match to select the instruction to invoke. There’s only one match arm in here because our program only has one instruction. 

Now, we have our program logic set up to call the proper instruction when invoked. However, process_create and the create mod don’t exist yet, so let’s create them.

In a terminal, run:

Code
// you should be at the root of your project 

 // enter the program/src directory
 cd program/src
 
 // delete add.rs and initialize.rs
 rm -rf add.rs initialize.rs
 
 // create create.rs
 touch create.rs 

Now update program/src/create.rs with:

Code
use create_token_api::prelude::*;
use solana_program::{msg, program_pack::Pack};
use steel::*;

pub fn process_create(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult {
    // Load accounts.
    let [user_info, mint_info, metadata_info, token_program, token_metadata_program, system_program, rent_sysvar] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    // validate
    user_info.is_signer()?;
    mint_info.is_empty()?.is_signer()?;
    metadata_info.is_empty()?.is_writable()?;
    token_program.is_program(&spl_token::ID)?;
    token_metadata_program.is_program(&mpl_token_metadata::ID)?;
    system_program.is_program(&system_program::ID)?;
    rent_sysvar.is_sysvar(&sysvar::rent::ID)?;

    // create mint account
    create_account(
        user_info,
        mint_info,
        system_program,
        spl_token::state::Mint::LEN,
        &token_program.key,
    )?;

    msg!("create account");

    let args = Create::try_from_bytes(data)?;
    let name = bytes_to_string::<32>(&args.name)?;
    let symbol = bytes_to_string::<8>(&args.symbol)?;
    let uri = bytes_to_string::<128>(&args.uri)?;
    let decimals = args.decimals;
   
    // initialize mint
    initialize_mint(
        mint_info,
        user_info,
        Some(user_info),
        token_program,
        rent_sysvar,
        decimals,
    )?;

    msg!("initialize mint");

    // create metadata account
    mpl_token_metadata::instructions::CreateMetadataAccountV3Cpi {
        __program: token_metadata_program,
        metadata: metadata_info,
        mint: mint_info,
        mint_authority: user_info,
        payer: user_info,
        update_authority: (user_info, true),
        system_program,
        rent: Some(rent_sysvar),
        __args: mpl_token_metadata::instructions::CreateMetadataAccountV3InstructionArgs {
            data: mpl_token_metadata::types::DataV2 {
                name,
                symbol,
                uri,
                seller_fee_basis_points: 0,
                creators: None,
                collection: None,
                uses: None,
            },
            is_mutable: true,
            collection_details: None,
        },
    }
    .invoke()?;
    msg!("metadata account created");

    Ok(())
}

Let’s walk through what’s happening here.

Code
// Load accounts.
    let [user_info, mint_info, metadata_info, token_program, token_metadata_program, system_program, rent_sysvar] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

In this code block, we load the necessary accounts for this instruction. If the accounts that are passed don’t match the accounts defined, this block will throw the ProgramError::NotEnoughAccountKeys error.

If you look closely, you’ll notice a pattern in how accounts are named:

  • “Regular” accounts end with info
  • Program accounts end with program
  • Sysvars end with sysvar

This is an opinionated way of naming accounts in Steel—you can decide to do something different as it has no real impact on the program.

Next, this code block validates our accounts:

Code
// validate
user_info.is_signer()?; // user is a signer
mint_info.is_empty()?.is_signer()?; // mint is empty and is a signer
metadata_info.is_empty()?.is_writable()?; // metadata is empty and is writable
token_program.is_program(&spl_token::ID)?; // token program == spl_token::ID
token_metadata_program.is_program(&mpl_token_metadata::ID)?; // token meatadata == mpl_token_metadata::ID
system_program.is_program(&system_program::ID)?; // system program == system_program::ID
rent_sysvar.is_sysvar(&sysvar::rent::ID)?; // rent sysvar == sysvar::rent::ID

Next, we create the mint account using the create_account helper:

Code
// create mint account
    create_account(
        user_info,
        mint_info,
        system_program,
        spl_token::state::Mint::LEN,
        &token_program.key,
    )?;

After creating the mint accounts, we deserialize the instruction data from bytes to Rust types:

Code
    let args = Create::try_from_bytes(data)?;
    let name = bytes_to_string::<32>(&args.name)?;
    let symbol = bytes_to_string::<8>(&args.symbol)?;
    let uri = bytes_to_string::<128>(&args.uri)?;
    let decimals = args.decimals;

The first line converts the instruction data, which is of type &[u8], to api/instructions.rs/Create, while the next three lines convert the fields in Create, which are bytes, to strings with the bytes_to_string helper.

Also, notice that bytes_to_string takes a const generic parameter (i.e., ::<32>) that helps generate the string with the exact length to save compute units.

Code
// initialize mint
    initialize_mint(
        mint_info,
        user_info,
        Some(user_info),
        token_program,
        rent_sysvar,
        decimals,
    )?;

Next, we initialize the mint account with the initialize_mint helper function.

Code
// create metadata account
    mpl_token_metadata::instructions::CreateMetadataAccountV3Cpi {
        __program: token_metadata_program,
        metadata: metadata_info,
        mint: mint_info,
        mint_authority: user_info,
        payer: user_info,
        update_authority: (user_info, true),
        system_program,
        rent: Some(rent_sysvar),
        __args: mpl_token_metadata::instructions::CreateMetadataAccountV3InstructionArgs {
            data: mpl_token_metadata::types::DataV2 {
                name,
                symbol,
                uri,
                seller_fee_basis_points: 0,
                creators: None,
                collection: None,
                uses: None,
            },
            is_mutable: true,
            collection_details: None,
        },
    }
    .invoke()?;

Here, we created the metadata account for our token mint. It contains information such as the name, symbol, and creators of the collection.

Now that we’re done with create.rs file, let’s run steel build

We should see the following errors:

Code
error[E0433]: failed to resolve: use of undeclared crate or module `spl_token`
  --> program/src/create.rs:28:9
   |
28 |         spl_token::state::Mint::LEN,
   |         ^^^^^^^^^ use of undeclared crate or module `spl_token`

error[E0433]: failed to resolve: use of undeclared crate or module `mpl_token_metadata`
  --> program/src/create.rs:69:5
   |
69 |     mpl_token_metadata::instructions::CreateMetadataAccountV3Cpi {
   |     ^^^^^^^^^^^^^^^^^^ use of undeclared crate or module `mpl_token_metadata`

error[E0433]: failed to resolve: use of undeclared crate or module `mpl_token_metadata`
  --> program/src/create.rs:78:17
   |
78 |         __args: mpl_token_metadata::instructions::CreateMetadataAccountV3Instru...
   |                 ^^^^^^^^^^^^^^^^^^ use of undeclared crate or module `mpl_token_metadata`

error[E0433]: failed to resolve: use of undeclared crate or module `mpl_token_metadata`
  --> program/src/create.rs:79:19
   |
79 |             data: mpl_token_metadata::types::DataV2 {
   |                   ^^^^^^^^^^^^^^^^^^ use of undeclared crate or module `mpl_token_metadata`

These errors indicate that we’re encountering dependency errors. We can update it by editing our /program/Cargo.toml file with:

Code
[dependencies]
...
...
mpl-token-metadata.workspace = true
spl-token.workspace = true

Now, if we run steel build again, we’ll run into one last error:

Code
error[E0425]: cannot find function `initialize_mint` in this scope
  --> program/src/create.rs:57:5
   |
57 |     initialize_mint(
   |     ^^^^^^^^^^^^^^^ not found in this scope

We get this because the initialize_mint helper function needs the spl feature in Steel to be accessed, so we have to update its import in the /Cargo.toml file:

Code
[workspace.dependencies]
...
...
steel = { version = "3.0", features = ["spl"] }

Now, if we run steel build our program should compile with no errors. 

Congratulations on making it this far!

One last step: we have to test our program.

Testing your Steel Program

Tests in Steel are written in Rust by default. Steel uses solana-program-test for testing, but you can use liteSVM or mollusk if you prefer.

Tests are written in /program/tests/test.rs.

Let’s start off by updating it with:

Code
use create_token_api::prelude::*;
use solana_program::hash::Hash;
use solana_program_test::{processor, BanksClient, ProgramTest};
use solana_sdk::{
    program_pack::Pack, signature::Keypair, signer::Signer, transaction::Transaction,
};
use steel::*;

async fn setup() -> (BanksClient, Keypair, Hash) {
    let mut program_test = ProgramTest::new(
        "create_token_program",
        create_token_api::ID,
        processor!(create_token_program::process_instruction),
    );

    program_test.add_program("token_metadata", mpl_token_metadata::ID, None);

    program_test.prefer_bpf(true);
    program_test.start().await
}

#[tokio::test]
async fn run_test() {
    // Setup test
    let (mut banks, payer, blockhash) = setup().await;
    let mint_keypair = Keypair::new();

    let name = string_to_bytes::<32>("ANATOLY").unwrap();
    let symbol = string_to_bytes::<8>("MERT").unwrap();
    let uri = string_to_bytes::<128>("blah blah blah").unwrap();
    let decimals = 9;

    // Submit create transaction.
    let ix = create(
        payer.pubkey(),
        mint_keypair.pubkey(),
        name,
        symbol,
        uri,
        decimals,
    );
    let tx = Transaction::new_signed_with_payer(
        &[ix],
        Some(&payer.pubkey()),
        &[&payer, &mint_keypair],
        blockhash,
    );
    let res = banks.process_transaction(tx).await;
    assert!(res.is_ok());

    let serialized_mint_data = banks
        .get_account(mint_keypair.pubkey())
        .await
        .unwrap()
        .unwrap()
        .data;

    let mint_data = spl_token::state::Mint::unpack(&serialized_mint_data).unwrap();
    assert!(mint_data.is_initialized);
    assert_eq!(mint_data.mint_authority.unwrap(), payer.pubkey());
    assert_eq!(mint_data.decimals, decimals);
}

Our test file consists of two functions, setup and run_test.

We do three important things in the setup function:

  1. We create an instance of ProgramTest, which has our create_token_program program added to it by default
  2. We add the token_metadata program to our instance of ProgramTest because the Metaplex token program, which we’re using, isn’t part of ProgramTest by default
  3. We start an instance of ProgramTest with the start method, which returns a tuple of (BanksClient, Keypair, Hash)
Code
async fn setup() -> (BanksClient, Keypair, Hash) {
    let mut program_test = ProgramTest::new(
        "create_token_program",
        create_token_api::ID,
        processor!(create_token_program::process_instruction),
    );

    program_test.add_program("token_metadata", mpl_token_metadata::ID, None);

    program_test.prefer_bpf(true);
    program_test.start().await
}

In the first part of run_test we call the setup function and create a Keypair for our token mint.

Code
// Setup test
 let (mut banks, payer, blockhash) = setup().await;
 let mint_keypair = Keypair::new();

Next, we prepare our instruction data.

Since our create instruction expects byte representations, we use the string_to_bytes helper to convert our strings to bytes.

Code
   let name = string_to_bytes::<32>("ANATOLY").unwrap();
    let symbol = string_to_bytes::<8>("MERT").unwrap();
    let uri = string_to_bytes::<128>("blah blah blah").unwrap();
    let decimals = 9;

Remember back in the api folder, we implemented a function that we didn’t use in our program logic, which was the create function in api/src/sdk.rs?

That’s the same function we’re calling first in the code block below to create an instance of an Instruction, which we pass to our Transaction instance with Transaction::new_signed_with_payer, and then we pass our transaction to banks.process_transaction(tx).await; to be processed.

assert!(res.is_ok()); confirms that the transaction was processed.

Code
// Submit create transaction.
    let ix = create(
        payer.pubkey(),
        mint_keypair.pubkey(),
        name,
        symbol,
        uri,
        decimals,
    );
    
    let tx = Transaction::new_signed_with_payer(
        &[ix],
        Some(&payer.pubkey()),
        &[&payer, &mint_keypair],
        blockhash,
    );
    
    let res = banks.process_transaction(tx).await;
    assert!(res.is_ok());

What we’ve done so far is execute our instruction in our test environment (ProgramTest).

Now let’s test if it executed properly:

Code
// get serialized data of mint account 
let serialized_mint_data = banks
        .get_account(mint_keypair.pubkey())
        .await
        .unwrap()
        .unwrap()
        .data;

// unpack the mint account data to get the SPL Mint information
let mint_data = spl_token::state::Mint::unpack(&serialized_mint_data).unwrap();

// check if the mint account was initilized 
assert!(mint_data.is_initialized);

// check if the mint authority matches the one we set
assert_eq!(mint_data.mint_authority.unwrap(), payer.pubkey());

// check if the decimals match
assert_eq!(mint_data.decimals, decimals);

We could write more assertions that check for other things like the data stored in the metadata account, but we’ll stop here for simplicity.

If you’re up to it, you can add them yourself, and if you need help, look at this Solana developer guide to steel tests.

Now that we’re done with our test file, let’s run the test command - steel test

Unfortunately, it’ll fail because we don’t have the source code/ELF file for the mpl_token_metadata program.

Don’t worry, we can fix that by running:

Code
// you have to be at the root of your project 

// create a folder called fixtures in program/tests 
// ProgramTest is going to check this folder for the ELF file for token metadata
mkdir program/tests/fixtures

// dump the ELF file for the Metaplex metadata program in the fixtures folder
solana program dump metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s program/tests/fixtures/token_metadata.so

Now, if we run steel test we should get the following:

Code
running 1 test
[2025-06-08T14:46:12.240628000Z INFO  solana_program_test] "create_token_program" SBF program from /Users/perelyn/helius/create-token/target/deploy/create_token_program.so, modified 3 seconds, 112 ms, 833 µs and 660 ns ago
[2025-06-08T14:46:12.242336000Z INFO  solana_program_test] "token_metadata" SBF program from tests/fixtures/token_metadata.so, modified 1 minute, 49 seconds, 247 ms, 367 µs and 400 ns ago
[2025-06-08T14:46:12.381492000Z DEBUG solana_runtime::message_processor::stable_log] Program z7msBPQHDJjTvdQRoEcKyENgXDhSRYeHieN1ZMTqo35 invoke [1]
[2025-06-08T14:46:12.382734000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 invoke [2]
[2025-06-08T14:46:12.383272000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 success
[2025-06-08T14:46:12.383298000Z DEBUG solana_runtime::message_processor::stable_log] Program log: create account
[2025-06-08T14:46:12.383562000Z DEBUG solana_runtime::message_processor::stable_log] Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
[2025-06-08T14:46:12.383783000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Instruction: InitializeMint
[2025-06-08T14:46:12.386049000Z DEBUG solana_runtime::message_processor::stable_log] Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 2968 of 192320 compute units
[2025-06-08T14:46:12.386068000Z DEBUG solana_runtime::message_processor::stable_log] Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
[2025-06-08T14:46:12.386099000Z DEBUG solana_runtime::message_processor::stable_log] Program log: initialize mint
[2025-06-08T14:46:12.386409000Z DEBUG solana_runtime::message_processor::stable_log] Program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s invoke [2]
[2025-06-08T14:46:12.387342000Z DEBUG solana_runtime::message_processor::stable_log] Program log: IX: Create Metadata Accounts v3
[2025-06-08T14:46:12.387576000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 invoke [3]
[2025-06-08T14:46:12.387588000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 success
[2025-06-08T14:46:12.387999000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Allocate space for the account
[2025-06-08T14:46:12.388226000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 invoke [3]
[2025-06-08T14:46:12.388264000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 success
[2025-06-08T14:46:12.388306000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Assign the account to the owning program
[2025-06-08T14:46:12.388851000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 invoke [3]
[2025-06-08T14:46:12.388873000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 success
[2025-06-08T14:46:12.392769000Z DEBUG solana_runtime::message_processor::stable_log] Program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s consumed 37330 of 185782 compute units
[2025-06-08T14:46:12.392790000Z DEBUG solana_runtime::message_processor::stable_log] Program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s success
[2025-06-08T14:46:12.392842000Z DEBUG solana_runtime::message_processor::stable_log] Program log: metadata account created
[2025-06-08T14:46:12.395012000Z DEBUG solana_runtime::message_processor::stable_log] Program z7msBPQHDJjTvdQRoEcKyENgXDhSRYeHieN1ZMTqo35 consumed 51973 of 200000 compute units
[2025-06-08T14:46:12.395031000Z DEBUG solana_runtime::message_processor::stable_log] Program z7msBPQHDJjTvdQRoEcKyENgXDhSRYeHieN1ZMTqo35 success
test run_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s

Congratulations again!

If you get the same output, your program passed the tests.

Conclusion

Steel is a modular and lightweight development framework for building smart, performance-optimized Solana programs. This article explained how Steel works, compared Steel to Anchor and Pinocchio, and walked through an example of how to create a new token using Steel.

Additional Resources

To continue learning about Steel and Solana program development, explore these resources:

Related Articles

Subscribe to Helius

Stay up-to-date with the latest in Solana development and receive updates when we post