From df9e9b7a710023457beb765939aa3135ce13cbed Mon Sep 17 00:00:00 2001 From: akushniruk Date: Mon, 21 Jul 2025 10:39:59 +0200 Subject: [PATCH 1/3] chapter-4-display-balances --- docs/chapter-4-display-balances.md | 399 ++++++++++++++++++ src/App.tsx | 91 +++- .../BalanceDisplay/BalanceDisplay.module.css | 21 + .../BalanceDisplay/BalanceDisplay.tsx | 19 + 4 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 docs/chapter-4-display-balances.md create mode 100644 src/components/BalanceDisplay/BalanceDisplay.module.css create mode 100644 src/components/BalanceDisplay/BalanceDisplay.tsx diff --git a/docs/chapter-4-display-balances.md b/docs/chapter-4-display-balances.md new file mode 100644 index 0000000..e849f04 --- /dev/null +++ b/docs/chapter-4-display-balances.md @@ -0,0 +1,399 @@ +# 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. + +```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 + }); + } + }, [isAuthenticated, sessionKey, account]); + + // This effect handles all incoming WebSocket messages. + useEffect(() => { + const handleMessage = async (data: any) => { + const rawEventData = JSON.stringify(data); + const response = NitroliteRPC.parseResponse(rawEventData); + + // 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) { + 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) { + 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 ( +
+
+
+

Nexus

+

+ The Content Platform of Tomorrow +

