- Project Overview
- Architecture
- Dependencies
- Authentication & Wallet Setup
- In-App Wallet Implementation
- Gamification System
- Quest Engine
- Chain Integration
- Security Considerations
- Development Setup
Web3Unilag Onboard is a React Native mobile application that provides gamified Web3 onboarding for African university students. The app features chain-agnostic learning tracks, quest-based progression, and integrated wallet functionality.
- Chain selector & goal-based onboarding tracks
- Gamified progression & XP system
- In-app wallet (Solana & SUI)
- Social login (Google, Apple, EVM wallet)
- Smart contract-validated quests
- Ecosystem map & dApp directory
- Mini-game engine for events
- Chain dashboard for partners
- Mentorship matching
┌─────────────────────────────────────────┐
│ Frontend (React Native) │
├─────────────────────────────────────────┤
│ Authentication Layer │
│ (Privy SDK) │
├─────────────────────────────────────────┤
│ Wallet Management │
│ (In-App + External Wallets) │
├─────────────────────────────────────────┤
│ State Management │
│ (Redux Toolkit) │
├─────────────────────────────────────────┤
│ API Layer │
│ (Supabase Client) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Backend (Supabase) │
├─────────────────────────────────────────┤
│ Database (PostgreSQL) │
├─────────────────────────────────────────┤
│ Edge Functions │
│ (Quest Validation, Rewards) │
├─────────────────────────────────────────┤
│ Real-time Features │
│ (Leaderboards, Chat) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Blockchain Networks │
│ (Solana, SUI, Ethereum) │
└─────────────────────────────────────────┘
{
"dependencies": {
// React Native Core
"react": "18.2.0",
"react-native": "0.73.0",
// Navigation
"@react-navigation/native": "^6.1.9",
"@react-navigation/stack": "^6.3.20",
"@react-navigation/bottom-tabs": "^6.5.11",
"react-native-screens": "^3.27.0",
"react-native-safe-area-context": "^4.7.4",
// Authentication
"@privy-io/react-auth": "^1.52.0",
"@privy-io/expo": "^0.4.0",
// Backend
"@supabase/supabase-js": "^2.38.4",
// State Management
"@reduxjs/toolkit": "^1.9.7",
"react-redux": "^8.1.3",
// Blockchain SDKs
"@solana/web3.js": "^1.87.6",
"@mysten/sui.js": "^0.50.1",
"ethers": "^6.8.1",
// Crypto & Security
"react-native-keychain": "^8.1.3",
"expo-crypto": "^12.6.0",
"expo-secure-store": "^12.5.0",
"tweetnacl": "^1.0.3",
"ed25519-hd-key": "^1.3.0",
"bip39": "^3.1.0",
// UI & Animations
"react-native-reanimated": "^3.6.0",
"react-native-gesture-handler": "^2.14.0",
"lottie-react-native": "^6.4.1",
"react-native-svg": "^14.0.0",
// Utils
"react-native-mmkv": "^2.10.2",
"react-native-device-info": "^10.11.0",
"react-native-vector-icons": "^10.0.2"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-native": "^0.72.8",
"typescript": "^5.3.3"
}
}# iOS
pod 'RNCAsyncStorage', :path => '../node_modules/@react-native-async-storage/async-storage'
# Android
implementation 'androidx.biometric:biometric:1.1.0'// src/config/privy.ts
import { PrivyProvider } from '@privy-io/react-auth';
export const privyConfig = {
appId: process.env.EXPO_PUBLIC_PRIVY_APP_ID!,
config: {
loginMethods: ['google', 'apple', 'wallet'],
appearance: {
theme: 'dark',
accentColor: '#6366F1',
logo: 'https://your-logo-url.com/logo.png',
},
embeddedWallets: {
createOnLogin: 'users-without-wallets',
requireUserPasswordOnCreate: false,
},
externalWallets: {
metamask: true,
walletConnect: true,
coinbaseWallet: true,
},
},
};// src/hooks/useAuth.ts
import { usePrivy } from '@privy-io/react-auth';
import { useEffect } from 'react';
import { useAppDispatch } from '../store/hooks';
import { setUser, clearUser } from '../store/slices/authSlice';
export const useAuth = () => {
const { user, login, logout, authenticated } = usePrivy();
const dispatch = useAppDispatch();
useEffect(() => {
if (authenticated && user) {
dispatch(setUser({
id: user.id,
email: user.email?.address,
walletAddress: user.wallet?.address,
linkedAccounts: user.linkedAccounts,
}));
} else {
dispatch(clearUser());
}
}, [authenticated, user, dispatch]);
return {
user,
login,
logout,
authenticated,
};
};// src/services/WalletService.ts
import * as SecureStore from 'expo-secure-store';
import { generateMnemonic, mnemonicToSeedSync } from 'bip39';
import { derivePath } from 'ed25519-hd-key';
import { Keypair } from '@solana/web3.js';
import { Ed25519Keypair } from '@mysten/sui.js/keypairs/ed25519';
import nacl from 'tweetnacl';
export class WalletService {
private static MNEMONIC_KEY = 'user_mnemonic';
private static ENCRYPTION_KEY = 'wallet_encryption_key';
// Generate new wallet for user
static async generateWallet(userId: string): Promise<{
solanaKeypair: Keypair;
suiKeypair: Ed25519Keypair;
mnemonic: string;
}> {
try {
// Generate mnemonic
const mnemonic = generateMnemonic();
const seed = mnemonicToSeedSync(mnemonic);
// Derive Solana keypair (BIP44 path: m/44'/501'/0'/0')
const solanaPath = "m/44'/501'/0'/0'";
const solanaDerived = derivePath(solanaPath, seed.toString('hex'));
const solanaKeypair = Keypair.fromSeed(solanaDerived.key);
// Derive SUI keypair (BIP44 path: m/44'/784'/0'/0'/0')
const suiPath = "m/44'/784'/0'/0'/0'";
const suiDerived = derivePath(suiPath, seed.toString('hex'));
const suiKeypair = Ed25519Keypair.fromSeed(suiDerived.key);
// Encrypt and store mnemonic
await this.storeMnemonic(userId, mnemonic);
return {
solanaKeypair,
suiKeypair,
mnemonic,
};
} catch (error) {
throw new Error(`Failed to generate wallet: ${error}`);
}
}
// Store encrypted mnemonic
private static async storeMnemonic(userId: string, mnemonic: string): Promise<void> {
const key = `${this.MNEMONIC_KEY}_${userId}`;
await SecureStore.setItemAsync(key, mnemonic, {
requireAuthentication: true,
authenticationPrompt: 'Authenticate to access your wallet',
});
}
// Retrieve and decrypt mnemonic
static async getMnemonic(userId: string): Promise<string | null> {
try {
const key = `${this.MNEMONIC_KEY}_${userId}`;
return await SecureStore.getItemAsync(key, {
requireAuthentication: true,
authenticationPrompt: 'Authenticate to access your wallet',
});
} catch (error) {
console.error('Failed to retrieve mnemonic:', error);
return null;
}
}
// Restore wallet from mnemonic
static async restoreWallet(userId: string): Promise<{
solanaKeypair: Keypair;
suiKeypair: Ed25519Keypair;
} | null> {
try {
const mnemonic = await this.getMnemonic(userId);
if (!mnemonic) return null;
const seed = mnemonicToSeedSync(mnemonic);
// Restore Solana keypair
const solanaPath = "m/44'/501'/0'/0'";
const solanaDerived = derivePath(solanaPath, seed.toString('hex'));
const solanaKeypair = Keypair.fromSeed(solanaDerived.key);
// Restore SUI keypair
const suiPath = "m/44'/784'/0'/0'/0'";
const suiDerived = derivePath(suiPath, seed.toString('hex'));
const suiKeypair = Ed25519Keypair.fromSeed(suiDerived.key);
return {
solanaKeypair,
suiKeypair,
};
} catch (error) {
console.error('Failed to restore wallet:', error);
return null;
}
}
// Get wallet addresses
static async getWalletAddresses(userId: string): Promise<{
solana: string;
sui: string;
} | null> {
const wallet = await this.restoreWallet(userId);
if (!wallet) return null;
return {
solana: wallet.solanaKeypair.publicKey.toString(),
sui: wallet.suiKeypair.getPublicKey().toSuiAddress(),
};
}
}// src/services/TransactionService.ts
import { Connection, PublicKey, Transaction } from '@solana/web3.js';
import { SuiClient, getFullnodeUrl } from '@mysten/sui.js/client';
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { WalletService } from './WalletService';
export class TransactionService {
private static solanaConnection = new Connection(getFullnodeUrl('devnet'));
private static suiClient = new SuiClient({ url: getFullnodeUrl('devnet') });
// Sign Solana transaction
static async signSolanaTransaction(
userId: string,
transaction: Transaction
): Promise<string> {
const wallet = await WalletService.restoreWallet(userId);
if (!wallet) throw new Error('Wallet not found');
// Sign transaction
transaction.sign(wallet.solanaKeypair);
// Send transaction
const signature = await this.solanaConnection.sendRawTransaction(
transaction.serialize()
);
return signature;
}
// Sign SUI transaction
static async signSuiTransaction(
userId: string,
transactionBlock: TransactionBlock
): Promise<string> {
const wallet = await WalletService.restoreWallet(userId);
if (!wallet) throw new Error('Wallet not found');
// Execute transaction
const result = await this.suiClient.signAndExecuteTransactionBlock({
signer: wallet.suiKeypair,
transactionBlock,
});
return result.digest;
}
}// src/services/GameificationService.ts
import { supabase } from '../config/supabase';
export interface UserProgress {
id: string;
user_id: string;
total_xp: number;
level: number;
current_streak: number;
badges: string[];
completed_quests: string[];
chain_progress: Record<string, number>;
}
export class GamificationService {
// Calculate level from XP
static calculateLevel(xp: number): number {
return Math.floor(Math.sqrt(xp / 100)) + 1;
}
// Calculate XP needed for next level
static xpForNextLevel(currentLevel: number): number {
return (currentLevel * currentLevel) * 100;
}
// Award XP to user
static async awardXP(userId: string, amount: number, reason: string): Promise<void> {
const { data: progress, error } = await supabase
.from('user_progress')
.select('*')
.eq('user_id', userId)
.single();
if (error) throw error;
const newXP = progress.total_xp + amount;
const newLevel = this.calculateLevel(newXP);
// Check for level up
const leveledUp = newLevel > progress.level;
await supabase
.from('user_progress')
.update({
total_xp: newXP,
level: newLevel,
})
.eq('user_id', userId);
// Record XP transaction
await supabase
.from('xp_transactions')
.insert({
user_id: userId,
amount,
reason,
timestamp: new Date().toISOString(),
});
// Handle level up rewards
if (leveledUp) {
await this.handleLevelUp(userId, newLevel);
}
}
// Handle level up rewards
private static async handleLevelUp(userId: string, newLevel: number): Promise<void> {
// Award level up badge
await this.awardBadge(userId, `level_${newLevel}`);
// Send level up notification
await supabase
.from('notifications')
.insert({
user_id: userId,
type: 'level_up',
title: 'Level Up!',
message: `Congratulations! You've reached level ${newLevel}`,
data: { level: newLevel },
});
}
// Award badge to user
static async awardBadge(userId: string, badgeId: string): Promise<void> {
const { data: progress } = await supabase
.from('user_progress')
.select('badges')
.eq('user_id', userId)
.single();
if (!progress?.badges.includes(badgeId)) {
const updatedBadges = [...progress.badges, badgeId];
await supabase
.from('user_progress')
.update({ badges: updatedBadges })
.eq('user_id', userId);
}
}
}// src/services/QuestService.ts
import { supabase } from '../config/supabase';
import { GamificationService } from './GamificationService';
export interface Quest {
id: string;
title: string;
description: string;
chain: string;
type: 'transaction' | 'quiz' | 'social' | 'content';
requirements: Record<string, any>;
rewards: {
xp: number;
badges?: string[];
nfts?: string[];
};
validation_contract?: string;
is_active: boolean;
}
export class QuestService {
// Get available quests for user
static async getAvailableQuests(userId: string, chain?: string): Promise<Quest[]> {
let query = supabase
.from('quests')
.select('*')
.eq('is_active', true);
if (chain) {
query = query.eq('chain', chain);
}
// Exclude completed quests
const { data: completedQuests } = await supabase
.from('quest_completions')
.select('quest_id')
.eq('user_id', userId);
const completedQuestIds = completedQuests?.map(q => q.quest_id) || [];
const { data: quests, error } = await query.not('id', 'in', `(${completedQuestIds.join(',')})`);
if (error) throw error;
return quests || [];
}
// Validate quest completion
static async validateQuest(userId: string, questId: string, proof: any): Promise<boolean> {
const { data: quest, error } = await supabase
.from('quests')
.select('*')
.eq('id', questId)
.single();
if (error) throw error;
let isValid = false;
switch (quest.type) {
case 'transaction':
isValid = await this.validateTransactionQuest(quest, proof);
break;
case 'quiz':
isValid = await this.validateQuizQuest(quest, proof);
break;
case 'social':
isValid = await this.validateSocialQuest(quest, proof);
break;
case 'content':
isValid = await this.validateContentQuest(quest, proof);
break;
}
if (isValid) {
await this.completeQuest(userId, questId, quest.rewards);
}
return isValid;
}
// Complete quest and award rewards
private static async completeQuest(
userId: string,
questId: string,
rewards: Quest['rewards']
): Promise<void> {
// Record completion
await supabase
.from('quest_completions')
.insert({
user_id: userId,
quest_id: questId,
completed_at: new Date().toISOString(),
});
// Award XP
await GamificationService.awardXP(userId, rewards.xp, `Quest completion: ${questId}`);
// Award badges
if (rewards.badges) {
for (const badge of rewards.badges) {
await GamificationService.awardBadge(userId, badge);
}
}
// Mint NFTs (if applicable)
if (rewards.nfts) {
await this.mintRewardNFTs(userId, rewards.nfts);
}
}
// Validate transaction quest
private static async validateTransactionQuest(quest: Quest, proof: any): Promise<boolean> {
// Implementation depends on specific requirements
// This would typically involve checking blockchain transactions
return true; // Placeholder
}
// Other validation methods...
private static async validateQuizQuest(quest: Quest, proof: any): Promise<boolean> {
// Validate quiz answers
return true; // Placeholder
}
private static async validateSocialQuest(quest: Quest, proof: any): Promise<boolean> {
// Validate social media interactions
return true; // Placeholder
}
private static async validateContentQuest(quest: Quest, proof: any): Promise<boolean> {
// Validate content creation
return true; // Placeholder
}
private static async mintRewardNFTs(userId: string, nftIds: string[]): Promise<void> {
// Implement NFT minting logic
}
}// src/services/SolanaService.ts
import {
Connection,
PublicKey,
Transaction,
SystemProgram,
LAMPORTS_PER_SOL
} from '@solana/web3.js';
import { WalletService } from './WalletService';
export class SolanaService {
private static connection = new Connection('https://api.devnet.solana.com');
// Get balance
static async getBalance(address: string): Promise<number> {
const publicKey = new PublicKey(address);
const balance = await this.connection.getBalance(publicKey);
return balance / LAMPORTS_PER_SOL;
}
// Send SOL
static async sendSOL(
userId: string,
toAddress: string,
amount: number
): Promise<string> {
const wallet = await WalletService.restoreWallet(userId);
if (!wallet) throw new Error('Wallet not found');
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: wallet.solanaKeypair.publicKey,
toPubkey: new PublicKey(toAddress),
lamports: amount * LAMPORTS_PER_SOL,
})
);
const signature = await this.connection.sendTransaction(
transaction,
[wallet.solanaKeypair]
);
return signature;
}
// Get transaction history
static async getTransactionHistory(address: string): Promise<any[]> {
const publicKey = new PublicKey(address);
const signatures = await this.connection.getSignaturesForAddress(publicKey);
const transactions = await Promise.all(
signatures.map(sig =>
this.connection.getTransaction(sig.signature, {
maxSupportedTransactionVersion: 0
})
)
);
return transactions.filter(tx => tx !== null);
}
}// src/services/SuiService.ts
import { SuiClient, getFullnodeUrl } from '@mysten/sui.js/client';
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { WalletService } from './WalletService';
export class SuiService {
private static client = new SuiClient({ url: getFullnodeUrl('devnet') });
// Get balance
static async getBalance(address: string): Promise<string> {
const balance = await this.client.getBalance({
owner: address,
});
return balance.totalBalance;
}
// Send SUI
static async sendSUI(
userId: string,
toAddress: string,
amount: number
): Promise<string> {
const wallet = await WalletService.restoreWallet(userId);
if (!wallet) throw new Error('Wallet not found');
const txb = new TransactionBlock();
const [coin] = txb.splitCoins(txb.gas, [txb.pure(amount)]);
txb.transferObjects([coin], txb.pure(toAddress));
const result = await this.client.signAndExecuteTransactionBlock({
signer: wallet.suiKeypair,
transactionBlock: txb,
});
return result.digest;
}
// Get owned objects
static async getOwnedObjects(address: string): Promise<any[]> {
const objects = await this.client.getOwnedObjects({
owner: address,
options: {
showType: true,
showContent: true,
showDisplay: true,
},
});
return objects.data;
}
}-
Secure Storage
- Use
expo-secure-storefor mnemonic storage - Require biometric authentication for wallet access
- Never store private keys in plain text
- Use
-
Encryption
- Encrypt sensitive data before storage
- Use device-specific encryption keys
- Implement key rotation for long-term storage
-
Network Security
- Use HTTPS for all API communications
- Implement certificate pinning
- Validate all server responses
-
Input Validation
- Sanitize all user inputs
- Validate transaction parameters
- Implement rate limiting
// src/utils/Security.ts
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
export class SecurityUtils {
// Generate device-specific encryption key
static async generateDeviceKey(): Promise<string> {
const randomBytes = await Crypto.getRandomBytesAsync(32);
return Array.from(randomBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
// Encrypt data
static async encryptData(data: string, key: string): Promise<string> {
// Implementation using a crypto library
// This is a simplified example
return Buffer.from(data).toString('base64');
}
// Decrypt data
static async decryptData(encryptedData: string, key: string): Promise<string> {
// Implementation using a crypto library
// This is a simplified example
return Buffer.from(encryptedData, 'base64').toString();
}
// Validate transaction parameters
static validateTransactionParams(params: any): boolean {
// Implement validation logic
if (!params.to || !params.amount) return false;
if (params.amount <= 0) return false;
return true;
}
}# .env
EXPO_PUBLIC_PRIVY_APP_ID=your_privy_app_id
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
EXPO_PUBLIC_ENVIRONMENT=development{
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"build:android": "eas build --platform android",
"build:ios": "eas build --platform ios",
"test": "jest",
"type-check": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx"
}
}src/
├── components/ # Reusable UI components
│ ├── common/ # Common components
│ ├── gamification/ # XP, badges, progress bars
│ └── wallet/ # Wallet-related components
├── screens/ # App screens
│ ├── auth/ # Authentication screens
│ ├── onboarding/ # Onboarding flow
│ ├── quests/ # Quest screens
│ └── profile/ # User profile
├── services/ # Business logic services
├── hooks/ # Custom React hooks
├── store/ # Redux store configuration
├── utils/ # Utility functions
├── types/ # TypeScript type definitions
└── config/ # App configuration
-- Users table (extends Privy user data)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
privy_id TEXT UNIQUE NOT NULL,
email TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- User progress
CREATE TABLE user_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
total_xp INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
current_streak INTEGER DEFAULT 0,
badges TEXT[] DEFAULT '{}',
completed_quests TEXT[] DEFAULT '{}',
chain_progress JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Quests
CREATE TABLE quests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
chain TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('transaction', 'quiz', 'social', 'content')),
requirements JSONB DEFAULT '{}',
rewards JSONB DEFAULT '{}',
validation_contract TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Quest completions
CREATE TABLE quest_completions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
quest_id UUID REFERENCES quests(id) ON DELETE CASCADE,
completed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, quest_id)
);
-- XP transactions
CREATE TABLE xp_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
amount INTEGER NOT NULL,
reason TEXT,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);This comprehensive documentation provides the foundation for building the Web3Unilag Onboard mobile application with all the specified features, security considerations, and development best practices.