icRamp Devlog #19 — Pay with Crypto (Frontend UX & Provider Flows)

icRamp Devlog #19 — Pay with Crypto (Frontend UX & Provider Flows)

11/8/202518 min • icramp
ICPEVMSolanaBitcoinBridgesUSDCEURCDevlogFrontendReact

This post is the frontend continuation of Devlog #18 — Pay with Crypto (Experimental Trustless P2P Bridge).

In Devlog #18, we reshaped the backend model: PaymentProvider became a clean sum type, we added a Crypto variant and wired it into User and Order so the offramper can advertise “pay me in USDC/EURC on other chains”. We also wired the validation path for crypto transactions on the backend.

Now it’s time to surface all of that in the UI:

  • "a user profile that lets an offramper define crypto receivers in EVM / Solana / ICP,"

  • "a Create Order form that only offers relevant cross-chain providers (bridge semantics),"

  • "an order list where the onramper picks how to pay via tiny “method chips” (PayPal, Revolut, Crypto on Base, Crypto on Solana…)."

The goal of this post is to document the frontend wiring and the UX decisions, so future me (and reviewers of the grant) can trace exactly how this “experimental bridge” works end to end.

1. Recap: backend model from Devlog #18

On the backend side we ended with:

  • PaymentProvider as an enum with variants: PayPal, Revolut, Stripe, Email, Crypto.

  • Crypto is one asset + one receiving address:

pub enum PaymentProvider {
    PayPal { id: String },
    Revolut { scheme: String, id: String, name: Option<String> },
    Stripe { account_id: String, platform: String },
    Email  { email: String },
    Crypto { asset: BlockchainAsset, address: TransactionAddress },
}
 
pub enum BlockchainAsset {
    EVM    { chain_id: u64, token_address: Option<String> },
    Solana { spl_token: Option<String> },
    ICP    { ledger_principal: Principal },
    Bitcoin{ rune_id: Option<String> },
}

And each User and Order now owns a vector of providers:

 pub struct User {
-    pub payment_providers: HashSet<PaymentProvider>,
+    pub payment_providers: Vec<PaymentProvider>,
 }
 
 pub struct CreatedOrder {
     // ...
-    pub offramper_providers: HashSet<PaymentProvider>,
+    pub offramper_providers: Vec<PaymentProvider>,
 }

The offramper chooses which providers to attach to a specific order, and the onramper will later pick one of them to actually pay.

In this post we’ll see how those types appear from the frontend

2. Crypto providers in the User Profile

The first step was giving offrampers a place to configure their “pay me here” crypto endpoints.

2.1. Shared helpers: addresses, assets, and equality

I extracted a small set of helpers that are used both in profile and create-order flows:

export const EVM_CHAIN_IDS = Object.values(NetworkIds).map((n) => n.id);
 
export const hasEvm = (addrs: TransactionAddress[]) =>
  addrs.some((a) => 'EVM' in a.address_type);
export const hasSol = (addrs: TransactionAddress[]) =>
  addrs.some((a) => 'Solana' in a.address_type);
export const hasIcp = (addrs: TransactionAddress[]) =>
  addrs.some((a) => 'ICP' in a.address_type);
 
export const deepEqual = (a: any, b: any): boolean => {
  if (a === b) return true;
  if (typeof a === 'bigint' && typeof b === 'bigint') return a === b;
  if (
    typeof a !== 'object' ||
    typeof b !== 'object' ||
    a === null ||
    b === null
  ) return false;
 
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
      if (!deepEqual(a[i], b[i])) return false;
    }
    return true;
  }
 
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
 
  for (const key of keysA) {
    if (!keysB.includes(key) || !deepEqual(a[key], b[key])) return false;
  }
  return true;
};

A couple of key utilities:

  • getRecvAddr(addrs, kind) returns the address the user registered for EVM, Solana, or ICP:
