Skip to main content

Connect to the ClearNode

A ClearNode is a specialized service that facilitates off-chain communication, message relay, and state validation in the Nitrolite ecosystem. This guide explains how to establish and manage connections to a ClearNode using the NitroliteRPC protocol.

What is a ClearNode?

A ClearNode is an implementation of a message broker for the Clearnet protocol. It serves as a critical infrastructure component in the Nitrolite ecosystem, providing several important functions in the state channel network:

  • Multi-Chain Support: Connect to multiple EVM blockchains (Polygon, Celo, Base)
  • Off-Chain Payments: Efficient payment channels for high-throughput transactions
  • Virtual Applications: Create multi-participant applications
  • Quorum-Based Signatures: Support for multi-signature schemes with weight-based quorums

Understanding NitroliteRPC

NitroliteRPC is a utility in our SDK that standardizes message creation for communication with ClearNodes. It's not a full protocol implementation but rather a set of helper functions that ensure your application constructs properly formatted messages for ClearNode interaction.

Key functions of NitroliteRPC include:

  • Message Construction: Creates properly formatted request messages
  • Signature Management: Handles the cryptographic signing of messages
  • Standard Format Enforcement: Ensures all messages follow the required format for ClearNode compatibility
  • Authentication Flow Helpers: Simplifies the authentication process

Under the hood, NitroliteRPC provides functions that generate message objects with the correct structure, timestamps, and signature formatting so you don't have to build these messages manually when communicating with ClearNodes.

Connecting to a ClearNode

After initializing your client and creating a channel, you need to establish a WebSocket connection to a ClearNode. It's important to understand that the Nitrolite SDK doesn't provide its own transport layer - you'll need to implement the WebSocket connection yourself using your preferred library.

// Import your preferred WebSocket library
import WebSocket from 'ws'; // Node.js
// or use the browser's built-in WebSocket

// Create a WebSocket connection to the ClearNode
const ws = new WebSocket('wss://clearnode.example.com');

// Set up basic event handlers
ws.onopen = () => {
console.log('WebSocket connection established');
// Connection is open, can now proceed with authentication
};

ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received message:', message);
// Process incoming messages
};

ws.onerror = (error) => {
console.error('WebSocket error:', error);
};

ws.onclose = (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
};

Authentication Flow

When connecting to a ClearNode, you need to follow a specific authentication flow using the NitroliteRPC utility to create properly formatted and signed messages:

  1. Initial Connection: The client establishes a WebSocket connection to the ClearNode's URL
  2. Auth Request: On the first connection client sends an auth_request message with its identity information
  3. Challenge: The ClearNode responds with an auth_challenge containing a random nonce
  4. Signature Verification: The client signs the challenge along with session key and allowances using EIP712 signature and sends an auth_verify message
  5. Auth Result: The ClearNode verifies the signature and responds with auth_success or auth_failure
  6. Reconnection: On success ClearNode will return the JWT Token, which can be used for subsequent reconnections without needing to re-authenticate.

This flow ensures that only authorized participants with valid signing keys can connect to the ClearNode and participate in channel operations.

import { createAuthRequestMessage, createAuthVerifyMessage } from '@erc7824/nitrolite';
import { ethers } from 'ethers';

// This is crucial for proper ClearNode communication
const eip712MessageSigner = async (payload) => {
try {
// Extract the challenge message from the data
const parsed = JSON.parse(data);
const challenge = parsed.challenge_message;

const walletAddress = walletClient.account.address;

const message = {
challenge: challenge,
scope: 'console',
wallet: '0xYourWalletAddress',
application: '0xYourApplicationAddress', // Your application address
participant: '0xYourSignerAddress', // The address of the signer
expire: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiration
allowances: [],
};

const signature = await walletClient.signTypedData({
account: walletClient.account!,
domain: {
name: 'Your Domain',
},
types: AUTH_TYPES,
primaryType: 'Policy',
message: message,
});

return signature;
} catch (error) {
console.error("Error signing message:", error);
throw error;
}
};

