icRamp Devlog #4 — icRamp frontend Deployment Setup with Solana

icRamp Devlog #4 — icRamp frontend Deployment Setup with Solana

8/28/202517 min • icramp
ICPSolanaChain FusionEscrowCanisters

This post continues the build of icRamp under my second ICP Chain Fusion grant. After the Bitcoin + Runes integration and the standalone Solana backend canister we wired the icRamp orchestrator canister with Solana. We are now ready to plug everything together and start integrating our frontend. In this devlog we will deploy all our backend components locally, including its dependencies. Thus we are going to review all the pieces we have so far setup in our platform, and start coding our frontend's UX changes.


Let's first give a look to our dfx.json. Currently it mixes dev and prod deployments, since we are maintaining two parallel deployments on ICP's mainnet (one with sandboxed paypal and testnet blockchain connexions). Later I will create two folders with different dfx setups, since it is increasingly becoming more complex the more canisters we add. For now let's focus on our initial testnet deployments on a local dfx network.

{
  "canisters": {
    "evm_rpc": {
      "type": "pull",
      "id": "7hfb6-caaaa-aaaar-qadga-cai",
      "declarations": {
        "output": "frontend/src/declarations/evm_rpc"
      }
    },
    "sol_rpc": {
      "specified_id": "tghme-zyaaa-aaaar-qarca-cai",
      "type": "custom",
      "candid": "https://github.com/dfinity/sol-rpc-canister/releases/latest/download/sol_rpc_canister.did",
      "wasm": "https://github.com/dfinity/sol-rpc-canister/releases/latest/download/sol_rpc_canister.wasm.gz",
      "metadata": [
        {
          "name": "candid:service"
        }
      ],
      "init_arg": "( record {} )",
      "remote": {
        "id": {
          "ic": "tghme-zyaaa-aaaar-qarca-cai"
        }
      }
    },
    "xrc": {
      "type": "pull",
      "id": "uf6dk-hyaaa-aaaaq-qaaaq-cai",
      "declarations": {
        "output": "frontend/src/declarations/xrc"
      }
    },
    "icramp_backend": {
      "dependencies": ["evm_rpc", "xrc"],
      "candid": "backend/icramp/icramp_backend.did",
      "package": "icramp_backend",
      "type": "rust",
      "declarations": {
        "output": "frontend/src/declarations/icramp_backend"
      }
    },
    "bitcoin_backend": {
      "dependencies": [],
      "candid": "backend/bitcoin/bitcoin_backend.did",
      "package": "bitcoin_backend",
      "type": "rust",
      "declarations": {
        "output": "frontend/src/declarations/bitcoin_backend"
      }
    },
    "solana_backend": {
      "dependencies": [],
      "candid": "backend/solana/solana_backend.did",
      "package": "solana_backend",
      "type": "rust",
      "declarations": {
        "output": "frontend/src/declarations/solana_backend"
      }
    },
    "frontend": {
      "dependencies": ["icramp_backend"],
      "type": "assets",
      "source": ["frontend/dist/"],
      "workspace": "frontend"
    },
    "internet_identity": {
      "type": "custom",
      "candid": "https://github.com/dfinity/internet-identity/releases/download/release-2024-07-12/internet_identity.did",
      "wasm": "https://github.com/dfinity/internet-identity/releases/download/release-2024-07-12/internet_identity_dev.wasm.gz",
      "remote": {
        "id": {
          "ic": "rdmx6-jaaaa-aaaaa-aaadq-cai"
        }
      },
      "frontend": {},
      "declarations": {
        "output": "frontend/src/declarations/internet_identity"
      }
    },
    "icp_ledger_canister": {
      "type": "custom",
      "candid": "https://raw.githubusercontent.com/dfinity/ic/94fd38099f0e63950eb5d5673b7b9d23780ace2d/rs/rosetta-api/icp_ledger/ledger.did",
      "wasm": "https://download.dfinity.systems/ic/94fd38099f0e63950eb5d5673b7b9d23780ace2d/canisters/ledger-canister.wasm.gz",
      "remote": {
        "id": {
          "ic": "ryjl3-tyaaa-aaaaa-aaaba-cai"
        }
      },
      "specified_id": "ryjl3-tyaaa-aaaaa-aaaba-cai",
      "declarations": {
        "output": "frontend/src/declarations/icp_ledger_canister"
      }
    },
    "ckbtc_ledger_canister_testnet": {
      "type": "custom",
      "candid": "https://raw.githubusercontent.com/dfinity/ic/94fd38099f0e63950eb5d5673b7b9d23780ace2d/rs/rosetta-api/icp_ledger/ledger.did",
      "wasm": "https://download.dfinity.systems/ic/94fd38099f0e63950eb5d5673b7b9d23780ace2d/canisters/ledger-canister.wasm.gz",
      "remote": {
        "id": {
          "ic": "mc6ru-gyaaa-aaaar-qaaaq-cai"
        }
      },
      "specified_id": "mc6ru-gyaaa-aaaar-qaaaq-cai",
      "declarations": {
        "output": "frontend/src/declarations/ckbtc_ledger_canister_testnet"
      }
    },
    "icrc1_ledger_canister": {
      "type": "custom",
      "candid": "https://raw.githubusercontent.com/dfinity/ic/08f32722df2f56f1e5c1e603fee0c87c40b77cba/rs/rosetta-api/icrc1/ledger/ledger.did",
      "wasm": "https://download.dfinity.systems/ic/08f32722df2f56f1e5c1e603fee0c87c40b77cba/canisters/ic-icrc1-ledger.wasm.gz",
      "declarations": {
        "output": "frontend/src/declarations/icrc1_ledger_canister"
      }
    }
  },
  "defaults": {
    "build": {
      "args": "",
      "packtool": "npm run --silent sources"
    }
  },
  "networks": {
    "local": {
      "bind": "127.0.0.1:8080",
      "type": "ephemeral",
      "replica": {
        "subnet_type": "system"
      }
    }
  },
  "output_env_file": ".env",
  "version": 1
}

