diff --git a/apps/app/.env.example b/apps/app/.env.example index 8eaaa605..784c0d30 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -4,14 +4,15 @@ # To switch networks: uncomment one block, comment out the other. # ══════════════════════════════════════════════════════════════ -# Enoki API Key (get from https://portal.enoki.mystenlabs.com) -VITE_ENOKI_API_KEY= +# Enoki zkLogin — enables "Sign in with Google" +# Get Enoki API key from https://portal.enoki.mystenlabs.com +VITE_ENOKI_API_KEY=enoki_public_9aac56cf5c1e5b1254d1fa09bb6e9f0c # Google OAuth Client ID (from Google Cloud Console) -VITE_GOOGLE_CLIENT_ID= +VITE_GOOGLE_CLIENT_ID=386692102434-pn0enkrr12r5q3arflsfrrvb14rvhs10.apps.googleusercontent.com -# MemWal Server URL -VITE_MEMWAL_SERVER_URL=http://localhost:8000 +# MemWal Server URL (also handles /sponsor and /sponsor/execute for gasless tx) +VITE_MEMWAL_SERVER_URL=https://relayer.dev.memwal.ai # Docs URL (separate deployment) VITE_DOCS_URL=http://localhost:5174 diff --git a/apps/app/src/index.css b/apps/app/src/index.css index abd7d38d..ea53adcf 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -774,6 +774,39 @@ h1, h2, h3 { box-shadow: 5px 5px 0 #000000 !important; } +/* ── Login popover: ConnectButton full-width + matching style ── */ + +.lp-login-wallet-btn { + width: 100%; +} + +.lp-login-wallet-btn [class*="ConnectButton"], +.lp-login-wallet-btn div { + width: 100% !important; +} + +.lp-login-wallet-btn [class*="ConnectButton"] button, +.lp-login-wallet-btn button[data-dapp-kit] { + width: 100% !important; + background: #E8FF75 !important; + color: #000000 !important; + border: 2px solid #000000 !important; + border-radius: 10px !important; + padding: 10px 16px !important; + font-size: 0.88rem !important; + font-weight: 700 !important; + font-family: var(--font-sans) !important; + box-shadow: 3px 3px 0 #000000 !important; + transition: transform 0.15s, box-shadow 0.15s !important; + justify-content: center !important; +} + +.lp-login-wallet-btn [class*="ConnectButton"] button:hover, +.lp-login-wallet-btn button[data-dapp-kit]:hover { + transform: translate(-1px, -1px) !important; + box-shadow: 4px 4px 0 #000000 !important; +} + /* ── Responsive ── */ @media (max-width: 860px) { diff --git a/apps/app/src/pages/LandingPage.tsx b/apps/app/src/pages/LandingPage.tsx index 1c930f3a..895420d7 100644 --- a/apps/app/src/pages/LandingPage.tsx +++ b/apps/app/src/pages/LandingPage.tsx @@ -1,5 +1,11 @@ /** - * Landing Page — Sign in with Google (Enoki zkLogin) or any Sui wallet + * Landing Page — Two login options via "SDK Playground" popover: + * + * 1. Sign in with Google (Enoki) + * 2. Connect Wallet (any Sui wallet) + * + * After login, redirects to /dashboard where SetupWizard handles + * delegate key generation if needed. */ import { @@ -10,18 +16,37 @@ import { } from '@mysten/dapp-kit' import { isEnokiWallet, type EnokiWallet, type AuthProvider } from '@mysten/enoki' import { ChevronDown, Github } from 'lucide-react' -import { useRef, useState, useEffect } from 'react' +import { useRef, useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' +import { useDelegateKey } from '../App' import { config } from '../config' import memwalLogo from '../assets/memwal-logo.svg' +type AuthMethod = 'enoki' | 'wallet' | null + +const AUTH_METHOD_KEY = 'memwal_auth_method' + +function persistAuthMethod(method: AuthMethod) { + if (method) { + sessionStorage.setItem(AUTH_METHOD_KEY, method) + } else { + sessionStorage.removeItem(AUTH_METHOD_KEY) + } +} + +function getPersistedAuthMethod(): AuthMethod { + const val = sessionStorage.getItem(AUTH_METHOD_KEY) + if (val === 'enoki' || val === 'wallet') return val + return null +} + export default function LandingPage() { const currentAccount = useCurrentAccount() const { mutate: connect } = useConnectWallet() const wallets = useWallets() const enokiWallets = wallets.filter(isEnokiWallet) + const { delegateKey } = useDelegateKey() - // Find Google wallet from registered Enoki wallets const walletsByProvider = enokiWallets.reduce( (map, wallet) => map.set(wallet.provider, wallet), new Map(), @@ -29,28 +54,61 @@ export default function LandingPage() { const googleWallet = walletsByProvider.get('google') const navigate = useNavigate() - const hasEnokiConfig = config.enokiApiKey && config.googleClientId + const hasEnokiConfig = !!(config.enokiApiKey && config.googleClientId) const demoUrls = config.demoUrls + + // ── Dropdown states ── const [demoOpen, setDemoOpen] = useState(false) const demoRef = useRef(null) + const [loginOpen, setLoginOpen] = useState(false) + const loginRef = useRef(null) + // ── Track wallet click for ConnectButton flow ── + const walletClickedRef = useRef(false) + + // ── Close dropdowns on outside click ── useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (demoRef.current && !demoRef.current.contains(e.target as Node)) { setDemoOpen(false) } + if (loginRef.current && !loginRef.current.contains(e.target as Node) && !walletClickedRef.current) { + setLoginOpen(false) + } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - const handleConnect = () => { - if (currentAccount) { - navigate('/dashboard') - } else if (hasEnokiConfig && googleWallet) { - connect({ wallet: googleWallet }) + // ── Detect wallet connection via ConnectButton ── + const updateAuthMethod = useCallback((method: AuthMethod) => { + persistAuthMethod(method) + }, []) + + useEffect(() => { + if (currentAccount && !delegateKey) { + const persisted = getPersistedAuthMethod() + if (!persisted && walletClickedRef.current) { + walletClickedRef.current = false + setLoginOpen(false) + updateAuthMethod('wallet') + } + // Navigate to dashboard/setup after connection navigate('/dashboard') } + }, [currentAccount, delegateKey, updateAuthMethod, navigate]) + + // ── Button handlers ── + const handleEnokiConnect = () => { + if (!googleWallet) return + updateAuthMethod('enoki') + setLoginOpen(false) + connect({ wallet: googleWallet }) + } + + const handleWalletClick = () => { + walletClickedRef.current = true + updateAuthMethod('wallet') } return ( @@ -63,6 +121,7 @@ export default function LandingPage() {
+ {/* Demo dropdown */} {demoUrls.length > 0 && (
- ) : hasEnokiConfig && googleWallet ? ( - ) : ( - +
+ + {loginOpen && ( +
+ {hasEnokiConfig && googleWallet && ( + + )} + +
+ +
+
+ )} +
)}
diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx index 25df17f5..78a55d05 100644 --- a/apps/app/src/pages/SetupWizard.tsx +++ b/apps/app/src/pages/SetupWizard.tsx @@ -2,13 +2,13 @@ * Setup Wizard — Generate delegate key + create MemWalAccount onchain * * Steps: - * 1. Generate Ed25519 keypair - * 2. Create MemWalAccount onchain (if not exists) - * 3. Add delegate key onchain - * 4. Save key to localStorage → proceed to Dashboard + * 1. Intro — explain delegate keys, "generate delegate key" button + * 2. Generate Ed25519 keypair → show key + copy + confirm (both flows) + * 3. On-chain registration (Enoki: sponsored/silent, Wallet: user approves) + * 4. Save key to sessionStorage → redirect to Dashboard */ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { useCurrentAccount, useDisconnectWallet, @@ -17,12 +17,18 @@ import { import { Transaction } from '@mysten/sui/transactions' import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' import { useDelegateKey } from '../App' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { LogOut, Copy } from 'lucide-react' import { config } from '../config' import memwalLogo from '../assets/memwal-logo.svg' -type Step = 'intro' | 'generating' | 'show-key' | 'onchain' | 'done' +type Step = 'intro' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error' + +const AUTH_METHOD_KEY = 'memwal_auth_method' + +function getPersistedAuthMethod(): string | null { + return sessionStorage.getItem(AUTH_METHOD_KEY) +} export default function SetupWizard() { const currentAccount = useCurrentAccount() @@ -30,6 +36,7 @@ export default function SetupWizard() { const { mutateAsync: signAndExecute } = useSponsoredTransaction() const suiClient = useSuiClient() const { setDelegateKeys } = useDelegateKey() + const navigate = useNavigate() const [step, setStep] = useState('intro') const [privateKeyHex, setPrivateKeyHex] = useState('') @@ -40,162 +47,186 @@ export default function SetupWizard() { const [error, setError] = useState('') const [suiAddress, setSuiAddress] = useState('') + const setupRunningRef = useRef(false) const address = currentAccount?.address || '' + const isEnoki = getPersistedAuthMethod() === 'enoki' + + // ── Done: redirect to dashboard ── + useEffect(() => { + if (step === 'done') { + sessionStorage.removeItem(AUTH_METHOD_KEY) + const timer = setTimeout(() => navigate('/dashboard'), 1500) + return () => clearTimeout(timer) + } + }, [step, navigate]) + + // ── Generate Ed25519 keypair (shared) ── + const generateKeys = useCallback(async () => { + const ed = await import('@noble/ed25519') + const { blake2b } = await import('@noble/hashes/blake2.js') + const privateKey = new Uint8Array(32) + crypto.getRandomValues(privateKey) + const publicKey = await ed.getPublicKeyAsync(privateKey) + + const privHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('') + const pubHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('') + + const input = new Uint8Array(33) + input[0] = 0x00 + input.set(publicKey, 1) + const addressBytes = blake2b(input, { dkLen: 32 }) + const suiAddr = '0x' + Array.from(new Uint8Array(addressBytes)).map((b: number) => b.toString(16).padStart(2, '0')).join('') + + return { privHex, pubHex, suiAddr } + }, []) + + // ── Register delegate key on-chain (shared) ── + const registerOnchain = useCallback(async ( + ownerAddress: string, + pubKeyHex: string, + delegateSuiAddress: string, + ): Promise => { + let knownAccountId: string | null = null + + try { + const registryObj = await suiClient.getObject({ + id: config.memwalRegistryId, + options: { showContent: true }, + }) + if (registryObj?.data?.content && 'fields' in registryObj.data.content) { + const fields = registryObj.data.content.fields as any + const tableId = fields?.accounts?.fields?.id?.id + if (tableId) { + const dynField = await suiClient.getDynamicFieldObject({ + parentId: tableId, + name: { type: 'address', value: ownerAddress }, + }) + if (dynField?.data?.content && 'fields' in dynField.data.content) { + knownAccountId = (dynField.data.content.fields as any).value as string + } + } + } + } catch { + // Dynamic field not found → no account yet + } + + const pubKeyBytes = Array.from( + { length: pubKeyHex.length / 2 }, + (_, i) => parseInt(pubKeyHex.slice(i * 2, i * 2 + 2), 16) + ) + + if (knownAccountId) { + setTxStatus('account found! adding delegate key...') + const tx = new Transaction() + tx.moveCall({ + target: `${config.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx.object(knownAccountId), + tx.pure('vector', pubKeyBytes), + tx.pure('address', delegateSuiAddress), + tx.pure('string', 'Web App'), + tx.object('0x6'), + ], + }) + const result = await signAndExecute({ transaction: tx }) + await suiClient.waitForTransaction({ digest: result.digest }) + } else { + setTxStatus('creating account...') + const tx = new Transaction() + tx.moveCall({ + target: `${config.memwalPackageId}::account::create_account`, + arguments: [ + tx.object(config.memwalRegistryId), + tx.object('0x6'), + ], + }) + const createResult = await signAndExecute({ transaction: tx }) + await suiClient.waitForTransaction({ digest: createResult.digest }) + + const txDetails = await suiClient.getTransactionBlock({ + digest: createResult.digest, + options: { showObjectChanges: true }, + }) + const createdObj = txDetails.objectChanges?.find( + (c) => c.type === 'created' && + 'objectType' in c && + c.objectType.includes('MemWalAccount') + ) + if (createdObj && 'objectId' in createdObj) { + knownAccountId = createdObj.objectId + } + + if (!knownAccountId) { + throw new Error('Account created but object ID not found in transaction. Please try again.') + } + + setTxStatus('adding delegate key...') + const tx2 = new Transaction() + tx2.moveCall({ + target: `${config.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx2.object(knownAccountId), + tx2.pure('vector', pubKeyBytes), + tx2.pure('address', delegateSuiAddress), + tx2.pure('string', 'Web App'), + tx2.object('0x6'), + ], + }) + const addResult = await signAndExecute({ transaction: tx2 }) + await suiClient.waitForTransaction({ digest: addResult.digest }) + } + + return knownAccountId! + }, [suiClient, signAndExecute]) + + // ── "Generate delegate key" button handler ── + const handleGenerate = useCallback(async () => { + if (setupRunningRef.current) return + setupRunningRef.current = true - // -------------------------------------------------------- - // Step 1: Generate Ed25519 keypair - // -------------------------------------------------------- - const generateKeypair = useCallback(async () => { setStep('generating') setError('') try { - const ed = await import('@noble/ed25519') - const { blake2b } = await import('@noble/hashes/blake2.js') - const privateKey = new Uint8Array(32) - crypto.getRandomValues(privateKey) - const publicKey = await ed.getPublicKeyAsync(privateKey) - - const privHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('') - const pubHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('') - - // Derive Sui address: blake2b256(0x00 || public_key) - const input = new Uint8Array(33) - input[0] = 0x00 // Ed25519 scheme flag - input.set(publicKey, 1) - const addressBytes = blake2b(input, { dkLen: 32 }) - const suiAddr = '0x' + Array.from(new Uint8Array(addressBytes)).map((b: number) => b.toString(16).padStart(2, '0')).join('') - + const { privHex, pubHex, suiAddr } = await generateKeys() setPrivateKeyHex(privHex) setPublicKeyHex(pubHex) setSuiAddress(suiAddr) setStep('show-key') } catch (err) { - console.error('Key generation failed:', err) - setError('failed to generate key. please try again.') - setStep('intro') + console.error('Setup failed:', err) + const message = err instanceof Error ? err.message : 'setup failed. please try again.' + setError(message) + setStep('error') + } finally { + setupRunningRef.current = false } - }, []) + }, [generateKeys]) - // -------------------------------------------------------- - // Step 2: Onchain — create account + add delegate key - // -------------------------------------------------------- + // ── Wallet: register on-chain after user confirms key ── const executeOnchain = useCallback(async () => { + if (setupRunningRef.current) return + setupRunningRef.current = true + setStep('onchain') setError('') + setTxStatus('checking existing account...') try { - // Check if user already has a MemWalAccount via registry lookup - setTxStatus('checking existing account...') - let knownAccountId: string | null = null - - try { - // First, get the registry object to find the Table's inner ID - // (Move Table stores dynamic fields on its own UID, not the parent's) - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, - }) - if (registryObj?.data?.content && 'fields' in registryObj.data.content) { - const fields = registryObj.data.content.fields as any - const tableId = fields?.accounts?.fields?.id?.id - if (tableId) { - const dynField = await suiClient.getDynamicFieldObject({ - parentId: tableId, - name: { type: 'address', value: address }, - }) - if (dynField?.data?.content && 'fields' in dynField.data.content) { - knownAccountId = (dynField.data.content.fields as any).value as string - } - } - } - } catch { - // Dynamic field not found → no account yet - } - - const pubKeyBytes = Array.from( - { length: publicKeyHex.length / 2 }, - (_, i) => parseInt(publicKeyHex.slice(i * 2, i * 2 + 2), 16) - ) - - if (knownAccountId) { - // Account exists — add user delegate key - setTxStatus('account found! adding delegate key...') - const tx = new Transaction() - - tx.moveCall({ - target: `${config.memwalPackageId}::account::add_delegate_key`, - arguments: [ - tx.object(knownAccountId), - tx.pure('vector', pubKeyBytes), - tx.pure('address', suiAddress), - tx.pure('string', 'Web App'), - tx.object('0x6'), - ], - }) - - const result = await signAndExecute({ transaction: tx }) - await suiClient.waitForTransaction({ digest: result.digest }) - } else { - // Step A: Create account first (now creates a shared object) - setTxStatus('creating account...') - const tx = new Transaction() - - tx.moveCall({ - target: `${config.memwalPackageId}::account::create_account`, - arguments: [ - tx.object(config.memwalRegistryId), - tx.object('0x6'), - ], - }) - - const createResult = await signAndExecute({ transaction: tx }) - await suiClient.waitForTransaction({ digest: createResult.digest }) - - // Find the created MemWalAccount object (now shared) - const txDetails = await suiClient.getTransactionBlock({ - digest: createResult.digest, - options: { showObjectChanges: true }, - }) - const createdObj = txDetails.objectChanges?.find( - (c) => c.type === 'created' && - 'objectType' in c && - c.objectType.includes('MemWalAccount') - ) - if (createdObj && 'objectId' in createdObj) { - knownAccountId = createdObj.objectId - } - - // Step B: Add user's delegate key - setTxStatus('adding delegate key...') - const tx2 = new Transaction() - tx2.moveCall({ - target: `${config.memwalPackageId}::account::add_delegate_key`, - arguments: [ - tx2.object(knownAccountId!), - tx2.pure('vector', pubKeyBytes), - tx2.pure('address', suiAddress), - tx2.pure('string', 'Web App'), - tx2.object('0x6'), - ], - }) - - const addResult = await signAndExecute({ transaction: tx2 }) - await suiClient.waitForTransaction({ digest: addResult.digest }) - } - + const accountId = await registerOnchain(address, publicKeyHex, suiAddress) setTxStatus('delegate key registered onchain!') - - // Save to localStorage (including account object ID) - setDelegateKeys(privateKeyHex, publicKeyHex, knownAccountId || '') + setDelegateKeys(privateKeyHex, publicKeyHex, accountId) + setPrivateKeyHex('') setStep('done') } catch (err: unknown) { console.error('Onchain operation failed:', err) const message = err instanceof Error ? err.message : 'transaction failed. please try again.' setError(message) - setStep('show-key') // Go back to key display + setStep('show-key') + } finally { + setupRunningRef.current = false } - }, [address, publicKeyHex, privateKeyHex, suiAddress, suiClient, signAndExecute, setDelegateKeys]) + }, [address, publicKeyHex, privateKeyHex, suiAddress, registerOnchain, setDelegateKeys]) const copyKey = useCallback(async () => { await navigator.clipboard.writeText(privateKeyHex) @@ -203,6 +234,11 @@ export default function SetupWizard() { setTimeout(() => setCopied(false), 2000) }, [privateKeyHex]) + const handleRetry = useCallback(() => { + setError('') + setStep('intro') + }, []) + return ( <>