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 Orbition Native Chain 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 Orbition Native Chain 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
flag with the
anchor test
command.
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
attribute to include different token addresses for local testing compared to
other deployments:
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:
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.
Create the config account
The first step is adding what we’ll call a “config” account to your program. You can customize this to best suit your needs, but we suggest a single global PDA. In Anchor, that simply means creating an account struct and using a single seed to derive the account’s address.Constrain config updates to hard-coded admins
You’ll need a way to initialize and update the config account data. That means you need to have one or more instructions that only an admin can invoke. The simplest way to do this is to hard-code an admin’s public key in your code and then add a simple signer check into your instruction’s account validation comparing the signer to this public key. In Anchor, constraining anupdate_program_config
instruction to only be usable
by a hard-coded admin might look like this:
ADMIN_PUBKEY
. Notice that the
example above doesn’t show the instruction that initializes the config account,
but it should have similar constraints to ensure that an attacker can’t
initialize the account with unexpected values.
While this approach works, it also means keeping track of an admin wallet on top
of keeping track of a program’s upgrade authority. With a few more lines of
code, you could simply restrict an instruction to only be callable by the
upgrade authority. The only tricky part is getting a program’s upgrade authority
to compare against.
Constrain config updates to the program’s upgrade authority
Fortunately, every program has a program data account that translates to the AnchorProgramData
account type and has the upgrade_authority_address
field.
The program itself stores this account’s address in its data in the field
programdata_address
.
So in addition to the two accounts required by the instruction in the hard-coded
admin example, this instruction requires the program
and the program_data
accounts.
The accounts then need the following constraints:
- A constraint on
program
ensuring that the providedprogram_data
account matches the program’sprogramdata_address
field - A constraint on the
program_data
account ensuring that the instruction’s signer matches theprogram_data
account’supgrade_authority_address
field.
Constrain config updates to a provided admin
Both of the previous options are fairly secure but also inflexible. What if you want to update the admin to be someone else? For that, you can store the admin on the config account.admin
field.
initialize
, it’s very
unlikely that an attacker is even aware of your program’s existence much less
trying to make themselves the admin. If by some crazy stroke of bad luck someone
“intercepts” your program, you can close the program with the upgrade authority
and redeploy.
Lab
Now let’s go ahead and try this out together. For this lab, we’ll be working with a simple program that enables USDC payments. The program collects a small fee for facilitating the transfer. Note that this is somewhat contrived since you can do direct transfers without an intermediary contract, but it simulates how some complex DeFi programs work. We’ll quickly learn while testing our program that it could benefit from the flexibility provided by an admin-controlled configuration account and some feature flags.1. Starter
Download the starter code from thestarter
branch
of this repository.
The code contains a program with a single instruction and a single test in the
tests
directory.
Let’s quickly walk through how the program works.
The lib.rs
file includes a constant for the USDC address and a single
payment
instruction. The payment
instruction simply calls the
payment_handler
function in the instructions/payment.rs
file where the
instruction logic is contained.
The instructions/payment.rs
file contains both the payment_handler
function
as well as the Payment
account validation struct representing the accounts
required by the payment
instruction. The payment_handler
function calculates
a 1% fee from the payment amount, transfers the fee to a designated token
account, and transfers the remaining amount to the payment recipient.
Finally, the tests
directory has a single test file, config.ts
that simply
invokes the payment
instruction and asserts that the corresponding token
account balances have been debited and credited accordingly.
Before we continue, take a few minutes to familiarize yourself with these files
and their contents.
2. Run the existing test
Let’s start by running the existing test. Make sure you useyarn
or npm install
to install the dependencies laid out
in the package.json
file. Then be sure to run anchor keys list
to get the
public key for your program printed to the console. This differs based on the
keypair you have locally, so you need to update lib.rs
and Anchor.toml
to
use your key.
Finally, run anchor test
to start the test. It should fail with the following
output:
lib.rs
file of the program), but that mint
doesn’t exist in the local environment.
3. Adding a local-testing
feature
To fix this, we need a mint we can use locally and hard-code into the program.
Since the local environment is reset often during testing, you’ll need to store
a keypair that you can use to recreate the same mint address every time.
Additionally, you don’t want to have to change the hard-coded address between
local and mainnet builds since that could introduce human error (and is just
annoying). So we’ll create a local-testing
feature that, when enabled, will
make the program use our local mint but otherwise use the production USDC mint.
Generate a new keypair by running solana-keygen grind
. Run the following
command to generate a keypair with a public key that begins with “env”.
lib.rs
file. Use the cfg
attribute to define the USDC_MINT_PUBKEY
constant depending on whether the
local-testing
feature is enabled or disabled. Remember to set the
USDC_MINT_PUBKEY
constant for local-testing
with the one generated in the
previous step rather than copying the one below.
local-testing
feature to the Cargo.toml
file located in
/programs
.
config.ts
test file to create a mint using the generated
keypair. Start by deleting the mint
constant.
local-testing
feature enabled.
4. Program Config
Features are great for setting different values at compilation, but what if you wanted to be able to dynamically update the fee percentage used by the program? Let’s make that possible by creating a Program Config account that allows us to update the fee without upgrading the program. To begin, let’s first update thelib.rs
file to:
- Include a
SEED_PROGRAM_CONFIG
constant, which will be used to generate the PDA for the program config account. - Include an
ADMIN
constant, which will be used as a constraint when initializing the program config account. Run thesolana address
command to get your address to use as the constant’s value. - Include a
state
module that we’ll implement shortly. - Include the
initialize_program_config
andupdate_program_config
instructions and calls to their “handlers,” both of which we’ll implement in another step.
5. Program Config State
Next, let’s define the structure for theProgramConfig
state. This account
will store the admin, the token account where fees are sent, and the fee rate.
We’ll also specify the number of bytes required to store this structure.
Create a new file called state.rs
in the /src
directory and add the
following code.
6. Add Initialize Program Config Account Instruction
Now let’s create the instruction logic for initializing the program config account. It should only be callable by a transaction signed by theADMIN
key
and should set all the properties on the ProgramConfig
account.
Create a folder called program_config
at the path
/src/instructions/program_config
. This folder will store all instructions
related to the program config account.
Within the program_config
folder, create a file called
initialize_program_config.rs
and add the following code.
7. Add Update Program Config Fee Instruction
Next, implement the instruction logic for updating the config account. The instruction should require that the signer match theadmin
stored in the
program_config
account.
Within the program_config
folder, create a file called
update_program_config.rs
and add the following code.
8. Add mod.rs and update instructions.rs
Next, let’s expose the instruction handlers we created so that the call fromlib.rs
doesn’t show an error. Start by adding a file mod.rs
in the
program_config
folder. Add the code below to make the two modules,
initialize_program_config
and update_program_config
accessible.
instructions.rs
at the path /src/instructions.rs
. Add the code
below to make the two modules, program_config
and payment
accessible.
9. Update Payment Instruction
Lastly, let’s update the payment instruction to check that thefee_destination
account in the instruction matches the fee_destination
stored in the program
config account. Then update the instruction’s fee calculation to be based on the
fee_basis_point
stored in the program config account.
10. Test
Now that we’re done implementing our new program configuration struct and instructions, let’s move on to testing our updated program. To begin, add the PDA for the program config account to the test file.- The program config account is initialized correctly
- The payment instruction is functioning as intended
- The config account can be updated successfully by the admin
- The config account cannot be updated by someone other than the admin
programConfig
account.
solution
branch
of the same repository.
Challenge
Now it’s time for you to do some of this on your own. We mentioned being able to use the program’s upgrade authority as the initial admin. Go ahead and update the lab’sinitialize_program_config
so that only the upgrade authority can
call it rather than having a hardcoded ADMIN
.
Note that the anchor test
command, when run on a local network, starts a new
test validator using solana-test-validator
. This test validator uses a
non-upgradeable loader. The non-upgradeable loader makes it so the program’s
program_data
account isn’t initialized when the validator starts. You’ll
recall from the lesson that this account is how we access the upgrade authority
from the program.
To work around this, you can add a deploy
function to the test file that runs
the deploy command for the program with an upgradeable loader. To use it, run
anchor test --skip-deploy
, and call the deploy
function within the test to
run the deploy command after the test validator has started.
challenge
branch of
the same repository
to see one possible solution.