laconic-deployer-frontend/docs/architecture/wallet_migration/2-phase-2-wallet-ui.md

15 KiB

Phase 2: Wallet UI Components Implementation

Overview

Phase 2 focuses on building UI components for the wallet and integrating them with the Next.js frontend. This phase transforms the core wallet functionality into usable UI components and connects them to the application.

Timeline

Duration: 2 weeks Dependencies: Phase 1 (wallet-core package) Team: Frontend team

Directory Structure

graph TD
    A[services/ui/src/wallet] --> B[components/]
    A --> C[hooks/]
    A --> D[providers/]
    
    B --> B1[WalletConnectButton.tsx]
    B --> B2[WalletModal.tsx]
    B --> B3[TransactionApproval.tsx]
    B --> B4[SignMessageModal.tsx]
    B --> B5[AccountSelector.tsx]
    B --> B6[NetworkSelector.tsx]
    B --> B7[BalanceDisplay.tsx]
    
    C --> C1[useWalletUI.ts]
    C --> C2[useTransaction.ts]
    
    D --> D1[WalletUIProvider.tsx]
    
    E[apps/deploy-fe/src] --> F[components/wallet/]
    F --> F1[WalletProvider.tsx]
    F --> F2[ConnectWallet.tsx]
    
    G[apps/deploy-fe/src/app/api] --> H[wallet/]
    H --> H1[balance/route.ts]
    H --> H2[sign/route.ts]
    H --> H3[connect/route.ts]

Migration Reference

These implementations adapt functionality from:

  • WalletModal.tsx → from /repos/laconic-wallet-web/src/components/wallet/AutoSignInIFrameModal.tsx
  • TransactionApproval.tsx → from /repos/laconic-wallet-web/src/screens/ApproveTransaction.tsx
  • WalletUIProvider.tsx → from /repos/laconic-wallet-web/src/context/WalletContextProvider.tsx

Security Considerations

Before implementation, please note these important security considerations:

  1. CORS Configuration: API routes must have proper CORS configuration to prevent unauthorized access
  2. Input Validation: All user input and transaction data must be validated server-side using Zod or similar
  3. Error Handling: Implement comprehensive error handling to prevent information leakage
  4. Key Management: Never expose private keys in client-side code
  5. Signature Verification: Always verify signatures on the server-side
  6. Responsive Design: Wallet UI components should be mobile-responsive with proper accessibility attributes

Step-by-Step Implementation

1. Create Wallet UI Components

Create the directory structure:

mkdir -p services/ui/src/wallet/components
mkdir -p services/ui/src/wallet/hooks
mkdir -p services/ui/src/wallet/providers

2. Implement Key Components

Create services/ui/src/wallet/components/WalletConnectButton.tsx:

'use client'

import { Button } from '@workspace/ui/components/button'
import React from 'react'
import { useWalletUI } from '../hooks/useWalletUI'

interface WalletConnectButtonProps {
  variant?: 'default' | 'outline' | 'ghost'
  size?: 'default' | 'sm' | 'lg'
}

export function WalletConnectButton({
  variant = 'default',
  size = 'default'
}: WalletConnectButtonProps) {
  const { isConnected, connect, disconnect, wallet } = useWalletUI()

  return (
    <Button
      variant={variant}
      size={size}
      onClick={isConnected ? disconnect : connect}
    >
      {isConnected
        ? `Connected: ${wallet?.address?.slice(0, 6)}...${wallet?.address?.slice(-4)}`
        : 'Connect Wallet'}
    </Button>
  )
}

Create services/ui/src/wallet/components/WalletModal.tsx:

'use client'

import React, { useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@workspace/ui/components/dialog'
import { Button } from '@workspace/ui/components/button'
import { useWalletUI } from '../hooks/useWalletUI'
import { signIn } from '@workspace/wallet-core'

export function WalletModal() {
  const { isOpen, closeModal, wallet, setWallet, connectWallet } = useWalletUI()

  useEffect(() => {
    async function handleSignIn() {
      if (wallet?.address) {
        try {
          // Replace the direct iframe messaging with server action
          await signIn(wallet.address)
        } catch (error) {
          console.error('Error during sign-in:', error)
        }
      }
    }

    if (wallet?.address) {
      handleSignIn()
    }
  }, [wallet])

  return (
    <Dialog open={isOpen} onOpenChange={closeModal}>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Connect your wallet</DialogTitle>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <Button onClick={connectWallet}>
            Connect Laconic Wallet
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  )
}

Create services/ui/src/wallet/components/TransactionApproval.tsx:

'use client'

import React, { useState } from 'react'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle
} from '@workspace/ui/components/card'
import { Button } from '@workspace/ui/components/button'
import { useWalletUI } from '../hooks/useWalletUI'

