icRamp Devlog #6 — icRamp Orders with Solana

icRamp Devlog #6 — icRamp Orders with Solana

9/4/20259 min • icramp
ICPSolanaChain FusionEscrowCanistersFrontend

In this post we are finally wrapping up the solana implementation and integration in our onramping platform!

Pricing refactor for Solana

We cleaned up our price-orchestration layer so the frontend and backend speak a single, explicit "pricing route" for each asset. This was needed before wiring Solana deposits into orders.

What changed (and why)

  • New entrypoint (backend):
#[ic_cdk::update]
async fn get_exchange_rate(fiat_symbol: String, base_asset: RateAsset) -> Result<f64>

Instead of passing booleans like is_rune, we pass a typed route via RateAsset.

  • RateAsset enum (single source of truth). Implements:

    • .normalize() standardizes symbols (ckBTC→BTC, rune name cleanup, fiat detection).
    • .key_symbol() gives a stable cache key.
    • .to_xrc_asset() converts to our XRC Asset when applicable.
enum RateAsset {
  Fiat { symbol: String },          // e.g. USD, EUR
  Crypto { symbol: String },        // e.g. BTC, ETH, USDC
  Rune { name: String },            // e.g. DOG•GO•TO•THE•MOON
  Solana { symbol: String, mint: Option<String> } // symbol + optional mint
}
  • String-keyed cache: We cache by normalized symbols ((base, quote) -> rate), avoiding extra struct juggling:
heap::get_cached_rate(&base_symbol, &quote_symbol)
heap::cache_exchange_rate(&base_symbol, &quote_symbol, rate)
  • Orchestrator logic (unchanged semantics, clearer paths):

    1. Stable pairs (USD/USD, EUR/EUR) → 1.0
    2. Cache hit → return
    3. Rune paths
    • Rune → *: Ordiscan (USD), then USD→quote via XRC
    • * → Rune: base→USD via XRC, then divide by Ordiscan(USD)
    1. Everything else → XRC by symbol
    • Solana fallback: if XRC fails and mint is provided, price via Jupiter (mint→USD), then USD→quote via XRC. Works both when Solana is base or quote.
  • End-to-end usage from order code:

    • BlockchainAsset::to_rate_asset() maps each on-chain asset to the right RateAsset variant (Bitcoin runes → Rune, Solana SPL → Solana { symbol, mint }, the rest → Crypto).
    • calculate_price_and_fee() now uses:
    let base = crypto.asset.to_rate_asset().await?;
    let quote = RateAsset::Fiat { symbol: currency.into() };
    let rate  = get_exchange_rate(base, quote).await?;

Why this is better

  • Explicit routing: no more hidden flags; each asset’s pricing path is self-describing.
  • Cleaner cache keys: normalized strings avoid drift and reduce conversions.
  • Robust Solana support: we try XRC first (by symbol), and only fall back to Jupiter if needed—and only when we actually have a mint.
  • Rune integrity: runes never touch XRC; we keep the USD pivot via Ordiscan and reuse XRC only for the fiat leg.

This refactor sets the stage for the final step: creating and funding Solana orders (native SOL and SPLs) with consistent pricing, fees, and UX.

Unified fee quoting (all chains)

We introduced one backend entrypoint to quote fees for any asset and amount, so the UI can block underfunded orders before broadcasting any tx:

#[derive(CandidType, Deserialize, Serialize)]
pub struct FeeQuote {
    pub blockchain_fee: u128,  // network fee in token base units
    pub admin_fee: u128,       // protocol fee
    pub total_fee: u128,       // blockchain_fee + admin_fee
}
 
#[ic_cdk::update]
async fn calculate_order_fees(
    asset: BlockchainAsset,
    crypto_amount: u128,
    estimated_gas_lock: Option<u64>,
    estimated_gas_withdraw: Option<u64>,
) -> Result<FeeQuote> {
    let total_fee = order_management::order_crypto_fee(
        asset.clone(),
        crypto_amount,
        estimated_gas_lock,
        estimated_gas_withdraw,
    )
    .await?;
 
    let admin_fee = get_admin_fee(crypto_amount);
    let blockchain_fee = total_fee.saturating_sub(admin_fee);
 
    Ok(FeeQuote {
        blockchain_fee,
        admin_fee,
        total_fee,
    })
}

