Program Configuration
Summary
- There are no "out of the box" solutions for creating distinct environments in an onchain program, but you can achieve something similar to environment variables if you get creative.
- You can use the
cfg
attribute with Rust features (#[cfg(feature = ...)]
) to run different code or provide different variable values based on the Rust feature provided. This happens at compile-time and doesn't allow you to swap values after a program has been deployed. - Similarly, you can use the
cfg!
macro to compile different code paths based on the features that are enabled. - Alternatively, you can achieve something similar to environment variables that can be modified after deployment by creating accounts and instructions that are only accessible by the program’s upgrade authority.
Lesson
One of the difficulties engineers face across all types of software development is that of writing testable code and creating distinct environments for local development, testing, production, etc.
This can be particularly difficult in Solana program development. For example, imagine creating an NFT staking program that rewards each staked NFT with 10 reward tokens per day. How do you test the ability to claim rewards when tests run in a few hundred milliseconds, not nearly long enough to earn rewards?
Traditional web development solves some of this with environment variables whose values can differ in each distinct "environment." Currently, there's no formal concept of environment variables in a Solana program. If there were, you could just make it so that rewards in your test environment are 10,000,000 tokens per day and it would be easier to test the ability to claim rewards.
Fortunately, you can achieve similar functionality if you get creative. The best approach is probably a combination of two things:
- Rust feature flags that allow you to specify in your build command the "environment" of the build, coupled with code that adjusts specific values accordingly
- Program "admin-only" accounts and instructions that are only accessible by the program's upgrade authority
Rust feature flags
One of the simplest ways to create environments is to use Rust features.
Features are defined in the [features]
table of the program’s Cargo.toml
file. You may define multiple features for different use cases.
[features]
feature-one = []
feature-two = []
It's important to note that the above simply defines a feature. To enable a
feature when testing your program, you can use the --features
flag with the
anchor test
command.
anchor test -- --features "feature-one"
You can also specify multiple features by separating them with a comma.
anchor test -- --features "feature-one", "feature-two"
Make code conditional using the cfg
attribute
With a feature defined, you can then use the cfg
attribute within your code to
conditionally compile code based on whether or not a given feature is enabled.
This allows you to include or exclude certain code from your program.
The syntax for using the cfg
attribute is like any other attribute macro:
#[cfg(feature=[FEATURE_HERE])]
. For example, the following code compiles the
function function_for_testing
when the testing
feature is enabled and the
function_when_not_testing
otherwise:
#[cfg(feature = "testing")]
fn function_for_testing() {
// code that will be included only if the "testing" feature flag is enabled
}
#[cfg(not(feature = "testing"))]
fn function_when_not_testing() {
// code that will be included only if the "testing" feature flag is not enabled
}
This allows you to enable or disable certain functionality in your Anchor program at compile time by enabling or disabling the feature.
It's not a stretch to imagine wanting to use this to create distinct "environments" for different program deployments. For example, not all tokens have deployments across both Mainnet and Devnet. So you might hard-code one token address for Mainnet deployments but hard-code a different address for Devnet and Localnet deployments. That way you can quickly switch between different environments without requiring any changes to the code itself.
The code below shows an example of an Anchor program that uses the cfg
attribute to include different token addresses for local testing compared to
other deployments:
use anchor_lang::prelude::*;
use anchor_spl::token::{Mint, Token, TokenAccount};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[cfg(feature = "local-testing")]
pub mod constants {
use solana_program::{pubkey, pubkey::Pubkey};
pub const USDC_MINT_PUBKEY: Pubkey = pubkey!("WaoKNLQVDyBx388CfjaVeyNbs3MT2mPgAhoCfXyUvg8");
}
#[cfg(not(feature = "local-testing"))]
pub mod constants {
use solana_program::{pubkey, pubkey::Pubkey};
pub const USDC_MINT_PUBKEY: Pubkey = pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
}
#[program]
pub mod test_program {
use super::*;
pub fn initialize_usdc_token_account(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = payer,
token::mint = mint,
token::authority = payer,
)]
pub token: Account<'info, TokenAccount>,
#[account(address = constants::USDC_MINT_PUBKEY)]
pub mint: Account<'info, Mint>,
#[account(mut)]
pub payer: Signer<'info>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
In this example, the cfg
attribute is used to conditionally compile two
different implementations of the constants
module. This allows the program to
use different values for the USDC_MINT_PUBKEY
constant depending on whether or
not the local-testing
feature is enabled.
Make code conditional using the cfg!
macro
Similar to the cfg
attribute, the cfg!
macro in Rust allows you to check
the values of certain configuration flags at runtime. This can be useful if you
want to execute different code paths depending on the values of certain
configuration flags.
You could use this to bypass or adjust the time-based constraints required in the NFT staking app we mentioned previously. When running a test, you can execute code that provides far higher staking rewards when compared to running a production build.
To use the cfg!
macro in an Anchor program, you simply add a cfg!
macro call
to the conditional statement in question:
#[program]
pub mod my_program {
use super::*;
pub fn test_function(ctx: Context<Test>) -> Result<()> {
if cfg!(feature = "local-testing") {
// This code will be executed only if the "local-testing" feature is enabled
// ...
} else {
// This code will be executed only if the "local-testing" feature is not enabled
// ...
}
// Code that should always be included goes here
...
Ok(())
}
}
In this example, the test_function
uses the cfg!
macro to check the value of
the local-testing
feature at runtime. If the local-testing
feature is
enabled, the first code path is executed. If the local-testing
feature is not
enabled, the second code path is executed instead.
Admin-only instructions
Feature flags are great for adjusting values and code paths at compilation, but they don't help much if you end up needing to adjust something after you've already deployed your program.
For example, if your NFT staking program has to pivot and use a different rewards token, there'd be no way to update the program without redeploying. If only there were a way for program admins to update certain program values... Well, it's possible!
First, you need to structure your program to store the values you anticipate changing in an account rather than hard-coding them into the program code.
Next, you need to ensure that this account can only be updated by some known program authority, or what we're calling an admin. That means any instructions that modify the data on this account need to have constraints limiting who can sign for the instruction. This sounds fairly straightforward in theory, but there is one main issue: how does the program know who is an authorized admin?
Well, there are a few solutions, each with their own benefits and drawbacks:
- Hard-code an admin public key that can be used in the admin-only instruction constraints.
- Make the program's upgrade authority the admin.
- Store the admin in the config account and set the first admin in an
initialize
instruction.