diff --git a/CHAPTER_5_APP_SESSIONS.md b/CHAPTER_5_APP_SESSIONS.md new file mode 100644 index 0000000..751df11 --- /dev/null +++ b/CHAPTER_5_APP_SESSIONS.md @@ -0,0 +1,142 @@ +# Chapter 5: App Sessions + +This chapter replaces the transfer functionality with app sessions, demonstrating how to use the Nitrolite protocol to create stateful application sessions. + +## What Changed + +Instead of using simple transfers, we now use **app sessions** which: +1. **Create**: User (A) allocates funds into a session +2. **Close**: Funds are immediately moved to recipient (B) + +This showcases how app sessions work as a two-step process, executed automatically in sequence. + +## Implementation + +### 1. Hooks + +#### `useCreateAppSession` (`src/hooks/useCreateAppSession.ts`) +Creates an app session with: +- `from`: The user's wallet (participant A) - allocates the funds +- `to`: The recipient (participant B) - receives 0 initially +- `amount`: Amount to lock in the session +- `sessionData`: Optional JSON string with app-specific data + +**Key Detail**: On create, participant A puts in the funds, participant B puts in 0. + +#### `useCloseAppSession` (`src/hooks/useCloseAppSession.ts`) +Closes an app session with final allocations: +- Takes `appSessionId` and final `allocations` array +- Optional `sessionData` parameter for close state +- Redistributes funds according to the allocations + +**Key Detail**: On close, we send all funds to participant B (amount: sessionAmount) and A gets 0. Session data can include completion status, final state, etc. + +### 2. User Flow + +#### When user clicks "Support" button: +1. **Create App Session**: + - Creates app session with user as participant A + - Allocates the support amount from user's balance + - Includes session_data: `{type: 'support', recipient, amount, timestamp}` + - Shows: "Sending support..." + +2. **Immediately Close Session** (automatic): + - Upon receiving session ID, automatically closes the session + - Moves all funds to the recipient (participant B) + - Includes session_data: `{type: 'support_complete', recipient, amount, timestamp, completed: true}` + - Shows: "Support sent!" + +The entire flow happens in one click, but demonstrates the two-step app session protocol (create → close) with session data on both steps. + +### 3. State Management + +App state includes: +- `appSessionId`: Temporarily stores session ID between create and close +- `sessionRecipient`: Who will receive the funds on close +- `sessionAmount`: How much to send on close +- `isCreatingSession`: Loading state for creation +- `isClosingSession`: Loading state for closure +- `appSessionStatus`: User feedback message ("Sending support..." → "Support sent!") + +Session data is only held in memory during the create→close sequence and is cleared immediately after. + +### 4. API Structure + +#### Create App Session +```json +{ + "definition": { + "application": "clearnode", + "protocol": "NitroRPC/0.2", + "participants": ["0xUserAddress", "0xRecipientAddress"], + "weights": [50, 50], + "quorum": 100, + "challenge": 0, + "nonce": 1234567890 + }, + "allocations": [ + { "participant": "0xUserAddress", "asset": "usdc", "amount": "5.0" }, + { "participant": "0xRecipientAddress", "asset": "usdc", "amount": "0" } + ], + "session_data": "{\"type\":\"support\",\"recipient\":\"0x...\",\"amount\":\"5.0\",\"timestamp\":1234567890}" +} +``` + +#### Close App Session +```json +{ + "app_session_id": "0x...", + "allocations": [ + { "participant": "0xUserAddress", "asset": "usdc", "amount": "0" }, + { "participant": "0xRecipientAddress", "asset": "usdc", "amount": "5.0" } + ], + "session_data": "{\"type\":\"support_complete\",\"recipient\":\"0x...\",\"amount\":\"5.0\",\"timestamp\":1234567890,\"completed\":true}" +} +``` + +### 5. Key Differences from Transfer + +| Transfer | App Session | +|----------|-------------| +| One RPC call | Two RPC calls (create → close) | +| Single protocol message | Two protocol messages in sequence | +| Simple send | Can include custom session data | +| No state | Stateful with session ID (temporary) | +| Direct | Demonstrates two-phase commit pattern | + +**Note**: In this demo, both steps happen automatically on one click to showcase the protocol while maintaining a simple UX. + +### 6. UI/UX + +The UI remains the same as the transfer version: +- PostList with "Support" buttons +- Status message showing current operation +- Buttons disabled during operations +- Same styling and layout + +**User Experience**: +- Single click: Creates session and immediately closes it, completing the support +- Status messages: "Sending support..." → "Support sent!" +- Seamless UX that feels like a direct transfer + +## Testing + +1. Start the dev server: `npm run dev` +2. Connect wallet and authenticate +3. Click "Support" on any post +4. Watch the flow: create session → immediately close → funds transferred +5. Check balance updates in real-time + +## Benefits of App Sessions + +- **Stateful**: Can track ongoing games, escrows, etc. +- **Flexible**: Session data can store game state, rules, etc. +- **Two-phase commits**: Funds locked until close +- **Custom logic**: Allocations can be determined by app logic before close + +## Example Use Cases + +- **Games**: Lock entry fees, distribute based on winner +- **Escrows**: Hold funds until conditions met +- **Tournaments**: Collect buy-ins, distribute prizes +- **Subscriptions**: Lock payment, release based on usage diff --git a/src/App.tsx b/src/App.tsx index 5273ae3..791efc9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'preact/hooks'; +import { useState, useEffect, useRef } from 'preact/hooks'; import { createWalletClient, custom, type Address, type WalletClient } from 'viem'; import { mainnet } from 'viem/chains'; // CHAPTER 3: Authentication imports @@ -15,13 +15,15 @@ import { createGetLedgerBalancesMessage, type GetLedgerBalancesResponse, type BalanceUpdateResponse, - type TransferResponse, + type CreateAppSessionResponse, + type CloseAppSessionResponse, } 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'; +// CHAPTER 5: Import app session hooks +import { useCreateAppSession } from './hooks/useCreateAppSession'; +import { useCloseAppSession } from './hooks/useCloseAppSession'; import { posts } from './data/posts'; import { webSocketService, type WsStatus } from './lib/websocket'; // CHAPTER 3: Authentication utilities @@ -65,12 +67,18 @@ export function App() { // 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); + // CHAPTER 5: Add app session state + const [appSessionId, setAppSessionId] = useState(null); + const [isCreatingSession, setIsCreatingSession] = useState(false); + const [isClosingSession, setIsClosingSession] = useState(false); + const [appSessionStatus, setAppSessionStatus] = useState(null); + + // CHAPTER 5: Use ref to track pending close (survives async state updates) + const pendingCloseRef = useRef<{ recipient: string; amount: string; sessionData: string } | null>(null); + + // CHAPTER 5: Use app session hooks + const { createAppSession } = useCreateAppSession(sessionKey, isAuthenticated); + const { closeAppSession } = useCloseAppSession(sessionKey, isAuthenticated); useEffect(() => { // CHAPTER 3: Get or generate session key on startup (IMPORTANT: Store in localStorage) @@ -83,6 +91,7 @@ export function App() { setSessionKey(newSessionKey); } + webSocketService.addStatusListener(setWsStatus); webSocketService.connect(); @@ -149,24 +158,47 @@ export function App() { } }, [isAuthenticated, sessionKey, account]); - // FINAL: Handle support function for PostList + // CHAPTER 5: Handle support function for PostList using app sessions 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 (!account) { + alert('Please connect your wallet first'); + return; + } + + // Create new app session + setIsCreatingSession(true); + setAppSessionStatus('Sending support...'); + + const sessionData = JSON.stringify({ + type: 'support', + recipient: recipient, + amount: amount, + timestamp: Date.now(), + }); + + // Store recipient, amount, and sessionData in ref for immediate close after create + pendingCloseRef.current = { recipient, amount, sessionData }; + + const result = await createAppSession({ + from: account, + to: recipient, + amount: amount, + sessionData, + application: 'clearnode', + challenge: 0, + }); + + if (!result.success) { + setIsCreatingSession(false); + setAppSessionStatus(null); + pendingCloseRef.current = null; if (result.error) { - alert(result.error); + alert(`Failed to create session: ${result.error}`); } } }; + // CHAPTER 3: Handle server messages for authentication useEffect(() => { const handleMessage = async (data: any) => { @@ -247,25 +279,91 @@ 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!`); + // CHAPTER 5: Handle create app session response + if (response.method === RPCMethod.CreateAppSession) { + const createSessionResponse = response as CreateAppSessionResponse; + console.log('App session created:', createSessionResponse.params); + + const pendingClose = pendingCloseRef.current; + if (createSessionResponse.params.appSessionId && pendingClose && account) { + const sessionId = createSessionResponse.params.appSessionId; + setAppSessionId(sessionId); + + // Immediately close the session to complete the transfer + setIsCreatingSession(false); + setIsClosingSession(true); + + console.log('Immediately closing session with:', { + sessionId, + recipient: pendingClose.recipient, + amount: pendingClose.amount, + sessionData: pendingClose.sessionData, + }); + + const closeSessionData = JSON.stringify({ + type: 'support_complete', + recipient: pendingClose.recipient, + amount: pendingClose.amount, + timestamp: Date.now(), + completed: true, + }); + + closeAppSession( + sessionId, + [ + { + participant: account, + asset: 'usdc', + amount: '0', // A gets nothing + }, + { + participant: pendingClose.recipient, + asset: 'usdc', + amount: pendingClose.amount, // B gets all the funds + }, + ], + closeSessionData + ).catch((error) => { + console.error('Failed to close session:', error); + setIsClosingSession(false); + setAppSessionStatus(null); + pendingCloseRef.current = null; + alert('Failed to close session'); + }); + } + } + + // CHAPTER 5: Handle close app session response + if (response.method === RPCMethod.CloseAppSession) { + const closeSessionResponse = response as CloseAppSessionResponse; + console.log('App session closed:', closeSessionResponse.params); + + setAppSessionId(null); + pendingCloseRef.current = null; + + setIsClosingSession(false); + setAppSessionStatus('Support sent!'); + + // Clear status after a delay + setTimeout(() => { + setAppSessionStatus(null); + }, 3000); } // Handle errors if (response.method === RPCMethod.Error) { console.error('RPC Error:', response.params); - - if (isTransferring) { - setIsTransferring(false); - setTransferStatus(null); - alert(`Transfer failed: ${response.params.error}`); + + if (isCreatingSession) { + setIsCreatingSession(false); + setAppSessionStatus(null); + pendingCloseRef.current = null; + alert(`App session creation failed: ${response.params.error}`); + } else if (isClosingSession) { + setIsClosingSession(false); + setAppSessionStatus(null); + pendingCloseRef.current = null; + alert(`App session closure failed: ${response.params.error}`); } else { // Other errors (like auth failures) removeJWT(); @@ -278,7 +376,7 @@ export function App() { webSocketService.addMessageListener(handleMessage); return () => webSocketService.removeMessageListener(handleMessage); - }, [walletClient, sessionKey, sessionExpireTimestamp, account, isTransferring]); + }, [walletClient, sessionKey, sessionExpireTimestamp, account, isCreatingSession, isClosingSession]); const connectWallet = async () => { if (!window.ethereum) { @@ -356,21 +454,20 @@ export function App() {
- - {/* FINAL: Status message for transfers */} - {transferStatus && ( + {/* CHAPTER 5: Status message for app sessions */} + {appSessionStatus && (
- {transferStatus} + {appSessionStatus}
)} - {/* CHAPTER 4: Pass authentication state to enable balance-dependent features */} -
diff --git a/src/hooks/useCloseAppSession.ts b/src/hooks/useCloseAppSession.ts new file mode 100644 index 0000000..2bd6c02 --- /dev/null +++ b/src/hooks/useCloseAppSession.ts @@ -0,0 +1,65 @@ +// CHAPTER 5: Custom hook for closing application sessions +import { useCallback } from 'preact/hooks'; +import { + createCloseAppSessionMessage, + AccountID, + CloseAppSessionRequestParams, + RPCAppSessionAllocation, + createECDSAMessageSigner, +} from '@erc7824/nitrolite'; +import { webSocketService } from '../lib/websocket'; +import type { SessionKey } from '../lib/utils'; + +export interface CloseAppSessionResult { + success: boolean; + error?: string; +} + +export const useCloseAppSession = (sessionKey: SessionKey | null, isAuthenticated: boolean) => { + const closeAppSession = useCallback( + async ( + appSessionId: AccountID, + finalAllocations: RPCAppSessionAllocation[], + sessionData?: string + ): Promise => { + if (!isAuthenticated || !sessionKey) { + return { success: false, error: 'Please authenticate first' }; + } + + if (!appSessionId) { + return { success: false, error: 'Application session ID is required' }; + } + + if (!finalAllocations || finalAllocations.length === 0) { + return { success: false, error: 'Final allocations are required' }; + } + + try { + // Create session signer + const sessionSigner = createECDSAMessageSigner(sessionKey.privateKey); + + // Create close request + const closeRequest: CloseAppSessionRequestParams = { + app_session_id: appSessionId, + allocations: finalAllocations, + ...(sessionData && { session_data: sessionData }), + }; + + // Create signed message + const signedMessage = await createCloseAppSessionMessage(sessionSigner, closeRequest); + + console.log('Sending close app session request...'); + webSocketService.send(signedMessage); + + return { success: true }; + } catch (error) { + console.error('Failed to close app session:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to close app session'; + return { success: false, error: errorMsg }; + } + }, + [sessionKey, isAuthenticated] + ); + + return { closeAppSession }; +}; diff --git a/src/hooks/useCreateAppSession.ts b/src/hooks/useCreateAppSession.ts new file mode 100644 index 0000000..0a4dd7d --- /dev/null +++ b/src/hooks/useCreateAppSession.ts @@ -0,0 +1,99 @@ +// CHAPTER 5: Custom hook for creating application sessions +import { useCallback } from 'preact/hooks'; +import { + createAppSessionMessage, + RPCAppDefinition, + RPCAppSessionAllocation, + createECDSAMessageSigner, +} from '@erc7824/nitrolite'; +import type { Hex } from 'viem'; +import { webSocketService } from '../lib/websocket'; +import type { SessionKey } from '../lib/utils'; + +export interface CreateAppSessionResult { + success: boolean; + appSessionId?: string; + error?: string; +} + +export interface AppSessionConfig { + from: string; // Participant A - the one allocating funds + to: string; // Participant B - the recipient + amount: string; // Amount to allocate from A + sessionData?: string; // JSON string containing game/app-specific data + application?: string; // Application identifier (defaults to 'clearnode') + challenge?: number; // Challenge period in seconds (defaults to 0) +} + +const DEFAULT_PROTOCOL = 'NitroRPC/0.4'; +const DEFAULT_WEIGHTS = [100, 0]; +const DEFAULT_QUORUM = 100; +const DEFAULT_APPLICATION = 'clearnode'; + +export const useCreateAppSession = (sessionKey: SessionKey | null, isAuthenticated: boolean) => { + const createAppSession = useCallback( + async (config: AppSessionConfig): Promise => { + if (!isAuthenticated || !sessionKey) { + return { success: false, error: 'Please authenticate first' }; + } + + try { + const { + from, + to, + amount, + sessionData, + application = DEFAULT_APPLICATION, + challenge = 0, + } = config; + + // Create app definition + const appDefinition: RPCAppDefinition = { + application, + protocol: DEFAULT_PROTOCOL, + participants: [from, to] as Hex[], + weights: DEFAULT_WEIGHTS, + quorum: DEFAULT_QUORUM, + challenge, + nonce: Date.now(), + }; + + // Create allocations: A puts in funds, B puts in 0 + const allocations: RPCAppSessionAllocation[] = [ + { + participant: from as Hex, + asset: 'usdc', + amount: amount, + }, + { + participant: to as Hex, + asset: 'usdc', + amount: '0', + }, + ]; + + // Create session signer + const sessionSigner = createECDSAMessageSigner(sessionKey.privateKey); + + // Create signed message with session_data if provided + const signedMessage = await createAppSessionMessage(sessionSigner, { + definition: appDefinition, + allocations, + ...(sessionData && { session_data: sessionData }), + }); + + console.log('Sending create app session request...'); + webSocketService.send(signedMessage); + + return { success: true }; + } catch (error) { + console.error('Failed to create app session:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to create app session'; + return { success: false, error: errorMsg }; + } + }, + [sessionKey, isAuthenticated] + ); + + return { createAppSession }; +}; diff --git a/src/hooks/useTransfer.ts b/src/hooks/useTransfer.ts deleted file mode 100644 index ed95ea6..0000000 --- a/src/hooks/useTransfer.ts +++ /dev/null @@ -1,47 +0,0 @@ -// 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 6cff65d..57484ad 100644 --- a/src/index.css +++ b/src/index.css @@ -176,7 +176,7 @@ select { background-color: #da3633; } -/* FINAL: Transfer status styles */ +/* CHAPTER 5: App session status styles (replaces transfer status) */ .transfer-status { text-align: center; padding: 1rem;