icRamp Devlog #17 — Liquid Orders: Partial Fills

icRamp Devlog #17 — Liquid Orders: Partial Fills

10/31/202512 min • icramp
ICPEscrowStripeRevolutPayPalPartial FillsEVMSolanaBitcoin

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.

Onramper Lock UI with partial amount + quick % buttons

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(&currency, &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 Email pseudo-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_transaction verifies PayPal/Revolut/Stripe and then calls handle_payment_completion.
  • handle_payment_completion computes pro-rata crypto fee and triggers a chain-specific settlement.
  • Listeners (EVM release, BTC/SOL complete) finalize: attach tx_id, then call set_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): Created with crypto.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.

  1. 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)
  1. 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 })
  1. 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_funds can subtract from OFFRAMPER and credit ONRAMPER staging without underflow.

Onramper Commited Order with partial amount

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.

Onramper Commited Order with partial amount

Ops & Safety

  • One lock_order path (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_id and call set_order_completed, which:
    • unsets processing,
    • transitions to Completed or re-opens with leftover,
    • clears the timer.

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.