diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fd6c30fa2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,237 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Unicity AgentSphere is a React-based cryptocurrency wallet application for the Unicity network. It provides a dual-layer wallet interface supporting both Layer 1 (ALPHA blockchain) and Layer 3 (Unicity state transition network) operations. The app integrates with multiple Unicity SDKs for token management, state transitions, and peer-to-peer transfers via Nostr. + +## Development Commands + +```bash +# Start development server +npm run dev + +# Build for production (runs TypeScript compiler then Vite build) +npm run build + +# Lint the codebase +npm run lint + +# Run all tests (watch mode) +npm run test + +# Run tests once (no watch mode) +npm run test:run + +# Run a single test file +npx vitest run tests/unit/components/wallet/L3/services/TokenValidationService.test.ts + +# Preview production build +npm run preview + +# Type check only (without building) +npx tsc --noEmit +``` + +## Architecture + +### Tech Stack +- React 19 + TypeScript with Vite 7 +- TanStack Query v5 for server state management +- Tailwind CSS 4 for styling +- Framer Motion for animations +- React Router DOM v7 for routing +- Vitest + jsdom for testing +- Helia for IPFS/IPNS browser integration + +### Application Structure + +The app uses a single-page architecture with three main routes: +- `/` - Intro/splash screen +- `/home` - Main dashboard with agent cards, chat, and wallet panel +- `/ai` - AI assistant page + +All routes except intro use `DashboardLayout` which provides header, navigation, and handles incoming transfers. + +### Wallet Architecture (Two-Layer System) + +**Layer 1 (L1) - ALPHA Blockchain:** +- Location: `src/components/wallet/L1/` +- Custom HD wallet implementation with BIP32-style derivation (see `SPHERE_DEVELOPER_GUIDE.md` for details) +- Uses Fulcrum WebSocket for blockchain data (Electrum-style protocol) +- Supports vesting classification (coins from blocks ≤280,000 are "vested") +- SDK in `src/components/wallet/L1/sdk/` handles crypto, transactions, network calls + +**Layer 3 (L3) - Unicity Network:** +- Location: `src/components/wallet/L3/` +- Uses `@unicitylabs/state-transition-sdk` for token operations +- Nostr integration for P2P messaging and token transfers +- Nametag system for human-readable addresses +- IPFS/IPNS for decentralized token storage and sync +- ServiceProvider singleton manages SDK clients + +### Key Patterns + +**State Management:** +- TanStack Query manages all async state (wallet, balance, transactions) +- Custom events (`wallet-updated`) trigger cross-component refreshes +- localStorage persists wallet data; IndexedDB for vesting cache + +**Query Key Structure:** +- L1: `["l1", "wallet"]`, `["l1", "balance", address]`, `["l1", "vesting", address]` +- L3: `["wallet", "identity"]`, `["wallet", "tokens"]`, `["wallet", "aggregated"]` + +**Services Pattern (L3):** +- `ServiceProvider` - singleton for SDK clients (aggregator, state transition) +- `IdentityManager` - handles wallet identity and key management +- `NostrService` - P2P messaging and token transfer via Nostr protocol +- `NametagService` - human-readable address resolution (@username lookup) +- `IpfsStorageService` - IPFS/IPNS storage with Helia, supports bidirectional sync +- `SyncCoordinator` - tab coordination for IPFS sync with tombstone support +- `TokenValidationService` - validates tokens against aggregator +- `ConflictResolutionService` - handles token conflicts during sync +- `FaucetService` - obtains test tokens from faucet +- `NostrPinPublisher` - broadcasts token pins for discovery +- `TxfSerializer` - serializes token transfer files (.txf format) +- `IpnsNametagFetcher` - resolves nametags via IPNS during wallet import + +**Shared Services:** +- `UnifiedKeyManager` - cross-layer key management (L1/L3 key derivation) + +### SDK Layer (L1) + +The `src/components/wallet/L1/sdk/` directory contains: +- `wallet.ts` - wallet creation/management +- `address.ts` - HD key derivation and address generation +- `network.ts` - Fulcrum WebSocket connection and RPC calls +- `tx.ts` - transaction creation and signing +- `vesting.ts` - coinbase tracing for vesting classification +- `vestingState.ts` - vesting mode state management + +### Important Types + +```typescript +// L1 Wallet (sdk/types.ts) +interface Wallet { + masterPrivateKey: string; + chainCode?: string; + addresses: WalletAddress[]; + isBIP32?: boolean; +} + +// L3 Token (L3/data/model) +class Token { + id: string; + symbol: string; + amount: string; + jsonData: string; // Serialized SDK token + status: TokenStatus; +} +``` + +### Vite Configuration + +- Base path: configurable via `BASE_PATH` env var (default `/`) +- Node polyfills enabled for crypto libraries +- Proxy `/rpc` to `https://goggregator-test.unicity.network` for L3 aggregator +- Optional HTTPS support via `SSL_CERT_PATH` env var +- Remote HMR support via `HMR_HOST` env var + +### Component Hierarchy + +``` +App +└── DashboardLayout + ├── Header + ├── Navigation + └── HomePage + ├── AgentCard[] (chat agents) + ├── ChatSection / SimpleAIChat / etc. + └── WalletPanel + ├── L1WalletView (when Layer 1 selected) + └── L3WalletView (when Layer 3 selected) +``` + +## Environment Variables + +Copy `.env.example` to `.env` and configure: + +```env +VITE_AGENT_API_URL=http://localhost:3000 # Agentic chatbot backend +VITE_USE_MOCK_AGENTS=true # Use mock agents (for local dev without backend) +VITE_AGGREGATOR_URL=/rpc # Unicity aggregator (proxied in dev) +VITE_ENABLE_IPFS=true # Enable IPFS storage for wallet backup + +# Optional: HTTPS for dev server (e.g., for WebCrypto APIs) +SSL_CERT_PATH=/path/to/certs # Path to SSL certificate directory +HMR_HOST=your-dev-server.example.com # Custom HMR host for remote dev +BASE_PATH=/ # Base path for deployment (default: /) +``` + +## Testing + +Tests are located in `tests/` directory and run with Vitest: +- Test files: `tests/**/*.test.ts`, `tests/**/*.test.tsx` +- Environment: jsdom +- Path alias: `@` maps to `/src` +- Globals enabled: `describe`, `it`, `expect`, `vi` are available without imports + +## Developer Notes + +### Crypto Libraries +The project uses node polyfills (`vite-plugin-node-polyfills`) for browser compatibility with crypto libraries like `elliptic`, `bip39`, and `crypto-js`. The `/rpc` endpoint is proxied to the Unicity aggregator in development. + +### BIP32 Implementation +The L1 wallet uses a custom derivation that differs from standard BIP32 (see `SPHERE_DEVELOPER_GUIDE.md` for migration details). Standard path would be `m/44'/0'/0'/0/{index}`. + +### Vesting System +ALPHA coins are classified as "vested" or "unvested" based on coinbase block height (threshold: 280,000). The classifier traces each UTXO back to its coinbase origin and caches results in IndexedDB. + +### Token Transfer Flow (L3) +1. Calculate optimal token split via `TokenSplitCalculator` +2. Create transfer commitment with SDK +3. Submit to aggregator and wait for inclusion proof +4. Send token + proof to recipient via Nostr +5. Broadcast pin to Nostr for discovery +6. Update local storage and IPFS, trigger query refresh + +### IPFS Storage (L3) +Tokens are synced to IPFS with IPNS for consistent addressing: +- Dual publishing: HTTP API to backend + browser DHT +- Bidirectional sync with conflict resolution +- Tombstones track deleted tokens across devices +- Tab coordination prevents concurrent writes + +**Unicity IPFS Bootstrap Peers:** +| Host | Peer ID | +|------|---------| +| unicity-ipfs2.dyndns.org | 12D3KooWLNi5NDPPHbrfJakAQqwBqymYTTwMQXQKEWuCrJNDdmfh | +| unicity-ipfs3.dyndns.org | 12D3KooWQ4aujVE4ShLjdusNZBdffq3TbzrwT2DuWZY9H1Gxhwn6 | +| unicity-ipfs4.dyndns.org | 12D3KooWJ1ByPfUzUrpYvgxKU8NZrR8i6PU1tUgMEbQX9Hh2DEn1 | +| unicity-ipfs5.dyndns.org | 12D3KooWB1MdZZGHN5B8TvWXntbycfe7Cjcz7n6eZ9eykZadvmDv | + +### Embedded Wallet (guiwallet-main) +A standalone single-file HTML wallet exists at `src/components/wallet/L1/guiwallet-main/`. This is a separate 888KB self-contained wallet application, not integrated into the React app. + +### localStorage Keys +Key persistence patterns: +- `unified_wallet_*` - Encrypted wallet credentials (mnemonic, master key, chain code) +- `unicity_wallet_{address}` - Per-address wallet data +- `unicity_transaction_history` - L1 transaction history +- `unicity_chat_*` - Chat conversations and messages +- `wallet-active-layer` - Currently selected layer (L1/L3) +- `sphere-theme` - UI theme preference +- `l3_selected_address_path` - Selected address BIP32 path for L3 identity (e.g., "m/84'/1'/0'/0/0"); determines which derived key is used for IPFS/IPNS publishing and token ownership + +### Custom Events +The app uses custom events for cross-component communication: +- `wallet-updated` - Triggers TanStack Query refetch for wallet data +- Dispatch via `window.dispatchEvent(new Event('wallet-updated'))` + +### Key External Dependencies +- `@unicitylabs/state-transition-sdk` (v1.6.0) - L3 token operations and state transitions +- `@unicitylabs/nostr-js-sdk` - P2P messaging and token transfers +- `helia` / `@helia/ipns` / `@helia/json` - Browser-based IPFS/IPNS for decentralized storage +- `elliptic` - secp256k1 cryptography for L1 wallet +- `bip39` - Seed phrase generation and validation diff --git a/src/components/auth/WalletGate.tsx b/src/components/auth/WalletGate.tsx index 1f640a0f6..ab5e0d5bf 100644 --- a/src/components/auth/WalletGate.tsx +++ b/src/components/auth/WalletGate.tsx @@ -1,8 +1,10 @@ -import type { ReactNode } from "react"; +import { type ReactNode, useEffect } from "react"; import { motion } from "framer-motion"; import { Loader2 } from "lucide-react"; import { useWallet } from "../wallet/L3/hooks/useWallet"; import { CreateWalletFlow } from "../wallet/L3/onboarding/CreateWalletFlow"; +import { NostrPinPublisher } from "../wallet/L3/services/NostrPinPublisher"; +import { NOSTR_PIN_CONFIG } from "../../config/nostrPin.config"; interface WalletGateProps { children: ReactNode; @@ -83,6 +85,21 @@ export function WalletGate({ children }: WalletGateProps) { const isLoading = isLoadingIdentity || (!!identity && isLoadingNametag); const isAuthenticated = !!identity && !!nametag; + // Start NostrPinPublisher when authenticated + // This enables automatic CID announcements to Nostr for pinning + useEffect(() => { + if (isAuthenticated && NOSTR_PIN_CONFIG.enabled) { + const publisher = NostrPinPublisher.getInstance(); + publisher.start().catch((err) => { + console.error("Failed to start NostrPinPublisher:", err); + }); + + return () => { + publisher.stop(); + }; + } + }, [isAuthenticated]); + if (isLoading) { return ; } diff --git a/src/components/splash/WelcomeModal.tsx b/src/components/splash/WelcomeModal.tsx new file mode 100644 index 000000000..ca92e5d07 --- /dev/null +++ b/src/components/splash/WelcomeModal.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Shield, Rocket, MessageCircle, Coins, AlertTriangle, ChevronRight } from 'lucide-react'; + +interface WelcomeModalProps { + show: boolean; + onAccept: () => void; +} + +export function WelcomeModal({ show, onAccept }: WelcomeModalProps) { + const [ageConfirmed, setAgeConfirmed] = useState(false); + const [termsAccepted, setTermsAccepted] = useState(false); + + const canProceed = ageConfirmed && termsAccepted; + + const features = [ + { + icon: Coins, + title: 'Buy & Trade', + description: 'Access decentralized markets', + }, + { + icon: Rocket, + title: 'Bet & Win', + description: 'Prediction markets & games', + }, + { + icon: MessageCircle, + title: 'Chat & Connect', + description: 'Private, encrypted messaging', + }, + ]; + + return ( + + {show && ( + + + {/* Glow effect */} +
+ + {/* Content */} +
+ {/* Header */} + +
+ +
+