export const getRecvAddr = (
  addrs: TransactionAddress[],
  kind: 'EVM' | 'Solana' | 'ICP',
): TransactionAddress | null => {
  if (kind === 'EVM') {
    return addrs.find((a) => 'EVM' in a.address_type) ?? null;
  }
  if (kind === 'Solana') {
    return addrs.find((a) => 'Solana' in a.address_type) ?? null;
  }
  return addrs.find((a) => 'ICP' in a.address_type) ?? null;
};
  • sameCryptoAsset(a, b) compares two BlockchainAssets (EVM / Sol / ICP / Bitcoin) and is reused everywhere we need to know if two providers actually point to the same token:
export const sameCryptoAsset = (
  a: BlockchainAsset,
  b: BlockchainAsset,
): boolean => {
  if (!a || !b) return false;
 
  if ('EVM' in a && 'EVM' in b) {
    if (a.EVM.chain_id !== b.EVM.chain_id) return false;
    const addrA = a.EVM.token_address?.[0] ?? '';
    const addrB = b.EVM.token_address?.[0] ?? '';
    return addrA.toLowerCase() === addrB.toLowerCase();
  }
 
  if ('Solana' in a && 'Solana' in b) {
    const mintA = a.Solana.spl_token?.[0] ?? '';
    const mintB = b.Solana.spl_token?.[0] ?? '';
    return mintA === mintB;
  }
 
  if ('ICP' in a && 'ICP' in b) {
    try {
      const pA = a.ICP.ledger_principal;
      const pB = b.ICP.ledger_principal;
      const tA = typeof pA.toText === 'function' ? pA.toText() : String(pA);
      const tB = typeof pB.toText === 'function' ? pB.toText() : String(pB);
      return tA === tB;
    } catch {
      return false;
    }
  }
 
  if ('Bitcoin' in a && 'Bitcoin' in b) {
    const rA = a.Bitcoin.rune_id?.[0] ?? '';
    const rB = b.Bitcoin.rune_id?.[0] ?? '';
    return rA === rB;
  }
 
  return false;
};

This function will later be used to align:

  • the offramper’s crypto provider in the order, with
  • the onramper’s own configured crypto provider on the same chain / token.

2.2. Generating providers for EVM, Solana, ICP

Instead of making the user fill 10 different forms, I added bulk builders that create the right Crypto providers for a given currency and receiver address.

For EVM:

export const buildEvmProviders = async (
  recv: TransactionAddress,
  symbol: 'USDC' | 'EURC',
): Promise<PaymentProvider[]> => {
  const out: PaymentProvider[] = [];
  for (const cid of EVM_CHAIN_IDS) {
    try {
      const tokens = getEvmTokens(cid);
      const t = tokens.find(
        (tt) => tt.name.toUpperCase() === symbol && !tt.isNative,
      );
      if (!t) continue;
      out.push({
        Crypto: {
          asset: { EVM: { chain_id: BigInt(cid), token_address: [t.address] } },
          address: recv,
        },
      });
    } catch {
      // unknown chain mapping → skip
    }
  }
  return out;
};

For Solana:

export const buildSolProvider = async (
  recv: TransactionAddress,
  symbol: 'USDC' | 'EURC',
): Promise<PaymentProvider | null> => {
  const registry = await fetchSolanaTokenOptions();
  const t = registry.find((tt) => tt.name.toUpperCase() === symbol);
  if (!t) return null;
  return {
    Crypto: {
      asset: { Solana: { spl_token: [t.address] } },
      address: recv,
    },
  };
};

For ICP (currently ckUSDT only, used as “ckUSD” in the UI):

export const buildIcpProvider = async (
  recv: TransactionAddress,
): Promise<PaymentProvider | null> => {
  const ckUsd = ICP_TOKENS.find((t) => t.name.toUpperCase() === 'CKUSDT');
  if (!ckUsd) return null;
  return {
    Crypto: {
      asset: { ICP: { ledger_principal: Principal.from(ckUsd.address) } },
      address: recv,
    },
  };
};

This is the core: with one click we can generate all the relevant stables on all supported chains (for the currencies we care about).

2.3. “Add crypto” options driven by currency + registered addresses

In the profile we compute available crypto options based on the user’s registered receiver addresses and the selected fiat currency.

