
icRamp Devlog #8 — Testing Saga 1: Refractor and Solana Test Expansion
In this post we'll show the exact steps we took to refactor our IC test libraries (originally built for the Bitcoin backend) and expand them to support a robust Solana backend integration test flow. It's a walkthrough you can copy-paste into your own repo.
TL;DR
- We split our initial "big test crate" into three focused crates:
testkit
(shared utilities),bitcoin_tests
, andsolana_tests
. - We standardized on PocketIC for deterministic, parallelizable integration tests and multi-subnet topologies.
- We added a Solana RPC canister fixture and a deploy routine that wires our backend to a known canister ID and (optionally) provider API keys.
- We now have a clean Solana test environment you can spin up with one call and use in update/query tests.
Why PocketIC (quick primer)
PocketIC is a lightweight, deterministic test environment for canisters. It removes consensus/networking and gives you synchronous control over time, subnets, and system behavior—ideal for reproducible tests.
Highlights relevant to us:
- deterministic: non-flaky CI, reproducible failures
- fast, headless: no containers/VMs required; just a binary
- multi-subnet: stand up NNS/II/app subnets locally
- parallel: multiple independent instances for parallel test runs
- language clients: Rust, Python, JS/TS
Usage patterns:
dfx start
uses PocketIC by default sincev0.26.0
, or you can- use the Rust client directly in tests (what we do). Point
POCKET_IC_BIN
to the binary and go.
The refactor: from “one pile” to three clean crates
Initial layout:
tests/
├── Cargo.toml
├── common
│ ├── bitcoin.rs
│ ├── helpers.rs
│ ├── mod.rs
│ └── setup.rs
├── integration
│ ├── env.rs
│ ├── bitcoin_tests.rs
│ ├── mod.rs
│ ├── rune_tests.rs
│ └── vault_tests.rs
├── lib.rs
├── README.md
└── wasms
└── ic-btc-canister.wasm.gz
4 directories, 13 files
Final layout:
tests/
├── bitcoin_tests
│ ├── Cargo.toml
│ └── src
│ ├── bitcoin_tests.rs
│ ├── env.rs
│ ├── helpers.rs
│ ├── lib.rs
│ ├── rune_tests.rs
│ ├── setup.rs
│ ├── token_tests.rs
│ └── vault_tests.rs
├── fixtures
│ └── wasm
│ ├── ic-btc-canister.wasm.gz
│ └── sol_rpc_canister.wasm.gz
├── README.md
├── solana_tests
│ ├── Cargo.toml
│ └── src
│ ├── env.rs
│ ├── helpers.rs
│ ├── lib.rs
│ ├── setup.rs
│ ├── solana_tests.rs
│ └── vault_tests.rs
└── testkit
├── Cargo.toml
└── src
├── helpers.rs
└── lib.rs
9 directories, 22 files
We moved all shared glue into tests/testkit
, left Bitcoin tests in their own crate, and added tests/solana_tests
for everything Solana-specific. This keeps dependencies slim per crate and makes failures localized and faster to iterate.
In the workspace root Cargo.toml
, register the new members:
[workspace]
members = [
"backend/icramp",
"backend/bitcoin",
"backend/solana",
"backend/shared",
"tests/testkit",
"tests/bitcoin_tests",
"tests/solana_tests",
]
testkit
: a tiny utility crate
testkit
is intentionally minimal—just helpers to call canisters and decode results, so tests read like business logic, not boilerplate.
tests/testkit/Cargo.toml
:
[package]
name = "testkit"
version.workspace = true
edition.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
candid = { workspace = true }
ic-cdk = { workspace = true }
pocket-ic = { workspace = true }
serde = { workspace = true }
tests/testkit/src/helpers.rs
:
use candid::{CandidType, Principal, decode_one, encode_args, utils::ArgumentEncoder};
use ic_cdk::api::management_canister::main::CanisterId;
use pocket_ic::PocketIc;
use serde::de::DeserializeOwned;
/// Executes a query call on the specified canister and decodes the response into the expected type.
pub fn query_call<T, R>(
pic: &PocketIc,
canister_id: CanisterId,
method: &str,
args: T,
) -> Result<R, String>
where
T: CandidType + ArgumentEncoder,
R: CandidType + DeserializeOwned,
{
match pic.query_call(
canister_id,
Principal::anonymous(),
method,
encode_args(args).unwrap(),
) {
Ok(response) => decode_one(&response).map_err(|e| e.to_string()),
Err(e) => Err(format!("Query call failed: {}", e)),
}
}
/// Executes an update call on the specified canister and decodes the response into the expected type.
pub fn update_call<T, R>(
pic: &mut PocketIc,
canister_id: CanisterId,
method: &str,
args: T,
) -> Result<R, String>
where
T: CandidType + ArgumentEncoder,
R: CandidType + DeserializeOwned,
{
match pic.update_call(
canister_id,
Principal::anonymous(),
method,
encode_args(args).unwrap(),
) {
Ok(response) => decode_one(&response).map_err(|e| format!("error decoding: {}", e)),
Err(e) => Err(format!("Update call failed: {}", e)),
}
}
This paid for itself immediately: our tests got shorter, and we stopped copy-pasting the encode/decode
plumbing.
Solana tests: crate setup
tests/solana_tests/Cargo.toml
:
[package]
name = "solana_tests"
version.workspace = true
edition.workspace == true
[lib]
path = "src/lib.rs"
[dev-dependencies]
candid = { workspace = true }
canhttp = { workspace = true }
dotenvy = "0.15"
ic-cdk = { workspace = true }
icramp_types = { workspace = true }
lazy_static = { workspace = true }
pocket-ic = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sol_rpc_types = { workspace = true }
testkit = { workspace = true }
You'll notice we bring in:
pocket-ic
for the envcanhttp
for parsing IC HTTP outcalls (we'll use this in a follow-up to mock JSON-RPC)sol_rpc_types
for the RPC canister's install args & enumsicramp_types
for our backend's shared Solana types
Solana test environment (env.rs
)
We keep a single lazy-initialized instance of PocketIC + deployed backend canister and expose ergonomic getters in tests:
tests/solana_tests/src/env.rs
:
use candid::Principal;
use lazy_static::lazy_static;
use pocket_ic::PocketIc;
use std::{collections::HashMap, sync::Mutex};
use icramp_types::solana::{errors::Result, token::TokenInfo, vault::VaultEntry};
use crate::setup::setup_solana_backend;
use testkit::helpers::{query_call, update_call};
lazy_static! {
pub static ref SOLANA_ENV: Mutex<Option<SolanaTestEnv>> = Mutex::new(None);
}
pub struct SolanaTestEnv {
pub pic: PocketIc,
pub canister_id: Principal,
pub sol_account: Option<String>,
}
pub fn get_solana_env() -> std::sync::MutexGuard<'static, Option<SolanaTestEnv>> {
let mut env = SOLANA_ENV.lock().unwrap_or_else(|p| p.into_inner());
if env.is_none() {
*env = Some(SolanaTestEnv::new());
}
env
}
impl SolanaTestEnv {
pub fn new() -> Self {
let (pic, can_id) = setup_solana_backend();
Self {
pic,
canister_id: can_id,
sol_account: None,
}
}
#[allow(dead_code)]
pub fn print_canister_logs(&self) {
let logs = self
.pic
.fetch_canister_logs(self.canister_id, candid::Principal::anonymous())
.expect("Failed to fetch logs");
for log in logs {
let content =
String::from_utf8(log.content).unwrap_or_else(|_| "<Invalid UTF-8>".to_string());
ic_cdk::println!("Log [{}]: {}", log.timestamp_nanos, content);
}
}
pub fn get_canister_account(&mut self) -> String {
if let Some(ref acc) = self.sol_account {
return acc.clone();
}
let account: String = update_call(
&mut self.pic,
self.canister_id,
"canister_solana_account",
(),
)
.expect("Failed to get canister solana account");
self.sol_account = Some(account.clone());
account
}
}
That get_canister_account()
is a nice example of why testkit::update_call
exists: tests don't need to care about Candid details.
Installing Solana backend + RPC canister (setup.rs
)
This file does the heavy lifting:
- boots a PocketIC topology (we use II + Application subnets)
- deploys a known-ID Solana RPC canister fixture from
tests/fixtures/wasm/sol_rpc_canister.wasm.gz
- installs our Solana backend canister and wires it to the RPC canister principal
- optionally configures provider API keys (
updateApiKeys
), if present in project root's.env
file
Heads-up: place your
sol_rpc_canister.wasm.gz
undertests/fixtures/wasm/
and commit it (or fetch/build during CI).
tests/solana_tests/src/setup.rs
:
use candid::{Encode, Principal};
use icramp_types::solana::{
ed25519::Ed25519KeyName,
setup::{InitArg, InstallArg},
};
use pocket_ic::{PocketIc, PocketIcBuilder};
use sol_rpc_types::{SolanaCluster, SupportedRpcProviderId};
const SOLANA_BACKEND_WASM: &str = "../../target/wasm32-unknown-unknown/release/solana_backend.wasm";
const SOL_RPC_WASM_GZ: &str = "../fixtures/wasm/sol_rpc_canister.wasm.gz";
pub const SOL_RPC_CANISTER_ID: &str = "tghme-zyaaa-aaaar-qarca-cai";
const INIT_CYCLES: u128 = 2_000_000_000_000;
pub(crate) fn setup_solana_backend() -> (PocketIc, Principal) {
let _ = dotenvy::dotenv();
unsafe { std::env::set_var("POCKET_IC_BIN", "/usr/local/bin/pocket-ic") };
let pic = PocketIcBuilder::new()
.with_ii_subnet()
.with_application_subnet()
.build();
deploy_sol_rpc_canister(&pic);
let canister_id = pic.create_canister();
let wasm =
std::fs::read(SOLANA_BACKEND_WASM).expect("Backend wasm file not found, run 'dfx build'.");
let sol_rpc_canister_id = Principal::from_text(SOL_RPC_CANISTER_ID).unwrap();
let arg = InstallArg::Reinstall(InitArg {
sol_rpc_canister_id: Some(sol_rpc_canister_id),
ed25519_key_name: Ed25519KeyName::LocalDevelopment,
network: SolanaCluster::Devnet,
proxy_url: "https://example.xyz".to_string(),
});
let arg = Encode!(&arg).expect("Failed to encode init args");
pic.add_cycles(canister_id, INIT_CYCLES);
pic.install_canister(canister_id, wasm, arg, None);
ic_cdk::println!("Deployed canister ID: {}", canister_id);
(pic, canister_id)
}
fn deploy_sol_rpc_canister(pic: &PocketIc) {
let nns_root_canister_id: Principal =
Principal::from_text("r7inp-6aaaa-aaaaa-aaabq-cai").unwrap();
let sol_rpc_canister_id = Principal::from_text(SOL_RPC_CANISTER_ID).unwrap();
let actual_canister_id = pic
.create_canister_with_id(Some(nns_root_canister_id), None, sol_rpc_canister_id)
.unwrap();
assert_eq!(actual_canister_id, sol_rpc_canister_id);
let sol_wasm =
std::fs::read(SOL_RPC_WASM_GZ).expect("Failed to read SOL RPC canister wasm file");
let install_args = sol_rpc_types::InstallArgs::default();
pic.add_cycles(sol_rpc_canister_id, INIT_CYCLES);
pic.install_canister(
sol_rpc_canister_id,
sol_wasm,
Encode!(&install_args).unwrap(),
Some(nns_root_canister_id),
);
if let (Ok(ankr), Ok(alchemy)) = (
std::env::var("SOL_ANKR_KEY"),
std::env::var("SOL_ALCHEMY_KEY"),
) {
let update_args = vec![
(SupportedRpcProviderId::AlchemyDevnet, Some(alchemy)),
(SupportedRpcProviderId::AnkrDevnet, Some(ankr)),
];
pic.update_call(
sol_rpc_canister_id,
nns_root_canister_id,
"updateApiKeys",
Encode!(&update_args).unwrap(),
)
.expect("updateApiKeys failed");
}
}
A couple of details worth calling out:
- We use a hard-coded RPC canister principal (
tghme-zyaaa-aaaar-qarca-cai
) to simplify backend wiring & mock routing. - Passing
InstallArg::Reinstall(InitArg { ... })
into our backend ensures it knows which RPC canister to talk to and under which network (Devnet
). - 2T cycles is a safe default in tests for both canisters.
What's next (mocking & HTTP outcalls)
Solana client calls leave the IC via HTTP outcalls to an RPC provider. In tests we don't touch the network. Instead we'll:
- capture outgoing JSON-RPC requests with PocketIC,
- parse them using
canhttp::http::json::JsonRpcRequest
, - respond deterministically with mock JSON payloads,
- and drive the replicated state forward with explicit
tick()
s.
In the next post we share the exact helpers we built to abstract repetitive JSON-RPC matching, generate valid blockhashes/signatures for Solana's parsers, and make the tests read like specs instead of spaghetti.
If you want to peek ahead, the ingredients you'll see:
pump_and_mock_http(pic, rounds, responder)
- tiny utilities like
first_param_str(jr)
- scenario responders (e.g., “mint owner lookup → Token Program”, “ATA exists/missing probes”, “sendTransaction → 64-byte base58 sig”)
Stay tuned.
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

icRamp Devlog #9 — Testing Saga 2: PocketIC Solana Mocks & Test Harness
We continue the Solana testing story by building a clean HTTP-outcall mocking layer, composable responders, and readable integration tests.

icRamp Devlog #11 — Testing Saga 4: Vault State (SOL + SPL), Full Suite Green
We finish the vault branch for SOL and SPL: deposits, cancels, locks, unlocks, and completion — entirely in-canister state. Plus, the full Solana suite now passes.

icRamp Devlog #10 — Testing Saga 3: Token Registry, Token-2022 & Solid Wasm Paths
We include the Solana token registry tests (incl. Token-2022), fix flaky JSON-RPC shapes, and make the test harness robust with `include_bytes!` + workspace-aware wasm paths.