Per-chain details baked in

  • EVM: uses calculate_order_evm_fees with your gas estimates (lock + withdraw) and max fee per gas; converts to token units if paying with an ERC‐20.
  • Bitcoin / Runes: base fee from the BTC backend; for Runes we scale by rune rate & divisibility; we keep a ×2 safety factor inside order_crypto_fee.
  • ICP: ledger fee * 2 (lock + withdraw).
  • Solana / SPL: on-chain prioritization fees + signatures; if SPL, we add ATA rent (165-byte rent exemption) and convert lamports → token base units using SOL→token rate (ceil’d).

This single quote powers a consistent UX and ensures the backend's FundsTooLow never surprises the user. Frontend guard (one check before any tx):

if (!feeQuote) throw new Error("Could not compute fees");
if (cryptoAmountUnits - feeQuote.total_fee <= 0) {
  throw new Error("Amount too low to cover network + protocol fees.");
}

That’s it. We removed the scattered, chain-specific pre-checks. For EVM we still compute gas first (because the quote needs it), but we do no price math in the UI.

Solana fee estimator (why quotes are accurate)

In the Solana canister we added a conservative estimator:

  • Median µ-lamports / CU from getRecentPrioritizationFees over:

    • system_program::ID
    • ComputeBudget111…
    • the token program owner of the mint (Token or Token-2022) when SPL
    • our vault pubkey (bias toward our accounts)
  • Compute-unit budgets:

    • SOL xfer: ~600 CU (+ signature)
    • SPL xfer: ~10k CU (+ optional ATA create ~40k CU)
    • ATA rent via getMinimumBalanceForRentExemption(165) with a safe fallback
    • If SPL, convert lamports → token base units using SOL→token rate and token decimals (ceil to avoid undercharging).

We log the full breakdown:

[order_crypto_fee]
  lock_lamports=..., withdraw_lamports=..., lamports_total=...,
  rate(tokens/SOL)≈..., decimals=..., token_fee_human≈..., token_fee_base=...

Verify Solana Transactions

We finally wired Solana deposits into the order flow — both native SOL and SPL tokens — using a single RPC call to the sol_rpc canister. This section explains the two thorniest bits: getting the right encoding and proving a deposit is really for this off-ramper.

In the backend, we need to validate the transaction given the transaction signature once the offramper deposits the funds of the order into the backend vault. Let's focus on the emergency method to create an order in the case the offramper deposited correctly the funds but our backend threw some unexpected error. In that case, we want to be able to trigger the creation of the order again in our backend to match the solana deposit signaled by the offramper.

// ------------------
// Solana Management
// ------------------
#[ic_cdk::update]
pub async fn create_solana_order_with_tx(
    signature: String,
    user: u64,
    offramper: String,
    providers: HashMap<PaymentProviderType, PaymentProvider>,
    currency: String,
    amount: u128,
    spl_token: Option<String>,
) -> Result<u64> {
    guards::only_controller()?;
 
    let asset = BlockchainAsset::Solana {
        spl_token: spl_token.clone(),
    };
    order_management::validate_deposit_tx(
        &asset,
        Some(DepositInput::Solana(SolanaOrderInput {
            signature: signature.clone(),
            mint: spl_token.clone(),
        })),
        offramper.clone(),
        amount,
    )
    .await?;
 
    solana_backend_deposit_funds(offramper.clone(), amount as u64, spl_token.clone()).await?;
 
    let order_id = order_management::create_order(
        &currency,
        user,
        TransactionAddress {
            address_type: AddressType::Solana,
            address: offramper,
        },
        providers,
        asset,
        amount,
        None,
        None,
        None,
    )
    .await?;
 
    spent_transactions::mark_tx_hash_as_processed(signature);
 
    Ok(order_id)
}

The validate_deposit_tx will also be used in the usual create_order. We accept a deposit only if exactly one of the following is true:

  • SPL token path (we expect a mint):
  1. Look at pre_token_balances and post_token_balances for that mint.
  2. Compute delta = post - pre at each account_index.
  3. There must be exactly one positive delta == order_amount, and the owner of that token account must equal the off-ramper’s address.