interface TransactionApprovalProps {
  amount: string
  recipient: string
  denom: string
  onApprove: () => void
  onReject: () => void
}

export function TransactionApproval({
  amount,
  recipient,
  denom,
  onApprove,
  onReject
}: TransactionApprovalProps) {
  const { wallet } = useWalletUI()
  const [isProcessing, setIsProcessing] = useState(false)

  const handleApprove = async () => {
    setIsProcessing(true)
    try {
      await onApprove()
    } finally {
      setIsProcessing(false)
    }
  }

  return (
    <Card className="w-full max-w-md mx-auto">
      <CardHeader>
        <CardTitle>Approve Transaction</CardTitle>
        <CardDescription>Review and approve this transaction</CardDescription>
      </CardHeader>
      <CardContent>
        <div className="space-y-4">
          <div className="grid grid-cols-3 gap-2">
            <div className="text-sm font-medium">From:</div>
            <div className="col-span-2 text-sm truncate">
              {wallet?.address || 'Not connected'}
            </div>
          </div>
          <div className="grid grid-cols-3 gap-2">
            <div className="text-sm font-medium">To:</div>
            <div className="col-span-2 text-sm truncate">{recipient}</div>
          </div>
          <div className="grid grid-cols-3 gap-2">
            <div className="text-sm font-medium">Amount:</div>
            <div className="col-span-2 text-sm">
              {amount} {denom}
            </div>
          </div>
        </div>
      </CardContent>
      <CardFooter className="flex justify-between">
        <Button
          variant="outline"
          onClick={onReject}
          disabled={isProcessing}
        >
          Reject
        </Button>
        <Button
          onClick={handleApprove}
          disabled={isProcessing}
        >
          {isProcessing ? 'Processing...' : 'Approve'}
        </Button>
      </CardFooter>
    </Card>
  )
}

3. Implement UI Hooks

Create services/ui/src/wallet/hooks/useWalletUI.ts:

'use client'

import { useEffect, useState } from 'react'
import { useWallet } from '@workspace/wallet-core'
import { toast } from 'sonner'

export function useWalletUI() {
  const walletCore = useWallet()
  const [isOpen, setIsOpen] = useState(false)
  
  const openModal = () => setIsOpen(true)
  const closeModal = () => setIsOpen(false)
  
  const connectWallet = async () => {
    try {
      // This now uses server actions instead of iframe messaging
      await walletCore.connect()
      closeModal()
      toast.success('Wallet connected')
    } catch (error) {
      toast.error('Failed to connect wallet')
      console.error(error)
    }
  }
  
  const disconnect = () => {
    walletCore.disconnect()
    toast.info('Wallet disconnected')
  }
  
  // Expose the core wallet methods and UI-specific methods
  return {
    ...walletCore,
    isOpen,
    openModal,
    closeModal,
    connectWallet
  }
}

4. Implement Provider

Create services/ui/src/wallet/providers/WalletUIProvider.tsx:

'use client'

import React, { createContext, useContext, useEffect, useState } from 'react'
import { WalletProvider } from '@workspace/wallet-core'
import { WalletModal } from '../components/WalletModal'
import { toast } from 'sonner'

interface WalletUIContextType {
  openWalletModal: () => void
  closeWalletModal: () => void
  isModalOpen: boolean
}

const WalletUIContext = createContext<WalletUIContextType>({
  openWalletModal: () => {},
  closeWalletModal: () => {},
  isModalOpen: false
})

export function useWalletUIContext() {
  return useContext(WalletUIContext)
}

export function WalletUIProvider({ children }: { children: React.ReactNode }) {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const openWalletModal = () => setIsModalOpen(true)
  const closeWalletModal = () => setIsModalOpen(false)

  return (
    <WalletProvider>
      <WalletUIContext.Provider
        value={{
          openWalletModal,
          closeWalletModal,
          isModalOpen
        }}
      >
        {children}
        <WalletModal open={isModalOpen} onOpenChange={setIsModalOpen} />
      </WalletUIContext.Provider>
    </WalletProvider>
  )
}

5. Create Index Exports

Create services/ui/src/wallet/index.ts:

// Component exports
export { WalletConnectButton } from './components/WalletConnectButton'
export { WalletModal } from './components/WalletModal'
export { TransactionApproval } from './components/TransactionApproval'
export { AccountSelector } from './components/AccountSelector'
export { NetworkSelector } from './components/NetworkSelector'
export { BalanceDisplay } from './components/BalanceDisplay'

// Hook exports
export { useWalletUI } from './hooks/useWalletUI'
export { useTransaction } from './hooks/useTransaction'

