
icRamp Devlog #17 — Liquid Orders: Partial Fills
Continuation of Devlog #16 — Top-Up Orders. This time: partial fills from UI to canister, refactors to keep one
lock_order, fee pro-rating, and robust completion via listeners.

Why partial fills?
- Offramper lists a crypto amount; onramper may want less.
- Lock only what you’ll actually pay, release only that fraction, and leave the remainder as a new open balance.
Backend
1) Types: fill records + extended completion
Only deltas shown; unchanged code omitted.
```rust
// path/backend/src/types.rs
// Purpose: Add FillRecord to Order; extend CompletedOrder
#[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: HashMap<PaymentProviderType, PaymentProvider>,
pub crypto: Crypto,
pub fills: Vec<FillRecord>, // NEW: accumulates partial fills
pub processing: bool,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
pub struct FillRecord {
pub payer_user_id: u64,
pub payer: TransactionAddress,
pub provider: PaymentProvider,
pub fiat: u64, // price for this lock
pub offramper_fee: u64, // fee for this lock (minor units)
pub crypto_amount: u128, // locked crypto (gross)
pub crypto_fee: u128, // pro-rata crypto fee for this fill
pub payment_id: String, // PayPal capture / Stripe session id
pub tx_id: Option<String>, // on-chain tx hash/signature (optional; finalized by listeners)
pub created_at: u64,
}
#[derive(CandidType, Deserialize, Clone)]
pub struct CompletedOrder {
pub offramper: TransactionAddress,
pub price: u64, // last lock price (for reference)
pub asset: BlockchainAsset,
pub fills: Vec<FillRecord>, // full fill history
pub total_fiat: u64,
pub total_offramper_fee: u64,
pub total_crypto: u128,
pub total_crypto_fee: u128,
pub completed_at: u64,
}
impl From<LockedOrder> for CompletedOrder {
fn from(locked: LockedOrder) -> Self {
let base = locked.base;
let total_offramper_fee = base.fills.iter().map(|f| f.offramper_fee).sum();
let total_fiat : u64 = base.fills.iter().map(|f| f.fiat).sum();
let total_crypto : u128 = base.fills.iter().map(|f| f.crypto_amount).sum();
let total_crypto_fee : u128 = base.fills.iter().map(|f| f.crypto_fee).sum();
CompletedOrder {
offramper: base.offramper_address,
price : locked.price,
asset : base.crypto.asset,
fills : base.fills,
total_fiat,
total_offramper_fee,
total_crypto,
total_crypto_fee,
completed_at: ic_cdk::api::time(),
}
}
}Also extend the lock payload to carry how much we lock:
// path/backend/src/order/lock.rs
// Purpose: LockInput carries partial amount; single lock path
pub struct LockInput {
pub price: u64,
pub lock_amount: u128, // NEW
pub offramper_fee: u64,
pub onramper_user_id: u64,
pub onramper_provider: PaymentProvider,
pub onramper_address: TransactionAddress,
pub revolut_consent: Option<RevolutConsent>,
pub stripe_session: Option<(String, String)>,
}2) Price function unified (no duplicates)
// path/backend/src/order/pricing.rs
// Purpose: one function for both create + partial lock
#[ic_cdk::update]
async fn calculate_order_price(currency: String, asset: BlockchainAsset, amount: u128)
-> Result<(u64, u64)> {
order_management::calculate_price_and_fee(¤cy, &asset, amount).await
}3) Single lock_order with partial amount & minimum remainder
- Validates `lock_amount > 0 && lock_amount ≤ available++
- Blocks tiny remainders (
MIN_REMAINING_MINOR) - Creates Stripe session for
Emailpseudo-provider - For EVM sends Commit; for ICP/Bitcoin/Solana locks in-place and defers settlement to listeners
// path/backend/src/order/lock.rs
// Purpose: single path; partial; fee sanity
pub async fn lock_order(..., lock_amount: u128, ...) -> Result<()> {
let order = memory::stable::orders::get_order(&order_id)?.created()?;
if lock_amount == 0 || lock_amount > order.crypto.amount {
return Err(OrderError::InvalidInput("invalid partial amount".into()).into());
}
let remaining = order.crypto.amount - lock_amount;
if remaining > 0 {
let (rem_minor, _) = calculate_price_and_fee(&order.currency, &order.crypto.asset, remaining).await?;
if rem_minor < MIN_REMAINING_MINOR {
return Err(OrderError::InvalidInput("remaining below minimum; lock full amount".into()).into());
}
}
// ... provider checks, price calc, stripe consent/session ...
match order.crypto.asset {
BlockchainAsset::EVM { chain_id, token_address } => {
let est = Ic2P2ramp::get_average_gas_price(chain_id, &TransactionAction::Commit).await?;
Ic2P2ramp::commit_deposit(
chain_id, order_id, order.offramper_address.address, token_address,
lock_amount, Some(est),
LockInput { lock_amount, price, offramper_fee, onramper_user_id,
onramper_provider, onramper_address, revolut_consent, stripe_session }
).await?;
Ok(())
}
// ICP/Bitcoin/Solana paths call memory lock + backend lock_funds...
_ => { /* unchanged differences omitted */ Ok(()) }
}
}4) Payment verification → unified settlement
process_transactionverifies PayPal/Revolut/Stripe and then callshandle_payment_completion.handle_payment_completioncomputes pro-rata crypto fee and triggers a chain-specific settlement.- Listeners (
EVM release,BTC/SOL complete) finalize: attachtx_id, then callset_order_completed.
// path/backend/src/payment/settlement.rs
// Purpose: pro-rata fee, one path, defer tx id to listeners
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;
let fill_record = 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, // filled by listeners if available
created_at: ic_cdk::api::time(),
};
// Persist pending fill (idempotent append)
let _ = crate::memory::stable::orders::append_fill_if_new(order.base.id, fill_record);
match order.base.crypto.asset.clone() {
BlockchainAsset::EVM { chain_id, token_address } => {
let mut partial = order.base.crypto.clone();
partial.amount = locked;
partial.fee = fee_part;
Ic2P2ramp::release_funds(order.base.id, offramper, onramper, partial, chain_id, token_address).await
}
BlockchainAsset::ICP { ledger_principal } => {
handle_icp_payment_completion(order, &ledger_principal, fee_part).await
}
BlockchainAsset::Bitcoin { rune_id } => {
// transfer net to onramper and confirm via listener
// ... code omitted in post for brevity, same structure as EVM ...
Ok(())
}
BlockchainAsset::Solana { spl_token } => {
// send SOL/SPL net to onramper and confirm via listener
// ... code omitted in post for brevity ...
Ok(())
}
}
}Listeners attach tx_id before completion:
// path/backend/src/evm/vault.rs
// Purpose: finalize pending fill with tx hash, then complete
pub fn spawn_release_listener(..., tx_hash: &str, ...) {
transaction::spawn_transaction_checker(..., move |receipt| {
let _ = finalize_pending_fill(order_id, Some(receipt.transactionHash.to_string()));
match set_order_completed(order_id) { /* log */ }
}, on_fail_callback(order_id));
}Same idea for Bitcoin/Solana listeners (txid / signature).
5) Completion that unsets processing and re-opens remainder
We moved unset_processing inside completion and made it remainder-aware.
// path/backend/src/order/storage.rs
// Purpose: do state transition + timer clear atomically
pub fn set_order_completed(order_id: u64) -> Result<()> {
mutate_order(&order_id, |st| match st {
OrderState::Locked(order) => {
let total = order.base.crypto.amount;
let filled = order.lock_amount;
if filled > total { return Err(OrderError::InvalidInput("locked > available".into())); }
order.base.unset_processing(); // centralize here
if filled == total {
*st = OrderState::Completed(order.clone().complete());
} else {
let remaining = total - filled;
let mut base = order.base.clone();
base.crypto.amount = remaining;
*st = OrderState::Created(base); // back to open with leftover
}
Ok(())
}
_ => Err(OrderError::InvalidOrderState(st.to_string()))?,
})??;
clear_order_timer(order_id)
}Frontend
1) Safe decimal → bigint units (no float BigInt crash)
// path/frontend/src/hooks/useOrderLogic.ts
// Purpose: avoid "Cannot convert 0.006 to a BigInt"
const unitsFromDecimalInput = (input: string, decimals: number): bigint => {
const s = (input ?? '').trim();
if (!/^\d*\.?\d*$/.test(s) || s === '' || s === '.') throw new Error('Invalid amount format');
const [i, fRaw = ''] = s.split('.');
const f = (fRaw + '0'.repeat(decimals)).slice(0, decimals);
const whole = i === '' ? '0' : i;
const combined = (whole + f).replace(/^0+/, '') || '0';
return BigInt(combined);
};Use it in commitToOrder and always clear loading on error:
// path/frontend/src/hooks/useOrderLogic.ts
// Purpose: parse, validate, price, lock; surface errors; no stuck spinner
const lockUnits = unitsFromDecimalInput(lockAmount, token?.decimals ?? 8);
// ... call calculate price with lockUnits; backend.lock_order(..., lockUnits, ...) ...
// catch => setMessage(err.message); setIsLoading(false)2) Onramper UI: amount input + Max + 25/50/75/100
// path/frontend/src/components/OrderCard.tsx
// Purpose: Lock amount UX + quick % buttons
<div className="relative mb-2">
<input ... value={lockAmount} onChange={(e)=>setLockAmount(e.target.value)} ... />
<button onClick={() => setLockAmount(maxLockHuman.toString())} className="absolute right-1 ...">Max</button>
</div>
<div className="flex gap-2 mb-2">
{[0.25,0.5,0.75,1].map(p=>(
<button key={p} onClick={()=>setLockAmount((maxLockHuman*p).toFixed(Math.min(6, dec)))}
className="px-2 py-1 rounded-md text-xs bg-white/10 hover:bg-white/20">{Math.round(p*100)}%</button>
))}
</div>
{message && <div className="text-xs text-red-400 mb-2">{message}</div>}3) Locked panel: show exactly what is locked
// path/frontend/src/components/OrderCard.tsx
// Purpose: display locked crypto (human)
<div className="text-lg flex justify-between mt-2">
<span className="opacity-90">Locked Amount:</span>
<span className="font-medium">
{(() => {
const dec = token?.decimals ?? 8;
const v = Number(orderState.Locked.lock_amount) / Math.pow(10, dec);
return `${v.toFixed(Math.min(6, dec))} ${token?.name ?? ''}`;
})()}
</span>
</div>4) Completed panel: richer view (fills roll-up)
Our new CompletedOrder includes fills and totals — render a quick summary (list UI not shown here to keep this post focused).
Post-merge fixes: Solana vault, top-ups & partial-fill UX
1) Symptom: Solana InsufficientBalance after lock
While locking a partial amount on a Solana order we hit:
BlockchainError - SolanaBackendError - {"VaultError":{"InsufficientBalance":null}}
What happened
- Order state (before lock):
Createdwithcrypto.amount = 188_000_000(0.188 SOL). - Solana backend vault for the offramper only had 0.008 SOL:
$ dfx canister call solana_backend get_offramper_deposits '("4HBf9tVigC3MSWjUfiMionmqPXJsvDagG7Mhiz2mtcHB")' (variant { Ok = record { lamports = 8_000_000 : nat64; tokens = vec {} } }) - The lock path on Solana calls
vault::lock::lock_funds, which first rolls back (subtracts) from the OFFRAMPER vault (cancel_deposit) and then moves it to the ONRAMPER staging vault. Since our top-up flow did not deposit to the Solana vault, subtraction underflowed.
Root cause
top_up_order mirrored fee/amount math but skipped the intercall vault deposit:
create_order(Solana): validates tx →solana_backend_deposit_funds→ create order.top_up_order(Solana): validated tx → did not notify solana backend → only bumped order totals.
Fix: on top_up_order, after validate_deposit_tx, call the same blockchain deposit used by create_order:
// path/backend/src/order/topup.rs
// Purpose: Ensure Solana top-ups credit the vault before changing order totals
#[ic_cdk::update]
async fn top_up_order(/* ... */) -> Result<()> {
// ... auth + get order + set_processing + validate_deposit_tx ...
match order.crypto.asset.clone() {
BlockchainAsset::Solana { spl_token } => {
// credit offramper vault FIRST
solana_backend_deposit_funds(
order.offramper_address.address.clone(),
amount as u64,
spl_token,
).await?;
if let Some(sig) = tx_hash { spent_transactions::mark_tx_hash_as_processed(sig); }
// then bump amount + recompute fee
order_management::topup_order(&order, amount, None, None).await?;
return orders::unset_processing_order(&order_id);
}
// Bitcoin/EVM/ICP branches unchanged here (see BTC listener below)
_ => { /* ... */ }
}
}Bitcoin needs L1 confirm before deposit: add TopUpFunds listener
// path/backend/src/bitcoin/management.rs
// Purpose: Confirm BTC top-up on-chain, then deposit and bump order
#[derive(Clone, Debug)]
pub enum BitcoinTransactionAction {
/* existing variants ... */
TopUpFunds { order_id: u64, offramper_address: String, amount: u128 }, // NEW
}
pub fn spawn_bitcoin_tx_listener(/* ... */) {
/* ... on confirm ... */
match action.clone() {
/* existing arms ... */
BitcoinTransactionAction::TopUpFunds { order_id, offramper_address, amount } => {
// 1) deposit to BTC backend vault after confirm
match bitcoin_backend_deposit_funds(offramper_address.clone(), amount as u64, rune_id.clone()).await {
Ok(()) => {
// 2) bump order totals (fee recompute) in Created state
if let Ok(ord) = memory::stable::orders::get_order(&order_id) {
let _ = order_management::topup_order(&ord.created().unwrap(), amount, None, None).await;
spent_transactions::mark_tx_hash_as_processed(txid.clone());
}
}
Err(e) => ic_cdk::println!("[btc_topup] deposit error: {:?}", e),
}
}
}
}EVM/ICP top-ups don’t modify a backend vault; they only adjust the order totals + fees (unchanged).
Manual Recovery (no redeploy): “hack” the vault to match the order:
To proceed with the demo without a redeploy, we manually credited the offramper’s Solana vault to the intended order amount.
- Verify current vault vs. order:
$ dfx canister call solana_backend get_offramper_deposits '("4HBf9tVigC3MSWjUfiMionmqPXJsvDagG7Mhiz2mtcHB")'
(variant { Ok = record { lamports = 8_000_000 : nat64; tokens = vec {} } })
$ dfx canister call icramp_backend get_order '(1)'
# shows Created with amount = 188_000_000 lamports (0.188 SOL)- Timer flipped
Locked → Created(lock expired), so we can safely re-align vault:
$ dfx canister call solana_backend deposit_to_vault_canister '("4HBf9tVigC3MSWjUfiMionmqPXJsvDagG7Mhiz2mtcHB", 180_000_000, null)'
(variant { Ok })- Confirm vault now matches order:
$ dfx canister call solana_backend get_offramper_deposits '("4HBf9tVigC3MSWjUfiMionmqPXJsvDagG7Mhiz2mtcHB")'
(variant { Ok = record { lamports = 188_000_000 : nat64; tokens = vec {} } })With vault parity restored and the code patched, partial locks work:
lock_fundscan subtract from OFFRAMPER and credit ONRAMPER staging without underflow.