Let's quickly recap why we need all those canisters:

  • evm_rpc: DFINITY’s EVM RPC canister, used by icRamp backend to send raw Ethereum JSON-RPC calls without relying on an external node.
  • sol_rpc: DFINITY’s Solana RPC canister, giving us devnet/mainnet access to Solana nodes in a canister-friendly way.
  • xrc: the cross-chain exchange rate canister, used to price assets at order creation and settlement.
  • icramp_backend: the orchestrator canister; manages EVM orders, coordinates escrow flows, inter-canister calls, and external integrations (PayPal/Revolut).
  • bitcoin_backend: manages Bitcoin vaults, Ordinals/Runes, and interfaces with Unisat + Ordiscan APIs.
  • solana_backend: handles Solana vaults, SOL/SPL token transfers, and maintains the token allowlist registry.
  • frontend: the React app served as an asset canister.
  • internet_identity: for users logging in with II; we use the public IC canister but mirror declarations locally.
  • icp_ledger_canister: local ICP ledger for dev/test, so we can create and settle ICP orders.
  • ckbtc_ledger_canister_testnet: local ckBTC ledger, used to mimic Chain-Key Bitcoin in test flows.
  • icrc1_ledger_canister: general ICRC-1 ledger support, useful for testing non-ICP ICRC assets.

Deployment

Let's start by running dfx local network in one terminal:

dfx start --clean

Also let's load the env variables with source .env.

We deploy both backends with parameters for local development. For a complete understanding of them, please refer to the posts where we report those canisters in depth.

Bitcoin:

First, we build the package for the wasm target, and generate the candid file:

cargo build --release --target wasm32-unknown-unknown --package bitcoin_backend
candid-extractor target/wasm32-unknown-unknown/release/bitcoin_backend.wasm > backend/bitcoin/bitcoin_backend.did

Then deploy with some minimal parameters:

dfx deploy bitcoin_backend --specified-id zhuzm-wqaaa-aaaap-qpk2q-cai --argument "(
    variant {
        Reinstall = record {
            network = variant { regtest };
            proxy_url = \"https://ic2p2ramp.xyz\";
            unisat = record {
                api_url = \"open-api-testnet4.unisat.io\";
                api_key = \"${UNISAT_API_KEY}\";
            };
        }
    }
)"

