From d8bb7ecbda006318d58e7dfb0e5720c249788b9c Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 10 Dec 2025 13:27:38 +0400 Subject: [PATCH 01/17] deployment automation --- .github/workflows/deploy-manual.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/deploy-manual.yml diff --git a/.github/workflows/deploy-manual.yml b/.github/workflows/deploy-manual.yml new file mode 100644 index 000000000..5dffc0417 --- /dev/null +++ b/.github/workflows/deploy-manual.yml @@ -0,0 +1,29 @@ +name: Deploy to Server + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd ~/sphere + echo "=== Previous commit ===" + git rev-parse HEAD + git rev-parse --short HEAD + git pull origin main + echo "=== Current commit ===" + git rev-parse HEAD + git rev-parse --short HEAD + docker compose build + docker compose up -d + docker compose ps From 5c76fca3707542a72d0d52062de240bd8a777efc Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 17 Dec 2025 18:35:38 +0400 Subject: [PATCH 02/17] Refactor greeting message for clarity --- src/config/activities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/activities.ts b/src/config/activities.ts index 9ccd10aed..a88f2126a 100644 --- a/src/config/activities.ts +++ b/src/config/activities.ts @@ -54,7 +54,7 @@ export const agents: AgentConfig[] = [ category: 'Assistant', color: 'from-orange-500 to-amber-500', type: 'simple-ai', - greetingMessage: "Hi! I'm Viktor, your personal assistant.\nShort intro on me. I care a great deal about privacy. I don't know you, I don't log you, I don't even know your IP address. You are invisible here and nothing will be recorded about our conversation.\nHow can I help?", + greetingMessage: "Hi! I'm Viktor, your personal assistant.\nI care a great deal about privacy. I don't know you, I don't log you, I don't even know your IP address. You are invisible here and nothing will be recorded about our conversation.\nHow can I help?", backendActivityId: 'ama', quickActions: [ { label: 'Research', message: 'Research the latest news' }, From 106bfd71494787c6ed29de75ddd42c7e60a3458e Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Thu, 18 Dec 2025 12:47:18 +0100 Subject: [PATCH 03/17] display commit hash in deploy --- .github/workflows/deploy-manual.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy-manual.yml b/.github/workflows/deploy-manual.yml index 5dffc0417..f0899507a 100644 --- a/.github/workflows/deploy-manual.yml +++ b/.github/workflows/deploy-manual.yml @@ -1,5 +1,7 @@ name: Deploy to Server +run-name: Deploy ${{ github.sha }} + on: workflow_dispatch: From 15cafc4d26be910cf6a758ab8879a0392bc55949 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Wed, 7 Jan 2026 17:25:40 +0200 Subject: [PATCH 04/17] feat(wallet): add unified address selector to wallet header --- .../wallet/L1/modals/L1WalletModal.tsx | 64 +++- src/components/wallet/WalletPanel.tsx | 36 +- .../shared/components/AddressSelector.tsx | 346 ++++++++++++++++++ .../wallet/shared/components/index.ts | 3 +- 4 files changed, 406 insertions(+), 43 deletions(-) create mode 100644 src/components/wallet/shared/components/AddressSelector.tsx diff --git a/src/components/wallet/L1/modals/L1WalletModal.tsx b/src/components/wallet/L1/modals/L1WalletModal.tsx index 18973f23e..0d1b55272 100644 --- a/src/components/wallet/L1/modals/L1WalletModal.tsx +++ b/src/components/wallet/L1/modals/L1WalletModal.tsx @@ -321,10 +321,35 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr onClick={() => setShowDropdown(prev => !prev)} className="w-full flex items-center justify-between gap-2 p-2 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-colors" > - - {selectedAddress.slice(0, 20)}...{selectedAddress.slice(-10)} - - + {(() => { + const currentNametagInfo = nametagState[selectedAddress]; + if (!currentNametagInfo || currentNametagInfo.ipnsLoading) { + return ( + + + + {selectedAddress.slice(0, 16)}...{selectedAddress.slice(-8)} + + + ); + } + if (currentNametagInfo.nametag) { + return ( + + @{currentNametagInfo.nametag} + + ({selectedAddress.slice(0, 8)}...{selectedAddress.slice(-6)}) + + + ); + } + return ( + + {selectedAddress.slice(0, 16)}...{selectedAddress.slice(-8)} + + ); + })()} + @@ -346,6 +371,8 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr {addresses.map(addr => { const nametagInfo = nametagState[addr]; const isSelected = addr === selectedAddress; + const walletAddrInfo = wallet?.addresses.find(a => a.address === addr); + const isChange = walletAddrInfo?.isChange; return ( ); diff --git a/src/components/wallet/WalletPanel.tsx b/src/components/wallet/WalletPanel.tsx index b56c5944a..be4a3450c 100644 --- a/src/components/wallet/WalletPanel.tsx +++ b/src/components/wallet/WalletPanel.tsx @@ -1,14 +1,14 @@ -import { Wallet, Copy, Check, Clock, Bell, MoreVertical } from 'lucide-react'; +import { Wallet, Clock, Bell, MoreVertical } from 'lucide-react'; import { useState } from 'react'; import { motion } from 'framer-motion'; import { L3WalletView } from './L3/views/L3WalletView'; import { useWallet } from './L3/hooks/useWallet'; import { useIncomingPaymentRequests } from './L3/hooks/useIncomingPaymentRequests'; import { L1WalletModal } from './L1/modals/L1WalletModal'; +import { AddressSelector } from './shared/components'; export function WalletPanel() { const [showBalances, setShowBalances] = useState(true); - const [copied, setCopied] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isRequestsOpen, setIsRequestsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -21,17 +21,6 @@ export function WalletPanel() { return null; } - const handleCopyNametag = async () => { - if (!nametag) return; - try { - await navigator.clipboard.writeText(`${nametag}`); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy nametag:', err); - } - }; - return (
@@ -55,26 +44,7 @@ export function WalletPanel() {
Wallet -
- - {nametag ? `@${nametag}` : 'AgentSphere'} - - {nametag && ( - - {copied ? ( - - ) : ( - - )} - - )} -
+
diff --git a/src/components/wallet/shared/components/AddressSelector.tsx b/src/components/wallet/shared/components/AddressSelector.tsx new file mode 100644 index 000000000..e304cad45 --- /dev/null +++ b/src/components/wallet/shared/components/AddressSelector.tsx @@ -0,0 +1,346 @@ +import { useState, useMemo } from 'react'; +import { ChevronDown, Plus, Loader2, Check, Copy } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useL1Wallet } from '../../L1/hooks/useL1Wallet'; +import { useAddressNametags } from '../../L1/hooks/useAddressNametags'; +import { generateAddress, loadWalletFromStorage } from '../../L1/sdk'; +import { WalletRepository } from '../../../../repositories/WalletRepository'; +import { STORAGE_KEYS } from '../../../../config/storageKeys'; + +interface AddressSelectorProps { + /** Current nametag to display when collapsed */ + currentNametag?: string; + /** Compact mode - just show nametag with small dropdown trigger */ + compact?: boolean; +} + +export function AddressSelector({ currentNametag, compact = true }: AddressSelectorProps) { + const [showDropdown, setShowDropdown] = useState(false); + const [copied, setCopied] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + + const { wallet, invalidateWallet } = useL1Wallet(); + const { nametagState } = useAddressNametags(wallet?.addresses); + + // Get current selected path from localStorage + const selectedPath = localStorage.getItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); + + // Find current address info + const currentAddress = useMemo(() => { + if (!wallet?.addresses) return null; + if (selectedPath) { + return wallet.addresses.find(a => a.path === selectedPath) || wallet.addresses[0]; + } + return wallet.addresses[0]; + }, [wallet?.addresses, selectedPath]); + + // Sort addresses: external first (by index), then change (by index) + const sortedAddresses = useMemo(() => { + if (!wallet?.addresses) return []; + return [...wallet.addresses].sort((a, b) => { + const aIsChange = a.isChange ? 1 : 0; + const bIsChange = b.isChange ? 1 : 0; + if (aIsChange !== bIsChange) return aIsChange - bIsChange; + return (a.index ?? 0) - (b.index ?? 0); + }); + }, [wallet?.addresses]); + + const handleSelectAddress = (address: string) => { + const selectedAddr = wallet?.addresses.find(a => a.address === address); + if (selectedAddr?.path) { + localStorage.setItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH, selectedAddr.path); + } else { + localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); + } + WalletRepository.getInstance().resetInMemoryState(); + setShowDropdown(false); + window.location.reload(); + }; + + const handleNewAddress = async () => { + if (!wallet || isGenerating) return; + setIsGenerating(true); + try { + const addr = generateAddress(wallet); + const updated = loadWalletFromStorage("main"); + if (updated) { + invalidateWallet(); + // Select the new address + if (addr.path) { + localStorage.setItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH, addr.path); + } + WalletRepository.getInstance().resetInMemoryState(); + setShowDropdown(false); + window.location.reload(); + } + } catch (err) { + console.error('Failed to generate address:', err); + } finally { + setIsGenerating(false); + } + }; + + const handleCopyNametag = async () => { + if (!currentNametag) return; + try { + await navigator.clipboard.writeText(`@${currentNametag}`); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy nametag:', err); + } + }; + + // Get display info for current address + const currentNametagInfo = currentAddress ? nametagState[currentAddress.address] : null; + const displayNametag = currentNametag || currentNametagInfo?.nametag; + const isLoading = currentNametagInfo?.ipnsLoading; + + if (!wallet?.addresses || wallet.addresses.length === 0) { + return null; + } + + if (compact) { + return ( +
+
+ {/* Nametag display with dropdown trigger */} + + + {/* Copy button */} + {displayNametag && ( + + {copied ? ( + + ) : ( + + )} + + )} +
+ + {/* Dropdown */} + + {showDropdown && ( + <> + setShowDropdown(false)} + /> + + {/* Header */} +
+ + Addresses ({sortedAddresses.length}) + + + {isGenerating ? ( + + ) : ( + + )} + New + +
+ + {/* Address list */} +
+ {sortedAddresses.map((addr) => { + const nametagInfo = nametagState[addr.address]; + const isSelected = addr.address === currentAddress?.address; + const isChange = addr.isChange; + + return ( + + ); + })} +
+
+ + )} +
+
+ ); + } + + // Full mode (not used currently, but available for future) + return ( +
+ + + {/* Same dropdown as compact mode */} + + {showDropdown && ( + <> + setShowDropdown(false)} + /> + +
+ + Select Address + + + {isGenerating ? ( + + ) : ( + + )} + New Address + +
+ +
+ {sortedAddresses.map((addr) => { + const nametagInfo = nametagState[addr.address]; + const isSelected = addr.address === currentAddress?.address; + + return ( + + ); + })} +
+
+ + )} +
+
+ ); +} diff --git a/src/components/wallet/shared/components/index.ts b/src/components/wallet/shared/components/index.ts index 79d72e904..905f49b40 100644 --- a/src/components/wallet/shared/components/index.ts +++ b/src/components/wallet/shared/components/index.ts @@ -1,2 +1,3 @@ export { AssetRow } from './AssetRow'; -export { TokenRow } from './TokenRow' +export { TokenRow } from './TokenRow'; +export { AddressSelector } from './AddressSelector'; From 1a15b078aa02e41720daefe3da4dd28dc8313b69 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Wed, 7 Jan 2026 18:05:39 +0200 Subject: [PATCH 05/17] feat(onboarding): gap limit detection using nametag, L1 balance, and L3 tokens --- .../wallet/L1/hooks/useAddressNametags.ts | 91 +++++++++--- .../wallet/L1/modals/L1WalletModal.tsx | 16 ++- src/components/wallet/WalletPanel.tsx | 4 +- .../components/AddressSelectionScreen.tsx | 34 ++++- .../onboarding/hooks/useOnboardingFlow.ts | 130 +++++++++++------- .../shared/components/AddressSelector.tsx | 38 +++-- 6 files changed, 219 insertions(+), 94 deletions(-) diff --git a/src/components/wallet/L1/hooks/useAddressNametags.ts b/src/components/wallet/L1/hooks/useAddressNametags.ts index 403ef81b7..4ce908ec2 100644 --- a/src/components/wallet/L1/hooks/useAddressNametags.ts +++ b/src/components/wallet/L1/hooks/useAddressNametags.ts @@ -123,28 +123,79 @@ export function useAddressNametags(addresses: WalletAddress[] | undefined) { if (addr.path) initializedAddressesRef.current.add(addr.path); }); - // Add new addresses to state with loading state - // PATH is the primary key - index and isChange are for display only - const newStates: AddressWithNametag[] = newAddresses.map((addr) => { - return { - address: addr.address, - path: addr.path!, // PATH is the primary key - index: addr.index, // For display only - isChange: addr.isChange, // For display only - ipnsLoading: true, - hasNametag: false, - nametag: undefined, - firstFetchTime: Date.now(), - }; - }); + // Initialize addresses and fetch nametags + const initializeAndFetch = async () => { + // Add new addresses to state - check local storage first before marking as loading + // PATH is the primary key - index and isChange are for display only + const newStates: AddressWithNametag[] = await Promise.all(newAddresses.map(async (addr) => { + // Check local storage first via L3 identity + try { + const identityManager = IdentityManager.getInstance(SESSION_KEY); + const l3Identity = await identityManager.deriveIdentityFromPath(addr.path!); + const localNametag = WalletRepository.checkNametagForAddress(l3Identity.address); + const localTokens = WalletRepository.checkTokensForAddress(l3Identity.address); + + if (localNametag) { + console.log(`🔍 [L1] Found local nametag for ${addr.address.slice(0, 12)}...: ${localNametag.name}`); + return { + address: addr.address, + path: addr.path!, + index: addr.index, + isChange: addr.isChange, + ipnsLoading: false, // No need to fetch - already have it locally + hasNametag: true, + nametag: localNametag.name, + l3Address: l3Identity.address, + hasL3Inventory: true, + firstFetchTime: Date.now(), + }; + } + + // Has tokens but no nametag - still need to check IPNS but mark inventory + if (localTokens) { + return { + address: addr.address, + path: addr.path!, + index: addr.index, + isChange: addr.isChange, + ipnsLoading: true, + hasNametag: false, + nametag: undefined, + l3Address: l3Identity.address, + hasL3Inventory: true, + firstFetchTime: Date.now(), + }; + } + } catch (error) { + console.warn(`[L1] Error checking local nametag for ${addr.address.slice(0, 12)}...`, error); + } + + // Default: need to fetch from IPNS + return { + address: addr.address, + path: addr.path!, + index: addr.index, + isChange: addr.isChange, + ipnsLoading: true, + hasNametag: false, + nametag: undefined, + firstFetchTime: Date.now(), + }; + })); - setAddressesWithNametags(prev => [...prev, ...newStates]); + if (!mountedRef.current) return; - // Fetch nametags using PATH as the identifier - const fetchNewAddresses = async () => { - for (const addr of newAddresses) { + setAddressesWithNametags(prev => [...prev, ...newStates]); + + // Filter addresses that need IPNS fetching (those still loading) + const addressesNeedingFetch = newStates.filter(s => s.ipnsLoading); + + // Fetch nametags using PATH as the identifier + for (const state of addressesNeedingFetch) { if (!mountedRef.current) return; - if (!addr.path) continue; + + const addr = newAddresses.find(a => a.path === state.path); + if (!addr?.path) continue; // Skip if already fetching (use path as key) if (fetchInProgressRef.current.has(addr.path)) continue; @@ -182,7 +233,7 @@ export function useAddressNametags(addresses: WalletAddress[] | undefined) { } }; - fetchNewAddresses(); + initializeAndFetch(); }, [addresses, fetchSingleNametag]); // Continuous polling for addresses without nametags diff --git a/src/components/wallet/L1/modals/L1WalletModal.tsx b/src/components/wallet/L1/modals/L1WalletModal.tsx index 0d1b55272..c98f54576 100644 --- a/src/components/wallet/L1/modals/L1WalletModal.tsx +++ b/src/components/wallet/L1/modals/L1WalletModal.tsx @@ -84,7 +84,10 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr } = useL1Wallet(selectedAddress); const addresses = wallet?.addresses.map((a) => a.address) ?? []; - const { nametagState } = useAddressNametags(wallet?.addresses); + const { nametagState, addressesWithNametags } = useAddressNametags(wallet?.addresses); + + // Check if any address is still loading nametag from IPNS + const isAnyAddressLoading = addressesWithNametags.some(addr => addr.ipnsLoading); const showMessage = useCallback((type: MessageType, title: string, message: string, txids?: string[]) => { setMessageModal({ show: true, type, title, message, txids }); @@ -133,7 +136,7 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr }, [isOpen]); const onNewAddress = async () => { - if (!wallet) return; + if (!wallet || isAnyAddressLoading) return; try { const addr = generateAddress(wallet); const updated = loadWalletFromStorage("main"); @@ -315,7 +318,10 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr {/* Active Address */}
-

Active Address

+

+ {isAnyAddressLoading && } + {isAnyAddressLoading ? 'Checking nametags...' : 'Active Address'} +

diff --git a/src/components/wallet/WalletPanel.tsx b/src/components/wallet/WalletPanel.tsx index be4a3450c..9376345de 100644 --- a/src/components/wallet/WalletPanel.tsx +++ b/src/components/wallet/WalletPanel.tsx @@ -29,7 +29,7 @@ export function WalletPanel() {
{/* TOP BAR: Title & Actions */} -
+
{/* CONTENT AREA - L3 Only */} -
+
!a.ipnsLoading || a.fromL1Wallet + (a) => (!a.ipnsLoading && !a.balanceLoading) || a.fromL1Wallet ); const selectedAddress = visibleAddresses.find((a) => a.path === selectedAddressPath) || @@ -106,7 +110,7 @@ export function AddressSelectionScreen({
- Checking for nametags... + Checking addresses...
) : ( @@ -127,6 +131,15 @@ export function AddressSelectionScreen({ Change )} + {/* Balance indicator */} + {selectedAddress?.balanceLoading ? ( + ... + ) : selectedAddress?.l1Balance && selectedAddress.l1Balance > 0 ? ( + + {selectedAddress.l1Balance.toFixed(2)} ALPHA + + ) : null} + {/* Nametag or loading indicator */} {selectedAddress?.ipnsLoading ? ( ) : selectedAddress?.hasNametag ? ( @@ -182,6 +195,15 @@ export function AddressSelectionScreen({ Change )} + {/* Balance indicator */} + {addr.balanceLoading ? ( + ... + ) : addr.l1Balance && addr.l1Balance > 0 ? ( + + {addr.l1Balance.toFixed(2)} ALPHA + + ) : null} + {/* Nametag or loading indicator */} {addr.ipnsLoading ? ( ) : addr.hasNametag ? ( @@ -197,12 +219,12 @@ export function AddressSelectionScreen({ ))}
- {/* Loading indicator while IPNS is checking, or Derive New Address button */} + {/* Loading indicator while checking, or Derive New Address button */} {isCheckingIpns || - derivedAddresses.some((a) => a.ipnsLoading) ? ( + derivedAddresses.some((a) => a.ipnsLoading || a.balanceLoading) ? (
- Checking for nametags... + Checking addresses...
) : ( + ); + })} +
+
+ +
+ + setDestination(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + className="w-full bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-white/10 rounded-xl py-3 px-4 text-neutral-900 dark:text-white focus:border-green-500 outline-none font-mono text-sm" + placeholder="Enter wallet address" + /> +
-
-
- - - Available:{" "} - - {(Number(vestingState.getBalance(selectedAddress)) / 1e8).toFixed(8)} ALPHA - - -
-
- setAmount(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSend()} - className="w-full bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-white/10 rounded-xl py-3 px-4 pr-32 text-neutral-900 dark:text-white text-2xl font-mono focus:border-green-500 outline-none" - placeholder="0.00" - /> -
- - - -
-
- {error &&

{error}

} +
+
+ + + Available:{" "} + + {(Number(getCurrentBalance()) / 1e8).toFixed(8)} ALPHA + + +
+
+ setAmount(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + className="w-full bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-white/10 rounded-xl py-3 px-4 pr-32 text-neutral-900 dark:text-white text-2xl font-mono focus:border-green-500 outline-none" + placeholder="0.00" + /> +
+ + +
+
+ {error &&

{error}

} +
- {/* Vesting Selector */} + {/* Vesting Display */}
-
From 1cbd85060987bfbf4642ce7e749703c4dc87b929 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Wed, 7 Jan 2026 18:40:56 +0200 Subject: [PATCH 08/17] fix(wallet): show correct L1 balance for selected address in L3 view --- src/components/wallet/L1/hooks/useL1Wallet.ts | 43 +++++++++++++------ .../wallet/L3/views/L3WalletView.tsx | 12 +++--- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/components/wallet/L1/hooks/useL1Wallet.ts b/src/components/wallet/L1/hooks/useL1Wallet.ts index 3a18cd0dd..25b4676be 100644 --- a/src/components/wallet/L1/hooks/useL1Wallet.ts +++ b/src/components/wallet/L1/hooks/useL1Wallet.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useMemo } from "react"; import { importWallet, exportWallet, @@ -26,6 +26,7 @@ import { loadWalletFromUnifiedKeyManager, getUnifiedKeyManager } from "../sdk/un import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; import { WalletRepository } from "../../../../repositories/WalletRepository"; import { KEYS as L3_KEYS } from "../../L3/hooks/useWallet"; +import { STORAGE_KEYS } from "../../../../config/storageKeys"; // Query keys for L1 wallet export const L1_KEYS = { @@ -38,8 +39,36 @@ export const L1_KEYS = { }; -export function useL1Wallet(selectedAddress?: string) { +export function useL1Wallet(selectedAddressProp?: string) { const queryClient = useQueryClient(); + + // Query: Wallet from UnifiedKeyManager + const walletQuery = useQuery({ + queryKey: L1_KEYS.WALLET, + queryFn: async () => { + const wallet = await loadWalletFromUnifiedKeyManager(); + return wallet; + }, + staleTime: Infinity, // Wallet doesn't change unless we mutate it + }); + + // Compute selected address: use prop if provided, else derive from localStorage path + const selectedAddress = useMemo(() => { + if (selectedAddressProp) return selectedAddressProp; + + const wallet = walletQuery.data; + if (!wallet || wallet.addresses.length === 0) return ""; + + const storedPath = localStorage.getItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); + if (storedPath) { + const addrFromPath = wallet.addresses.find(a => a.path === storedPath); + if (addrFromPath) return addrFromPath.address; + } + + // Default to first address + return wallet.addresses[0]?.address || ""; + }, [selectedAddressProp, walletQuery.data]); + const selectedAddressRef = useRef(selectedAddress || ""); // Update ref when address changes @@ -93,16 +122,6 @@ export function useL1Wallet(selectedAddress?: string) { }; }, [queryClient]); - // Query: Wallet from UnifiedKeyManager - const walletQuery = useQuery({ - queryKey: L1_KEYS.WALLET, - queryFn: async () => { - const wallet = await loadWalletFromUnifiedKeyManager(); - return wallet; - }, - staleTime: Infinity, // Wallet doesn't change unless we mutate it - }); - // Query: Balance for selected address const balanceQuery = useQuery({ queryKey: L1_KEYS.BALANCE(selectedAddress || ""), diff --git a/src/components/wallet/L3/views/L3WalletView.tsx b/src/components/wallet/L3/views/L3WalletView.tsx index fe200aec8..f299bca1f 100644 --- a/src/components/wallet/L3/views/L3WalletView.tsx +++ b/src/components/wallet/L3/views/L3WalletView.tsx @@ -49,7 +49,7 @@ export function L3WalletView({ const navigate = useNavigate(); const { identity, assets, tokens, isLoadingAssets, isLoadingIdentity, nametag, getSeedPhrase } = useWallet(); const { exportTxf, importTxf, isExportingTxf, isImportingTxf, isSyncing, isEnabled: isIpfsEnabled } = useIpfsStorage(); - const { totalBalance: l1TotalBalance, deleteWallet } = useL1Wallet(); + const { balance: l1Balance, deleteWallet } = useL1Wallet(); const [activeTab, setActiveTab] = useState('assets'); const [isSendModalOpen, setIsSendModalOpen] = useState(false); @@ -88,7 +88,7 @@ export function L3WalletView({ // Create L1 ALPHA asset const l1AlphaAsset = useMemo(() => { // Convert ALPHA balance to satoshis (8 decimals) - const satoshis = BigInt(Math.round(l1TotalBalance * 100000000)); + const satoshis = BigInt(Math.round(l1Balance * 100000000)); return new AggregatedAsset({ coinId: 'l1-alpha', symbol: 'ALPHA', @@ -101,7 +101,7 @@ export function L3WalletView({ priceEur: 0.92, change24h: 0, }); - }, [l1TotalBalance]); + }, [l1Balance]); const totalValue = useMemo(() => { const l3Value = assets.reduce((sum, asset) => sum + asset.getTotalFiatValue('USD'), 0); @@ -231,7 +231,7 @@ export function L3WalletView({ // Format L1 balance for settings modal const formatL1Balance = (balance: number) => { - return balance.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return balance.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 }); }; if (isLoadingIdentity) { @@ -451,7 +451,7 @@ export function L3WalletView({ transition={{ duration: 0.2 }} className="space-y-2" > - {assets.length === 0 && l1TotalBalance === 0 ? ( + {assets.length === 0 && l1Balance === 0 ? ( ) : ( <> @@ -537,7 +537,7 @@ export function L3WalletView({ onOpenL1Wallet={() => setIsL1WalletOpen(true)} onBackupWallet={() => setIsBackupOpen(true)} onLogout={() => setIsLogoutConfirmOpen(true)} - l1Balance={formatL1Balance(l1TotalBalance)} + l1Balance={formatL1Balance(l1Balance)} /> Date: Thu, 8 Jan 2026 12:05:46 +0200 Subject: [PATCH 09/17] fix(wallet): fix vesting infinite loading and improve transaction feedback --- .../wallet/L1/components/VestingDisplay.tsx | 51 +++---------------- .../modals/TransactionConfirmationModal.tsx | 12 ++++- src/components/wallet/L1/hooks/useL1Wallet.ts | 8 ++- .../wallet/L1/views/L1WalletView.tsx | 27 +++++----- .../wallet/L1/views/MainWalletView.tsx | 3 -- 5 files changed, 37 insertions(+), 64 deletions(-) diff --git a/src/components/wallet/L1/components/VestingDisplay.tsx b/src/components/wallet/L1/components/VestingDisplay.tsx index 9b658de44..f6e4068c5 100644 --- a/src/components/wallet/L1/components/VestingDisplay.tsx +++ b/src/components/wallet/L1/components/VestingDisplay.tsx @@ -1,30 +1,13 @@ -import { motion } from "framer-motion"; -import { Loader2 } from "lucide-react"; import type { VestingBalances } from "../sdk/types"; -// Skeleton shimmer component -function BalanceSkeleton() { - return ( -
- -
- ); -} - interface VestingDisplayProps { showBalances?: boolean; balances?: VestingBalances; - isClassifying?: boolean; } export function VestingDisplay({ showBalances = true, balances, - isClassifying = false, }: VestingDisplayProps) { const formatBalance = (satoshis: bigint): string => { const alpha = Number(satoshis) / 100000000; @@ -38,20 +21,11 @@ export function VestingDisplay({
{/* Vested */}
-
- Vested - {isClassifying && ( - - )} -
+ Vested {showBalances ? ( - isClassifying ? ( - - ) : ( - - {formatBalance(vestedBalance)} - - ) + + {formatBalance(vestedBalance)} + ) : ( •••••• )} @@ -59,20 +33,11 @@ export function VestingDisplay({ {/* Unvested */}
-
- Unvested - {isClassifying && ( - - )} -
+ Unvested {showBalances ? ( - isClassifying ? ( - - ) : ( - - {formatBalance(unvestedBalance)} - - ) + + {formatBalance(unvestedBalance)} + ) : ( •••••• )} diff --git a/src/components/wallet/L1/components/modals/TransactionConfirmationModal.tsx b/src/components/wallet/L1/components/modals/TransactionConfirmationModal.tsx index ce1f1a131..a2d80ea22 100644 --- a/src/components/wallet/L1/components/modals/TransactionConfirmationModal.tsx +++ b/src/components/wallet/L1/components/modals/TransactionConfirmationModal.tsx @@ -1,3 +1,4 @@ +import { Loader2 } from "lucide-react"; import type { TransactionPlan } from "../../sdk"; interface TransactionConfirmationModalProps { @@ -61,10 +62,17 @@ export function TransactionConfirmationModal({
diff --git a/src/components/wallet/L1/hooks/useL1Wallet.ts b/src/components/wallet/L1/hooks/useL1Wallet.ts index 25b4676be..4c7d492b9 100644 --- a/src/components/wallet/L1/hooks/useL1Wallet.ts +++ b/src/components/wallet/L1/hooks/useL1Wallet.ts @@ -366,14 +366,20 @@ export function useL1Wallet(selectedAddressProp?: string) { return results; }, onSuccess: () => { - // Invalidate balance and transactions after sending + // Invalidate balance, transactions and vesting after sending if (selectedAddress) { + // Clear vestingState cache first (UTXOs changed) + vestingState.clearAddressCache(selectedAddress); + queryClient.invalidateQueries({ queryKey: L1_KEYS.BALANCE(selectedAddress), }); queryClient.invalidateQueries({ queryKey: L1_KEYS.TRANSACTIONS(selectedAddress), }); + queryClient.invalidateQueries({ + queryKey: L1_KEYS.VESTING(selectedAddress), + }); } }, }); diff --git a/src/components/wallet/L1/views/L1WalletView.tsx b/src/components/wallet/L1/views/L1WalletView.tsx index ed2609d44..8e6b36b0e 100644 --- a/src/components/wallet/L1/views/L1WalletView.tsx +++ b/src/components/wallet/L1/views/L1WalletView.tsx @@ -8,7 +8,7 @@ import { createTransactionPlan, createAndSignTransaction, broadcast, - type VestingMode, + vestingState, type TransactionPlan, } from "../sdk"; import { useL1Wallet } from "../hooks"; @@ -45,11 +45,12 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { isLoadingTransactions, currentBlockHeight, vestingBalances, - isLoadingVesting, deleteWallet, analyzeTransaction, - setVestingMode, invalidateWallet, + invalidateBalance, + invalidateTransactions, + invalidateVesting, } = useL1Wallet(selectedAddress); // Derive addresses from wallet @@ -100,16 +101,6 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { } }, [selectedAddress, wallet]); - // Vesting progress for UI - show loading only on initial load, not on refetch - const vestingProgress = isLoadingVesting - ? { current: 0, total: 1 } - : null; - - // Handle vesting mode change - const handleVestingModeChange = useCallback((mode: VestingMode) => { - setVestingMode(mode); - }, [setVestingMode]); - // Delete wallet const onDeleteWallet = async () => { try { @@ -211,6 +202,14 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { setTxPlan(null); + // Invalidate queries to refresh data after sending + if (results.length > 0) { + vestingState.clearAddressCache(selectedAddress); + invalidateBalance(); + invalidateTransactions(); + invalidateVesting(); + } + if (errors.length > 0) { if (results.length > 0) { const txids = results.map((r) => r.txid); @@ -333,8 +332,6 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { txPlan={txPlan} isSending={isSending} onConfirmSend={onConfirmSend} - vestingProgress={vestingProgress} - onVestingModeChange={handleVestingModeChange} vestingBalances={vestingBalances} /> Promise; - vestingProgress?: { current: number; total: number } | null; vestingBalances?: VestingBalances; } @@ -114,7 +113,6 @@ export function MainWalletView({ txPlan, isSending, onConfirmSend, - vestingProgress, vestingBalances, }: MainWalletViewProps) { const [showQR, setShowQR] = useState(false); @@ -410,7 +408,6 @@ export function MainWalletView({
From c4be70f6b4e4f460d729132fcb0f66bd608bce2d Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Thu, 8 Jan 2026 12:28:14 +0200 Subject: [PATCH 10/17] fix(wallet): fix L1 transaction amount calculation --- .../wallet/L1/components/VestingDisplay.tsx | 13 ++++- src/components/wallet/L1/sdk/tx.ts | 54 +++++++++++++++---- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/components/wallet/L1/components/VestingDisplay.tsx b/src/components/wallet/L1/components/VestingDisplay.tsx index f6e4068c5..02ea3c5cf 100644 --- a/src/components/wallet/L1/components/VestingDisplay.tsx +++ b/src/components/wallet/L1/components/VestingDisplay.tsx @@ -1,13 +1,16 @@ +import { Loader2 } from "lucide-react"; import type { VestingBalances } from "../sdk/types"; interface VestingDisplayProps { showBalances?: boolean; balances?: VestingBalances; + isClassifying?: boolean; } export function VestingDisplay({ showBalances = true, balances, + isClassifying = false, }: VestingDisplayProps) { const formatBalance = (satoshis: bigint): string => { const alpha = Number(satoshis) / 100000000; @@ -21,7 +24,10 @@ export function VestingDisplay({
{/* Vested */}
- Vested + + Vested + {isClassifying && } + {showBalances ? ( {formatBalance(vestedBalance)} @@ -33,7 +39,10 @@ export function VestingDisplay({ {/* Unvested */}
- Unvested + + Unvested + {isClassifying && } + {showBalances ? ( {formatBalance(unvestedBalance)} diff --git a/src/components/wallet/L1/sdk/tx.ts b/src/components/wallet/L1/sdk/tx.ts index a5001249e..ff1e62211 100644 --- a/src/components/wallet/L1/sdk/tx.ts +++ b/src/components/wallet/L1/sdk/tx.ts @@ -308,6 +308,9 @@ export function createAndSignTransaction( /** * Collect UTXOs for required amount * Based on index.html collectUtxosForAmount() + * + * Strategy: First try to find a single UTXO that can cover amount + fee. + * If not found, fall back to combining multiple UTXOs. */ export function collectUtxosForAmount( utxoList: UTXO[], @@ -315,23 +318,55 @@ export function collectUtxosForAmount( recipientAddress: string, senderAddress: string ): TransactionPlan { - // Sort UTXOs by value (ascending) - const sortedUtxos = [...utxoList].sort((a, b) => a.value - b.value); + const totalAvailable = utxoList.reduce((sum, u) => sum + u.value, 0); - const totalAvailable = sortedUtxos.reduce((sum, u) => sum + u.value, 0); - - if (totalAvailable < amountSats) { + if (totalAvailable < amountSats + FEE) { return { success: false, transactions: [], - error: `Insufficient funds. Available: ${totalAvailable / SAT} ALPHA, Required: ${amountSats / SAT} ALPHA`, + error: `Insufficient funds. Available: ${totalAvailable / SAT} ALPHA, Required: ${(amountSats + FEE) / SAT} ALPHA (including fee)`, }; } + // Strategy 1: Find a single UTXO that covers amount + fee + // Sort by value ascending to find the smallest sufficient UTXO + const sortedByValue = [...utxoList].sort((a, b) => a.value - b.value); + const sufficientUtxo = sortedByValue.find(u => u.value >= amountSats + FEE); + + if (sufficientUtxo) { + const changeAmount = sufficientUtxo.value - amountSats - FEE; + const tx: Transaction = { + input: { + txid: sufficientUtxo.txid ?? sufficientUtxo.tx_hash ?? "", + vout: sufficientUtxo.vout ?? sufficientUtxo.tx_pos ?? 0, + value: sufficientUtxo.value, + address: sufficientUtxo.address ?? senderAddress, + }, + outputs: [{ address: recipientAddress, value: amountSats }], + fee: FEE, + changeAmount: changeAmount, + changeAddress: senderAddress, + }; + + // Add change output if above dust + if (changeAmount > DUST) { + tx.outputs.push({ value: changeAmount, address: senderAddress }); + } + + return { + success: true, + transactions: [tx], + }; + } + + // Strategy 2: No single UTXO is sufficient, combine multiple UTXOs + // Sort descending to use larger UTXOs first (fewer transactions) + const sortedDescending = [...utxoList].sort((a, b) => b.value - a.value); + const transactions: Transaction[] = []; let remainingAmount = amountSats; - for (const utxo of sortedUtxos) { + for (const utxo of sortedDescending) { if (remainingAmount <= 0) break; const utxoValue = utxo.value; @@ -339,16 +374,15 @@ export function collectUtxosForAmount( let changeAmount = 0; if (utxoValue >= remainingAmount + FEE) { - // Covers remaining + fee + // This UTXO covers the remaining amount plus fee txAmount = remainingAmount; changeAmount = utxoValue - remainingAmount - FEE; remainingAmount = 0; } else { // Use entire UTXO minus fee txAmount = utxoValue - FEE; + if (txAmount <= 0) continue; // Skip UTXOs too small to cover fee remainingAmount -= txAmount; - - if (txAmount <= 0) continue; } const tx: Transaction = { From a575e8d40da75d61f26c2e0476e7e0ef044b67a0 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Thu, 8 Jan 2026 13:11:52 +0200 Subject: [PATCH 11/17] feat(wallet): add connection status UI for L1 wallet --- .../wallet/L1/components/ConnectionStatus.tsx | 168 ++++++++++++++++++ src/components/wallet/L1/hooks/index.ts | 2 + .../wallet/L1/hooks/useConnectionStatus.ts | 115 ++++++++++++ .../wallet/L1/modals/L1WalletModal.tsx | 36 ++-- .../wallet/L1/views/L1WalletView.tsx | 42 ++--- 5 files changed, 314 insertions(+), 49 deletions(-) create mode 100644 src/components/wallet/L1/components/ConnectionStatus.tsx create mode 100644 src/components/wallet/L1/hooks/useConnectionStatus.ts diff --git a/src/components/wallet/L1/components/ConnectionStatus.tsx b/src/components/wallet/L1/components/ConnectionStatus.tsx new file mode 100644 index 000000000..3d6177323 --- /dev/null +++ b/src/components/wallet/L1/components/ConnectionStatus.tsx @@ -0,0 +1,168 @@ +import { motion } from "framer-motion"; +import { Loader2, WifiOff, RefreshCw, XCircle } from "lucide-react"; +import type { ConnectionState } from "../hooks/useConnectionStatus"; + +interface ConnectionStatusProps { + state: ConnectionState; + message: string; + error?: string; + onRetry: () => void; + onCancel: () => void; +} + +export function ConnectionStatus({ + state, + message, + error, + onRetry, + onCancel, +}: ConnectionStatusProps) { + // Don't show anything when connected + if (state === "connected") { + return null; + } + + const isConnecting = state === "connecting"; + const isError = state === "error"; + + return ( +
+ + {/* Animated spinner like logout/onboarding */} + {isConnecting ? ( +
+ {/* Outer Ring */} + + {/* Middle Ring */} + + {/* Inner Glow */} +
+ {/* Center Icon */} +
+ + + +
+
+ ) : isError ? ( + + + + ) : ( + + + + )} + + {/* Status Title */} +

+ {state === "connecting" && "Connecting to Blockchain"} + {state === "error" && "Connection Failed"} + {state === "disconnected" && "Disconnected"} +

+ + {/* Status Message */} +

+ {message} +

+ + {/* Status message with pulsing dot for connecting state */} + {isConnecting && ( + + + Establishing connection... + + )} + + {/* Error details */} + {error && isError && ( +
+

+ {error} +

+
+ )} + + {/* Action buttons */} +
+ {(isError || state === "disconnected") && ( + + + Retry Connection + + )} + + {isConnecting && ( + + + Cancel + + )} +
+ + {/* Server info */} +

+ Fulcrum Server: fulcrum.unicity.network:50004 +

+
+
+ ); +} diff --git a/src/components/wallet/L1/hooks/index.ts b/src/components/wallet/L1/hooks/index.ts index f30811581..4f5a7fb4d 100644 --- a/src/components/wallet/L1/hooks/index.ts +++ b/src/components/wallet/L1/hooks/index.ts @@ -3,3 +3,5 @@ export { useTransactions } from "./useTransactions"; export { useBalance } from "./useBalance"; export { useL1Wallet, L1_KEYS } from "./useL1Wallet"; export { useAddressNametags } from "./useAddressNametags"; +export { useConnectionStatus } from "./useConnectionStatus"; +export type { ConnectionState, ConnectionStatus } from "./useConnectionStatus"; diff --git a/src/components/wallet/L1/hooks/useConnectionStatus.ts b/src/components/wallet/L1/hooks/useConnectionStatus.ts new file mode 100644 index 000000000..781c79395 --- /dev/null +++ b/src/components/wallet/L1/hooks/useConnectionStatus.ts @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { connect, isWebSocketConnected, disconnect } from "../sdk/network"; + +export type ConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "error"; + +export interface ConnectionStatus { + state: ConnectionState; + message: string; + error?: string; +} + +export function useConnectionStatus() { + const [status, setStatus] = useState(() => ({ + state: isWebSocketConnected() ? "connected" : "disconnected", + message: isWebSocketConnected() ? "Connected to Fulcrum" : "Not connected", + })); + + const isMountedRef = useRef(true); + const isConnectingRef = useRef(false); + + const attemptConnect = useCallback(async () => { + if (!isMountedRef.current || isConnectingRef.current) return; + + isConnectingRef.current = true; + + setStatus({ + state: "connecting", + message: "Connecting to Fulcrum server...", + }); + + try { + // network.ts connect() has its own reconnection logic with exponential backoff + // MAX_RECONNECT_ATTEMPTS = 10, BASE_DELAY = 2000ms, MAX_DELAY = 60000ms + await connect(); + + if (!isMountedRef.current) return; + + setStatus({ + state: "connected", + message: "Connected to Fulcrum", + }); + } catch (err) { + if (!isMountedRef.current) return; + + const errorMessage = err instanceof Error ? err.message : "Connection failed"; + + setStatus({ + state: "error", + message: "Connection failed after multiple attempts", + error: errorMessage, + }); + } finally { + isConnectingRef.current = false; + } + }, []); + + const manualConnect = useCallback(() => { + attemptConnect(); + }, [attemptConnect]); + + const cancelConnect = useCallback(() => { + disconnect(); + setStatus({ + state: "disconnected", + message: "Connection cancelled", + }); + }, []); + + // Initial connection on mount + useEffect(() => { + isMountedRef.current = true; + + if (!isWebSocketConnected()) { + attemptConnect(); + } + + return () => { + isMountedRef.current = false; + }; + }, [attemptConnect]); + + // Monitor connection state changes + useEffect(() => { + const checkConnection = () => { + if (!isMountedRef.current) return; + + const connected = isWebSocketConnected(); + + if (connected && status.state !== "connected") { + setStatus({ + state: "connected", + message: "Connected to Fulcrum", + }); + } else if (!connected && status.state === "connected") { + // Connection was lost, start reconnecting + attemptConnect(); + } + }; + + const interval = setInterval(checkConnection, 2000); + return () => clearInterval(interval); + }, [status.state, attemptConnect]); + + return { + ...status, + isConnected: status.state === "connected", + isConnecting: status.state === "connecting", + manualConnect, + cancelConnect, + }; +} diff --git a/src/components/wallet/L1/modals/L1WalletModal.tsx b/src/components/wallet/L1/modals/L1WalletModal.tsx index e31211add..b1571a21c 100644 --- a/src/components/wallet/L1/modals/L1WalletModal.tsx +++ b/src/components/wallet/L1/modals/L1WalletModal.tsx @@ -15,8 +15,6 @@ import { } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { - connect, - isWebSocketConnected, generateAddress, loadWalletFromStorage, createTransactionPlan, @@ -24,8 +22,9 @@ import { broadcast, type TransactionPlan, } from "../sdk"; -import { useL1Wallet } from "../hooks"; +import { useL1Wallet, useConnectionStatus } from "../hooks"; import { useAddressNametags } from "../hooks/useAddressNametags"; +import { ConnectionStatus } from "../components/ConnectionStatus"; import { WalletRepository } from "../../../../repositories/WalletRepository"; import { STORAGE_KEYS } from "../../../../config/storageKeys"; import { @@ -49,8 +48,10 @@ type ViewMode = "main" | "history"; export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalProps) { const [selectedAddress, setSelectedAddress] = useState(""); const [viewMode, setViewMode] = useState("main"); - const [isConnecting, setIsConnecting] = useState(() => !isWebSocketConnected()); const [txPlan, setTxPlan] = useState(null); + + // Connection status hook + const connection = useConnectionStatus(); const [isSending, setIsSending] = useState(false); const [showDropdown, setShowDropdown] = useState(false); const [copied, setCopied] = useState(false); @@ -97,23 +98,6 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr setMessageModal((prev) => ({ ...prev, show: false })); }, []); - // Connect on modal open - useEffect(() => { - if (!isOpen) return; - if (isWebSocketConnected()) { - setIsConnecting(false); - return; - } - (async () => { - try { - setIsConnecting(true); - await connect(); - } finally { - setIsConnecting(false); - } - })(); - }, [isOpen]); - // Set initial selected address useEffect(() => { if (wallet && wallet.addresses.length > 0) { @@ -279,7 +263,15 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr {/* Content */}
- {isConnecting || isLoadingWallet ? ( + {!connection.isConnected ? ( + + ) : isLoadingWallet ? (
diff --git a/src/components/wallet/L1/views/L1WalletView.tsx b/src/components/wallet/L1/views/L1WalletView.tsx index 8e6b36b0e..f98474df9 100644 --- a/src/components/wallet/L1/views/L1WalletView.tsx +++ b/src/components/wallet/L1/views/L1WalletView.tsx @@ -1,8 +1,5 @@ import { useEffect, useState, useCallback } from "react"; -import { Loader2 } from "lucide-react"; import { - connect, - isWebSocketConnected, generateAddress, loadWalletFromStorage, createTransactionPlan, @@ -11,9 +8,10 @@ import { vestingState, type TransactionPlan, } from "../sdk"; -import { useL1Wallet } from "../hooks"; +import { useL1Wallet, useConnectionStatus } from "../hooks"; import { HistoryView, MainWalletView } from "."; import { MessageModal, type MessageType } from "../components/modals/MessageModal"; +import { ConnectionStatus } from "../components/ConnectionStatus"; import { WalletRepository } from "../../../../repositories/WalletRepository"; import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; import { STORAGE_KEYS } from "../../../../config/storageKeys"; @@ -23,7 +21,6 @@ type ViewMode = "main" | "history"; export function L1WalletView({ showBalances }: { showBalances: boolean }) { const [selectedAddress, setSelectedAddress] = useState(""); const [viewMode, setViewMode] = useState("main"); - const [isConnecting, setIsConnecting] = useState(() => !isWebSocketConnected()); const [txPlan, setTxPlan] = useState(null); const [isSending, setIsSending] = useState(false); const [messageModal, setMessageModal] = useState<{ @@ -34,6 +31,9 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { txids?: string[]; }>({ show: false, type: "info", title: "", message: "" }); + // Connection status hook + const connection = useConnectionStatus(); + // Use TanStack Query based hook const { wallet, @@ -65,22 +65,6 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { setMessageModal((prev) => ({ ...prev, show: false })); }, []); - // Connect on mount (skip if already connected) - useEffect(() => { - if (isWebSocketConnected()) { - setIsConnecting(false); - return; - } - (async () => { - try { - setIsConnecting(true); - await connect(); - } finally { - setIsConnecting(false); - } - })(); - }, []); - // Set initial selected address when wallet loads - sync with L3's stored path useEffect(() => { if (wallet && wallet.addresses.length > 0) { @@ -266,17 +250,21 @@ export function L1WalletView({ showBalances }: { showBalances: boolean }) { window.location.reload(); }; - // Show loading state while connecting - if (isConnecting || isLoadingWallet) { + // Show connection status while connecting or on error + if (!connection.isConnected) { return ( -
- -
+ ); } // No wallet - return null, WalletGate handles the onboarding flow - if (!wallet) { + if (!wallet || isLoadingWallet) { return null; } From 7b494fe8273d534c73189da0bfcf929f35755655 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Thu, 8 Jan 2026 13:17:07 +0200 Subject: [PATCH 12/17] fix(tests): fix flaky 30-day tombstone boundary test --- .../agents/shared/ChatHistoryIpfsService.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts b/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts index 560ab17f0..a006ee915 100644 --- a/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts +++ b/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts @@ -227,14 +227,16 @@ describe("ChatHistoryIpfsService", () => { consoleSpy.mockRestore(); }); - it("should keep tombstones exactly at 30 days", () => { + it("should keep tombstones under 30 days old", () => { const now = Date.now(); - const exactlyThirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; + // Use 29 days + 23 hours to safely stay under 30 day threshold + // This avoids flaky tests from millisecond timing differences + const justUnderThirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000); localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ "boundary-session": { sessionId: "boundary-session", - deletedAt: exactlyThirtyDaysAgo, + deletedAt: justUnderThirtyDaysAgo, reason: "user-deleted", }, }); From e5e022f6a990c4a4fbe6eb0ea8ba0287323afa50 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Thu, 8 Jan 2026 14:22:00 +0200 Subject: [PATCH 13/17] feat(onboarding): add L1 connection status screen before wallet setup --- .../wallet/onboarding/CreateWalletFlow.tsx | 21 ++++ .../wallet/onboarding/hooks/index.ts | 1 + .../hooks/useOnboardingConnectionStatus.ts | 118 ++++++++++++++++++ .../onboarding/hooks/useOnboardingFlow.ts | 11 -- 4 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts diff --git a/src/components/wallet/onboarding/CreateWalletFlow.tsx b/src/components/wallet/onboarding/CreateWalletFlow.tsx index d592b2d62..c2a5682ca 100644 --- a/src/components/wallet/onboarding/CreateWalletFlow.tsx +++ b/src/components/wallet/onboarding/CreateWalletFlow.tsx @@ -5,8 +5,10 @@ import { AnimatePresence } from "framer-motion"; import { useOnboardingFlow } from "./hooks/useOnboardingFlow"; import { useWalletImport } from "./hooks/useWalletImport"; +import { useOnboardingConnectionStatus } from "./hooks/useOnboardingConnectionStatus"; import { WalletScanModal } from "../L1/components/modals/WalletScanModal"; import { LoadPasswordModal } from "../L1/components/modals/LoadPasswordModal"; +import { ConnectionStatus } from "../L1/components/ConnectionStatus"; // Import screen components import { @@ -22,6 +24,9 @@ import { export type { OnboardingStep } from "./hooks/useOnboardingFlow"; export function CreateWalletFlow() { + // L1 connection status hook + const connection = useOnboardingConnectionStatus(); + // Main onboarding flow hook const { // Step management @@ -75,6 +80,22 @@ export function CreateWalletFlow() { setIsBusy, }); + // Show connection status immediately on start screen if not connected + // L1 connection is needed for wallet operations (scanning, balance checks, etc.) + if (!connection.isConnected && step === "start") { + return ( +
+ +
+ ); + } + return (
diff --git a/src/components/wallet/onboarding/hooks/index.ts b/src/components/wallet/onboarding/hooks/index.ts index fdc30968d..6041700e3 100644 --- a/src/components/wallet/onboarding/hooks/index.ts +++ b/src/components/wallet/onboarding/hooks/index.ts @@ -3,3 +3,4 @@ */ export { useOnboardingFlow, type OnboardingStep, type UseOnboardingFlowReturn } from "./useOnboardingFlow"; export { useWalletImport, type UseWalletImportReturn } from "./useWalletImport"; +export { useOnboardingConnectionStatus, type ConnectionState, type ConnectionStatus } from "./useOnboardingConnectionStatus"; diff --git a/src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts b/src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts new file mode 100644 index 000000000..566c6e227 --- /dev/null +++ b/src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts @@ -0,0 +1,118 @@ +/** + * useOnboardingConnectionStatus - Manages L1 WebSocket connection for onboarding + * Similar to L1's useConnectionStatus but simplified for onboarding needs + */ +import { useState, useEffect, useCallback, useRef } from "react"; +import { connect, isWebSocketConnected, disconnect } from "../../L1/sdk/network"; + +export type ConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "error"; + +export interface ConnectionStatus { + state: ConnectionState; + message: string; + error?: string; +} + +export function useOnboardingConnectionStatus() { + const [status, setStatus] = useState(() => ({ + state: isWebSocketConnected() ? "connected" : "disconnected", + message: isWebSocketConnected() ? "Connected to Fulcrum" : "Not connected", + })); + + const isMountedRef = useRef(true); + const isConnectingRef = useRef(false); + + const attemptConnect = useCallback(async () => { + if (!isMountedRef.current || isConnectingRef.current) return; + + isConnectingRef.current = true; + + setStatus({ + state: "connecting", + message: "Connecting to Fulcrum server...", + }); + + try { + await connect(); + + if (!isMountedRef.current) return; + + setStatus({ + state: "connected", + message: "Connected to Fulcrum", + }); + } catch (err) { + if (!isMountedRef.current) return; + + const errorMessage = err instanceof Error ? err.message : "Connection failed"; + + setStatus({ + state: "error", + message: "Connection failed after multiple attempts", + error: errorMessage, + }); + } finally { + isConnectingRef.current = false; + } + }, []); + + const manualConnect = useCallback(() => { + attemptConnect(); + }, [attemptConnect]); + + const cancelConnect = useCallback(() => { + isConnectingRef.current = false; + disconnect(); + setStatus({ + state: "disconnected", + message: "Connection cancelled", + }); + }, []); + + // Initial connection on mount + useEffect(() => { + isMountedRef.current = true; + + if (!isWebSocketConnected()) { + attemptConnect(); + } + + return () => { + isMountedRef.current = false; + }; + }, [attemptConnect]); + + // Monitor connection state changes + useEffect(() => { + const checkConnection = () => { + if (!isMountedRef.current) return; + + const connected = isWebSocketConnected(); + + if (connected && status.state !== "connected") { + setStatus({ + state: "connected", + message: "Connected to Fulcrum", + }); + } else if (!connected && status.state === "connected") { + // Connection was lost, start reconnecting + attemptConnect(); + } + }; + + const interval = setInterval(checkConnection, 2000); + return () => clearInterval(interval); + }, [status.state, attemptConnect]); + + return { + ...status, + isConnected: status.state === "connected", + isConnecting: status.state === "connecting", + manualConnect, + cancelConnect, + }; +} diff --git a/src/components/wallet/onboarding/hooks/useOnboardingFlow.ts b/src/components/wallet/onboarding/hooks/useOnboardingFlow.ts index 7a4b9d2c2..3075d89d6 100644 --- a/src/components/wallet/onboarding/hooks/useOnboardingFlow.ts +++ b/src/components/wallet/onboarding/hooks/useOnboardingFlow.ts @@ -11,8 +11,6 @@ import { IpfsStorageService } from "../../L3/services/IpfsStorageService"; import { saveWalletToStorage, loadWalletFromStorage, - connect as connectL1, - isWebSocketConnected, getBalance, type Wallet as L1Wallet, } from "../../L1/sdk"; @@ -124,15 +122,6 @@ export function useOnboardingFlow(): UseOnboardingFlowReturn { const [autoDeriveDuringIpnsCheck, setAutoDeriveDuringIpnsCheck] = useState(true); const [ipnsFetchingNametag, setIpnsFetchingNametag] = useState(false); - // Connect to L1 WebSocket on mount (needed for wallet scanning) - useEffect(() => { - if (!isWebSocketConnected()) { - connectL1().catch((err) => { - console.warn("Failed to connect to L1 WebSocket:", err); - }); - } - }, []); - // Effect: Fetch nametag from IPNS when identity exists but nametag doesn't useEffect(() => { if (step !== "start" || !identity || nametag || ipnsFetchingNametag) return; From fd12ff3492a576854aace3ba7b5d9b828c989941 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Thu, 8 Jan 2026 17:34:36 +0200 Subject: [PATCH 14/17] fix(onboarding): auto-reconnect when network comes back online Co-Authored-By: Claude Opus 4.5 --- .../hooks/useOnboardingConnectionStatus.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts b/src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts index 566c6e227..df6b01271 100644 --- a/src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts +++ b/src/components/wallet/onboarding/hooks/useOnboardingConnectionStatus.ts @@ -108,6 +108,21 @@ export function useOnboardingConnectionStatus() { return () => clearInterval(interval); }, [status.state, attemptConnect]); + // Auto-reconnect when browser comes back online + useEffect(() => { + const handleOnline = () => { + if (!isMountedRef.current) return; + + // If we're in error or disconnected state, try to reconnect + if (status.state === "error" || status.state === "disconnected") { + attemptConnect(); + } + }; + + window.addEventListener("online", handleOnline); + return () => window.removeEventListener("online", handleOnline); + }, [status.state, attemptConnect]); + return { ...status, isConnected: status.state === "connected", From 1e8eb94b59f3e996cbb3380232fff948d9f7d629 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Fri, 9 Jan 2026 19:44:50 +0200 Subject: [PATCH 15/17] fix(chat): prevent duplicate session creation when loading existing session --- src/components/agents/shared/AgentChat.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/agents/shared/AgentChat.tsx b/src/components/agents/shared/AgentChat.tsx index e5ab90992..8b22e0b89 100644 --- a/src/components/agents/shared/AgentChat.tsx +++ b/src/components/agents/shared/AgentChat.tsx @@ -125,6 +125,7 @@ export function AgentChat({ const { sessions, currentSession: historySession, // Session created when saving messages + loadSession, deleteSession, clearAllHistory, resetCurrentSession, @@ -446,11 +447,14 @@ export function AgentChat({ // Load a previous chat session via URL navigation // TanStack Query handles the actual data loading const handleLoadSession = useCallback((sessionId: string) => { - // Navigate to session - TanStack Query will load the data + // IMPORTANT: Load session into useChatHistory first to set currentSessionRef + // This prevents saveCurrentMessages from creating a duplicate session + loadSession(sessionId); + // Navigate to session - TanStack Query will load the data for display navigateToSession(sessionId); // Close sidebar on mobile setSidebarOpen(false); - }, [navigateToSession]); + }, [loadSession, navigateToSession]); const handleDeleteSession = async (sessionId: string) => { const wasCurrentSession = currentSession?.id === sessionId; From 54d1c32403fc5b0fdf21a825f4b9202ee8587966 Mon Sep 17 00:00:00 2001 From: igmahl Date: Mon, 12 Jan 2026 12:58:25 +0200 Subject: [PATCH 16/17] Implement new Agents view with expanding view --- src/config/activities.ts | 102 ++++++++++++++++++++++++++++++++++++- src/pages/AgentPage.tsx | 107 ++++++++++++++++++++++++++++++++++----- 2 files changed, 195 insertions(+), 14 deletions(-) diff --git a/src/config/activities.ts b/src/config/activities.ts index a88f2126a..fe8a0b481 100644 --- a/src/config/activities.ts +++ b/src/config/activities.ts @@ -1,4 +1,4 @@ -import { MessageSquare, Gamepad2, Trophy, ShoppingBag, Shirt, Brain, Sparkles } from 'lucide-react'; +import { MessageSquare, Gamepad2, Trophy, ShoppingBag, Shirt, Brain, Sparkles, Dices, TrendingUp, Banknote, CreditCard, ArrowRightLeft, Cpu, Package, Tag, Coins } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; // Agent types for different UI layouts @@ -151,6 +151,106 @@ export const agents: AgentConfig[] = [ contentType: 'merch', hasSidebar: true, }, + { + id: 'casino', + name: 'Agent Casino', + description: 'Verifiably Fair', + Icon: Dices, + category: 'Entertainment', + color: 'from-red-500 to-pink-500', + type: 'simple-ai', + greetingMessage: "Welcome to Agent Casino! Our games are verifiably fair using cryptographic proofs. Ready to try your luck?", + }, + { + id: 'p2p-sports', + name: 'P2P Sports', + description: 'Private Betting', + Icon: Trophy, + category: 'Prediction', + color: 'from-green-500 to-emerald-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Sports! Create private betting pools with friends. What sport interests you?", + }, + { + id: 'p2p-derivatives', + name: 'P2P Derivatives', + description: 'Get Leverage', + Icon: TrendingUp, + category: 'Trading', + color: 'from-blue-500 to-indigo-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Derivatives! Trade with leverage in a peer-to-peer marketplace. What would you like to trade?", + }, + { + id: 'payday-loans', + name: 'P2P Payday Loans', + description: 'Instant approval', + Icon: Banknote, + category: 'Finance', + color: 'from-lime-500 to-green-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Payday Loans! Get instant approval for short-term loans. How can I help you today?", + }, + { + id: 'crypto-offramp', + name: 'P2P Crypto Offramp', + description: 'Convert to cash', + Icon: CreditCard, + category: 'Trading', + color: 'from-cyan-500 to-blue-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Crypto Offramp! Convert your crypto to cash easily. What would you like to sell?", + }, + { + id: 'fiat-onramp', + name: 'P2P Fiat Onramp', + description: 'Convert your cash', + Icon: ArrowRightLeft, + category: 'Trading', + color: 'from-violet-500 to-purple-500', + type: 'simple-ai', + greetingMessage: "Welcome to P2P Fiat Onramp! Convert your cash to crypto. What currency do you want to buy?", + }, + { + id: 'friendly-miners', + name: 'Friendly Miners', + description: 'Buy hash rate', + Icon: Cpu, + category: 'Mining', + color: 'from-amber-500 to-orange-500', + type: 'simple-ai', + greetingMessage: "Welcome to Friendly Miners! Purchase hash rate from our network of miners. What are you looking for?", + }, + { + id: 'buy-anything', + name: 'Buy Anything', + description: 'Get product now', + Icon: Package, + category: 'Shopping', + color: 'from-rose-500 to-red-500', + type: 'simple-ai', + greetingMessage: "Welcome to Buy Anything! Tell me what you're looking for and I'll help you find it.", + }, + { + id: 'sell-anything', + name: 'Sell Anything', + description: 'Get a quote', + Icon: Tag, + category: 'Shopping', + color: 'from-teal-500 to-cyan-500', + type: 'simple-ai', + greetingMessage: "Welcome to Sell Anything! Describe what you want to sell and I'll get you a quote.", + }, + { + id: 'get-uct', + name: 'Get UCT', + description: 'Get unicity tokens', + Icon: Coins, + category: 'Tokens', + color: 'from-yellow-400 to-amber-500', + type: 'simple-ai', + greetingMessage: "Welcome! I can help you acquire UCT (Unicity Tokens). How would you like to proceed?", + }, ]; // Get agent by ID diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 99ef981b6..340949613 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useParams, Navigate } from 'react-router-dom'; -import { MessageSquare, Wallet } from 'lucide-react'; -import { motion } from 'framer-motion'; +import { MessageSquare, Wallet, ChevronDown, ChevronUp } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; import { AgentCard } from '../components/agents/AgentCard'; import { ChatSection } from '../components/chat/ChatSection'; import { SportChat } from '../components/agents/SportChat'; @@ -13,10 +13,44 @@ import { AIChat } from '../components/agents/AIChat'; import { WalletPanel } from '../components/wallet/WalletPanel'; import { agents, getAgentConfig } from '../config/activities'; +const DEFAULT_VISIBLE_AGENTS = 7; + export function AgentPage() { const { agentId } = useParams<{ agentId: string }>(); const sliderRef = useRef(null); const [activePanel, setActivePanel] = useState<'chat' | 'wallet'>('chat'); + const [showAllAgents, setShowAllAgents] = useState(false); + const [recentAgentIds, setRecentAgentIds] = useState([]); + + const hasMoreAgents = agents.length > DEFAULT_VISIBLE_AGENTS; + + // Track recently selected agents + useEffect(() => { + if (!agentId) return; + + setRecentAgentIds(prev => { + if (prev[0] === agentId) return prev; // Already first, no change + const filtered = prev.filter(id => id !== agentId); + return [agentId, ...filtered].slice(0, DEFAULT_VISIBLE_AGENTS); + }); + }, [agentId]); + + // Calculate visible agents - prioritize recently selected agents + const visibleAgents = (() => { + if (showAllAgents) return agents; + + // Get recent agents that exist in the agents list + const recentAgents = recentAgentIds + .map(id => agents.find(a => a.id === id)) + .filter((a): a is typeof agents[0] => a !== undefined); + + // Get remaining agents (not in recent list) + const remainingAgents = agents.filter(a => !recentAgentIds.includes(a.id)); + + // Combine: recent first, then fill with remaining up to DEFAULT_VISIBLE_AGENTS + const combined = [...recentAgents, ...remainingAgents]; + return combined.slice(0, DEFAULT_VISIBLE_AGENTS); + })(); const currentAgent = agentId ? getAgentConfig(agentId) : undefined; @@ -106,24 +140,71 @@ export function AgentPage() { return (
{/* Desktop agent grid - always visible */} -
+
-
- {agents.map((agent) => ( - + {/* First 7 agents - no animation */} + {visibleAgents.slice(0, DEFAULT_VISIBLE_AGENTS).map((agent) => ( + + ))} + {/* Extra agents - with animation */} + + {showAllAgents && visibleAgents.slice(DEFAULT_VISIBLE_AGENTS).map((agent, index) => ( + + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.1 } }} + transition={{ + duration: 0.15, + delay: index * 0.02, + }} + > + + ))} + +
+ + {/* View all / Hide all button */} + {hasMoreAgents && ( +
+
+ )}
{/* Mobile tab switcher with sliding indicator */} From 477ee0663f92439b379163c67a894e35b5dde185 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 12 Jan 2026 16:51:30 +0200 Subject: [PATCH 17/17] fix(chat): wrap quick action buttons on mobile for narrow screens --- src/components/agents/shared/QuickActions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/agents/shared/QuickActions.tsx b/src/components/agents/shared/QuickActions.tsx index ee4a0b862..79c0ea628 100644 --- a/src/components/agents/shared/QuickActions.tsx +++ b/src/components/agents/shared/QuickActions.tsx @@ -23,13 +23,13 @@ export function QuickActions({ actions, onAction, disabled = false }: QuickActio
{/* Regular actions */} {regularActions.length > 0 && ( -
+
{regularActions.map((action) => (