Building a Reusable Wallet Connect UI Kit for Conflux eSpace

Introduction

Every Web3 developer has faced the same problem: you start a new dApp, and the first thing you rebuild from scratch is wallet connection. Connect button, network switching, wrong-network warnings, account display — the same boilerplate, project after project.

Conflux Connect UI Kit solves this by packaging wallet connection for Conflux eSpace into a drop-in React component library. Write it once, import it anywhere. The kit supports Conflux eSpace Mainnet (chain ID 1030) and Conflux eSpace Testnet (chain ID 71), with built-in detection for MetaMask and Fluent Wallet.

This post walks through the architecture, core code, and how to integrate the kit into your own project.


What We Built

Package Description
@conflux-connect/ui-kit React component library for wallet connect
@conflux-connect/demo Vite demo application
@conflux-connect/api Optional Express backend for SIWE auth

Key Features

  • ConnectKit — all-in-one connect button, wallet modal, and account menu
  • Network switching — auto-add and switch Conflux chains in MetaMask / Fluent
  • NetworkBanner — wrong-network warning with one-click fix
  • Wallet detection — MetaMask, Fluent Wallet, and generic injected providers
  • Headless hookuseConfluxConnect() for fully custom UIs
  • Light / dark theme — CSS variable-based theming
  • Optional SIWE — Sign-In with Ethereum for backend authentication

Project Structure

Conflux-Connect-UI-Kit/
├── packages/ui/              # @conflux-connect/ui-kit
│   └── src/
│       ├── provider.tsx      # Core wallet context & logic
│       ├── chains.ts         # Conflux network definitions
│       ├── utils.ts            # Wallet helpers
│       ├── components/         # UI components
│       └── styles.css          # Component styles
├── apps/demo/                # Vite demo app
├── apps/api/                   # Express SIWE API (optional)
└── package.json                # npm workspaces root

Quick Start

Clone the repository and run the demo:

git clone https://github.com/your-username/Conflux-Connect-UI-Kit.git
cd Conflux-Connect-UI-Kit
npm install
npm run dev