// After WebSocket connection is established
ws.onopen = async () => {
console.log('WebSocket connection established');

// Step 1: Create and send auth_request
const authRequestMsg = await createAuthRequestMessage({
wallet: '0xYourWalletAddress',
participant: '0xYourSignerAddress',
app_name: 'Your Domain',
expire: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiration
scope: 'console',
application: '0xYourApplicationAddress',
allowances: [],
});

ws.send(authRequestMsg);
};

// Handle incoming messages
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);

// Step 2: Handle auth_challenge response
if (message.res && message.res[1] === 'auth_challenge') {
console.log('Received auth challenge');

// Step 3: Create and send auth_verify with signed challenge
const authVerifyMsg = await createAuthVerifyMessage(
eip712MessageSigner, // Our custom eip712 signer function
event.data, // Raw challenge response from ClearNode
walletAddress // Client address (same as in auth_request)
);

ws.send(authVerifyMsg);
}
// Step 4: Handle auth_success or auth_failure
else if (message.res && message.res[1] === 'auth_success') {
console.log('Authentication successful');
// Now you can start using the channel

window.localStorage.setItem('clearnode_jwt', message.res[2][0]["jwt_token"]); // Store JWT token for future use
}
else if (message.res && message.res[1] === 'auth_failure') {
console.error('Authentication failed:', message.res[2]);
}
} catch (error) {
console.error('Error handling message:', error);
}
};

EIP-712 Signature

In the authentication process, the client must sign messages using EIP-712 structured data signatures. This ensures that the messages are tamper-proof and verifiable by the ClearNode.

The format of the EIP-712 message is as follows:

{
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" }
],
"Policy": [
{ "name": "challenge", "type": "string" },
{ "name": "scope", "type": "string" },
{ "name": "wallet", "type": "address" },
{ "name": "application", "type": "address" },
{ "name": "participant", "type": "address" },
{ "name": "expire", "type": "uint256" },
{ "name": "allowances", "type": "Allowances[]" }
],
"Allowance": [
{ "name": "asset", "type": "string" },
{ "name": "amount", "type": "uint256" }
]
},
// Domain and primary type
domain: {
name: 'Your Domain'
},
primaryType: 'Policy',
message: {
challenge: 'RandomChallengeString',
scope: 'console',
wallet: '0xYourWalletAddress',
application: '0xYourApplicationAddress',
participant: '0xYourSignerAddress',
expire: 100500,
allowances: []
}
}

Getting Channel Information

After authenticating with a ClearNode, you can request information about your channels. This is useful to verify your connection is working correctly and to retrieve channel data.

import { createGetChannelsMessage } from '@erc7824/nitrolite';

// Example of using the function after authentication is complete
ws.addEventListener('message', async (event) => {
const message = JSON.parse(event.data);

// Check if this is a successful authentication message
if (message.res && message.res[1] === 'auth_success') {
console.log('Successfully authenticated, requesting channel information...');

// Create a custom message signer function if you don't already have one
const messageSigner = async (payload) => {
// This is the same message signer function used in authentication
const message = JSON.stringify(payload);
const digestHex = ethers.id(message);
const messageBytes = ethers.getBytes(digestHex);
const { serialized: signature } = client.stateWalletClient.wallet.signingKey.sign(messageBytes);
return signature;
};

// Request channel information using the built-in helper function
const getChannelsMsg = await createGetChannelsMessage(
messageSigner,
client.stateWalletClient.account.address
);

ws.send(getChannelsMsg);
}

// Handle get_channels response
if (message.res && message.res[1] === 'get_channels') {
console.log('Received channels information:');
const channelsList = message.res[2][0]; // Note the response format has changed

if (channelsList && channelsList.length > 0) {
channelsList.forEach((channel, index) => {
console.log(`Channel ${index + 1}:`);
console.log(`- Channel ID: ${channel.channel_id}`);
console.log(`- Status: ${channel.status}`);
console.log(`- Participant: ${channel.participant}`);
console.log(`- Token: ${channel.token}`);
console.log(`- Amount: ${channel.amount}`);
console.log(`- Chain ID: ${channel.chain_id}`);
console.log(`- Adjudicator: ${channel.adjudicator}`);
console.log(`- Challenge: ${channel.challenge}`);
console.log(`- Nonce: ${channel.nonce}`);
console.log(`- Version: ${channel.version}`);
console.log(`- Created: ${channel.created_at}`);
console.log(`- Updated: ${channel.updated_at}`);
});
} else {
console.log('No active channels found');
}
}
});