Solana

Same as with the bitcoin backend, we build the package for the wasm target and generate the candid file:

cargo build --release --target wasm32-unknown-unknown --package solana_backend
candid-extractor target/wasm32-unknown-unknown/release/solana_backend.wasm > backend/solana/solana_backend.did

Then we need to deploy Dfinity's solana rpc canister, and add some rpc's api keys:

dfx deploy sol_rpc
 
dfx canister call sol_rpc updateApiKeys "(vec {
record { variant { AlchemyDevnet }; opt \"$ALCHEMY_KEY\" };
  record { variant { AnkrDevnet }; opt \"$ANKR_KEY\" };
})"

Now we are ready for deployment:

dfx deploy solana_backend --specified-id u6s2n-gx777-77774-qaaba-cai --argument "(
    variant {
        Reinstall = record {
            sol_rpc_canister_id = opt principal \"tghme-zyaaa-aaaar-qarca-cai\";
            ed25519_key_name = variant { LocalDevelopment };
            network = variant { Devnet };
            proxy_url = \"https://ic2p2ramp.xyz\";
        }
    }
)"

We follow here the approach of specifying the ids because we need to keep track of the principal of the deployed canisters in the main backend to do intercanister calls and coordinate the three backends:

The main backend's state follows, for the full struct see state.rs:

#[derive(Clone, CandidType, Deserialize)]
pub struct CanisterIds {
    pub solana_backend_id: Principal,
    pub bitcoin_backend_id: Principal,
}
 
#[derive(Clone, CandidType, Deserialize)]
pub struct State {
    pub canister_ids: CanisterIds,
    pub chains: HashMap<u64, ChainState>,
    pub ecdsa_pub_key: Option<Vec<u8>>,
    pub ecdsa_key_id: EcdsaKeyId,
    pub evm_address: Option<String>,
    pub paypal: PayPalState,
    pub revolut: RevolutState,
    pub proxy_url: String,
    pub ordiscan: OrdiscanState,
    pub unisat: UnisatState,
    pub icp_tokens: HashMap<Principal, IcpToken>,
}

Before we get to that main backend, let's continue deploying the dependencies. Now we will deploy some icp ledgers so we can create icp orders:

dfx identity use minter
export MINTER_ACCOUNT_ID=$(dfx ledger account-id)
 
dfx identity use default
export DEFAULT_ACCOUNT_ID=$(dfx ledger account-id)
 
dfx deploy icp_ledger_canister --argument "
  (variant {
    Init = record {
      minting_account = \"$MINTER_ACCOUNT_ID\";
      initial_values = vec {
        record {
          \"$DEFAULT_ACCOUNT_ID\";
          record {
            e8s = 10_000_000_000 : nat64;
          };
        };
      };
      send_whitelist = vec {};
      transfer_fee = opt record {
        e8s = 10_000 : nat64;
      };
      token_symbol = opt \"ICP\";
      token_name = opt \"Local ICP\";
    }
  })
"
 
dfx deploy ckbtc_ledger_canister_testnet --argument "
  (variant {
    Init = record {
      minting_account = \"$(dfx ledger account-id --of-principal ml52i-qqaaa-aaaar-qaaba-cai)\";
      initial_values = vec {
        record {
          \"$DEFAULT_ACCOUNT_ID\";
          record {
            e8s = 10_000_000_000 : nat64;
          };
        };
      };
      send_whitelist = vec {};
      transfer_fee = opt record {
        e8s = 10 : nat64;
      };
      token_symbol = opt \"BTC\";
      token_name = opt \"Chain key testnet Bitcoin\";
    }
  })
"

This imitates the behavior of mainnet ICP and ckBTC tokens in our local testnet.

We will then deploy the external canister dependencies:

dfx deps pull
dfx deps deploy xrc
dfx deps init evm_rpc --argument '(record {})' && dfx deps deploy
  • xrc will be used to fetch rates for the tokens at order creation
  • evm_rpc is used by our main backend to control interaction with evm via dfinity's-controlled evm rpc.

We can finally deploy our main backend:

