diff --git a/package.json b/package.json index 2a45b1d..9e7e87b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@aztec/entrypoints": "v4.0.0-devnet.1-patch.0", "@aztec/foundation": "v4.0.0-devnet.1-patch.0", "@aztec/noir-contracts.js": "v4.0.0-devnet.1-patch.0", + "@aztec/protocol-contracts": "v4.0.0-devnet.1-patch.0", "@aztec/pxe": "v4.0.0-devnet.1-patch.0", "@aztec/stdlib": "v4.0.0-devnet.1-patch.0", "@aztec/wallet-sdk": "v4.0.0-devnet.1-patch.0", diff --git a/src/App.tsx b/src/App.tsx index 2005cb3..e50ca9d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,15 +8,18 @@ import { SwapContainer } from './components/swap'; import { useWallet } from './contexts/wallet'; import { useOnboarding } from './contexts/onboarding'; import { OnboardingModal } from './components/OnboardingModal'; +import { TxNotificationCenter } from './components/TxNotificationCenter'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; export function App() { - const { disconnectWallet, setCurrentAddress, isUsingEmbeddedWallet, currentAddress, error: walletError, isLoading: walletLoading } = useWallet(); - const { isOnboardingModalOpen, startOnboarding, resetOnboarding } = useOnboarding(); + const { disconnectWallet, setCurrentAddress, currentAddress, error: walletError, isLoading: walletLoading } = useWallet(); + const { isOnboardingModalOpen, startOnboarding, resetOnboarding, status: onboardingStatus } = useOnboarding(); + + const isOnboarded = onboardingStatus === 'completed'; const handleWalletClick = () => { - // If already connected, start a new onboarding flow to change wallet - if (!isUsingEmbeddedWallet && currentAddress) { + // If already onboarded, start a new onboarding flow to change wallet + if (isOnboarded && currentAddress) { resetOnboarding(); } startOnboarding(); // Start onboarding when clicked from wallet chip @@ -60,7 +63,7 @@ export function App() { {/* Wallet Connection Chip */} @@ -131,6 +134,9 @@ export function App() { setCurrentAddress(address); }} /> + + {/* Transaction Progress Toasts (embedded wallet only) */} + ); } diff --git a/src/components/NetworkSwitcher.tsx b/src/components/NetworkSwitcher.tsx index dd6cbe8..3b16be3 100644 --- a/src/components/NetworkSwitcher.tsx +++ b/src/components/NetworkSwitcher.tsx @@ -17,8 +17,10 @@ import { useOnboarding } from '../contexts/onboarding'; export function NetworkSwitcher() { const { activeNetwork, availableNetworks, switchNetwork } = useNetwork(); - const { isUsingEmbeddedWallet, disconnectWallet } = useWallet(); - const { resetOnboarding } = useOnboarding(); + const { disconnectWallet } = useWallet(); + const { resetOnboarding, status: onboardingStatus } = useOnboarding(); + + const isOnboarded = onboardingStatus === 'completed'; const [confirmOpen, setConfirmOpen] = useState(false); const [pendingNetwork, setPendingNetwork] = useState(null); @@ -27,12 +29,12 @@ export function NetworkSwitcher() { const networkId = event.target.value; if (networkId === activeNetwork.id) return; - // If wallet is connected, show confirmation dialog - if (!isUsingEmbeddedWallet) { + // If user has completed onboarding (external or embedded), show confirmation dialog + if (isOnboarded) { setPendingNetwork(networkId); setConfirmOpen(true); } else { - // Embedded wallet, switch immediately + // Not yet onboarded, switch immediately switchNetwork(networkId); } }; diff --git a/src/components/OnboardingModal.tsx b/src/components/OnboardingModal.tsx index fa63ac9..45523bd 100644 --- a/src/components/OnboardingModal.tsx +++ b/src/components/OnboardingModal.tsx @@ -49,6 +49,8 @@ export function OnboardingModal({ open, onAccountSelect }: OnboardingModalProps) dismissDripError, setSimulationGrant, hasSimulationGrant, + selectEmbeddedWallet, + useEmbeddedWallet, } = useOnboarding(); const { discoverWallets, initiateConnection, confirmConnection, cancelConnection, onWalletDisconnect } = useWallet(); const { activeNetwork } = useNetwork(); @@ -107,7 +109,8 @@ export function OnboardingModal({ open, onAccountSelect }: OnboardingModalProps) setDiscoveredWallets(prev => [...prev, wallet]); } if (!foundAny) { - setAccountsError('No wallets found. Make sure your wallet extension is installed.'); + // No external wallets found — still show wallet selection phase so the embedded option is visible + setConnectionPhase('selecting_wallet'); } })(); @@ -140,7 +143,8 @@ export function OnboardingModal({ open, onAccountSelect }: OnboardingModalProps) } if (!foundAny) { - setAccountsError('No wallets found. Make sure your wallet extension is installed.'); + // No external wallets found — still show wallet selection phase so the embedded option is visible + setConnectionPhase('selecting_wallet'); } }; @@ -244,7 +248,7 @@ export function OnboardingModal({ open, onAccountSelect }: OnboardingModalProps) // Computed display states const showWalletSelection = - status === 'connecting' && connectionPhase === 'selecting_wallet' && discoveredWallets.length > 0; + status === 'connecting' && connectionPhase === 'selecting_wallet'; const showEmojiVerification = status === 'connecting' && connectionPhase === 'verifying' && pendingConnection !== null; const showAccountSelection = status === 'connecting' && connectionPhase === 'selecting_account' && accounts.length > 0; const showCompletionTransition = status === 'completed'; @@ -321,8 +325,8 @@ export function OnboardingModal({ open, onAccountSelect }: OnboardingModalProps) {/* Wallet Connection Flow */} - {isLoadingAccounts && connectionPhase === 'discovering' ? ( - + {connectionPhase === 'discovering' ? ( + ) : isLoadingAccounts && connectionPhase === 'connecting' && selectedWallet ? ( ) : showWalletSelection ? ( @@ -331,6 +335,7 @@ export function OnboardingModal({ open, onAccountSelect }: OnboardingModalProps) cancelledWalletIds={cancelledWalletIds} onSelect={handleWalletSelect} onRefresh={handleRediscover} + onUseEmbedded={selectEmbeddedWallet} /> ) : showEmojiVerification && selectedWallet && pendingConnection ? ( {/* Flow-specific Messages */} - + {/* Drip Password Input (shown when balance is 0) */} diff --git a/src/components/TxNotificationCenter.tsx b/src/components/TxNotificationCenter.tsx new file mode 100644 index 0000000..2429844 --- /dev/null +++ b/src/components/TxNotificationCenter.tsx @@ -0,0 +1,403 @@ +/** + * TxNotificationCenter + * Toast-style notification panel pinned to the bottom-right corner. + * Shows live transaction progress for embedded wallet operations, + * including phase status, elapsed time, and a PhaseTimeline breakdown on completion. + */ + +import { useEffect, useState, useRef, useMemo } from 'react'; +import { + Box, + Paper, + Typography, + IconButton, + Collapse, + Tooltip, + CircularProgress, + Chip, + keyframes, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; +import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; +import { txProgress, type TxProgressEvent, type PhaseTiming } from '../tx-progress'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const formatDuration = (ms: number): string => { + if (ms < 1000) return `${Math.round(ms)}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(1); + return `${minutes}m ${seconds}s`; +}; + +const formatDurationLong = (ms: number): string => { + if (ms < 1000) return `${Math.round(ms)} milliseconds`; + if (ms < 60000) return `${(ms / 1000).toFixed(2)} seconds`; + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(1); + return `${minutes}m ${seconds}s`; +}; + +const PHASE_LABELS: Record = { + simulating: 'Simulating', + proving: 'Proving', + sending: 'Sending', + mining: 'Waiting for confirmation', + complete: 'Complete', + error: 'Failed', +}; + +const pulse = keyframes` + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +`; + +// ─── PhaseTimeline (inline, simplified from demo-wallet) ───────────────────── + +function PhaseTimelineBar({ phases }: { phases: PhaseTiming[] }) { + const totalDuration = useMemo(() => phases.reduce((sum, p) => sum + p.duration, 0), [phases]); + const miningDuration = useMemo( + () => phases.filter(p => p.name === 'Mining').reduce((sum, p) => sum + p.duration, 0), + [phases], + ); + + if (phases.length === 0 || totalDuration === 0) return null; + + const preparingDuration = totalDuration - miningDuration; + const hasMining = miningDuration > 0; + + return ( + + {/* Summary chips */} + + {hasMining ? ( + <> + + + + + ) : ( + + )} + + + {/* Timeline bar */} + + {phases.map((phase, index) => { + const percentage = (phase.duration / totalDuration) * 100; + return ( + + + {phase.name} + + + {formatDurationLong(phase.duration)} ({percentage.toFixed(1)}%) + + {phase.breakdown?.map((item, idx) => ( + + {item.label}: {formatDuration(item.duration)} + + ))} + + } + arrow + placement="top" + > + 0 ? 2 : 0, + height: '100%', + bgcolor: phase.color, + borderRight: index < phases.length - 1 ? '1px solid rgba(255,255,255,0.3)' : undefined, + transition: 'filter 0.2s ease', + cursor: 'pointer', + '&:hover': { filter: 'brightness(1.2)' }, + }} + /> + + ); + })} + + + {/* Legend */} + + {phases.map(phase => ( + + + + {phase.name} + + + ))} + + + ); +} + +// ─── Single Toast ──────────────────────────────────────────────────────────── + +interface TxToastProps { + event: TxProgressEvent; + onDismiss: () => void; +} + +function TxToast({ event, onDismiss }: TxToastProps) { + const [elapsed, setElapsed] = useState(Date.now() - event.startTime); + const [expanded, setExpanded] = useState(true); + const frozenElapsed = useRef(null); + const isActive = event.phase !== 'complete' && event.phase !== 'error'; + + // Tick elapsed time while active, freeze when done + useEffect(() => { + if (!isActive) { + if (frozenElapsed.current === null) { + frozenElapsed.current = Date.now() - event.startTime; + setElapsed(frozenElapsed.current); + } + return; + } + frozenElapsed.current = null; + const interval = setInterval(() => setElapsed(Date.now() - event.startTime), 200); + return () => clearInterval(interval); + }, [isActive, event.startTime]); + + const isComplete = event.phase === 'complete'; + const isError = event.phase === 'error'; + + return ( + + {/* Header */} + + {/* Status indicator */} + {isComplete ? ( + + ) : isError ? ( + + ) : ( + + )} + + {/* Label and phase */} + + + {event.label} + + + + {PHASE_LABELS[event.phase] ?? event.phase} + + {isActive && ( + + {[0, 1, 2].map(i => ( + + ))} + + )} + + + + {/* Elapsed time */} + + {formatDuration(elapsed)} + + + {/* Expand/collapse */} + {isComplete && event.phases.length > 0 && ( + setExpanded(prev => !prev)} sx={{ p: 0.25 }}> + {expanded ? : } + + )} + + {/* Dismiss */} + + + + + + {/* Phase timeline breakdown (shown when complete) */} + 0}> + + + + + + {/* Error message */} + {isError && event.error && ( + + + {event.error.length > 200 ? event.error.slice(0, 200) + '...' : event.error} + + + )} + + ); +} + +// ─── Notification Center Container ─────────────────────────────────────────── + +export function TxNotificationCenter({ account }: { account?: string | null }) { + const [toasts, setToasts] = useState>(new Map()); + const [collapsed, setCollapsed] = useState(false); + const toastsRef = useRef(toasts); + toastsRef.current = toasts; + + // Set account scope and load persisted history + useEffect(() => { + if (!account) return; + txProgress.setAccount(account); + const history = txProgress.loadHistory(); + if (history.length > 0) { + setToasts(prev => { + const next = new Map(prev); + for (const event of history) { + if (!next.has(event.txId)) next.set(event.txId, event); + } + return next; + }); + } + }, [account]); + + useEffect(() => { + return txProgress.subscribe(event => { + setToasts(prev => { + const next = new Map(prev); + next.set(event.txId, event); + return next; + }); + }); + }, []); + + const dismiss = (txId: string) => { + txProgress.dismissPersisted(txId); + setToasts(prev => { + const next = new Map(prev); + next.delete(txId); + return next; + }); + }; + + const toastList = Array.from(toasts.entries()); + + if (toastList.length === 0) return null; + + const activeCount = toastList.filter(([, e]) => e.phase !== 'complete' && e.phase !== 'error').length; + + return ( + *': { pointerEvents: 'auto' }, + }} + > + {/* Toast list (rendered first = bottom in column-reverse) */} + {!collapsed && + toastList.map(([txId, event]) => ( + dismiss(txId)} /> + ))} + + {/* Collapse / expand toggle (rendered last = top in column-reverse) */} + + setCollapsed(prev => !prev)} + sx={{ + bgcolor: 'background.paper', + border: '1px solid', + borderColor: activeCount > 0 ? 'rgba(212, 255, 40, 0.3)' : 'divider', + '&:hover': { bgcolor: 'action.hover' }, + px: 1, + borderRadius: 1, + gap: 0.5, + }} + > + {collapsed && ( + + {toastList.length} tx{toastList.length !== 1 ? 's' : ''} + + )} + {collapsed ? : } + + + + ); +} diff --git a/src/components/onboarding/FlowMessages.tsx b/src/components/onboarding/FlowMessages.tsx index 58ae51a..fd2925e 100644 --- a/src/components/onboarding/FlowMessages.tsx +++ b/src/components/onboarding/FlowMessages.tsx @@ -9,9 +9,30 @@ import type { OnboardingStatus } from '../../contexts/onboarding'; interface FlowMessagesProps { status: OnboardingStatus; hasSimulationGrant?: boolean; + useEmbeddedWallet?: boolean; } -export function FlowMessages({ status, hasSimulationGrant }: FlowMessagesProps) { +export function FlowMessages({ status, hasSimulationGrant, useEmbeddedWallet }: FlowMessagesProps) { + // Show message during account deployment + if (status === 'deploying_account') { + return ( + + + Deploying your account on-chain. This may take a moment... + + + ); + } + // Show message during simulation - different text based on whether grant was given if (status === 'simulating') { return ( @@ -26,7 +47,7 @@ export function FlowMessages({ status, hasSimulationGrant }: FlowMessagesProps) }} > - {hasSimulationGrant + {useEmbeddedWallet || hasSimulationGrant ? 'Fetching your token balances...' : 'Please approve the batched queries in your wallet. This is a one-time setup that enables seamless interactions going forward.'} @@ -42,14 +63,20 @@ export function FlowMessages({ status, hasSimulationGrant }: FlowMessagesProps) Uh oh! You have no GregoCoin balance! - - Next steps: -
    -
  1. Approve the registration of ProofOfPassword contract in your wallet
  2. -
  3. Provide the password to claim your tokens
  4. -
  5. Authorize the transaction
  6. -
