diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f8e57ba
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# Nitrolite WebSocket URL for real-time communication
+# Replace with your actual Nitrolite WebSocket endpoint
+VITE_NITROLITE_WS_URL=wss://your-nitrolite-websocket-url
+
+# Example for development:
+# VITE_NITROLITE_WS_URL=wss://dev-nitrolite.example.com/ws
+
+# Example for production:
+# VITE_NITROLITE_WS_URL=wss://nitrolite.example.com/ws
\ No newline at end of file
diff --git a/README.md b/README.md
index a5e6547..d050e99 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,8 @@ In this workshop, we will use a sample content platform application as a practic
3. **Set up environment variables:**
Create a file named `.env.local` in the root of the project and add your Nitrolite WebSocket URL:
+ TODO: Specify the clearnode RPC or add link on how to set up a local Clearnode instance.
+
```env
# .env.local
VITE_NITROLITE_WS_URL=wss://your-rpc-endpoint.com/ws
diff --git a/docs/chapter-1-wallet-connect.md b/docs/chapter-1-wallet-connect.md
index e731e86..d7e4855 100644
--- a/docs/chapter-1-wallet-connect.md
+++ b/docs/chapter-1-wallet-connect.md
@@ -48,6 +48,7 @@ npm install viem
First, modify `src/components/PostList/PostList.tsx` to accept a prop `isWalletConnected` and use it to enable or disable a new "Sponsor" button.
```tsx
+// TODO: I would add an alias (`@/`) to the `tsconfig.json` to avoid relative imports and improve readability.
// filepath: src/components/PostList/PostList.tsx
import { type Post } from '../../data/posts';
import styles from './PostList.module.css';
@@ -176,6 +177,7 @@ Finally, modify `src/App.tsx` to manage the `walletClient` and `account` state,
// filepath: src/App.tsx
import { useState } from 'preact/hooks';
import { createWalletClient, custom, type Address, type WalletClient } from 'viem';
+// TODO: specify that we are using only the mainnet chain or move to a separate file for chains, to easily change it.
import { mainnet } from 'viem/chains';
import { PostList } from './components/PostList/PostList';
import { posts } from './data/posts';
@@ -201,6 +203,7 @@ export function App() {
const formatAddress = (address: Address) => `${address.slice(0, 6)}...${address.slice(-4)}`;
return (
+ // TODO: either use styles as a string or as an imported object. Because it looks strange to have different styles usage in neighboring code snippets
diff --git a/docs/chapter-2-ws-connection.md b/docs/chapter-2-ws-connection.md
index da3c797..c03b900 100644
--- a/docs/chapter-2-ws-connection.md
+++ b/docs/chapter-2-ws-connection.md
@@ -68,6 +68,7 @@ class WebSocketService {
};
this.socket.onmessage = (event) => {
try {
+ // TODO: It's better to use provided RPC parser from Nitrolite SDK, and since it parses the string -- do not use JSON.parse directly.
const data = JSON.parse(event.data);
this.messageListeners.forEach((listener) => listener(data));
} catch (error) {
@@ -79,6 +80,8 @@ class WebSocketService {
}
public send(method: string, params: any) {
+ // TODO: requestId increment might be dangerous (because of lack of mutex), consider using timestamp
+ // And use request constructor from Nitrolite SDK: NitroliteRPC.createRequest(...);
const payload = JSON.stringify({ jsonrpc: '2.0', id: this.requestId++, method, params });
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(payload);
else this.messageQueue.push(payload);
@@ -242,7 +245,9 @@ Before running the application, make sure to set the WebSocket URL in your `.env
```bash
# Nitrolite Configuration
+#TODO: Specify somewhere that this is a Clearnode RPC, that was used to open a channel
VITE_NITROLITE_WS_URL=wss://clearnet.yellow.com/ws
+# TODO: what is this URL?
VITE_NITROLITE_API_URL=http://localhost:8080
# Application Configuration
diff --git a/docs/chapter-3-session-auth.md b/docs/chapter-3-session-auth.md
index 6200efb..cbdf001 100644
--- a/docs/chapter-3-session-auth.md
+++ b/docs/chapter-3-session-auth.md
@@ -33,6 +33,8 @@ sequenceDiagram
App->>App: 9. Enable authenticated features
```
+TODO: add a separate diagram for the JWT re-authentication flow.
+
## Key Tasks
1. **Create Helper Utilities**: Build simple helpers for session keys and JWT storage.
@@ -52,6 +54,7 @@ npm install @erc7824/nitrolite
### 2. Create Helper Utilities
Create `src/lib/utils.ts` with session key generation and JWT helpers:
+TODO: explain what is a session key and why we need it. Or leave url to the docs.
```typescript
// filepath: src/lib/utils.ts
@@ -287,6 +290,7 @@ useEffect(() => {
webSocketService.send(payload);
});
}
+// TODO: too much dependencies, it might cause message spam (e.g. when session key and account become true at the same time)
}, [account, sessionKey, wsStatus, isAuthenticated, isAuthAttempted]);
```
@@ -305,6 +309,7 @@ useEffect(() => {
response.method === RPCMethod.AuthChallenge &&
walletClient &&
sessionKey &&
+ // TODO: account is not needed here
account &&
sessionExpireTimestamp
) {
@@ -347,6 +352,7 @@ useEffect(() => {
webSocketService.addMessageListener(handleMessage);
return () => webSocketService.removeMessageListener(handleMessage);
+ // Again, too many dependencies might cause unpredictable behavior, consider adding message listener only once on mount (perhaps use ref)
}, [walletClient, sessionKey, sessionExpireTimestamp, account]);
```
diff --git a/docs/chapter-4-display-balances.md b/docs/chapter-4-display-balances.md
new file mode 100644
index 0000000..20bd803
--- /dev/null
+++ b/docs/chapter-4-display-balances.md
@@ -0,0 +1,410 @@
+# Chapter 4: Fetching Balances with the Session Key
+
+## Goal
+
+Now that we have an authenticated session, we can perform actions on behalf of the user without prompting them for another signature. In this chapter, we will use the temporary **session key** to sign and send our first request: fetching the user's off-chain asset balances from the Nitrolite RPC.
+
+## Why This Approach?
+
+This is the payoff for the setup in Chapter 3. By using the session key's private key (which our application holds in memory), we can create a `signer` function that programmatically signs requests. This enables a seamless, Web2-like experience where data can be fetched and actions can be performed instantly after the initial authentication.
+
+## Interaction Flow
+
+This diagram illustrates how the authenticated session key is used to fetch data directly from ClearNode.
+
+```mermaid
+sequenceDiagram
+ participant App as "Our dApp"
+ participant WSS as "WebSocketService"
+ participant ClearNode as "ClearNode"
+
+ App->>App: Authentication completes
+ App->>WSS: 1. Sends signed `get_ledger_balances` request
+ WSS->>ClearNode: 2. Forwards request via WebSocket
+ ClearNode->>ClearNode: 3. Verifies session signature & queries ledger
+ ClearNode-->>WSS: 4. Responds with `get_ledger_balances` response
+ WSS-->>App: 5. Receives balance data
+ App->>App: 6. Updates state and UI
+```
+
+## Key Tasks
+
+1. **Add Balance State**: Introduce a new state variable in `App.tsx` to store the fetched balances.
+2. **Create a `BalanceDisplay` Component**: Build a simple, reusable component to show the user's USDC balance.
+3. **Fetch Balances on Authentication**: Create a `useEffect` hook that automatically requests the user's balances as soon as `isAuthenticated` becomes `true`.
+4. **Use a Session Key Signer**: Use the `createECDSAMessageSigner` helper from the Nitrolite SDK to sign the request using the session key's private key.
+5. **Handle the Response**: Update the WebSocket message handler to parse both `get_ledger_balances` responses and automatic `BalanceUpdate` messages from the server.
+
+## Implementation Steps
+
+### 1. Create the `BalanceDisplay` Component
+
+Create a new file at `src/components/BalanceDisplay/BalanceDisplay.tsx`. This component will be responsible for showing the user's balance in the header.
+
+```tsx
+// filepath: src/components/BalanceDisplay/BalanceDisplay.tsx
+// CHAPTER 4: Balance display component
+import styles from './BalanceDisplay.module.css';
+
+interface BalanceDisplayProps {
+ balance: string | null;
+ symbol: string;
+}
+
+export function BalanceDisplay({ balance, symbol }: BalanceDisplayProps) {
+ // CHAPTER 4: Format balance for display
+ const formattedBalance = balance ? parseFloat(balance).toFixed(2) : '0.00';
+
+ return (
+
+ {formattedBalance}
+ {symbol}
+
+ );
+}
+```
+
+### 2. Update `App.tsx` to Fetch and Display Balances
+
+This is the final step. We'll add the logic to fetch and display the balances.
+
+TODO: consider continue using incremental approach, because right now there are too many lines of code
+and they are blending with previous chapters' changes.
+\+ consider moving balances logic into separate hook.
+\++ consider moving auth logic into separate hook.
+
+```tsx
+// filepath: src/App.tsx
+import { useState, useEffect } from 'preact/hooks';
+import { createWalletClient, custom, type Address, type WalletClient } from 'viem';
+import { mainnet } from 'viem/chains';
+import {
+ createAuthRequestMessage,
+ createAuthVerifyMessage,
+ createEIP712AuthMessageSigner,
+ NitroliteRPC,
+ RPCMethod,
+ type AuthChallengeResponse,
+ type AuthRequestParams,
+ // CHAPTER 4: Add balance fetching imports
+ createECDSAMessageSigner,
+ createGetLedgerBalancesMessage,
+ type GetLedgerBalancesResponse,
+ type BalanceUpdateResponse,
+} from '@erc7824/nitrolite';
+
+import { PostList } from './components/PostList/PostList';
+// CHAPTER 4: Import the new BalanceDisplay component
+import { BalanceDisplay } from './components/BalanceDisplay/BalanceDisplay';
+import { posts } from './data/posts';
+import { webSocketService, type WsStatus } from './lib/websocket';
+import {
+ generateSessionKey,
+ getStoredSessionKey,
+ storeSessionKey,
+ removeSessionKey,
+ storeJWT,
+ removeJWT,
+ type SessionKey,
+} from './lib/utils';
+
+const SESSION_DURATION_SECONDS = 3600; // 1 hour
+
+export function App() {
+ const [account, setAccount] = useState(null);
+ const [walletClient, setWalletClient] = useState(null);
+ const [wsStatus, setWsStatus] = useState('Disconnected');
+ const [sessionKey, setSessionKey] = useState(null);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [isAuthAttempted, setIsAuthAttempted] = useState(false);
+ // CHAPTER 4: Add balance state to store fetched balances
+ const [balances, setBalances] = useState | null>(null);
+ // CHAPTER 4: Add loading state for better user experience
+ const [isLoadingBalances, setIsLoadingBalances] = useState(false);
+
+ // Initialize WebSocket connection and session key (from previous chapters)
+ useEffect(() => {
+ const existingSessionKey = getStoredSessionKey();
+ if (existingSessionKey) {
+ setSessionKey(existingSessionKey);
+ } else {
+ const newSessionKey = generateSessionKey();
+ storeSessionKey(newSessionKey);
+ setSessionKey(newSessionKey);
+ }
+
+ webSocketService.addStatusListener(setWsStatus);
+ webSocketService.connect();
+
+ return () => {
+ webSocketService.removeStatusListener(setWsStatus);
+ };
+ }, []);
+
+ // Auto-trigger authentication when conditions are met (from Chapter 3)
+ useEffect(() => {
+ if (account && sessionKey && wsStatus === 'Connected' && !isAuthenticated && !isAuthAttempted) {
+ setIsAuthAttempted(true);
+ const expireTimestamp = String(Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS);
+
+ const authParams: AuthRequestParams = {
+ wallet: account,
+ participant: sessionKey.address,
+ app_name: 'Nexus',
+ expire: expireTimestamp,
+ scope: 'nexus.app',
+ application: account,
+ allowances: [],
+ };
+
+ createAuthRequestMessage(authParams).then((payload) => {
+ webSocketService.send(payload);
+ });
+ }
+ }, [account, sessionKey, wsStatus, isAuthenticated, isAuthAttempted]);
+
+ // CHAPTER 4: Automatically fetch balances when user is authenticated
+ // This useEffect hook runs whenever authentication status, sessionKey, or account changes
+ useEffect(() => {
+ // Only proceed if all required conditions are met:
+ // 1. User has completed authentication
+ // 2. We have a session key (temporary private key for signing)
+ // 3. We have the user's wallet address
+ if (isAuthenticated && sessionKey && account) {
+ console.log('Authenticated! Fetching ledger balances...');
+
+ // CHAPTER 4: Show loading state while we fetch balances
+ setIsLoadingBalances(true);
+
+ // CHAPTER 4: Create a "signer" - this is what signs our requests without user popups
+ // Think of this like a temporary stamp that proves we're allowed to make requests
+ const sessionSigner = createECDSAMessageSigner(sessionKey.privateKey);
+
+ // CHAPTER 4: Create a signed request to get the user's asset balances
+ // This is like asking "What's in my wallet?" but with cryptographic proof
+ createGetLedgerBalancesMessage(sessionSigner, account)
+ .then((getBalancesPayload) => {
+ // Send the signed request through our WebSocket connection
+ console.log('Sending balance request...');
+ webSocketService.send(getBalancesPayload);
+ })
+ .catch((error) => {
+ console.error('Failed to create balance request:', error);
+ setIsLoadingBalances(false); // Stop loading on error
+ // In a real app, you might show a user-friendly error message here
+ });
+ }
+ // TODO: again, these dependencies are very similar and become true at the same time, consider using a single effect
+ }, [isAuthenticated, sessionKey, account]);
+
+ // This effect handles all incoming WebSocket messages.
+ useEffect(() => {
+ const handleMessage = async (data: any) => {
+ // TODO: make data as a string and avoid stringifiying and parsing it multiple times
+ const rawEventData = JSON.stringify(data);
+ // TODO: this method is outdated, use rpcResponseParser.authChallenge
+ const response = NitroliteRPC.parseResponse(rawEventData);
+
+ // TODO: that's not a method from the previous chapter (previous was better)
+ // Handle auth challenge (from Chapter 3)
+ if (response.method === RPCMethod.AuthChallenge && walletClient && sessionKey && account) {
+ const challengeResponse = response as AuthChallengeResponse;
+ const expireTimestamp = String(Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS);
+
+ const authParams = {
+ scope: 'nexus.app',
+ application: walletClient.account?.address as `0x${string}`,
+ participant: sessionKey.address as `0x${string}`,
+ expire: expireTimestamp,
+ allowances: [],
+ };
+
+ const eip712Signer = createEIP712AuthMessageSigner(walletClient, authParams, {
+ name: 'Nexus',
+ });
+
+ try {
+ const authVerifyPayload = await createAuthVerifyMessage(eip712Signer, challengeResponse);
+ webSocketService.send(authVerifyPayload);
+ } catch (error) {
+ alert('Signature rejected. Please try again.');
+ setIsAuthAttempted(false);
+ }
+ }
+
+ // Handle auth success (from Chapter 3)
+ if (response.method === RPCMethod.AuthVerify && response.params?.success) {
+ setIsAuthenticated(true);
+ if (response.params.jwtToken) storeJWT(response.params.jwtToken);
+ }
+
+ // CHAPTER 4: Handle balance responses (when we asked for balances)
+ if (response.method === RPCMethod.GetLedgerBalances) {
+ // TODO: usually linter will know the type of response, so manually casting is not needed
+ const balanceResponse = response as GetLedgerBalancesResponse;
+ console.log('Received balance response:', balanceResponse.params);
+
+ // Check if we actually got balance data back
+ if (balanceResponse.params && balanceResponse.params.length > 0) {
+ // CHAPTER 4: Transform the data for easier use in our UI
+ // Convert from: [{asset: "usdc", amount: "100"}, {asset: "eth", amount: "0.5"}]
+ // To: {"usdc": "100", "eth": "0.5"}
+ const balancesMap = Object.fromEntries(
+ balanceResponse.params.map((balance) => [balance.asset, balance.amount]),
+ );
+ console.log('Setting balances:', balancesMap);
+ setBalances(balancesMap);
+ } else {
+ console.log('No balance data received - wallet appears empty');
+ setBalances({});
+ }
+ // CHAPTER 4: Stop loading once we receive any balance response
+ setIsLoadingBalances(false);
+ }
+
+ // CHAPTER 4: Handle live balance updates (server pushes these automatically)
+ if (response.method === RPCMethod.BalanceUpdate) {
+ const balanceUpdate = response as BalanceUpdateResponse;
+ console.log('Live balance update received:', balanceUpdate.params);
+
+ // Same data transformation as above
+ const balancesMap = Object.fromEntries(
+ balanceUpdate.params.map((balance) => [balance.asset, balance.amount]),
+ );
+ console.log('Updating balances in real-time:', balancesMap);
+ setBalances(balancesMap);
+ }
+
+ // Handle errors (from Chapter 3)
+ if (response.method === RPCMethod.Error) {
+ // TODO: that's just very "panicish" error handling, consider using a more user-friendly approach
+ removeJWT();
+ removeSessionKey();
+ alert(`Authentication failed: ${response.params.error}`);
+ setIsAuthAttempted(false);
+ }
+ };
+
+ webSocketService.addMessageListener(handleMessage);
+ return () => webSocketService.removeMessageListener(handleMessage);
+ }, [walletClient, sessionKey, account]);
+
+ const connectWallet = async () => {
+ if (!window.ethereum) {
+ alert('Please install MetaMask!');
+ return;
+ }
+
+ const tempClient = createWalletClient({
+ chain: mainnet,
+ transport: custom(window.ethereum),
+ });
+ const [address] = await tempClient.requestAddresses();
+
+ const walletClient = createWalletClient({
+ account: address,
+ chain: mainnet,
+ transport: custom(window.ethereum),
+ });
+
+ setWalletClient(walletClient);
+ setAccount(address);
+ };
+
+ const formatAddress = (address: Address) => {
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
+ };
+
+ return (
+