const cryptoAddOptions = useMemo(() => {
  if (!currency || (currency !== 'USD' && currency !== 'EUR')) return [];
 
  const symbol = currency === 'USD' ? 'USDC' : 'EURC';
  const evmAddr = getRecvAddr(user.addresses, 'EVM');
  const solAddr = getRecvAddr(user.addresses, 'Solana');
  const icpAddr = getRecvAddr(user.addresses, 'ICP');
 
  const opts: {
    key: string;
    title: string;
    enabled: boolean;
    build: () => Promise<PaymentProvider[]>;
  }[] = [];
 
  if (evmAddr) {
    opts.push({
      key: 'evm',
      title: `Add EVM ${symbol}`,
      enabled: true,
      build: async () => await buildEvmProviders(evmAddr, symbol),
    });
  }
 
  if (solAddr) {
    opts.push({
      key: 'sol',
      title: `Add Solana ${symbol}`,
      enabled: true,
      build: async () =>
        buildSolProvider(solAddr, symbol).then((p) => (p ? [p] : [])),
    });
  }
 
  if (currency === 'USD' && icpAddr) {
    opts.push({
      key: 'icp',
      title: 'Add ICP ckUSD',
      enabled: true,
      build: async () =>
        buildIcpProvider(icpAddr).then((p) => (p ? [p] : [])),
    });
  }
 
  return opts;
}, [currency, user.addresses]);

The handleAddProvider logic then:

  1. Validates that the user selected a crypto type (EVM / Sol / ICP).
  2. Calls the corresponding build* function.
  3. Deduplicates providers with deepEqual before adding them to the backend.

Result: from the offramper’s perspective, adding “pay me in USDC / EURC” is one click per blockchain family.

💡 Screenshot:

Offramper Profile Providers

User Profile with EVM USDC/EURC grouped by address, Solana USDC/EURC and ICP ckUSD cards, each with the network/token badges.

2.4. Grouped EVM cards: one address, many chains

On the EVM side, one address can host USDC/EURC on multiple chains (Sepolia, Base Sepolia, Optimism Sepolia, Arbitrum Sepolia…). Instead of one card per chain, I grouped them.

export type EvmGroup = {
  symbol: 'USDC' | 'EURC';
  address: TransactionAddress;
  chains: number[];
  providers: PaymentProvider[];
  logo: string;
};
 
export const groupEvmProviders = (all: PaymentProvider[]): EvmGroup[] => {
  const map = new Map<string, EvmGroup>();
 
  for (const p of all) {
    if (!('Crypto' in p) || !('EVM' in p.Crypto.asset)) continue;
    const cid = Number(p.Crypto.asset.EVM.chain_id);
    const token = p.Crypto.asset.EVM.token_address?.[0];
    if (!token) continue;
 
    let symbol: 'USDC' | 'EURC' | undefined;
    let logo = '';
    try {
      const t = getEvmTokens(cid).find(
        (tt) => tt.address.toLowerCase() === token.toLowerCase(),
      );
      if (!t) continue;
      const n = t.name.toUpperCase();
      if (n === 'USDC' || n === 'EURC') symbol = n as 'USDC' | 'EURC';
      logo = t.logo;
    } catch {}
 
    if (!symbol) continue;
 
    const key = `${symbol}|${JSON.stringify(p.Crypto.address)}`;
    const existing = map.get(key) ?? {
      symbol,
      address: p.Crypto.address,
      chains: [],
      providers: [],
      logo,
    };
    if (!existing.chains.includes(cid)) existing.chains.push(cid);
    existing.providers.push(p);
    map.set(key, existing);
  }
 
  return Array.from(map.values()).map((g) => ({
    ...g,
    chains: g.chains.sort(
      (a, b) => EVM_CHAIN_IDS.indexOf(a) - EVM_CHAIN_IDS.indexOf(b),
    ),
  }));
};

The UI then renders one “EVM Crypto” card per (symbol, address) with small pills for each EVM chain:

