icRamp Devlog #5 — icRamp frontend Solana Wallet Adapter

icRamp Devlog #5 — icRamp frontend Solana Wallet Adapter

9/1/20257 min • icramp
SolanaFrontendWallet

In this post, we upgrade the Solana authentication UX by integrating the official Solana Wallet Adapter. This gives us a consistent, multi-wallet flow (Phantom, Solflare, Ledger, WalletConnect, etc.), removes brittle window.solana checks, and centralizes logic in our UserContext.


In the previous post we skipped through the wallet setup. We initially took the simpler approach: to look at the window object and brute-forcing our login operations. Now we are going to add the wallet adapter.

Why Wallet Adapter?

  • Standard hooks/components: ConnectionProvider, WalletProvider, WalletModalProvider.
  • Built-in modal for wallet selection (no auto-picking).
  • Consistent signMessage surface across wallets.
  • We keep autoConnect={false} to avoid first-load races where the app tries to sign before the wallet is unlocked.

We first define a SolanaProvider.tsx component:

import { FC, ReactNode, useMemo } from "react";
import {
  ConnectionProvider,
  WalletProvider,
} from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
import {
  PhantomWalletAdapter,
  SolflareWalletAdapter,
  LedgerWalletAdapter,
  WalletConnectWalletAdapter,
  UnsafeBurnerWalletAdapter,
} from "@solana/wallet-adapter-wallets";
import "@solana/wallet-adapter-react-ui/styles.css";
import { clusterApiUrl } from "@solana/web3.js";
 
const SOL_ENV =
  process.env.FRONTEND_SOL_ENV === "mainnet" ? "mainnet" : "devnet";
const NETWORK: WalletAdapterNetwork =
  SOL_ENV === "mainnet"
    ? WalletAdapterNetwork.Mainnet
    : WalletAdapterNetwork.Devnet;
 
export const SolanaProvider: FC<{ children: ReactNode; rpcUrl?: string }> = ({
  children,
  rpcUrl,
}) => {
  const endpoint = useMemo(() => rpcUrl || clusterApiUrl(NETWORK), [rpcUrl]);
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      new SolflareWalletAdapter({ network: NETWORK }),
      new LedgerWalletAdapter(),
      new WalletConnectWalletAdapter({
        network: NETWORK,
        options: { projectId: process.env.FRONTEND_WALLETCONNECT_PROJECT_ID! },
      }),
      ...(process.env.NODE_ENV !== "production"
        ? [new UnsafeBurnerWalletAdapter()]
        : []),
    ],
    [NETWORK]
  );
 
  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect={false}>
        <WalletModalProvider>{children}</WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
};

We pin the dApp to devnet/mainnet by setting the ConnectionProvider endpoint (either our SOLANA_RPC_URL or clusterApiUrl(WalletAdapterNetwork.Devnet|Mainnet)). Wallet UIs may still display "mainnet/devnet" independently; that label doesn’t override our RPC. The authoritative source is our endpoint.

Then we just plug it in in our main.tsx together with the other providers:

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <SolanaProvider rpcUrl={SOLANA_RPC_URL}>
            <UserProvider>
              <BrowserRouter>
                <PageTitleUpdater />
                <App />
              </BrowserRouter>
            </UserProvider>
          </SolanaProvider>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>
);

We previously added the method connectSolana in UserContext:

export const UserProvider = ({ children }: { children: ReactNode }) => {
  // ---
 
  const connectSolana = async (): Promise<string> => {
    const anyWindow = window as any;
    const provider = anyWindow?.solana ?? anyWindow?.solflare;
    if (!provider) throw new Error("No Solana wallet found");
 
    const res = await provider.connect?.();
    const pkObj = provider.publicKey ?? res?.publicKey;
    const pubkey = pkObj?.toBase58 ? pkObj.toBase58() : pkObj?.toString?.();
    if (!pubkey) throw new Error("Could not read Solana public key");
 
    setSolanaPubkey(pubkey);
    return pubkey;
  };
 
  // ---
};

