
icRamp Devlog #16 — Liquid Orders: Top‑ups + Provider Icons
Continuation of Devlog #15 — Stripe Order Payments. This time we made orders liquid: the offramper can top‑up crypto after order creation, and we polished the Payment Provider UI with icons shared across the app.
![]()
TL;DR — What changed
- Backend
- New
top_up_orderflow acquires a processing lock around mutation and always releases it. - Fee is recomputed on the new total (
previous + added), guarding against low‑funds. - Minor fix in
freeze_orderauthorization.
- New
- Frontend
- Added a Top‑up control on the Order Card with clean styling, max balance helper, and async refetch.
- Introduced a single
<ProviderIcon/>component and used it in Create Order, Order Card, User Profile, and Register User.
Data model (recap)
Orders stay in Created until locked by an onramper. Top‑up is allowed only in Created and only by the offramper owner. We track a processing boolean per order to serialize writes.
Backend — changes & snippets
1) top_up_order — scoped lock + finally‑unset
Only the parts that changed (full body replaced for clarity).
#[ic_cdk::update]
async fn top_up_order(
order_id: u64,
user_id: u64,
session_token: String,
amount: u128,
deposit_input: Option<DepositInput>,
) -> Result<()> {
// auth
let user = memory::stable::users::get_user(&user_id)?;
user.validate_session(&session_token)?;
// acquire lock; always release at the end
orders::set_processing_order(&order_id)?;
let res = async {
// operate on a fresh snapshot AFTER lock
let order = orders::get_order(&order_id)?.created()?;
if order.offramper_user_id != user_id {
return Err(UserError::Unauthorized.into());
}
let tx_hash = order_management::validate_deposit_tx(
&order.crypto.asset,
deposit_input.clone(),
order.offramper_address.clone().address,
amount,
)
.await?;
let (gas_lock, gas_withdraw) = match deposit_input {
Some(DepositInput::Evm(v)) => (Some(v.estimated_gas_lock), Some(v.estimated_gas_withdraw)),
_ => (None, None),
};
order_management::topup_order(&order, amount, gas_lock, gas_withdraw).await?;
if let Some(tx_hash) = tx_hash {
spent_transactions::mark_tx_hash_as_processed(tx_hash);
}
Ok(())
}
.await;
let _ = orders::unset_processing_order(&order_id);
res
}2) order_management::topup_order — fee on new total
pub async fn topup_order(
- order: &Order,
- amount: u128,
- estimated_gas_lock: Option<u64>,
- estimated_gas_withdraw: Option<u64>,
-) -> Result<()> {
- let crypto_fee = order_crypto_fee(
- order.crypto.asset.clone(),
- order.crypto.amount,
+ order: &Order,
+ amount: u128,
+ estimated_gas_lock: Option<u64>,
+ estimated_gas_withdraw: Option<u64>,
+) -> Result<()> {
+ let new_total = order.crypto.amount + amount;
+
+ let crypto_fee = order_crypto_fee(
+ order.crypto.asset.clone(),
+ new_total,
estimated_gas_lock,
estimated_gas_withdraw,
)
.await?;
- if 2 * crypto_fee >= order.crypto.amount + amount {
+ if 2 * crypto_fee >= new_total {
return Err(BlockchainError::FundsTooLow)?;
};
memory::stable::orders::mutate_order(&order.id, |order| {
let order = order.created_mut()?;
- order.crypto.amount += amount;
- order.crypto.fee = crypto_fee;
+ order.crypto.amount = new_total;
+ order.crypto.fee = crypto_fee;
Ok(())
})?
}3) freeze_order — auth fix
- if !order.offramper_user_id == user_id {
+ if order.offramper_user_id != user_id {
return Err(UserError::Unauthorized.into());
}Why the lock? We ensure only one writer mutates an order at a time (no stale reads), and we always reset
processingeven on errors. This preventscreated_mut()from failing withOrderNotProcessingdue to accidental early unsets.
Frontend — changes & snippets
1) Shared <ProviderIcon/>
New file:
// components/ui/ProviderIcon.tsx
import { PaymentProviderTypes } from '@/model/types';
import payPalLogo from '@/assets/logos/paypal-logo.png';
import stripeLogo from '@/assets/logos/stripe-logo.png';
import revolutLogo from '@/assets/logos/revolut-logo.webp';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMailReply } from '@fortawesome/free-solid-svg-icons';
export const ProviderIcon = ({ type, className = "h-5 rounded-md w-auto" }: { type: PaymentProviderTypes; className?: string }) => {
if (type === "PayPal") return <img src={payPalLogo} alt="PayPal" className={className} />;
if (type === "Stripe") return <img src={stripeLogo} alt="Stripe" className={className} />;
if (type === "Revolut") return <img src={revolutLogo} alt="Revolut" className={className} />;
if (type === "Email") return <FontAwesomeIcon icon={faMailReply} size="lg" />;
return null;
};Used in CreateOrder, OrderCard, UserProfile, RegisterUser next to the provider name (no layout churn, minimal gap).
2) useOrderLogic — top‑up state + handler
Only the added bits:
+ const [topUpAmount, setTopUpAmount] = useState<number>(0);
+ const [topUpLoading, setTopUpLoading] = useState(false);
+ const [topUpTxHash, setTopUpTxHash] = useState<string | null>(null);
- export const useOrderLogic = (order: OrderState, refetchOrders: () => void) => {
+ export const useOrderLogic = (order: OrderState, refetchOrders: () => Promise<void>) => {
+ const topUp = async () => {
+ if (!baseOrder || !token || !user || !sessionToken) return;
+ if (topUpAmount <= 0) return;
+ setTopUpLoading(true);
+ try {
+ const amountUnits = toUnits(topUpAmount, token.decimals);
+ let depositInput: [] | [DepositInput] = [];
+ // deposit per chain (EVM/ICP/BTC/SOL)… returns depositInput + txHash when applicable
+ // …
+ const r = await backend.top_up_order(BigInt(baseOrder.id), BigInt(user.id), sessionToken, amountUnits, depositInput);
+ if ('Err' in r) throw new Error(rampErrorToString(r.Err));
+ setTopUpAmount(0);
+ await refetchOrders();
+ } finally {
+ setTopUpLoading(false);
+ }
+ };3) Order Card — Top‑up UI (with max)
Minimal diffs only:
- <input className="w-full h-11 rounded-2xl bg-white/5 borderpl-4 pr-28 …" … />
+ <input className="w-full h-11 rounded-2xl bg-white/5 border pl-4 pr-28 …" … />
- <span className="absolute right-2 top-1/2 …">max: …</span>
+ <span
+ onClick={() => { const b = getAvailableBalance(); if (b) setTopUpAmount(b.raw); }}
+ className="absolute right-28 top-1/2 -translate-y-1/2 text-gray-400 text-xs cursor-pointer select-none z-10">
+ max: {getAvailableBalance() ? getAvailableBalance()!.formatted : "0.00"} {token?.name}
+ </span>4) Payment Methods — icons
Use <ProviderIcon/> next to labels:
- <label htmlFor={id} className="ml-1 text-lg inline-flex items-center">
- <span>{providerType}</span>
- </label>
+ <label htmlFor={id} className="ml-1 text-lg inline-flex items-center gap-2">
+ <ProviderIcon type={providerType} />
+ <span>{providerType}</span>
+ </label>And in read‑only lines:
- <div className="text-lg my-2 inline-flex items-center">
+ <div className="text-lg my-2 inline-flex items-center gap-2">
<ProviderIcon type={providerType} />
<span>{providerType}</span>
</div>E2E notes
- Top‑up is restricted to Created orders and to the offramper owner.
- The backend re‑checks the deposit transaction hash (
validate_deposit_tx) before mutating state. - We mark seen tx hashes (
spent_transactions::mark_tx_hash_as_processed) to avoid double‑counting retries. - Fees follow the new total to avoid underfunded orders after multiple top‑ups.
Deployment
# Build + deploy backend
dfx deploy icramp_backend --argument "( variant { Upgrade = null } )" --upgrade-unchanged
# Frontend: rebuild
cd frontend && npm run build && cd .. && dfx deploy frontend --mode reinstall --yesGotchas & next
- Candid
Optionfrom TS is[] | [value]— make sure per‑chain deposit helpers return that shape. - We’ll extend top‑up to Locked orders when/if we support partial fills during the locked window (requires vault logic).
Next
- “Liquid” flow for partial fills and top‑ups by others (order book style).
- Stripe‑side notifications for partial payouts once vault logic is unified.
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

icRamp Devlog #15 — Stripe Order Payments (Email↔️Connect, per‑order redirects)
End-to-end Stripe payments for orders: Onramper pays by email, Offramper receives via Connect destination charges. Per‑order success/cancel, email verification, and a resilient FE redirect flow.

icRamp Devlog #14 — Stripe Frontend (Register & Checkout UX)
Frontend wiring for Stripe Connect: a register flow that survives redirect/refresh, provider cards UI, and Create Order with destination charges. Includes the Onramper≠Offramper split and backend-validated providers.

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.