{evmGroup.map((g, gIdx) => (
  <div key={`evm-${gIdx}`} className="relative rounded-xl border p-3 bg-emerald-500/10 border-emerald-500/40">
    <div className="flex items-start justify-between gap-3">
      <div className="flex items-center gap-2">
        <ProviderIcon type="Crypto" crypto="EVM" />
        <span className="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded border border-emerald-400/30 text-emerald-300 bg-emerald-400/10">
          Crypto
        </span>
        <img src={g.logo} alt={g.symbol} className="h-5 rounded-md w-auto" />
      </div>
      {/* Trash button: removes all providers in the group */}
    </div>
 
    <div className="mt-2 font-mono text-sm break-all">
      {g.address.address}
    </div>
 
    <div className="mt-2 flex flex-wrap gap-2">
      {g.chains.map((cid) => (
        <span
          key={cid}
          className="text-[11px] px-2 py-0.5 rounded border border-emerald-400/20 text-emerald-200/90 bg-emerald-400/5"
        >
          {Object.values(NetworkIds).find((n) => n.id === cid)?.name ?? cid}
        </span>
      ))}
    </div>
  </div>
))}

This gives a compact summary:

  • “This EVM address receives USDC/EURC on chains A, B, C”
  • And one click removes the whole group.

3. Create Order: offering only relevant crypto providers

Once the user has crypto providers, the Create Order screen needs to:

  • Only show providers that make sense for the selected blockchain + currency.
  • Enforce bridge semantics: you can’t pay in the same chain you are depositing on.

3.1. Filtering crypto providers by chain and currency

In the CreateOrder hook I added a cryptoProviders derived value:

const cryptoProviders = useMemo<PaymentProvider[]>(() => {
  if (!blockchainType || !currency) return [];
 
  const fiatLower = currency.toLowerCase();
  const matchesCurrency = (symbol?: string) =>
    !!symbol && symbol.toLowerCase().includes(fiatLower);
 
  const out: PaymentProvider[] = [];
  for (const p of user.payment_providers) {
    if (!('Crypto' in p)) continue;
    const asset = p.Crypto.asset;
 
    let assetChain: BlockchainTypes | null = null;
    if ('EVM' in asset) assetChain = 'EVM';
    else if ('Solana' in asset) assetChain = 'Solana';
    else if ('ICP' in asset) assetChain = 'ICP';
 
    // Bridge semantics: skip providers on the same chain as the escrow asset
    if (!assetChain || assetChain === blockchainType) continue;
 
    if ('EVM' in asset) {
      const cid = Number(asset.EVM.chain_id);
      const tokenAddr = asset.EVM.token_address?.[0];
      if (!tokenAddr) continue;
 
      let symbol: string | undefined;
      try {
        const token = getEvmTokens(cid).find(
          (t) => t.address.toLowerCase() === tokenAddr.toLowerCase(),
        );
        symbol = token?.name;
      } catch {
        continue;
      }
 
      if (!matchesCurrency(symbol)) continue;
      out.push(p);
      continue;
    } else if ('Solana' in asset) {
      const mint = asset.Solana.spl_token?.[0];
      if (!mint || !solOptions) continue;
      const token = solOptions.find((t) => t.address === mint);
      const symbol = token?.name;
      if (!matchesCurrency(symbol)) continue;
 
      out.push(p);
      continue;
    } else if (blockchainType === 'ICP' && 'ICP' in asset) {
      const principalStr =
        asset.ICP.ledger_principal.toText?.() ??
        String(asset.ICP.ledger_principal);
      const token = ICP_TOKENS.find((t) => t.address === principalStr);
      const symbol = token?.name;
      if (!matchesCurrency(symbol)) continue;
 
      out.push(p);
      continue;
    }
  }
 
  return out;
}, [user.payment_providers, blockchainType, tokenOptions, currency, solOptions]);

This function does a few important things:

  • Only looks at Crypto providers.
  • Skips assets whose chain equals the current deposit chain (blockchainType).
  • Filters by fiat currency: if the order is denominated in USD, we accept any token whose name contains usd (USDC, USDT, etc.); for EUR we match eurc, etc.

On top of this we derive:

  • nonEvmCryptoProviders → Solana / ICP crypto providers.
  • evmCryptoGroups → grouped EVM crypto providers, same logic as in the profile.
  • nonCryptoProviders → PayPal / Revolut / Stripe / Email.