Now we can modify is as such:

import { useWallet } from "@solana/wallet-adapter-react";
import { useWalletModal } from "@solana/wallet-adapter-react-ui";
import type {
  Adapter,
  MessageSignerWalletAdapter,
} from "@solana/wallet-adapter-base";
 
interface UserContextProps {
  user: User | null;
 
  // other fields
 
  connectSolana: () => Promise<string | null>;
  getSolanaMessageSigner: () => Promise<
    (msg: Uint8Array) => Promise<Uint8Array>
  >;
}
 
export const UserProvider = ({ children }: { children: ReactNode }) => {
  // ---
 
  const { wallet, disconnect: disconnectSol } = useWallet();
  const { setVisible } = useWalletModal();
  const walletRef = useRef(wallet);
  useEffect(() => {
    walletRef.current = wallet;
  }, [wallet]);
 
  useEffect(() => {
    const pk = walletRef.current?.adapter.publicKey?.toBase58?.();
    if (pk && pk !== solanaPubkey) setSolanaPubkey(pk);
  }, [wallet]);
 
  const hasSignMessage = (a: Adapter): a is MessageSignerWalletAdapter =>
    typeof (a as any)?.signMessage === "function";
 
  const getSolanaMessageSigner = async () => {
    // ensure a wallet is selected & connected
    const pk = solanaPubkey ?? (await connectSolana());
    if (!pk || !walletRef.current) throw new Error("No Solana wallet selected");
    const adapter = walletRef.current.adapter;
    if (!adapter.connected) await adapter.connect();
 
    // wait until wallet exposes signMessage (e.g., after unlock)
    const start = Date.now();
    while (!hasSignMessage(adapter)) {
      await new Promise((r) => setTimeout(r, 50));
      if (Date.now() - start > 60000)
        throw new Error("Selected wallet cannot sign messages");
    }
    return adapter.signMessage!.bind(adapter);
  };
 
  const waitForWalletPick = () =>
    new Promise<void>((resolve, reject) => {
      const start = Date.now();
      const id = setInterval(() => {
        if (walletRef.current) {
          clearInterval(id);
          resolve();
        }
        if (Date.now() - start > 60000) {
          clearInterval(id);
          reject(new Error("Wallet selection cancelled"));
        }
      }, 100);
    });
 
  const connectSolana = async (): Promise<string> => {
    // if no wallet selected, open modal and wait for user choice
    if (!walletRef.current) {
      setVisible(true);
      await waitForWalletPick();
    }
    const adapter = walletRef.current!.adapter;
 
    try {
      await adapter.connect();
    } catch (e) {
      throw e;
    }
 
    const start = Date.now();
    let pk = adapter.publicKey?.toBase58?.();
    while (!pk) {
      await new Promise((r) => setTimeout(r, 50));
      pk = adapter.publicKey?.toBase58?.();
      if (Date.now() - start > 60000)
        throw new Error("Wallet connect timed out");
    }
 
    setSolanaPubkey(pk);
    return pk;
  };
 
  // we will also use the privider to handle the solana state upon logout
  const logout = async (): Promise<void> => {
    try {
      // II, EVM and Bitcoin logouts
      try {
        await disconnectSol();
      } catch {}
    } catch (error) {
      console.error("Error logging out", error);
    } finally {
      // reset state and add:
      setSolanaBalance(null);
      setSolanaPubkey(null);
    }
  };
 
  // ---
 
  return (
    <UserContext.Provider
      value={{
        // etc
 
        solanaPubkey,
        connectSolana,
        getSolanaMessageSigner,
      }}
    >
      {children}
    </UserContext.Provider>
  );
};

Finally we can substitute the authentication flow for solana:

git diff frontend/src/components/ConnectAddress.tsx
 