-
+ {useEmbeddedWallet ? ( + + Registering the token faucet. You'll be able to claim free tokens shortly. + + ) : ( + + Next steps: +
    +
  1. Approve the registration of ProofOfPassword contract in your wallet
  2. +
  3. Provide the password to claim your tokens
  4. +
  5. Authorize the transaction
  6. +
+
+ )}
); diff --git a/src/components/onboarding/WalletDiscovery.tsx b/src/components/onboarding/WalletDiscovery.tsx index a374a3e..81902da 100644 --- a/src/components/onboarding/WalletDiscovery.tsx +++ b/src/components/onboarding/WalletDiscovery.tsx @@ -1,25 +1,85 @@ /** * WalletDiscovery Component - * Shows discovery animation while searching for wallets + * Shows the embedded wallet option immediately while searching for external wallets */ -import { Box, Typography } from '@mui/material'; +import { + Box, + Typography, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + CircularProgress, +} from '@mui/material'; +import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -export function WalletDiscovery() { +interface WalletDiscoveryProps { + onUseEmbedded: () => void; +} + +export function WalletDiscovery({ onUseEmbedded }: WalletDiscoveryProps) { return ( - - - Discovering wallets... - - + <> + + + Choose how to connect: + + + + + {/* Embedded wallet option — same style as external wallets */} + + + + + + + + + Continue without external wallet + + } + secondary={ + + Use a built-in wallet for this session + + } + /> + + + + + {/* Discovery indicator */} + + + Looking for external wallets... + + + ); } diff --git a/src/components/onboarding/WalletSelection.tsx b/src/components/onboarding/WalletSelection.tsx index 2fa232b..c7e8938 100644 --- a/src/components/onboarding/WalletSelection.tsx +++ b/src/components/onboarding/WalletSelection.tsx @@ -1,10 +1,21 @@ /** * WalletSelection Component - * Displays list of discovered wallets for user selection + * Displays list of discovered wallets for user selection, plus an option to continue with the embedded wallet */ -import { Box, Typography, List, ListItem, ListItemButton, ListItemIcon, ListItemText, IconButton } from '@mui/material'; +import { + Box, + Typography, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + Divider, +} from '@mui/material'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; import RefreshIcon from '@mui/icons-material/Refresh'; import type { WalletProvider } from '@aztec/wallet-sdk/manager'; @@ -13,22 +24,77 @@ interface WalletSelectionProps { cancelledWalletIds: Set; onSelect: (wallet: WalletProvider) => void; onRefresh: () => void; + onUseEmbedded: () => void; } -export function WalletSelection({ wallets, cancelledWalletIds, onSelect, onRefresh }: WalletSelectionProps) { +export function WalletSelection({ wallets, cancelledWalletIds, onSelect, onRefresh, onUseEmbedded }: WalletSelectionProps) { return ( <> - Select your wallet to connect: + Choose how to connect: - + + {/* Embedded wallet option */} + + + + + + + + + Continue without external wallet + + } + secondary={ + + Use a built-in wallet for this session + + } + /> + + + + {/* Divider between embedded and external wallets */} + {wallets.length > 0 && ( + + + or connect an external wallet + + + )} + + {/* External wallet options */} {wallets.map(provider => { const isCancelled = cancelledWalletIds.has(provider.id); return ( diff --git a/src/components/swap/SwapContainer.tsx b/src/components/swap/SwapContainer.tsx index f6765eb..3a86a51 100644 --- a/src/components/swap/SwapContainer.tsx +++ b/src/components/swap/SwapContainer.tsx @@ -19,7 +19,7 @@ import type { Balances } from '../../types'; export function SwapContainer() { const { isLoadingContracts, fetchBalances } = useContracts(); - const { isUsingEmbeddedWallet, currentAddress } = useWallet(); + const { currentAddress } = useWallet(); const { status: onboardingStatus, startOnboarding, @@ -51,9 +51,11 @@ export function SwapContainer() { const swapErrorRef = useRef(null); + const isOnboarded = onboardingStatus === 'completed'; + // Fetch balances const refetchBalances = useCallback(async () => { - if (isUsingEmbeddedWallet || !currentAddress) { + if (!isOnboarded || !currentAddress) { setBalances({ gregoCoin: null, gregoCoinPremium: null }); return; } @@ -67,14 +69,14 @@ export function SwapContainer() { } finally { setIsLoadingBalances(false); } - }, [fetchBalances, currentAddress, isUsingEmbeddedWallet]); + }, [fetchBalances, currentAddress, isOnboarded]); - // Clear balances when switching to embedded wallet or losing address + // Clear balances when not onboarded or losing address useEffect(() => { - if (isUsingEmbeddedWallet || !currentAddress) { + if (!isOnboarded || !currentAddress) { setBalances({ gregoCoin: null, gregoCoinPremium: null }); } - }, [isUsingEmbeddedWallet, currentAddress]); + }, [isOnboarded, currentAddress]); // Refetch balances when onboarding completes useEffect(() => { @@ -101,10 +103,10 @@ export function SwapContainer() { const handleSwapClick = () => { // Check if user needs onboarding - if (isUsingEmbeddedWallet || onboardingStatus === 'idle') { + if (!isOnboarded) { // Start onboarding - user initiated a swap transaction startOnboarding(true); - } else if (onboardingStatus === 'completed') { + } else { // Already onboarded, execute swap directly executeSwap(); } @@ -122,7 +124,7 @@ export function SwapContainer() { } }; - const showBalance = !isUsingEmbeddedWallet && currentAddress !== null; + const showBalance = isOnboarded && currentAddress !== null; // Only disable inputs when swap is in progress const disableFromBox = isSwapping; diff --git a/src/contexts/onboarding/OnboardingContext.tsx b/src/contexts/onboarding/OnboardingContext.tsx index cb51770..3f38660 100644 --- a/src/contexts/onboarding/OnboardingContext.tsx +++ b/src/contexts/onboarding/OnboardingContext.tsx @@ -20,7 +20,7 @@ import { type OnboardingResult, type DripPhase, } from './reducer'; -import { parseDripError } from '../../services/contractService'; +import { parseDripError, deployEmbeddedAccount } from '../../services/contractService'; export type { OnboardingStatus, OnboardingStep }; export { ONBOARDING_STEPS, ONBOARDING_STEPS_WITH_DRIP, getOnboardingSteps, getOnboardingStepsWithDrip }; @@ -35,6 +35,7 @@ interface OnboardingContextType { isOnboardingModalOpen: boolean; onboardingResult: OnboardingResult | null; needsDrip: boolean; + useEmbeddedWallet: boolean; // Derived state isSwapPending: boolean; @@ -58,6 +59,7 @@ interface OnboardingContextType { markRegistered: () => void; markSimulated: () => void; setSimulationGrant: (granted: boolean) => void; + selectEmbeddedWallet: () => void; closeModal: () => void; clearSwapPending: () => void; completeDripOnboarding: (password: string) => void; @@ -91,7 +93,7 @@ function setStoredOnboardingStatus(address: AztecAddress | null, completed: bool } export function OnboardingProvider({ children }: OnboardingProviderProps) { - const { currentAddress, isUsingEmbeddedWallet } = useWallet(); + const { wallet, currentAddress, isUsingEmbeddedWallet, node } = useWallet(); const { simulateOnboardingQueries, isLoadingContracts, registerBaseContracts, registerDripContracts, drip } = useContracts(); @@ -102,10 +104,11 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { // Computed values const steps = state.needsDrip - ? getOnboardingStepsWithDrip(state.hasSimulationGrant) - : getOnboardingSteps(state.hasSimulationGrant); - const currentStep = calculateCurrentStep(state.status, state.needsDrip); - const totalSteps = state.needsDrip ? 5 : 4; + ? getOnboardingStepsWithDrip(state.hasSimulationGrant, state.useEmbeddedWallet) + : getOnboardingSteps(state.hasSimulationGrant, state.useEmbeddedWallet); + const currentStep = calculateCurrentStep(state.status, state.needsDrip, state.useEmbeddedWallet); + const baseSteps = state.useEmbeddedWallet ? 5 : 4; + const totalSteps = state.needsDrip ? baseSteps + 1 : baseSteps; const isSwapPending = state.status === 'completed' && state.pendingSwap; const isDripPending = state.status === 'executing_drip' && state.dripPassword !== null; const isDripping = state.dripPhase === 'sending' || state.dripPhase === 'mining'; @@ -116,7 +119,7 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { if (state.status === 'idle' || state.status === 'completed' || state.status === 'error') return; try { - // Step 1: After wallet connection, register base contracts (AMM, tokens) + // Step 1a: After external wallet connection, go straight to registering if ( state.status === 'connecting' && currentAddress && @@ -128,6 +131,22 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { await registerBaseContracts(); } + // Step 1b: After embedded wallet selection, deploy account if needed + if ( + state.status === 'deploying_account' && + currentAddress && + wallet && + node && + !state.hasDeployedAccount + ) { + actions.markDeployedAccount(); + await deployEmbeddedAccount(wallet, node); + // After deployment, proceed to register contracts + actions.markRegistered(); + actions.advanceStatus('registering'); + await registerBaseContracts(); + } + // Step 2: After contracts are registered, simulate to check balances if (state.status === 'registering' && !isLoadingContracts && currentAddress && !state.hasSimulated) { actions.markSimulated(); @@ -168,8 +187,12 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { state.status, state.hasRegisteredBase, state.hasSimulated, + state.hasDeployedAccount, + state.useEmbeddedWallet, currentAddress, isUsingEmbeddedWallet, + wallet, + node, isLoadingContracts, simulateOnboardingQueries, registerBaseContracts, @@ -217,6 +240,7 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { isOnboardingModalOpen: state.isModalOpen, onboardingResult: state.result, needsDrip: state.needsDrip, + useEmbeddedWallet: state.useEmbeddedWallet, isSwapPending, isDripPending, dripPassword: state.dripPassword, @@ -232,6 +256,7 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { markRegistered: actions.markRegistered, markSimulated: actions.markSimulated, setSimulationGrant: actions.setSimulationGrant, + selectEmbeddedWallet: actions.selectEmbeddedWallet, closeModal: actions.closeModal, clearSwapPending: actions.clearPendingSwap, completeDripOnboarding: actions.setPassword, diff --git a/src/contexts/onboarding/reducer.ts b/src/contexts/onboarding/reducer.ts index e6fdbc2..131f4e1 100644 --- a/src/contexts/onboarding/reducer.ts +++ b/src/contexts/onboarding/reducer.ts @@ -12,6 +12,7 @@ import { createReducerHook, type ActionsFrom } from '../utils'; export type OnboardingStatus = | 'idle' | 'connecting' + | 'deploying_account' | 'registering' | 'simulating' | 'registering_drip' @@ -44,11 +45,14 @@ export interface OnboardingState { error: string | null; hasRegisteredBase: boolean; hasSimulated: boolean; + hasDeployedAccount: boolean; needsDrip: boolean; dripPhase: DripPhase; dripError: string | null; /** Whether simulation capabilities were granted in the manifest */ hasSimulationGrant: boolean; + /** Whether the user chose to continue with the embedded wallet */ + useEmbeddedWallet: boolean; } export const initialOnboardingState: OnboardingState = { @@ -60,10 +64,12 @@ export const initialOnboardingState: OnboardingState = { error: null, hasRegisteredBase: false, hasSimulated: false, + hasDeployedAccount: false, needsDrip: false, dripPhase: 'idle', dripError: null, hasSimulationGrant: false, + useEmbeddedWallet: false, }; // ============================================================================= @@ -77,7 +83,9 @@ export const onboardingActions = { setPassword: (password: string) => ({ type: 'onboarding/SET_PASSWORD' as const, password }), markRegistered: () => ({ type: 'onboarding/MARK_REGISTERED' as const }), markSimulated: () => ({ type: 'onboarding/MARK_SIMULATED' as const }), + markDeployedAccount: () => ({ type: 'onboarding/MARK_DEPLOYED_ACCOUNT' as const }), markNeedsDrip: () => ({ type: 'onboarding/MARK_NEEDS_DRIP' as const }), + selectEmbeddedWallet: () => ({ type: 'onboarding/SELECT_EMBEDDED_WALLET' as const }), setSimulationGrant: (granted: boolean) => ({ type: 'onboarding/SET_SIMULATION_GRANT' as const, granted }), complete: () => ({ type: 'onboarding/COMPLETE' as const }), closeModal: () => ({ type: 'onboarding/CLOSE_MODAL' as const }), @@ -126,9 +134,15 @@ export function onboardingReducer(state: OnboardingState, action: OnboardingActi case 'onboarding/MARK_SIMULATED': return { ...state, hasSimulated: true }; + case 'onboarding/MARK_DEPLOYED_ACCOUNT': + return { ...state, hasDeployedAccount: true }; + case 'onboarding/MARK_NEEDS_DRIP': return { ...state, needsDrip: true, pendingSwap: false }; + case 'onboarding/SELECT_EMBEDDED_WALLET': + return { ...state, useEmbeddedWallet: true, status: 'deploying_account' }; + case 'onboarding/SET_SIMULATION_GRANT': return { ...state, hasSimulationGrant: action.granted }; @@ -169,7 +183,31 @@ export function onboardingReducer(state: OnboardingState, action: OnboardingActi // Helpers // ============================================================================= -export function calculateCurrentStep(status: OnboardingStatus, needsDrip: boolean): number { +export function calculateCurrentStep(status: OnboardingStatus, needsDrip: boolean, useEmbeddedWallet: boolean): number { + if (useEmbeddedWallet) { + switch (status) { + case 'idle': + return 0; + case 'connecting': + return 1; + case 'deploying_account': + return 2; + case 'registering': + return 3; + case 'simulating': + return 4; + case 'registering_drip': + return 4; + case 'awaiting_drip': + case 'executing_drip': + return 5; + case 'completed': + return needsDrip ? 6 : 5; + default: + return 0; + } + } + switch (status) { case 'idle': return 0; @@ -191,23 +229,42 @@ export function calculateCurrentStep(status: OnboardingStatus, needsDrip: boolea } } -export function getOnboardingSteps(hasSimulationGrant: boolean): OnboardingStep[] { - return [ - { label: 'Connect Wallet', description: 'Select your account from the wallet extension' }, - { label: 'Register Contracts', description: 'Registering any missing contracts' }, - hasSimulationGrant - ? { label: 'Fetch Balances', description: 'Fetching your token balances' } - : { label: 'Approve Queries', description: 'Review and approve batched queries in your wallet' }, +export function getOnboardingSteps(hasSimulationGrant: boolean, useEmbeddedWallet: boolean = false): OnboardingStep[] { + const steps: OnboardingStep[] = [ + { label: 'Choose Wallet', description: 'Select how you want to connect' }, ]; + + if (useEmbeddedWallet) { + steps.push({ label: 'Deploy Account', description: 'Deploying your account on-chain' }); + } + + steps.push({ label: 'Register Contracts', description: 'Registering any missing contracts' }); + + if (useEmbeddedWallet || hasSimulationGrant) { + steps.push({ label: 'Fetch Balances', description: 'Fetching your token balances' }); + } else { + steps.push({ label: 'Approve Queries', description: 'Review and approve batched queries in your wallet' }); + } + + return steps; } -export function getOnboardingStepsWithDrip(hasSimulationGrant: boolean): OnboardingStep[] { - return [ - { label: 'Connect Wallet', description: 'Select your account from the wallet extension' }, +export function getOnboardingStepsWithDrip(hasSimulationGrant: boolean, useEmbeddedWallet: boolean = false): OnboardingStep[] { + const steps: OnboardingStep[] = [ + { label: 'Choose Wallet', description: 'Select how you want to connect' }, + ]; + + if (useEmbeddedWallet) { + steps.push({ label: 'Deploy Account', description: 'Deploying your account on-chain' }); + } + + steps.push( { label: 'Register Contracts', description: 'Registering any missing contracts' }, { label: 'Register Faucet', description: 'Registering the token faucet contract if needed' }, { label: 'Claim Tokens', description: 'Claiming your free GregoCoin tokens' }, - ]; + ); + + return steps; } // Keep backwards-compatible exports for default (no grant) case diff --git a/src/embedded_wallet.ts b/src/embedded_wallet.ts index f8e49d8..3469a11 100644 --- a/src/embedded_wallet.ts +++ b/src/embedded_wallet.ts @@ -5,14 +5,24 @@ import { getPXEConfig, type PXEConfig } from '@aztec/pxe/config'; import { createPXE, PXE } from '@aztec/pxe/client/lazy'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract'; -import { BlockHeader, mergeExecutionPayloads, type ExecutionPayload, type TxSimulationResult } from '@aztec/stdlib/tx'; -import type { DefaultAccountEntrypointOptions } from '@aztec/entrypoints/account'; +import { + BlockHeader, + collectOffchainEffects, + mergeExecutionPayloads, + type ExecutionPayload, + type TxSimulationResult, +} from '@aztec/stdlib/tx'; +import { AccountFeePaymentMethodOptions, type DefaultAccountEntrypointOptions } from '@aztec/entrypoints/account'; import { deriveSigningKey } from '@aztec/stdlib/keys'; import { SignerlessAccount, type Account, type AccountContract } from '@aztec/aztec.js/account'; import { AccountManager, type SimulateOptions } from '@aztec/aztec.js/wallet'; import type { AztecNode } from '@aztec/aztec.js/node'; -import type { SimulateInteractionOptions } from '@aztec/aztec.js/contracts'; +import { type InteractionWaitOptions, NO_WAIT, type SendReturn } from '@aztec/aztec.js/contracts'; import { Fr, GrumpkinScalar } from '@aztec/aztec.js/fields'; +import { waitForTx } from '@aztec/aztec.js/node'; +import type { SendOptions } from '@aztec/aztec.js/wallet'; +import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; +import { CallAuthorizationRequest } from '@aztec/aztec.js/authorization'; import { BaseWallet, buildMergedSimulationResult, @@ -20,6 +30,42 @@ import { simulateViaNode, type FeeOptions, } from '@aztec/wallet-sdk/base-wallet'; +import { txProgress, type PhaseTiming, type TxProgressEvent } from './tx-progress'; +import type { FieldsOf } from '@aztec/foundation/types'; +import { GasSettings } from '@aztec/stdlib/gas'; +import { getSponsoredFPCData } from './services'; +import { getCanonicalMultiCallEntrypoint } from '@aztec/protocol-contracts/multi-call-entrypoint/lazy'; + +const STORAGE_KEY_SECRET = 'gregoswap_embedded_secret'; +const STORAGE_KEY_SALT = 'gregoswap_embedded_salt'; + +function loadOrGenerateCredentials(): { secret: Fr; salt: Fr } { + try { + const storedSecret = localStorage.getItem(STORAGE_KEY_SECRET); + const storedSalt = localStorage.getItem(STORAGE_KEY_SALT); + + if (storedSecret && storedSalt) { + return { + secret: Fr.fromString(storedSecret), + salt: Fr.fromString(storedSalt), + }; + } + } catch { + // localStorage unavailable, fall through to generate + } + + const secret = Fr.random(); + const salt = Fr.random(); + + try { + localStorage.setItem(STORAGE_KEY_SECRET, secret.toString()); + localStorage.setItem(STORAGE_KEY_SALT, salt.toString()); + } catch { + // localStorage unavailable, credentials will only persist in-memory for this session + } + + return { secret, salt }; +} /** * Data for generating an account. @@ -41,6 +87,8 @@ export interface AccountData { export class EmbeddedWallet extends BaseWallet { protected accounts: Map = new Map(); + private accountManager: AccountManager | null = null; + private skipAuthWitExtraction = false; constructor(pxe: PXE, aztecNode: AztecNode) { super(pxe, aztecNode); @@ -96,34 +144,75 @@ export class EmbeddedWallet extends BaseWallet { async getAccounts() { if (this.accounts.size === 0) { + const { secret, salt } = loadOrGenerateCredentials(); const accountManager = await this.createAccount({ - salt: Fr.ZERO, - secret: Fr.ZERO, - contract: new SchnorrAccountContract(deriveSigningKey(Fr.ZERO)), + secret, + salt, + contract: new SchnorrAccountContract(deriveSigningKey(secret)), }); + this.accountManager = accountManager; const account = await accountManager.getAccount(); this.accounts.set(accountManager.address.toString(), account); } return Array.from(this.accounts.values()).map(acc => ({ item: acc.getAddress(), alias: '' })); } + getAccountManager(): AccountManager { + if (!this.accountManager) { + throw new Error('Account not yet initialized. Call getAccounts() first.'); + } + return this.accountManager; + } + + async isAccountDeployed(): Promise { + const accountManager = this.getAccountManager(); + const metadata = await this.getContractMetadata(accountManager.address); + return metadata.isContractInitialized; + } + + async deployAccount(sponsoredFPCAddress: AztecAddress) { + const accountManager = this.getAccountManager(); + const deployMethod = await accountManager.getDeployMethod(); + this.skipAuthWitExtraction = true; + try { + return await deployMethod.send({ + from: AztecAddress.ZERO, + fee: { + paymentMethod: new SponsoredFeePaymentMethod(sponsoredFPCAddress), + }, + }); + } finally { + this.skipAuthWitExtraction = false; + } + } + private async getFakeAccountDataFor(address: AztecAddress) { - const originalAccount = await this.getAccountFromAddress(address); - const originalAddress = await originalAccount.getCompleteAddress(); - const contractInstance = await this.pxe.getContractInstance(originalAddress.address); - if (!contractInstance) { - throw new Error(`No contract instance found for address: ${originalAddress.address}`); + if (!address.equals(AztecAddress.ZERO)) { + const originalAccount = await this.getAccountFromAddress(address); + const originalAddress = originalAccount.getCompleteAddress(); + const contractInstance = await this.pxe.getContractInstance(originalAddress.address); + if (!contractInstance) { + throw new Error(`No contract instance found for address: ${originalAddress.address}`); + } + const account = createStubAccount(originalAddress); + const StubAccountContractArtifact = await getStubAccountContractArtifact(); + const instance = await getContractInstanceFromInstantiationParams(StubAccountContractArtifact, { + salt: Fr.random(), + }); + return { + account, + instance, + artifact: StubAccountContractArtifact, + }; + } else { + const contract = await getCanonicalMultiCallEntrypoint(); + const account = new SignerlessAccount(); + return { + instance: contract.instance, + account, + artifact: contract.artifact, + }; } - const stubAccount = createStubAccount(originalAddress); - const StubAccountContractArtifact = await getStubAccountContractArtifact(); - const instance = await getContractInstanceFromInstantiationParams(StubAccountContractArtifact, { - salt: Fr.random(), - }); - return { - account: stubAccount, - instance, - artifact: StubAccountContractArtifact, - }; } override async simulateTx(executionPayload: ExecutionPayload, opts: SimulateOptions): Promise { @@ -169,6 +258,177 @@ export class EmbeddedWallet extends BaseWallet { return buildMergedSimulationResult(optimizedResults, normalResult); } + /** + * Completes partial user-provided fee options with wallet defaults. + * @param from - The address where the transaction is being sent from + * @param feePayer - The address paying for fees (if any fee payment method is embedded in the execution payload) + * @param gasSettings - User-provided partial gas settings + * @returns - Complete fee options that can be used to create a transaction execution request + */ + protected override async completeFeeOptions( + from: AztecAddress, + feePayer?: AztecAddress, + gasSettings?: Partial>, + ): Promise { + const maxFeesPerGas = + gasSettings?.maxFeesPerGas ?? (await this.aztecNode.getCurrentMinFees()).mul(1 + this.minFeePadding); + let accountFeePaymentMethodOptions; + let walletFeePaymentMethod; + // The transaction does not include a fee payment method, so we + // use the sponsoredFPC + if (!feePayer) { + accountFeePaymentMethodOptions = AccountFeePaymentMethodOptions.EXTERNAL; + const { instance } = await getSponsoredFPCData(); + walletFeePaymentMethod = new SponsoredFeePaymentMethod(instance.address); + } else { + // The transaction includes fee payment method, so we check if we are the fee payer for it + // (this can only happen if the embedded payment method is FeeJuiceWithClaim) + accountFeePaymentMethodOptions = from.equals(feePayer) + ? AccountFeePaymentMethodOptions.FEE_JUICE_WITH_CLAIM + : AccountFeePaymentMethodOptions.EXTERNAL; + } + const fullGasSettings: GasSettings = GasSettings.default({ ...gasSettings, maxFeesPerGas }); + this.log.debug(`Using L2 gas settings`, fullGasSettings); + return { + gasSettings: fullGasSettings, + walletFeePaymentMethod, + accountFeePaymentMethodOptions, + }; + } + + override async sendTx( + executionPayload: ExecutionPayload, + opts: SendOptions, + ): Promise> { + const txId = crypto.randomUUID(); + const startTime = Date.now(); + const phaseTimings: TxProgressEvent['phaseTimings'] = {}; + const phases: PhaseTiming[] = []; + + // Derive a human-readable label from the first meaningful call in the payload + // Skip fee payment methods (e.g. sponsor_unconditionally) to find the actual user call + const meaningfulCall = + executionPayload.calls?.find(c => c.name !== 'sponsor_unconditionally') ?? executionPayload.calls?.[0]; + const fnName = meaningfulCall?.name ?? 'Transaction'; + const label = + fnName === 'constructor' ? 'Deploy' : fnName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + + const emit = (phase: TxProgressEvent['phase'], extra?: Partial) => { + txProgress.emit({ + txId, + label, + phase, + startTime, + phaseTimings: { ...phaseTimings }, + phases: [...phases], + ...extra, + }); + }; + + try { + const feeOptions = await this.completeFeeOptions(opts.from, executionPayload.feePayer, opts.fee?.gasSettings); + if (!this.skipAuthWitExtraction) { + emit('simulating'); + const simulationStart = Date.now(); + + const simulationResult = await this.simulateViaEntrypoint(executionPayload, opts.from, feeOptions, true, true); + + const offchainEffects = collectOffchainEffects(simulationResult.privateExecutionResult); + const authWitnesses = await Promise.all( + offchainEffects.map(async effect => { + try { + const authRequest = await CallAuthorizationRequest.fromFields(effect.data); + return this.createAuthWit(opts.from, { + consumer: effect.contractAddress, + innerHash: authRequest.innerHash, + }); + } catch { + return undefined; // Not a CallAuthorizationRequest, skip + } + }), + ); + for (const wit of authWitnesses) { + if (wit) executionPayload.authWitnesses.push(wit); + } + + const simulationDuration = Date.now() - simulationStart; + phaseTimings.simulation = simulationDuration; + phases.push({ name: 'Simulation', duration: simulationDuration, color: '#ce93d8' }); + } + + // --- PROVING --- + emit('proving'); + const provingStart = Date.now(); + + const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions); + const provenTx = await this.pxe.proveTx(txRequest); + + const provingDuration = Date.now() - provingStart; + phaseTimings.proving = provingDuration; + + // Extract detailed stats from proving result if available + const stats = provenTx.stats; + if (stats?.timings) { + const t = stats.timings; + if (t.sync && t.sync > 0) phases.push({ name: 'Sync', duration: t.sync, color: '#90caf9' }); + if (t.perFunction?.length > 0) { + const witgenTotal = t.perFunction.reduce((sum: number, fn: { time: number }) => sum + fn.time, 0); + phases.push({ + name: 'Witgen', + duration: witgenTotal, + color: '#ffb74d', + breakdown: t.perFunction.map((fn: { functionName: string; time: number }) => ({ + label: fn.functionName.split(':').pop() || fn.functionName, + duration: fn.time, + })), + }); + } + if (t.proving && t.proving > 0) phases.push({ name: 'Proving', duration: t.proving, color: '#f48fb1' }); + if (t.unaccounted > 0) phases.push({ name: 'Other', duration: t.unaccounted, color: '#bdbdbd' }); + } else { + phases.push({ name: 'Proving', duration: provingDuration, color: '#f48fb1' }); + } + + // --- SENDING --- + emit('sending'); + const sendingStart = Date.now(); + + const tx = await provenTx.toTx(); + const txHash = tx.getTxHash(); + if (await this.aztecNode.getTxEffect(txHash)) { + throw new Error(`A settled tx with equal hash ${txHash.toString()} exists.`); + } + await this.aztecNode.sendTx(tx); + + const sendingDuration = Date.now() - sendingStart; + phaseTimings.sending = sendingDuration; + phases.push({ name: 'Sending', duration: sendingDuration, color: '#2196f3' }); + + // NO_WAIT: return txHash immediately + if (opts.wait === NO_WAIT) { + emit('complete'); + return txHash as SendReturn; + } + + // --- MINING --- + emit('mining'); + const miningStart = Date.now(); + + const waitOpts = typeof opts.wait === 'object' ? opts.wait : undefined; + const receipt = await waitForTx(this.aztecNode, txHash, waitOpts); + + const miningDuration = Date.now() - miningStart; + phaseTimings.mining = miningDuration; + phases.push({ name: 'Mining', duration: miningDuration, color: '#4caf50' }); + + emit('complete'); + return receipt as SendReturn; + } catch (err) { + emit('error', { error: err instanceof Error ? err.message : 'Transaction failed' }); + throw err; + } + } + protected override async simulateViaEntrypoint( executionPayload: ExecutionPayload, from: AztecAddress, diff --git a/src/services/contractService.ts b/src/services/contractService.ts index 729463d..0462670 100644 --- a/src/services/contractService.ts +++ b/src/services/contractService.ts @@ -38,7 +38,7 @@ export interface DripContracts { /** * Helper function to get SponsoredFPC contract data */ -async function getSponsoredFPCData() { +export async function getSponsoredFPCData() { const { SponsoredFPCContractArtifact } = await import('@aztec/noir-contracts.js/SponsoredFPC'); const sponsoredFPCInstance = await getContractInstanceFromInstantiationParams(SponsoredFPCContractArtifact, { salt: new Fr(SPONSORED_FPC_SALT), @@ -54,7 +54,7 @@ async function getSponsoredFPCData() { export async function registerSwapContracts( wallet: Wallet, node: AztecNode, - network: NetworkConfig + network: NetworkConfig, ): Promise { const gregoCoinAddress = AztecAddressClass.fromString(network.contracts.gregoCoin); const gregoCoinPremiumAddress = AztecAddressClass.fromString(network.contracts.gregoCoinPremium); @@ -134,7 +134,7 @@ export async function registerSwapContracts( export async function registerDripContracts( wallet: Wallet, node: AztecNode, - network: NetworkConfig + network: NetworkConfig, ): Promise { const popAddress = AztecAddressClass.fromString(network.contracts.pop); @@ -181,7 +181,7 @@ export async function registerDripContracts( export async function getExchangeRate( wallet: Wallet, contracts: SwapContracts, - fromAddress: AztecAddress + fromAddress: AztecAddress, ): Promise { const { gregoCoin, gregoCoinPremium, amm } = contracts; @@ -200,7 +200,7 @@ export async function getExchangeRate( export async function fetchBalances( wallet: Wallet, contracts: SwapContracts, - address: AztecAddress + address: AztecAddress, ): Promise<[bigint, bigint]> { const { gregoCoin, gregoCoinPremium } = contracts; @@ -220,7 +220,7 @@ export async function fetchBalances( export async function simulateOnboardingQueries( wallet: Wallet, contracts: SwapContracts, - address: AztecAddress + address: AztecAddress, ): Promise { const { gregoCoin, gregoCoinPremium, amm } = contracts; @@ -253,7 +253,7 @@ export async function executeSwap( contracts: SwapContracts, fromAddress: AztecAddress, amountOut: number, - amountInMax: number + amountInMax: number, ): Promise { const { gregoCoin, gregoCoinPremium, amm } = contracts; @@ -264,7 +264,7 @@ export async function executeSwap( gregoCoinPremium.address, BigInt(Math.round(amountOut)), BigInt(Math.round(amountInMax)), - authwitNonce + authwitNonce, ) .send({ from: fromAddress }); } @@ -298,7 +298,7 @@ export function parseSwapError(error: unknown): string { export async function executeDrip( pop: ProofOfPasswordContract, password: string, - recipient: AztecAddress + recipient: AztecAddress, ): Promise { const { instance: sponsoredFPCInstance } = await getSponsoredFPCData(); @@ -310,6 +310,35 @@ export async function executeDrip( }); } +/** + * Deploys the embedded wallet's account contract on-chain using SponsoredFPC for fees. + * Registers the SponsoredFPC contract if not already registered, checks if the account + * is already deployed, and deploys if needed. + */ +export async function deployEmbeddedAccount(wallet: Wallet, node: AztecNode): Promise { + const { EmbeddedWallet } = await import('../embedded_wallet'); + + if (!(wallet instanceof EmbeddedWallet)) { + throw new Error('deployEmbeddedAccount can only be called with an EmbeddedWallet'); + } + + // Check if already deployed + const isDeployed = await wallet.isAccountDeployed(); + if (isDeployed) { + return; + } + + // Register SponsoredFPC contract if needed + const { instance: sponsoredFPCInstance, artifact: SponsoredFPCContractArtifact } = await getSponsoredFPCData(); + const sponsoredFPCMetadata = await wallet.getContractMetadata(sponsoredFPCInstance.address); + if (!sponsoredFPCMetadata.instance) { + await wallet.registerContract(sponsoredFPCInstance, SponsoredFPCContractArtifact); + } + + // Deploy the account + await wallet.deployAccount(sponsoredFPCInstance.address); +} + /** * Parses a drip error into a user-friendly message */ diff --git a/src/tx-progress.ts b/src/tx-progress.ts new file mode 100644 index 0000000..1966e4b --- /dev/null +++ b/src/tx-progress.ts @@ -0,0 +1,108 @@ +/** + * Transaction Progress Tracking + * Event-based system for reporting tx lifecycle phases from the embedded wallet. + * The EmbeddedWallet emits events; the TxNotificationCenter listens and renders toast UI. + * Completed/errored events are persisted to localStorage, scoped by account address. + */ + +export type TxPhase = 'simulating' | 'proving' | 'sending' | 'mining' | 'complete' | 'error'; + +export interface PhaseTiming { + name: string; + duration: number; + color: string; + breakdown?: Array<{ label: string; duration: number }>; +} + +export interface TxProgressEvent { + txId: string; + label: string; + phase: TxPhase; + /** Wall-clock start time (Date.now()) of this tx */ + startTime: number; + /** Per-phase wall-clock durations collected so far */ + phaseTimings: { + simulation?: number; + proving?: number; + sending?: number; + mining?: number; + }; + /** Detailed phase breakdown for the timeline bar */ + phases: PhaseTiming[]; + /** Error message if phase === 'error' */ + error?: string; +} + +type TxProgressListener = (event: TxProgressEvent) => void; + +const STORAGE_PREFIX = 'gregoswap_tx_history_'; +const MAX_STORED = 50; + +class TxProgressEmitter { + private listeners = new Set(); + private accountKey: string | null = null; + + subscribe(listener: TxProgressListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(event: TxProgressEvent) { + for (const listener of this.listeners) { + listener(event); + } + // Persist terminal events + if (event.phase === 'complete' || event.phase === 'error') { + this.persist(event); + } + } + + /** Set the active account to scope persistent storage. Loads existing history. */ + setAccount(address: string) { + this.accountKey = `${STORAGE_PREFIX}${address}`; + } + + /** Load persisted history for the current account. */ + loadHistory(): TxProgressEvent[] { + if (!this.accountKey) return []; + try { + const raw = localStorage.getItem(this.accountKey); + if (!raw) return []; + return JSON.parse(raw) as TxProgressEvent[]; + } catch { + return []; + } + } + + /** Remove a tx from persisted storage. */ + dismissPersisted(txId: string) { + if (!this.accountKey) return; + try { + const history = this.loadHistory().filter(e => e.txId !== txId); + localStorage.setItem(this.accountKey, JSON.stringify(history)); + } catch { + // localStorage unavailable + } + } + + private persist(event: TxProgressEvent) { + if (!this.accountKey) return; + try { + const history = this.loadHistory(); + const idx = history.findIndex(e => e.txId === event.txId); + if (idx >= 0) { + history[idx] = event; + } else { + history.push(event); + } + // Keep only the most recent entries + const trimmed = history.slice(-MAX_STORED); + localStorage.setItem(this.accountKey, JSON.stringify(trimmed)); + } catch { + // localStorage unavailable + } + } +} + +/** Singleton emitter shared between EmbeddedWallet and UI */ +export const txProgress = new TxProgressEmitter(); diff --git a/vite.config.ts b/vite.config.ts index 6f7aaac..88d3770 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -109,7 +109,7 @@ export default defineConfig(({ mode }) => { }, }, optimizeDeps: { - exclude: ['@aztec/noir-acvm_js', '@aztec/noir-noirc_abi'], + exclude: ['@aztec/noir-acvm_js', '@aztec/noir-noirc_abi', '@aztec/bb.js'], }, plugins: [ react({ jsxImportSource: '@emotion/react' }), diff --git a/yarn.lock b/yarn.lock index 39d64bc..6cfd6e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -999,7 +999,7 @@ __metadata: languageName: node linkType: hard -"@aztec/protocol-contracts@npm:4.0.0-devnet.1-patch.0": +"@aztec/protocol-contracts@npm:4.0.0-devnet.1-patch.0, @aztec/protocol-contracts@npm:v4.0.0-devnet.1-patch.0": version: 4.0.0-devnet.1-patch.0 resolution: "@aztec/protocol-contracts@npm:4.0.0-devnet.1-patch.0" dependencies: @@ -6085,6 +6085,7 @@ __metadata: "@aztec/entrypoints": "npm:v4.0.0-devnet.1-patch.0" "@aztec/foundation": "npm:v4.0.0-devnet.1-patch.0" "@aztec/noir-contracts.js": "npm:v4.0.0-devnet.1-patch.0" + "@aztec/protocol-contracts": "npm:v4.0.0-devnet.1-patch.0" "@aztec/pxe": "npm:v4.0.0-devnet.1-patch.0" "@aztec/stdlib": "npm:v4.0.0-devnet.1-patch.0" "@aztec/test-wallet": "npm:v4.0.0-devnet.1-patch.0"