
icRamp Devlog #18 — Pay with Crypto (Experimental Trustless P2P Bridge)
This is a continuation of Devlog #17 — Partial Fills. There, we shipped order locks and partial fills.
Today we introduce an experimental feature: pay with crypto—a trustless P2P bridge inside icRamp.
Idea: the onramper pays the offramper in a different chain (bridge semantics). We accept the same tokens we already register in the vaults, but we’ll start the UI with stables only (USDC/EURC).
For that end, we had to refractor and improve
PaymentProviderand the inclusion of them inUserandOrder.
What we changed (backend)
1) Providers: single-asset Crypto + multiple providers per user & order
We simplified the model so each payment method is self-contained.
Users can list many providers (including many Crypto), and each order carries a free Vec<PaymentProvider> copied from the offramper.
pub struct User {
- pub payment_providers: HashSet<PaymentProvider>,
+ pub payment_providers: Vec<PaymentProvider>,// model/types/payment.rs
#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
pub enum PaymentProviderType { PayPal, Revolut, Stripe, Email, Crypto }
#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
pub enum PaymentProvider {
PayPal { id: String },
Revolut { scheme: String, id: String, name: Option<String> },
Stripe { account_id: String, platform: String },
Email { email: String },
// One provider = one asset + one destination address
Crypto { asset: BlockchainAsset, address: TransactionAddress },
}// model/types/order.rs
#[derive(CandidType, Deserialize, Clone, Debug)]
pub struct Order {
pub id: u64,
pub created_at: u64,
pub currency: String,
pub offramper_user_id: u64,
pub offramper_address: TransactionAddress,
pub offramper_providers: Vec<PaymentProvider>, // free vector
pub crypto: Crypto,
pub fills: Vec<FillRecord>,
pub processing: bool,
}2) Validation: subset on create, role rules on user, and Crypto address ownership
- User: providers are a Vec (not set); we reject exact duplicates, enforce roles (Stripe⇢Offramper only, Email⇢Onramper only), and require that
Crypto.address∈user.addresses. - Order creation: the offramper provider vector must be a subset of the user’s vector (we don’t enforce duplicates/“one per card type” here—that’s done by
validate_offramper_providers_for_order).
// api/users.rs (sketch)
pub async fn register_user(..., payment_providers: Vec<PaymentProvider>, ...) -> Result<User> {
// 1) field integrity
for p in &payment_providers { p.validate().await?; }
// 2) no exact duplicates
assert_no_exact_duplicates(&payment_providers)?;
// 3) role rules
assert_role_allows_list(&user_type, &payment_providers)?;
// 4) crypto addresses belong to user.addresses (built from login unless email)
assert_crypto_addresses_belong(&payment_providers, &initial_addresses)?;
...
user.payment_providers = payment_providers;
}Helpers:
/// Purpose: returns Err if the vector contains exact duplicate providers.
fn assert_no_exact_duplicates(list: &[PaymentProvider]) -> Result<()> {
use std::collections::HashSet;
let mut set: HashSet<&PaymentProvider> = HashSet::new();
for p in list {
if !set.insert(p) {
return Err(SystemError::InvalidInput("Duplicate payment provider".into()).into());
}
}
Ok(())
}
/// Purpose: role constraints (Stripe only Offramper, Email only Onramper).
fn assert_role_allows_list(user_type: &UserType, list: &[PaymentProvider]) -> Result<()> {
if matches!(user_type, UserType::Onramper)
&& list
.iter()
.any(|p| matches!(p, PaymentProvider::Stripe { .. }))
{
return Err(SystemError::InvalidInput(
"Stripe is only allowed for Offramper users.".into(),
)
.into());
}
if matches!(user_type, UserType::Offramper)
&& list
.iter()
.any(|p| matches!(p, PaymentProvider::Email { .. }))
{
return Err(
SystemError::InvalidInput("Email is only allowed for Onramper users.".into()).into(),
);
}
Ok(())
}
/// Purpose: ensure all Crypto provider addresses belong to the given address book.
fn assert_crypto_addresses_belong(
list: &[PaymentProvider],
addresses: &HashSet<TransactionAddress>,
) -> Result<()> {
for p in list {
if let PaymentProvider::Crypto { address, .. } = p {
if !addresses.contains(address) {
return Err(SystemError::InvalidInput(
"Crypto provider address not present in user's addresses".into(),
)
.into());
}
}
}
Ok(())
}
/// Purpose: single-candidate version for add_payment_provider().
fn assert_can_add_provider(user: &User, candidate: &PaymentProvider) -> Result<()> {
// role rules
if matches!(user.user_type, UserType::Onramper)
&& matches!(candidate, PaymentProvider::Stripe { .. })
{
return Err(SystemError::InvalidInput(
"Stripe is only allowed for Offramper users.".into(),
)
.into());
}
if matches!(user.user_type, UserType::Offramper)
&& matches!(candidate, PaymentProvider::Email { .. })
{
return Err(
SystemError::InvalidInput("Email is only allowed for Onramper users.".into()).into(),
);
}
// duplicate guard (exact)
if user.payment_providers.iter().any(|p| p == candidate) {
return Err(SystemError::InvalidInput("Duplicate payment provider".into()).into());
}
// crypto address ownership
if let PaymentProvider::Crypto { address, .. } = candidate {
if !user.addresses.contains(address) {
return Err(SystemError::InvalidInput(
"Crypto provider address not present in user's addresses".into(),
)
.into());
}
}
Ok(())
}Orders:
// api/order_create.rs (subset)
if let Some(missing_ty) = first_missing_provider(&offramper_providers, &user.payment_providers) {
return Err(UserError::ProviderNotInUser(missing_ty))?;
}
validate_offramper_providers_for_order(&offramper_providers)?; // one PayPal/Revolut/Stripe; many Crypto// api/payment_providers.rs (add)
pub async fn add_payment_provider(..., payment_provider: PaymentProvider) -> Result<()> {
payment_provider.validate().await?;
users::mutate_user(user_id, |user| {
user.validate_session(token)?;
assert_can_add_provider(user, &payment_provider)?; // role, exact dup, crypto addr ownership
user.payment_providers.push(payment_provider);
Ok(())
})?
}New helpers:
fn contains_provider_exact(needle: &PaymentProvider, haystack: &[PaymentProvider]) -> bool {
haystack.iter().any(|p| p == needle)
}
/// Purpose: ensure `subset` ⊆ `superset` (exact entries); returns first missing provider type if any.
pub fn first_missing_provider<'a>(
subset: &'a [PaymentProvider],
superset: &'a [PaymentProvider],
) -> Option<PaymentProviderType> {
subset
.iter()
.find(|p| !contains_provider_exact(p, superset))
.map(|p| p.provider_type())
}
pub fn validate_offramper_providers_for_order(providers: &[PaymentProvider]) -> Result<()> {
if providers.is_empty() {
return Err(OrderError::InvalidInput("Provider list is empty".into()).into());
}
// duplicates (exact) check
{
let mut set = HashSet::new();
for p in providers {
if !set.insert(p) {
return Err(OrderError::InvalidInput("Duplicate provider entry".into()).into());
}
}
}
// one-of-each for these types
let mut seen_paypal = false;
let mut seen_revolut = false;
let mut seen_stripe = false;
for p in providers {
match p.provider_type() {
PaymentProviderType::PayPal => {
if seen_paypal {
return Err(
OrderError::InvalidInput("Multiple PayPal not allowed".into()).into(),
);
}
seen_paypal = true;
}
PaymentProviderType::Revolut => {
if seen_revolut {
return Err(
OrderError::InvalidInput("Multiple Revolut not allowed".into()).into(),
);
}
seen_revolut = true;
}
PaymentProviderType::Stripe => {
if seen_stripe {
return Err(
OrderError::InvalidInput("Multiple Stripe not allowed".into()).into(),
);
}
seen_stripe = true;
}
PaymentProviderType::Email => {
return Err(OrderError::InvalidInput(
"Offramper provider cannot be of type email".into(),
)
.into());
}
PaymentProviderType::Crypto => {}
}
}
Ok(())
}3) Lock flow: “is this provider allowed?” (order + user)
Lock now checks:
- If onramper uses Email → offramper must expose Stripe (email-checkout path).
- Otherwise, the onramper provider type must be present in the order’s offramper providers, and the onramper actually owns that provider.
let provider_ok = if on_is_email {
off_supports_stripe && contains_provider_exact(&onramper_provider, &user.payment_providers)
} else {
contains_provider_type(&onramper_provider, &order.offramper_providers)
&& contains_provider_exact(&onramper_provider, &user.payment_providers)
};
if !provider_ok { return Err(OrderError::InvalidOnramperProvider.into()); }4) Verify path: Crypto branch in the same API (no new endpoints)
We extended your existing verify_transaction signature with an optional payment_input and handled PaymentProvider::Crypto in place.
Bridge rule is enforced: payment chain must differ from the escrowed asset’s chain.
MVP policy: allow only stables (USDC/EURC) in the frontend for now, even though the backend accepts any registered vault asset.
// api/verify.rs
#[ic_cdk::update]
async fn verify_transaction(order_id: u64, session_token: Option<String>, transaction_id: String, payment_input: Option<DepositInput>) -> Result<()> {
orders::set_processing_order(&order_id)?;
if let Err(e) = process_transaction(order_id, session_token, transaction_id, payment_input).await {
orders::unset_processing_order(&order_id)?;
return Err(e);
}
Ok(())
}
async fn process_transaction(..., payment_input: Option<DepositInput>) -> Result<()> {
let order = order_management::verify_order_is_payable(order_id, session_token)?;
match &order.clone().onramper.provider {
// PayPal/Revolut/Email branches unchanged...
PaymentProvider::Crypto { asset, address } => {
let input = payment_input.ok_or(OrderError::InvalidInput("deposit input is empty".into()))?;
payment_management::crypto::verify_crypto_transaction(asset, address, &input, &order).await?;
}
_ => { /* other providers */ }
}
payment_management::handle_payment_completion(&order).await
}Under the hood, we reuse the same per-chain verification we already use for deposits:
EVM: check tx status → parse
Transfer(to=offramper_addr, amount)if ERC-20; native path later.Solana: fetch tx metadata → assert SPL/native credit to offramper owner.
Bitcoin/ICP: wiring comes next; we’ll leverage the existing listeners (see next sections).
Why this is a “trustless bridge”
- The escrowed asset remains locked in icRamp’s vault on chain A.
- The onramper pays the offramper directly on chain B (currently stables for UX).
- Once chain-B payment is verified on-chain, icRamp releases the escrow on chain A—no custodial swap, no market maker.
- Because we leverage the same vault registries and on-chain confirmations, counterparties don’t have to trust each other.
Developer notes
- MVP UX: UI will expose USDC (USD) and EURC (EUR) as payment options first. Other registered assets remain available via API and will be revealed gradually.
- Auditing: we append a
FillRecordwithpayment_id/tx_id, then reuse the existing release/complete machinery. - Safety: the subset check prevents offramper-spoofing of providers; Crypto addresses must belong to the user’s address book.
Cleaning up deposit verification: one function to rule them all
Before wiring “Pay with crypto” into orders, I wanted the deposit side to be boring and predictable.
Previously, the backend had a validate_deposit_tx helper sitting inside order_management, with a big match asset that:
- queried EVM receipts and parsed events,
- pulled Solana
TxMetadataand did balance diffing, - checked Bitcoin txids for duplication,
- and basically mixed business logic (“is this amount correct?”) with the specific call site (“this is
create_order, so we do X afterwards”).
That worked, but it made it hard to reuse the exact same invariants in different flows: creating an order from the frontend, creating an order from a controller, topping up, or verifying a payment for a release.
So the first step in this session was to extract everything into a single, chain-agnostic verifier:
/// Verifies that a user-supplied transaction really corresponds to the
/// expected on-chain transfer (amount, asset, addresses, freshness).
///
/// Returns the chain-specific tx id / signature if one exists for that asset.
/// - `logical_sender` is the "offramper" address for deposits in `create_order`
/// and "onramper" in pay-with-crypto verification paths.
/// - `amount` is in native units (lamports / wei / smallest token units).
pub async fn verify_crypto_transaction(
asset: &BlockchainAsset,
deposit_input: Option<DepositInput>,
logical_sender: &str,
amount: u128,
) -> Result<Option<String>> {
match asset {
BlockchainAsset::EVM { chain_id, token_address } => {
verify_evm_deposit(
*chain_id,
token_address.clone(),
deposit_input,
logical_sender,
amount,
)
.await
}
BlockchainAsset::ICP { ledger_principal } => {
// No ICP tx introspection from the canister; just verify token support.
is_icp_token_supported(ledger_principal)?;
Ok(None)
}
BlockchainAsset::Bitcoin { rune_id } => {
verify_bitcoin_deposit(rune_id.clone(), deposit_input, amount).await
}
BlockchainAsset::Solana { spl_token } => {
verify_solana_deposit(spl_token.clone(), deposit_input, amount).await
}
}
}A couple of key design choices here:
- Logical sender: instead of hardcoding “offramper” vs “onramper”, the function just receives a
logical_sender: &str. For deposits (create_order,top-ups) this is the offramper; for “pay with crypto” it’s the onramper. The EVM/Solana branches simply check that the transfer’s from / credited owner matcheslogical_sender. - Per-chain helpers:
- EVM: fetch receipt, parse our
Depositevent, checkuser,amount,token, and expiry vs latest block. - Solana: fetch
TxMetadata, diffpre_token_balances/post_token_balances(or lamports) for the expected mint/address, enforce a single unambiguous credit equal toamountinto our vault. - Bitcoin: run rune validation (if applicable) and, crucially, ensure the tx isn’t already marked as processed. Full confirmation and UTXO validation remain in the listener.
- ICP: only check that the ledger is supported; the actual transfer is handled by the agent flow on the frontend/caller side.
- EVM: fetch receipt, parse our
With this, every call site that previously used validate_deposit_tx now calls verify_crypto_transaction, and the old helper is gone.
Wiring verify_crypto_transaction into create / top-up flows
The nice part is how this collapses a lot of duplicated patterns into one line per call site.
Create order (frontend-initiated deposits)
#[ic_cdk::update]
async fn create_order(
session_token: String,
currency: String,
offramper_providers: Vec<PaymentProvider>,
asset: BlockchainAsset,
crypto_amount: u128,
offramper_address: TransactionAddress,
offramper_user_id: u64,
deposit_input: Option<DepositInput>,
) -> Result<Option<u64>> {
let user = stable::users::get_user(&offramper_user_id)?;
user.validate_session(&session_token)?;
user.is_banned()?;
user.is_offramper()?;
if let Some(missing_ty) = first_missing_provider(&offramper_providers, &user.payment_providers)
{
return Err(UserError::ProviderNotInUser(missing_ty))?;
}
// 🔍 unified cross-chain verification of the deposit
let tx_hash = verify_crypto_transaction(
&asset,
deposit_input.clone(),
&offramper_address.address,
crypto_amount,
)
.await?;
match asset.clone() {
BlockchainAsset::Bitcoin { rune_id } => {
// BTC: order is created later from the listener.
let canister_address = match deposit_input {
Some(DepositInput::Bitcoin(v)) => Ok(v.canister_address),
_ => Err(OrderError::InvalidInput(
"Missing bitcoin order input".to_string(),
)),
}?;
bitcoin_management::spawn_bitcoin_tx_listener(
tx_hash.unwrap(),
BitcoinTransactionAction::DepositFunds {
offramper_providers,
offramper_address,
offramper_id: offramper_user_id,
asset,
currency,
amount: crypto_amount,
},
canister_address,
rune_id,
0,
);
Ok(None)
}
BlockchainAsset::Solana { spl_token } => {
// Solana: order created immediately, then we mirror to the Solana backend.
let order_id = order_management::create_order(
¤cy,
offramper_user_id,
offramper_address.clone(),
offramper_providers,
asset,
crypto_amount,
None,
None,
None,
)
.await?;
solana_backend_deposit_funds(
offramper_address.address,
crypto_amount as u64,
spl_token,
)
.await?;
if let Some(sig) = tx_hash {
spent_transactions::mark_tx_hash_as_processed(sig);
}
Ok(Some(order_id))
}
_ => {
// EVM / ICP: same idea, but with gas estimates for EVM.
let (gas_lock, gas_withdraw) = match deposit_input {
Some(DepositInput::Evm(v)) => {
(Some(v.estimated_gas_lock), Some(v.estimated_gas_withdraw))
}
_ => (None, None),
};
let order_id = order_management::create_order(
¤cy,
offramper_user_id,
offramper_address,
offramper_providers,
asset,
crypto_amount,
gas_lock,
gas_withdraw,
None,
)
.await?;
if let Some(tx_hash) = tx_hash {
spent_transactions::mark_tx_hash_as_processed(tx_hash);
};
Ok(Some(order_id))
}
}
}Key points:
- For EVM/Solana, the frontend already waits for confirmation (Metamask
wait()/ SolanagetSignatureStatuses), and the backend re-verifies withverify_crypto_transactionbefore creating the order or touching the vault. - For Bitcoin, the backend doesn’t trust the tx yet; we only check for basic consistency / non-duplication and then hand off to the Bitcoin listener, which waits for actual confirmation and then creates the order + writes into the BTC backend.
Exactly the same pattern is used in:
create_evm_order_with_tx(controller-driven backfill of orders from a known tx)create_solana_order_with_txcreate_bitcoin_order_with_txtop_up_order(partial fills where the offramper adds more crypto to an existing order)
So just by adding verify_crypto_transaction, all these flows now share one single, audited verification pipeline.
Implementing “Pay with Crypto” verification
Once deposits felt clean, I could tackle the other half: verifying that the onramper actually paid before we release the vault.
Conceptually, the process_transaction flow already existed for PayPal / Revolut / Stripe. It does:
- Check that the order can be paid (
verify_order_is_payable). - Inspect the payment provider:
- PayPal: fetch capture details, check amount / currency / payer / payee, mark paid.
- Revolut: similar with their API.
- Stripe: verify via checkout session and customer email.
- Once verification passes, call
handle_payment_completion(&order)to:- record a pending fill,
- trigger the vault release on the order asset (not the payment asset),
- and eventually mark the order as Open or Completed once L1 confirms.
The idea for crypto was to plug into the exact same pipeline, using PaymentProvider::Crypto { asset, address } and our new verify_crypto_transaction.
The crypto payment branch in process_transaction
The entry point now looks like this (simplified around the crypto provider):
async fn process_transaction(
order_id: u64,
session_token: Option<String>,
transaction_id: String,
payment_input: Option<DepositInput>,
) -> Result<()> {
let order = order_management::verify_order_is_payable(order_id, session_token)?;
let mut defer_completion_to_bitcoin_listener = false;
match &order.clone().onramper.provider {
PaymentProvider::PayPal { id: onramper_id } => {
// ... verify PayPal, set payment_id = transaction_id, mark paid, append fill ...
}
PaymentProvider::Revolut { .. } => {
// ... Revolut verification ...
}
PaymentProvider::Stripe { .. } => {
return Err(OrderError::InvalidOnramperProvider.into())
}
PaymentProvider::Email { email } => {
// ... Stripe Checkout verification ...
}
PaymentProvider::Crypto { asset, address } => {
if payment_input.is_none() {
return Err(OrderError::InvalidInput(
"deposit input is empty".to_string(),
))?;
}
// Bridge rule: base asset and payment asset must live on different chains.
if asset.blockchain_type() == order.base.crypto.asset.blockchain_type() {
return Err(OrderError::SameChainPaymentForbidden.into());
}
// Sanity: the address must actually belong to the expected blockchain.
if address.to_blockchain_type() != asset.blockchain_type() {
return Err(OrderError::InvalidInput(
"crytpo address does not correspond to asset type".into(),
)
.into());
}
match asset {
BlockchainAsset::Bitcoin { rune_id } => {
// BTC: verification is split between a quick check and a listener.
handle_bitcoin_crypto_payment(
&order,
asset,
address,
payment_input.clone(),
rune_id.clone(),
)
.await?;
defer_completion_to_bitcoin_listener = true;
}
_ => {
// Non-BTC (EVM, Solana, ICP): verify now, then complete normally.
handle_non_bitcoin_crypto_payment(
&order,
asset,
address,
payment_input.clone(),
)
.await?;
}
}
}
}
if defer_completion_to_bitcoin_listener {
Ok(())
} else {
payment_management::handle_payment_completion(&order).await
}
}Let’s unpack the two branches.
Non-Bitcoin “Pay with Crypto”: synchronous path
For EVM and Solana we keep the same philosophy as for deposits:
- The frontend already waits for the tx to be confirmed.
- The backend re-verifies the tx with
verify_crypto_transaction. - If everything matches, we mark the payment as accepted and release from the vault.
The helper looks roughly like this:
async fn handle_non_bitcoin_crypto_payment(
order: &LockedOrder,
asset: &BlockchainAsset,
address: &TransactionAddress,
payment_input: Option<DepositInput>,
) -> Result<()> {
let tx_opt = verify_crypto_transaction(
asset,
payment_input,
&address.address, // onramper is the payer here
order.lock_amount, // amount we expect for this fill on the payment chain
)
.await?;
let tx_hash = tx_opt.ok_or(OrderError::PaymentVerificationFailed)?;
// 1) payment_id = tx hash (like PayPal's capture id)
crate::memory::stable::orders::set_payment_id(order.base.id, tx_hash.clone())?;
crate::management::order::mark_order_as_paid(order.base.id)?;
// 2) FillRecord: this represents the *fiat* and *crypto* part of the fill,
// but note: `tx_id` here is kept for the release transaction, so we leave it as None.
let total = order.base.crypto.amount.max(1);
let locked = order.lock_amount;
let fee_part: u128 = (order.base.crypto.fee.saturating_mul(locked)) / total;
append_fill_if_new(
order.base.id,
FillRecord {
payer_user_id: order.onramper.user_id,
payer: order.onramper.address.clone(),
provider: order.onramper.provider.clone(),
fiat: order.price,
offramper_fee: order.offramper_fee,
crypto_amount: locked,
crypto_fee: fee_part,
payment_id: tx_hash.clone(), // payment reference = tx hash
tx_id: None, // reserved for the vault release tx
created_at: ic_cdk::api::time(),
},
)?;
// 3) Prevent replay: once we've accepted this tx as payment, we don't want
// it to be re-used in another order or another verification.
spent_transactions::mark_tx_hash_as_processed(tx_hash);
Ok(())
}Two important details here:
payment_idvstx_id:payment_idis the “I got paid” reference: PayPal capture id, Revolut id, or in this case, the payment chain tx hash.tx_idis reserved for the release transaction from our vault (EVM commit/release, Solana refund/complete, etc.). For payments we keep itNoneat this stage; it will be filled in later by the vault listeners when the release tx is confirmed.
- Replay protection:
spent_transactions::mark_tx_hash_as_processedis now used consistently for both deposit txs and payment txs, so a single hash can’t be used twice in the system.
Once this helper returns, process_transaction calls:
payment_management::handle_payment_completion(&order).await…and that’s where the actual vault release is triggered.
Bitcoin “Pay with Crypto”: listener-based path
Bitcoin is special: we don’t want the user to sit in the frontend waiting 30–60 minutes for enough confirmations (settlement is very unreliable in testnets), and we already have a solid listener pattern for deposits, cancellations, and completions.
The flow for BTC payments is:
process_transaction:
- makes sure we’re dealing with a BTC
PaymentProvider::Crypto, - runs
verify_crypto_transactionto:- validate the input shape and rune_id,
- ensure the tx id isn’t already marked as processed,
- spawns a Bitcoin listener with a new action variant,
- and does not call
handle_payment_completionyet.
- The Bitcoin listener polls Unisat (or your backend) until the tx is confirmed, doing extra rune checks if needed.
- Once confirmed, the listener:
- marks the order as paid,
- writes the fill,
- marks the tx as processed,
- and calls
handle_payment_completion(&order)to release the base asset.
New Bitcoin action: PaymentDeposit
I extended the Bitcoin action enum:
#[derive(Clone)]
pub enum BitcoinTransactionAction {
DepositFunds { /* existing */ },
TopUpFunds { /* existing */ },
CompleteOrder { /* existing */ },
CancelOrder { /* existing */ },
/// Pay-with-crypto: confirms the incoming BTC payment from the onramper
/// before triggering vault release on the order's base asset.
PaymentDeposit {
order_id: u64,
amount: u128,
dst_address: String,
},
}And plugged it into spawn_bitcoin_tx_listener:
match action.clone() {
BitcoinTransactionAction::DepositFunds { .. } => { /* unchanged */ }
BitcoinTransactionAction::TopUpFunds { .. } => { /* unchanged */ }
BitcoinTransactionAction::CompleteOrder { .. } => { /* unchanged */ }
BitcoinTransactionAction::CancelOrder { .. } => { /* unchanged */ }
BitcoinTransactionAction::PaymentDeposit {
order_id,
amount,
dst_address: pay_dst,
} => {
let runes = if rune_id.is_some() {
match fetch_utxos_for_order(&txid_clone, &pay_dst).await {
Ok(r) if !r.is_empty() => {
ic_cdk::println!(
"[spawn_bitcoin_tx_listener] Payment rune UTXOs = {:?}",
r
);
r
}
_ => {
ic_cdk::println!(
"[spawn_bitcoin_tx_listener] No rune UTXOs found for payment tx {}",
txid_clone
);
return;
}
}
} else {
Vec::new()
};
if !action.validate_rune_amount(runes.clone()) {
ic_cdk::println!(
"[spawn_bitcoin_tx_listener] Error: Rune amount mismatch for payment tx {}",
txid_clone
);
return;
}
if let Err(e) = payment_management::crypto::on_bitcoin_payment_confirmed(
order_id,
txid_clone.clone(),
amount,
)
.await
{
ic_cdk::println!(
"[spawn_bitcoin_tx_listener] Error handling confirmed bitcoin payment for order {}: {:?}",
order_id,
e
);
}
}
}on_bitcoin_payment_confirmed: mirroring the PayPal path
The helper on_bitcoin_payment_confirmed is essentially the “BTC version” of what we do in verify_paypal_payment, but called from the listener:
pub async fn on_bitcoin_payment_confirmed(
order_id: u64,
txid: String,
amount: u128,
) -> Result<()> {
ic_cdk::println!(
"[on_bitcoin_payment_confirmed] order_id = {}, txid = {}",
order_id,
txid
);
let order = order_management::verify_order_is_payable(order_id, None)?;
// Optional sanity: compare `amount` with `order.lock_amount`.
if order.lock_amount != amount {
ic_cdk::println!(
"[on_bitcoin_payment_confirmed] warning: lock_amount ({}) != payment amount ({})",
order.lock_amount,
amount
);
}
// 1) payment_id = txid, mark order as paid.
crate::memory::stable::orders::set_payment_id(order.base.id, txid.clone())?;
crate::management::order::mark_order_as_paid(order.base.id)?;
// 2) Fill record, same logic as PayPal but payment_id = txid and tx_id = None.
let total = order.base.crypto.amount.max(1);
let locked = order.lock_amount;
let fee_part: u128 = (order.base.crypto.fee.saturating_mul(locked)) / total;
append_fill_if_new(
order.base.id,
FillRecord {
payer_user_id: order.onramper.user_id,
payer: order.onramper.address.clone(),
provider: order.onramper.provider.clone(),
fiat: order.price,
offramper_fee: order.offramper_fee,
crypto_amount: locked,
crypto_fee: fee_part,
payment_id: txid.clone(),
tx_id: None, // release tx comes later
created_at: ic_cdk::api::time(),
},
)?;
// 3) Mark the BTC payment tx as processed → no replay.
spent_transactions::mark_tx_hash_as_processed(txid.clone());
// 4) Trigger vault release on the order base asset.
super::handle_payment_completion(&order).await
}pub async fn on_bitcoin_payment_confirmed(
order_id: u64,
txid: String,
amount: u128,
) -> Result<()> {
ic_cdk::println!(
"[on_bitcoin_payment_confirmed] order_id = {}, txid = {}",
order_id,
txid
);
let order = order_management::verify_order_is_payable(order_id, None)?;
// Optional sanity: compare `amount` with `order.lock_amount`.
if order.lock_amount != amount {
ic_cdk::println!(
"[on_bitcoin_payment_confirmed] warning: lock_amount ({}) != payment amount ({})",
order.lock_amount,
amount
);
}
// 1) payment_id = txid, mark order as paid.
crate::memory::stable::orders::set_payment_id(order.base.id, txid.clone())?;
crate::management::order::mark_order_as_paid(order.base.id)?;
// 2) Fill record, same logic as PayPal but payment_id = txid and tx_id = None.
let total = order.base.crypto.amount.max(1);
let locked = order.lock_amount;
let fee_part: u128 = (order.base.crypto.fee.saturating_mul(locked)) / total;
append_fill_if_new(
order.base.id,
FillRecord {
payer_user_id: order.onramper.user_id,
payer: order.onramper.address.clone(),
provider: order.onramper.provider.clone(),
fiat: order.price,
offramper_fee: order.offramper_fee,
crypto_amount: locked,
crypto_fee: fee_part,
payment_id: txid.clone(),
tx_id: None, // release tx comes later
created_at: ic_cdk::api::time(),
},
)?;
// 3) Mark the BTC payment tx as processed → no replay.
spent_transactions::mark_tx_hash_as_processed(txid.clone());
// 4) Trigger vault release on the order base asset.
super::handle_payment_completion(&order).await
}And with that, Bitcoin “Pay with crypto” is aligned with the rest of the system:
- Frontend:
- sends the tx,
- provides us the
txid+ payment asset/mint.
- Backend:
- runs a cheap, immediate sanity check via
verify_crypto_transaction, - spawns a listener that waits as long as necessary,
- only after confirmation do we:
- mark the order paid,
- write the fill,
- mark tx as processed,
- and release from the vault.
- runs a cheap, immediate sanity check via
From payment to release: handle_payment_completion as the bridge
The last piece is how all this plugs into vault release and order state transitions.
Once any provider (PayPal, Revolut, Stripe, crypto) has been verified and the order has been marked as “paid”, we call:
pub async fn handle_payment_completion(order: &LockedOrder) -> Result<()> {
let offramper = order.base.offramper_address.address.clone();
let onramper = order.onramper.address.address.clone();
let total = order.base.crypto.amount.max(1);
let locked = order.lock_amount;
let fee_part: u128 = (order.base.crypto.fee.saturating_mul(locked)) / total;
// Record a *pending* fill: this says "we accepted a payment", but the
// vault release is still in-flight on the base chain.
let pending = FillRecord {
payer_user_id: order.onramper.user_id,
payer: order.onramper.address.clone(),
provider: order.onramper.provider.clone(),
fiat: order.price,
offramper_fee: order.offramper_fee,
crypto_amount: locked,
crypto_fee: fee_part,
payment_id: order.payment_id.clone().unwrap_or_default(),
tx_id: None,
created_at: ic_cdk::api::time(),
};
let _ = crate::memory::stable::orders::set_pending_fill(order.base.id, pending);
match order.base.crypto.asset.clone() {
BlockchainAsset::EVM { chain_id, token_address } => {
// Calls Ic2P2ramp::release_funds, which broadcasts the
// vault release tx and uses its own listeners to wait for confirmation
// before marking the order as filled / completed.
}
BlockchainAsset::ICP { ledger_principal } => {
// For ICP we can do the transfer synchronously (no external listener needed),
// then finalize the pending fill and set the order as completed.
}
BlockchainAsset::Bitcoin { rune_id } => {
// BTC: we call bitcoin_backend_transfer from the vault to the onramper,
// then spawn another Bitcoin listener (CompleteOrder) to wait for release
// confirmation and finally mark the order as completed.
}
BlockchainAsset::Solana { spl_token } => {
// Solana: send SPL / SOL to the onramper, then use spawn_solana_tx_listener
// to wait for the release tx to finalize and only then flip the order state.
}
}
}So the chronology per fill is:
- Deposit side:
- offramper deposits into vault (frontend waits, backend verifies with
verify_crypto_transaction), - order is created / topped up and vault balance is increased.
- offramper deposits into vault (frontend waits, backend verifies with
- Payment side:
- onramper pays offramper via PayPal/Revolut/Stripe or crypto,
- backend verifies payment, sets
payment_id, marks order as paid, writes or updates a fill, handle_payment_completionkicks off a vault release on the order base chain.
- Release side:
- EVM/Solana/Bitcoin: a second layer of listeners waits for the vault release tx to confirm,
- only then do we finalize the pending fill and move the order state to Open (partial) or Completed.
This is the backbone that makes the “experimental trustless bridge” behavior actually safe: every state transition is tied to a concrete tx on either the payment chain or the base chain, and every tx can only be used once thanks to the spent_transactions guard.
Frontend changes
Frontend changes and the UI/UX side of the new crypto payment options will come in the next post.
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

icRamp Devlog #19 — Pay with Crypto (Frontend UX & Provider Flows)
Frontend wiring for the experimental 'pay with crypto' path: crypto providers in the user profile, filtered provider selection in Create Order, and a compact order card UX for onrampers choosing how to pay.

icRamp Devlog #20 — Pay with Crypto (Settlement & Verification)
We finish the pay-with-crypto flow: from Locked orders to on-chain payments, matching provider assets, and verifying EVM/Solana txs on the backend.

icRamp Devlog #17 — Liquid Orders: Partial Fills
We add partial fills: the onramper can lock only a fraction of the order, pay, and get a proportional crypto payout while the rest stays open. Single lock path, pro-rata fees, idempotent fill records, and listener-safe completion.