@@ -51,6 +51,7 @@ const ConnectAddress: React.FC = () => {
         setLoginMethod,
         setUser,
         connectSolana,
+        getSolanaMessageSigner,
         connectUnisat,
         loginInternetIdentity,
         authenticateUser
@@ -241,11 +242,6 @@ const ConnectAddress: React.FC = () => {
         cleanMessages();
 
         try {
-            const anyWindow = window as any;
-            const provider =
-                anyWindow?.solana ?? anyWindow?.solflare;
-            if (!provider) throw new Error('No Solana wallet found. Install Phantom or Solflare.');
-
             const pubkey = solanaPubkey ?? (await connectSolana());
             if (!pubkey) throw new Error('Could not read Solana public key');
             console.log("solana address = ", pubkey);
@@ -258,26 +254,10 @@ const ConnectAddress: React.FC = () => {
             console.log("[generate_auth_message] res = ", JSON.stringify(authRes));
 
             if ('Ok' in authRes) {
-                const msg = authRes.Ok as string;
-                const msgBytes = new TextEncoder().encode(msg);
-
-                // Wallet-standard signMessage if available, else wallet-specific
-                let rawSig: Uint8Array | string;
-                if (provider.signMessage) {
-                    const signed = await provider.signMessage(msgBytes, 'utf8');
-                    rawSig = signed.signature ?? signed; // some wallets return {signature}
-                } else if (provider.sign) {
-                    // solflare legacy (rare)
-                    const signed = await provider.sign(msgBytes, 'utf8');
-                    rawSig = signed.signature ?? signed;
-                } else {
-                    throw new Error('Wallet does not support signMessage');
-                }
-
-                const signatureB58 =
-                    rawSig instanceof Uint8Array ? bs58.encode(rawSig) :
-                        Array.isArray(rawSig) ? bs58.encode(Uint8Array.from(rawSig)) :
-                            (typeof rawSig === 'string' ? rawSig : (() => { throw new Error('Unknown signature format'); })());
+                const msgBytes = new TextEncoder().encode(authRes.Ok as string);
+                const signer = await getSolanaMessageSigner();
+                const rawSig = await signer(msgBytes);
+                const signatureB58 = bs58.encode(rawSig as Uint8Array);
 
                 const result = await authenticateUser(
                     loginAddress,
 

For the other buttons we have in the menu and user profile, no changes are needed since they just use UserContext's exported connectSolana.

Auth flow (what changed under the hood)

  • connectSolana() opens the modal, waits for a user pick, connects via the selected adapter (not the hook), and only resolves once a public key is available.
  • getSolanaMessageSigner() returns the adapter's signMessage after ensuring the wallet is connected/unlocked (solves the "wallet does not support signMessage" race).
  • ConnectAddress.tsx uses the context-provided signer to produce a base58 signature we send to the backend.

Troubleshooting footnotes

  • We were finding a Buffer is not defined in Vite, we solved it by adding tiny polyfills in our main.tsx:
import { Buffer } from "buffer";
import process from "process";
(window as any).global ||= window;
(window as any).process ||= process;
(window as any).Buffer ||= Buffer;
  • WalletNotSelectedError: happens if we call connect() before a selection exists. We fixed it by opening the modal inside connectSolana() and connecting via the chosen adapter.

Now we have a professional and multi-wallet solana login! Check it out:

In our next post, we are going to create solana orders.

Stay Updated

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

You might also like

icRamp Devlog #6 — icRamp Orders with Solana

Everything is ready for us to create orders in the frontend containing solana and executing the full offramping flow.

icRamp Devlog 12 — Milestone Submission: Solana P2P Onramping (SOL + BONK)

Final wrap-up for the Solana Integration milestone: 5-min demo, slides, deliverables checklist, tests, and canister URLs.

icRamp Devlog #11 — Testing Saga 4: Vault State (SOL + SPL), Full Suite Green

We finish the vault branch for SOL and SPL: deposits, cancels, locks, unlocks, and completion — entirely in-canister state. Plus, the full Solana suite now passes.