Implementing Wallet Connectors#
For this tutorial, we’ll be focusing on two key Concordium features:
Wallet connection (Browser and Mobile)
Zero-knowledge proof generation
Connecting to wallets allows users to interact with your dApp using their Concordium accounts, while zero-knowledge proofs enable privacy-preserving identity verification - a fundamental feature of compliance-focused applications on Concordium.
Attention
This tutorial presents selected code snippets to help you understand the key concepts and implementation patterns of a dApp on Concordium. The snippets are intentionally simplified and focus on illustrating specific concepts rather than providing complete implementations. Explanatory comments have been added to the code snippets to highlight important concepts and implementation details. For the full working code and context, please refer to the GitHub repository. Exploring the complete codebase alongside this tutorial is recommended for a comprehensive understanding.
Setting up the project#
Before diving into the code details, you are encouraged to clone the project repository and explore it firsthand:
$ git clone https://github.com/DOBEN/ZK_Proof_Demo
$ cd zk-proof-demo
At this point, open your preferred code editor in the zk-proof-demo directory to explore the full codebase.
$ cd frontend
$ yarn install
$ yarn dev
Running the application and interacting with it will give you a better understanding of the concepts we’ll be discussing. Feel free to explore the codebase, particularly focusing on the wallet connection components and ZK proof implementation.
Before running the application, ensure you have:
A Concordium wallet (either the Browser Wallet extension or CryptoX)
If you’d like to build this project from scratch or follow along step-by-step, you’ll need to set up your development environment:
Install NodeJS and yarn (or npm)
Set up your frontend directories
Add core dependencies:
$ yarn add @concordium/web-sdk @concordium/browser-wallet-api-helpers @walletconnect/sign-client @walletconnect/qrcode-modal
$ yarn add buffer json-bigint sha256 qrcode lucide-react
Creating wallet provider abstraction#
First, let’s create an abstract class that serves as the foundation for different wallet providers. We’ll start by defining the basic structure and extending the EventEmitter
class:
// wallet-connection.tsx, other imports ommited for brevity
import EventEmitter from "events";
export abstract class WalletProvider extends EventEmitter {
connectedAccount: string | undefined;
// Abstract methods will be defined below
}
The EventEmitter
extension enables our class to use a publish-subscribe pattern for wallet events.
This allows React components to listen for account changes without tight coupling. We also define a connectedAccount
property to track the currently connected account address.
Next, let’s add the connection-related methods:
export abstract class WalletProvider extends EventEmitter {
connectedAccount: string | undefined;
// Connect to the wallet and return the selected account
abstract connect(): Promise<string | undefined>;
// Optional method to disconnect from the wallet
disconnect?(): Promise<void>;
// Update state and emit event when account changes
protected onAccountChanged(new_account: string | undefined) {
this.connectedAccount = new_account;
this.emit("accountChanged", new_account);
}
}
These methods handle the connection lifecycle:
connect()
: An abstract method that concrete implementations must provide. It establishes a connection with the wallet and prompts the user to select an account.disconnect()
: An optional method (note the?
) that concrete implementations can provide to properly end the wallet connection.onAccountChanged()
: A helper method that updates the internal state and emits an event when the account changes, allowing UI components to react.
Now, let’s add the zero-knowledge proof related method:
// Request a ZK proof from the wallet
abstract requestVerifiablePresentation(
challenge: HexString,
statement: CredentialStatements,
): Promise<VerifiablePresentation>;
This method is the core of our ZK functionality:
It accepts a Challenge (a hex-encoded string) that ensures the proof is generated for this specific request
It takes Statement parameters that define what should be proved about the user’s identity
It returns a VerifiablePresentation containing the generated proof
By using this abstract class as a foundation, we can implement concrete wallet providers for different environments (browser extension, mobile app) while maintaining a consistent interface throughout our application. This approach makes it easy to add support for new wallet types in the future without changing the rest of the codebase.
The next sections will show how to implement concrete providers for both browser and mobile wallets.
Browser wallet implementation#
Now let’s implement the Browser Wallet provider by extending our abstract class. Let’s start with the constructor and event management:
// From wallet-connection.tsx
import { detectConcordiumProvider, WalletApi } from "@concordium/browser-wallet-api-helpers";
import { serializeTypeValue, toBuffer } from "@concordium/web-sdk";
let browserWalletInstance: BrowserWalletProvider | undefined;
export class BrowserWalletProvider extends WalletProvider {
// Private reference to the wallet API
constructor(private provider: WalletApi) {
super();
// Set up event listeners for account changes
provider.on("accountChanged", (account) => super.onAccountChanged(account));
provider.on("accountDisconnected", async () => {
// When disconnected, check if there's another selected account
super.onAccountChanged(
(await provider.getMostRecentlySelectedAccount()) ?? undefined
);
});
}
}
The constructor takes a WalletApi
instance from the browser wallet extension. We set up event listeners to forward wallet events to our abstract class’s event system:
When the account changes in the wallet, we call
onAccountChanged
to update our state and emit an eventWhen the wallet is disconnected, we check if there’s another selected account before clearing our state
Next, let’s implement the singleton pattern to ensure only one provider instance exists:
// Singleton pattern - ensure only one instance exists, allowing existing session to be restored
static async getInstance() {
if (browserWalletInstance === undefined) {
const provider = await detectConcordiumProvider();
browserWalletInstance = new BrowserWalletProvider(provider);
}
return browserWalletInstance;
}
The getInstance()
static method:
Checks if we already have a provider instance
If not, it uses detectConcordiumProvider() to get a reference to the wallet extension
Creates a new provider instance and caches it
Returns the instance (either new or existing)
This pattern prevents potential conflicts from multiple simultaneous connections to the wallet extension.
Now let’s implement the connect
method to initiate the wallet connection:
// Connect to the browser wallet
async connect(): Promise<string | undefined> {
// Request accounts and update state
const new_connected_account = (await this.provider.requestAccounts())[0];
super.onAccountChanged(new_connected_account ?? undefined);
return new_connected_account;
}
The connect()
method:
Calls
requestAccounts()
on the wallet provider, which prompts the user to select an accountTakes the first account from the returned array
Updates our internal state by calling
onAccountChanged
Returns the selected account address
For requesting zero-knowledge proofs, we implement a simple pass-through method:
// Request ZK proof directly from browser wallet
async requestVerifiablePresentation(
challenge: HexString,
statement: CredentialStatements,
): Promise<VerifiablePresentation> {
return this.provider.requestVerifiablePresentation(challenge, statement);
}
The requestVerifiablePresentation() method directly calls the browser wallet’s implementation:
It passes through the challenge and statements without modification
The wallet extension shows a UI to the user for approving the ZK proof generation
The wallet handles all the complex cryptography required
The method returns the verifiable presentation containing the proof
This implementation demonstrates how the Concordium Browser Wallet extension simplifies dApp development by handling the complex cryptographic operations while exposing a straightforward API for wallet interactions, account management, and ZK proof generation.
Mobile wallet implementation#
For mobile wallets, we implement the connection using WalletConnect. Let’s start with the basic class structure and constructor:
// From wallet-connection.tsx
let walletConnectInstance: WalletConnectProvider | undefined;
export class WalletConnectProvider extends WalletProvider {
// Track the WalletConnect session topic
private topic: string | undefined;
constructor(private client: SignClient) {
super();
// Set up event handlers for session changes
this.client.on("session_update", ({ params }) => {
// Update connected account when session changes
this.connectedAccount = this.getAccount(params.namespaces);
super.onAccountChanged(this.connectedAccount);
});
this.client.on("session_delete", () => {
// Clear state when session is deleted
this.connectedAccount = undefined;
this.topic = undefined;
super.onAccountChanged(this.connectedAccount);
});
}
}
The constructor takes a SignClient
instance from WalletConnect and sets up event listeners:
session_update
: Called when the session information changes, allowing us to update the connected accountsession_delete
: Called when the session is deleted by the wallet, allowing us to clear our state
We also define a private topic
property to track the active WalletConnect session ID.
Next, let’s implement the singleton pattern:
// Singleton pattern - ensure only one instance exists, allowing existing session to be restored
static async getInstance() {
if (walletConnectInstance === undefined) {
const client = await SignClient.init(walletConnectOpts);
walletConnectInstance = new WalletConnectProvider(client);
}
return walletConnectInstance;
}
The getInstance()
method ensures that only one WalletConnect provider exists throughout the application:
It initializes a new WalletConnect client with our configuration options if one doesn’t exist
It caches the provider instance for future use
This prevents potential issues with multiple concurrent connections
Now let’s implement the connection method:
// from wallet-connection.tsx, connect to a mobile wallet via WalletConnect
async connect(): Promise<string | undefined> {
// Request connection with required methods and chains
const { uri, approval } = await this.client.connect({
requiredNamespaces: {
["ccd"]: { // Concordium's identifier in WalletConnect
methods: ["request_verifiable_presentation"], // Methods supported by Concordium wallets
chains: ["ccd:testnet"], // For testnet use "ccd:testnet", for mainnet use "ccd:mainnet"
events: ["accounts_changed"],
},
},
});
// Connecting to an existing pairing; it can be assumed that the account is already available.
if (!uri) {
return this.connectedAccount;
}
// Open QRCode modal if a URI was returned (i.e. we're not connecting an existing pairing).
QRCodeModal.open(uri, undefined);
// Await session approval from the wallet and store session information
const session = await approval();
this.connectedAccount = this.getAccount(session.namespaces);
this.topic = session.topic;
QRCodeModal.close();
return this.connectedAccount;
}
The connect()
method establishes a connection with a mobile wallet:
It initiates a connection request with specific Concordium requirements:
Namespace:
ccd
(Concordium’s identifier in WalletConnect)Methods: Includes
request_verifiable_presentation
for requesting ZK proofsChains: The appropriate Concordium network (“ccd:testnet” for testnet or “ccd:mainnet” for mainnet)
Events: To listen for account changes
For new connections, it:
Displays a QR code for the user to scan with their mobile wallet
Waits asynchronously for the user to approve the connection
Extracts the connected account and stores the session information
Closes the QR code modal once connected
For existing connections, it simply returns the current account
Now let’s implement the ZK proof request method:
// Request ZK proof via WalletConnect
async requestVerifiablePresentation(
challenge: HexString,
statement: CredentialStatements,
): Promise<VerifiablePresentation> {
if (!this.topic) {
throw new Error("No connection");
}
// Prepare parameters for the request
const params = {
challenge,
credentialStatements: statement,
};
// Use JSONBigInt for proper handling of large numbers
const serializedParams = JSONBigInt.stringify(params);
// will continue in next code block
}
First, we check if we have an active connection by verifying the existence of a session topic. If no connection exists, we throw an error to prevent attempting to request a proof without a connected wallet.
The parameters for the ZK proof request include:
challenge
: A unique challenge string to prevent replay attacksCredentialStatements: The statements defining what should be proven
The ZK proof verification process involves two parties:
Prover: The wallet user who wants to prove something about their identity (in our case, the user with the Concordium account)
Verifier: The application or service that needs to validate the proof (our dApp or its backend)
It’s important to understand that the challenge must be generated by the verifier (not the prover) to prevent replay attacks. Here’s how the process works:
The verifier generates a unique challenge (in our implementation, we use a recent block hash combined with a context string)
The prover (wallet) creates a proof using this challenge
The verifier checks that:
The proof is cryptographically valid
The proof was created using the specific challenge it provided
The challenge has not been used in a previous proof
Note that protection against replay attacks isn’t automatic - the verifier must implement proper challenge validation, typically by:
Checking that the challenge includes a recent block hash (time-limiting the proof)
Tracking which challenges have been used in a database
Rejecting proofs with previously-used challenges
As an example, in the compliant-reward-distribution dApp, the verifier checks that the proof hasn’t expired by verifying that the block height is recent enough, but it doesn’t track which challenges have been used before. For complete security in a production environment, a backend would need to maintain a record of used challenges to fully protect against replay attacks.
We serialize these parameters using JSONBigInt
instead of standard JSON. This is important because ZK proofs often involve large numbers that standard JSON can’t handle correctly.
Now, let’s implement the actual request to the mobile wallet:
// Request ZK proof via WalletConnect
async requestVerifiablePresentation(
challenge: HexString,
statement: CredentialStatements,
): Promise<VerifiablePresentation> {
// code omitted from previous code block
try {
// Send request to the mobile wallet
const result = await this.client.request<{
verifiablePresentationJson: string;
}>({
topic: this.topic,
request: {
method: "request_verifiable_presentation",
params: { paramsJson: serializedParams },
},
chainId: "ccd:testnet", // Use "ccd:testnet" for testnet or "ccd:mainnet" for mainnet
});
// Parse the result into a VerifiablePresentation
return VerifiablePresentation.fromString(
result.verifiablePresentationJson,
);
}
The request is sent to the mobile wallet using the WalletConnect protocol. We specify:
topic
: The current session identifiermethod
: The Concordium-specificrequest_verifiable_presentation
method.params
: The serialized parameters wrapped in aparamsJson
fieldchainId
: The Concordium chain identifier (testnet or mainnet)
When the wallet responds with the generated proof, we parse the JSON string into a structured VerifiablePresentation
object using the fromString
method provided by Concordium’s SDK.
Finally, let’s handle potential errors:
catch (e) {
if (isWalletConnectError(e)) {
throw new Error(
"Generating proof request rejected in wallet: " + JSON.stringify(e),
);
}
throw e;
}
The error handling section checks for WalletConnect-specific errors using the isWalletConnectError
helper function. This allows us to provide more specific error messages when the proof request is rejected by the wallet.
For example, the user might deny the proof request in their mobile wallet, which would result in a WalletConnect error with a specific error code. We capture this and throw a more descriptive error message to improve the user experience.
Next, let’s add helper methods for session management. First, the disconnect
method:
// Disconnect from the wallet
async disconnect(): Promise<void> {
if (this.topic === undefined) {
return;
}
await this.client.disconnect({
topic: this.topic,
reason: {
code: 1,
message: "user disconnecting",
},
});
}
The disconnect()
method properly terminates the WalletConnect session:
It first checks if there’s an active session (topic)
If no session exists, it returns early without doing anything
Otherwise, it sends a disconnect request to the wallet using the WalletConnect client
The request includes the session topic and a reason with a standard error code (1) for user-initiated disconnection
After terminating the session with the wallet, we need to update our local state:
// Continuation of disconnect method
this.connectedAccount = undefined;
this.topic = undefined;
super.onAccountChanged(this.connectedAccount);
Once the wallet session is terminated:
We clear the
connectedAccount
property since no account is connected anymoreWe clear the
topic
property since there’s no active sessionWe call
onAccountChanged()
from our parent class to emit an event notifying the application that the account has changedThis ensures that UI components can update to reflect the disconnected state
Now, let’s implement the helper for extracting the account address:
// Helper to extract Concordium account from WalletConnect namespaces
private getAccount(ns: SessionTypes.Namespaces): string | undefined {
const [, , account] =
ns["ccd"].accounts[0].split(":"); // "ccd" is the Concordium namespace in WalletConnect
return account;
}
The getAccount()
method handles parsing Concordium account addresses from WalletConnect’s namespace format:
WalletConnect represents accounts in a standardized format:
namespace:chainId:address
For Concordium, this looks like
ccd:testnet:3XSLuJcXg6xEua6iBPnWacc3iWh93yEDMCqX8FbE3RDSbEnT9P
The method splits this string by colons and extracts the third element (the account address)
It uses array destructuring with empty positions to skip the namespace and chainId elements
This helper is used when processing session updates and initial connections
This implementation demonstrates the integration of WalletConnect
, providing a seamless connection to mobile wallets with support for Concordium’s unique zero-knowledge proof capabilities.
Connecting to a wallet in React#
Now let’s look at how to use these wallet providers in a React component. Let’s start with the basic component structure and hooks:
First, we import the necessary dependencies and set up our component. The key elements here are:
useWallet
hook: A custom hook that provides access to our wallet context, containing:
provider
: The current wallet provider instance
setProvider
: Function to update the provider
setConnectedAccount
: Function to update the connected account
useNavigate
: React Router’s hook for programmatic navigation
Next, let’s implement the connection function:
// Connect to selected wallet provider
const connectProvider = async (provider: WalletProvider) => {
const account = await provider.connect();
console.log("account", account);
if (account) {
setConnectedAccount(account);
}
setProvider(provider);
};
The connectProvider
function handles the wallet connection process:
It takes a wallet provider instance (Browser or Mobile)
It calls the provider’s
connect()
method, which prompts the user to select an accountIf an account is successfully connected, it updates the app’s state
Now, let’s add cleanup on component unmount:
// Clean up on unmount
useEffect(() => {
try {
if (provider) {
return () => {
provider?.disconnect?.().then(() => provider.removeAllListeners());
};
}
} catch (error) {
console.error("Error:", error);
}
}, [provider]);
This first useEffect
hook:
Runs when the component mounts or when the provider changes
Returns a cleanup function that:
Disconnects from the wallet
Removes all event listeners to prevent memory leaks
The dependency array
[provider]
ensures this effect runs whenever the provider changes
Next, let’s look at the second useEffect
that listens for account changes:
// Listen for account changes
useEffect(() => {
try {
const handleAccountChange = (newAccount: any) => {
setConnectedAccount(newAccount);
};
provider?.on("accountChanged", handleAccountChange);
return () => {
provider?.off("accountChanged", handleAccountChange);
};
} catch (error) {
console.error("Error:", error);
}
}, [provider]);
This second useEffect
hook:
Sets up an event listener for the provider’s
accountChanged
eventWhen the account changes in the wallet, it updates our app’s state
Also depends on the provider, so it runs whenever the provider changes
Finally, let’s render the wallet options:
return (
<Container className="connect-wallet-container text-center pt-2">
<h1 className="connect-wallet-title">Connect your wallet</h1>
{/* Browser Wallet Option */}
<Container
onClick={async (e) => {
connectProvider(await BrowserWalletProvider.getInstance());
}}
className="wallet-option p-4 cursor-pointer rounded-lg"
>
{/* UI elements for Browser Wallet */}
</Container>
{/* Mobile Wallet Option */}
<Container
onClick={async () => {
connectProvider(await WalletConnectProvider.getInstance());
}}
className="wallet-option p-4 rounded-lg cursor-pointer mt-2"
>
{/* UI elements for Mobile Wallet */}
</Container>
</Container>
);
The render function creates a simple UI with:
A heading that prompts the user to connect a wallet
Two clickable containers representing the wallet options:
Browser Wallet: Uses
BrowserWalletProvider.getInstance()
to get a provider instanceMobile Wallet: Uses
WalletConnectProvider.getInstance()
to get a provider instance
When clicked, each option calls the
connectProvider
function with the appropriate provider
This component demonstrates a clean implementation pattern for wallet connections in React:
It uses React Context from
frontend/src/context/WalletContext.tsx
to manage global wallet state.It properly handles component lifecycle with
useEffect
.It sets up and cleans up event listeners to prevent memory leaks.
It provides a user-friendly interface for selecting a wallet type.
It encapsulates all the complexity of wallet connection behind a simple abstraction.
By using this approach, we can maintain a consistent wallet connection experience throughout our application regardless of which wallet provider the user chooses.