2) Backend: idempotent finalize_pending_fill
We previously appended a duplicate fill when the listener arrived. Now we update an existing non-finalized fill (or consume pending_fill) and avoid duplicates.
// path/backend/src/memory/stable/orders.rs
// Purpose: update-in-place if a non-finalized fill matches; else consume pending_fill; else no-op
- if let Some(mut fill) = lo.pending_fill.take() {
- if let Some(tid) = tx_id { fill.tx_id = Some(tid); }
- lo.base.fills.push(fill);
- *s = OrderState::Locked(lo);
- return Ok(());
- }
+ // (a) try update an existing fill with tx_id == None (payment_id or amount match)
+ if let Some(ref tid) = tx_id {
+ if let Some(i) = lo.base.fills.iter().rposition(|f|
+ f.tx_id.is_none() &&
+ ( (!f.payment_id.is_empty() && lo.pending_fill.as_ref().map(|pf| &pf.payment_id) == Some(&f.payment_id))
+ || lo.pending_fill.as_ref().map(|pf| pf.crypto_amount) == Some(f.crypto_amount) )
+ ) {
+ if lo.base.fills[i].tx_id.is_none() { lo.base.fills[i].tx_id = Some(tid.clone()); }
+ *s = OrderState::Locked(lo); return Ok(());
+ }
+ }
+ // (b) otherwise, consume pending_fill (attach tx_id) and avoid dup by key
+ if let Some(mut fill) = lo.pending_fill.take() {
+ if let Some(tid) = tx_id { fill.tx_id = Some(tid); }
+ if let Some(i) = lo.base.fills.iter().rposition(|f|
+ f.payer_user_id == fill.payer_user_id &&
+ f.crypto_amount == fill.crypto_amount &&
+ (f.payment_id == fill.payment_id || f.payment_id.is_empty() || fill.payment_id.is_empty())
+ ) {
+ if lo.base.fills[i].tx_id.is_none() { lo.base.fills[i].tx_id = fill.tx_id.clone(); }
+ } else {
+ lo.base.fills.push(fill);
+ }
+ *s = OrderState::Locked(lo); return Ok(());
+ }3) Frontend: Stripe return & EVM polling route by fresh order
After paying, orders might be only partially filled. We removed the old “always Completed” redirect and now route by the refetched order state.
// path/frontend/src/hooks/useOrderLogic.ts
// Purpose: in pollTransactionLog => on Confirmed, refetch & route by actual state
- setTimeout(() => { fetchOrder(orderId); refetchUser(); fetchBalances(); setIsLoading(false);
- navigate('Release' in transactionLog.action ? '/view?status=Completed' : ...); }, 3500);
+ setTimeout(async () => {
+ const fresh = await fetchOrder(orderId);
+ refetchUser(); fetchBalances(); setIsLoading(false);
+ if (fresh) {
+ if ('Completed' in fresh) navigate('/view?status=Completed');
+ else if ('Created' in fresh) navigate('/view'); // partial remainder stays open
+ }
+ }, 3500);
// avoid stale UI price after partial changes
+ localStorage.removeItem(`order_${orderId}_price`);4) UI: Created shows Filled % (N) + modal; Completed paginates fills
We keep the Created card clean—only a “Filled: XX% (N)” line; clicking opens a modal with fill paging. Completed cards now show one fill at a time with Prev/Next.
// path/frontend/src/components/order/OrderCard.tsx
// Purpose: Created card 'Filled: % (N)' + modal (paging)
+ <div className="text-lg flex justify-between items-center">
+ <span className="opacity-90">Filled:</span>
+ <button onClick={() => fills.length && setFillsOpen(true)} ...>{pct}% (<small>{fills.length} fill(s)</small>)</button>
+ </div>
+ {fillsOpen && <FillsModal fills={orderState.Created.fills} ... />}
// Purpose: Completed card shows single fill with Prev/Next
- {orderState.Completed.fills.map(...)} // removed big list
+ {/* single fill renderer + pager (Prev/Next) */}Result: locks settle cleanly across chains, duplicate fills are gone, and post-payment UX reflects partials without bouncing users to the wrong list.

Ops & Safety
- One
lock_orderpath (no forked flows). - Pro-rata crypto fee:
fee_part = total_fee * locked / total. - Minimum remainder avoids $5 leftovers.
- Idempotency:
append_fill_if_new(by key:payment_id+payer_user_id+crypto_amount). - Listeners finalize the
tx_idand callset_order_completed, which:- unsets
processing, - transitions to
Completedor re-opens with leftover, - clears the timer.
- unsets
What’s next
We are ready for the last steps to complete this milestone:
- Pay with crypto: effectively making icRamp opt into a p2p bridge.
- Simplify EVM vaults!
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

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 #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 #18 — Pay with Crypto (Experimental Trustless P2P Bridge)
We add an experimental 'pay with crypto' path that lets onrampers settle in stables on a different chain than the escrowed asset. Includes provider model refactor and order validation. Frontend exposure starts with stables for speed.