cargo build --release --target wasm32-unknown-unknown --package icramp_backend
candid-extractor target/wasm32-unknown-unknown/release/icramp_backend.wasm > backend/icramp/icramp_backend.did
 
dfx deploy icramp_backend --argument "(
  variant {
    Reinstall = record {
      canister_ids = record {
        bitcoin_backend_id = \"zhuzm-wqaaa-aaaap-qpk2q-cai\";
        solana_backend_id = \"u6s2n-gx777-77774-qaaba-cai\";
      };
      ecdsa_key_id = record {
        name = \"dfx_test_key\";
        curve = variant { secp256k1 };
      };
      chains = vec {
        record {
          chain_id = 11155111 : nat64;
          vault_manager_address = \"${CONTRACT_SEPOLIA}\";
          services = variant { EthSepolia = opt vec { variant { Alchemy } } };
          currency_symbol = \"ETH\";
        };
        record {
          chain_id = 84532 : nat64;
          vault_manager_address = \"${CONTRACT_BASE_SEPOLIA}\";
          services = variant {
            Custom = record {
              chainId = 84532 : nat64;
              services = vec {
                record { url = \"https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY}\"; headers = null };
              };
            }
          };
          currency_symbol = \"ETH\";
        };
        record {
          chain_id = 11155420 : nat64;
          vault_manager_address = \"${CONTRACT_OP_SEPOLIA}\";
          services = variant {
            Custom = record {
              chainId = 11155420 : nat64;
              services = vec {
                record { url = \"https://opt-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY}\"; headers = null };
              };
            }
          };
          currency_symbol = \"ETH\";
        };
        record {
          chain_id = 421614 : nat64;
          vault_manager_address = \"${CONTRACT_ARBITRUM_SEPOLIA}\";
          services = variant {
            Custom = record {
              chainId = 421614 : nat64;
              services = vec {
                record { url = \"https://arb-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY}\"; headers = null };
              };
            }
          };
          currency_symbol = \"ETH\";
        };
        record {
          chain_id = 5003 : nat64;
          vault_manager_address = \"${CONTRACT_MANTLE_SEPOLIA}\";
          services = variant {
            Custom = record {
              chainId = 5003 : nat64;
              services = vec {
                record { url = \"https://rpc.ankr.com/mantle_sepolia\"; headers = null };
              };
            }
          };
          currency_symbol = \"MNT\";
        };
      };
      paypal = record {
        client_id = \"${PAYPAL_CLIENT_ID}\";
        client_secret = \"${PAYPAL_CLIENT_SECRET}\";
        api_url = \"api-m.sandbox.paypal.com\";
      };
      revolut = record {
        client_id = \"${REVOLUT_CLIENT_ID}\";
        api_url = \"https://sandbox-oba.revolut.com\";
        proxy_url = \"https://dc55-92-178-206-241.ngrok-free.app\";
        private_key_der = blob \"$(echo $(cat revolut_certs/private.key | base64 -w 0) | base64 --decode)\";
        kid = \"kid_0\";
        tan = \"test-jwk.s3.eu-west-3.amazonaws.com\";
      };
      proxy_url = \"https://ic2p2ramp.xyz\";
      ordiscan = record {
        api_url = \"api.ordiscan.com\";
        api_key = \"${ORDISCAN_API_KEY}\";
      };
      unisat = record {
        api_url = \"open-api-testnet4.unisat.io\";
        api_key = \"${UNISAT_API_KEY}\";
      };
    }
  }
)"

State setup

Let's register some tokens for icp, evm, bitcoin and solana so we allow them to be used to create orders in our frontend:

dfx canister call icramp_backend register_icp_tokens '(vec { "ryjl3-tyaaa-aaaaa-aaaba-cai"; "mc6ru-gyaaa-aaaar-qaaaq-cai" })'
 
dfx canister call icramp_backend register_evm_tokens '(11155111 : nat64, vec {
    record { "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; 6 : nat8; "USD"; opt "Sepolia Official USDC" };
    record { "0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4"; 6 : nat8; "EUR"; opt "Sepolia Official EURC" };
    record { "0x878bfCfbB8EAFA8A2189fd616F282E1637E06bcF"; 18 : nat8; "USD"; opt "Custom USDT deployed by me" };
})'
 
dfx canister call bitcoin_backend register_runes '(vec {
    record { id = "66593:594"; name = "DOG•GO•TO•THE•MOON"; symbol = "🐕"; divisibility = 6 : nat8; cap = 0 : nat; premine = 1_000_000_000 : nat };
    record { id = "73393:191"; name = "UNCOMMON•GOODS"; symbol = "⧉"; divisibility = 0 : nat8; cap = 10_000 : nat; premine = 0 : nat };
})'
 