const nonEvmCryptoProviders = useMemo(
  () =>
    cryptoProviders.filter(
      (p) => 'Crypto' in p && !('EVM' in p.Crypto.asset),
    ),
  [cryptoProviders],
);
 
const evmCryptoGroups = useMemo(
  () =>
    groupEvmProviders(
      cryptoProviders.filter(
        (p) => 'Crypto' in p && 'EVM' in p.Crypto.asset,
      ),
    ),
  [cryptoProviders],
);
 
const nonCryptoProviders = useMemo(
  () => (user?.payment_providers ?? []).filter((p) => !('Crypto' in p)),
  [user?.payment_providers],
);

3.2. Create Order providers UI

The Create Order card now modifies the Payment Providers section such that:

  • shows non-crypto providers as cards,
  • shows EVM crypto as a single card per (address, symbol) with mini chain toggles,
  • shows Solana/ICP crypto providers as simple cards.

We already saw the profile cards; the Create Order cards follow the same visual language, just with checkboxes which control selectedProviders.

💡 Screenshots:

Solana Order with Pay With Crypto EVM Create Order on Solana with an EVM USDC group available.

Bitcoin Order with Pay With Crypto Create Order on Bitcoin with EVM USDC and Solana USDC available as payment methods.

This is where the bridge semantics are enforced visually: if the offramper is creating a Bitcoin order, the UI will offer EVM / Solana / ICP stables; if they are creating a Solana order, they’ll see EVM / ICP, etc.

4. Order list UX: small method chips for onrampers

The last big piece of this frontend pass is the OrderCard. For orders in Created state, the onramper needs to:

  • see a compact summary of price, amount, address and network,
  • and choose how they want to pay: PayPal, Revolut, Crypto on Base, Crypto on Solana, etc.

4.1. Price & token overlay

The card header is a small detail but makes the UI feel more like a real dApp: we show the network logo and the token logo slightly overlaid.

const tokenOverlay = token && (
  <img
    src={token.logo}
    alt={token.name}
    title={token.name}
    className="h-4 w-4 rounded-full border border-white bg-gray-100 absolute -bottom-1 -right-1"
  />
);
 
<li className={`px-14 pt-10 pb-8 border rounded-xl shadow-md ${backgroundColor} ${borderColor} ${textColor} relative`}>
  {getNetworkLogo() && (
    <div className="absolute top-2.5 left-2.5 h-8 w-8">
      <div className="relative inline-block">
        <img
          src={getNetworkLogo()}
          alt="Blockchain Logo"
          title={getNetworkName()}
          className="h-8 w-8"
        />
        {tokenOverlay}
      </div>
    </div>
  )}
  {/* ... */}
</li>

The network circle is in the background, and the token circle sits as a half-overlapping badge — visually: “this order is on Base / Sepolia / Solana, and the asset is USDC / BTC / BONK”.

💡 Screenshot:

Order List view For Offramper Offramper order list with token overlays and partial fill percentage indicator.

4.2. Rendering crypto providers in the order card

For each PaymentProvider stored in orderState.Created.offramper_providers, we derive some metadata:

const describeCryptoProvider = (provider: PaymentProvider): {
  chain: 'EVM' | 'Solana' | 'ICP' | undefined;
  evmNetwork: NetworkProps | undefined;
  token: TokenOption | undefined;
} => {
  if (!('Crypto' in provider)) return { chain: undefined, evmNetwork: undefined, token: undefined };
  const asset = provider.Crypto.asset;
 
  let chain: 'EVM' | 'Solana' | 'ICP' | undefined;
  let evmNetwork: NetworkProps | undefined;
  let token: TokenOption | undefined;
 
  if ('EVM' in asset) {
    chain = 'EVM';
    const cid = Number(asset.EVM.chain_id);
    evmNetwork = Object.values(NetworkIds).find((n) => n.id === cid);
    const tokenAddr = asset.EVM.token_address?.[0];
    if (tokenAddr) {
      token = getEvmTokens(cid).find(
        (t) => t.address.toLowerCase() === tokenAddr.toLowerCase(),
      );
    }
  } else if ('Solana' in asset) {
    chain = 'Solana';
    const mint = asset.Solana.spl_token?.[0];
    if (mint && solOptions) {
      token = solOptions.find((t) => t.address === mint);
    }
  } else if ('ICP' in asset) {
    chain = 'ICP';
    const principalStr =
      asset.ICP.ledger_principal.toText?.() ??
      String(asset.ICP.ledger_principal);
    token = ICP_TOKENS.find((t) => t.address === principalStr);
  }
 
  return { chain, evmNetwork, token };
};

