
icRamp Devlog #9 — Testing Saga 2: PocketIC Solana Mocks & Test Harness
This is Part 2 of our testing saga. In Part 1 we refactored our repo into a clean multi-crate test workspace, stood up PocketIC topologies, and deployed our Solana backend plus an RPC fixture canister. Today we turn that environment into a professional Solana test harness: deterministic JSON-RPC mocks, minimal boilerplate, and test cases that read like specs.
What we're solving
Solana canister flows leave the IC via HTTP outcalls to an RPC provider. Naively returning null
or shape-mismatched JSON yields opaque errors (“expected u64”, “wrong size for signature”, etc.). We wanted:
- An ergonomic loop that pumps PocketIC, captures every JSON-RPC request, and injects well-typed responses.
- A thin DSL of responders for common scenarios (get balance, create ATA, send SOL/SPL) so tests focus on behavior, not wire formats.
- Valid placeholder data (blockhashes, signatures) that satisfy Solana's strict parsers without needing real network calls.
Below is the exact code we landed on.
Helpers: the mini mocking framework
These live in tests/solana_tests/src/helpers.rs
. They do three things:
pump_and_mock_http(...)
— the core loop: drain pending HTTP outcalls, parse the JSON-RPC request, hand it to a responder closure, and inject a 200/OK JSON-RPCresult
.- Tiny utilities to emit correctly-shaped Solana responses (slot, block, token amount, fees, etc.).
- Scenario responders that capture the sequencing Solana clients expect (e.g., “mint owner → ATA probe → blockhash → sendTransaction”).
use canhttp::http::json::JsonRpcRequest;
use pocket_ic::PocketIc;
use pocket_ic::common::rest::{
CanisterHttpHeader, CanisterHttpReply, CanisterHttpResponse, MockCanisterHttpResponse,
};
use serde_json::{Value, json};
use std::cell::RefCell;
// ---------- Common constants (valid base58 sizes) ----------
const FAKE_BLOCKHASH_32: &str = "11111111111111111111111111111111"; // 32 chars → 32 zero bytes
const FAKE_SIG_64: &str = "1111111111111111111111111111111111111111111111111111111111111111"; // 64 chars → 64 zero bytes
/// Pump execution and answer all pending HTTP outcalls using `responder`.
pub fn pump_and_mock_http(
pic: &PocketIc,
rounds: usize,
mut responder: impl FnMut(&JsonRpcRequest<Value>) -> Value,
) {
for _ in 0..rounds {
// Drain everything currently queued; tick after each drain
loop {
let reqs = pic.get_canister_http();
if reqs.is_empty() {
break;
}
for req in reqs {
let jr: JsonRpcRequest<Value> =
serde_json::from_slice(&req.body).expect("json-rpc request parse");
let result = responder(&jr);
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": jr.id(),
"result": result,
})
.to_string()
.into_bytes();
pic.mock_canister_http_response(MockCanisterHttpResponse {
subnet_id: req.subnet_id,
request_id: req.request_id,
response: CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply {
status: 200,
headers: vec![CanisterHttpHeader {
name: "content-type".into(),
value: "application/json".into(),
}],
body,
}),
additional_responses: vec![],
});
}
// let the canister consume the replies and enqueue follow-ups
pic.tick();
}
// progress timers/rounds even if nothing was queued this iteration
pic.tick();
}
}
// ---------- Tiny utilities ----------
/// Convenience: extract first string param (e.g., mint/ATA pubkey)
fn first_param_str(jr: &JsonRpcRequest<Value>) -> Option<&str> {
jr.params()?
.as_array()
.and_then(|a| a.get(0))
.and_then(|v| v.as_str())
}
fn resp_ctx_null() -> Value {
json!({ "context": { "slot": 0 }, "value": null })
}
fn resp_ctx_owner(owner_program: &str) -> Value {
json!({
"context": { "slot": 0 },
"value": {
"data": ["", "base64"],
"executable": false,
"lamports": 0,
"owner": owner_program,
"rentEpoch": 0,
"space": 0
}
})
}
fn resp_token_balance(amount: &str, decimals: u8) -> Value {
json!({
"context": { "slot": 0 },
"value": {
"amount": amount,
"decimals": decimals,
"uiAmount": null,
"uiAmountString": amount
}
})
}
fn resp_slot(slot: u64) -> Value {
json!(slot)
}
fn resp_block(blockhash: &str, parent_slot: u64) -> Value {
json!({
"previousBlockhash": blockhash,
"blockhash": blockhash,
"parentSlot": parent_slot,
"blockTime": 1u64,
"blockHeight": parent_slot + 1,
"rewards": []
})
}
fn resp_latest_blockhash(blockhash: &str) -> Value {
json!({
"context": { "slot": 1u64 },
"value": { "blockhash": blockhash, "lastValidBlockHeight": 999_999u64 }
})
}
fn resp_recent_blockhash(blockhash: &str, lamports_per_sig: u64) -> Value {
json!({
"context": { "slot": 1u64 },
"value": { "blockhash": blockhash, "feeCalculator": { "lamportsPerSignature": lamports_per_sig } }
})
}
fn resp_prioritization_fees_zero() -> Value {
json!([{ "slot": 1u64, "prioritizationFee": 0u64 }])
}
fn resp_send_sig(sig: &str) -> Value {
json!(sig)
}
// ======================================================================
// Scenario builders: return closures we can hand to `pump_and_mock_http`.
// ======================================================================
/// Balance: SPL mint owner (legacy Token program) + zero token balance on derived ATA.
pub fn responder_balance_zero_for_mint(
mint: String,
) -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
move |jr: &JsonRpcRequest<Value>| match jr.method() {
// Mint owner lookup → return Token program
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" => {
if first_param_str(jr) == Some(mint.as_str()) {
resp_ctx_owner("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
} else {
// Any other account info probe → say "missing"
resp_ctx_null()
}
}
// Then the client queries the derived ATA balance → 0
"getTokenAccountBalance" => resp_token_balance("0", 6),
// Harmless defaults some paths use:
"getSlot" => resp_slot(1_234_567),
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
_ => Value::Null,
}
}
/// Create ATA: mint owner → Token program; first non-mint `getAccountInfo` = exists; second = missing.
/// Also provides blockhash + sendTransaction response.
pub fn responder_create_ata_flow(mint: String) -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
let non_mint_probe = RefCell::new(0usize);
move |jr: &JsonRpcRequest<Value>| match jr.method() {
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" => {
if first_param_str(jr) == Some(mint.as_str()) {
// Mint owner is legacy Token program
resp_ctx_owner("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
} else {
// Probe order: first non-mint account exists, second is missing
let i = {
let mut c = non_mint_probe.borrow_mut();
*c += 1;
*c
};
if i == 1 {
resp_ctx_owner("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
} else {
resp_ctx_null()
}
}
}
"getSlot" => resp_slot(1_234_567),
"getBlock" => resp_block(FAKE_BLOCKHASH_32, 1_234_566),
"sendTransaction" => resp_send_sig(FAKE_SIG_64),
_ => Value::Null,
}
}
/// Send SOL: blockhash + (optional) fee helpers + signature.
pub fn responder_send_sol() -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
move |jr: &JsonRpcRequest<Value>| match jr.method() {
"getSlot" => resp_slot(1_234_567),
"getBlock" => resp_block(FAKE_BLOCKHASH_32, 1_234_566),
"getLatestBlockhash" => resp_latest_blockhash(FAKE_BLOCKHASH_32),
"getRecentBlockhash" => resp_recent_blockhash(FAKE_BLOCKHASH_32, 5_000),
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
"sendTransaction" => resp_send_sig(FAKE_SIG_64),
_ => Value::Null,
}
}
/// Send SPL: mint owner; source ATA exists, dest ATA missing; blockhash + signature.
pub fn responder_send_spl_token_create_dest_ata(
mint: String,
) -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
let non_mint_probe = RefCell::new(0usize);
move |jr: &JsonRpcRequest<Value>| match jr.method() {
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" => {
if first_param_str(jr) == Some(mint.as_str()) {
resp_ctx_owner("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
} else {
let i = {
let mut c = non_mint_probe.borrow_mut();
*c += 1;
*c
};
if i == 1 {
// Source ATA exists
resp_ctx_owner("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
} else {
// Dest ATA missing → the canister will create it
resp_ctx_null()
}
}
}
"getSlot" => resp_slot(1_234_560),
"getBlock" => resp_block(FAKE_BLOCKHASH_32, 1_234_559),
"getLatestBlockhash" => resp_latest_blockhash(FAKE_BLOCKHASH_32),
"getRecentBlockhash" => resp_recent_blockhash(FAKE_BLOCKHASH_32, 5_000),
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
"sendTransaction" => resp_send_sig(FAKE_SIG_64),
_ => Value::Null,
}
}
Why these placeholders?
FAKE_BLOCKHASH_32
andFAKE_SIG_64
are base58 strings that decode to the expected byte lengths (32 for blockhash, 64 for signature). That keeps Solana’s parsers happy without inventing random data.- Returning
null
forgetSlot
is invalid (it must be au64
). Same for shape/field casing: the helpers above match the structs deserialized inside our RPC canister and backend.
The Solana tests: readable and deterministic
Here's tests/solana_tests/src/solana_tests.rs
. The tests look like the behavior they verify, thanks to the scenario responders.
use candid::{Encode, Nat, Principal};
use icramp_types::solana::errors::Result;
use sol_rpc_types::TokenAmount;
use crate::{
env::get_solana_env,
helpers::{
pump_and_mock_http, responder_balance_zero_for_mint, responder_create_ata_flow,
responder_send_sol, responder_send_spl_token_create_dest_ata,
},
setup::SOL_RPC_CANISTER_ID,
};
#[test]
fn test_solana_canister_init() {
let binding = get_solana_env();
let env = binding.as_ref().unwrap();
let query_stats = env
.pic
.canister_status(env.canister_id, None)
.unwrap()
.query_stats;
assert_eq!(query_stats.num_calls_total, Nat::from(0u32));
assert!(
!env.canister_id.to_string().is_empty(),
"Canister ID is empty!"
);
}
#[test]
fn test_get_sol_account_balance() {
let mut binding = get_solana_env();
let env = binding.as_mut().unwrap();
let account = env.get_canister_account();
assert!(!account.is_empty(), "canister account is empty");
ic_cdk::println!("account = {:?}", account);
let msg_id = env
.pic
.submit_call(
env.canister_id,
Principal::anonymous(),
"get_balance",
Encode!(&account.clone()).unwrap(),
)
.expect("submit_call");
pump_and_mock_http(
&env.pic,
30,
|_| serde_json::json!({ "context": { "slot": 0 }, "value": 0u64 }),
);
let bytes = env.pic.await_call(msg_id).expect("await_call");
let api_result: Result<Nat> = candid::decode_one(&bytes).expect("decode candid");
let balance = api_result.expect("backend returned Err");
assert_eq!(balance, Nat::from(0u32));
}
#[test]
fn test_get_spl_token_balance_zero() {
let mut binding = get_solana_env();
let env = binding.as_mut().unwrap();
let owner = env.get_canister_account();
let mint = "So11111111111111111111111111111111111111112".to_string();
let msg = env
.pic
.submit_call(
env.canister_id,
Principal::anonymous(),
"get_spl_token_balance",
Encode!(&owner.clone(), &mint.clone()).unwrap(),
)
.unwrap();
pump_and_mock_http(&env.pic, 80, responder_balance_zero_for_mint(mint.clone()));
let bytes = env.pic.await_call(msg).unwrap();
let res: Result<TokenAmount> = candid::decode_one(&bytes).unwrap();
assert_eq!(res.unwrap().amount, "0");
}
#[test]
fn test_create_ata_when_missing() {
let mut binding = get_solana_env();
let env = binding.as_mut().unwrap();
let mint = "So11111111111111111111111111111111111111112".to_string();
let sol_rpc_canister_id = Principal::from_text(SOL_RPC_CANISTER_ID).unwrap();
let msg = env
.pic
.submit_call(
env.canister_id,
Principal::anonymous(),
"create_associated_token_account",
Encode!(&Some(sol_rpc_canister_id), &mint.clone()).unwrap(),
)
.unwrap();
pump_and_mock_http(&env.pic, 80, responder_create_ata_flow(mint.clone()));
let bytes = env.pic.await_call(msg).unwrap();
let res: Result<String> = candid::decode_one(&bytes).unwrap();
assert!(!res.unwrap().is_empty());
}
#[test]
fn test_send_sol_happy_path() {
let mut binding = get_solana_env();
let env = binding.as_mut().unwrap();
let dst = env.get_canister_account();
let sol_rpc_canister_id = Principal::from_text(SOL_RPC_CANISTER_ID).unwrap();
let msg = env
.pic
.submit_call(
env.canister_id,
Principal::anonymous(),
"send_sol",
Encode!(
&Some(sol_rpc_canister_id),
&dst.clone(),
&Nat::from(1_000_000u64)
)
.unwrap(),
)
.unwrap();
pump_and_mock_http(&env.pic, 80, responder_send_sol());
let bytes = env.pic.await_call(msg).unwrap();
let res: Result<String> = candid::decode_one(&bytes).unwrap();
assert!(res.unwrap().len() > 40);
}
#[test]
fn test_send_spl_token_creates_dest_ata() {
let mut binding = get_solana_env();
let env = binding.as_mut().unwrap();
let mint = "So11111111111111111111111111111111111111112".to_string();
let to = env.get_canister_account();
let sol_rpc_canister_id = Principal::from_text(SOL_RPC_CANISTER_ID).unwrap();
let msg = env
.pic
.submit_call(
env.canister_id,
Principal::anonymous(),
"send_spl_token",
Encode!(
&Some(sol_rpc_canister_id),
&mint.clone(),
&to.clone(),
&Nat::from(10u64)
)
.unwrap(),
)
.unwrap();
pump_and_mock_http(
&env.pic,
100,
responder_send_spl_token_create_dest_ata(mint.clone()),
);
let bytes = env.pic.await_call(msg).unwrap();
let res: Result<String> = candid::decode_one(&bytes).unwrap();
assert!(res.unwrap().len() > 40);
}
Why these pass (and the common failure modes we hit)
getSlot
must be a number: returningnull
for “no slot” causesinvalid type: null, expected u64
. We always return a concreteu64
.getBlock
shape: the backend expects block fields likeblockhash
,previousBlockhash
,parentSlot
, etc. Using helperresp_block(...)
keeps casing and types aligned.sendTransaction
result must be a base58 signature decodable to 64 bytes. We returnFAKE_SIG_64
(64 base58'1'
s) to satisfy the parser.- Mint owner: we register legacy SPL Token mints, so the
owner
program we return isTokenkegQ...VQ5DA
.
Takeaways
- Treat HTTP outcalls as a contract: match Solana's types and your RPC canister's JSON exactly. A single
null
or mis-cased field can break deserialization. - Encapsulate the boring bits: our scenario responders made tests shrink and removed test-specific
match
jungles. - Determinism > everything: PocketIC + fixed responders = reproducible CI.
What's next (Part 3)
We will wrap the series with the complete coverup of the solana canister APIs:
- Token registry: register, maintain and validate allowed tokens.
- Vault behavior for deposits, withdraws, locks, unlocks and cancel in behalf of the offrampers and onrampers.
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

icRamp Devlog #8 — Testing Saga 1: Refractor and Solana Test Expansion
We refractored and improved our testing architecture and expanded it to include a fully-fledged solana backend canister integration test flow.

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.