dfx canister call solana_backend register_tokens '(vec { record { "FxoGGtuyjfVybdA3X5WgxzNhjvSN73R5zqPYg3on8hwE"; "KONG"; "KONG" } })'

The specifics of those registers are documented on each canister's readme and in other posts.


Frontend deployment

First we generate the declarations of the canisters we need to interact with in the frontend:

dfx generate icramp_backend
dfx generate bitcoin_backend
dfx generate solana_backend

And simply deploy:

cd frontend && npm run build && cd .. && dfx deploy frontend --mode reinstall

Everything is setup so we can start integrating our new solana canister in our platform!

We can also fund the frontend's Internet Identity princpal with some tokens of our deployed ledgers. If you login with II, copy the principal reported and execute:

export TO_PRINCIPAL="<your-II-principal>"
export TO_SUBACCOUNT="null"
export AMOUNT="2_500_000_000"
export FEE="10_000"
 
dfx canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_transfer \
'(record {
    to = record {
        owner = principal "'$TO_PRINCIPAL'";
        subaccount = '$TO_SUBACCOUNT';
    };
    fee = opt '$FEE';
    memo = null;
    from_subaccount = null;
    created_at_time = null;
    amount = '$AMOUNT';
})'
 
export AMOUNT="500_000_000"
export FEE="10"
 
dfx canister call mc6ru-gyaaa-aaaar-qaaaq-cai icrc1_transfer \
'(record {
    to = record {
        owner = principal "'$TO_PRINCIPAL'";
        subaccount = '$TO_SUBACCOUNT';
    };
    fee = opt '$FEE';
    memo = null;
    from_subaccount = null;
    created_at_time = null;
    amount = '$AMOUNT';
})'

But for this time we will focus on solana order and auth flow, so just make sure to keep your wallet's account funded for devnet.

Authentication with Solana

Once deploy, we'll get the list of our local deployments: let's open the frontend.

> icramp-frontend@0.0.0 build
> tsc && vite build --mode none
 
 built in 20.50s
Deploying: frontend
frontend canister created with canister id: umunu-kh777-77774-qaaca-cai
Building canister 'frontend'.
 
Reinstalled code for canister frontend, with canister ID umunu-kh777-77774-qaaca-cai
Deployed canisters.
URLs:
  Frontend canister via browser:
    frontend:
      - http://umunu-kh777-77774-qaaca-cai.localhost:8080/ (Recommended)
      - http://127.0.0.1:8080/?canisterId=umunu-kh777-77774-qaaca-cai (Legacy)
  Backend canister via Candid interface:
    bitcoin_backend: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=zhuzm-wqaaa-aaaap-qpk2q-cai
    ckbtc_ledger_canister_testnet: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=mc6ru-gyaaa-aaaar-qaaaq-cai
    evm_rpc: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=7hfb6-caaaa-aaaar-qadga-cai
    icp_ledger_canister: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=ryjl3-tyaaa-aaaaa-aaaba-cai
    icramp_backend: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=uzt4z-lp777-77774-qaabq-cai
    sol_rpc: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=tghme-zyaaa-aaaar-qarca-cai
    solana_backend: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=u6s2n-gx777-77774-qaaba-cai
    xrc: http://127.0.0.1:8080/?canisterId=uqqxf-5h777-77774-qaaaa-cai&id=uf6dk-hyaaa-aaaaq-qaaaq-cai

Let's check the main page in http://umunu-kh777-77774-qaaca-cai.localhost:8080/:

icRamp Login Page — overview

We added the new "Sign in with Solana".

