
icRamp Devlog #10 — Testing Saga 3: Token Registry, Token-2022 & Solid Wasm Paths
This is Part 3 of the testing saga. In [Part 1] and [Part 2] we set up the PocketIC topology and built a clean JSON-RPC mocking layer. Today we finish the token registry tests, fix a couple of tricky Solana gotchas (Token-2022, base64 slice, and the notorious
space
field), and make wasm paths bullet-proof for local + CI runs.
What we're building
The Solana backend exposes a token registry:
register_tokens(Vec<(mint, symbol, rate_symbol)>)
get_registered_tokens() -> HashMap<mint, TokenInfo>
get_token_info(mint) -> Result<TokenInfo>
is_token_supported(mint) -> Result<()>
At registration time we validate mints and RPC-fetch decimals from the SPL Mint account (byte 44) for both programs:
- Legacy SPL Token:
Tokenkeg…VQ5DA
- Token-2022:
TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
The pitfalls we hit (and fixed)
- Owner program mismatch
If the mockedowner
string doesn't match the backend constant exactly, registration fails withUnsupportedToken
.
✅ Action: unify constants across backend + tests and guard with a compile-time check:
// tests/solana_tests/src/helpers/mock.rs
const TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
space
must not be null The JSON forUiAccount
must includespace: 0
. Omitting it triggers a trap inside the RPC canister client. ✅ Action: our JSON helpers always set space: 0, including when returning the 1-bytedata_slice
.- Decimals slice must be exactly 1 byte
Returning 2 bytes (
"BgA="
) provokes the intended error:Expected 1 byte, got 2
. ✅ Action: responders deliberately return one byte for success, two for the failure case. - Relative wasm paths are fragile
Test harness failed on different CWDs.
✅ Action:
- Embed the sol-rpc fixture with
include_bytes!
. - Compute the backend wasm with
CARGO_MANIFEST_DIR
(and allow an env override).
- Embed the sol-rpc fixture with
Refactor: tiny, readable utilities
We split helpers to keep tests clean:
tests/solana_tests/src/utilities/
├── json.rs // low-level JSON constructors: resp_* (always correct shapes)
├── mock.rs // high-level responders: responder_* (behavioral scenarios)
└── mod.rs // pub use re-exports
Naming rule:
resp_*
→ raw JSON shapes (no logic)responder_*
→ stateful sequences that mirror Solana client flows
Only-added pieces (JSON helpers)
pub 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
}})
}
// same, but with base64 payload and space=0 (for data_slice responses)
pub fn resp_ctx_owner_with_b64(owner_program: &str, b64: &str) -> Value {
json!({ "context": { "slot": 0 }, "value": {
"data": [b64, "base64"], "executable": false, "lamports": 0,
"owner": owner_program, "rentEpoch": 0, "space": 0
}})
}
Only-added pieces (responders)
// utilities/mock.rs (added)
use base64::engine::general_purpose::STANDARD as B64;
use canhttp::http::json::JsonRpcRequest;
use serde_json::Value;
use std::cell::RefCell;
use crate::utilities::json::{resp_ctx_owner, resp_ctx_owner_with_b64, resp_slot, resp_prioritization_fees_zero};
const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
// happy path (legacy token)
pub fn responder_mint_decimals_ok_token(decimals: u8) -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
let step = RefCell::new(0usize);
move |jr| match jr.method() {
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" => {
let i = { let mut s = step.borrow_mut(); *s += 1; *s };
if i == 1 { resp_ctx_owner(TOKEN_PROGRAM_ID) }
else {
let b64 = B64.encode([decimals]);
resp_ctx_owner_with_b64(TOKEN_PROGRAM_ID, &b64)
}
}
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
"getSlot" => resp_slot(1_234_567),
_ => Value::Null,
}
}
// happy path (Token-2022)
pub fn responder_mint_decimals_ok_token2022(decimals: u8) -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
let step = RefCell::new(0usize);
move |jr| match jr.method() {
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" => {
let i = { let mut s = step.borrow_mut(); *s += 1; *s };
if i == 1 { resp_ctx_owner(TOKEN_2022_PROGRAM_ID) }
else {
let b64 = B64.encode([decimals]);
resp_ctx_owner_with_b64(TOKEN_2022_PROGRAM_ID, &b64)
}
}
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
"getSlot" => resp_slot(1_234_567),
_ => Value::Null,
}
}
// wrong owner → UnsupportedToken
pub fn responder_wrong_owner() -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
move |jr| match jr.method() {
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" =>
resp_ctx_owner("11111111111111111111111111111111"), // System program
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
"getSlot" => resp_slot(1_234_567),
_ => Value::Null,
}
}
// second probe (data_slice) is missing
pub fn responder_missing_mint_data_token() -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
let step = RefCell::new(0usize);
move |jr| match jr.method() {
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" => {
let i = { let mut s = step.borrow_mut(); *s += 1; *s };
if i == 1 { resp_ctx_owner(TOKEN_PROGRAM_ID) } else {
serde_json::json!({ "context": { "slot": 0 }, "value": Value::Null })
}
}
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
"getSlot" => resp_slot(1_234_567),
_ => Value::Null,
}
}
// return TWO bytes for decimals to provoke "Expected 1 byte, got 2"
pub fn responder_bad_b64_len_token(first: u8, second: u8) -> impl FnMut(&JsonRpcRequest<Value>) -> Value {
let step = RefCell::new(0usize);
move |jr| match jr.method() {
"getAccountInfo" | "getAccountInfoWithContext" | "getAccountInfoWithOpts" => {
let i = { let mut s = step.borrow_mut(); *s += 1; *s };
if i == 1 { resp_ctx_owner(TOKEN_PROGRAM_ID) }
else {
let b64 = B64.encode([first, second]);
resp_ctx_owner_with_b64(TOKEN_PROGRAM_ID, &b64)
}
}
"getRecentPrioritizationFees" => resp_prioritization_fees_zero(),
"getSlot" => resp_slot(1_234_567),
_ => Value::Null,
}
}
The tests (registry)
All tests read like specs now:
- invalid pubkey →
validate_token_mint
fails - wrong owner →
UnsupportedToken
- bad decimals len → RPC error “Expected 1 byte, got 2”
- missing mint data → “Mint account not found”
- happy path (Token) → ok, with
decimals == 9
- happy path (Token-2022) → ok, with
decimals == 6
use crate::utilities::mock::{
pump_and_mock_http,
responder_wrong_owner,
responder_bad_b64_len_token,
responder_mint_decimals_ok_token,
responder_mint_decimals_ok_token2022,
responder_missing_mint_data_token,
};
#[test]
fn test_register_and_query_success_token_program() {
// mint can be any valid pubkey; we used wSOL
pump_and_mock_http(&env.pic, 20, responder_mint_decimals_ok_token(9));
// ...assert decimals == 9, etc.
}
#[test]
fn test_register_and_query_success_token_2022() {
pump_and_mock_http(&env.pic, 20, responder_mint_decimals_ok_token2022(6));
// ...assert decimals == 6, etc.
}
#[test]
fn test_register_token_wrong_owner_rejected() {
pump_and_mock_http(&env.pic, 20, responder_wrong_owner());
// ...assert UnsupportedToken
}
#[test]
fn test_register_token_bad_decimal_len_rejected() {
pump_and_mock_http(&env.pic, 20, responder_bad_b64_len_token(6, 0));
// ...assert RpcError("Expected 1 byte, got 2")
}
#[test]
fn test_register_missing_mint_data_rejected() {
pump_and_mock_http(&env.pic, 20, responder_missing_mint_data_token());
// ...assert RpcError("Mint account not found")
}
✅ With these in place we now see 9/9 passing locally and in CI.
$ cargo test -p solana_tests registry_tests -- --nocapture
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.29s
Running unittests src/lib.rs (target/debug/deps/solana_tests-d15fb005799d4f4f)
running 9 tests
2025-09-18T22:36:39.165941Z INFO pocket_ic_server: The PocketIC server is listening on port 33737
2021-05-06 19:17:10.000000006 UTC: [Canister tghme-zyaaa-aaaar-qarca-cai] INFO canister/src/main.rs:54 [r7inp-6aaaa-aaaaa-aaabq-cai] Updating API keys for providers: AlchemyDevnet, AnkrDevnet
2021-05-06 19:17:10.000000012 UTC: [Canister 7tjcv-pp777-77776-qaaaa-cai] [init]: state: State { sol_rpc_canister_id: "tghme-zyaaa-aaaar-qarca-cai", network: Devnet, ed25519_key_name: LocalDevelopment, ed25519_root_pk: None, proxy_url: "https://example.xyz" }
Deployed canister ID: 7tjcv-pp777-77776-qaaaa-cai
test registry_tests::test_get_token_info_invalid_pubkey ... ok
test registry_tests::test_is_token_supported_unregistered ... ok
test registry_tests::test_get_token_info_unregistered_err ... ok
test registry_tests::test_register_and_query_success_token_2022 ... ok
test registry_tests::test_register_and_query_success_token_program ... ok
test registry_tests::test_register_missing_mint_data_rejected ... ok
test registry_tests::test_register_token_bad_decimal_len_rejected ... ok
test registry_tests::test_register_token_invalid_pubkey_rejected ... ok
test registry_tests::test_register_token_wrong_owner_rejected ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 6.77s
Wasm paths that Just Work
Two tiny changes made our test harness reliable across shells, IDEs and CI:
- Embed the sol-rpc wasm:
// tests/solana_tests/src/setup.rs
const SOL_RPC_WASM_GZ_BYTES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../fixtures/wasm/sol_rpc_canister.wasm.gz"
));
- Resolve the backend wasm via
CARGO_MANIFEST_DIR
(+ env override):
fn read_backend_wasm() -> Vec<u8> {
if let Ok(p) = std::env::var("SOLANA_BACKEND_WASM") {
return std::fs::read(p).expect("SOLANA_BACKEND_WASM points to a missing file");
}
let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().and_then(|p| p.parent()).expect("resolve workspace root");
let p = workspace_root.join("target/wasm32-unknown-unknown/release/solana_backend.wasm");
std::fs::read(&p).unwrap_or_else(|_| {
panic!(
"Backend wasm not found at {:?}. Build it first:\n\
cargo build -p solana_backend --release --target wasm32-unknown-unknown\n\
Or set SOLANA_BACKEND_WASM to the wasm path.", p
)
})
}
No more “relative path roulette”.
Debug diary
'space' field should not be null
UiAccount
JSON must have"space": 0
. We added it to both owner probe and data_slice responses.ParsePubkeyError("String is the wrong size")
Means the program id string wasn't a 32-byte base58. Use the exact Token-2022 id above and add a compile-time check.UnsupportedToken(mint)
Returned an owner that doesn't match either program id constant. Double-check constants and that the test imports the new utilities module (not a stalehelpers
path).wasm file not found
Fix withinclude_bytes!
and workspace-aware path, or passSOLANA_BACKEND_WASM=/abs/path/...wasm
.
How to run
# 1) Build the Solana backend wasm once
cargo build -p solana_backend --release --target wasm32-unknown-unknown
# 2) Run just the registry tests
cargo test -p solana_tests registry_tests -- --nocapture
# Optional: point to a custom wasm
SOLANA_BACKEND_WASM=/full/path/solana_backend.wasm \
cargo test -p solana_tests registry_tests -- --nocapture
That's it for Part 3. The registry is solid, Token-2022 is first-class, and the harness is reliable. Next one: Vault behavior with crisp invariants and production-grade tests.
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

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 #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 #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.