The ProviderIcon component was extended so that Crypto icons can show:

  • a generic Ethereum logo, or
  • a specific EVM network logo (Base / Optimism / Arbitrum / Sepolia),
  • or the Solana / ICP logos.
export const ProviderIcon = ({
  type,
  className = 'h-5 rounded-md w-auto',
  crypto,
  evmChain,
}: {
  type: PaymentProviderTypes;
  className?: string;
  crypto?: 'EVM' | 'Solana' | 'ICP';
  evmChain?: number;
}) => {
  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={faCreditCard} size="lg" />;
 
  if (type === 'Crypto') {
    if (crypto === 'EVM') {
      let logo = ethereumLogo;
      let alt = 'EVM';
      if (evmChain !== undefined) {
        const chain = Object.values(NetworkIds).find((n) => n.id === evmChain);
        if (chain) {
          logo = chain.logo;
          alt = chain.name;
        }
      }
      return <img src={logo} alt={alt} className={className} />;
    }
 
    if (crypto === 'Solana') {
      return <img src={solanaLogo} alt="Solana" className={className} />;
    }
    if (crypto === 'ICP') {
      return <img src={icpLogo} alt="ICP" className={className} />;
    }
  }
  return null;
};

So a Crypto provider on Base Sepolia USDC is not just a generic “Crypto” icon; it shows the Base logo.

4.3. Onramper selection: tiny chips + asset-aware mapping

In the OrderCard we finally render the “Payment Methods” area.

For the onramper, providers are shown as small pill buttons:

<div className="text-lg">
  <span className="opacity-90">Payment Methods:</span>
  <div className="mt-2 flex flex-wrap gap-2">
    {orderState.Created.offramper_providers.map((provider, index) => {
      const providerType = paymentProviderTypeToString(
        providerToProviderType(provider),
      );
 
      let chain: 'EVM' | 'Solana' | 'ICP' | undefined;
      let evmNetwork: NetworkProps | undefined;
      let token: TokenOption | undefined;
      if ('Crypto' in provider) {
        const meta = describeCryptoProvider(provider);
        chain = meta.chain;
        evmNetwork = meta.evmNetwork;
        token = meta.token;
      }
 
      if (userType === 'Onramper') {
        let checked = false;
        if (committedProvider) {
          const committedProv = committedProvider[1];
          const committedProvType = paymentProviderTypeToString(
            committedProvider[0],
          );
          if (
            providerType === 'Crypto' &&
            'Crypto' in provider &&
            'Crypto' in committedProv
          ) {
            checked = sameCryptoAsset(
              provider.Crypto.asset,
              committedProv.Crypto.asset,
            );
          } else {
            checked =
              providerType === committedProvType ||
              (providerType === 'Stripe' && committedProvType === 'Email');
          }
        }
 
        return (
          <button
            key={index}
            type="button"
            onClick={() =>
              handleProviderSelection(providerType, provider)
            }
            className={clsx(
              'inline-flex items-center gap-1 px-3 py-1 rounded-full border text-sm transition',
              checked
                ? 'bg-indigo-600/25 border-indigo-400 text-indigo-50'
                : 'bg-gray-700/40 border-gray-500/60 text-gray-200 hover:bg-indigo-500/10 hover:border-indigo-400/60',
            )}
          >
            <ProviderIcon
              type={providerType}
              className="h-4 w-auto rounded-md"
              crypto={chain ?? undefined}
              evmChain={evmNetwork?.id ?? undefined}
            />
            <span>{providerType}</span>
            {token?.logo && (
              <img
                src={token.logo}
                alt={token.logo}
                className="h-5 w-auto rounded-md"
              />
            )}
          </button>
        );
      }
 
      // Offramper view: static chips
      return (
        <span
          key={index}
          className="inline-flex items-center gap-1 px-3 py-1 rounded-full border text-sm bg-gray-700/40 border-gray-500/60 text-gray-200 cursor-default"
        >
          <ProviderIcon
            type={providerType}
            className="h-4 w-auto rounded-md"
            crypto={chain ?? undefined}
            evmChain={evmNetwork?.id ?? undefined}
          />
          <span>{providerType}</span>
          {token?.logo && (
            <img
              src={token.logo}
              alt={token.logo}
              className="h-5 w-auto rounded-md"
            />
          )}
        </span>
      );
    })}
  </div>