If we click on the new button, we will be redirected to the /register page. Our flow is such that we try to generate an auth message to be signed with the wallet, but if the user is still not registered, we can check in the console logs that we get this error: [generate_auth_message] res = {"Err":{"UserError":{"UserNotFound":null}}}. We will automatically be redirected to the registration page:

icRamp Register Page — overview

After adding the paypal provider, we can register as the first user in the platform. The rest is automatically taken care of, we are redirected to the login page, this time with a ?auth=true url parameter, since the user has been registered in the background, we just need to validate it by signing the message with the Solflare wallet:

This will trigger the handler again, but this time we will not get a UserNotFound error anymore and we will proceed to sign the random message generated by the backend:

useEffect(() => {
  const isAuth = searchParams.get("auth") === "true";
  const pwd = searchParams.get("pwd");
  const email = searchParams.get("email");
  console.log("loginMethod = ", loginMethod);
 
  const performLogin = async () => {
    setLoginAttempt(true);
    try {
      if (loginMethod && "EVM" in loginMethod) {
        await handleEvmLogin();
      } else if (loginMethod && "Bitcoin" in loginMethod) {
        await handleBitcoinLogin();
      } else if (loginMethod && "Solana" in loginMethod) {
        await handleSolanaLogin();
      } else if (loginMethod && "ICP" in loginMethod) {
        await handleInternetIdentityLogin(true);
      } else if (pwd && email) {
        await handleEmailLogin(email, pwd);
      } else {
        console.error("unknown login method");
      }
    } catch (error) {
      console.error("Error during login:", error);
    }
  };
 
  if (isAuth && !loginAttempt) {
    performLogin();
  }
}, [loginMethod]);

We can check in the logs what happened:

[generate_auth_message] res =  {"Ok":"Please sign this message to authenticate: CnzUv9EqfVv5yiYWugHiVzmvuBBzRxHYK64DkLhSWsUj\nNonce: c72ef23b5c78819677c404df6b7debf4f7db81e1ef23a73c9f3d943157d2611c"}

After signing the backend's message with the solana wallet, we are already logged in and our auth session validated. We can start using the platform with our Solana auth!

icRamp Signing Message — overview

We can query the backend directly to check that first newly created user:

$ dfx canister call icramp_backend get_user '( 1 )'
(
  variant {
    Ok = record {
      id = 1 : nat64;
      user_type = variant { Offramper };
      fiat_amounts = vec {};
      payment_providers = vec {
        variant { PayPal = record { id = "dummy@test.com" } };
      };
      auth_message = opt "Please sign this message to authenticate: CnzUv9EqfVv5yiYWugHiVzmvuBBzRxHYK64DkLhSWsUj\nNonce: c72ef23b5c78819677c404df6b7debf4f7db81e1ef23a73c9f3d943157d2611c";
      score = 1 : int32;
      login = variant {
        Solana = record {
          address = "CnzUv9EqfVv5yiYWugHiVzmvuBBzRxHYK64DkLhSWsUj";
        }
      };
      addresses = vec {
        record {
          address_type = variant { Solana };
          address = "CnzUv9EqfVv5yiYWugHiVzmvuBBzRxHYK64DkLhSWsUj";
        };
      };
      session = opt record {
        token = "c05299b7ec94a927a49a40bdb3af5f042f4e02a6bc4abba348382582fbd8f894";
        expires_at = 1_756_446_526_362_203_140 : nat64;
      };
      hashed_password = null;
    }
  },
)

Solana balances and User Login Information

We also adapted the user pages to show the new Solana login and balances. In order to do that, we connect the solana backend declarations:

import { solana_backend as devSolanaBackend } from "@/declarations/solana_backend";
 
import { solana_backend_prod as prodSolanaBackend } from "@/declarations/solana_backend_prod";
 
const isProductionSol = process.env.FRONTEND_SOL_ENV === "mainnet";
 
console.log("isProductionSol", isProductionSol);
 
export const solanaBackend = isProductionSol
  ? prodSolanaBackend
  : devSolanaBackend;

We are currently maintaining prod and dev declarations because we want to allow having two deployments in ICP's mainnet.