This starts both the demo app (http://localhost:5173) and the API server (http://localhost:3001) together.

To integrate into your own React project, install the UI kit and wrap your app:

import {
  ConfluxConnectProvider,
  ConnectKit,
  CONFLUX_ESPACE_TESTNET,
  SUPPORTED_CHAINS,
} from "@conflux-connect/ui-kit";
import "@conflux-connect/ui-kit/styles.css";

function App() {
  return (
    <ConfluxConnectProvider
      chainId={CONFLUX_ESPACE_TESTNET.chainId}
      chains={[...SUPPORTED_CHAINS]}
      appName="My Conflux DApp"
    >
      <ConnectKit theme="dark" />
    </ConfluxConnectProvider>
  );
}

Three steps: import, wrap with provider, add ConnectKit. Done.


Defining Conflux Networks

All supported chains are defined in packages/ui/src/chains.ts. Each chain object follows the ConfluxChain interface:

export interface ConfluxChain {
  chainId: number;
  chainIdHex: string;
  name: string;
  nativeCurrency: {
    name: string;
    symbol: string;
    decimals: number;
  };
  rpcUrls: readonly string[];
  blockExplorerUrls: readonly string[];
}

Conflux eSpace Mainnet and Testnet are pre-configured:

/** Conflux eSpace Mainnet — chain ID 1030 (0x406). */
export const CONFLUX_ESPACE_MAINNET: ConfluxChain = {
  chainId: 1030,
  chainIdHex: "0x406",
  name: "Conflux eSpace",
  nativeCurrency: {
    name: "Conflux",
    symbol: "CFX",
    decimals: 18,
  },
  rpcUrls: ["https://evm.confluxrpc.com"],
  blockExplorerUrls: ["https://evm.confluxscan.org"],
};

/** Conflux eSpace Testnet — chain ID 71 (0x47). */
export const CONFLUX_ESPACE_TESTNET: ConfluxChain = {
  chainId: 71,
  chainIdHex: "0x47",
  name: "Conflux eSpace Testnet",
  nativeCurrency: {
    name: "Conflux",
    symbol: "CFX",
    decimals: 18,
  },
  rpcUrls: ["https://evmtestnet.confluxrpc.com"],
  blockExplorerUrls: ["https://evmtestnet.confluxscan.org"],
};

export const SUPPORTED_CHAINS = [
  CONFLUX_ESPACE_MAINNET,
  CONFLUX_ESPACE_TESTNET,
] as const;

You can add a custom network by defining a new ConfluxChain object and passing it to the provider’s chains prop.


Core Provider: Wallet State Management

ConfluxConnectProvider is the brain of the library. It manages wallet state via React Context and exposes a useConfluxConnect() hook to any child component.

Provider Configuration

<ConfluxConnectProvider
  chainId={71}                    // Default target network
  chains={[...SUPPORTED_CHAINS]}  // Supported networks
  appName="My Conflux DApp"       // Shown in wallet prompts
  authApiUrl=""                   // Optional SIWE backend URL
>
  {children}
</ConfluxConnectProvider>
Prop Type Description
chainId number Default target chain ID (defaults to Testnet 71)
chains ConfluxChain[] List of supported networks
appName string Application name for wallet prompts
authApiUrl string SIWE API base URL (empty = same-origin /api)

The Connect Flow

When a user clicks Connect Wallet, three things happen in sequence:

const connect = useCallback(async (connectorId?: string) => {
  const ethereum = getEthereum();
  if (!ethereum) {
    setError("No wallet detected. Install MetaMask or Fluent Wallet.");
    return;
  }

  setIsConnecting(true);
  try {
    await requestAccounts(ethereum);           // 1. Request wallet permission
    await switchToChain(ethereum, targetChain); // 2. Switch to Conflux network
    await bindWallet(ethereum);                 // 3. Save address + chainId
  } catch (err) {
    setError(err instanceof Error ? err.message : "Failed to connect wallet");
  } finally {
    setIsConnecting(false);
  }
}, [bindWallet, getEthereum, targetChain]);
  1. eth_requestAccounts — asks the wallet for permission
  2. wallet_switchEthereumChain — switches to the target Conflux network (or adds it if missing)
  3. State update — stores the address and chain ID in React state

Network Switching

The switchToChain utility handles both switching and adding chains:

export async function switchToChain(
  ethereum: EthereumProvider,
  chain: ConfluxChain
): Promise<void> {
  try {
    await ethereum.request({
      method: "wallet_switchEthereumChain",
      params: [{ chainId: chain.chainIdHex }],
    });
  } catch (err: unknown) {
    const code =
      err && typeof err === "object" && "code" in err
        ? (err as { code: number }).code
        : null;

    // Error 4902 = chain not added to wallet yet
    if (code !== 4902) throw err;

    await ethereum.request({
      method: "wallet_addEthereumChain",
      params: [{
        chainId: chain.chainIdHex,
        chainName: chain.name,
        nativeCurrency: chain.nativeCurrency,
        rpcUrls: [...chain.rpcUrls],
        blockExplorerUrls: [...chain.blockExplorerUrls],
      }],
    });
  }
}

If the Conflux network is not yet in the user’s wallet, error code 4902 is caught and wallet_addEthereumChain is called automatically.

Wallet Event Listeners

The provider listens for wallet events so the UI stays in sync even when the user changes account or network directly inside MetaMask:

useEffect(() => {
  const ethereum = getEthereum();
  if (!ethereum) return;

  const handleAccountsChanged = (accounts: unknown) => {
    const list = accounts as string[];
    if (list.length === 0) {
      disconnect();
      return;
    }
    setAddress(list[0]);
  };

  const handleChainChanged = (hexChainId: unknown) => {
    setCurrentChainId(parseInt(hexChainId as string, 16));
  };

  ethereum.on("accountsChanged", handleAccountsChanged);
  ethereum.on("chainChanged", handleChainChanged);
  void bindWallet(ethereum);

  return () => {
    ethereum.removeListener("accountsChanged", handleAccountsChanged);
    ethereum.removeListener("chainChanged", handleChainChanged);
  };
}, [bindWallet, disconnect, getEthereum]);

ConnectKit: The All-in-One Component

ConnectKit is the simplest way to add wallet connect to your app. It conditionally renders based on connection state:

export function ConnectKit({ className, theme = "dark" }: ConnectKitProps) {
  const [modalOpen, setModalOpen] = useState(false);
  const { isConnected, isCorrectChain } = useConfluxConnect();

  return (
    <div className={cn("cc-root", className)} data-theme={theme}>
      {isConnected && isCorrectChain ? (
        <AccountMenu theme={theme} />
      ) : (
        <ConnectButton showModal onOpenModal={() => setModalOpen(true)} />
      )}
      <WalletModal open={modalOpen} onClose={() => setModalOpen(false)} />
    </div>
  );
}
State What renders
Not connected Connect Wallet button → opens wallet modal
Connected, wrong network Switch Network button
Connected, correct network Account menu with address + explorer link

Component Reference

ConnectKit

All-in-one widget. Drop it anywhere inside the provider:

<ConnectKit theme="dark" />

ConnectButton

Standalone connect / disconnect button:

import { ConnectButton } from "@conflux-connect/ui-kit";

<ConnectButton />

WalletModal

Wallet picker modal (MetaMask, Fluent):

import { WalletModal } from "@conflux-connect/ui-kit";

const [open, setOpen] = useState(false);

<WalletModal open={open} onClose={() => setOpen(false)} />

NetworkBanner

Wrong-network warning banner. Appears automatically when the user is on the wrong chain:

import { NetworkBanner } from "@conflux-connect/ui-kit";

<NetworkBanner theme="dark" />

NetworkSwitcher

Chain selector for toggling between Mainnet and Testnet:

import { NetworkSwitcher } from "@conflux-connect/ui-kit";

<div className="cc-root" data-theme="dark">
  <NetworkSwitcher />
</div>

AccountMenu

Address dropdown with explorer link and disconnect:

import { AccountMenu } from "@conflux-connect/ui-kit";

<AccountMenu theme="dark" />

Headless Usage with useConfluxConnect

For fully custom UIs, use the hook directly without the pre-built components:

import { useConfluxConnect } from "@conflux-connect/ui-kit";

function CustomWalletButton() {
  const {
    address,
    chainId,
    isConnected,
    isCorrectChain,
    isConnecting,
    connect,
    disconnect,
    switchNetwork,
    error,
  } = useConfluxConnect();

  if (!isConnected) {
    return (
      <button onClick={() => connect()} disabled={isConnecting}>
        {isConnecting ? "Connecting…" : "Connect Wallet"}
      </button>
    );
  }

  if (!isCorrectChain) {
    return <button onClick={() => switchNetwork()}>Switch Network</button>;
  }

  return (
    <div>
      <span>{address}</span>
      <button onClick={disconnect}>Disconnect</button>
    </div>
  );
}

Hook API

Property / Method Type Description
address string \| null Connected wallet address
chainId number \| null Current chain ID
isConnected boolean Whether a wallet is connected
isCorrectChain boolean Whether user is on the target network
isConnecting boolean Connect in progress
isSwitchingChain boolean Network switch in progress
error string \| null Last error message
connect() () => Promise<void> Connect wallet
disconnect() () => void Disconnect wallet
switchNetwork(chainId?) (chainId?) => Promise<void> Switch network

Wallet Detection

The kit auto-detects installed wallets via window.ethereum:

export function detectConnectors() {
  const connectors = [];

  if (typeof window !== "undefined" && window.ethereum) {
    const eth = window.ethereum;

    if (eth.isFluent) {
      connectors.push({ id: "fluent", name: "Fluent Wallet", installed: true });
    }
    if (eth.isMetaMask) {
      connectors.push({ id: "metamask", name: "MetaMask", installed: true });
    }
    if (connectors.length === 0) {
      connectors.push({ id: "injected", name: "Browser Wallet", installed: true });
    }
  }

  // Show install links for wallets not detected
  connectors.push({
    id: "metamask",
    name: "MetaMask",
    installed: false,
    downloadUrl: "https://metamask.io/download/",
  });

  return connectors;
}

If a wallet is not installed, clicking it opens the download page in a new tab.


Optional: SIWE Authentication

Wallet connect gives you the user’s address. SIWE (Sign-In with Ethereum) proves they own that address by signing a message with their private key. This is only needed if you have a backend with user-specific data.

When do you need SIWE?

Use case Wallet connect only SIWE required
On-chain transactions Yes No
Read public blockchain data Yes No
User profiles / saved data No Yes
Protected API routes No Yes

Enabling SIWE

Set authApiUrl on the provider:

<ConfluxConnectProvider
  chainId={CONFLUX_ESPACE_TESTNET.chainId}
  authApiUrl=""   // empty = uses Vite proxy at /api
>

Then call signIn() from the hook:

const { signIn, signOut, isAuthenticated } = useConfluxConnect();

<button onClick={() => isAuthenticated ? signOut() : signIn()}>
  {isAuthenticated ? "Sign Out" : "Sign In"}
</button>

SIWE Flow

1. Client  →  POST /api/auth/nonce  →  Server generates SIWE message
2. Client  →  personal_sign(message)  →  Wallet signs the message
3. Client  →  POST /api/auth/verify  →  Server verifies signature, returns JWT

The API normalizes wallet addresses to EIP-55 checksum format before creating SIWE messages, since MetaMask often returns lowercase addresses.


Theming

Components support light and dark themes via the data-theme attribute and CSS variables:

<ConnectKit theme="dark" />
<NetworkBanner theme="light" />

Override CSS variables for custom branding:

.cc-root {
  --cc-primary: #1b70e2;
  --cc-primary-hover: #1558b8;
  --cc-surface: #ffffff;
  --cc-text: #0f172a;
  --cc-border: #e2e8f0;
  --cc-radius: 14px;
}

.cc-root[data-theme="dark"] {
  --cc-primary: #3b9eff;
  --cc-surface: #111827;
  --cc-text: #f1f5f9;
  --cc-border: #334155;
}

Building the Library

The UI kit is built with tsup and published from packages/ui:

# Build the library
npm run build:ui

# Output
packages/ui/dist/
├── index.js        # ESM bundle
├── index.cjs       # CommonJS bundle
├── index.d.ts      # TypeScript declarations
└── ui-kit.css      # Component styles

package.json exports

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/ui-kit.css"
  }
}