</div>

The last piece is the mapping between offramper providers and onramper providers.

The onramper has their own user.payment_providers. When they click a chip, we need to:

  • For non-crypto: pick their provider of that type (PayPal, Revolut, Email, …).
  • For crypto: pick their crypto provider on the same asset (chain + token) as the offramper’s provider.

That’s handled in useOrderLogic:

const handleProviderSelection = (
  selectedProviderType: PaymentProviderTypes,
  offramperProvider: PaymentProvider,
) => {
  if (!user) return;
 
  let onramperProvider: PaymentProvider | undefined;
  setMessage('');
 
  if (selectedProviderType === 'Crypto' && 'Crypto' in offramperProvider) {
    const offAsset = offramperProvider.Crypto.asset;
    onramperProvider = user.payment_providers.find((userProvider) => {
      if (!('Crypto' in userProvider)) return false;
      return sameCryptoAsset(offAsset, userProvider.Crypto.asset);
    });
 
    if (!onramperProvider) {
      setMessage(
        `No matching crypto provider for ${blockchainAssetToChain(offAsset)}`,
      );
      return;
    }
  } else {
    onramperProvider = user.payment_providers.find((userProvider) => {
      const p = paymentProviderTypeToString(
        providerToProviderType(userProvider),
      );
      return (
        p === selectedProviderType ||
        (p === 'Email' && selectedProviderType === 'Stripe')
      );
    });
    if (!onramperProvider) {
      setMessage(`no matching provider for type: ${selectedProviderType}`);
      return;
    }
  }
 
  const already =
    committedProvider && committedProvider[1] === onramperProvider;
  if (already) {
    setCommittedProvider(undefined);
  } else {
    setCommittedProvider([
      providerToProviderType(onramperProvider),
      onramperProvider,
    ]);
  }
};

blockchainAssetToChain is a tiny helper to give human-readable error messages:

export const blockchainAssetToChain = (asset: BlockchainAsset) => {
  if ('EVM' in asset)
    return (
      Object.values(NetworkIds).find((n) => n.id === Number(asset.EVM.chain_id))
        ?.name ?? 'EVM'
    );
  if ('ICP' in asset) return 'ICP';
  if ('Solana' in asset) return 'Solana';
  if ('Bitcoin' in asset) return 'Bitcoin';
  throw new Error('Unknown blockchain');
};

So when the onramper clicks a Crypto chip and they don’t have a matching provider configured, the order card tells them: “No matching crypto provider for Base Sepolia”, etc.

💡 Screenshot:

Onramper's Order List view For Onramper sees each order with small chips: [PayPal] [Revolut] [Crypto (Base USDC)] [Crypto (Solana USDC)]. Selecting one highlights it and internally sets committedProvider.

Conclusion and Next Steps

At this point, the frontend can:

  • let offrampers configure crypto receivers across EVM / Solana / ICP,
  • let them attach bridge payment options to new orders,
  • and let onrampers choose how they want to pay, with a clean chip-based UX.

What’s still missing is the final mile: once the onramper selects a provider and locks the order, we need to actually:

  • guide them through the real payment (e.g. send USDC on Base, send USDC on Solana, etc.),
  • verify that payment via verify_crypto_transaction (or fiat providers),
  • and then trigger the vault release from the escrow chain.

We’ll cover that in the next devlog.

Stay Updated

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

You might also like

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.

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.