
How to Write Solana Programs with Steel
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.
Introducing: Steel Steel is a new modular framework for building smart contracts on Solana. Write programs with minimum boilerplate and maximum flexibility. 🧵
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.
Note:
To be able to access the CPI helpers from spl_token_program
and spl_associated_token_program in Steel, you’ll have to enable the spl feature flag.
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:
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:
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:
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:
cargo install steel-cli
Create a Steel Project
Creating a Steel project is as easy as running:
// creates a new Steel project named `create-token`
steel new token
// enter directory
cd create-token
Our token
directory should look like this:
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:
# 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:
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:
#[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:
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:
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 bytessymbol
: [u8; 8] — symbols are usually shorturi
: [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:
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:
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:
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:
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:
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:
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:
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:
// /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:
// /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:
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:
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.
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.
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:
// 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:
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.
// 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:
// 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
Note:
Steel has simple helpers for validating accounts that are chainable.
Next, we create the mint
account using the create_account
helper:
// 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:
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.
// 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.
// 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:
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:
[dependencies]
...
...
mpl-token-metadata.workspace = true
spl-token.workspace = true
Now, if we run steel build
again, we’ll run into one last error:
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:
[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:
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:
- We create an instance of
ProgramTest
, which has ourcreate_token_program
program added to it by default - We add the
token_metadata
program to our instance ofProgramTest
because the Metaplex token program, which we’re using, isn’t part ofProgramTest
by default - We start an instance of
ProgramTest
with thestart
method, which returns a tuple of (BanksClient
,Keypair
,Hash
)
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.
// 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.
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.
// 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:
// 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:
// 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:
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:
- Steel GitHub Repo
- Solana Development Bootcamp (GitHub)
- Solana Development Bootcamp (Video)
- Blueshift — Learn how to write your own on-chain programs
Related Articles
Subscribe to Helius
Stay up-to-date with the latest in Solana development and receive updates when we post