icRamp Devlog #9 — Testing Saga 2: PocketIC Solana Mocks & Test Harness

icRamp Devlog #9 — Testing Saga 2: PocketIC Solana Mocks & Test Harness

9/15/20258 min • icramp
SolanaSPL TokensDevnetTesting CanistersPocketIC

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:

  1. 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-RPC result.
  2. Tiny utilities to emit correctly-shaped Solana responses (slot, block, token amount, fees, etc.).
  3. 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 and FAKE_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 for getSlot is invalid (it must be a u64). 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: returning null for “no slot” causes invalid type: null, expected u64. We always return a concrete u64.
  • getBlock shape: the backend expects block fields like blockhash, previousBlockhash, parentSlot, etc. Using helper resp_block(...) keeps casing and types aligned.
  • sendTransaction result must be a base58 signature decodable to 64 bytes. We return FAKE_SIG_64 (64 base58 '1's) to satisfy the parser.
  • Mint owner: we register legacy SPL Token mints, so the owner program we return is TokenkegQ...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.