DApp-wallet Integration Development
Introduction
This article walks through implementing the wallet connection for a DApp on Hathor. Hathor official wallet applications (desktop and mobile) expose a JSON-RPC API over the Reown (WalletConnect) protocol. Your DApp uses the Reown SDK to establish a session with the wallet, then calls RPC methods to request data and submit transactions 1.
RPC methods quick reference
| Method | What it does |
|---|---|
htr_getAddress | Get the user's wallet address |
htr_getBalance | Get the user's balance for a token |
htr_getUtxos | Get the user's UTXOs |
htr_createToken | Ask the wallet to create a new token |
htr_signWithAddress | Ask the wallet to sign data with a specific address |
htr_sendNanoContractTx | Ask the wallet to assemble, sign, and submit a nano contract transaction |
For the complete API reference, see Hathor RPC lib API docs.
:::tip Start with the scaffold The fastest way to start a new DApp is with create-hathor-dapp, a bootstrap that sets up the WalletConnect integration, project structure, and boilerplate for you. The steps below explain what the scaffold sets up so you understand the integration end-to-end. :::
Integration steps
The following eight steps cover the full integration. Each section below details one step with a JavaScript example. At the end of this article, the bet demo DApp (TypeScript + Next.js) shows how these same steps look in a production-ready React context.
- Sign up to Reown cloud.
- Install packages.
- Create WalletConnect sign client.
- Create session proposal for wallet.
- Display pairing modal.
- Complete DApp-wallet pairing.
- Set up listeners.
- Send session requests.
Sign up to Reown cloud
DApp-wallet communication is mediated by Reown's relay server 2. To use it, create an account on Reown Cloud and register your project. You will receive a project ID that authenticates your DApp with the relay service.
Install packages
Reown provides packages for web, iOS, Android, Flutter, Unity, and more. For your platform:
Create WalletConnect sign client
When the user presses the "connect wallet" button, the first thing your DApp does is create the WalletConnect sign client. The client connects to the Reown relay server during initialization.
import SignClient from "@walletconnect/sign-client";
const signClient = await SignClient.init({
projectId: "<your_project_ID>",
relayUrl: "<your_chosen_relay_server>",
metadata: {
name: "Your DApp",
description: "Your DApp description",
url: "https://your-dapp.com",
icons: ["https://your-dapp.com/icon.png"],
},
});
Create session proposal for wallet
Once the client is connected to the relay, call signClient.connect() to propose a session to the wallet. This tells the relay which RPC methods and blockchain networks (chains) this session will use.
const { uri, approval } = await signClient.connect({
requiredNamespaces: {
hathor: {
methods: ['htr_signWithAddress', 'htr_sendNanoContractTx'],
chains: ['hathor:testnet'], // or 'hathor:mainnet'
events: []
},
},
});
The relay immediately returns two properties:
uri— the session URI. Display this as a QR code (or copy-paste link) so the user can connect from their wallet.approval— a promise that resolves when the user approves (or rejects) the pairing in their wallet app.
At this point the wallet is not yet connected and is not even aware of the session. The pairing is only complete after approval resolves.
Display pairing modal
Display the modal so the user can scan the QR code or use the deep link to connect their wallet. The Reown modal handles this for you:
import { WalletConnectModal } from '@walletconnect/modal';
// Create modal
const walletConnectModal = new WalletConnectModal({
projectId: "<your_project_ID>",
standaloneChains: ['hathor:testnet']
});
// Display modal
if (uri) {
walletConnectModal.openModal({ uri });
}
The modal presents:
- A QR code for the user to scan from their mobile wallet.
- A copy URI button as an alternative to QR scanning.
- A wallet list — deep links that open the wallet app directly (only works when DApp and wallet are on the same device).
Complete DApp-wallet pairing
The user scans the QR code, pastes the URI, or follows the deep link. Their wallet app then prompts them to approve or reject the pairing. Once approved, approval() resolves, the session is established, and your DApp is ready to send requests.
Full pairing example (sign client + modal + session)
import { WalletConnectModal } from ‘@walletconnect/modal’;
// Create modal
const walletConnectModal = new WalletConnectModal({
projectId: "<your_project_ID>",
standaloneChains: [‘hathor:testnet’]
});
// Pairing process
try {
const { uri, approval } = await signClient.connect({
requiredNamespaces: {
hathor: {
methods: [
‘htr_signWithAddress’,
‘htr_sendNanoContractTx’
],
chains: [‘hathor:testnet’],
events: []
}
}
});
if (uri) {
walletConnectModal.openModal({ uri });
// Wait for the user to approve on their wallet
const session = await approval();
// Update UI to "connected" state
onSessionConnect(session);
walletConnectModal.closeModal();
}
} catch (e) {
console.error(e);
}
Set up listeners
Register event listeners after the session is established to handle wallet-initiated events — like the user disconnecting their wallet or switching networks:
signClient.on('session_event', ({ event }) => {
// Handle session events defined in requiredNamespaces
});
signClient.on('session_update', ({ topic, params }) => {
const { namespaces } = params;
const _session = signClient.session.get(topic);
// Overwrite the `namespaces` of the existing session with the incoming one.
const updatedSession = { ..._session, namespaces };
// Integrate the updated session state into your dapp state.
onSessionUpdate(updatedSession);
});
signClient.on('session_delete', () => {
// Session was deleted -> reset the dapp state, clean up from user session, etc.
//Example — clean DApp state
resetAppState();
//Example — notify user
alert('Session has been closed. Please reconnect your wallet.');
});
Session events:
session_event— fired when a wallet event defined inrequiredNamespaces.eventsoccurs (e.g. the user switched networks or wallets).session_update— fired when the session namespace changes (e.g. new methods or chains added). Merge the new namespaces into your session state.session_delete— fired when the wallet ends the session. Reset your DApp to a disconnected state.
For all available events, see Session events at Reown docs.
Send session requests
With the session established, your DApp sends JSON-RPC requests to the wallet using signClient.request(). A single user operation may chain several calls — for example: get the user's address → check balance → call htr_sendNanoContractTx. The final step of every user operation is always a nano contract execution.
When the wallet receives an htr_sendNanoContractTx request, it assembles the transaction, prompts the user to authorize it, then signs and submits it to Hathor Network.
The basic request shape:
await signClient.request({
topic: session.topic, // identifies the active session
chainId: "hathor:testnet",
request: {
method: "htr_sendNanoContractTx",
id: '1',
jsonrpc: '2.0',
params: {
// params vary by method — see the full example below
}
}
});
signClient.request() parameters:
topic— identifies the session on the relay. Obtained from thesessionobject after pairing.chainId— the blockchain network:hathor:mainnet,hathor:testnet, orhathor:privatenet.request— a JSON-RPC 2.0 payload:jsonrpc,id,method, andparams.
:::tip Two levels of method
There are two fields named method in htr_sendNanoContractTx calls. request.method is the RPC method name (e.g. "htr_sendNanoContractTx"). request.params.method is the nano contract method to execute (e.g. "bet"). They are different things at different levels.
:::
Full example — executing a nano contract (betting DApp)
This example assumes a sports betting DApp where each event is a nano contract. The user (bettor) has already connected their wallet and is placing a bet.
await signClient.request({
topic: session.topic,
chainId: "hathor:mainnet",
request: {
method: "htr_sendNanoContractTx",
id: '1',
jsonrpc: '2.0',
params: {
method: "bet", // nano contract method to call
nc_id: "000000368cc0b54e45fd193e50ef47bdb1699a15b22732be72dff867c4c78520",
actions: [
{
type: "deposit",
token: "00000000179feb9d31e1ded6592099d1820100bf0a1fc20267a5ab2d8b914353",
amount: 10000 // amount in smallest unit
}
],
args: [
"H9L7do74fkLF7VHa662a4huGni1zKW4EGZ", // bettor address (base58check)
"real-madrid2x0barcelona" // outcome the user is betting on
],
push_tx: true, // true = wallet submits; false = wallet returns tx to DApp
}
}
});
htr_sendNanoContractTx params for contract execution:
nc_id— the on-chain contract ID (32 bytes hex) to execute.method— the contract method to call.actions— deposits or withdrawals that result from the execution. Each action specifies a token and an amount.args— arguments passed to the contract method.push_tx—truemeans the wallet submits the signed transaction directly to Hathor Network.falsemeans the wallet returns the signed transaction to your DApp, which then handles submission.
React contexts
The following examples show how to structure the integration in a React application. The first is a minimal generic example; the remaining three are taken directly from the bet demo DApp (TypeScript + Next.js).
A recommended pattern is to initialize the sign client once inside a React context and make it available to the entire component tree. This avoids creating multiple connections and keeps session state centralized.
WalletConnectProvider.js
import React, { createContext, useContext, useEffect, useState } from "react";
import SignClient from "@walletconnect/sign-client";
const WalletConnectContext = createContext();
export const useWalletConnect = () => useContext(WalletConnectContext);
export const WalletConnectProvider = ({ children }) => {
const [client, setClient] = useState(null);
useEffect(() => {
const initClient = async () => {
try {
const _client = await SignClient.init({
projectId: "<your_project_ID>",
relayUrl: "<your_chosen_relay_server>",
metadata: {
name: "Your DApp",
description: "Your DApp description",
url: "https://your-dapp.com",
icons: ["https://your-dapp.com/icon.png"],
},
});
setClient(_client);
} catch (error) {
console.error("Error initializing WalletConnect client:", error);
}
};
initClient();
}, []);
return (
<WalletConnectContext.Provider value={{ client }}>
{children}
</WalletConnectContext.Provider>
);
};
The following modules show the real implementation from the bet demo DApp. The client is created as a singleton so the app does not open multiple relay connections:
bet-dapp/src/contexts/WalletConnectClient.ts
import Client from '@walletconnect/sign-client';
import {
DEFAULT_APP_METADATA,
DEFAULT_LOGGER,
DEFAULT_PROJECT_ID,
DEFAULT_RELAY_URL,
} from '@/constants';
let client: Client | null = null;
export async function initializeClient(): Promise<Client> {
if (client) {
return client;
}
try {
client = await Client.init({
logger: DEFAULT_LOGGER,
relayUrl: DEFAULT_RELAY_URL,
projectId: DEFAULT_PROJECT_ID,
metadata: DEFAULT_APP_METADATA,
});
return client;
} catch (error) {
console.error('Failed to initialize WalletConnect client:', error);
throw error;
}
}
export function getClient(): Client {
if (!client) {
throw new Error('WalletConnect client is not initialized');
}
return client;
}
The previous module creates the client and a React context to maintain its state throughout the application. The following module shows how the context provider was implemented:
bet-dapp/src/contexts/WalletConnectClientContext.ts
import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import Client from '@walletconnect/sign-client';
import { PairingTypes, SessionTypes } from '@walletconnect/types';
import { Web3Modal } from '@web3modal/standalone';
import { getSdkError } from '@walletconnect/utils';
import { get } from 'lodash';
import { DEFAULT_PROJECT_ID } from '@/constants';
import { getClient } from './WalletConnectClient';
interface IContext {
client: Client | undefined;
session: SessionTypes.Struct | undefined;
connect: (pairing?: { topic: string }) => Promise<void>;
disconnect: () => Promise<void>;
chains: string[];
pairings: PairingTypes.Struct[];
accounts: string[];
setChains: React.Dispatch<React.SetStateAction<string[]>>;
getFirstAddress: () => string;
}
const WalletConnectClientContext = createContext<IContext>({} as IContext);
const web3Modal = new Web3Modal({
projectId: DEFAULT_PROJECT_ID,
themeMode: 'dark',
walletConnectVersion: 2,
});
export function WalletConnectClientContextProvider({
children,
}: {
children: ReactNode | ReactNode[];
}) {
const [client, setClient] = useState<Client>();
const [pairings, setPairings] = useState<PairingTypes.Struct[]>([]);
const [session, setSession] = useState<SessionTypes.Struct>();
const [accounts, setAccounts] = useState<string[]>([]);
const [chains, setChains] = useState<string[]>([]);
const reset = () => {
setSession(undefined);
setAccounts([]);
setChains([]);
};
const onSessionConnected = useCallback(
async (_session: SessionTypes.Struct) => {
const allNamespaceAccounts = Object.values(_session.namespaces)
.map((namespace) => namespace.accounts)
.flat();
const allNamespaceChains = Object.keys(_session.namespaces);
setSession(_session);
setChains(allNamespaceChains);
setAccounts(allNamespaceAccounts);
},
[]
);
const getFirstAddress = useCallback(() => {
const [_, _network, addr] = get(session, 'namespaces.hathor.accounts[0]', '::').split(':');
return addr;
}, [session]);
const _subscribeToEvents = useCallback(
async (_client: Client) => {
if (!_client) {
throw new Error('WalletConnect is not initialized');
}
_client.on('session_ping', (args) => {
console.log('EVENT', 'session_ping', args);
});
_client.on('session_event', (args) => {
console.log('EVENT', 'session_event', args);
});
_client.on('session_update', ({ topic, params }) => {
console.log('EVENT', 'session_update', { topic, params });
const { namespaces } = params;
const _session = _client.session.get(topic);
const updatedSession = { ..._session, namespaces };
onSessionConnected(updatedSession);
});
_client.on('session_delete', () => {
console.log('EVENT', 'session_delete');
reset();
});
},
[onSessionConnected]
);
const _checkPersistedState = useCallback(
async (_client: Client) => {
if (!_client) {
throw new Error('WalletConnect is not initialized');
}
setPairings(_client.pairing.getAll({ active: true }));
if (session) return;
if (_client.session.length) {
const lastKeyIndex = _client.session.keys.length - 1;
const _session = _client.session.get(_client.session.keys[lastKeyIndex]);
await onSessionConnected(_session);
return _session;
}
},
[session, onSessionConnected]
);
useEffect(() => {
(async () => {
const client = getClient();
await _subscribeToEvents(client);
await _checkPersistedState(client);
setClient(client);
})();
}, [_subscribeToEvents, _checkPersistedState]);
const connect = useCallback(
async (pairing: { topic: string } | undefined) => {
if (!client) {
throw new Error('WalletConnect is not initialized');
}
if (session) {
return;
}
try {
const requiredNamespaces = {
'hathor': {
methods: ['htr_signWithAddress', 'htr_sendNanoContractTx'],
chains: ['hathor:testnet'],
events: [],
}
};
const { uri, approval } = await client.connect({
pairingTopic: pairing?.topic,
requiredNamespaces,
});
if (uri) {
const standaloneChains = Object.values(requiredNamespaces)
.map((namespace) => namespace.chains)
.flat() as string[];
web3Modal.openModal({ uri, standaloneChains });
}
const session = await approval();
await onSessionConnected(session);
setPairings(client.pairing.getAll({ active: true }));
} catch (e) {
console.error(e);
} finally {
web3Modal.closeModal();
}
},
[client, session, onSessionConnected]
);
const disconnect = useCallback(async () => {
if (!client) {
throw new Error('WalletConnect is not initialized');
}
if (!session) {
throw new Error('Session is not connected');
}
try {
await client.disconnect({
topic: session.topic,
reason: getSdkError('USER_DISCONNECTED'),
});
} catch (error) {
console.error('SignClient.disconnect failed:', error);
} finally {
reset();
}
}, [client, session]);
const value = useMemo(
() => ({
pairings,
accounts,
chains,
client,
session,
connect,
disconnect,
getFirstAddress,
setChains,
}),
[
pairings,
accounts,
chains,
client,
session,
connect,
disconnect,
getFirstAddress,
setChains,
]
);
return (
<WalletConnectClientContext.Provider value={value}>
{children}
</WalletConnectClientContext.Provider>
);
}
export function useWalletConnectClient() {
const context = useContext(WalletConnectClientContext);
if (context === undefined) {
throw new Error(
'useWalletConnectClient must be used within a WalletConnectClientContextProvider'
);
}
return context;
}
Finally, note that the JSON-RPC API is also made available in the DApp through a context:
bet-dapp/src/contexts/WalletConnectClient.ts
import { createContext, ReactNode, useCallback, useContext, useState } from 'react';
import { useWalletConnectClient } from './WalletConnectClientContext';
import {
SendNanoContractRpcRequest,
SendNanoContractTxResponse,
SignOracleDataResponse,
SignOracleDataRpcRequest,
} from 'hathor-rpc-handler-test';
import { HATHOR_TESTNET_CHAIN } from '../constants';
/**
* Types
*/
export interface IFormattedRpcResponse<T> {
method?: string;
address?: string;
valid: boolean;
result: T;
}
export interface IHathorRpc {
sendNanoContractTx: (ncTxRpcReq: SendNanoContractRpcRequest) => Promise<SendNanoContractTxResponse>;
signOracleData: (signOracleDataReq: SignOracleDataRpcRequest) => Promise<SignOracleDataResponse>;
}
export interface IContext {
ping: () => Promise<void>;
hathorRpc: IHathorRpc;
rpcResult?: IFormattedRpcResponse<SignOracleDataResponse | SendNanoContractTxResponse | string | null | undefined> | null;
isRpcRequestPending: boolean;
reset: () => void;
}
/**
* Context
*/
export const JsonRpcContext = createContext<IContext>({} as IContext);
/**
* Provider
*/
export function JsonRpcContextProvider({
children,
}: {
children: ReactNode | ReactNode[];
}) {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<IFormattedRpcResponse<SignOracleDataResponse | SendNanoContractTxResponse | string | null | undefined> | null>();
const { client, session } = useWalletConnectClient();
const reset = useCallback(() => {
setPending(false);
setResult(null);
}, []);
const ping = async () => {
if (typeof client === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session === 'undefined') {
throw new Error('Session is not connected');
}
try {
setPending(true);
let valid = false;
try {
await client.ping({ topic: session.topic });
valid = true;
} catch (e) {
valid = false;
}
// display result
setResult({
method: 'ping',
valid,
result: valid ? 'Ping succeeded' : 'Ping failed',
});
} catch (e) {
console.error(e);
setResult(null);
} finally {
setPending(false);
}
};
const walletClientGuard = () => {
if (client == null) {
throw new Error('WalletConnect is not initialized');
}
if (session == null) {
throw new Error('Session is not connected');
}
};
const hathorRpc = {
sendNanoContractTx: async (ncTxRpcReq: SendNanoContractRpcRequest): Promise<SendNanoContractTxResponse> => {
walletClientGuard();
try {
setPending(true);
const result: SendNanoContractTxResponse = await client!.request<SendNanoContractTxResponse>({
chainId: HATHOR_TESTNET_CHAIN,
topic: session!.topic,
request: ncTxRpcReq,
});
return result;
} catch (error: any) {
setResult({
valid: false,
result: error?.message ?? error,
});
// Still propagate the error
throw error;
} finally {
setPending(false);
}
},
signOracleData: async (signOracleDataReq: SignOracleDataRpcRequest): Promise<SignOracleDataResponse> => {
walletClientGuard();
try {
setPending(true);
const result: SignOracleDataResponse = await client!.request<SignOracleDataResponse>({
chainId: HATHOR_TESTNET_CHAIN,
topic: session!.topic,
request: signOracleDataReq,
});
return result;
} catch (error: any) {
setResult({
valid: false,
result: error?.message ?? error,
});
// Still propagate the error
throw error;
} finally {
setPending(false);
}
}
};
return (
<JsonRpcContext.Provider
value={{
ping,
hathorRpc,
rpcResult: result,
isRpcRequestPending: pending,
reset,
}}
>
{children}
</JsonRpcContext.Provider>
);
}
export function useJsonRpc() {
const context = useContext(JsonRpcContext);
if (context === undefined) {
throw new Error('useJsonRpc must be used within a JsonRpcContextProvider');
}
return context;
}
What's next?
Hathor RPC lib API docs at Github: for the comprehensive documentation of the JSON-RPC API.