With that, we can start defining what we need to query the solana canister:

import { TokenInfo } from "@/declarations/solana_backend/solana_backend.did";
import { solanaBackend } from "../solanaBackendProxy";
 
const RPC_API_BASE =
  process.env.FRONTEND_SOL_ENV === "devnet"
    ? "https://solana-devnet.g.alchemy.com/v2/"
    : "https://solana-mainnet.g.alchemy.com/v2/";
 
export const SOLANA_RPC_URL =
  process.env.FRONTEND_SOLANA_API_TOKEN && process.env.FRONTEND_SOL_ENV
    ? RPC_API_BASE + process.env.FRONTEND_SOLANA_API_TOKEN
    : "https://api.devnet.solana.com";
 
export const fetchSolanaCanisterAddress = async () => {
  try {
    return await solanaBackend.canister_solana_account();
  } catch (error) {
    console.error("Failed to fetch solana canister address:", error);
    throw error;
  }
};
 
export async function getRegisteredSolanaTokens(): Promise<
  Record<string, TokenInfo>
> {
  const raw: Array<[string, TokenInfo]> =
    await solanaBackend.get_registered_tokens();
 
  const out: Record<string, TokenInfo> = {};
  for (const [mint, info] of raw) {
    out[mint] = info;
  }
  return out;
}

We don't need by now the first function: we'll need the solana canister address for creating orders, the offramper will deposit the funds into the solana canister upon order creation. For the purposes of this devlog, we just need to fetch the registered solana tokens, so we can fetch the balances of the user. For Ethereum, ICP and Bitcoin, we did it a bit differently: we hardcoded the allowed tokens and maintained it separately both in the canister registry and in the frontend. Take for example bitcoin's:

import dogRuneLogo from "@/assets/runes/dog-rune-logo.webp";
 
interface RuneIds {
  runeId: string;
  symbol: string;
  logo: string;
  name: string;
}
 
const mainnetRuneIds: RuneIds[] = [
  {
    runeId: "840000:3",
    symbol: "🐕",
    logo: dogRuneLogo,
    name: "DOG•GO•TO•THE•MOON",
  },
  // etc
];
 
const testRuneIds: RuneIds[] = [
  // etc
];
 
export const supportedRuneIds =
  process.env.FRONTEND_BTC_ENV === "mainnet" ? mainnetRuneIds : testRuneIds;

This was done initially when the setup but simpler (before adding registries) but is now inefficient and will be changed in the coming versions. The registry will be the single source of truth and thus avoid drift between frontend/backend. For solana, we did it correctly from the beginning. However, we can still improve a bit the process by offloading the solana canister call and caching the Registry appropiately so we do not spend unnecessary cycles by calling get_registered_tokens again and again. We will use this function for the order creation as well so we do not want to overload computations.

So in the previous file, we will cache the registry and use it inside getRegisteredSolanaTokens:

type Registry = Record<string, TokenInfo>;
const REGISTRY_CACHE_KEY = `sol-registry:${
  process.env.FRONTEND_SOL_ENV || "devnet"
}`;
const REGISTRY_TTL_MS = 24 * 60 * 60 * 1000; // 24h
let __registryMem: { data: Registry | null; ts: number } = {
  data: null,
  ts: 0,
};
let __registryPending: Promise<Registry> | null = null;
 
function readLS(): Registry | null {
  if (typeof window === "undefined") return null;
  const raw = window.localStorage.getItem(REGISTRY_CACHE_KEY);
  if (!raw) return null;
  try {
    const parsed = JSON.parse(raw) as {
      ts: number;
      data: Array<[string, TokenInfo]> | Registry;
    };
    if (Date.now() - parsed.ts > REGISTRY_TTL_MS) return null;
    const map = Array.isArray(parsed.data)
      ? Object.fromEntries(parsed.data as Array<[string, TokenInfo]>)
      : (parsed.data as Registry);
    return map;
  } catch {
    return null;
  }
}
 
function writeLS(kv: Array<[string, TokenInfo]>) {
  if (typeof window === "undefined") return;
  window.localStorage.setItem(
    REGISTRY_CACHE_KEY,
    JSON.stringify({ ts: Date.now(), data: kv })
  );
}
 
