
icRamp Devlog #5 — icRamp frontend Solana Wallet Adapter
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 ourUserContext
.
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'ssignMessage
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 ourmain.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 callconnect()
before a selection exists. We fixed it by opening the modal insideconnectSolana()
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.