Skip to content

Commit df9e9b7

Browse files
committed
chapter-4-display-balances
1 parent 94fb7f7 commit df9e9b7

File tree

4 files changed

+529
-1
lines changed

4 files changed

+529
-1
lines changed

docs/chapter-4-display-balances.md

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
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

Comments
 (0)