Full Demo App Example

Here is how the demo app wires everything together in apps/demo/src/App.tsx:

import {
  ConfluxConnectProvider,
  ConnectKit,
  NetworkBanner,
  NetworkSwitcher,
  useConfluxConnect,
  CONFLUX_ESPACE_TESTNET,
  SUPPORTED_CHAINS,
} from "@conflux-connect/ui-kit";
import "@conflux-connect/ui-kit/styles.css";

export default function App() {
  return (
    <ConfluxConnectProvider
      chainId={CONFLUX_ESPACE_TESTNET.chainId}
      chains={[...SUPPORTED_CHAINS]}
      appName="Conflux Connect Demo"
      authApiUrl=""
    >
      <header>
        <ConnectKit theme="dark" />
      </header>

      <NetworkBanner theme="dark" />

      <main>
        <WalletStatus />

        <div className="cc-root" data-theme="dark">
          <NetworkSwitcher />
        </div>
      </main>
    </ConfluxConnectProvider>
  );
}

function WalletStatus() {
  const { address, chainId, isConnected, isCorrectChain, error } =
    useConfluxConnect();

  return (
    <div>
      <p>Connected: {isConnected ? "Yes" : "No"}</p>
      <p>Address: {address ?? "—"}</p>
      <p>Chain ID: {chainId ?? "—"}</p>
      <p>Correct Network: {isCorrectChain ? "Yes" : "No"}</p>
      {error && <p style={{ color: "red" }}>{error}</p>}
    </div>
  );
}

Supported Networks

Network Chain ID Hex RPC URL
Conflux eSpace Mainnet 1030 0x406 https://evm.confluxrpc.com
Conflux eSpace Testnet 71 0x47 https://evmtestnet.confluxrpc.com

Conclusion

Conflux Connect UI Kit eliminates the repetitive work of building wallet connection for every Conflux dApp. Wrap your app with ConfluxConnectProvider, drop in ConnectKit, and you get wallet detection, network switching, wrong-network handling, and account management out of the box.

The library is designed to be extended — use individual components, build custom UIs with the headless hook, or add SIWE authentication when your backend needs it.


Resources