Why not use account keys here? Token balances already give us the owner key — simpler and more robust.

  • SOL path (no mint provided):
  1. Compute lamport deltas from pre_balances and post_balances.
  2. Use our derived account_keys to bind each delta to an address.
  3. There must be exactly one positive delta == order_amount to the off-ramper's address.

Any other positive credit in the same tx → ambiguous → we reject. If the tx signature was already processed, we reject as well (idempotency).

Why all this for "just one tx"? Because indices aren't addresses. Solana's status/meta API gives you balances by index, not by address. On legacy txs that's fine; v0 can add keys dynamically. If you don't rebuild the full account-key list (static + loaded), you can't safely say who got credited — which is the whole point of escrow intake. One Base64 decode + bincode pass solves this with zero extra RPC calls.

We can take a transaction done earlier that did not create an order so we can manually check it out:

dfx canister call icramp_backend create_solana_order_with_tx '(
    "4z6D7HWpqcTUb7oBnbP4fkPdTyfDDSm1myHBMNjvsgrcxDdFZkP5no25UF9i6b8R4zZvAWKCuCTfhABqsbBhcVwe",
    1 : nat64,
    "CnzUv9EqfVv5yiYWugHiVzmvuBBzRxHYK64DkLhSWsUj",
    vec {
        record {
            variant { PayPal };
            variant { PayPal = record { id = "sb-ioze230588840@personal.example.com" : text } };
        }
    },
    "USD",
    20_000_000 : nat,
    null
)'
(variant { Ok = 1 : nat64 })

Our first solana order is:

dfx canister call icramp_backend get_order '(1)'
(
  variant {
    Ok = variant {
      Created = record {
        id = 1 : nat64;
        created_at = 1_757_261_730_263_095_323 : nat64;
        offramper_user_id = 1 : nat64;
        crypto = record {
          fee = 110_000 : nat;
          asset = variant { Solana = record { spl_token = null } };
          rune_utxos = null;
          amount = 20_000_000 : nat;
        };
        currency = "USD";
        offramper_providers = vec {
          record {
            variant { PayPal };
            variant {
              PayPal = record { id = "sb-ioze230588840@personal.example.com" }
            };
          };
        };
        offramper_address = record {
          address_type = variant { Solana };
          address = "CnzUv9EqfVv5yiYWugHiVzmvuBBzRxHYK64DkLhSWsUj";
        };
        processing = false;
      }
    }
  },
)

Again in the frontend, in the /view page, we can observe the newly manually-created order lacks the price and amount in the card's UX. Let's fix that:

Frontend fix: solana confirmations

Currently, when creating a solana order, we can hit an error: SPL “MetaError: encoded confirmation transaction not found”. The root cause: reading getTransaction immediately after sending the tx can return None on devnet RPCs.

Fix: we now wait for confirmation client-side before calling create_order:

await waitForSolanaConfirmation(txSig, { timeoutMs: 90_000, minConfirms: 1 });

Solana order UI: filters, price & amount wiring

We fixed a UI gap where Solana orders showed neither price nor amount:

  • Root cause: our useOrderLogic hook didn’t build a TokenOption for Solana and returned "Solana not implemented" in the amount formatter. With no token, the hook skipped getCurrentPrice() entirely, so both price and amount stayed blank.
  • We added:
    1. Token resolution for Solana
    2. Amount formatting
  • Additionally, we added the solana button in the filters. Check it out:

icRamp Solana Order UX

Locking and paying for the order

We can logout, create another user using metamask solana wallet, for example. This time the user will be of type onramper, so once registered and authorized, we see the order of the previous offramper. Let's click the button Lock. The order becomes in locked state, so now we have 30 min to pay the offramper via the onramper's paypal. Behind the scenes, everything is the same as our previous orders, just that now the assets are being handled and moved using our new Solana Backend Canister.

icRamp Solana Order Locked

As usual clicking on the "pay with paypal" button, we are promped to the paypal modal and we just accept the transaction. The backend does the rest.

icRamp Solana Order Paying

icRamp Solana Order Completed

Full flow of a Solana Order with a SPL token

Stay Updated

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

You might also like

icRamp Devlog #4 — icRamp frontend Deployment Setup with Solana

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