+ Welcome Aboard +

+

+ Infrastructure for a free Internet +

+
+ + {/* Manifesto */} + +

+ "Privacy isn't a feature. It's the foundation of freedom." +

+
+ + {/* Features */} + + {features.map((feature, index) => ( + + + {feature.title} + {feature.description} + + ))} + + + {/* Disclaimer */} + +
+ +

+ This platform involves financial transactions and prediction markets. + You are solely responsible for your actions. +

+
+ + {/* Age verification */} + + + {/* Terms acceptance */} + +
+ + {/* Enter button */} + + + Enter the Sphere + + + +
+ + + )} + + ); +} diff --git a/src/components/wallet/L1/components/modals/BridgeModal.tsx b/src/components/wallet/L1/components/modals/BridgeModal.tsx index afa56f30d..00d2842af 100644 --- a/src/components/wallet/L1/components/modals/BridgeModal.tsx +++ b/src/components/wallet/L1/components/modals/BridgeModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowRight, @@ -142,19 +142,7 @@ export function BridgeModal({ const [balanceInfo, setBalanceInfo] = useState(null); const [error, setError] = useState(null); // Check balance when modal opens - useEffect(() => { - if (show && address) { - checkBalance(); - } else { - // Reset state when modal closes - setStatus("idle"); - setBalanceInfo(null); - setError(null); - setUnicityId(""); - } - }, [show, address]); - - const checkBalance = async () => { + const checkBalance = useCallback(async () => { setStatus("checking"); setError(null); @@ -187,7 +175,19 @@ export function BridgeModal({ (err instanceof Error ? err.message : String(err)) ); } - }; + }, [address]); + + useEffect(() => { + if (show && address) { + checkBalance(); + } else { + // Reset state when modal closes + setStatus("idle"); + setBalanceInfo(null); + setError(null); + setUnicityId(""); + } + }, [show, address, checkBalance]); const handleBridge = async () => { if (!unicityId.trim()) { diff --git a/src/components/wallet/L1/components/modals/ImportWalletModal.tsx b/src/components/wallet/L1/components/modals/ImportWalletModal.tsx index de9ced2b5..7811b9284 100644 --- a/src/components/wallet/L1/components/modals/ImportWalletModal.tsx +++ b/src/components/wallet/L1/components/modals/ImportWalletModal.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useCallback } from "react"; import { motion } from "framer-motion"; -import { Upload, FileText, X } from "lucide-react"; +import { Upload, FileText, FileJson, X } from "lucide-react"; +import { isJSONWalletFormat } from "../../sdk/import-export"; interface ImportWalletModalProps { show: boolean; @@ -30,7 +31,7 @@ export function ImportWalletModal({ show, onImport, onCancel }: ImportWalletModa setIsDragging(false); const file = e.dataTransfer.files[0]; - if (file && (file.name.endsWith(".txt") || file.name.endsWith(".dat"))) { + if (file && (file.name.endsWith(".txt") || file.name.endsWith(".dat") || file.name.endsWith(".json"))) { setSelectedFile(file); await checkIfNeedsScanning(file); } @@ -45,8 +46,25 @@ export function ImportWalletModal({ show, onImport, onCancel }: ImportWalletModa return; } - // For .txt files, check if BIP32 or standard const content = await file.text(); + + // JSON wallet files - check format and derivation mode + if (file.name.endsWith(".json") || isJSONWalletFormat(content)) { + try { + const json = JSON.parse(content); + // JSON files with mnemonic don't need scanning - restore directly from seed + // JSON files with BIP32 but no mnemonic need scanning + const hasMnemonic = !!json.mnemonic || !!json.encrypted?.mnemonic; + const isBIP32 = json.derivationMode === "bip32" || json.chainCode; + setNeedsScanning(!hasMnemonic && isBIP32); + setScanCount(10); + } catch { + setNeedsScanning(true); + } + return; + } + + // For .txt files, check if BIP32 or standard const isBIP32 = content.includes("MASTER CHAIN CODE") || content.includes("WALLET TYPE: BIP32") || content.includes("WALLET TYPE: Alpha descriptor"); @@ -128,7 +146,7 @@ export function ImportWalletModal({ show, onImport, onCancel }: ImportWalletModa Select wallet file

- .txt or .dat + .json, .txt or .dat

or drag & drop here @@ -139,7 +157,7 @@ export function ImportWalletModal({ show, onImport, onCancel }: ImportWalletModa type="file" ref={fileInputRef} className="hidden" - accept=".txt,.dat" + accept=".json,.txt,.dat" onChange={handleFileSelect} /> @@ -148,7 +166,11 @@ export function ImportWalletModal({ show, onImport, onCancel }: ImportWalletModa {/* Selected file */}

- + {selectedFile.name.endsWith(".json") ? ( + + ) : ( + + )}

{selectedFile.name} diff --git a/src/components/wallet/L1/components/modals/SaveWalletModal.tsx b/src/components/wallet/L1/components/modals/SaveWalletModal.tsx index a2db6be79..192e0a926 100644 --- a/src/components/wallet/L1/components/modals/SaveWalletModal.tsx +++ b/src/components/wallet/L1/components/modals/SaveWalletModal.tsx @@ -1,14 +1,16 @@ import { useState } from "react"; import { motion } from "framer-motion"; -import { Shield, AlertCircle } from "lucide-react"; +import { Shield, AlertCircle, FileJson } from "lucide-react"; interface SaveWalletModalProps { show: boolean; onConfirm: (filename: string, password?: string) => void; onCancel: () => void; + /** Whether mnemonic is available (shows indicator) */ + hasMnemonic?: boolean; } -export function SaveWalletModal({ show, onConfirm, onCancel }: SaveWalletModalProps) { +export function SaveWalletModal({ show, onConfirm, onCancel, hasMnemonic }: SaveWalletModalProps) { const [filename, setFilename] = useState("alpha_wallet_backup"); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); @@ -75,10 +77,25 @@ export function SaveWalletModal({ show, onConfirm, onCancel }: SaveWalletModalPr

Backup Wallet

- Export your wallet keys to a file. Keep this safe! + Export your wallet keys to a JSON file. Keep this safe!

+ {/* Format indicator */} +
+ + JSON Format + {hasMnemonic && ( + + +mnemonic + + )} +
+ +

+ Includes verification address{hasMnemonic ? " and recovery phrase" : ""} +

+ diff --git a/src/components/wallet/L1/components/modals/WalletScanModal.tsx b/src/components/wallet/L1/components/modals/WalletScanModal.tsx index 4ec7ffd71..08d5f0ec3 100644 --- a/src/components/wallet/L1/components/modals/WalletScanModal.tsx +++ b/src/components/wallet/L1/components/modals/WalletScanModal.tsx @@ -18,6 +18,7 @@ export function WalletScanModal({ show, wallet, initialScanCount = 100, onSelect const [foundAddresses, setFoundAddresses] = useState([]); const [selectedAddresses, setSelectedAddresses] = useState>(new Set()); const [scanCount, setScanCount] = useState(initialScanCount); + const [l1Complete, setL1Complete] = useState(false); // Latches true once L1 scan finishes const stopScanRef = useRef(false); // Reset state when modal opens @@ -27,6 +28,7 @@ export function WalletScanModal({ show, wallet, initialScanCount = 100, onSelect setFoundAddresses([]); setSelectedAddresses(new Set()); setProgress({ current: 0, total: initialScanCount, found: 0, totalBalance: 0, foundAddresses: [] }); + setL1Complete(false); stopScanRef.current = false; // Auto-start scanning with initial count startScan(initialScanCount); @@ -53,6 +55,10 @@ export function WalletScanModal({ show, wallet, initialScanCount = 100, onSelect scanLimit, (p) => { setProgress(p); + // Latch l1Complete once L1 scan finishes (prevents IPNS callbacks from resetting it) + if (p.l1ScanComplete) { + setL1Complete(true); + } // Update found addresses in real-time from progress callback if (p.foundAddresses && p.foundAddresses.length > 0) { setFoundAddresses(p.foundAddresses); @@ -75,6 +81,7 @@ export function WalletScanModal({ show, wallet, initialScanCount = 100, onSelect const stopScan = () => { stopScanRef.current = true; + setIsScanning(false); // Immediate UI feedback }; const handleCancel = () => { @@ -137,7 +144,11 @@ export function WalletScanModal({ show, wallet, initialScanCount = 100, onSelect

Scanning Wallet

- {isScanning ? "Searching for addresses with balances..." : "Click addresses to select/deselect"} + {isScanning && !l1Complete + ? "Searching for addresses with balances..." + : isScanning + ? "Resolving Unicity IDs..." + : "Click addresses to select/deselect"}

@@ -210,6 +221,11 @@ export function WalletScanModal({ show, wallet, initialScanCount = 100, onSelect CHANGE )} + {addr.l3Nametag && ( + + {addr.l3Nametag} + + )}
{addr.address} @@ -263,7 +279,7 @@ export function WalletScanModal({ show, wallet, initialScanCount = 100, onSelect > Cancel - {isScanning ? ( + {isScanning && !l1Complete ? ( @@ -230,32 +309,63 @@ export function MainWalletView({ transition={{ duration: 0.2 }} className="absolute z-20 mt-2 w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl max-h-52 overflow-y-auto custom-scrollbar" > - {addresses.map((a) => ( -
{ - onSelectAddress(a); - setShowDropdown(false); - }} - > - - {a} - - e.stopPropagation()} + {sortedAddresses.map((a) => { + const nametagInfo = nametagState[a]; + const walletAddrInfo = walletAddresses?.find(wa => wa.address === a); + const isChangeAddr = walletAddrInfo?.isChange; + return ( + - ))} + + {!nametagInfo || nametagInfo.ipnsLoading ? ( + + + {a.slice(0, 8)}...{a.slice(-6)} + + ) : nametagInfo.nametag ? ( + + @{nametagInfo.nametag} + + {a.slice(0, 6)}...{a.slice(-4)} + + + ) : nametagInfo.hasL3Inventory ? ( + + {a} + + L3 + + + ) : ( + {a} + )} + + {isChangeAddr && ( + + Change + + )} + e.stopPropagation()} + > + + +
+ ); + })} )} @@ -407,6 +517,7 @@ export function MainWalletView({ show={showSaveModal} onConfirm={handleSave} onCancel={() => setShowSaveModal(false)} + hasMnemonic={hasMnemonic} /> (null); const [isServiceReady, setIsServiceReady] = useState(false); + const [isSyncingRealtime, setIsSyncingRealtime] = useState(false); - const isEnabled = import.meta.env.VITE_ENABLE_IPFS === 'true'; + // IPFS is always enabled since useWallet starts the service unconditionally + // The env var VITE_ENABLE_IPFS is checked elsewhere but useWallet starts IPFS regardless + const isEnabled = true; - // Get storage service instance (only if enabled) - const storageService = isEnabled ? IpfsStorageService.getInstance(identityManager) : null; + // Get storage service instance - useWallet will have started this + const storageService = IpfsStorageService.getInstance(identityManager); // Listen for storage events useEffect(() => { + console.log(`🔄 useIpfsStorage: setting up event listener`); const handleEvent = (e: CustomEvent) => { setLastEvent(e.detail); + // Handle real-time sync state changes for immediate UI updates + if (e.detail.type === "sync:state-changed" && e.detail.data?.isSyncing !== undefined) { + console.log(`🔄 useIpfsStorage: received sync:state-changed, isSyncing=${e.detail.data.isSyncing}`); + setIsSyncingRealtime(e.detail.data.isSyncing); + } + // Invalidate status query on storage completion if ( e.detail.type === "storage:completed" || @@ -67,6 +77,11 @@ export function useIpfsStorage() { storageService.startAutoSync(); setIsServiceReady(true); + // Initialize sync state from service (in case sync already started before hook mounted) + const currentSyncState = storageService.isCurrentlySyncing(); + console.log(`🔄 useIpfsStorage: initializing sync state to ${currentSyncState}`); + setIsSyncingRealtime(currentSyncState); + return () => { // Note: Don't shutdown on unmount as service is singleton }; @@ -224,7 +239,7 @@ export function useIpfsStorage() { // Sync operations sync: syncMutation.mutateAsync, - isSyncing: syncMutation.isPending || (storageService?.isCurrentlySyncing() ?? false), + isSyncing: syncMutation.isPending || isSyncingRealtime || (storageService?.isCurrentlySyncing() ?? false), syncError: syncMutation.error, // Restore operations diff --git a/src/components/wallet/L3/hooks/useWallet.ts b/src/components/wallet/L3/hooks/useWallet.ts index 0c378bbee..c05ce64ee 100644 --- a/src/components/wallet/L3/hooks/useWallet.ts +++ b/src/components/wallet/L3/hooks/useWallet.ts @@ -19,6 +19,7 @@ import { TokenId } from "@unicitylabs/state-transition-sdk/lib/token/TokenId"; import { IpfsStorageService } from "../services/IpfsStorageService"; import { useServices } from "../../../../contexts/useServices"; import type { NostrService } from "../services/NostrService"; +import { OutboxRecoveryService } from "../services/OutboxRecoveryService"; export const KEYS = { IDENTITY: ["wallet", "identity"], @@ -85,9 +86,57 @@ export const useWallet = () => { enabled: !!identityQuery.data?.address, }); - const checkNametagAvailability = async(nametag: string): Promise => { - return await nametagService.isNametagAvailable(nametag); - } + // Initialize IPFS storage service ONLY when fully authenticated + // This prevents race condition where old wallet data is synced while user is on onboarding screen + useEffect(() => { + const identity = identityQuery.data; + const nametag = nametagQuery.data; + + // Only start auto-sync when user is fully authenticated (has both identity AND nametag) + if (identity && nametag) { + const storageService = IpfsStorageService.getInstance(identityManager); + storageService.startAutoSync(); + } + }, [identityQuery.data, nametagQuery.data, identityManager]); + + // Recover any incomplete transfers from outbox on startup and enable periodic retry + // This runs when identity is loaded and services are available + useEffect(() => { + const identity = identityQuery.data; + if (!identity?.address || !nostrService) return; + + const recoveryService = OutboxRecoveryService.getInstance(); + recoveryService.setIdentityManager(identityManager); + + // Run initial recovery + const pendingCount = recoveryService.getPendingCount(identity.address); + if (pendingCount > 0) { + console.log(`📤 useWallet: Found ${pendingCount} pending outbox entries, starting recovery...`); + } + + recoveryService.recoverPendingTransfers(identity.address, nostrService) + .then((result) => { + if (result.recovered > 0 || result.failed > 0) { + console.log(`📤 useWallet: Initial recovery - ${result.recovered} recovered, ${result.failed} failed`); + // Refresh tokens after recovery + queryClient.invalidateQueries({ queryKey: KEYS.TOKENS }); + queryClient.invalidateQueries({ queryKey: KEYS.AGGREGATED }); + } + + // Start periodic retry after initial recovery + recoveryService.startPeriodicRetry(identity.address, nostrService); + }) + .catch((error) => { + console.error("📤 useWallet: Initial recovery failed:", error); + // Still start periodic retry even if initial recovery failed + recoveryService.startPeriodicRetry(identity.address, nostrService); + }); + + // Cleanup on unmount or identity change + return () => { + recoveryService.stopPeriodicRetry(); + }; + }, [identityQuery.data?.address, nostrService, identityManager, queryClient]); // Ensure registry is loaded before aggregating assets const registryQuery = useQuery({ @@ -405,13 +454,33 @@ export const useWallet = () => { // 4. EXECUTE SPLIT if (plan.requiresSplit) { console.log("✂️ Executing split..."); + + // Import outbox repository for tracking + const { OutboxRepository } = await import("../../../../repositories/OutboxRepository"); + const outboxRepo = OutboxRepository.getInstance(); + + // Get wallet address for outbox context + const wallet = walletRepo.getWallet(); + const walletAddress = wallet?.address || ""; + if (walletAddress) { + outboxRepo.setCurrentAddress(walletAddress); + } + + // Create outbox context for tracking + const outboxContext = walletAddress ? { + walletAddress, + recipientNametag, + recipientPubkey, + } : undefined; + const executor = new TokenSplitExecutor(); const splitResult = await executor.executeSplitPlan( plan, recipientAddress, signingService, - (burnedId) => walletRepo.removeToken(burnedId, undefined, true) // Skip history for split + (burnedId) => walletRepo.removeToken(burnedId, undefined, true), // Skip history for split + outboxContext ); // Add transaction history for the actual sent amount @@ -430,6 +499,7 @@ export const useWallet = () => { for (let i = 0; i < splitResult.tokensForRecipient.length; i++) { const token = splitResult.tokensForRecipient[i]; const tx = splitResult.recipientTransferTxs[i]; + const outboxEntryId = splitResult.outboxEntryIds[i]; const sourceTokenString = JSON.stringify(token.toJSON()); const transferTxString = JSON.stringify(tx.toJSON()); @@ -441,11 +511,38 @@ export const useWallet = () => { console.log("📨 Sending split token via Nostr..."); await nostrService.sendTokenTransfer(recipientPubkey, payload, undefined, undefined, params.eventId); + + // Update outbox: Nostr sent + if (outboxEntryId) { + outboxRepo.updateStatus(outboxEntryId, "NOSTR_SENT"); + console.log(`📤 Outbox: Split transfer ${outboxEntryId.slice(0, 8)}... sent via Nostr`); + } } for (const keptToken of splitResult.tokensKeptBySender) { saveChangeTokenToWallet(keptToken, params.coinId); } + + // Mark all outbox entries as completed + for (const outboxEntryId of splitResult.outboxEntryIds) { + if (outboxEntryId) { + outboxRepo.updateStatus(outboxEntryId, "COMPLETED"); + console.log(`📤 Outbox: Split transfer ${outboxEntryId.slice(0, 8)}... completed`); + } + } + + // Clean up split group + if (splitResult.splitGroupId) { + outboxRepo.removeSplitGroup(splitResult.splitGroupId); + } + + // Final IPFS sync after split completion + if (walletAddress) { + const ipfsService = IpfsStorageService.getInstance(identityManager); + await ipfsService.syncNow().catch(err => { + console.warn("⚠️ Final IPFS sync after split failed:", err); + }); + } } return true; @@ -467,6 +564,17 @@ export const useWallet = () => { nostr: NostrService, recipientNametag: string ) => { + const { OutboxRepository } = await import("../../../../repositories/OutboxRepository"); + const { createOutboxEntry } = await import("../services/types/OutboxTypes"); + const outboxRepo = OutboxRepository.getInstance(); + + // Get wallet address for outbox repository + const wallet = walletRepo.getWallet(); + if (wallet?.address) { + outboxRepo.setCurrentAddress(wallet.address); + } + + // 1. Generate salt and create commitment const salt = Buffer.alloc(32); window.crypto.getRandomValues(salt); @@ -479,11 +587,80 @@ export const useWallet = () => { signingService ); + // 2. Extract amount and coinId from source token + let amount = "0"; + let coinId = ""; + const coinsOpt = sourceToken.coins; + if (coinsOpt) { + const rawCoins = coinsOpt.coins; + const firstItem = rawCoins[0]; + if (Array.isArray(firstItem) && firstItem.length === 2) { + coinId = firstItem[0]?.toString() || ""; + const val = firstItem[1]; + if (Array.isArray(val)) { + amount = val[1]?.toString() || "0"; + } else if (val) { + amount = val.toString(); + } + } + } + + // 3. Create outbox entry BEFORE any network calls (CRITICAL) + const outboxEntry = createOutboxEntry( + "DIRECT_TRANSFER", + uiId, + recipientNametag, + recipientPubkey, + JSON.stringify(recipientAddress.toJSON ? recipientAddress.toJSON() : recipientAddress), + amount, + coinId, + Buffer.from(salt).toString("hex"), + JSON.stringify(sourceToken.toJSON()), + JSON.stringify(transferCommitment.toJSON()) + ); + + // 4. Save to localStorage + outboxRepo.addEntry(outboxEntry); + console.log(`📤 Outbox: Created entry ${outboxEntry.id.slice(0, 8)}... for direct transfer`); + + // 5. CRITICAL: Sync to IPFS and WAIT for success + try { + const ipfsService = IpfsStorageService.getInstance(identityManager); + const syncResult = await ipfsService.syncNow(); + + if (!syncResult.success) { + // Remove outbox entry since we didn't start the transfer + outboxRepo.removeEntry(outboxEntry.id); + throw new Error(`Failed to sync outbox to IPFS - aborting transfer: ${syncResult.error}`); + } + + console.log(`📤 Outbox entry synced to IPFS: ${syncResult.cid?.slice(0, 12)}...`); + } catch (err) { + // Remove outbox entry if IPFS sync failed + outboxRepo.removeEntry(outboxEntry.id); + throw err; + } + + // 6. Update status: ready to submit + outboxRepo.updateEntry(outboxEntry.id, { status: "READY_TO_SUBMIT" }); + + // 7. NOW safe to submit to aggregator (idempotent - REQUEST_ID_EXISTS is OK) const client = ServiceProvider.stateTransitionClient; const res = await client.submitTransferCommitment(transferCommitment); - if (res.status !== "SUCCESS") + + if (res.status !== "SUCCESS" && res.status !== "REQUEST_ID_EXISTS") { + outboxRepo.updateEntry(outboxEntry.id, { + status: "FAILED", + lastError: `Aggregator error: ${res.status}`, + }); throw new Error(`Direct transfer failed: ${res.status}`); + } + + // 8. Update status: submitted + outboxRepo.updateEntry(outboxEntry.id, { status: "SUBMITTED" }); + console.log(`📤 Transfer submitted to aggregator (status: ${res.status})`); + // 9. Wait for inclusion proof const proof = await waitInclusionProof( ServiceProvider.getRootTrustBase(), client, @@ -492,6 +669,15 @@ export const useWallet = () => { const tx = transferCommitment.toTransaction(proof); + // 10. Update status: proof received + outboxRepo.updateEntry(outboxEntry.id, { + status: "PROOF_RECEIVED", + inclusionProofJson: JSON.stringify(proof.toJSON()), + transferTxJson: JSON.stringify(tx.toJSON()), + }); + console.log(`📤 Inclusion proof received`); + + // 11. Send via Nostr const sourceTokenString = JSON.stringify(sourceToken.toJSON()); const transferTxString = JSON.stringify(tx.toJSON()); @@ -502,7 +688,25 @@ export const useWallet = () => { await nostr.sendTokenTransfer(recipientPubkey, payload); + // 12. Update status: Nostr sent + outboxRepo.updateEntry(outboxEntry.id, { status: "NOSTR_SENT" }); + console.log(`📤 Token sent via Nostr to ${recipientNametag}`); + + // 13. Archive and remove token from active wallet walletRepo.removeToken(uiId, recipientNametag); + + // 14. Mark outbox entry as completed + outboxRepo.updateEntry(outboxEntry.id, { status: "COMPLETED" }); + console.log(`📤 Direct transfer completed - outbox entry ${outboxEntry.id.slice(0, 8)}... marked complete`); + + // 15. Final IPFS sync to update outbox status + try { + const ipfsService = IpfsStorageService.getInstance(identityManager); + await ipfsService.syncNow(); + } catch (err) { + console.warn(`📤 Final IPFS sync after transfer failed:`, err); + // Non-critical - token is transferred, outbox will be cleaned up later + } }; const saveChangeTokenToWallet = (sdkToken: SdkToken, coinId: string) => { @@ -527,12 +731,42 @@ export const useWallet = () => { const def = registryService.getCoinDefinition(coinId); const iconUrl = def ? registryService.getIconUrl(def) : undefined; + // Get SDK token JSON and validate TXF structure + const sdkJson = sdkToken.toJSON() as any; + + // Ensure the JSON has required TXF structure (genesis, state) + // This is critical for IPFS sync to work properly + if (!sdkJson.genesis || !sdkJson.state) { + console.error(`❌ Change token missing required TXF fields!`, { + hasGenesis: !!sdkJson.genesis, + hasState: !!sdkJson.state, + keys: Object.keys(sdkJson), + }); + // Still try to save - maybe the fields are named differently + } else { + console.log(`✅ Change token has valid TXF structure`); + } + + // Ensure TXF compatibility fields exist + const txfJson = { + ...sdkJson, + version: sdkJson.version || "2.0", + transactions: sdkJson.transactions || [], + nametags: sdkJson.nametags || [], + _integrity: sdkJson._integrity || { + genesisDataJSONHash: "0000" + "0".repeat(60), + }, + }; + + // Extract token ID for logging + const genesisTokenId = sdkJson.genesis?.data?.tokenId; + const uiToken = new Token({ id: uuidv4(), name: def?.symbol || "Change Token", symbol: def?.symbol || "UNK", type: "Fungible", - jsonData: JSON.stringify(sdkToken.toJSON()), + jsonData: JSON.stringify(txfJson), status: TokenStatus.CONFIRMED, amount: amount, coinId: coinId, @@ -540,7 +774,7 @@ export const useWallet = () => { timestamp: Date.now(), }); - console.log(`💾 Saving change token: ${amount} ${def?.symbol}`); + console.log(`💾 Saving change token: ${amount} ${def?.symbol}, tokenId: ${genesisTokenId?.slice(0, 8) || 'unknown'}...`); walletRepo.addToken(uiToken, true); // Skip history for change token }; @@ -560,6 +794,10 @@ export const useWallet = () => { return identityManager.getUnifiedKeyManager(); }; + const checkNametagAvailability = async (nametag: string): Promise => { + return await nametagService.isNametagAvailable(nametag); + }; + return { identity: identityQuery.data, isLoadingIdentity: identityQuery.isLoading, diff --git a/src/components/wallet/L3/modals/SeedPhraseModal.tsx b/src/components/wallet/L3/modals/SeedPhraseModal.tsx index e728d178d..a5575ca4f 100644 --- a/src/components/wallet/L3/modals/SeedPhraseModal.tsx +++ b/src/components/wallet/L3/modals/SeedPhraseModal.tsx @@ -95,7 +95,7 @@ export function SeedPhraseModal({ isOpen, onClose, seedPhrase }: SeedPhraseModal
{seedPhrase.map((word, index) => ( ('start'); const [nametagInput, setNametagInput] = useState(''); @@ -42,9 +61,14 @@ export function CreateWalletFlow() { // Address selection state const [derivedAddresses, setDerivedAddresses] = useState([]); - const [selectedAddressIndex, setSelectedAddressIndex] = useState(0); + const [selectedAddressPath, setSelectedAddressPath] = useState(null); const [showAddressDropdown, setShowAddressDropdown] = useState(false); + // Helper to get selected address by path + // Show addresses that are: checked (IPNS done) OR from L1 wallet (e.g., .dat import) + const visibleAddresses = derivedAddresses.filter(a => !a.ipnsLoading || a.fromL1Wallet); + const selectedAddress = visibleAddresses.find((a) => a.path === selectedAddressPath) || visibleAddresses[0]; + // Wallet import and scanning state (for .dat and BIP32 .txt files) const [showScanModal, setShowScanModal] = useState(false); const [showLoadPasswordModal, setShowLoadPasswordModal] = useState(false); @@ -58,6 +82,12 @@ export function CreateWalletFlow() { const [needsScanning, setNeedsScanning] = useState(true); const [isDragging, setIsDragging] = useState(false); + // State for IPNS nametag fetching on Complete Setup screen + const [ipnsFetchingNametag, setIpnsFetchingNametag] = useState(false); + + // State for processing status message + const [processingStatus, setProcessingStatus] = useState(''); + // Connect to L1 WebSocket on mount (needed for wallet scanning) useEffect(() => { if (!isWebSocketConnected()) { @@ -67,6 +97,162 @@ export function CreateWalletFlow() { } }, []); + // Effect: Fetch nametag from IPNS when identity exists but nametag doesn't + // This allows auto-proceeding if the nametag was published from another device + useEffect(() => { + // Only run on 'start' step when identity exists but no nametag + if (step !== 'start' || !identity || nametag || ipnsFetchingNametag) return; + + const fetchNametag = async () => { + setIpnsFetchingNametag(true); + console.log('🔍 [Complete Setup] Checking IPNS for existing nametag...'); + + try { + const result = await fetchNametagFromIpns(identity.privateKey); + + if (result.nametag && result.nametagData) { + console.log(`🔍 [Complete Setup] Found nametag: ${result.nametag}`); + + // Save nametag to localStorage + WalletRepository.saveNametagForAddress(identity.address, { + name: result.nametagData.name, + token: result.nametagData.token, + timestamp: result.nametagData.timestamp || Date.now(), + format: result.nametagData.format || "TXF", + version: "1.0", + }); + + // Reload to proceed to wallet with the found nametag + console.log('✅ [Complete Setup] Nametag found, proceeding to wallet...'); + window.location.reload(); + } else { + console.log('🔍 [Complete Setup] No nametag found in IPNS'); + setIpnsFetchingNametag(false); + } + } catch (error) { + console.warn('🔍 [Complete Setup] IPNS fetch error:', error); + setIpnsFetchingNametag(false); + } + }; + + fetchNametag(); + }, [step, identity, nametag, ipnsFetchingNametag]); + + // State for sequential IPNS checking + const [isCheckingIpns, setIsCheckingIpns] = useState(false); + const [firstFoundNametagPath, setFirstFoundNametagPath] = useState(null); + // Auto-derive new addresses during IPNS check (for mnemonic imports) + // For .dat imports, this is set to false - we only check existing addresses + const [autoDeriveDuringIpnsCheck, setAutoDeriveDuringIpnsCheck] = useState(true); + + // Effect: Fetch nametags from IPNS sequentially + // Check ALL addresses up to limit (10), don't stop on first nametag found + // Auto-select the FIRST address with nametag + useEffect(() => { + // Only run when in addressSelection step and we have addresses to check + if (step !== 'addressSelection' || derivedAddresses.length === 0 || isCheckingIpns) return; + + // Find the next address that needs IPNS fetching + const nextToCheck = derivedAddresses.find( + (addr) => addr.ipnsLoading && addr.privateKey + ); + + if (!nextToCheck) { + // All current addresses checked, no more pending + console.log('🔍 All current addresses checked'); + return; + } + + // Sequential fetch - one at a time + const fetchAndMaybeDerive = async () => { + setIsCheckingIpns(true); + const addr = nextToCheck; + const chainLabel = addr.isChange ? 'change' : 'external'; + console.log(`🔍 Checking IPNS for ${chainLabel} #${addr.index} (path: ${addr.path})...`); + + try { + const result = await fetchNametagFromIpns(addr.privateKey!); + console.log(`🔍 IPNS result for ${chainLabel} (path: ${addr.path}): ${result.nametag || 'none'} (via ${result.source})`); + + // Update state with fetched result + setDerivedAddresses((prev) => + prev.map((a) => + a.path === addr.path + ? { + ...a, + ipnsName: result.ipnsName, + hasNametag: !!result.nametag, + existingNametag: result.nametag || undefined, + nametagData: result.nametagData, + ipnsLoading: false, + ipnsError: result.error, + privateKey: undefined, + } + : a + ) + ); + + // If this is the FIRST nametag found, auto-select this address + if (result.nametag && !firstFoundNametagPath) { + console.log(`✅ Found FIRST nametag "${result.nametag}" at ${chainLabel} #${addr.index}, auto-selecting`); + setFirstFoundNametagPath(addr.path); + setSelectedAddressPath(addr.path); + } else if (result.nametag) { + console.log(`✅ Found another nametag "${result.nametag}" at ${chainLabel} #${addr.index}`); + } + } catch (error: any) { + console.warn(`🔍 IPNS fetch error for ${chainLabel} (path: ${addr.path}):`, error.message); + setDerivedAddresses((prev) => + prev.map((a) => + a.path === addr.path + ? { + ...a, + ipnsLoading: false, + ipnsError: error.message, + privateKey: undefined, + } + : a + ) + ); + } + + setIsCheckingIpns(false); + + // Only auto-derive new addresses for mnemonic imports (not for .dat/scanned imports) + // For .dat imports, we only check IPNS for the addresses selected during blockchain scan + if (autoDeriveDuringIpnsCheck && derivedAddresses.length < 10) { + console.log(`🔍 Deriving next address (#${derivedAddresses.length})...`); + await deriveNextAddressInternal(); + } + }; + + fetchAndMaybeDerive(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step, derivedAddresses, isCheckingIpns, firstFoundNametagPath, autoDeriveDuringIpnsCheck]); + + // Internal helper to derive next address (used by auto-derive) + const deriveNextAddressInternal = async () => { + const nextIndex = derivedAddresses.length; + const keyManager = getUnifiedKeyManager(); + const basePath = keyManager.getBasePath(); + const path = `${basePath}/0/${nextIndex}`; + const derived = keyManager.deriveAddressFromPath(path); + const l3Identity = await identityManager.deriveIdentityFromPath(path); + const existingNametag = WalletRepository.checkNametagForAddress(l3Identity.address); + const hasLocalNametag = !!existingNametag; + + setDerivedAddresses(prev => [...prev, { + index: nextIndex, + l1Address: derived.l1Address, + l3Address: l3Identity.address, + path: path, + hasNametag: hasLocalNametag, + existingNametag: existingNametag?.name, + privateKey: hasLocalNametag ? undefined : l3Identity.privateKey, + ipnsLoading: !hasLocalNametag, + }]); + }; + // Helper: truncate address for display const truncateAddress = (addr: string) => addr ? addr.slice(0, 12) + "..." + addr.slice(-8) : ''; @@ -74,43 +260,61 @@ export function CreateWalletFlow() { // Helper: derive addresses and check for existing nametags const deriveAndCheckAddresses = async (count: number): Promise => { const keyManager = getUnifiedKeyManager(); + const basePath = keyManager.getBasePath(); const results: DerivedAddressInfo[] = []; for (let i = 0; i < count; i++) { - const derived = keyManager.deriveAddress(i); - const l3Identity = await identityManager.deriveIdentityFromUnifiedWallet(i); + // Build path and derive using path-based method - PATH is the single identifier + const path = `${basePath}/0/${i}`; + const derived = keyManager.deriveAddressFromPath(path); + // Use path-based derivation for unambiguous L3 identity + const l3Identity = await identityManager.deriveIdentityFromPath(path); const existingNametag = WalletRepository.checkNametagForAddress(l3Identity.address); + const hasLocalNametag = !!existingNametag; results.push({ index: i, l1Address: derived.l1Address, l3Address: l3Identity.address, - path: derived.path, - hasNametag: !!existingNametag, + path: path, // PATH is the primary key! + hasNametag: hasLocalNametag, existingNametag: existingNametag?.name, + // Store L3 private key for IPNS derivation (IPNS name is tied to L3 identity key) + privateKey: hasLocalNametag ? undefined : l3Identity.privateKey, + // Mark for IPNS loading if no local nametag found + ipnsLoading: !hasLocalNametag, }); } return results; }; - // Helper: derive one more address + // Helper: derive one more address and resume IPNS checking const handleDeriveNewAddress = async () => { setIsBusy(true); try { const nextIndex = derivedAddresses.length; const keyManager = getUnifiedKeyManager(); - const derived = keyManager.deriveAddress(nextIndex); - const l3Identity = await identityManager.deriveIdentityFromUnifiedWallet(nextIndex); + const basePath = keyManager.getBasePath(); + // Build path and derive using path-based method - PATH is the single identifier + const path = `${basePath}/0/${nextIndex}`; + const derived = keyManager.deriveAddressFromPath(path); + // Use path-based derivation for unambiguous L3 identity + const l3Identity = await identityManager.deriveIdentityFromPath(path); const existingNametag = WalletRepository.checkNametagForAddress(l3Identity.address); + const hasLocalNametag = !!existingNametag; setDerivedAddresses([...derivedAddresses, { index: nextIndex, l1Address: derived.l1Address, l3Address: l3Identity.address, - path: derived.path, - hasNametag: !!existingNametag, + path: path, // PATH is the primary key! + hasNametag: hasLocalNametag, existingNametag: existingNametag?.name, + // Store private key for IPNS derivation (only if no local nametag) + privateKey: hasLocalNametag ? undefined : l3Identity.privateKey, + // Mark for IPNS loading if no local nametag found + ipnsLoading: !hasLocalNametag, }]); } catch (e: any) { setError("Failed to derive new address: " + e.message); @@ -125,12 +329,74 @@ export function CreateWalletFlow() { setError(null); try { - const selected = derivedAddresses[selectedAddressIndex]; + if (!selectedAddress) { + throw new Error("No address selected"); + } - // Store selected index for future identity derivation - identityManager.setSelectedAddressIndex(selected.index); + // Store selected PATH for future identity derivation + // Path is the only unambiguous identifier + identityManager.setSelectedAddressPath(selectedAddress.path); - if (selected.hasNametag) { + // Save L1 wallet with ALL derived addresses that have nametags + // This ensures all addresses are preserved after reload + const keyManager = getUnifiedKeyManager(); + + // Log all addresses and their nametag status for debugging + console.log("📋 All derived addresses:", derivedAddresses.map(a => ({ + index: a.index, + path: a.path, + hasNametag: a.hasNametag, + nametag: a.existingNametag, + hasNametagData: !!a.nametagData, + }))); + + const addressesToSave = derivedAddresses + .filter(addr => addr.hasNametag || addr.path === selectedAddress.path) + .map(addr => { + const derived = keyManager.deriveAddressFromPath(addr.path); + return { + index: addr.index, + address: derived.l1Address, + privateKey: derived.privateKey, + publicKey: derived.publicKey, + path: addr.path, + isChange: addr.isChange, + createdAt: new Date().toISOString(), + }; + }); + + console.log(`💾 Addresses to save: ${addressesToSave.length} (with nametags or selected)`); + + // Save wallet to L1 storage + const l1Wallet: L1Wallet = { + masterPrivateKey: keyManager.getMasterKeyHex() || '', + chainCode: keyManager.getChainCodeHex() || undefined, + addresses: addressesToSave, + isBIP32: keyManager.getDerivationMode() === 'bip32', + }; + saveWalletToStorage("main", l1Wallet); + console.log(`💾 Saved L1 wallet with ${addressesToSave.length} addresses`); + + // Reset IPFS service so it will be re-initialized with the new identity + // This is critical when user selects a different address than the one + // that was previously used to initialize the IPFS service + await IpfsStorageService.resetInstance(); + + // Save all nametags fetched from IPNS to localStorage + for (const addr of derivedAddresses) { + if (addr.hasNametag && addr.nametagData && addr.l3Address) { + console.log(`💾 Saving nametag for ${addr.l3Address.slice(0, 20)}...`); + WalletRepository.saveNametagForAddress(addr.l3Address, { + name: addr.nametagData.name, + token: addr.nametagData.token, + timestamp: addr.nametagData.timestamp || Date.now(), + format: addr.nametagData.format || "TXF", + version: "1.0", + }); + } + } + + if (selectedAddress.hasNametag) { // Address already has nametag - proceed to main app console.log("✅ Address has existing nametag, proceeding to main app"); window.location.reload(); @@ -146,7 +412,9 @@ export function CreateWalletFlow() { }; // Helper: go to address selection after wallet creation/restore/import - const goToAddressSelection = async () => { + // skipIpnsCheck: if true, disable auto-derive during IPNS check (for .dat imports) + // IPNS will still be checked for addresses without nametag, but no new addresses will be derived + const goToAddressSelection = async (skipIpnsCheck: boolean = false) => { setIsBusy(true); setError(null); try { @@ -154,47 +422,89 @@ export function CreateWalletFlow() { const l1Wallet = loadWalletFromStorage("main"); if (l1Wallet && l1Wallet.addresses && l1Wallet.addresses.length > 0) { - // Use addresses from L1 wallet storage - console.log(`📋 Loading ${l1Wallet.addresses.length} addresses from L1 wallet storage`); + // Use ALL addresses from L1 wallet storage (both external and change) + // External and change addresses have DIFFERENT L3 identities + const allAddresses = l1Wallet.addresses; + const changeCount = allAddresses.filter(addr => addr.isChange).length; + console.log(`📋 Loading ${allAddresses.length} addresses from L1 wallet storage (${allAddresses.length - changeCount} external, ${changeCount} change)`); const results: DerivedAddressInfo[] = []; - // For each L1 address, derive L3 identity using sequential index (not L1's index) - // This ensures we can find existing nametags that were created with deriveIdentityFromUnifiedWallet - for (let i = 0; i < l1Wallet.addresses.length; i++) { - const addr = l1Wallet.addresses[i]; + // For each L1 address, derive L3 identity using the address's actual index AND isChange flag + // External and change addresses have DIFFERENT L3 identities (different chain in BIP32 path) + // Log UnifiedKeyManager state for debugging + const keyManager = getUnifiedKeyManager(); + console.log(`🔍 [goToAddressSelection] UnifiedKeyManager state:`, { + basePath: keyManager.getBasePath(), + isInitialized: keyManager.isInitialized(), + masterKeyPrefix: keyManager.getMasterKeyHex()?.slice(0, 16) || 'unknown', + }); - if (!addr.privateKey) { - console.warn(`Skipping address ${addr.address} - no private key`); + for (const addr of allAddresses) { + // Use PATH for L3 derivation - the only unambiguous identifier + // Skip addresses without a path (should not happen in BIP32 wallets) + if (!addr.path) { + console.warn(`⚠️ Address ${addr.address.slice(0, 20)}... has no path, skipping`); continue; } - // Use sequential index i for L3 derivation (0, 1, 2...) - // This matches how deriveIdentityFromUnifiedWallet works - const l3Identity = await identityManager.deriveIdentityFromUnifiedWallet(i); + // Use path-based derivation for unambiguous L3 identity + const l3Identity = await identityManager.deriveIdentityFromPath(addr.path); const existingNametag = WalletRepository.checkNametagForAddress(l3Identity.address); - console.log(`🔍 Address ${i}: L1=${addr.address.slice(0, 20)}... L3=${l3Identity.address.slice(0, 20)}... hasNametag=${!!existingNametag} nametag=${existingNametag?.name}`); + const isChange = addr.isChange ?? false; + const chainLabel = isChange ? "change" : "external"; + console.log(`🔍 Address (path: ${addr.path}, ${chainLabel}): L1=${addr.address.slice(0, 20)}... L3=${l3Identity.address.slice(0, 20)}... key=${l3Identity.privateKey.slice(0, 8)}... hasNametag=${!!existingNametag} nametag=${existingNametag?.name}`); + + // Enable IPNS fetching for addresses without local nametag + // Note: For .dat imports, we still check IPNS for addresses without nametag + // (in case IPNS didn't finish during scan), but we don't auto-derive new addresses + const enableIpnsFetching = !existingNametag; results.push({ - index: i, // Use sequential index for L3 + index: addr.index, // Keep for display purposes only l1Address: addr.address, l3Address: l3Identity.address, - path: addr.path || `m/44'/0'/0'/0/${addr.index}`, + path: addr.path, // PATH is the primary key! hasNametag: !!existingNametag, existingNametag: existingNametag?.name, + isChange, // Track change status for UI display + fromL1Wallet: true, // Address from L1 wallet - show immediately even while IPNS loading + // Enable IPNS nametag fetching for addresses without local nametag + // IMPORTANT: Use l3Identity.privateKey (from UnifiedKeyManager) for IPNS derivation, + // NOT addr.privateKey (L1 wallet). The IPNS name is tied to the L3 identity key. + privateKey: enableIpnsFetching ? l3Identity.privateKey : undefined, + ipnsLoading: enableIpnsFetching, }); } + // Sort addresses: external first (by index), then change (by index) + results.sort((a, b) => { + const aIsChange = a.isChange ? 1 : 0; + const bIsChange = b.isChange ? 1 : 0; + if (aIsChange !== bIsChange) return aIsChange - bIsChange; + return a.index - b.index; + }); + setDerivedAddresses(results); - setSelectedAddressIndex(0); + // Select first address by default (using path, not index) + setSelectedAddressPath(results[0]?.path || null); } else { // No L1 wallet addresses - derive from UnifiedKeyManager console.log("📋 No L1 wallet addresses found, deriving from UnifiedKeyManager"); - const addresses = await deriveAndCheckAddresses(1); + const addresses = await deriveAndCheckAddresses(1); // Start with 1 address, auto-derive more if no nametag found setDerivedAddresses(addresses); - setSelectedAddressIndex(0); + // Select first address by default (using path, not index) + setSelectedAddressPath(addresses[0]?.path || null); } + // Reset IPNS checking state for new address selection + setIsCheckingIpns(false); + setFirstFoundNametagPath(null); + + // For .dat imports, disable auto-derive (only check IPNS for existing addresses) + // For mnemonic imports, enable auto-derive to find all addresses with nametags + setAutoDeriveDuringIpnsCheck(!skipIpnsCheck); + setStep('addressSelection'); } catch (e: any) { setError("Failed to derive addresses: " + e.message); @@ -203,6 +513,37 @@ export function CreateWalletFlow() { } }; + // Helper: Verify nametag is available via IPNS with retry (30s timeout) + const verifyNametagInIpnsWithRetry = async ( + privateKey: string, + expectedNametag: string, + timeoutMs: number = 30000 + ): Promise => { + const startTime = Date.now(); + const retryInterval = 3000; // 3 seconds between retries + + while (Date.now() - startTime < timeoutMs) { + try { + console.log(`🔄 IPNS verification attempt for "${expectedNametag}"...`); + const result = await fetchNametagFromIpns(privateKey); + if (result.nametag === expectedNametag) { + return true; // Verified! + } + console.log(`🔄 IPNS returned "${result.nametag || 'null'}", expected "${expectedNametag}"`); + } catch (error) { + console.log('🔄 IPNS verification attempt failed, retrying...', error); + } + + // Wait before next retry (unless we've exceeded timeout) + const remainingTime = timeoutMs - (Date.now() - startTime); + if (remainingTime > retryInterval) { + await new Promise(resolve => setTimeout(resolve, retryInterval)); + } + } + + return false; // Timeout reached + }; + const handleCreateKeys = async () => { // Prevent double-clicking if (isBusy) return; @@ -210,7 +551,9 @@ export function CreateWalletFlow() { setIsBusy(true); setError(null); try { - // Always create new wallet (overwrites existing if any) + // Clear all wallet data to ensure clean slate + UnifiedKeyManager.clearAll(); + await createWallet(); // Go directly to nametag step setStep('nametag'); @@ -231,17 +574,54 @@ export function CreateWalletFlow() { const cleanTag = nametagInput.trim().replace('@', ''); const isNametagAvailable = await checkNametagAvailability(cleanTag); - if(!isNametagAvailable) { + if (!isNametagAvailable) { setError(`${cleanTag} already exists.`); + setIsBusy(false); return; } setStep('processing'); + + // Step 1: Mint nametag on blockchain and save to localStorage + setProcessingStatus('Minting Unicity ID on blockchain...'); + console.log('🏷️ Step 1: Minting nametag on blockchain...'); await mintNametag(cleanTag); - // Successfully minted nametag - reload to reinitialize with new nametag + console.log('✅ Nametag minted and saved to localStorage'); + + // Step 2: Sync to IPFS storage + setProcessingStatus('Syncing to IPFS storage...'); + console.log('🏷️ Step 2: Syncing to IPFS...'); + try { + const ipfsService = IpfsStorageService.getInstance(identityManager); + await ipfsService.syncNow(); + console.log('✅ IPFS sync completed'); + } catch (syncError) { + console.warn('⚠️ IPFS sync failed, continuing anyway:', syncError); + } + + // Step 3: Verify nametag can be fetched from IPNS (30s timeout with retries) + setProcessingStatus('Verifying IPFS availability...'); + console.log('🏷️ Step 3: Verifying nametag in IPNS (30s timeout)...'); + const currentIdentity = await identityManager.getCurrentIdentity(); + if (!currentIdentity) { + console.warn('⚠️ Could not get current identity for verification, proceeding anyway'); + } + const verified = currentIdentity + ? await verifyNametagInIpnsWithRetry(currentIdentity.privateKey, cleanTag, 30000) + : false; + + if (!verified) { + console.warn('⚠️ IPNS verification timed out after 30s, proceeding anyway'); + } else { + console.log(`✅ Verified nametag "${cleanTag}" available via IPNS`); + } + + // Step 4: Successfully completed - reload to reinitialize with new nametag // This ensures React Query refreshes and the app transitions to main wallet view + console.log('🏷️ Step 4: All steps completed, reloading...'); window.location.reload(); } catch (e: any) { + console.error('❌ Nametag minting failed:', e); setError(e.message || "Minting failed"); setStep('nametag'); } finally { @@ -262,8 +642,17 @@ export function CreateWalletFlow() { setError(null); try { + // Clear all wallet data to ensure clean slate + UnifiedKeyManager.clearAll(); + const mnemonic = words.join(' '); - await restoreWallet(mnemonic); + + // Set up UnifiedKeyManager but DON'T create identity yet + // Creating identity triggers query invalidation which can unmount CreateWalletFlow + // if the address already has a nametag stored. We need to show address selection first. + const keyManager = getUnifiedKeyManager(); + await keyManager.createFromMnemonic(mnemonic); + // Go to address selection instead of nametag await goToAddressSelection(); } catch (e: any) { @@ -278,6 +667,14 @@ export function CreateWalletFlow() { setError(null); try { + // Clear any existing wallet data to prevent conflicts with old identity + const existingKeyManager = getUnifiedKeyManager(); + if (existingKeyManager?.isInitialized()) { + console.log("🔐 Clearing existing wallet before importing from file"); + existingKeyManager.clear(); + UnifiedKeyManager.resetInstance(); + } + // Clear any existing L1 wallet from storage before importing new one // This prevents showing old addresses when importing a different wallet localStorage.removeItem("wallet_main"); @@ -303,7 +700,87 @@ export function CreateWalletFlow() { const content = await file.text(); - // Check if encrypted + // Handle JSON wallet files (new v1.0 format) + if (file.name.endsWith(".json") || isJSONWalletFormat(content)) { + try { + const json = JSON.parse(content); + + // Check if encrypted JSON + if (json.encrypted) { + setPendingFile(file); + setInitialScanCount(scanCountParam || 10); + setShowLoadPasswordModal(true); + setIsBusy(false); + return; + } + + // Import unencrypted JSON + const result = await importWalletFromJSON(content); + console.log("📦 JSON import result:", { + success: result.success, + hasMnemonic: !!result.mnemonic, + mnemonic: result.mnemonic?.slice(0, 20) + "...", + source: result.source, + derivationMode: result.derivationMode, + }); + if (!result.success || !result.wallet) { + throw new Error(result.error || "Import failed"); + } + + // If has mnemonic, set up UnifiedKeyManager but DON'T create identity yet + // Creating identity triggers query invalidation which can unmount CreateWalletFlow + // if the address already has a nametag stored. We need to show address selection first. + if (result.mnemonic) { + console.log("📦 Setting up UnifiedKeyManager from mnemonic (without creating identity)..."); + const keyManager = getUnifiedKeyManager(); + await keyManager.createFromMnemonic(result.mnemonic); + + // Clear selected address path - user will choose in address selection + localStorage.removeItem("l3_selected_address_path"); + localStorage.removeItem("l3_selected_address_index"); // Clean up legacy + + // Go to address selection - this will derive multiple addresses + // and let user choose which one to use (e.g., find addresses with nametags via IPNS) + await goToAddressSelection(); + return; + } + + // Check if BIP32 needs scanning + const isJsonBIP32 = result.derivationMode === "bip32" || result.wallet.chainCode; + if (isJsonBIP32) { + // Import master key into UnifiedKeyManager first + const keyManager = getUnifiedKeyManager(); + const chainCode = result.wallet.chainCode || result.wallet.masterChainCode || null; + // Get basePath from descriptorPath (e.g., "84'/1'/0'" -> "m/84'/1'/0'") + const basePath = result.wallet.descriptorPath ? `m/${result.wallet.descriptorPath}` : undefined; + await keyManager.importWithMode(result.wallet.masterPrivateKey, chainCode, result.derivationMode || "bip32", basePath); + + setPendingWallet(result.wallet); + setInitialScanCount(scanCountParam || 10); + setShowScanModal(true); + setIsBusy(false); + return; + } + + // Standard JSON wallet - save and use directly + const keyManager = getUnifiedKeyManager(); + await keyManager.importWithMode(result.wallet.masterPrivateKey, null, "wif_hmac"); + + saveWalletToStorage("main", result.wallet); + + // Go to address selection + await goToAddressSelection(); + return; + } catch (e) { + // If JSON parsing fails but it looked like JSON, throw error + if (file.name.endsWith(".json")) { + throw new Error(`Invalid JSON wallet file: ${e instanceof Error ? e.message : String(e)}`); + } + // Otherwise continue to try other formats + } + } + + // Check if encrypted TXT file if (content.includes("ENCRYPTED MASTER KEY")) { setPendingFile(file); setInitialScanCount(scanCountParam || 10); @@ -355,7 +832,9 @@ export function CreateWalletFlow() { } if (mnemonic) { - await restoreWallet(mnemonic); + // Set up UnifiedKeyManager but DON'T create identity yet + const keyManager = getUnifiedKeyManager(); + await keyManager.createFromMnemonic(mnemonic); imported = true; } } catch { @@ -369,7 +848,9 @@ export function CreateWalletFlow() { if (words.length === 12 || words.length === 24) { const isMnemonic = words.every(w => /^[a-z]+$/.test(w.toLowerCase())); if (isMnemonic) { - await restoreWallet(trimmed); + // Set up UnifiedKeyManager but DON'T create identity yet + const keyManager = getUnifiedKeyManager(); + await keyManager.createFromMnemonic(trimmed); imported = true; } } @@ -439,13 +920,32 @@ export function CreateWalletFlow() { // Save L1 wallet to storage saveWalletToStorage("main", walletWithAddress); - // Import the wallet into UnifiedKeyManager + // Save nametag found during scan to WalletRepository (for L3 address selection) + if (scannedAddr.l3Nametag && scannedAddr.path) { + try { + const l3Identity = await identityManager.deriveIdentityFromPath(scannedAddr.path); + WalletRepository.saveNametagForAddress(l3Identity.address, { + name: scannedAddr.l3Nametag, + token: {}, // Minimal token data - full sync will happen later + timestamp: Date.now(), + format: "TXF", + version: "1.0", + }); + console.log(`💾 Saved nametag @${scannedAddr.l3Nametag} for L3 address ${l3Identity.address.slice(0, 20)}...`); + } catch (e) { + console.warn(`Failed to save nametag for address ${scannedAddr.path}:`, e); + } + } + + // Import the wallet into UnifiedKeyManager with basePath preserved const keyManager = getUnifiedKeyManager(); + const basePath = pendingWallet.descriptorPath ? `m/${pendingWallet.descriptorPath}` : undefined; if (pendingWallet.masterPrivateKey && pendingWallet.masterChainCode) { await keyManager.importWithMode( pendingWallet.masterPrivateKey, pendingWallet.masterChainCode, - "bip32" + "bip32", + basePath ); } else if (pendingWallet.masterPrivateKey) { await keyManager.importWithMode( @@ -459,8 +959,8 @@ export function CreateWalletFlow() { setPendingWallet(null); // Go to address selection to choose L3 identity - // User will see address #0 (the one they selected) in the dropdown - await goToAddressSelection(); + // Skip IPNS check for .dat imports - nametags already found during blockchain scan + await goToAddressSelection(true); } catch (e: any) { setError(e.message || "Failed to import wallet"); setIsBusy(false); @@ -496,13 +996,35 @@ export function CreateWalletFlow() { // Save L1 wallet to storage with ALL addresses saveWalletToStorage("main", walletWithAddresses); - // Import the wallet into UnifiedKeyManager + // Save nametags found during scan to WalletRepository (for L3 address selection) + // This ensures goToAddressSelection() can find them via checkNametagForAddress() + for (const addr of scannedAddresses) { + if (addr.l3Nametag && addr.path) { + try { + const l3Identity = await identityManager.deriveIdentityFromPath(addr.path); + WalletRepository.saveNametagForAddress(l3Identity.address, { + name: addr.l3Nametag, + token: {}, // Minimal token data - full sync will happen later + timestamp: Date.now(), + format: "TXF", + version: "1.0", + }); + console.log(`💾 Saved nametag @${addr.l3Nametag} for L3 address ${l3Identity.address.slice(0, 20)}...`); + } catch (e) { + console.warn(`Failed to save nametag for address ${addr.path}:`, e); + } + } + } + + // Import the wallet into UnifiedKeyManager with basePath preserved const keyManager = getUnifiedKeyManager(); + const basePath = pendingWallet.descriptorPath ? `m/${pendingWallet.descriptorPath}` : undefined; if (pendingWallet.masterPrivateKey && pendingWallet.masterChainCode) { await keyManager.importWithMode( pendingWallet.masterPrivateKey, pendingWallet.masterChainCode, - "bip32" + "bip32", + basePath ); } else if (pendingWallet.masterPrivateKey) { await keyManager.importWithMode( @@ -516,8 +1038,8 @@ export function CreateWalletFlow() { setPendingWallet(null); // Go to address selection to choose which L1 address to use for L3 identity - // This will show all the scanned addresses in the dropdown - await goToAddressSelection(); + // Skip IPNS check for .dat imports - nametags already found during blockchain scan + await goToAddressSelection(true); } catch (e: any) { setError(e.message || "Failed to import wallet"); setIsBusy(false); @@ -539,7 +1061,58 @@ export function CreateWalletFlow() { setError(null); setShowLoadPasswordModal(false); - // Import with password + const content = await pendingFile.text(); + + // Check if this is an encrypted JSON file + if (pendingFile.name.endsWith(".json") || isJSONWalletFormat(content)) { + const result = await importWalletFromJSON(content, password); + if (!result.success || !result.wallet) { + throw new Error(result.error || "Import failed"); + } + + setPendingFile(null); + + // If has mnemonic, set up UnifiedKeyManager but DON'T create identity yet + // Creating identity triggers query invalidation which can unmount CreateWalletFlow + // if the address already has a nametag stored. We need to show address selection first. + if (result.mnemonic) { + console.log("📦 Setting up UnifiedKeyManager from encrypted JSON mnemonic (without creating identity)..."); + const keyManager = getUnifiedKeyManager(); + await keyManager.createFromMnemonic(result.mnemonic); + + // Clear selected address path - user will choose in address selection + localStorage.removeItem("l3_selected_address_path"); + localStorage.removeItem("l3_selected_address_index"); // Clean up legacy + + // Go to address selection after restoring from mnemonic + await goToAddressSelection(); + return; + } + + // Check if BIP32 needs scanning + const isBIP32 = result.derivationMode === "bip32" || result.wallet.chainCode; + if (isBIP32) { + const keyManager = getUnifiedKeyManager(); + const chainCode = result.wallet.chainCode || result.wallet.masterChainCode || null; + // Get basePath from descriptorPath (e.g., "84'/1'/0'" -> "m/84'/1'/0'") + const basePath = result.wallet.descriptorPath ? `m/${result.wallet.descriptorPath}` : undefined; + await keyManager.importWithMode(result.wallet.masterPrivateKey, chainCode, result.derivationMode || "bip32", basePath); + + setPendingWallet(result.wallet); + setShowScanModal(true); + setIsBusy(false); + return; + } + + // Standard JSON wallet + const keyManager = getUnifiedKeyManager(); + await keyManager.importWithMode(result.wallet.masterPrivateKey, null, "wif_hmac"); + saveWalletToStorage("main", result.wallet); + await goToAddressSelection(); + return; + } + + // Handle TXT files with password const result = await importWalletFromFile(pendingFile, password); if (!result.success || !result.wallet) { throw new Error(result.error || "Import failed"); @@ -561,11 +1134,13 @@ export function CreateWalletFlow() { }); const keyManager = getUnifiedKeyManager(); + const basePath = result.wallet.descriptorPath ? `m/${result.wallet.descriptorPath}` : undefined; if (result.wallet.masterPrivateKey && result.wallet.masterChainCode) { await keyManager.importWithMode( result.wallet.masterPrivateKey, result.wallet.masterChainCode, - "bip32" + "bip32", + basePath ); } else if (result.wallet.masterPrivateKey) { await keyManager.importWithMode( @@ -599,8 +1174,25 @@ export function CreateWalletFlow() { return; } - // For .txt files, check if BIP32 or standard const content = await file.text(); + + // JSON wallet files - check format and derivation mode + if (file.name.endsWith(".json") || isJSONWalletFormat(content)) { + try { + const json = JSON.parse(content); + // JSON files with mnemonic don't need scanning - restore directly from seed + // JSON files with BIP32 but no mnemonic need scanning + const hasMnemonic = !!json.mnemonic || !!json.encrypted?.mnemonic; + const isBIP32 = json.derivationMode === "bip32" || json.chainCode; + setNeedsScanning(!hasMnemonic && isBIP32); + setScanCount(10); + } catch { + setNeedsScanning(true); + } + return; + } + + // For .txt files, check if BIP32 or standard const isBIP32 = content.includes("MASTER CHAIN CODE") || content.includes("WALLET TYPE: BIP32") || content.includes("WALLET TYPE: Alpha descriptor"); @@ -635,7 +1227,7 @@ export function CreateWalletFlow() { setIsDragging(false); const file = e.dataTransfer.files[0]; - if (file && (file.name.endsWith(".txt") || file.name.endsWith(".dat"))) { + if (file && (file.name.endsWith(".txt") || file.name.endsWith(".dat") || file.name.endsWith(".json"))) { await handleFileSelect(file); } }; @@ -693,20 +1285,34 @@ export function CreateWalletFlow() { {/* Show "Continue Setup" if identity exists but no nametag */} {identity && !nametag && ( - setStep('nametag')} - disabled={isBusy} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - transition={{ duration: 0.1 }} - className="relative w-full py-3 md:py-3.5 px-5 md:px-6 rounded-xl bg-linear-to-r from-emerald-500 to-emerald-600 text-white text-sm md:text-base font-bold shadow-xl shadow-emerald-500/30 flex items-center justify-center gap-2 md:gap-3 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden group mb-3" - > -
- - - Continue Setup - - + <> + setStep('nametag')} + disabled={isBusy} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + transition={{ duration: 0.1 }} + className="relative w-full py-3 md:py-3.5 px-5 md:px-6 rounded-xl bg-linear-to-r from-emerald-500 to-emerald-600 text-white text-sm md:text-base font-bold shadow-xl shadow-emerald-500/30 flex items-center justify-center gap-2 md:gap-3 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden group mb-3" + > +
+ + + Continue Setup + + + + {/* Show loading indicator while checking IPNS */} + {ipnsFetchingNametag && ( + + + Checking for existing Unicity ID... + + )} + )} {/* Divider when showing continue option */} @@ -794,7 +1400,7 @@ export function CreateWalletFlow() { {/* 12-word grid */}
{Array.from({ length: 12 }).map((_, index) => ( -
+
{index + 1}. @@ -806,6 +1412,19 @@ export function CreateWalletFlow() { newWords[index] = e.target.value; setSeedWords(newWords); }} + onPaste={(e) => { + const pastedText = e.clipboardData.getData('text').trim(); + const words = pastedText.split(/\s+/).filter(w => w.length > 0); + // If pasted text contains multiple words, fill all fields + if (words.length > 1) { + e.preventDefault(); + const newWords = Array(12).fill(''); + words.slice(0, 12).forEach((word, i) => { + newWords[i] = word.toLowerCase(); + }); + setSeedWords(newWords); + } + }} onKeyDown={(e) => { if (e.key === 'Enter' && index < 11) { const nextInput = e.currentTarget.parentElement?.nextElementSibling?.querySelector('input'); @@ -900,33 +1519,48 @@ export function CreateWalletFlow() { {/* Address Dropdown */}
- +
+
+ + #{selectedAddress?.index ?? 0} + + + {truncateAddress(selectedAddress?.l1Address || '')} + + {selectedAddress?.isChange && ( + + Change + + )} + {selectedAddress?.ipnsLoading ? ( + + ) : selectedAddress?.hasNametag ? ( + + + {selectedAddress?.existingNametag} + + ) : null} +
+
+ + + + + )} {/* Dropdown Menu */} @@ -939,15 +1573,16 @@ export function CreateWalletFlow() { className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-50" >
- {derivedAddresses.map((addr, idx) => ( + {/* Only show addresses that have been checked or from L1 wallet */} + {visibleAddresses.map((addr) => ( ))}
- {/* Derive New Address Button */} - + {/* Loading indicator while IPNS is checking, or Derive New Address button */} + {isCheckingIpns || derivedAddresses.some(a => a.ipnsLoading) ? ( +
+ + Checking for nametags... +
+ ) : ( + + )} )}
@@ -988,7 +1637,7 @@ export function CreateWalletFlow() {
L3 Unicity Address
- {derivedAddresses[selectedAddressIndex]?.l3Address || '...'} + {selectedAddress?.l3Address || '...'}
@@ -1019,7 +1668,7 @@ export function CreateWalletFlow() { Loading... - ) : derivedAddresses[selectedAddressIndex]?.hasNametag ? ( + ) : selectedAddress?.hasNametag ? ( <> Continue @@ -1035,7 +1684,7 @@ export function CreateWalletFlow() {
{/* Info about nametag */} - {derivedAddresses[selectedAddressIndex]?.hasNametag && ( + {selectedAddress?.hasNametag && ( - {/* Progress Steps */} + {/* Dynamic Progress Status */} - {[ - { text: "Minting Nametag on Blockchain", delay: 0.4 }, - { text: "Registering on Nostr Relay", delay: 0.6 }, - { text: "Finalizing Wallet", delay: 0.8 } - ].map((step, index) => ( + {/* Current status indicator */} + - - {step.text} - - ))} + animate={{ + scale: [1, 1.2, 1], + opacity: [0.5, 1, 0.5] + }} + transition={{ + duration: 1.5, + repeat: Infinity + }} + className="w-2 h-2 md:w-2.5 md:h-2.5 rounded-full bg-orange-500 dark:bg-orange-400 shrink-0" + /> + + {processingStatus || 'Initializing...'} + + + + {/* Step indicators */} +
+
+
+
+
- This may take a few moments... + {processingStatus.includes('Verifying') + ? 'Verifying IPFS storage (up to 30 seconds)...' + : 'This may take a few moments...'} )} @@ -1324,7 +1988,7 @@ export function CreateWalletFlow() { Import from File
- Import wallet from .dat or .txt file + Import wallet from .json, .dat or .txt file
@@ -1401,13 +2065,13 @@ export function CreateWalletFlow() { Select wallet file

- .txt or .dat + .json, .txt or .dat