// Provider exports
export { WalletUIProvider, useWalletUIContext } from './providers/WalletUIProvider'

6. Create API Routes for Wallet Communication

Create apps/deploy-fe/src/app/api/wallet/balance/route.ts:

import { checkBalance } from '@workspace/wallet-core'
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const { userId } = await auth()
  
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const body = await request.json()
    const { chainId, address, amount } = body
    
    const result = await checkBalance(chainId, address, amount)
    
    return NextResponse.json(result)
  } catch (error) {
    console.error('Balance check error:', error)
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Unknown error' },
      { status: 500 }
    )
  }
}

Create apps/deploy-fe/src/app/api/wallet/sign/route.ts:

import { validateSignature } from '@workspace/wallet-core'
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'

// Define validation schema
const signRequestSchema = z.object({
  message: z.string().min(1, "Message is required"),
  signature: z.string().min(1, "Signature is required")
})

export async function POST(request: Request) {
  const { userId } = await auth()
  
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const body = await request.json()
    
    // Validate input data
    const result = signRequestSchema.safeParse(body)
    if (!result.success) {
      return NextResponse.json(
        { error: 'Invalid request data', details: result.error.format() }, 
        { status: 400 }
      )
    }
    
    const { message, signature } = result.data
    
    // Add additional validation for signature format if needed
    // (e.g., check if it matches expected pattern)
    
    const validationResult = await validateSignature(message, signature)
    
    return NextResponse.json(validationResult)
  } catch (error) {
    console.error('Signature validation error:', error)
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Unknown error' },
      { status: 500 }
    )
  }
}

7. Integrate with Next.js App

Create apps/deploy-fe/src/components/wallet/WalletProvider.tsx:

'use client'

import { ReactNode } from 'react'
import { WalletUIProvider } from '@workspace/ui/wallet'

export function WalletProvider({ children }: { children: ReactNode }) {
  return <WalletUIProvider>{children}</WalletUIProvider>
}

Update apps/deploy-fe/src/components/providers/index.tsx:

'use client'

import React, { ReactNode } from 'react'
import { ThemeProvider } from 'next-themes'
import { WalletProvider } from '../wallet/WalletProvider'

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="light" enableSystem>
      <WalletProvider>{children}</WalletProvider>
    </ThemeProvider>
  )
}

Integration Example

Create a wallet connection button in apps/deploy-fe/src/components/foundation/top-navigation/TopNavigation.tsx:

'use client'

import React from 'react'
import { WalletConnectButton } from '@workspace/ui/wallet'

export function TopNavigation() {
  return (
    <header className="border-b sticky top-0 z-40 bg-background">
      <div className="container flex h-16 items-center px-4 sm:px-6">
        <nav className="flex-1 flex items-center justify-between">
          {/* Logo and other navigation items */}
          
          {/* Right side items */}
          <div className="flex items-center space-x-4">
            <WalletConnectButton />
            {/* Other right-side items */}
          </div>
        </nav>
      </div>
    </header>
  )
}

Replace apps/deploy-fe/src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx with server action:

'use client'

import { useEffect } from 'react'
import { checkBalance } from '@workspace/wallet-core'

interface CheckBalanceProps {
  onBalanceChange: (value: boolean | undefined) => void
  isPollingEnabled: boolean
  amount: string
}

export default function CheckBalance({
  onBalanceChange,
  isPollingEnabled,
  amount
}: CheckBalanceProps) {
  useEffect(() => {
    let interval: NodeJS.Timeout

    const fetchBalance = async () => {
      try {
        // Uses server action instead of iframe
        const chainId = process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID || ''
        const result = await fetch('/api/wallet/balance', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ chainId, amount })
        }).then(res => res.json())
        
        onBalanceChange(result.hasEnoughBalance)
      } catch (error) {
        console.error('Error checking balance:', error)
        onBalanceChange(undefined)
      }
    }

    fetchBalance()

    if (isPollingEnabled) {
      interval = setInterval(fetchBalance, 5000)
    }

    return () => {
      if (interval) clearInterval(interval)
    }
  }, [amount, isPollingEnabled, onBalanceChange])

  // This component doesn't render anything visually
  return null
}

Testing

  1. Build the UI package:
cd services/ui
pnpm build
  1. Run the frontend app:
cd apps/deploy-fe
pnpm dev
  1. Test wallet functionality:
    • Connection button in navigation
    • Login flow with wallet
    • Balance checks
    • Transaction signing

Next Steps

  • Phase 3: Complete Clerk auth integration
  • Phase 3: Implement comprehensive middleware
  • Phase 3: Develop unified authentication flow