Response Format

The response to a get_channels request includes detailed information about each channel:

{
"res": [1, "get_channels", [[ // Notice the nested array structure
{
"channel_id": "0xfedcba9876543210...",
"participant": "0x1234567890abcdef...",
"status": "open", // Can be "open", "closed", "settling", etc.
"token": "0xeeee567890abcdef...", // ERC20 token address
"amount": "100000", // Current channel balance
"chain_id": 137, // Chain ID (e.g., 137 for Polygon)
"adjudicator": "0xAdjudicatorContractAddress...", // Contract address
"challenge": 86400, // Challenge period in seconds
"nonce": 1,
"version": 2,
"created_at": "2023-05-01T12:00:00Z",
"updated_at": "2023-05-01T12:30:00Z"
}
]], 1619123456789],
"sig": ["0xabcd1234..."]
}

Framework-Specific Integration

Here are examples of integrating ClearNode WebSocket connections with various frameworks. Since the Nitrolite SDK doesn't provide its own transport layer, these examples show how to implement WebSocket connections and the NitroliteRPC message format in different frameworks.

import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
import {
createAuthRequestMessage,
createAuthVerifyMessage,
createGetChannelsMessage,
createGetLedgerBalancesMessage,
createGetConfigMessage,
generateRequestId,
getCurrentTimestamp
} from '@erc7824/nitrolite';

// Custom hook for ClearNode connection
function useClearNodeConnection(clearNodeUrl, stateWallet) {
const [ws, setWs] = useState(null);
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [error, setError] = useState(null);

// Message signer function
const messageSigner = useCallback(async (payload) => {
if (!stateWallet) throw new Error('State wallet not available');

try {
const message = JSON.stringify(payload);
const digestHex = ethers.id(message);
const messageBytes = ethers.getBytes(digestHex);
const { serialized: signature } = stateWallet.signingKey.sign(messageBytes);
return signature;
} catch (error) {
console.error("Error signing message:", error);
throw error;
}
}, [stateWallet]);

// Create a signed request
const createSignedRequest = useCallback(async (method, params = []) => {
if (!stateWallet) throw new Error('State wallet not available');

const requestId = generateRequestId();
const timestamp = getCurrentTimestamp();
const requestData = [requestId, method, params, timestamp];
const request = { req: requestData };

// Sign the request
const message = JSON.stringify(request);
const digestHex = ethers.id(message);
const messageBytes = ethers.getBytes(digestHex);
const { serialized: signature } = stateWallet.signingKey.sign(messageBytes);
request.sig = [signature];

return JSON.stringify(request);
}, [stateWallet]);

// Send a message to the ClearNode
const sendMessage = useCallback((message) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
setError('WebSocket not connected');
return false;
}

try {
ws.send(typeof message === 'string' ? message : JSON.stringify(message));
return true;
} catch (error) {
setError(`Error sending message: ${error.message}`);
return false;
}
}, [ws]);

// Connect to the ClearNode
const connect = useCallback(() => {
if (ws) {
ws.close();
}

setConnectionStatus('connecting');
setError(null);

const newWs = new WebSocket(clearNodeUrl);

newWs.onopen = async () => {
setConnectionStatus('connected');

// Start authentication process
try {
const authRequest = await createAuthRequestMessage(
messageSigner,
stateWallet.address
);
newWs.send(authRequest);
} catch (err) {
setError(`Authentication request failed: ${err.message}`);
}
};

newWs.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);

// Handle authentication flow
if (message.res && message.res[1] === 'auth_challenge') {
try {
const authVerify = await createAuthVerifyMessage(
messageSigner,
message,
stateWallet.address
);
newWs.send(authVerify);
} catch (err) {
setError(`Authentication verification failed: ${err.message}`);
}
} else if (message.res && message.res[1] === 'auth_success') {
setIsAuthenticated(true);
} else if (message.res && message.res[1] === 'auth_failure') {
setIsAuthenticated(false);
setError(`Authentication failed: ${message.res[2]}`);
}

// Additional message handling can be added here
} catch (err) {
console.error('Error handling message:', err);
}
};