+
+
+ {/* CHAPTER 4: Display balance when authenticated */} + {isAuthenticated && ( + + )} +
+ {wsStatus} +
+
+ {account ? ( + + ) : ( + + )} +
+
+
+ +
+ {/* CHAPTER 4: Pass authentication state to enable balance-dependent features */} + +
+
+ ); +} +``` + +## Expected Outcome + +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. + +### What You'll See: + +1. **Loading State**: "Loading... USDC" appears briefly while fetching balances +2. **Balance Display**: Real balance appears (e.g., "0.52 USDC") +3. **Real-time Updates**: Balance updates automatically when server pushes changes +4. **Console Logs**: Educational messages in browser console showing the process + +### For Beginners: What's Happening Behind the Scenes? + +- **Session Key**: Like a temporary ID card that lets your app make requests without asking you to sign every time +- **WebSocket**: Real-time connection that lets the server push updates to your app instantly +- **Balance Request**: Your app asks "What's in my wallet?" using cryptographic proof +- **Data Transformation**: Converting server response format to something easy for your UI to use + +## Optional: CSS Styling for BalanceDisplay + +If you want to style the `BalanceDisplay` component, create the following CSS module file: + +```css +/* filepath: src/components/BalanceDisplay/BalanceDisplay.module.css */ +.balanceContainer { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: 'JetBrains Mono', monospace; + padding: 0.75rem 1rem; + border-radius: 6px; + border: 1px solid var(--border); + background-color: var(--surface); + font-size: 0.9rem; + color: var(--text-primary); +} + +.balanceAmount { + font-weight: 600; +} + +.balanceSymbol { + font-weight: 400; + color: var(--text-secondary); +} +``` diff --git a/src/App.tsx b/src/App.tsx index be5506c..388373c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'preact/hooks'; import { createWalletClient, custom, type Address, type WalletClient } from 'viem'; import { mainnet } from 'viem/chains'; // CHAPTER 3: Authentication imports +// CHAPTER 4: Add balance fetching imports import { createAuthRequestMessage, createAuthVerifyMessage, @@ -10,8 +11,14 @@ import { RPCMethod, type AuthChallengeResponse, type AuthRequestParams, + 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'; // CHAPTER 3: Authentication utilities @@ -50,6 +57,10 @@ export function App() { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthAttempted, setIsAuthAttempted] = useState(false); const [sessionExpireTimestamp, setSessionExpireTimestamp] = useState(''); + // 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); useEffect(() => { // CHAPTER 3: Get or generate session key on startup (IMPORTANT: Store in localStorage) @@ -95,6 +106,39 @@ export function App() { } }, [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 + }); + } + }, [isAuthenticated, sessionKey, account]); + // CHAPTER 3: Handle server messages for authentication useEffect(() => { const handleMessage = async (data: any) => { @@ -135,6 +179,42 @@ export function App() { if (response.params.jwtToken) storeJWT(response.params.jwtToken); } + // CHAPTER 4: Handle balance responses (when we asked for balances) + if (response.method === RPCMethod.GetLedgerBalances) { + 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 if (response.method === RPCMethod.Error) { removeJWT(); @@ -182,6 +262,15 @@ export function App() {

Decentralized insights for the next generation of builders

+ {/* CHAPTER 4: Display balance when authenticated */} + {isAuthenticated && ( + + )}
{wsStatus}
@@ -198,7 +287,7 @@ export function App() {
- {/* CHAPTER 3: Pass authentication state to PostList */} + {/* CHAPTER 4: Pass authentication state to enable balance-dependent features */}
diff --git a/src/components/BalanceDisplay/BalanceDisplay.module.css b/src/components/BalanceDisplay/BalanceDisplay.module.css new file mode 100644 index 0000000..0c5a492 --- /dev/null +++ b/src/components/BalanceDisplay/BalanceDisplay.module.css @@ -0,0 +1,21 @@ +.balanceContainer { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: "JetBrains Mono", monospace; + padding: 0.75rem 1rem; + border-radius: 6px; + border: 1px solid var(--border); + background-color: var(--surface); + font-size: 0.9rem; + color: var(--text-primary); +} + +.balanceAmount { + font-weight: 600; +} + +.balanceSymbol { + font-weight: 400; + color: var(--text-secondary); +} diff --git a/src/components/BalanceDisplay/BalanceDisplay.tsx b/src/components/BalanceDisplay/BalanceDisplay.tsx new file mode 100644 index 0000000..d371e5d --- /dev/null +++ b/src/components/BalanceDisplay/BalanceDisplay.tsx @@ -0,0 +1,19 @@ +// 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} +
+ ); +} From 71c8d9ba3108355c7152a47fab90741e9f846729 Mon Sep 17 00:00:00 2001 From: akushniruk Date: Mon, 21 Jul 2025 11:18:35 +0200 Subject: [PATCH 2/3] final-p2p-transfer --- .env.example | 9 + docs/final-p2p-transfer.md | 317 +++++++++++++++++++++++++++ src/App.tsx | 127 +++++++++-- src/components/PostList/PostList.tsx | 31 ++- src/data/users.ts | 12 +- src/hooks/useTransfer.ts | 47 ++++ src/index.css | 12 + 7 files changed, 517 insertions(+), 38 deletions(-) create mode 100644 .env.example create mode 100644 docs/final-p2p-transfer.md create mode 100644 src/hooks/useTransfer.ts 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/docs/final-p2p-transfer.md b/docs/final-p2p-transfer.md new file mode 100644 index 0000000..73ffca3 --- /dev/null +++ b/docs/final-p2p-transfer.md @@ -0,0 +1,317 @@ +# Final Chapter: Peer-to-Peer Transfers + +## Goal + +In this final chapter, we'll add peer-to-peer transfer functionality to our Web3 application. Users will be able to send USDC support using session keys, creating a seamless Web2-like experience for Web3 transactions with just one button click. + +## Why This Matters + +This demonstrates the full power of session keys: + +- **Instant Transfers**: No wallet popups after initial authentication +- **Real-time Updates**: Balance updates immediately after transfers +- **User-Friendly UX**: Simple forms instead of complex transaction flows + +## Interaction Flow + +```mermaid +sequenceDiagram + participant User as "User" + participant App as "Our dApp" + participant WSS as "WebSocketService" + participant ClearNode as "ClearNode" + + User->>App: 1. Clicks support button + App->>App: 2. Uses predefined recipient and amount + App->>WSS: 3. Sends signed transfer request + WSS->>ClearNode: 4. Forwards transfer request + ClearNode->>ClearNode: 5. Processes transfer & updates balances + ClearNode-->>WSS: 6. Confirms transfer completion + WSS-->>App: 7. Receives transfer confirmation + App->>App: 8. Updates UI with success message + ClearNode-->>WSS: 9. Sends balance update to sender + WSS-->>App: 10. Real-time balance update + App->>App: 11. Updates balance display +``` + +## Implementation Steps + +### 1. Import User Data + +We'll use a predefined support address from our users data. The users are already defined in `src/data/users.ts`. + +### 2. Create useTransfer Hook + +Create `src/hooks/useTransfer.ts` for clean transfer logic separation: + +```tsx +// FINAL: Custom hook for handling transfers +import { useCallback } from 'preact/hooks'; +import { createTransferMessage, createECDSAMessageSigner } from '@erc7824/nitrolite'; +import type { Address } from 'viem'; +import { webSocketService } from '../lib/websocket'; +import type { SessionKey } from '../lib/utils'; + +export interface TransferResult { + success: boolean; + error?: string; +} + +export const useTransfer = (sessionKey: SessionKey | null, isAuthenticated: boolean) => { + const handleTransfer = useCallback( + async (recipient: Address, amount: string, asset: string = 'usdc'): Promise => { + if (!isAuthenticated || !sessionKey) { + return { success: false, error: 'Please authenticate first' }; + } + + try { + const sessionSigner = createECDSAMessageSigner(sessionKey.privateKey); + + const transferPayload = await createTransferMessage(sessionSigner, { + destination: recipient, + allocations: [ + { + asset: asset.toLowerCase(), + amount: amount, + } + ], + }); + + console.log('Sending transfer request...'); + webSocketService.send(transferPayload); + + return { success: true }; + } catch (error) { + console.error('Failed to create transfer:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to create transfer'; + return { success: false, error: errorMsg }; + } + }, + [sessionKey, isAuthenticated] + ); + + return { handleTransfer }; +}; +``` + +### 3. Update App.tsx - Add Transfer Imports + +Add these imports to your existing App.tsx: + +```tsx +// FINAL: Add transfer response import to existing imports +import { + // ... existing imports ... + type TransferResponse, +} from '@erc7824/nitrolite'; + +// FINAL: Import useTransfer hook +import { useTransfer } from './hooks/useTransfer'; +// FINAL: Import users for support address +import { users } from './data/users'; +``` + +### 4. Update App.tsx - Add Transfer State and Hook + +Add transfer state variables and the useTransfer hook to your App component: + +```tsx +// FINAL: Add transfer state after existing state +const [isTransferring, setIsTransferring] = useState(false); +const [transferStatus, setTransferStatus] = useState(null); + +// FINAL: Use transfer hook +const { handleTransfer: transferFn } = useTransfer(sessionKey, isAuthenticated); +``` + +### 5. Update App.tsx - Add Support Function + +Add this support function before your useEffect hooks: + +```tsx +// FINAL: Handle support function for PostList +const handleSupport = async (recipient: string, amount: string) => { + setIsTransferring(true); + setTransferStatus('Sending support...'); + + const result = await transferFn(recipient as Address, amount); + + if (result.success) { + setTransferStatus('Support sent!'); + } else { + setIsTransferring(false); + setTransferStatus(null); + if (result.error) { + alert(result.error); + } + } +}; +``` + +### 6. Update App.tsx - Handle Transfer Responses + +Add transfer response handling to your existing message handler useEffect: + +```tsx +// FINAL: Add this to your existing handleMessage function, after balance handling +if (response.method === RPCMethod.Transfer) { + const transferResponse = response as TransferResponse; + console.log('Transfer completed:', transferResponse.params); + + setIsTransferring(false); + setTransferStatus(null); + + alert(`Transfer completed successfully!`); +} + +// FINAL: Update error handling to include transfers +if (response.method === RPCMethod.Error) { + console.error('RPC Error:', response.params); + + if (isTransferring) { + setIsTransferring(false); + setTransferStatus(null); + alert(`Transfer failed: ${response.params.error}`); + } else { + // Other errors (like auth failures) + removeJWT(); + removeSessionKey(); + alert(`Error: ${response.params.error}`); + setIsAuthAttempted(false); + } +} +``` + +### 7. Update PostList Component + +Update `src/components/PostList/PostList.tsx` to handle transfers: + +```tsx +// Add users import and transfer props +import { users } from '../../data/users'; + +interface PostListProps { + posts: Post[]; + isWalletConnected: boolean; + isAuthenticated: boolean; + onTransfer?: (recipient: string, amount: string) => Promise; + isTransferring?: boolean; +} + +export function PostList({ posts, isWalletConnected, isAuthenticated, onTransfer, isTransferring }: PostListProps) { + const handleTip = async (post: Post) => { + if (!onTransfer) { + console.log('Transfer function not available'); + return; + } + + // Find the author's wallet address from users data + const author = users.find(user => user.id === post.authorId); + if (!author) { + console.error('Author wallet address not found'); + return; + } + + console.log(`Supporting ${post.authorName} with 0.01 USDC`); + await onTransfer(author.walletAddress, '0.01'); + }; + + // Update the support button to show transfer status + +} +``` + +### 8. Update App.tsx - Pass Transfer Props to PostList + +Update your PostList usage to pass the transfer function: + +```tsx +
+ {/* FINAL: Status message for transfers */} + {transferStatus && ( +
+ {transferStatus} +
+ )} + + +
+``` + +### 9. Add Basic Transfer Status CSS + +Add this to your `src/index.css`: + +```css +/* FINAL: Transfer status styles */ +.transfer-status { + text-align: center; + padding: 1rem; + margin: 1rem auto; + max-width: 400px; + background-color: var(--accent); + color: white; + border-radius: 6px; + font-weight: 500; +} +``` + +## Expected Outcome + +Your completed application now provides: + +### What Users Experience + +1. **One-Click Support**: Support buttons on each post to send 0.01 USDC to authors +2. **Popup-Free Transfers**: Send USDC without wallet confirmations +3. **Real-time Updates**: Balance updates immediately after successful transfers +4. **Clear Feedback**: Button states show "Supporting..." during transfers +5. **Dynamic Recipients**: Each post's author receives the support automatically + +### Technical Achievement + +- **Session Key Transfers**: Cryptographically signed transfers without user popups +- **Real-time State**: WebSocket updates keep UI synchronized with blockchain +- **Professional UX**: Loading states, validation, and error handling +- **Type Safety**: Full TypeScript integration with the SDK + +## What You've Learned + +You've built a complete Web3 application demonstrating: + +- **Session Authentication**: Temporary keys for seamless user experience +- **Peer-to-Peer Transfers**: Direct asset movement between participants +- **Real-time Updates**: Live blockchain state synchronization +- **Professional UX**: Modern interface patterns for Web3 applications + +This represents the future of Web3 user experience - where blockchain complexity is abstracted away, leaving users with familiar, intuitive interfaces. + +## Key Benefits + +This simplified approach offers several advantages for workshop participants: + +- **Easy to understand**: One button, clear purpose +- **No form validation**: Removes complexity around input handling +- **Predefined values**: No need to think about amounts or addresses +- **Focus on core concept**: Emphasizes session key functionality over UI details diff --git a/src/App.tsx b/src/App.tsx index 388373c..881bce1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,10 +15,13 @@ import { createGetLedgerBalancesMessage, type GetLedgerBalancesResponse, type BalanceUpdateResponse, + type TransferResponse, } from '@erc7824/nitrolite'; import { PostList } from './components/PostList/PostList'; // CHAPTER 4: Import the new BalanceDisplay component import { BalanceDisplay } from './components/BalanceDisplay/BalanceDisplay'; +// FINAL: Import useTransfer hook +import { useTransfer } from './hooks/useTransfer'; import { posts } from './data/posts'; import { webSocketService, type WsStatus } from './lib/websocket'; // CHAPTER 3: Authentication utilities @@ -61,6 +64,13 @@ export function App() { const [balances, setBalances] = useState | null>(null); // CHAPTER 4: Add loading state for better user experience const [isLoadingBalances, setIsLoadingBalances] = useState(false); + + // FINAL: Add transfer state + const [isTransferring, setIsTransferring] = useState(false); + const [transferStatus, setTransferStatus] = useState(null); + + // FINAL: Use transfer hook + const { handleTransfer: transferFn } = useTransfer(sessionKey, isAuthenticated); useEffect(() => { // CHAPTER 3: Get or generate session key on startup (IMPORTANT: Store in localStorage) @@ -139,6 +149,24 @@ export function App() { } }, [isAuthenticated, sessionKey, account]); + // FINAL: Handle support function for PostList + const handleSupport = async (recipient: string, amount: string) => { + setIsTransferring(true); + setTransferStatus('Sending support...'); + + const result = await transferFn(recipient as Address, amount); + + if (result.success) { + setTransferStatus('Support sent!'); + } else { + setIsTransferring(false); + setTransferStatus(null); + if (result.error) { + alert(result.error); + } + } + }; + // CHAPTER 3: Handle server messages for authentication useEffect(() => { const handleMessage = async (data: any) => { @@ -215,41 +243,78 @@ export function App() { setBalances(balancesMap); } + // FINAL: Handle transfer response + if (response.method === RPCMethod.Transfer) { + const transferResponse = response as TransferResponse; + console.log('Transfer completed:', transferResponse.params); + + setIsTransferring(false); + setTransferStatus(null); + + alert(`Transfer completed successfully!`); + } + // Handle errors if (response.method === RPCMethod.Error) { - removeJWT(); - // Clear session key on auth failure to regenerate next time - removeSessionKey(); - alert(`Authentication failed: ${response.params.error}`); - setIsAuthAttempted(false); + console.error('RPC Error:', response.params); + + if (isTransferring) { + setIsTransferring(false); + setTransferStatus(null); + alert(`Transfer failed: ${response.params.error}`); + } else { + // Other errors (like auth failures) + removeJWT(); + removeSessionKey(); + alert(`Error: ${response.params.error}`); + setIsAuthAttempted(false); + } } }; webSocketService.addMessageListener(handleMessage); return () => webSocketService.removeMessageListener(handleMessage); - }, [walletClient, sessionKey, sessionExpireTimestamp, account]); + }, [walletClient, sessionKey, sessionExpireTimestamp, account, isTransferring]); const connectWallet = async () => { if (!window.ethereum) { - alert('Please install MetaMask!'); + alert('MetaMask not found! Please install MetaMask from https://metamask.io/'); return; } - const tempClient = createWalletClient({ - chain: mainnet, - transport: custom(window.ethereum), - }); - const [address] = await tempClient.requestAddresses(); - - // CHAPTER 3: Create wallet client with account for EIP-712 signing - const walletClient = createWalletClient({ - account: address, - chain: mainnet, - transport: custom(window.ethereum), - }); - - setWalletClient(walletClient); - setAccount(address); + try { + // Check current network + const chainId = await window.ethereum.request({ method: 'eth_chainId' }); + if (chainId !== '0x1') { // Not mainnet + alert('Please switch to Ethereum Mainnet in MetaMask for this workshop'); + // Note: In production, you might want to automatically switch networks + } + + const tempClient = createWalletClient({ + chain: mainnet, + transport: custom(window.ethereum), + }); + const [address] = await tempClient.requestAddresses(); + + if (!address) { + alert('No wallet address found. Please ensure MetaMask is unlocked.'); + return; + } + + // CHAPTER 3: Create wallet client with account for EIP-712 signing + const walletClient = createWalletClient({ + account: address, + chain: mainnet, + transport: custom(window.ethereum), + }); + + setWalletClient(walletClient); + setAccount(address); + } catch (error) { + console.error('Wallet connection failed:', error); + alert('Failed to connect wallet. Please try again.'); + return; + } }; const formatAddress = (address: Address) => `${address.slice(0, 6)}...${address.slice(-4)}`; @@ -266,7 +331,7 @@ export function App() { {isAuthenticated && ( @@ -287,8 +352,22 @@ export function App() {
+ + {/* FINAL: Status message for transfers */} + {transferStatus && ( +
+ {transferStatus} +
+ )} + {/* CHAPTER 4: Pass authentication state to enable balance-dependent features */} - +
); diff --git a/src/components/PostList/PostList.tsx b/src/components/PostList/PostList.tsx index 1209eb7..808444e 100644 --- a/src/components/PostList/PostList.tsx +++ b/src/components/PostList/PostList.tsx @@ -1,18 +1,31 @@ import { type Post } from '../../data/posts'; +import { users } from '../../data/users'; import styles from './PostList.module.css'; interface PostListProps { posts: Post[]; isWalletConnected: boolean; isAuthenticated: boolean; + onTransfer?: (recipient: string, amount: string) => Promise; + isTransferring?: boolean; } -export function PostList({ posts, isWalletConnected, isAuthenticated }: PostListProps) { - const handleTip = (post: Post) => { - // --- WORKSHOP: INSTANT TIP LOGIC --- - // This is where we'll implement the instant tip functionality - // using the sessionSigner to send 1 USDC to the post author - console.log('Tipping post:', post.id, 'by', post.authorName); +export function PostList({ posts, isWalletConnected, isAuthenticated, onTransfer, isTransferring }: PostListProps) { + const handleTip = async (post: Post) => { + if (!onTransfer) { + console.log('Transfer function not available'); + return; + } + + // Find the author's wallet address from users data + const author = users.find(user => user.id === post.authorId); + if (!author) { + console.error('Author wallet address not found'); + return; + } + + console.log(`Supporting ${post.authorName} with 0.01 USDC`); + await onTransfer(author.walletAddress, '0.01'); }; const formatDate = (dateString: string) => { @@ -59,7 +72,7 @@ export function PostList({ posts, isWalletConnected, isAuthenticated }: PostList
diff --git a/src/data/users.ts b/src/data/users.ts index 636176f..a1e82eb 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -8,31 +8,31 @@ export const users: User[] = [ { id: '1', name: 'Alice Johnson', - walletAddress: '0x742d35Cc6634C0532925a3b8D5c3e5De4a1234567', + walletAddress: '0xE03faB973fA4f1E48fb89410ec70B7c49eBE97D3', }, { id: '2', name: 'Bob Smith', - walletAddress: '0x742d35Cc6634C0532925a3b8D5c3e5De4a7654321', + walletAddress: '0xE03faB973fA4f1E48fb89410ec70B7c49eBE97D3', }, { id: '3', name: 'Charlie Davis', - walletAddress: '0x742d35Cc6634C0532925a3b8D5c3e5De4a9876543', + walletAddress: '0xE03faB973fA4f1E48fb89410ec70B7c49eBE97D3', }, { id: '4', name: 'Diana Brown', - walletAddress: '0x742d35Cc6634C0532925a3b8D5c3e5De4a1357924', + walletAddress: '0xE03faB973fA4f1E48fb89410ec70B7c49eBE97D3', }, { id: '5', name: 'Eve Wilson', - walletAddress: '0x742d35Cc6634C0532925a3b8D5c3e5De4a2468135', + walletAddress: '0xE03faB973fA4f1E48fb89410ec70B7c49eBE97D3', }, { id: '6', name: 'Frank Miller', - walletAddress: '0x742d35Cc6634C0532925a3b8D5c3e5De4a8642097', + walletAddress: '0xE03faB973fA4f1E48fb89410ec70B7c49eBE97D3', }, ]; diff --git a/src/hooks/useTransfer.ts b/src/hooks/useTransfer.ts new file mode 100644 index 0000000..ed95ea6 --- /dev/null +++ b/src/hooks/useTransfer.ts @@ -0,0 +1,47 @@ +// FINAL: Custom hook for handling transfers +import { useCallback } from 'preact/hooks'; +import { createTransferMessage, createECDSAMessageSigner } from '@erc7824/nitrolite'; +import type { Address } from 'viem'; +import { webSocketService } from '../lib/websocket'; +import type { SessionKey } from '../lib/utils'; + +export interface TransferResult { + success: boolean; + error?: string; +} + +export const useTransfer = (sessionKey: SessionKey | null, isAuthenticated: boolean) => { + const handleTransfer = useCallback( + async (recipient: Address, amount: string, asset: string = 'usdc'): Promise => { + if (!isAuthenticated || !sessionKey) { + return { success: false, error: 'Please authenticate first' }; + } + + try { + const sessionSigner = createECDSAMessageSigner(sessionKey.privateKey); + + const transferPayload = await createTransferMessage(sessionSigner, { + destination: recipient, + allocations: [ + { + asset: asset.toLowerCase(), + amount: amount, + } + ], + }); + + console.log('Sending transfer request...'); + webSocketService.send(transferPayload); + + return { success: true }; + } catch (error) { + console.error('Failed to create transfer:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to create transfer'; + return { success: false, error: errorMsg }; + } + }, + [sessionKey, isAuthenticated] + ); + + return { handleTransfer }; +}; \ No newline at end of file diff --git a/src/index.css b/src/index.css index ee02ba2..6cff65d 100644 --- a/src/index.css +++ b/src/index.css @@ -175,3 +175,15 @@ select { .ws-status.disconnected .status-dot { background-color: #da3633; } + +/* FINAL: Transfer status styles */ +.transfer-status { + text-align: center; + padding: 1rem; + margin: 1rem auto; + max-width: 400px; + background-color: var(--accent); + color: white; + border-radius: 6px; + font-weight: 500; +} From c11fcdd183e86d0e6c0df01edab5a92aab6d28ce Mon Sep 17 00:00:00 2001 From: MaxMoskalenko Date: Wed, 23 Jul 2025 15:28:42 +0300 Subject: [PATCH 3/3] Add review to markdown docs --- README.md | 2 ++ docs/chapter-1-wallet-connect.md | 3 +++ docs/chapter-2-ws-connection.md | 5 +++++ docs/chapter-3-session-auth.md | 6 ++++++ docs/chapter-4-display-balances.md | 11 +++++++++++ 5 files changed, 27 insertions(+) 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 index e849f04..20bd803 100644 --- a/docs/chapter-4-display-balances.md +++ b/docs/chapter-4-display-balances.md @@ -68,6 +68,11 @@ export function BalanceDisplay({ balance, symbol }: BalanceDisplayProps) { 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'; @@ -189,14 +194,18 @@ export function App() { // 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; @@ -231,6 +240,7 @@ export function App() { // 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); @@ -267,6 +277,7 @@ export function App() { // 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}`);