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
);
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"