newWs.onerror = (error) => {
setError(`WebSocket error: ${error.message}`);
setConnectionStatus('error');
};

newWs.onclose = () => {
setConnectionStatus('disconnected');
setIsAuthenticated(false);
};

setWs(newWs);
}, [clearNodeUrl, messageSigner, stateWallet]);

// Disconnect from the ClearNode
const disconnect = useCallback(() => {
if (ws) {
ws.close();
setWs(null);
}
}, [ws]);

// Connect when the component mounts
useEffect(() => {
if (clearNodeUrl && stateWallet) {
connect();
}

// Clean up on unmount
return () => {
if (ws) {
ws.close();
}
};
}, [clearNodeUrl, stateWallet, connect]);

// Create helper methods for common operations
const getChannels = useCallback(async () => {
// Using the built-in helper function from NitroliteRPC
const message = await createGetChannelsMessage(
messageSigner,
stateWallet.address
);
return sendMessage(message);
}, [messageSigner, sendMessage, stateWallet]);

const getLedgerBalances = useCallback(async (channelId) => {
// Using the built-in helper function from NitroliteRPC
const message = await createGetLedgerBalancesMessage(
messageSigner,
channelId
);
return sendMessage(message);
}, [messageSigner, sendMessage]);

const getConfig = useCallback(async () => {
// Using the built-in helper function from NitroliteRPC
const message = await createGetConfigMessage(
messageSigner,
stateWallet.address
);
return sendMessage(message);
}, [messageSigner, sendMessage, stateWallet]);

return {
connectionStatus,
isAuthenticated,
error,
ws,
connect,
disconnect,
sendMessage,
getChannels,
getLedgerBalances,
getConfig,
createSignedRequest
};
}

// Example usage in a component
function ClearNodeComponent() {
const stateWallet = /* your state wallet initialization */;
const {
connectionStatus,
isAuthenticated,
error,
getChannels
} = useClearNodeConnection('wss://clearnode.example.com', stateWallet);

return (
<div>
<p>Status: {connectionStatus}</p>
<p>Authenticated: {isAuthenticated ? 'Yes' : 'No'}</p>
{error && <p className="error">Error: {error}</p>}

<button
onClick={getChannels}
disabled={!isAuthenticated}
>
Get Channels
</button>
</div>
);
}

Security Considerations

When working with ClearNodes and state channels, keep these security best practices in mind:

  1. Secure State Wallet Storage: Properly encrypt and secure the private key for your state wallet
  2. Verify Message Signatures: Always verify that received messages have valid signatures from expected sources
  3. Monitor Connection Status: Implement monitoring to detect unexpected disconnections or authentication failures
  4. Implement Timeout Handling: Add timeouts for operations to prevent hanging on unresponsive connections
  5. Validate Channel States: Verify that channel states are valid before processing or saving them
  6. Use Secure WebSocket Connections: Always use wss:// (WebSocket Secure) for ClearNode connections, never ws://
  7. Implement Rate Limiting: Add protection against excessive message sending to prevent abuse

Troubleshooting Common Issues

IssuePossible CausesSolution
Connection timeoutNetwork latency, ClearNode unavailableImplement retry logic with exponential backoff
Authentication failureInvalid state wallet, incorrect signingVerify your state wallet is properly initialized and signing correctly
Frequent disconnectionsUnstable network, server-side issuesMonitor connection events and implement automatic reconnection
Message delivery failuresConnection issues, invalid message formatAdd message queuing and confirmation mechanism
Invalid signature errorsEIP-191 prefix issuesEnsure you're signing raw message bytes without the EIP-191 prefix

Next Steps

After successfully connecting to a ClearNode, you can:

  1. View and manage channel assets
  2. Create an application session