icRamp Devlog #10 — Testing Saga 3: Token Registry, Token-2022 & Solid Wasm Paths

icRamp Devlog #10 — Testing Saga 3: Token Registry, Token-2022 & Solid Wasm Paths

9/16/20257 min • icramp
SolanaSPL TokensToken-2022PocketICTesting Canisters

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)

  1. Owner program mismatch
    If the mocked owner string doesn't match the backend constant exactly, registration fails with UnsupportedToken.
    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";
  1. space must not be null The JSON for UiAccount must include space: 0. Omitting it triggers a trap inside the RPC canister client. ✅ Action: our JSON helpers always set space: 0, including when returning the 1-byte data_slice.
  2. 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.
  3. 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).

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 pubkeyvalidate_token_mint fails
  • wrong ownerUnsupportedToken
  • 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:

  1. 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"
));
  1. 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 stale helpers path).
  • wasm file not found Fix with include_bytes! and workspace-aware path, or pass SOLANA_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.