|
| 1 | +# Chapter 4: Fetching Balances with the Session Key |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +## Why This Approach? |
| 8 | + |
| 9 | +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. |
| 10 | + |
| 11 | +## Interaction Flow |
| 12 | + |
| 13 | +This diagram illustrates how the authenticated session key is used to fetch data directly from ClearNode. |
| 14 | + |
| 15 | +```mermaid |
| 16 | +sequenceDiagram |
| 17 | + participant App as "Our dApp" |
| 18 | + participant WSS as "WebSocketService" |
| 19 | + participant ClearNode as "ClearNode" |
| 20 | +
|
| 21 | + App->>App: Authentication completes |
| 22 | + App->>WSS: 1. Sends signed `get_ledger_balances` request |
| 23 | + WSS->>ClearNode: 2. Forwards request via WebSocket |
| 24 | + ClearNode->>ClearNode: 3. Verifies session signature & queries ledger |
| 25 | + ClearNode-->>WSS: 4. Responds with `get_ledger_balances` response |
| 26 | + WSS-->>App: 5. Receives balance data |
| 27 | + App->>App: 6. Updates state and UI |
| 28 | +``` |
| 29 | + |
| 30 | +## Key Tasks |
| 31 | + |
| 32 | +1. **Add Balance State**: Introduce a new state variable in `App.tsx` to store the fetched balances. |
| 33 | +2. **Create a `BalanceDisplay` Component**: Build a simple, reusable component to show the user's USDC balance. |
| 34 | +3. **Fetch Balances on Authentication**: Create a `useEffect` hook that automatically requests the user's balances as soon as `isAuthenticated` becomes `true`. |
| 35 | +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. |
| 36 | +5. **Handle the Response**: Update the WebSocket message handler to parse both `get_ledger_balances` responses and automatic `BalanceUpdate` messages from the server. |
| 37 | + |
| 38 | +## Implementation Steps |
| 39 | + |
| 40 | +### 1. Create the `BalanceDisplay` Component |
| 41 | + |
| 42 | +Create a new file at `src/components/BalanceDisplay/BalanceDisplay.tsx`. This component will be responsible for showing the user's balance in the header. |
| 43 | + |
| 44 | +```tsx |
| 45 | +// filepath: src/components/BalanceDisplay/BalanceDisplay.tsx |
| 46 | +// CHAPTER 4: Balance display component |
| 47 | +import styles from './BalanceDisplay.module.css'; |
| 48 | + |
| 49 | +interface BalanceDisplayProps { |
| 50 | + balance: string | null; |
| 51 | + symbol: string; |
| 52 | +} |
| 53 | + |
| 54 | +export function BalanceDisplay({ balance, symbol }: BalanceDisplayProps) { |
| 55 | + // CHAPTER 4: Format balance for display |
| 56 | + const formattedBalance = balance ? parseFloat(balance).toFixed(2) : '0.00'; |
| 57 | + |
| 58 | + return ( |
| 59 | + <div className={styles.balanceContainer}> |
| 60 | + <span className={styles.balanceAmount}>{formattedBalance}</span> |
| 61 | + <span className={styles.balanceSymbol}>{symbol}</span> |
| 62 | + </div> |
| 63 | + ); |
| 64 | +} |
| 65 | +``` |
| 66 | + |
| 67 | +### 2. Update `App.tsx` to Fetch and Display Balances |
| 68 | + |
| 69 | +This is the final step. We'll add the logic to fetch and display the balances. |
| 70 | + |
| 71 | +```tsx |
| 72 | +// filepath: src/App.tsx |
| 73 | +import { useState, useEffect } from 'preact/hooks'; |
| 74 | +import { createWalletClient, custom, type Address, type WalletClient } from 'viem'; |
| 75 | +import { mainnet } from 'viem/chains'; |
| 76 | +import { |
| 77 | + createAuthRequestMessage, |
| 78 | + createAuthVerifyMessage, |
| 79 | + createEIP712AuthMessageSigner, |
| 80 | + NitroliteRPC, |
| 81 | + RPCMethod, |
| 82 | + type AuthChallengeResponse, |
| 83 | + type AuthRequestParams, |
| 84 | + // CHAPTER 4: Add balance fetching imports |
| 85 | + createECDSAMessageSigner, |
| 86 | + createGetLedgerBalancesMessage, |
| 87 | + type GetLedgerBalancesResponse, |
| 88 | + type BalanceUpdateResponse, |
| 89 | +} from '@erc7824/nitrolite'; |
| 90 | + |
| 91 | +import { PostList } from './components/PostList/PostList'; |
| 92 | +// CHAPTER 4: Import the new BalanceDisplay component |
| 93 | +import { BalanceDisplay } from './components/BalanceDisplay/BalanceDisplay'; |
| 94 | +import { posts } from './data/posts'; |
| 95 | +import { webSocketService, type WsStatus } from './lib/websocket'; |
| 96 | +import { |
| 97 | + generateSessionKey, |
| 98 | + getStoredSessionKey, |
| 99 | + storeSessionKey, |
| 100 | + removeSessionKey, |
| 101 | + storeJWT, |
| 102 | + removeJWT, |
| 103 | + type SessionKey, |
| 104 | +} from './lib/utils'; |
| 105 | + |
| 106 | +const SESSION_DURATION_SECONDS = 3600; // 1 hour |
| 107 | + |
| 108 | +export function App() { |
| 109 | + const [account, setAccount] = useState<Address | null>(null); |
| 110 | + const [walletClient, setWalletClient] = useState<WalletClient | null>(null); |
| 111 | + const [wsStatus, setWsStatus] = useState<WsStatus>('Disconnected'); |
| 112 | + const [sessionKey, setSessionKey] = useState<SessionKey | null>(null); |
| 113 | + const [isAuthenticated, setIsAuthenticated] = useState(false); |
| 114 | + const [isAuthAttempted, setIsAuthAttempted] = useState(false); |
| 115 | + // CHAPTER 4: Add balance state to store fetched balances |
| 116 | + const [balances, setBalances] = useState<Record<string, string> | null>(null); |
| 117 | + // CHAPTER 4: Add loading state for better user experience |
| 118 | + const [isLoadingBalances, setIsLoadingBalances] = useState(false); |
| 119 | + |
| 120 | + // Initialize WebSocket connection and session key (from previous chapters) |
| 121 | + useEffect(() => { |
| 122 | + const existingSessionKey = getStoredSessionKey(); |
| 123 | + if (existingSessionKey) { |
| 124 | + setSessionKey(existingSessionKey); |
| 125 | + } else { |
| 126 | + const newSessionKey = generateSessionKey(); |
| 127 | + storeSessionKey(newSessionKey); |
| 128 | + setSessionKey(newSessionKey); |
| 129 | + } |
| 130 | + |
| 131 | + webSocketService.addStatusListener(setWsStatus); |
| 132 | + webSocketService.connect(); |
| 133 | + |
| 134 | + return () => { |
| 135 | + webSocketService.removeStatusListener(setWsStatus); |
| 136 | + }; |
| 137 | + }, []); |
| 138 | + |
| 139 | + // Auto-trigger authentication when conditions are met (from Chapter 3) |
| 140 | + useEffect(() => { |
| 141 | + if (account && sessionKey && wsStatus === 'Connected' && !isAuthenticated && !isAuthAttempted) { |
| 142 | + setIsAuthAttempted(true); |
| 143 | + const expireTimestamp = String(Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS); |
| 144 | + |
| 145 | + const authParams: AuthRequestParams = { |
| 146 | + wallet: account, |
| 147 | + participant: sessionKey.address, |
| 148 | + app_name: 'Nexus', |
| 149 | + expire: expireTimestamp, |
| 150 | + scope: 'nexus.app', |
| 151 | + application: account, |
| 152 | + allowances: [], |
| 153 | + }; |
| 154 | + |
| 155 | + createAuthRequestMessage(authParams).then((payload) => { |
| 156 | + webSocketService.send(payload); |
| 157 | + }); |
| 158 | + } |
| 159 | + }, [account, sessionKey, wsStatus, isAuthenticated, isAuthAttempted]); |
| 160 | + |
| 161 | + // CHAPTER 4: Automatically fetch balances when user is authenticated |
| 162 | + // This useEffect hook runs whenever authentication status, sessionKey, or account changes |
| 163 | + useEffect(() => { |
| 164 | + // Only proceed if all required conditions are met: |
| 165 | + // 1. User has completed authentication |
| 166 | + // 2. We have a session key (temporary private key for signing) |
| 167 | + // 3. We have the user's wallet address |
| 168 | + if (isAuthenticated && sessionKey && account) { |
| 169 | + console.log('Authenticated! Fetching ledger balances...'); |
| 170 | + |
| 171 | + // CHAPTER 4: Show loading state while we fetch balances |
| 172 | + setIsLoadingBalances(true); |
| 173 | + |
| 174 | + // CHAPTER 4: Create a "signer" - this is what signs our requests without user popups |
| 175 | + // Think of this like a temporary stamp that proves we're allowed to make requests |
| 176 | + const sessionSigner = createECDSAMessageSigner(sessionKey.privateKey); |
| 177 | + |
| 178 | + // CHAPTER 4: Create a signed request to get the user's asset balances |
| 179 | + // This is like asking "What's in my wallet?" but with cryptographic proof |
| 180 | + createGetLedgerBalancesMessage(sessionSigner, account) |
| 181 | + .then((getBalancesPayload) => { |
| 182 | + // Send the signed request through our WebSocket connection |
| 183 | + console.log('Sending balance request...'); |
| 184 | + webSocketService.send(getBalancesPayload); |
| 185 | + }) |
| 186 | + .catch((error) => { |
| 187 | + console.error('Failed to create balance request:', error); |
| 188 | + setIsLoadingBalances(false); // Stop loading on error |
| 189 | + // In a real app, you might show a user-friendly error message here |
| 190 | + }); |
| 191 | + } |
| 192 | + }, [isAuthenticated, sessionKey, account]); |
| 193 | + |
| 194 | + // This effect handles all incoming WebSocket messages. |
| 195 | + useEffect(() => { |
| 196 | + const handleMessage = async (data: any) => { |
| 197 | + const rawEventData = JSON.stringify(data); |
| 198 | + const response = NitroliteRPC.parseResponse(rawEventData); |
| 199 | + |
| 200 | + // Handle auth challenge (from Chapter 3) |
| 201 | + if (response.method === RPCMethod.AuthChallenge && walletClient && sessionKey && account) { |
| 202 | + const challengeResponse = response as AuthChallengeResponse; |
| 203 | + const expireTimestamp = String(Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS); |
| 204 | + |
| 205 | + const authParams = { |
| 206 | + scope: 'nexus.app', |
| 207 | + application: walletClient.account?.address as `0x${string}`, |
| 208 | + participant: sessionKey.address as `0x${string}`, |
| 209 | + expire: expireTimestamp, |
| 210 | + allowances: [], |
| 211 | + }; |
| 212 | + |
| 213 | + const eip712Signer = createEIP712AuthMessageSigner(walletClient, authParams, { |
| 214 | + name: 'Nexus', |
| 215 | + }); |
| 216 | + |
| 217 | + try { |
| 218 | + const authVerifyPayload = await createAuthVerifyMessage(eip712Signer, challengeResponse); |
| 219 | + webSocketService.send(authVerifyPayload); |
| 220 | + } catch (error) { |
| 221 | + alert('Signature rejected. Please try again.'); |
| 222 | + setIsAuthAttempted(false); |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + // Handle auth success (from Chapter 3) |
| 227 | + if (response.method === RPCMethod.AuthVerify && response.params?.success) { |
| 228 | + setIsAuthenticated(true); |
| 229 | + if (response.params.jwtToken) storeJWT(response.params.jwtToken); |
| 230 | + } |
| 231 | + |
| 232 | + // CHAPTER 4: Handle balance responses (when we asked for balances) |
| 233 | + if (response.method === RPCMethod.GetLedgerBalances) { |
| 234 | + const balanceResponse = response as GetLedgerBalancesResponse; |
| 235 | + console.log('Received balance response:', balanceResponse.params); |
| 236 | + |
| 237 | + // Check if we actually got balance data back |
| 238 | + if (balanceResponse.params && balanceResponse.params.length > 0) { |
| 239 | + // CHAPTER 4: Transform the data for easier use in our UI |
| 240 | + // Convert from: [{asset: "usdc", amount: "100"}, {asset: "eth", amount: "0.5"}] |
| 241 | + // To: {"usdc": "100", "eth": "0.5"} |
| 242 | + const balancesMap = Object.fromEntries( |
| 243 | + balanceResponse.params.map((balance) => [balance.asset, balance.amount]), |
| 244 | + ); |
| 245 | + console.log('Setting balances:', balancesMap); |
| 246 | + setBalances(balancesMap); |
| 247 | + } else { |
| 248 | + console.log('No balance data received - wallet appears empty'); |
| 249 | + setBalances({}); |
| 250 | + } |
| 251 | + // CHAPTER 4: Stop loading once we receive any balance response |
| 252 | + setIsLoadingBalances(false); |
| 253 | + } |
| 254 | + |
| 255 | + // CHAPTER 4: Handle live balance updates (server pushes these automatically) |
| 256 | + if (response.method === RPCMethod.BalanceUpdate) { |
| 257 | + const balanceUpdate = response as BalanceUpdateResponse; |
| 258 | + console.log('Live balance update received:', balanceUpdate.params); |
| 259 | + |
| 260 | + // Same data transformation as above |
| 261 | + const balancesMap = Object.fromEntries( |
| 262 | + balanceUpdate.params.map((balance) => [balance.asset, balance.amount]), |
| 263 | + ); |
| 264 | + console.log('Updating balances in real-time:', balancesMap); |
| 265 | + setBalances(balancesMap); |
| 266 | + } |
| 267 | + |
| 268 | + // Handle errors (from Chapter 3) |
| 269 | + if (response.method === RPCMethod.Error) { |
| 270 | + removeJWT(); |
| 271 | + removeSessionKey(); |
| 272 | + alert(`Authentication failed: ${response.params.error}`); |
| 273 | + setIsAuthAttempted(false); |
| 274 | + } |
| 275 | + }; |
| 276 | + |
| 277 | + webSocketService.addMessageListener(handleMessage); |
| 278 | + return () => webSocketService.removeMessageListener(handleMessage); |
| 279 | + }, [walletClient, sessionKey, account]); |
| 280 | + |
| 281 | + const connectWallet = async () => { |
| 282 | + if (!window.ethereum) { |
| 283 | + alert('Please install MetaMask!'); |
| 284 | + return; |
| 285 | + } |
| 286 | + |
| 287 | + const tempClient = createWalletClient({ |
| 288 | + chain: mainnet, |
| 289 | + transport: custom(window.ethereum), |
| 290 | + }); |
| 291 | + const [address] = await tempClient.requestAddresses(); |
| 292 | + |
| 293 | + const walletClient = createWalletClient({ |
| 294 | + account: address, |
| 295 | + chain: mainnet, |
| 296 | + transport: custom(window.ethereum), |
| 297 | + }); |
| 298 | + |
| 299 | + setWalletClient(walletClient); |
| 300 | + setAccount(address); |
| 301 | + }; |
| 302 | + |
| 303 | + const formatAddress = (address: Address) => { |
| 304 | + return `${address.slice(0, 6)}...${address.slice(-4)}`; |
| 305 | + }; |
| 306 | + |
| 307 | + return ( |
| 308 | + <div className="app-container"> |
| 309 | + <header className="header"> |
| 310 | + <div className="header-content"> |
| 311 | + <h1 className="logo">Nexus</h1> |
| 312 | + <p className="tagline"> |
| 313 | + <span>The Content Platform of Tomorrow</span> |
| 314 | + </p> |
| 315 | + </div> |
| 316 | + <div className="header-controls"> |
| 317 | + {/* CHAPTER 4: Display balance when authenticated */} |
| 318 | + {isAuthenticated && ( |
| 319 | + <BalanceDisplay |
| 320 | + balance={ |
| 321 | + isLoadingBalances ? 'Loading...' : (balances?.['usdc'] ?? balances?.['USDC'] ?? null) |
| 322 | + } |
| 323 | + symbol="USDC" |
| 324 | + /> |
| 325 | + )} |
| 326 | + <div className={`ws-status ${wsStatus.toLowerCase()}`}> |
| 327 | + <span className="status-dot"></span> {wsStatus} |
| 328 | + </div> |
| 329 | + <div className="wallet-connector"> |
| 330 | + {account ? ( |
| 331 | + <button |
| 332 | + onClick={() => { |
| 333 | + /* Disconnect logic can be added here */ |
| 334 | + }} |
| 335 | + > |
| 336 | + {formatAddress(account)} |
| 337 | + </button> |
| 338 | + ) : ( |
| 339 | + <button onClick={connectWallet}>Connect Wallet</button> |
| 340 | + )} |
| 341 | + </div> |
| 342 | + </div> |
| 343 | + </header> |
| 344 | + |
| 345 | + <main className="main-content"> |
| 346 | + {/* CHAPTER 4: Pass authentication state to enable balance-dependent features */} |
| 347 | + <PostList posts={posts} isWalletConnected={!!account} isAuthenticated={isAuthenticated} /> |
| 348 | + </main> |
| 349 | + </div> |
| 350 | + ); |
| 351 | +} |
| 352 | +``` |
| 353 | + |
| 354 | +## Expected Outcome |
| 355 | + |
| 356 | +As soon as the user's session is authenticated, the application will automatically make a background request to fetch their balances. A new "Balance" component will appear in the header, displaying their current USDC balance (e.g., "100.00 USDC"). This entire process happens instantly and without any further action required from the user, demonstrating the power of session keys. |
| 357 | + |
| 358 | +### What You'll See: |
| 359 | + |
| 360 | +1. **Loading State**: "Loading... USDC" appears briefly while fetching balances |
| 361 | +2. **Balance Display**: Real balance appears (e.g., "0.52 USDC") |
| 362 | +3. **Real-time Updates**: Balance updates automatically when server pushes changes |
| 363 | +4. **Console Logs**: Educational messages in browser console showing the process |
| 364 | + |
| 365 | +### For Beginners: What's Happening Behind the Scenes? |
| 366 | + |
| 367 | +- **Session Key**: Like a temporary ID card that lets your app make requests without asking you to sign every time |
| 368 | +- **WebSocket**: Real-time connection that lets the server push updates to your app instantly |
| 369 | +- **Balance Request**: Your app asks "What's in my wallet?" using cryptographic proof |
| 370 | +- **Data Transformation**: Converting server response format to something easy for your UI to use |
| 371 | + |
| 372 | +## Optional: CSS Styling for BalanceDisplay |
| 373 | + |
| 374 | +If you want to style the `BalanceDisplay` component, create the following CSS module file: |
| 375 | + |
| 376 | +```css |
| 377 | +/* filepath: src/components/BalanceDisplay/BalanceDisplay.module.css */ |
| 378 | +.balanceContainer { |
| 379 | + display: flex; |
| 380 | + align-items: center; |
| 381 | + gap: 0.5rem; |
| 382 | + font-family: 'JetBrains Mono', monospace; |
| 383 | + padding: 0.75rem 1rem; |
| 384 | + border-radius: 6px; |
| 385 | + border: 1px solid var(--border); |
| 386 | + background-color: var(--surface); |
| 387 | + font-size: 0.9rem; |
| 388 | + color: var(--text-primary); |
| 389 | +} |
| 390 | + |
| 391 | +.balanceAmount { |
| 392 | + font-weight: 600; |
| 393 | +} |
| 394 | + |
| 395 | +.balanceSymbol { |
| 396 | + font-weight: 400; |
| 397 | + color: var(--text-secondary); |
| 398 | +} |
| 399 | +``` |
0 commit comments