chore: LAC-126 Components Migrated (#5)

* chore: Onboarding components migrated

* chore: wrap pages

* chore(components): Migrate core and navigation components

Migrated core and navigation components from Snowballtools repository:
- Core components: Dropdown, FormatMilliSecond, Logo, SearchBar, Stepper, StopWatch, VerticalStepper
- Navigation components: GitHubSessionButton, LaconicIcon, NavigationActions, WalletSessionId

Follows component migration guidelines with:
- Tailwind styling
- Consistent file structure
- TypeScript type definitions
- README documentation

* chore(routing): Basic routing within navigation

* chore: Import project cards and deps

* fix: start services in turborepo

* docs: phases for wallet service
This commit is contained in:
Ian Cameron Lyles
2025-03-24 11:48:30 -07:00
committed by GitHub
parent 9f9870113c
commit 402762425b
10 changed files with 2056 additions and 15 deletions
+2 -3
View File
@@ -1,4 +1,3 @@
import { createServer } from 'node:http'
import {
ApolloServerPluginDrainHttpServer,
ApolloServerPluginLandingPageLocalDefault,
@@ -9,12 +8,12 @@ import cors from 'cors'
import debug from 'debug'
import express from 'express'
import session from 'express-session'
import { createServer } from 'node:http'
import { makeExecutableSchema } from '@graphql-tools/schema'
import type { TypeSource } from '@graphql-tools/utils'
import type { ServerConfig } from './config'
import { DEFAULT_GQL_PATH } from './constants'
import authRouter from './routes/auth'
import githubRouter from './routes/github'
import stagingRouter from './routes/staging'
@@ -38,7 +37,7 @@ export const createAndStartServer = async (
resolvers: any,
service: Service
): Promise<ApolloServer> => {
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig
const { host, port, gqlPath = '/graphql' } = serverConfig
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session
const app = express()
+18 -10
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert'
import path from 'node:path'
import debug from 'debug'
import fs from 'fs-extra'
import assert from 'node:assert'
import path from 'node:path'
import type { Octokit } from 'octokit'
import toml from 'toml'
import type {
@@ -12,26 +12,34 @@ import type {
} from 'typeorm'
import type { Config } from './config'
import { DEFAULT_CONFIG_FILE_PATH } from './constants'
import type { PackageJSON } from './types'
interface PackageJSON {
name: string
description?: string
homepage?: string
license?: string
author?: string | { [key: string]: unknown }
version?: string
[key: string]: unknown
}
const log = debug('snowball:utils')
export async function getConfig() {
// TODO: get config path using cli
return await _getConfig<Config>(DEFAULT_CONFIG_FILE_PATH)
return await _getConfig<Config>(
path.join(__dirname, '../environments/local.toml')
)
}
const _getConfig = async <ConfigType>(
configFile: string
): Promise<ConfigType> => {
const configFilePath = path.resolve(configFile)
const fileExists = await fs.pathExists(configFilePath)
const fileExists = await fs.pathExists(configFile)
if (!fileExists) {
throw new Error(`Config file not found: ${configFilePath}`)
throw new Error(`Config file not found: ${configFile}`)
}
const config = toml.parse(await fs.readFile(configFilePath, 'utf8'))
const config = toml.parse(await fs.readFile(configFile, 'utf8'))
log('config', JSON.stringify(config, null, 2))
return config
+1 -1
View File
@@ -1,6 +1,6 @@
import { Providers } from '@/components/providers'
import { ClerkProvider } from '@clerk/nextjs'
// import '@workspace/ui/globals.css'
import '@workspace/ui/globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
@@ -1,6 +1,13 @@
import { getInitials } from '@/utils/getInitials'
import { Avatar, AvatarFallback, AvatarImage } from '@radix-ui/react-avatar'
import {
Avatar,
AvatarFallback,
AvatarImage
} from '@workspace/ui/components/avatar'
import type { Project } from '@workspace/gql-client'
import { Card, CardContent, CardHeader } from '@workspace/ui/components/card'
import { AlertTriangle } from 'lucide-react'
import { useRouter } from 'next/router'
@@ -0,0 +1,92 @@
# Laconic Wallet Integration Project
## Overview
This project migrates the Laconic wallet from an iframe-based implementation to a native Next.js 15 integration within our Turborepo. The goal is to eliminate cross-origin communication issues, improve security, and leverage Clerk authentication.
## Architecture
```mermaid
graph TD
subgraph "Frontend Application"
A[apps/deploy-fe] --> B[Clerk Auth]
A --> C[Wallet Integration]
end
subgraph "Monorepo Packages"
D[services/wallet-core] --> E[Crypto Operations]
D --> F[State Management]
D --> G[Server Actions]
H[services/ui/wallet] --> I[UI Components]
H --> J[Connection Modals]
end
B --- K[GitHub OAuth]
B --- L[Wallet Auth]
C --> D
C --> H
L --> D
```
## Implementation Phases
### Phase 1: Core Wallet Package (Weeks 1-2)
- Create `services/wallet-core` package
- Implement core wallet functionality
- Develop storage adapters
- Setup TypeScript interfaces
### Phase 2: UI Integration (Weeks 3-4)
- Create wallet UI components in `services/ui`
- Implement client hooks
- Connect wallet state management
- Create transaction signing UI
### Phase 3: Auth Integration (Weeks 5-6)
- Integrate with Clerk authentication
- Implement middleware protection
- Create unified auth flow
- Test complete flow
## Tech Stack
- **Frontend**: Next.js 15, React 19
- **Authentication**: Clerk with GitHub OAuth
- **State Management**: React Context + Server Components
- **Styling**: Tailwind CSS with shadcn/ui
- **Wallet**: Cosmos/Ethereum crypto
- **Validation**: Zod for type-safe schema validation
- **Security**: SIWE (Sign-In With Ethereum) standard
## Documentation References
- [Next.js 15 Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components)
- [Clerk Authentication](https://clerk.com/docs/references/nextjs/overview)
- [SIWE Protocol](https://docs.login.xyz/)
- [Cosmos SDK](https://docs.cosmos.network/main/architecture/adr-057-app-wiring)
- [Zod Schema Validation](https://zod.dev/)
## Migration Goals
1. **Remove iframe dependency**: Eliminate cross-origin communication issues
2. **Improve security**: Direct integration with authenticated routes
3. **Better UX**: Seamless authentication flow with GitHub and wallet
4. **Maintainability**: Proper separation of concerns in monorepo structure
## Deliverables
- `services/wallet-core` package
- Wallet UI components in `services/ui`
- Server actions for wallet operations
- Clerk integration for wallet authentication
- Comprehensive documentation
## Dependencies
- Clerk authentication setup
- Cosmos JS SDK
- Ethereum libraries
- SIWE (Sign-In With Ethereum)
@@ -0,0 +1,583 @@
# Phase 1: Wallet Core Package Implementation
## Overview
Phase 1 establishes the foundation by creating the `services/wallet-core` package that will replace the iframe-based wallet implementation. This package will contain all core wallet functionality without UI components.
## Timeline
**Duration**: 2 weeks
**Dependencies**: None
**Team**: Backend/Crypto team
## Directory Structure
```mermaid
graph TD
A[services/wallet-core] --> B[src/]
B --> C[accounts/]
B --> D[networks/]
B --> E[storage/]
B --> F[crypto/]
B --> G[actions/]
B --> H[hooks/]
B --> I[types/]
C --> C1[accounts.ts]
C --> C2[accountsContext.ts]
D --> D1[networks.ts]
D --> D2[networksContext.ts]
D --> D3[constants.ts]
E --> E1[keystore.ts]
E --> E2[localStorage.ts]
E --> E3[sessionStorage.ts]
F --> F1[eth.ts]
F --> F2[cosmos.ts]
F --> F3[signing.ts]
G --> G1[walletActions.ts]
H --> H1[useWallet.ts]
H --> H2[useNetwork.ts]
H --> H3[useAccounts.ts]
I --> I1[accounts.d.ts]
I --> I2[networks.d.ts]
I --> I3[storage.d.ts]
```
## Step-by-Step Implementation
### 1. Set up `services/wallet-core` package
```bash
mkdir -p services/wallet-core/src
cd services/wallet-core
```
Create `package.json`:
```json
{
"name": "@workspace/wallet-core",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"license": "MIT",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts",
"lint": "eslint src/",
"clean": "rm -rf .turbo dist node_modules",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@cosmjs/proto-signing": "^0.31.1",
"@cosmjs/stargate": "^0.31.1",
"ethers": "^6.8.1",
"siwe": "^2.1.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.5.2",
"@types/react": "^18.2.0",
"@workspace/typescript-config": "*",
"eslint": "^8.46.0",
"typescript": "^5.1.6",
"tsup": "^7.2.0"
},
"peerDependencies": {
"next": "^15.0.0",
"react": "^19.0.0"
}
}
```
Create `tsconfig.json`:
```json
{
"extends": "@workspace/typescript-config/react-library.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "../../services/ui/tailwind.config.ts"],
"exclude": ["node_modules", "dist"]
}
```
### 2. Implement core types and validation schemas
Create `src/types/accounts.d.ts`:
```typescript
export interface Account {
index: number;
address: string;
hdPath: string;
pubKey: string;
}
export interface WalletState {
accounts: Account[];
currentIndex: number;
isConnected: boolean;
isReady: boolean;
}
// Validation schemas (using Zod)
import { z } from 'zod';
// Pattern for valid Ethereum address
const ethereumAddressPattern = /^0x[a-fA-F0-9]{40}$/;
// Pattern for valid Cosmos address (using laconic prefix)
const cosmosAddressPattern = /^laconic[a-zA-Z0-9]{39,59}$/;
export const accountSchema = z.object({
index: z.number().int().nonnegative(),
address: z.string().refine(
val => ethereumAddressPattern.test(val) || cosmosAddressPattern.test(val),
{ message: "Invalid wallet address format" }
),
hdPath: z.string(),
pubKey: z.string()
});
```
Create `src/types/networks.d.ts`:
```typescript
export interface NetworksFormData {
chainId: string;
networkName: string;
namespace: string;
rpcUrl: string;
blockExplorerUrl: string;
nativeDenom?: string;
addressPrefix?: string;
coinType: string;
gasPrice?: string;
isDefault?: boolean;
currencySymbol?: string;
}
export interface NetworksDataState extends NetworksFormData {
networkId: string;
}
export interface NetworkState {
networks: NetworksDataState[];
selectedNetwork?: NetworksDataState;
networkType: string;
}
```
Create `src/types/index.ts`:
```typescript
export * from './accounts';
export * from './networks';
export * from './storage';
```
### 3. Implement storage adapters
Create `src/storage/keystore.ts`:
```typescript
// A next.js-friendly implementation of the key store
// ⚠️ SECURITY WARNING: This implementation is for demonstration purposes only
// Storing sensitive wallet data including private keys in browser storage can be a security risk
// For production, consider using a secure hardware wallet or a dedicated wallet provider
// In place of localStorage in browser environments
export function setInternetCredentials(name: string, username: string, password: string): void {
if (typeof window !== 'undefined') {
sessionStorage.setItem(name, password);
}
}
export function getInternetCredentials(name: string): string | null {
if (typeof window !== 'undefined') {
return sessionStorage.getItem(name);
}
return null;
}
export function resetInternetCredentials(name: string): void {
if (typeof window !== 'undefined') {
sessionStorage.removeItem(name);
}
}
// Helper to encrypt sensitive data
// In a production environment, consider using the Web Crypto API or a dedicated encryption library
export function encryptSensitiveData(data: string): string {
// Implement proper encryption in production
// This is a placeholder to emphasize sensitive data should be encrypted
return data;
}
```
### 4. Implement networks functionality
Create `src/networks/constants.ts`:
```typescript
import { NetworksFormData } from '../types';
export const EIP155 = 'eip155';
export const COSMOS = 'cosmos';
export const DEFAULT_NETWORKS: NetworksFormData[] = [
{
chainId: 'laconic-testnet-2',
networkName: 'laconicd testnet-2',
namespace: COSMOS,
rpcUrl: process.env.NEXT_PUBLIC_LACONICD_RPC_URL!,
blockExplorerUrl: '',
nativeDenom: 'alnt',
addressPrefix: 'laconic',
coinType: '118',
gasPrice: '0.001',
isDefault: true,
},
{
chainId: '1',
networkName: 'Ethereum Mainnet',
namespace: EIP155,
rpcUrl: 'https://mainnet.infura.io/v3/your-key',
blockExplorerUrl: '',
currencySymbol: 'ETH',
coinType: '60',
isDefault: true,
},
];
```
Create `src/networks/networks.ts`:
```typescript
import { NetworksDataState, NetworksFormData } from '../types';
import { getInternetCredentials, setInternetCredentials } from '../storage/keystore';
import { DEFAULT_NETWORKS } from './constants';
export async function retrieveNetworksData(): Promise<NetworksDataState[]> {
console.log("Retrieving networks data");
const networks = await getInternetCredentials('networks');
if(!networks){
console.log("No networks found in credentials, using DEFAULT_NETWORKS");
// Convert NetworksFormData to NetworksDataState by adding networkId
const defaultNetworksWithId = DEFAULT_NETWORKS.map((network, index) => ({
...network,
networkId: String(index)
}));
// Store default networks in credentials
await setInternetCredentials(
'networks',
'_',
JSON.stringify(defaultNetworksWithId)
);
return defaultNetworksWithId;
}
console.log("Networks found in credentials");
const parsedNetworks: NetworksDataState[] = JSON.parse(networks);
return parsedNetworks;
}
export async function storeNetworkData(
networkData: NetworksFormData,
): Promise<NetworksDataState[]> {
const networks = await getInternetCredentials('networks');
let retrievedNetworks = [];
if (networks) {
retrievedNetworks = JSON.parse(networks!);
}
let networkId = 0;
if (retrievedNetworks.length > 0) {
networkId = retrievedNetworks[retrievedNetworks.length - 1].networkId + 1;
}
const updatedNetworks: NetworksDataState[] = [
...retrievedNetworks,
{
...networkData,
networkId: String(networkId),
},
];
await setInternetCredentials(
'networks',
'_',
JSON.stringify(updatedNetworks),
);
return updatedNetworks;
}
```
### 5. Implement crypto functionality
Create `src/crypto/signing.ts`:
```typescript
import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing';
import { ethers } from 'ethers';
import { SiweMessage } from 'siwe';
import { getInternetCredentials } from '../storage/keystore';
import { COSMOS, EIP155 } from '../networks/constants';
interface SignMessageParams {
message: string;
namespace: string;
chainId: string;
accountId: number;
}
export async function signMessage({
message,
namespace,
chainId,
accountId,
}: SignMessageParams): Promise<string | undefined> {
const path = await getPathKey(`${namespace}:${chainId}`, accountId);
switch (namespace) {
case EIP155:
return await signEthMessage(message, accountId, chainId);
case COSMOS:
return await signCosmosMessage(message, path.path, path.address);
default:
throw new Error('Invalid wallet type');
}
}
async function signEthMessage(
message: string,
accountId: number,
chainId: string,
): Promise<string | undefined> {
try {
const privKey = (await getPathKey(`${EIP155}:${chainId}`, accountId))
.privKey;
const wallet = new ethers.Wallet(privKey);
const signature = await wallet.signMessage(message);
return signature;
} catch (error) {
console.error('Error signing Ethereum message:', error);
throw error;
}
}
async function signCosmosMessage(
message: string,
path: string,
cosmosAddress: string,
): Promise<string | undefined> {
// Implementation for Cosmos signing...
return "cosmos_signature_placeholder";
}
async function getPathKey(
namespaceChainId: string,
accountId: number,
): Promise<{
path: string;
privKey: string;
pubKey: string;
address: string;
}> {
const pathKeyStore = await getInternetCredentials(
`accounts/${namespaceChainId}/${accountId}`,
);
if (!pathKeyStore) {
throw new Error('Error while fetching key data');
}
const pathKeyVal = pathKeyStore;
const pathkey = pathKeyVal.split(',');
const path = pathkey[0];
const privKey = pathkey[1];
const pubKey = pathkey[2];
const address = pathkey[3];
return { path, privKey, pubKey, address };
}
export async function createSiweMessage(address: string, statement: string = 'Sign in With Ethereum.'): Promise<string> {
const message = new SiweMessage({
version: '1',
domain: typeof window !== 'undefined' ? window.location.host : '',
uri: typeof window !== 'undefined' ? window.location.origin : '',
chainId: 1,
address: address,
statement,
}).prepareMessage();
return message;
}
```
### 6. Create client hooks
Create `src/hooks/useWallet.ts`:
```typescript
import { useCallback, useState } from 'react';
import { createSiweMessage, signMessage } from '../crypto/signing';
import { Account } from '../types';
export function useWallet() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [isConnected, setIsConnected] = useState(false);
const [isReady, setIsReady] = useState(false);
const connect = useCallback(async () => {
// Implementation will come in Phase 2
console.log('Connect wallet functionality');
}, []);
const disconnect = useCallback(() => {
setAccounts([]);
setIsConnected(false);
setIsReady(false);
}, []);
const signIn = useCallback(async () => {
if (!accounts.length || currentIndex >= accounts.length) {
throw new Error('No account selected');
}
const account = accounts[currentIndex];
const message = await createSiweMessage(account.address);
try {
// Placeholder for the actual implementation
console.log('Sign in with wallet');
return { success: true };
} catch (error) {
console.error('Error signing in:', error);
return { success: false, error };
}
}, [accounts, currentIndex]);
return {
accounts,
setAccounts,
currentIndex,
setCurrentIndex,
isConnected,
setIsConnected,
isReady,
setIsReady,
connect,
disconnect,
signIn,
};
}
```
### 7. Implement server actions
Create `src/actions/walletActions.ts`:
```typescript
'use server'
import { cookies } from 'next/headers';
import { createSiweMessage } from '../crypto/signing';
export async function validateSignature(message: string, signature: string) {
// This will be implemented in Phase 2
// Verifies the signature on the server side
return { success: true };
}
export async function storeWalletSession(address: string) {
// Store wallet address in session cookie
cookies().set('wallet_address', address, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
});
return { success: true };
}
export async function getWalletSession() {
return cookies().get('wallet_address')?.value;
}
export async function clearWalletSession() {
cookies().delete('wallet_address');
return { success: true };
}
export async function checkBalance(chainId: string, address: string, amount: string) {
// This will connect to the blockchain RPC and check balance
// Implementation will come in Phase 2
// Mock implementation for now
return {
success: true,
hasEnoughBalance: true,
balance: "1000.00"
};
}
```
### 8. Create package exports
Create `src/index.ts`:
```typescript
// Export core functionality
export * from './types';
export * from './networks/constants';
export * from './networks/networks';
export * from './crypto/signing';
export * from './hooks/useWallet';
export * from './storage/keystore';
export * from './actions/walletActions';
```
## Testing
1. Build the package:
```bash
cd services/wallet-core
pnpm build
```
2. Test in the frontend app by adding the dependency:
```json
"dependencies": {
"@workspace/wallet-core": "workspace:*",
// other dependencies
}
```
## Next Steps
- Phase 2: Implement UI components in `services/ui/wallet`
- Phase 2: Integrate with the Next.js frontend app
- Phase 3: Complete Clerk auth integration
@@ -0,0 +1,592 @@
# 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
```mermaid
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:
```bash
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`:
```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`:
```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`:
```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`:
```tsx
'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`:
```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`:
```typescript
// 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`:
```typescript
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`:
```typescript
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`:
```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`:
```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`:
```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:
```tsx
'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:
```bash
cd services/ui
pnpm build
```
2. Run the frontend app:
```bash
cd apps/deploy-fe
pnpm dev
```
3. 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
@@ -0,0 +1,754 @@
# Phase 3: Clerk Authentication Integration
## Overview
Phase 3 completes the wallet integration by connecting it to Clerk authentication, implementing middleware protection, and creating a unified authentication flow. This phase eliminates the need for separate auth systems and provides a seamless experience.
## Timeline
**Duration**: 2 weeks
**Dependencies**: Phase 1 & 2 completion
**Team**: Auth & Backend team
## Architecture
```mermaid
graph TD
A[Browser] --> B[Next.js Middleware]
B --> C{Auth Check}
C -->|No Auth| D[Clerk Sign-in]
C -->|Has Auth| E[Protected Routes]
D --> F[GitHub OAuth]
D --> G[Wallet Auth]
F --> H[Clerk Session]
G --> H
H --> I[User Metadata]
I --> J[Wallet Address]
E --> K[Wallet Operations]
K --> L[API Routes]
L --> M[Server Actions]
```
## Migration Reference
These implementations adapt functionality from:
- Wallet auth flow → from `/repos/laconic-wallet-web/src/screens/AutoSignIn.tsx`
- Session management → from `/repos/laconic-wallet-web/src/App.tsx` session handling
- Clerk integration → extends existing `/apps/deploy-fe/src/middleware.ts`
## Security Considerations
This phase deals with authentication and sensitive wallet operations. Consider these critical security aspects:
1. **Session Management**: Implement proper session handling using Clerk's secure mechanisms
2. **Wallet-Clerk Linking**: Verify wallet ownership through cryptographic signatures before linking to a user account
3. **Authorization**: Use proper authorization checks for all wallet operations
4. **Error States**: Gracefully handle network failures and blockchain errors
5. **Rate Limiting**: Implement rate limiting for sensitive operations like wallet linking
## Step-by-Step Implementation
### 1. Extend Clerk User Metadata
Create a TypeScript interface for the extended user metadata in `apps/deploy-fe/src/types/clerk.d.ts`:
```typescript
import { User } from '@clerk/nextjs/server'
declare module '@clerk/nextjs/server' {
interface User {
publicMetadata: {
walletAddress?: string
walletChainId?: string
walletConnected?: boolean
}
}
}
```
### 2. Implement Clerk API Routes for Wallet Auth
Create `apps/deploy-fe/src/app/api/clerk/wallet/link/route.ts`:
```typescript
import { auth, clerkClient } from '@clerk/nextjs/server'
import { validateSignature } from '@workspace/wallet-core'
import { NextResponse } from 'next/server'
import { z } from 'zod'
// Define request validation schema with Zod
const linkWalletSchema = z.object({
address: z.string()
.refine(addr => /^(0x[a-fA-F0-9]{40}|laconic[a-zA-Z0-9]{39,59})$/.test(addr), {
message: "Invalid wallet address format"
}),
message: z.string().min(10),
signature: z.string().min(1),
chainId: z.string().min(1)
})
export async function POST(request: Request) {
// Rate limiting - in production implement proper rate limiting middleware
// This is a placeholder to show where it should be implemented
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
// Validate request data
const validationResult = linkWalletSchema.safeParse(body)
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Invalid request data', details: validationResult.error.format() },
{ status: 400 }
)
}
const { address, message, signature, chainId } = validationResult.data
// Ensure the signed message actually contains the correct address
// This prevents replay attacks where a valid signature for one address is used for another
if (!message.includes(address)) {
return NextResponse.json(
{ error: 'Address in message does not match provided address' },
{ status: 400 }
)
}
// Validate signature using wallet-core
const isValid = await validateSignature(message, signature)
if (!isValid.success) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
)
}
// Update Clerk user metadata with wallet info
await clerkClient.users.updateUser(userId, {
publicMetadata: {
walletAddress: address,
walletChainId: chainId,
walletConnected: true
}
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error linking wallet:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
```
Create `apps/deploy-fe/src/app/api/clerk/wallet/unlink/route.ts`:
```typescript
import { auth, clerkClient } 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 user = await clerkClient.users.getUser(userId)
// Remove wallet information from metadata
await clerkClient.users.updateUser(userId, {
publicMetadata: {
...user.publicMetadata,
walletAddress: null,
walletChainId: null,
walletConnected: false
}
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error unlinking wallet:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
```
### 3. Enhance Middleware with Wallet Verification
Update `apps/deploy-fe/src/middleware.ts`:
```typescript
import { clerkMiddleware, createRouteMatcher, getAuth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
// Define routes that require wallet connection
const requiresWalletAuth = createRouteMatcher([
'/wallet(.*)',
'/projects(.*)',
'/buy-prepaid-service(.*)'
])
// Define public routes that don't require any auth
const isPublicRoute = createRouteMatcher([
'/sign-in(.*)',
'/sign-up(.*)',
'/api/github/webhook'
])
export default clerkMiddleware(async (auth, req) => {
const { userId } = auth
// Skip auth check for webhook endpoint
if (req.nextUrl.pathname === '/api/github/webhook') {
return NextResponse.next()
}
// For public routes, allow access
if (isPublicRoute(req)) {
return NextResponse.next()
}
// For all other routes, require authentication
if (!userId) {
return NextResponse.redirect(new URL('/sign-in', req.url))
}
// For wallet-required routes, check wallet connection
if (requiresWalletAuth(req)) {
const user = auth.user
// If wallet not connected, redirect to wallet connection page
if (!user?.publicMetadata?.walletConnected) {
return NextResponse.redirect(new URL('/wallet/connect', req.url))
}
}
return NextResponse.next()
})
export const config = {
matcher: [
// Skip Next.js internals and all static files
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)'
]
}
```
### 4. Create Wallet Connection Page
Create `apps/deploy-fe/src/app/(web3-authenticated)/wallet/connect/page.tsx`:
```tsx
'use client'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useUser } from '@clerk/nextjs'
import { WalletConnectButton, useWalletUI } from '@workspace/ui/wallet'
import { Button } from '@workspace/ui/components/button'
import { PageWrapper } from '@/components/foundation'
import { createSiweMessage, signMessage } from '@workspace/wallet-core'
import { toast } from 'sonner'
import { ArrowLeft } from 'lucide-react'
export default function ConnectWalletPage() {
const router = useRouter()
const { isConnected, wallet } = useWalletUI()
const { user, isLoaded } = useUser()
const [isLinking, setIsLinking] = useState(false)
// Check if user already has a linked wallet
useEffect(() => {
if (isLoaded && user?.publicMetadata?.walletConnected) {
router.push('/')
}
}, [isLoaded, user, router])
// Function to link wallet to Clerk user
const linkWallet = async () => {
if (!wallet?.address) return
setIsLinking(true)
try {
// Create SIWE message
const message = await createSiweMessage(wallet.address)
// Sign message with wallet
const signature = await signMessage({
message,
namespace: 'eip155',
chainId: '1',
accountId: 0
})
// Link wallet to Clerk user
const response = await fetch('/api/clerk/wallet/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: wallet.address,
message,
signature,
chainId: '1'
})
})
if (response.ok) {
toast.success('Wallet connected successfully')
router.push('/')
} else {
const data = await response.json()
throw new Error(data.error || 'Failed to link wallet')
}
} catch (error) {
console.error('Error linking wallet:', error)
toast.error(error instanceof Error ? error.message : 'Failed to link wallet')
} finally {
setIsLinking(false)
}
}
return (
<PageWrapper header={{ title: 'Connect Wallet' }}>
<div className="max-w-md mx-auto text-center py-12">
<h1 className="text-2xl font-bold mb-6">Connect Your Wallet</h1>
<p className="mb-8 text-muted-foreground">
Connect your wallet to access additional features like deployments
and blockchain-related functionality.
</p>
<div className="space-y-6">
{!isConnected ? (
<WalletConnectButton size="lg" />
) : (
<div className="space-y-4">
<p className="font-medium">
Wallet connected: {wallet?.address?.slice(0, 6)}...{wallet?.address?.slice(-4)}
</p>
<Button
onClick={linkWallet}
disabled={isLinking}
size="lg"
>
{isLinking ? 'Linking...' : 'Link Wallet with Account'}
</Button>
</div>
)}
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</div>
</div>
</PageWrapper>
)
}
```
### 5. Update User Profile to Show Wallet Information
Create `apps/deploy-fe/src/components/user-profile/WalletInfo.tsx`:
```tsx
'use client'
import React from 'react'
import { useUser } from '@clerk/nextjs'
import { Button } from '@workspace/ui/components/button'
import { WalletConnectButton } from '@workspace/ui/wallet'
import { toast } from 'sonner'
export function WalletInfo() {
const { user, isLoaded } = useUser()
const walletAddress = user?.publicMetadata?.walletAddress as string | undefined
const walletConnected = user?.publicMetadata?.walletConnected as boolean
const disconnectWallet = async () => {
try {
const response = await fetch('/api/clerk/wallet/unlink', {
method: 'POST'
})
if (response.ok) {
toast.success('Wallet disconnected')
// Reload user to update metadata
user?.reload()
} else {
const data = await response.json()
throw new Error(data.error || 'Failed to disconnect wallet')
}
} catch (error) {
console.error('Error disconnecting wallet:', error)
toast.error(error instanceof Error ? error.message : 'Failed to disconnect wallet')
}
}
if (!isLoaded) {
return <div className="animate-pulse h-12 bg-gray-200 rounded" />
}
return (
<div className="border rounded-lg p-4">
<h3 className="text-lg font-medium mb-2">Wallet</h3>
{walletConnected && walletAddress ? (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-muted-foreground">Connected Address:</p>
<p className="font-mono text-sm">
{walletAddress.slice(0, 8)}...{walletAddress.slice(-6)}
</p>
</div>
<Button variant="destructive" size="sm" onClick={disconnectWallet}>
Disconnect
</Button>
</div>
</div>
) : (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground mb-4">
No wallet connected to your account
</p>
<WalletConnectButton />
</div>
)}
</div>
)
}
```
### 6. Create Server Action to Check Wallet Auth
Create `apps/deploy-fe/src/app/actions/wallet.ts`:
```typescript
'use server'
import { auth, clerkClient } from '@clerk/nextjs/server'
import { checkBalance } from '@workspace/wallet-core'
export async function checkWalletBalance(chainId: string, amount: string) {
const { userId } = await auth()
if (!userId) {
throw new Error('Unauthorized')
}
const user = await clerkClient.users.getUser(userId)
const walletAddress = user.publicMetadata.walletAddress as string | undefined
if (!walletAddress) {
throw new Error('No wallet connected')
}
// Use the wallet-core to check balance
return checkBalance(chainId, walletAddress, amount)
}
export async function getWalletStatus() {
const { userId } = await auth()
if (!userId) {
return { isConnected: false }
}
const user = await clerkClient.users.getUser(userId)
const walletConnected = user.publicMetadata.walletConnected as boolean
const walletAddress = user.publicMetadata.walletAddress as string | undefined
return {
isConnected: !!walletConnected,
address: walletAddress
}
}
```
### 7. Update Balance Checking Components
Replace `apps/deploy-fe/src/components/projects/project/deployments/CheckBalanceWrapper.tsx`:
```tsx
'use client'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { checkWalletBalance } from '@/app/actions/wallet'
import { toast } from 'sonner'
interface CheckBalanceWrapperProps {
children: React.ReactNode
requiredAmount: string
redirectTo?: string
}
export function CheckBalanceWrapper({
children,
requiredAmount,
redirectTo = '/buy-prepaid-service'
}: CheckBalanceWrapperProps) {
const router = useRouter()
const [isChecking, setIsChecking] = useState(true)
const [hasSufficientBalance, setHasSufficientBalance] = useState<boolean>()
useEffect(() => {
const checkBalance = async () => {
try {
setIsChecking(true)
const chainId = process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID || ''
const result = await checkWalletBalance(chainId, requiredAmount)
setHasSufficientBalance(result.hasEnoughBalance)
if (!result.hasEnoughBalance) {
toast.error('Insufficient balance for this operation')
router.push(redirectTo)
}
} catch (error) {
console.error('Error checking balance:', error)
toast.error('Failed to check wallet balance')
} finally {
setIsChecking(false)
}
}
checkBalance()
}, [requiredAmount, redirectTo, router])
if (isChecking) {
return <div className="animate-pulse h-full w-full bg-gray-100 rounded opacity-50" />
}
if (hasSufficientBalance === false) {
return null
}
return <>{children}</>
}
```
### 8. Create Wallet Status React Context
Create `apps/deploy-fe/src/context/WalletStatusContext.tsx`:
```tsx
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
import { useUser } from '@clerk/nextjs'
interface WalletStatus {
isConnected: boolean
address?: string
}
interface WalletStatusContextType {
walletStatus: WalletStatus
refreshWalletStatus: () => void
isLoading: boolean
}
const WalletStatusContext = createContext<WalletStatusContextType | undefined>(undefined)
export function WalletStatusProvider({ children }: { children: React.ReactNode }) {
const { user, isLoaded } = useUser()
const [isLoading, setIsLoading] = useState(true)
const [walletStatus, setWalletStatus] = useState<WalletStatus>({
isConnected: false
})
const refreshWalletStatus = () => {
if (isLoaded && user) {
const walletConnected = user.publicMetadata.walletConnected as boolean
const walletAddress = user.publicMetadata.walletAddress as string | undefined
setWalletStatus({
isConnected: !!walletConnected,
address: walletAddress
})
setIsLoading(false)
} else {
setWalletStatus({ isConnected: false })
setIsLoading(!isLoaded)
}
}
useEffect(() => {
refreshWalletStatus()
}, [isLoaded, user])
return (
<WalletStatusContext.Provider value={{ walletStatus, refreshWalletStatus, isLoading }}>
{children}
</WalletStatusContext.Provider>
)
}
export function useWalletStatus() {
const context = useContext(WalletStatusContext)
if (context === undefined) {
throw new Error('useWalletStatus must be used within a WalletStatusProvider')
}
return context
}
```
### 9. Update Providers
Update `apps/deploy-fe/src/components/providers/index.tsx`:
```tsx
'use client'
import React, { ReactNode } from 'react'
import { ThemeProvider } from 'next-themes'
import { WalletProvider } from '../wallet/WalletProvider'
import { WalletStatusProvider } from '@/context/WalletStatusContext'
export function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<WalletStatusProvider>
<WalletProvider>{children}</WalletProvider>
</WalletStatusProvider>
</ThemeProvider>
)
}
```
### 10. Add Wallet Status to Application UI
Update `apps/deploy-fe/src/components/foundation/top-navigation/TopNavigation.tsx`:
```tsx
'use client'
import React from 'react'
import { WalletConnectButton } from '@workspace/ui/wallet'
import { useWalletStatus } from '@/context/WalletStatusContext'
import { Button } from '@workspace/ui/components/button'
import Link from 'next/link'
import { Wallet } from 'lucide-react'
export function TopNavigation() {
const { walletStatus, isLoading } = useWalletStatus()
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">
{isLoading ? (
<div className="h-9 w-32 bg-gray-200 rounded animate-pulse" />
) : walletStatus.isConnected ? (
<Button variant="outline" size="sm" asChild>
<Link href="/wallet">
<Wallet className="mr-2 h-4 w-4" />
{walletStatus.address?.slice(0, 6)}...{walletStatus.address?.slice(-4)}
</Link>
</Button>
) : (
<Button asChild>
<Link href="/wallet/connect">
<Wallet className="mr-2 h-4 w-4" />
Connect Wallet
</Link>
</Button>
)}
{/* Other right-side items */}
</div>
</nav>
</div>
</header>
)
}
```
## Testing
1. Run the frontend app:
```bash
cd apps/deploy-fe
pnpm dev
```
2. Test the integration:
- Sign in with Clerk (GitHub OAuth)
- Connect and link wallet
- Check wallet information in user profile
- Test protected routes requiring wallet connection
- Test balance checking functionality
- Test wallet disconnection
## Fallback Handling
To ensure robustness, implement these fallback mechanisms:
```typescript
// Example fallback for chain disconnections
async function getBalanceWithFallback(address: string, chainId: string) {
try {
// Try primary RPC endpoint
return await checkBalance(chainId, address, "1")
} catch (error) {
console.error("Primary RPC failed:", error)
// Try fallback RPC
try {
const fallbackRpc = getFallbackRpcForChain(chainId)
return await checkBalanceWithCustomRpc(fallbackRpc, address, "1")
} catch (fallbackError) {
console.error("Fallback RPC failed:", fallbackError)
throw new Error("Unable to connect to blockchain nodes")
}
}
}
```
## Integration Complete
At this point, the integration of the Laconic wallet within the Next.js application is complete. The system provides:
1. **Unified Authentication**: GitHub OAuth through Clerk combined with wallet authentication
2. **Secure Middleware**: Route protection based on auth status and wallet connection
3. **Clean Architecture**: No iframe dependencies, using server actions and API routes
4. **Improved UX**: Seamless integration with the existing UI components
5. **Robust Validation**: Type-safe validation with Zod throughout the application
6. **Proper Error Handling**: Comprehensive error states with fallback mechanisms
This completes all three phases of migrating the wallet from an iframe-based implementation to a native Next.js integration with Clerk auth.
## Additional Resources
- [Clerk Webhook Documentation](https://clerk.com/docs/users/sync-data-to-your-backend) - For keeping external systems in sync
- [Next.js Middleware Documentation](https://nextjs.org/docs/app/building-your-application/routing/middleware) - For advanced route protection
- [Ethers.js Documentation](https://docs.ethers.org/v6/) - For Ethereum wallet functionality
- [CosmJS Documentation](https://cosmos.github.io/cosmjs/) - For Cosmos wallet functionality
+1
View File
@@ -7,6 +7,7 @@
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"start": "turbo start",
"lint:fix": "turbo lint:fix",
"format": "turbo format",
"format:fix": "turbo format:fix",
+5
View File
@@ -8,6 +8,11 @@
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"]
},
"start": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
"check-types": {
"dependsOn": ["^build"],
"outputs": []