export function invalidateRegisteredSolanaTokensCache() {
  __registryMem = { data: null, ts: 0 };
  if (typeof window !== "undefined")
    window.localStorage.removeItem(REGISTRY_CACHE_KEY);
}
 
export async function getRegisteredSolanaTokens(
  forceRefresh = false
): Promise<Registry> {
  const now = Date.now();
 
  // in-memory fresh
  if (
    !forceRefresh &&
    __registryMem.data &&
    now - __registryMem.ts < REGISTRY_TTL_MS
  ) {
    return __registryMem.data;
  }
 
  // localStorage
  if (!forceRefresh) {
    const ls = readLS();
    if (ls) {
      __registryMem = { data: ls, ts: now }; // refresh in-memory clock
      return ls;
    }
  }
 
  // coalesce concurrent calls
  if (__registryPending && !forceRefresh) return __registryPending;
 
  __registryPending = solanaBackend
    .get_registered_tokens()
    .then((kv) => {
      const map: Registry = Object.fromEntries(kv);
      __registryMem = { data: map, ts: now };
      writeLS(kv);
      __registryPending = null;
      return map;
    })
    .catch((e) => {
      __registryPending = null;
      if (__registryMem.data) return __registryMem.data;
      throw e;
    });
 
  return __registryPending;
}

Now we can make use of getRegisteredSolanaTokens in UserContext and finally get all the solana-related user information. The user state context is now:

export interface Balance {
  raw: bigint | number;
  formatted: string;
  logo: string;
}
 
export interface BitcoinBalance {
  balance: Balance;
  runes: { [runeId: string]: Balance & { symbol: string; name: string } };
}
 
export interface SolanaBalance {
  balance: Balance;
  splTokens: {
    [mint: string]: Balance & { symbol?: string; decimals?: number };
  };
}
 
interface UserContextProps {
  user: User | null;
  userType: UserTypes;
  refetchUser: () => Promise<void>;
  setUser: (user: User | null) => void;
  loginMethod: LoginAddress | null;
  setLoginMethod: (login: LoginAddress | null, pwd?: string) => void;
 
  currency: string;
  setCurrency: (currency: string) => void;
 
  sessionToken: string | null;
  password: string | null;
  authenticateUser: (
    login: LoginAddress | null,
    authData?: AuthenticationData,
    backendActor?: ActorSubclass<_SERVICE>
  ) => Promise<Result_1>;
  logout: () => Promise<void>;
 
  icpAgent: HttpAgent | null;
  backendActor: ActorSubclass<_SERVICE> | null;
  principal: Principal | null;
  loginInternetIdentity: () => Promise<[Principal, HttpAgent]>;
 
  icpBalances: { [tokenName: string]: Balance } | null;
  evmBalances: { [tokenAddress: string]: Balance } | null;
  solanaBalance: SolanaBalance | null;
  bitcoinBalance: BitcoinBalance | null;
  fetchBalances: () => Promise<void>;
 
  bitcoinAddress: string | null;
  connectUnisat: () => Promise<string | null>;
 
  solanaPubkey: string | null;
  connectSolana: () => Promise<string | null>;
}

First of all, we use that in the menu, as seen in the screenshot, we now see the Solana balance (or the button to connect the solana wallet if not connected):

icRamp Solana Menu — overview

The last step is to update the Profile and Balance pages!

icRamp User Balances — overview

In the next devlogs, we will finish the solana integration. This will include order creation, deposits to the vault canister, and complete/cancel flows.

Stay Updated

Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.

You might also like

icRamp Devlog #6 — icRamp Orders with Solana

Everything is ready for us to create orders in the frontend containing solana and executing the full offramping flow.

icRamp Devlog #3 — icRamp Canister & Solana Integration

Third Chain Fusion grant log: wiring icRamp's core backend with the Solana canister, persisting canister IDs, and preparing escrow flows for SOL/SPL assets.

icRamp Devlog #2 — Solana Canister, Registry & Vault

Second Chain Fusion grant: building a Solana canister with safe token registry and a thin vault to coordinate escrow.