diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7d7b041 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# WalletConnect Configuration +# Get your Project ID from: https://cloud.walletconnect.com +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id_here + +# Network Configuration +NEXT_PUBLIC_NETWORK=testnet +# Options: testnet, mainnet + +# API Configuration +NEXT_PUBLIC_API_URL=/api + +# Soroban Contract Configuration +NEXT_PUBLIC_CONTRACT_ID=CDUMMY diff --git a/WALLETCONNECT_SETUP.md b/WALLETCONNECT_SETUP.md new file mode 100644 index 0000000..f1ae110 --- /dev/null +++ b/WALLETCONNECT_SETUP.md @@ -0,0 +1,264 @@ +# WalletConnect Integration Guide + +This document describes the real WalletConnect integration for RentarPay, replacing the previous mock implementation. + +## Overview + +WalletConnect enables secure communication between RentarPay and user Stellar wallets, allowing users to: +- Connect their wallet without sharing private keys +- Sign authentication challenges securely +- Perform transactions through their wallet + +## Setup Instructions + +### 1. Get a WalletConnect Project ID + +1. Visit [WalletConnect Cloud](https://cloud.walletconnect.com) +2. Sign in or create an account +3. Create a new project +4. Copy your **Project ID** + +### 2. Configure Environment Variables + +Create a `.env.local` file in the project root: + +```env +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id_here +NEXT_PUBLIC_NETWORK=testnet +``` + +Or copy and modify the provided `.env.example`: + +```bash +cp .env.example .env.local +``` + +Then edit `.env.local` and set `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`. + +### 3. Install Dependencies + +```bash +npm install +# or +yarn install +# or +pnpm install +``` + +This will install: +- `@walletconnect/sign-client@^2.13.0` - WalletConnect client library +- `@walletconnect/modal@^2.6.2` - WalletConnect modal UI + +### 4. Run Development Server + +```bash +npm run dev +``` + +## Architecture + +### Key Components + +#### `src/lib/wallet/walletconnect.ts` +- Real WalletConnect client implementation +- Manages SignClient initialization and lifecycle +- Handles Stellar namespace configuration +- Implements `stellar_signMessage` CAIP method +- Session persistence and restoration + +#### `src/contexts/wallet-context.tsx` +- React context for wallet state management +- Coordinates Freighter and WalletConnect connections +- SEP-10 challenge signing flow +- Session restoration on app mount + +#### `next.config.ts` +- Exposes `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` environment variable + +### Authentication Flow + +``` +1. User clicks "Connect Wallet" + ↓ +2. WalletConnect modal opens with QR code + ↓ +3. User scans with Stellar wallet (mobile app or browser extension) + ↓ +4. Wallet approves connection + ↓ +5. SignClient receives session approval with Stellar account + ↓ +6. Real Stellar public key extracted and displayed + ↓ +7. Challenge fetched from backend + ↓ +8. User signs challenge in wallet + ↓ +9. Signed challenge sent to backend for verification + ↓ +10. JWT token issued, user authenticated +``` + +## Key Features + +### ✅ Real Wallet Integration +- Connects to actual Stellar wallets (Freighter, Albedo, etc.) +- Retrieves real Stellar public keys +- No mock keys or signatures + +### ✅ Security +- Uses WalletConnect v2 protocol +- Private keys never leave the user's wallet +- Secure message signing via `stellar_signMessage` +- Public key validation using Stellar SDK + +### ✅ Session Management +- Automatic session persistence +- Session restoration on app reload +- Graceful disconnection handling + +### ✅ Network Support +- Testnet support for development +- Mainnet support for production +- Network-aware configuration + +### ✅ Error Handling +- Project ID validation at initialization +- Public key format validation +- Detailed error messages for debugging + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | ✅ Yes | - | WalletConnect Project ID from Cloud dashboard | +| `NEXT_PUBLIC_NETWORK` | ❌ No | `testnet` | Stellar network: `testnet` or `mainnet` | +| `NEXT_PUBLIC_API_URL` | ❌ No | `/api` | Backend API URL | +| `NEXT_PUBLIC_CONTRACT_ID` | ❌ No | `CDUMMY` | Soroban contract address | + +## Testing + +### Manual Testing + +1. **Connect with WalletConnect:** + - Click "Connect Wallet" button + - Select "WalletConnect" option + - Scan QR code with Stellar wallet + - Approve connection + +2. **Verify Public Key:** + - After connection, verify the displayed public key starts with "G" + - Confirm it matches your wallet's public key + +3. **Sign Challenge:** + - Complete login flow + - Verify you're authenticated with correct account + +### Network Testing + +**Testnet (Default):** +```env +NEXT_PUBLIC_NETWORK=testnet +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= +``` + +**Mainnet:** +```env +NEXT_PUBLIC_NETWORK=mainnet +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= +``` + +## Troubleshooting + +### "WalletConnect Project ID not configured" +- Ensure `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` is set in `.env.local` +- Restart dev server after changing environment variables + +### "No Stellar accounts found in WalletConnect session" +- Verify the connected wallet supports Stellar +- Check WalletConnect modal shows Stellar chains +- Try reconnecting + +### "Invalid Stellar public key format" +- Verify your wallet's public key is valid +- Public keys should start with "G" +- Check network matches (testnet vs mainnet) + +### QR Code not displaying +- Ensure `@walletconnect/modal` is installed +- Check browser console for errors +- Verify Project ID is valid + +## API Integration + +### Challenge Endpoint +```typescript +POST /api/auth/challenge +{ + "publicKey": "GXXXXXX..." +} +``` + +Returns: +```json +{ + "challenge": "rentar.io - SEP-10 challenge...", + "token": "challenge_token_..." +} +``` + +### Verify Endpoint +```typescript +POST /api/auth/verify +{ + "publicKey": "GXXXXXX...", + "signedChallenge": "signed_message_from_wallet" +} +``` + +Returns: +```json +{ + "token": "jwt_token", + "user": { + "id": "user_id", + "publicKey": "GXXXXXX...", + "displayName": "User Name", + "email": "user@example.com", + "kycStatus": "verified" + } +} +``` + +## Security Considerations + +1. **Never expose the Project ID in production code** - It's safe to use `NEXT_PUBLIC_` prefix because this Project ID is meant to be public +2. **Validate all signatures on the backend** - Never trust client-side signature validation +3. **Use HTTPS in production** - WalletConnect requires secure connections +4. **Implement rate limiting** - Protect challenge endpoint from abuse +5. **Monitor session expiry** - Implement session timeout and refresh + +## Migration from Mock + +If you were previously using the mock WalletConnect implementation: + +1. The API remains the same +2. Update environment variables +3. No changes needed to `wallet-context.tsx` +4. `walletconnect.ts` is now a real implementation +5. Session restoration is automatic + +## Resources + +- [WalletConnect Documentation](https://docs.walletconnect.com) +- [WalletConnect Cloud Dashboard](https://cloud.walletconnect.com) +- [Stellar Documentation](https://developers.stellar.org) +- [WalletConnect Stellar CAIP](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0005.md) + +## Support + +For issues or questions: +1. Check this guide's troubleshooting section +2. Review WalletConnect documentation +3. Check browser console for error messages +4. Open an issue with error logs diff --git a/next.config.ts b/next.config.ts index fac7266..5274fe5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "/api", NEXT_PUBLIC_NETWORK: process.env.NEXT_PUBLIC_NETWORK || "testnet", NEXT_PUBLIC_CONTRACT_ID: process.env.NEXT_PUBLIC_CONTRACT_ID || "CDUMMY", + NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, }, images: { remotePatterns: [{ protocol: "https", hostname: "**" }], diff --git a/package.json b/package.json index 1949a47..bc1a2f2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^16.0.1", "@tanstack/react-query": "^5.101.2", + "@walletconnect/modal": "^2.6.2", + "@walletconnect/sign-client": "^2.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", diff --git a/src/contexts/wallet-context.tsx b/src/contexts/wallet-context.tsx index 8d4089f..41d7583 100644 --- a/src/contexts/wallet-context.tsx +++ b/src/contexts/wallet-context.tsx @@ -1,5 +1,5 @@ "use client" -import { createContext, useContext, useState, ReactNode } from "react" +import { createContext, useContext, useState, ReactNode, useEffect } from "react" import { connectFreighter, signWithFreighter, isFreighterInstalled } from "@/lib/wallet/freighter" import { walletConnectService } from "@/lib/wallet/walletconnect" import { useAuth } from "./auth-context" @@ -26,6 +26,23 @@ export function WalletProvider({ children }: { children: ReactNode }) { const [isConnecting, setIsConnecting] = useState(false) const { login, getChallengeForKey, logout } = useAuth() + // Restore WalletConnect session on mount + useEffect(() => { + const restoreWCSession = async () => { + try { + const session = await walletConnectService.restoreSession() + if (session) { + setPublicKey(session.publicKey) + setWalletType("walletconnect") + } + } catch (error) { + console.warn("Failed to restore WalletConnect session:", error) + } + } + + restoreWCSession() + }, []) + const handleSep10Login = async (pk: string, signer: (msg: string) => Promise) => { const challenge = await getChallengeForKey(pk) const signed = await signer(challenge) diff --git a/src/lib/wallet/walletconnect.ts b/src/lib/wallet/walletconnect.ts index 49a2a4c..c483e61 100644 --- a/src/lib/wallet/walletconnect.ts +++ b/src/lib/wallet/walletconnect.ts @@ -1,35 +1,268 @@ "use client" -// Simplified WalletConnect mock for demo - in production you'd use @walletconnect/sign-client -export interface WCConnection { +import SignClient from "@walletconnect/sign-client" +import { Web3Modal } from "@walletconnect/modal" +import type { SessionTypes } from "@walletconnect/types" +import * as StellarSdk from "@stellar/stellar-sdk" + +// Configuration +const PROJECT_ID = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID +const NETWORK = process.env.NEXT_PUBLIC_NETWORK || "testnet" + +// Stellar WalletConnect CAIP namespace +const STELLAR_NAMESPACE = { + chains: [ + NETWORK === "mainnet" + ? "stellar:mainnet-soroban" + : "stellar:testnet-soroban" + ], + methods: ["stellar_signMessage"], + events: ["stellar_chainChanged"], +} + +interface WCConnection { topic: string publicKey: string + session: SessionTypes.Struct } +let signClient: SignClient | null = null +let web3Modal: Web3Modal | null = null let wcSession: WCConnection | null = null -export const walletConnectService = { - async connect(): Promise { - // Simulate WC modal flow - return new Promise((resolve) => { - const mockKey = `G${Math.random().toString(36).substring(2, 15).toUpperCase()}${Math.random().toString(36).substring(2, 30).toUpperCase()}` - const session = { topic: `wc-${Date.now()}`, publicKey: mockKey.padEnd(56, 'X').slice(0,56) } - wcSession = session - setTimeout(() => resolve(session), 800) - }) - }, - - async disconnect() { +/** + * Validates WalletConnect Project ID is configured + */ +function validateProjectId(): void { + if (!PROJECT_ID) { + throw new Error( + "WalletConnect Project ID not configured. Set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID environment variable." + ) + } +} + +/** + * Initializes WalletConnect SignClient and Modal + */ +async function initializeSignClient(): Promise { + if (signClient) return signClient + + validateProjectId() + + signClient = await SignClient.init({ + projectId: PROJECT_ID!, + metadata: { + name: "RentarPay", + description: "Stellar-based rent payment platform", + url: typeof window !== "undefined" ? window.location.origin : "https://rentarpay.io", + icons: ["https://rentarpay.io/logo.png"], + }, + }) + + // Initialize Web3Modal for UI + web3Modal = new Web3Modal({ + projectId: PROJECT_ID!, + chains: STELLAR_NAMESPACE.chains, + walletConnectVersion: 2, + }) + + // Subscribe to events + signClient.on("session_ping", ({ id }) => { + console.log("WalletConnect ping received:", id) + }) + + signClient.on("session_event", (event) => { + console.log("WalletConnect event:", event) + }) + + signClient.on("session_delete", () => { wcSession = null - }, + console.log("WalletConnect session deleted") + }) - getSession() { - return wcSession - }, + return signClient +} + +/** + * Creates a WalletConnect session with a Stellar wallet + */ +async function createSession(): Promise { + const client = await initializeSignClient() - async signMessage(message: string): Promise { - if (!wcSession) throw new Error("No WalletConnect session") - // Mock signature - in production this would go through WC sign client - return `signed_${btoa(message).slice(0,20)}_${Date.now()}` + // Generate connection URI + const { uri, approval } = await client.connect({ + requiredNamespaces: { + stellar: STELLAR_NAMESPACE, + }, + }) + + if (!uri) { + throw new Error("Failed to generate WalletConnect connection URI") } + + // Open modal with URI + if (web3Modal) { + await web3Modal.openModal({ uri }) + } + + // Wait for session approval + const session = await approval() + + if (!session) { + throw new Error("WalletConnect session approval failed or was cancelled") + } + + // Extract Stellar account from session + const stellarNamespace = session.namespaces?.stellar + if (!stellarNamespace?.accounts || stellarNamespace.accounts.length === 0) { + throw new Error("No Stellar accounts found in WalletConnect session") + } + + // Parse account in format: stellar:: + const accountString = stellarNamespace.accounts[0] + const publicKey = accountString.split(":").pop() + + if (!publicKey) { + throw new Error("Failed to extract public key from WalletConnect session") + } + + // Validate Stellar public key format + if (!StellarSdk.StrKey.isValidEd25519PublicKey(publicKey)) { + throw new Error(`Invalid Stellar public key format: ${publicKey}`) + } + + wcSession = { + topic: session.topic, + publicKey, + session, + } + + return wcSession +} + +/** + * Signs a message using WalletConnect + * Implements Stellar WalletConnect CAIP for message signing + */ +async function signMessage(message: string): Promise { + if (!wcSession) { + throw new Error("No WalletConnect session active. Please connect first.") + } + + const client = await initializeSignClient() + + // Encode message as base64 per Stellar spec + const messageB64 = Buffer.from(message).toString("base64") + + try { + const response = await client.request({ + topic: wcSession.topic, + chainId: STELLAR_NAMESPACE.chains[0], + request: { + method: "stellar_signMessage", + params: { + message: messageB64, + }, + }, + }) + + // Response should contain signed message + if (typeof response === "object" && response !== null && "signature" in response) { + return (response as any).signature + } + + if (typeof response === "string") { + return response + } + + throw new Error("Invalid signature response from wallet") + } catch (error: any) { + throw new Error(`Failed to sign message: ${error.message}`) + } +} + +/** + * Disconnects WalletConnect session + */ +async function disconnect(): Promise { + if (!wcSession) return + + const client = await initializeSignClient() + + try { + await client.disconnect({ + topic: wcSession.topic, + reason: { + code: 6000, + message: "User disconnected", + }, + }) + } catch (error: any) { + console.warn("Error disconnecting WalletConnect:", error.message) + } + + wcSession = null + + if (web3Modal) { + web3Modal.closeModal() + } +} + +/** + * Restores session from persistent storage if available + */ +async function restoreSession(): Promise { + if (wcSession) return wcSession + + const client = await initializeSignClient() + + // Get all active sessions + const sessions = Object.values(client.session.getAll()) + + if (sessions.length === 0) return null + + // Use the most recent session (assumes one active Stellar session) + const session = sessions[0] + const stellarNamespace = session.namespaces?.stellar + + if (!stellarNamespace?.accounts || stellarNamespace.accounts.length === 0) { + return null + } + + const accountString = stellarNamespace.accounts[0] + const publicKey = accountString.split(":").pop() + + if (!publicKey) return null + + wcSession = { + topic: session.topic, + publicKey, + session, + } + + return wcSession +} + +/** + * Returns current session or null + */ +function getSession(): WCConnection | null { + return wcSession +} + +/** + * Returns current public key or null + */ +function getPublicKey(): string | null { + return wcSession?.publicKey || null +} + +export const walletConnectService = { + connect: createSession, + disconnect, + signMessage, + getSession, + getPublicKey, + restoreSession, + isInitialized: () => signClient !== null, }