diff --git a/.env.example b/.env.example index 1ebbe772..67984e6b 100644 --- a/.env.example +++ b/.env.example @@ -17,11 +17,6 @@ VITE_AGENT_API_URL=http://localhost:3000 # - For production: https://goggregator-test.unicity.network VITE_AGGREGATOR_URL=/rpc -# Enable IPFS storage for wallet backup (default: true) -# - true/unset: Enables automatic wallet sync to IPFS -# - false: IPFS storage disabled -# VITE_ENABLE_IPFS=true - # Nostr Relays for DMs and token transfers (comma-separated) # Default: wss://nostr-relay.testnet.unicity.network # VITE_NOSTR_RELAYS=wss://relay1.example.com,wss://relay2.example.com diff --git a/INTEGRATION_SPHERE_SDK_PLAN.md b/INTEGRATION_SPHERE_SDK_PLAN.md deleted file mode 100644 index a25f9823..00000000 --- a/INTEGRATION_SPHERE_SDK_PLAN.md +++ /dev/null @@ -1,1156 +0,0 @@ -# Sphere-SDK Integration Plan - -## Overview - -Integration of local `sphere-sdk` into the Sphere project to replace current L1/L3 wallet implementations. - -**SDK Location:** `/home/linux/unicitynetwork/sphere-sdk` -**SDK Version:** 0.1.8 - ---- - -## Table of Contents - -1. [Project Preparation](#phase-1-project-preparation) -2. [SDK Constants](#sdk-constants) -3. [Cleanup - Remove Legacy Code](#phase-2-cleanup---remove-legacy-code) -4. [SDK Adapter Layer](#phase-3-sdk-adapter-layer) -5. [Query Keys Design](#query-keys-design) -6. [React Hooks Design](#react-hooks-design) -7. [Component Migration](#phase-6-component-migration) -8. [Execution Order](#execution-order) - ---- - -## Phase 1: Project Preparation - -### 1.1 Link SDK Locally - -Add to `package.json`: -```json -{ - "dependencies": { - "@unicitylabs/sphere-sdk": "file:../sphere-sdk" - } -} -``` - -Then run: -```bash -npm install -``` - -### 1.2 Remove IPFS Dependencies (Temporarily) - -Remove from `package.json` for now: -``` -- @helia/ipns -- @helia/json -- helia -- @noble/ed25519 (if only used for IPFS) -``` - -> **Note:** Helia is an **optional peer dependency** in sphere-sdk. When IPFS support -> is implemented in SDK, we'll add helia back to sphere's dependencies. The SDK will -> automatically detect and use it. - ---- - -## SDK Constants - -SDK provides all network/wallet constants. Most current configs will be deleted. - -### Constants from SDK (use directly) - -```typescript -import { - // Storage - STORAGE_PREFIX, // 'sphere_' - STORAGE_KEYS_GLOBAL, // { MNEMONIC, MASTER_KEY, CHAIN_CODE, ... } - STORAGE_KEYS_ADDRESS, // { PENDING_TRANSFERS, OUTBOX, CONVERSATIONS, ... } - getAddressStorageKey, // (addressId, key) => string - getAddressId, // (directAddress) => 'DIRECT_xxx_yyy' - - // Nostr - DEFAULT_NOSTR_RELAYS, // ['wss://relay.unicity.network', ...] - TEST_NOSTR_RELAYS, // ['wss://nostr-relay.testnet.unicity.network'] - NOSTR_EVENT_KINDS, // { DIRECT_MESSAGE: 4, TOKEN_TRANSFER: 31113, ... } - - // Aggregator (Oracle) - DEFAULT_AGGREGATOR_URL, // 'https://aggregator.unicity.network/rpc' - DEV_AGGREGATOR_URL, // 'https://dev-aggregator.dyndns.org/rpc' - TEST_AGGREGATOR_URL, // 'https://goggregator-test.unicity.network' - DEFAULT_AGGREGATOR_TIMEOUT, // 30000 - DEFAULT_AGGREGATOR_API_KEY, // 'sk_...' - - // IPFS (for future use) - DEFAULT_IPFS_GATEWAYS, // ['https://ipfs.unicity.network', ...] - DEFAULT_IPFS_BOOTSTRAP_PEERS, - - // Wallet - DEFAULT_BASE_PATH, // "m/44'/0'/0'" - DEFAULT_DERIVATION_PATH, // "m/44'/0'/0'/0/0" - COIN_TYPES, // { ALPHA: 'ALPHA', TEST: 'TEST' } - - // L1 (Electrum) - DEFAULT_ELECTRUM_URL, // 'wss://fulcrum.alpha.unicity.network:50004' - TEST_ELECTRUM_URL, // 'wss://fulcrum.alpha.testnet.unicity.network:50004' - - // Networks (presets) - NETWORKS, // { mainnet: {...}, testnet: {...}, dev: {...} } - - // Timeouts & Limits - TIMEOUTS, // { WEBSOCKET_CONNECT, NOSTR_RECONNECT_DELAY, ... } - LIMITS, // { NAMETAG_MIN_LENGTH, NAMETAG_MAX_LENGTH, ... } -} from '@unicitylabs/sphere-sdk'; -``` - -### Config files to DELETE (replaced by SDK) - -``` -src/config/nostr.config.ts → SDK: DEFAULT_NOSTR_RELAYS, NOSTR_EVENT_KINDS -src/config/ipfs.config.ts → SDK: DEFAULT_IPFS_GATEWAYS, DEFAULT_IPFS_BOOTSTRAP_PEERS -src/config/nostrPin.config.ts → SDK handles internally -``` - -### Config files to KEEP (app-specific) - -``` -src/config/queryKeys.ts → REPLACE with src/sdk/queryKeys.ts (new SPHERE_KEYS) -src/config/storageKeys.ts → SIMPLIFY (keep only app-specific keys) -src/config/activities.ts → Keep (agent UI config) -src/config/groupChat.config.ts → Keep (NIP-29 group chat) -``` - -### Simplified storageKeys.ts (after cleanup) - -```typescript -// src/config/storageKeys.ts (simplified) - -// Re-export SDK storage constants -export { - STORAGE_PREFIX, - STORAGE_KEYS_GLOBAL, - STORAGE_KEYS_ADDRESS, - getAddressStorageKey, - getAddressId, -} from '@unicitylabs/sphere-sdk'; - -// App-specific keys (not in SDK) -export const APP_STORAGE_KEYS = { - // Theme & UI - THEME: 'sphere_theme', - WALLET_ACTIVE_LAYER: 'sphere_wallet_active_layer', - WELCOME_ACCEPTED: 'sphere_welcome_accepted', - - // Onboarding state - AUTHENTICATED: 'sphere_authenticated', - ONBOARDING_IN_PROGRESS: 'sphere_onboarding_in_progress', - ONBOARDING_COMPLETE: 'sphere_onboarding_complete', - - // Chat (DMs) - CHAT_CONVERSATIONS: 'sphere_chat_conversations', - CHAT_MESSAGES: 'sphere_chat_messages', - CHAT_MODE: 'sphere_chat_mode', - CHAT_SELECTED_GROUP: 'sphere_chat_selected_group', - CHAT_SELECTED_DM: 'sphere_chat_selected_dm', - - // Group Chat (NIP-29) - GROUP_CHAT_GROUPS: 'sphere_group_chat_groups', - GROUP_CHAT_MESSAGES: 'sphere_group_chat_messages', - GROUP_CHAT_MEMBERS: 'sphere_group_chat_members', - GROUP_CHAT_RELAY_URL: 'sphere_group_chat_relay_url', - - // Agent Chat - AGENT_CHAT_SESSIONS: 'sphere_agent_chat_sessions', - AGENT_CHAT_TOMBSTONES: 'sphere_agent_chat_tombstones', - - // Dev settings - DEV_AGGREGATOR_URL: 'sphere_dev_aggregator_url', - DEV_SKIP_TRUST_BASE: 'sphere_dev_skip_trust_base', -} as const; - -export const APP_STORAGE_KEY_GENERATORS = { - agentMemory: (userId: string, activityId: string) => - `sphere_agent_memory:${userId}:${activityId}` as const, - agentChatMessages: (sessionId: string) => - `sphere_agent_chat_messages:${sessionId}` as const, -} as const; -``` - -### 1.3 Remove IPFS Files - -**Full removal (11 files):** -``` -src/components/wallet/L3/services/IpfsStorageService.ts -src/components/wallet/L3/services/IpfsHttpResolver.ts -src/components/wallet/L3/services/IpfsPublisher.ts -src/components/wallet/L3/services/IpfsMetrics.ts -src/components/wallet/L3/services/IpfsCache.ts -src/components/wallet/L3/services/IpnsUtils.ts -src/components/wallet/L3/services/IpnsNametagFetcher.ts -src/components/wallet/L3/services/types/IpfsTransport.ts -src/components/wallet/L3/hooks/useIpfsStorage.ts -src/components/agents/shared/ChatHistoryIpfsService.ts -src/config/ipfs.config.ts -``` - ---- - -## Phase 2: Cleanup - Remove Legacy Code - -### 2.1 Remove Legacy L3 Services - -**Services replaced by SDK (delete these):** -``` -src/components/wallet/L3/services/ServiceProvider.ts -src/components/wallet/L3/services/IdentityManager.ts -src/components/wallet/L3/services/NostrService.ts -src/components/wallet/L3/services/NametagService.ts -src/components/wallet/L3/services/RegistryService.ts -src/components/wallet/L3/services/TokenValidationService.ts -src/components/wallet/L3/services/transfer/TokenSplitCalculator.ts -src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts -src/components/wallet/L3/services/TxfSerializer.ts -src/components/wallet/L3/services/TokenBackupService.ts -src/components/wallet/L3/services/TokenRecoveryService.ts -src/components/wallet/L3/services/OutboxRecoveryService.ts -src/components/wallet/L3/services/NostrPinPublisher.ts -src/components/wallet/L3/services/InventorySyncService.ts -src/components/wallet/L3/services/SyncCoordinator.ts -src/components/wallet/L3/services/SyncQueue.ts -src/components/wallet/L3/services/ConflictResolutionService.ts -src/components/wallet/L3/services/InventoryBackgroundLoops.ts -src/components/wallet/L3/services/utils/SyncModeDetector.ts -``` - -**Keep:** -``` -src/components/wallet/L3/services/FaucetService.ts → Keep (faucet API) -src/components/wallet/L3/services/api.ts → Keep (agent API) -``` - -**Types to remove:** -``` -src/components/wallet/L3/services/types/OutboxTypes.ts -src/components/wallet/L3/services/types/TxfSchemas.ts -src/components/wallet/L3/services/types/TxfTypes.ts -src/components/wallet/L3/services/types/QueueTypes.ts -``` - -### 2.2 Remove Legacy L1 SDK - -**Remove entire directory (16 files):** -``` -src/components/wallet/L1/sdk/ -``` - -### 2.3 Remove Shared Services - -``` -src/components/wallet/shared/services/UnifiedKeyManager.ts -src/repositories/WalletRepository.ts -src/repositories/OutboxRepository.ts -``` - -### 2.4 Remove Legacy Hooks - -``` -src/components/wallet/L3/hooks/useWallet.ts -src/components/wallet/L3/hooks/useInventorySync.ts -src/components/wallet/L3/hooks/useTransactionHistory.ts -src/components/wallet/L1/hooks/useL1Wallet.ts -``` - ---- - -## Phase 3: SDK Adapter Layer - -### 3.1 Directory Structure (mirrors SDK modules) - -``` -src/sdk/ -├── index.ts # Public exports -├── SphereProvider.tsx # React Context + TanStack Query integration -├── config.ts # SDK configuration -├── queryKeys.ts # TanStack Query keys -├── types.ts # Re-export SDK types + UI types -│ -├── hooks/ -│ ├── index.ts # All hook exports -│ │ -│ ├── core/ # Core wallet hooks -│ │ ├── useSphere.ts # Access Sphere instance -│ │ ├── useWalletStatus.ts # Loading/exists state -│ │ ├── useIdentity.ts # Current identity -│ │ ├── useNametag.ts # Nametag operations -│ │ └── useSphereEvents.ts # SDK event subscriptions -│ │ -│ ├── payments/ # L3 payments hooks -│ │ ├── useTokens.ts # Token list -│ │ ├── useBalance.ts # Balance by coinId -│ │ ├── useAssets.ts # Aggregated assets -│ │ ├── useTransfer.ts # Send tokens mutation -│ │ └── useTransactionHistory.ts -│ │ -│ └── l1/ # L1 (ALPHA) hooks -│ ├── useL1Balance.ts # L1 balance + vesting -│ ├── useL1Utxos.ts # UTXOs -│ ├── useL1Send.ts # L1 send mutation -│ └── useL1Transactions.ts # L1 tx history -│ -└── utils/ - ├── format.ts # Amount formatting - └── queryHelpers.ts # Query invalidation helpers -``` - ---- - -## Query Keys Design - -```typescript -// src/sdk/queryKeys.ts - -export const SPHERE_KEYS = { - // ───────────────────────────────────────────────────────────── - // Root - // ───────────────────────────────────────────────────────────── - all: ['sphere'] as const, - - // ───────────────────────────────────────────────────────────── - // Core / Wallet - // ───────────────────────────────────────────────────────────── - wallet: { - all: ['sphere', 'wallet'] as const, - exists: ['sphere', 'wallet', 'exists'] as const, - status: ['sphere', 'wallet', 'status'] as const, - }, - - identity: { - all: ['sphere', 'identity'] as const, - current: ['sphere', 'identity', 'current'] as const, - nametag: ['sphere', 'identity', 'nametag'] as const, - addresses: ['sphere', 'identity', 'addresses'] as const, - }, - - // ───────────────────────────────────────────────────────────── - // Payments (L3) - // ───────────────────────────────────────────────────────────── - payments: { - all: ['sphere', 'payments'] as const, - - tokens: { - all: ['sphere', 'payments', 'tokens'] as const, - list: ['sphere', 'payments', 'tokens', 'list'] as const, - byId: (id: string) => ['sphere', 'payments', 'tokens', id] as const, - }, - - balance: { - all: ['sphere', 'payments', 'balance'] as const, - byCoin: (coinId: string) => ['sphere', 'payments', 'balance', coinId] as const, - total: ['sphere', 'payments', 'balance', 'total'] as const, - }, - - assets: { - all: ['sphere', 'payments', 'assets'] as const, - list: ['sphere', 'payments', 'assets', 'list'] as const, - }, - - transactions: { - all: ['sphere', 'payments', 'transactions'] as const, - history: ['sphere', 'payments', 'transactions', 'history'] as const, - pending: ['sphere', 'payments', 'transactions', 'pending'] as const, - }, - }, - - // ───────────────────────────────────────────────────────────── - // L1 (ALPHA blockchain) - // ───────────────────────────────────────────────────────────── - l1: { - all: ['sphere', 'l1'] as const, - balance: ['sphere', 'l1', 'balance'] as const, - utxos: ['sphere', 'l1', 'utxos'] as const, - transactions: ['sphere', 'l1', 'transactions'] as const, - vesting: ['sphere', 'l1', 'vesting'] as const, - blockHeight: ['sphere', 'l1', 'blockHeight'] as const, - }, - - // ───────────────────────────────────────────────────────────── - // Market data - // ───────────────────────────────────────────────────────────── - market: { - all: ['sphere', 'market'] as const, - prices: ['sphere', 'market', 'prices'] as const, - registry: ['sphere', 'market', 'registry'] as const, - }, -} as const; - -// Type helper -export type SphereQueryKey = typeof SPHERE_KEYS; -``` - -### Query Key Migration Table - -| Old Key | New Key | -|---------|---------| -| `['wallet', 'identity']` | `SPHERE_KEYS.identity.current` | -| `['wallet', 'nametag']` | `SPHERE_KEYS.identity.nametag` | -| `['wallet', 'tokens']` | `SPHERE_KEYS.payments.tokens.list` | -| `['wallet', 'aggregated']` | `SPHERE_KEYS.payments.assets.list` | -| `['wallet', 'transaction-history']` | `SPHERE_KEYS.payments.transactions.history` | -| `['l1', 'wallet']` | `SPHERE_KEYS.wallet.status` | -| `['l1', 'balance', addr]` | `SPHERE_KEYS.l1.balance` | -| `['l1', 'vesting', addr]` | `SPHERE_KEYS.l1.vesting` | -| `['l1', 'transactions', addr]` | `SPHERE_KEYS.l1.transactions` | -| `['market', 'prices']` | `SPHERE_KEYS.market.prices` | -| `['market', 'registry']` | `SPHERE_KEYS.market.registry` | - ---- - -## React Hooks Design - -### Core Hooks - -#### `useSphereContext` / `useSphere` - -```typescript -// src/sdk/hooks/core/useSphere.ts - -// Full context access -export function useSphereContext(): SphereContextValue { - const context = useContext(SphereContext); - if (!context) { - throw new Error('useSphereContext must be used within SphereProvider'); - } - return context; -} - -// Just the Sphere instance (throws if not initialized) -export function useSphere(): Sphere { - const { sphere } = useSphereContext(); - if (!sphere) { - throw new Error('Wallet not initialized'); - } - return sphere; -} -``` - -#### `useWalletStatus` - -```typescript -// src/sdk/hooks/core/useWalletStatus.ts - -interface WalletStatus { - isLoading: boolean; - isInitialized: boolean; - walletExists: boolean; - error: Error | null; -} - -export function useWalletStatus(): WalletStatus; -``` - -#### `useIdentity` - -```typescript -// src/sdk/hooks/core/useIdentity.ts - -interface UseIdentityReturn { - // Data - identity: Identity | null; - isLoading: boolean; - error: Error | null; - - // Computed helpers - directAddress: string | null; - l1Address: string | null; - nametag: string | null; - displayName: string; // @nametag or truncated address - shortAddress: string; // First 8 chars of directAddress -} - -export function useIdentity(): UseIdentityReturn; - -// Query key: SPHERE_KEYS.identity.current -// Stale time: Infinity -``` - -#### `useNametag` - -```typescript -// src/sdk/hooks/core/useNametag.ts - -interface UseNametagReturn { - // Current nametag - nametag: string | null; - isLoading: boolean; - - // Register mutation - register: (name: string) => Promise; - isRegistering: boolean; - registerError: Error | null; - - // Resolve helper - resolve: (name: string) => Promise; -} - -export function useNametag(): UseNametagReturn; - -// Query key: SPHERE_KEYS.identity.nametag -``` - ---- - -### Payments Hooks (L3) - -#### `useTokens` - -```typescript -// src/sdk/hooks/payments/useTokens.ts - -interface UseTokensReturn { - tokens: Token[]; - isLoading: boolean; - error: Error | null; - refetch: () => void; - - // Computed - tokenCount: number; - hasTokens: boolean; - - // Filters - confirmedTokens: Token[]; - pendingTokens: Token[]; -} - -export function useTokens(): UseTokensReturn; - -// Query key: SPHERE_KEYS.payments.tokens.list -// Stale time: Infinity (invalidated by events) -``` - -#### `useBalance` - -```typescript -// src/sdk/hooks/payments/useBalance.ts - -interface UseBalanceReturn { - balance: TokenBalance | null; - isLoading: boolean; - error: Error | null; - - // Formatted strings (human readable) - total: string; - confirmed: string; - unconfirmed: string; - - // Raw amounts (smallest units) - totalRaw: string; - confirmedRaw: string; - unconfirmedRaw: string; -} - -export function useBalance(coinId?: string): UseBalanceReturn; - -// Query key: SPHERE_KEYS.payments.balance.byCoin(coinId) -// Default coinId: 'ALPHA' -``` - -#### `useAssets` - -```typescript -// src/sdk/hooks/payments/useAssets.ts - -interface UseAssetsReturn { - assets: Asset[]; - isLoading: boolean; - error: Error | null; - - // Computed - assetCount: number; - totalValueUsd: string; -} - -export function useAssets(): UseAssetsReturn; - -// Query key: SPHERE_KEYS.payments.assets.list -``` - -#### `useTransfer` - -```typescript -// src/sdk/hooks/payments/useTransfer.ts - -interface TransferParams { - coinId: string; - amount: string; - recipient: string; // @nametag or DIRECT://... - memo?: string; -} - -interface UseTransferReturn { - // Mutation - transfer: (params: TransferParams) => Promise; - isLoading: boolean; - error: Error | null; - - // Last result - lastResult: TransferResult | null; - reset: () => void; -} - -export function useTransfer(): UseTransferReturn; - -// Invalidates on success: -// - SPHERE_KEYS.payments.tokens.all -// - SPHERE_KEYS.payments.balance.all -// - SPHERE_KEYS.payments.assets.all -// - SPHERE_KEYS.payments.transactions.all -``` - -#### `useTransactionHistory` - -```typescript -// src/sdk/hooks/payments/useTransactionHistory.ts - -interface Transaction { - id: string; - type: 'incoming' | 'outgoing'; - coinId: string; - symbol: string; - amount: string; - counterparty: string; // nametag or address - timestamp: number; - status: 'completed' | 'pending' | 'failed'; - memo?: string; -} - -interface UseTransactionHistoryReturn { - transactions: Transaction[]; - isLoading: boolean; - error: Error | null; - refetch: () => void; - - // Filters - incoming: Transaction[]; - outgoing: Transaction[]; -} - -export function useTransactionHistory(): UseTransactionHistoryReturn; - -// Query key: SPHERE_KEYS.payments.transactions.history -// Stale time: 30_000 -``` - ---- - -### L1 Hooks - -#### `useL1Balance` - -```typescript -// src/sdk/hooks/l1/useL1Balance.ts - -interface L1BalanceData { - confirmed: string; - unconfirmed: string; - total: string; - vested: string; - unvested: string; -} - -interface UseL1BalanceReturn { - balance: L1BalanceData | null; - isLoading: boolean; - error: Error | null; - refetch: () => void; - - // Formatted - totalFormatted: string; - vestedFormatted: string; - unvestedFormatted: string; -} - -export function useL1Balance(): UseL1BalanceReturn; - -// Query key: SPHERE_KEYS.l1.balance -// Stale time: 30_000 -// Refetch interval: 60_000 -``` - -#### `useL1Utxos` - -```typescript -// src/sdk/hooks/l1/useL1Utxos.ts - -interface Utxo { - txid: string; - vout: number; - value: string; - address: string; - isVested: boolean; -} - -interface UseL1UtxosReturn { - utxos: Utxo[]; - isLoading: boolean; - error: Error | null; - - // Computed - utxoCount: number; - vestedUtxos: Utxo[]; - unvestedUtxos: Utxo[]; -} - -export function useL1Utxos(): UseL1UtxosReturn; - -// Query key: SPHERE_KEYS.l1.utxos -``` - -#### `useL1Send` - -```typescript -// src/sdk/hooks/l1/useL1Send.ts - -interface L1SendParams { - toAddress: string; - amount: string; - feeRate?: number; - useVested?: boolean; -} - -interface L1SendResult { - txHash: string; - fee: string; -} - -interface UseL1SendReturn { - send: (params: L1SendParams) => Promise; - isLoading: boolean; - error: Error | null; - lastResult: L1SendResult | null; -} - -export function useL1Send(): UseL1SendReturn; - -// Invalidates on success: -// - SPHERE_KEYS.l1.all -``` - -#### `useL1Transactions` - -```typescript -// src/sdk/hooks/l1/useL1Transactions.ts - -interface L1Transaction { - txid: string; - type: 'incoming' | 'outgoing'; - amount: string; - fee: string; - confirmations: number; - timestamp: number; - address: string; -} - -interface UseL1TransactionsReturn { - transactions: L1Transaction[]; - isLoading: boolean; - error: Error | null; - refetch: () => void; -} - -export function useL1Transactions(): UseL1TransactionsReturn; - -// Query key: SPHERE_KEYS.l1.transactions -// Stale time: 30_000 -``` - ---- - -### Event Handling - -```typescript -// src/sdk/hooks/core/useSphereEvents.ts - -export function useSphereEvents(): void { - const { sphere } = useSphereContext(); - const queryClient = useQueryClient(); - - useEffect(() => { - if (!sphere) return; - - const handleIncomingTransfer = () => { - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.tokens.all }); - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.balance.all }); - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.transactions.all }); - }; - - const handleTransferConfirmed = () => { - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.tokens.all }); - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.balance.all }); - }; - - const handleNametagChange = () => { - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.identity.all }); - }; - - const handleIdentityChange = () => { - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.identity.all }); - queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.all }); - }; - - sphere.on('transfer:incoming', handleIncomingTransfer); - sphere.on('transfer:confirmed', handleTransferConfirmed); - sphere.on('nametag:registered', handleNametagChange); - sphere.on('nametag:recovered', handleNametagChange); - sphere.on('identity:changed', handleIdentityChange); - - return () => { - sphere.off('transfer:incoming', handleIncomingTransfer); - sphere.off('transfer:confirmed', handleTransferConfirmed); - sphere.off('nametag:registered', handleNametagChange); - sphere.off('nametag:recovered', handleNametagChange); - sphere.off('identity:changed', handleIdentityChange); - }; - }, [sphere, queryClient]); -} -``` - ---- - -## Phase 4: SphereProvider - -```typescript -// src/sdk/SphereProvider.tsx - -import { createContext, useState, useEffect, useCallback, ReactNode } from 'react'; -import { Sphere } from '@unicitylabs/sphere-sdk'; -import { createBrowserProviders, type BrowserProviders } from '@unicitylabs/sphere-sdk/impl/browser'; -import type { NetworkType, Identity } from '@unicitylabs/sphere-sdk'; - -export interface SphereContextValue { - // Instance - sphere: Sphere | null; - providers: BrowserProviders | null; - - // State - isLoading: boolean; - isInitialized: boolean; - walletExists: boolean; - error: Error | null; - - // Wallet lifecycle - createWallet: (options?: CreateWalletOptions) => Promise; - importWallet: (mnemonic: string, options?: ImportWalletOptions) => Promise; - deleteWallet: () => Promise; - - // Re-initialization - reinitialize: () => Promise; -} - -interface CreateWalletOptions { - nametag?: string; -} - -interface ImportWalletOptions { - nametag?: string; -} - -interface SphereProviderProps { - children: ReactNode; - network?: NetworkType; -} - -const SphereContext = createContext(null); - -export function SphereProvider({ - children, - network = 'testnet' -}: SphereProviderProps) { - const [sphere, setSphere] = useState(null); - const [providers, setProviders] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [walletExists, setWalletExists] = useState(false); - const [error, setError] = useState(null); - - const initialize = useCallback(async () => { - try { - setIsLoading(true); - setError(null); - - const browserProviders = createBrowserProviders({ network }); - setProviders(browserProviders); - - const exists = await Sphere.exists(browserProviders.storage); - setWalletExists(exists); - - if (exists) { - const { sphere: instance } = await Sphere.init({ - ...browserProviders, - }); - setSphere(instance); - } - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - } finally { - setIsLoading(false); - } - }, [network]); - - useEffect(() => { - initialize(); - }, [initialize]); - - const createWallet = useCallback(async (options?: CreateWalletOptions) => { - if (!providers) throw new Error('Providers not initialized'); - - const { sphere: instance, generatedMnemonic } = await Sphere.init({ - ...providers, - autoGenerate: true, - nametag: options?.nametag, - }); - - setSphere(instance); - setWalletExists(true); - - if (!generatedMnemonic) { - throw new Error('Failed to generate mnemonic'); - } - - return generatedMnemonic; - }, [providers]); - - const importWallet = useCallback(async ( - mnemonic: string, - options?: ImportWalletOptions - ) => { - if (!providers) throw new Error('Providers not initialized'); - - const { sphere: instance } = await Sphere.init({ - ...providers, - mnemonic, - nametag: options?.nametag, - }); - - setSphere(instance); - setWalletExists(true); - }, [providers]); - - const deleteWallet = useCallback(async () => { - if (sphere) { - await sphere.destroy(); - } - if (providers) { - await Sphere.clear(providers.storage); - } - setSphere(null); - setWalletExists(false); - }, [sphere, providers]); - - const value: SphereContextValue = { - sphere, - providers, - isLoading, - isInitialized: !!sphere, - walletExists, - error, - createWallet, - importWallet, - deleteWallet, - reinitialize: initialize, - }; - - return ( - - {children} - - ); -} - -export { SphereContext }; -``` - ---- - -## Phase 5: Hook Exports - -```typescript -// src/sdk/hooks/index.ts - -// Core -export { useSphereContext, useSphere } from './core/useSphere'; -export { useWalletStatus } from './core/useWalletStatus'; -export { useIdentity } from './core/useIdentity'; -export { useNametag } from './core/useNametag'; -export { useSphereEvents } from './core/useSphereEvents'; - -// Payments (L3) -export { useTokens } from './payments/useTokens'; -export { useBalance } from './payments/useBalance'; -export { useAssets } from './payments/useAssets'; -export { useTransfer } from './payments/useTransfer'; -export { useTransactionHistory } from './payments/useTransactionHistory'; - -// L1 -export { useL1Balance } from './l1/useL1Balance'; -export { useL1Utxos } from './l1/useL1Utxos'; -export { useL1Send } from './l1/useL1Send'; -export { useL1Transactions } from './l1/useL1Transactions'; -``` - -```typescript -// src/sdk/index.ts - -// Provider -export { SphereProvider, SphereContext } from './SphereProvider'; -export type { SphereContextValue } from './SphereProvider'; - -// Query keys -export { SPHERE_KEYS } from './queryKeys'; - -// All hooks -export * from './hooks'; - -// Re-export SDK types for convenience -export type { - Identity, - FullIdentity, - Token, - TokenBalance, - Asset, - TransferRequest, - TransferResult, - NetworkType, -} from '@unicitylabs/sphere-sdk'; -``` - ---- - -## Phase 6: Component Migration - -### Updated App Structure - -```typescript -// src/App.tsx (simplified) - -import { SphereProvider } from '@/sdk'; -import { useSphereEvents } from '@/sdk'; - -function AppContent() { - // Subscribe to SDK events for query invalidation - useSphereEvents(); - - return ; -} - -function App() { - return ( - - - - - - ); -} -``` - -### Component Hook Mapping - -| Component | Old Hook | New Hook | -|-----------|----------|----------| -| WalletGate | custom check | `useWalletStatus()` | -| Header | useWallet | `useIdentity()` | -| L3WalletView | useWallet | `useBalance()`, `useAssets()` | -| TokenList | useWallet.tokens | `useTokens()` | -| SendModal | useWallet.sendAmount | `useTransfer()` | -| ReceiveModal | useWallet.identity | `useIdentity()` | -| L1WalletView | useL1Wallet | `useL1Balance()` | -| L1SendModal | useL1Wallet.send | `useL1Send()` | -| TransactionHistory | useTransactionHistory | `useTransactionHistory()` | - ---- - -## Execution Order - -### Step 1: Setup -- [ ] Add `@unicitylabs/sphere-sdk": "file:../sphere-sdk"` to package.json -- [ ] `npm install` -- [ ] Remove IPFS dependencies from package.json -- [ ] Create `src/sdk/` directory structure - -### Step 2: Core Adapter Layer -- [ ] Create `src/sdk/queryKeys.ts` -- [ ] Create `src/sdk/SphereProvider.tsx` -- [ ] Create `src/sdk/hooks/core/useSphere.ts` -- [ ] Create `src/sdk/hooks/core/useWalletStatus.ts` -- [ ] Create `src/sdk/hooks/core/useIdentity.ts` -- [ ] Create `src/sdk/hooks/core/useNametag.ts` -- [ ] Create `src/sdk/hooks/core/useSphereEvents.ts` - -### Step 3: Payments Hooks (L3) -- [ ] Create `src/sdk/hooks/payments/useTokens.ts` -- [ ] Create `src/sdk/hooks/payments/useBalance.ts` -- [ ] Create `src/sdk/hooks/payments/useAssets.ts` -- [ ] Create `src/sdk/hooks/payments/useTransfer.ts` -- [ ] Create `src/sdk/hooks/payments/useTransactionHistory.ts` - -### Step 4: L1 Hooks -- [ ] Create `src/sdk/hooks/l1/useL1Balance.ts` -- [ ] Create `src/sdk/hooks/l1/useL1Utxos.ts` -- [ ] Create `src/sdk/hooks/l1/useL1Send.ts` -- [ ] Create `src/sdk/hooks/l1/useL1Transactions.ts` - -### Step 5: Exports -- [ ] Create `src/sdk/hooks/index.ts` -- [ ] Create `src/sdk/index.ts` - -### Step 6: Cleanup Legacy Code -- [ ] Remove IPFS files (11 files) -- [ ] Remove L3 services (~20 files) -- [ ] Remove L1 SDK (16 files) -- [ ] Remove legacy hooks (4 files) -- [ ] Remove repositories (2 files) -- [ ] Remove UnifiedKeyManager - -### Step 7: Component Migration -- [ ] Update `App.tsx` with SphereProvider -- [ ] Update `WalletGate` -- [ ] Simplify onboarding flow -- [ ] Update wallet views -- [ ] Update modals - -### Step 8: Testing -- [ ] Create wallet flow -- [ ] Import wallet flow -- [ ] Token display -- [ ] Send L3 flow -- [ ] L1 operations - ---- - -## Notes - -### localStorage Cleanup for MVP - -```typescript -// In SphereProvider initialization -const SDK_VERSION = '2.0.0'; -const stored = localStorage.getItem('sphere_sdk_version'); -if (stored !== SDK_VERSION) { - Object.keys(localStorage) - .filter(k => k.startsWith('sphere_')) - .forEach(k => localStorage.removeItem(k)); - localStorage.setItem('sphere_sdk_version', SDK_VERSION); -} -``` - -### Dependencies to Remove After Cleanup - -``` -- @unicitylabs/nostr-js-sdk → SDK includes -- @unicitylabs/state-transition-sdk → SDK includes -- bip39 → SDK includes -- elliptic → SDK includes -- crypto-js → SDK includes -- buffer → SDK includes -``` diff --git a/package-lock.json b/package-lock.json index 1bd336e5..4214b94e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,32 +8,26 @@ "name": "unicity-agentsphere", "version": "0.0.0", "dependencies": { - "@helia/ipns": "^9.1.3", - "@helia/json": "^5.0.3", "@noble/ed25519": "^3.0.0", "@tailwindcss/vite": "^4.1.16", "@tanstack/react-query": "^5.90.10", "@types/katex": "^0.16.7", "@unicitylabs/nostr-js-sdk": "^0.3.2", "@unicitylabs/sphere-sdk": "0.2.3", - "@unicitylabs/state-transition-sdk": "1.6.0", "asmcrypto.js": "^2.3.2", "axios": "^1.13.2", - "bip39": "^3.1.0", - "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "crypto-js": "^4.2.0", "elliptic": "^6.6.1", "framer-motion": "^12.23.24", - "helia": "^6.0.11", "katex": "^0.16.27", "latest": "^0.2.0", "lucide-react": "^0.552.0", "mixpanel-browser": "^2.72.0", "qr-code-styling": "^1.9.2", "react": "^19.1.1", - "react-dom": "^19.1.1", + "react-dom": "^19.2.4", "react-router-dom": "^7.9.6", "uuid": "^13.0.0", "webcrypto-liner": "^1.4.3", @@ -66,68 +60,30 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.26", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.26.tgz", - "integrity": "sha512-UMFbL3EnWH/eTvl21dz9s7Td4wYDMtxz/56zD8sL9IZGYyi48RxmdgPMiyT7R6Vn3rjMTwYZ42bqKa7ex74GEQ==", + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "dev": true, "license": "MIT" }, - "node_modules/@achingbrain/http-parser-js": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@achingbrain/http-parser-js/-/http-parser-js-0.5.9.tgz", - "integrity": "sha512-nPuMf2zVzBAGRigH/1jFpb/6HmJsps+15f4BPlGDp3vsjYB2ZgruAErUpKpcFiVRz3DHLXcGNmuwmqZx/sVI7A==", - "license": "MIT", - "dependencies": { - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@achingbrain/nat-port-mapper": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@achingbrain/nat-port-mapper/-/nat-port-mapper-4.0.5.tgz", - "integrity": "sha512-YAA4MW6jO6W7pmJaFzQ0AOLpu8iQClUkdT2HbfKLmtFjrpoZugnFj9wH8EONV9LxnIW+0W1J98ri+oApKyAKLQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@achingbrain/ssdp": "^4.1.0", - "@chainsafe/is-ip": "^2.0.2", - "@libp2p/logger": "^6.0.5", - "abort-error": "^1.0.0", - "err-code": "^3.0.1", - "netmask": "^2.0.2", - "p-defer": "^4.0.0", - "race-signal": "^2.0.0", - "xml2js": "^0.6.0" - } - }, - "node_modules/@achingbrain/ssdp": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@achingbrain/ssdp/-/ssdp-4.2.4.tgz", - "integrity": "sha512-1dZIV7dwYJRS1sTA0qIDzsMdwZAnPa7DGb2YuPqMq4PjEjvzBBuz2WIsXnrkRFCNY00JuqLiMby9GecnGsOgaQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "abort-error": "^1.0.0", - "freeport-promise": "^2.0.0", - "merge-options": "^3.0.4", - "xml2js": "^0.6.2" - } - }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", - "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.2" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -135,9 +91,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", - "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", "dev": true, "license": "MIT", "dependencies": { @@ -145,13 +101,13 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -169,6 +125,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -180,29 +137,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -222,6 +182,7 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -235,12 +196,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -254,33 +216,36 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -293,6 +258,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -302,6 +268,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -311,6 +278,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -320,19 +288,21 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -342,6 +312,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -353,213 +324,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", @@ -593,9 +357,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -605,6 +370,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -616,30 +382,11 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse--for-generate-function-map": { - "name": "@babel/traverse", "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -657,6 +404,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -666,153 +414,54 @@ "node": ">=6.9.0" } }, - "node_modules/@chainsafe/as-chacha20poly1305": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@chainsafe/as-chacha20poly1305/-/as-chacha20poly1305-0.1.0.tgz", - "integrity": "sha512-BpNcL8/lji/GM3+vZ/bgRWqJ1q5kwvTFmGPk7pxm/QQZDbaMI98waOHjEymTjq2JmdD/INdNBFOVSyJofXg7ew==", - "license": "Apache-2.0" - }, - "node_modules/@chainsafe/as-sha256": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@chainsafe/as-sha256/-/as-sha256-1.2.0.tgz", - "integrity": "sha512-H2BNHQ5C3RS+H0ZvOdovK6GjFAyq5T6LClad8ivwj9Oaiy28uvdsGVS7gNJKuZmg0FGHAI+n7F0Qju6U0QkKDA==", - "license": "Apache-2.0" - }, - "node_modules/@chainsafe/is-ip": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@chainsafe/is-ip/-/is-ip-2.1.0.tgz", - "integrity": "sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==", - "license": "MIT" - }, - "node_modules/@chainsafe/libp2p-noise": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@chainsafe/libp2p-noise/-/libp2p-noise-17.0.0.tgz", - "integrity": "sha512-vwrmY2Y+L1xYhIDiEpl61KHxwrLCZoXzTpwhyk34u+3+6zCAZPL3GxH3i2cs+u5IYNoyLptORdH17RKFXy7upA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@chainsafe/as-chacha20poly1305": "^0.1.0", - "@chainsafe/as-sha256": "^1.2.0", - "@libp2p/crypto": "^5.1.9", - "@libp2p/interface": "^3.0.0", - "@libp2p/peer-id": "^6.0.0", - "@libp2p/utils": "^7.0.0", - "@noble/ciphers": "^2.0.1", - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0", - "wherearewe": "^2.0.1" - } - }, - "node_modules/@chainsafe/libp2p-noise/node_modules/@noble/ciphers": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", - "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", - "license": "MIT", + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=20.19.0" } }, - "node_modules/@chainsafe/libp2p-noise/node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "node_modules/@csstools/css-calc": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.1.tgz", + "integrity": "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, "engines": { - "node": ">= 20.19.0" + "node": ">=20.19.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@chainsafe/libp2p-noise/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@chainsafe/libp2p-yamux": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@chainsafe/libp2p-yamux/-/libp2p-yamux-8.0.1.tgz", - "integrity": "sha512-pJsqmUg1cZRJZn/luAtQaq0uLcVfExo51Rg7iRtAEceNYtsKUi/exfegnvTBzTnF1CGmTzVEV3MCLsRhqiNyoA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.0.0", - "@libp2p/utils": "^7.0.0", - "race-signal": "^2.0.0", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/@chainsafe/netmask": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@chainsafe/netmask/-/netmask-2.0.0.tgz", - "integrity": "sha512-I3Z+6SWUoaljh3TBzCnCxjlUyN8tA+NAk5L6m9IxvCf1BENQTePzPMis97CoN/iMW1St3WN+AWCCRp+TTBRiDg==", - "license": "MIT", - "dependencies": { - "@chainsafe/is-ip": "^2.0.1" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", "dev": true, "funding": [ { @@ -826,21 +475,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -853,17 +502,18 @@ } ], "license": "MIT", + "peer": true, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.20.tgz", - "integrity": "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==", + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", "dev": true, "funding": [ { @@ -875,15 +525,12 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } + "license": "MIT-0" }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -896,27 +543,15 @@ } ], "license": "MIT", + "peer": true, "engines": { - "node": ">=18" - } - }, - "node_modules/@dnsquery/dns-packet": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@dnsquery/dns-packet/-/dns-packet-6.1.1.tgz", - "integrity": "sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==", - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.4", - "utf8-codec": "^1.0.0" - }, - "engines": { - "node": ">=6" + "node": ">=20.19.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -930,9 +565,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -946,9 +581,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -962,9 +597,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -978,9 +613,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -994,9 +629,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -1010,9 +645,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -1026,9 +661,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -1042,9 +677,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -1058,9 +693,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -1074,9 +709,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -1090,9 +725,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -1106,9 +741,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -1122,9 +757,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -1138,9 +773,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -1154,9 +789,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -1170,9 +805,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -1186,9 +821,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -1202,9 +837,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -1218,9 +853,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -1234,9 +869,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -1250,9 +885,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -1266,9 +901,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -1282,9 +917,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1298,9 +933,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1314,9 +949,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1330,9 +965,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1450,9 +1085,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -1486,183 +1121,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@helia/bitswap": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@helia/bitswap/-/bitswap-3.1.2.tgz", - "integrity": "sha512-MHkZFSnamHhoeY4BR4DhYmDWUQzURuYp75dEEI5bg7Lv0tlT0fAyowIAXqzBNBdGBBctokHrCMCAu34W41D7Cg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@helia/interface": "^6.1.1", - "@helia/utils": "^2.4.2", - "@libp2p/interface": "^3.1.0", - "@libp2p/logger": "^6.0.5", - "@libp2p/peer-collections": "^7.0.5", - "@libp2p/utils": "^7.0.5", - "@multiformats/multiaddr": "^13.0.1", - "any-signal": "^4.1.1", - "interface-blockstore": "^6.0.1", - "interface-store": "^7.0.0", - "it-drain": "^3.0.10", - "it-length-prefixed": "^10.0.1", - "it-map": "^3.1.4", - "it-pushable": "^3.2.3", - "it-take": "^3.0.9", - "it-to-buffer": "^4.0.10", - "multiformats": "^13.4.1", - "p-defer": "^4.0.1", - "progress-events": "^1.0.1", - "protons-runtime": "^5.6.0", - "race-event": "^1.6.1", - "uint8-varint": "^2.0.4", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@helia/block-brokers": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@helia/block-brokers/-/block-brokers-5.1.2.tgz", - "integrity": "sha512-Ols4+kpPHyrjWlMaBAqF+75zCfHwaCGRGQPxS8A+5To213bO7Dmfexat6eK+/Sl2lqNmlQQlgn5o0WuXQoyBLg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@helia/bitswap": "^3.1.2", - "@helia/interface": "^6.1.1", - "@helia/utils": "^2.4.2", - "@libp2p/interface": "^3.1.0", - "@libp2p/utils": "^7.0.5", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "@multiformats/multiaddr-to-uri": "^12.0.0", - "interface-blockstore": "^6.0.1", - "interface-store": "^7.0.0", - "multiformats": "^13.4.1", - "progress-events": "^1.0.1", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/@helia/delegated-routing-v1-http-api-client": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@helia/delegated-routing-v1-http-api-client/-/delegated-routing-v1-http-api-client-6.0.1.tgz", - "integrity": "sha512-Y1nGpUQrdN80XSDDAfe7azJFKKD0MxM0mQqfbefNEcrYMM344rHNQJ7xgiSqsH20vMIaKv+NnQqT/MEg2aWv6g==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.0.2", - "@libp2p/peer-id": "^6.0.3", - "@multiformats/multiaddr": "^13.0.1", - "any-signal": "^4.1.1", - "browser-readablestream-to-it": "^2.0.9", - "ipns": "^10.0.2", - "it-first": "^3.0.8", - "it-map": "^3.1.3", - "it-ndjson": "^1.1.3", - "multiformats": "^13.3.6", - "p-defer": "^4.0.1", - "p-queue": "^9.0.0", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@helia/interface": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@helia/interface/-/interface-6.1.1.tgz", - "integrity": "sha512-vcLr6lMB2sE3iweBMr2ZXmugOPw1U2kLppwit7raQ84L1wM/q4ERBQfouaeAA0dntliopXk1luPU8I9glE6PIA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@multiformats/dns": "^1.0.9", - "@multiformats/multiaddr": "^13.0.1", - "interface-blockstore": "^6.0.1", - "interface-datastore": "^9.0.2", - "interface-store": "^7.0.0", - "multiformats": "^13.4.1", - "progress-events": "^1.0.1" - } - }, - "node_modules/@helia/ipns": { - "version": "9.1.9", - "resolved": "https://registry.npmjs.org/@helia/ipns/-/ipns-9.1.9.tgz", - "integrity": "sha512-SbCyTsdkvxkY2NBV6Lg4Xg8XRaAPSfVuYMIHlcctEu9+tEFllAP9cyC3TzDTHBQOj2+qlxTXR2n4ayIGLSZwyg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@helia/interface": "^6.1.1", - "@libp2p/crypto": "^5.1.7", - "@libp2p/interface": "^3.1.0", - "@libp2p/kad-dht": "^16.1.0", - "@libp2p/keychain": "^6.0.5", - "@libp2p/logger": "^6.0.5", - "@libp2p/utils": "^7.0.5", - "interface-datastore": "^9.0.2", - "ipns": "^10.1.2", - "multiformats": "^13.4.1", - "progress-events": "^1.0.1", - "protons-runtime": "^5.5.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@helia/json": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@helia/json/-/json-5.0.7.tgz", - "integrity": "sha512-MayipDUTsEZA0a7g8Za9jljk1CO7QVHG0cbtg55D+j0+0opMghQq6O03wNjrCxBhF06QRm4awDECIRjr2K/DdA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@helia/interface": "^6.1.1", - "@libp2p/interface": "^3.1.0", - "interface-blockstore": "^6.0.1", - "it-to-buffer": "^4.0.10", - "multiformats": "^13.4.1", - "progress-events": "^1.0.1" - } - }, - "node_modules/@helia/routers": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@helia/routers/-/routers-5.0.3.tgz", - "integrity": "sha512-6yiaN8amvHrC1yynWV+HRjk0zPOL2QwB2QzilaF2R0XozqqoCyOOX0NE0Z5rkVA6IyUBSH2J+t+TpgnE5pTaHA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@helia/delegated-routing-v1-http-api-client": "^6.0.0", - "@helia/interface": "^6.1.1", - "@libp2p/interface": "^3.1.0", - "@libp2p/peer-id": "^6.0.3", - "@multiformats/uri-to-multiaddr": "^10.0.0", - "ipns": "^10.1.2", - "it-first": "^3.0.9", - "it-map": "^3.1.4", - "multiformats": "^13.4.1", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@helia/utils": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@helia/utils/-/utils-2.4.2.tgz", - "integrity": "sha512-a+5uTq5+O3aRmbdYW4a2Tm+eRqS6XMy26N+D2i++efubxt2loB195hAIb6Gue9BbzRt/IRKfndfSLVrcav2hZw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@helia/interface": "^6.1.1", - "@ipld/dag-cbor": "^9.2.5", - "@ipld/dag-json": "^10.2.5", - "@ipld/dag-pb": "^4.1.5", - "@libp2p/interface": "^3.1.0", - "@libp2p/keychain": "^6.0.5", - "@libp2p/utils": "^7.0.5", - "@multiformats/dns": "^1.0.9", - "@multiformats/multiaddr": "^13.0.1", - "any-signal": "^4.1.1", - "blockstore-core": "^6.1.1", - "cborg": "^4.2.15", - "interface-blockstore": "^6.0.1", - "interface-datastore": "^9.0.2", - "interface-store": "^7.0.0", - "it-drain": "^3.0.10", - "it-filter": "^3.1.4", - "it-foreach": "^2.1.5", - "it-merge": "^3.0.12", - "it-to-buffer": "^4.0.10", - "libp2p": "^3.0.6", - "mortice": "^3.3.1", - "multiformats": "^13.4.1", - "p-defer": "^4.0.1", - "progress-events": "^1.0.1", - "race-signal": "^2.0.0", - "uint8arrays": "^5.1.0" + "node_modules/@exodus/bytes": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.13.0.tgz", + "integrity": "sha512-VnfL2lS43Z9F8li1faMH9hDZwqfrF5JvOePmrF8oESfo0ijaujnT81zYtienQRpoFa+FJbq0E5rrnMWEW73gOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@humanfs/core": { @@ -1717,6179 +1191,3237 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@ipld/dag-cbor": { - "version": "9.2.5", - "resolved": "https://registry.npmjs.org/@ipld/dag-cbor/-/dag-cbor-9.2.5.tgz", - "integrity": "sha512-84wSr4jv30biui7endhobYhXBQzQE4c/wdoWlFrKcfiwH+ofaPg8fwsM8okX9cOzkkrsAsNdDyH3ou+kiLquwQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "cborg": "^4.0.0", - "multiformats": "^13.1.0" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@ipld/dag-json": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/@ipld/dag-json/-/dag-json-10.2.6.tgz", - "integrity": "sha512-51yc5azhmkvc9mp2HV/vtJ8SlgFXADp55wAPuuAjQZ+yPurAYuTVddS3ke5vT4sjcd4DbE+DWjsMZGXjFB2cuA==", - "license": "Apache-2.0 OR MIT", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", "dependencies": { - "cborg": "^4.4.0", - "multiformats": "^13.1.0" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@ipld/dag-pb": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@ipld/dag-pb/-/dag-pb-4.1.5.tgz", - "integrity": "sha512-w4PZ2yPqvNmlAir7/2hsCRMqny1EY5jj26iZcSgxREJexmbAc2FI21jp26MqiNdfgAxvkCnf2N/TJI18GaDNwA==", - "license": "Apache-2.0 OR MIT", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", "dependencies": { - "multiformats": "^13.1.0" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@ipshipyard/libp2p-auto-tls": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@ipshipyard/libp2p-auto-tls/-/libp2p-auto-tls-2.0.1.tgz", - "integrity": "sha512-zpDXVMY1ZgB6o30zFocXUzrD9+tz1bbEdgewFoBf4olDh5/CwjDi/k9v2RrJqujWKYWyRuHRg6Q+VRpvtGrpuw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@chainsafe/is-ip": "^2.0.2", - "@libp2p/crypto": "^5.0.9", - "@libp2p/http": "^2.0.0", - "@libp2p/interface": "^3.0.2", - "@libp2p/interface-internal": "^3.0.4", - "@libp2p/keychain": "^6.0.4", - "@libp2p/utils": "^7.0.4", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "@peculiar/x509": "^1.12.3", - "acme-client": "^5.4.0", - "any-signal": "^4.1.1", - "delay": "^6.0.0", - "interface-datastore": "^9.0.2", - "multiformats": "^13.3.1", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@ipshipyard/libp2p-auto-tls/node_modules/delay": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/delay/-/delay-6.0.0.tgz", - "integrity": "sha512-2NJozoOHQ4NuZuVIr5CWd0iiLVIRSDepakaovIN+9eIDHEhdCAEvSy2cuf1DCrPPQLvHmbqTHODlhHg8UCy4zw==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6.0.0" } }, - "node_modules/@isaacs/ttlcache": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", - "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "license": "ISC", - "peer": true, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@mixpanel/rrdom": { + "version": "2.0.0-alpha.18.2", + "resolved": "https://registry.npmjs.org/@mixpanel/rrdom/-/rrdom-2.0.0-alpha.18.2.tgz", + "integrity": "sha512-vX/tbnS14ZzzatC7vOyvAm9tOLU8tof0BuppBlphzEx1YHTSw8DQiAmyAc0AmXidchLV0W+cUHV/WsehPLh2hQ==", "license": "MIT", - "peer": true, "dependencies": { - "sprintf-js": "~1.0.2" + "@mixpanel/rrweb-snapshot": "^2.0.0-alpha.18" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@mixpanel/rrweb": { + "version": "2.0.0-alpha.18.2", + "resolved": "https://registry.npmjs.org/@mixpanel/rrweb/-/rrweb-2.0.0-alpha.18.2.tgz", + "integrity": "sha512-J3dVTEu6Z4p8di7y9KKvUooNuBjX97DdG6XGWoPEPi07A9512h9M8MEtvlY3mK0PGfuC0Mz5Pv/Ws6gjGYfKQg==", "license": "MIT", "peer": true, "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" + "@mixpanel/rrdom": "^2.0.0-alpha.18", + "@mixpanel/rrweb-snapshot": "^2.0.0-alpha.18", + "@mixpanel/rrweb-types": "^2.0.0-alpha.18", + "@mixpanel/rrweb-utils": "^2.0.0-alpha.18", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "node_modules/@mixpanel/rrweb-plugin-console-record": { + "version": "2.0.0-alpha.18.2", + "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.18.2.tgz", + "integrity": "sha512-Xkwh2gSdLqHRkWSXv8CPVCPQj5L85KnWc5DZQ0CXNRFgm2hTl5/YP6zfUubVs2JVXZHGcSGU+g7JVO2WcFJyyg==", "license": "MIT", - "peer": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "peerDependencies": { + "@mixpanel/rrweb": "^2.0.0-alpha.18", + "@mixpanel/rrweb-utils": "^2.0.0-alpha.18" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/@mixpanel/rrweb-snapshot": { + "version": "2.0.0-alpha.18.2", + "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.2.tgz", + "integrity": "sha512-2kSnjZZ3QZ9zOz/isOt8s54mXUUDgXk/u0eEi/rE0xBWDeuA0NHrBcqiMc+w4F/yWWUpo5F5zcuPeYpc6ufAsw==", "license": "MIT", - "peer": true, "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" + "postcss": "^8.4.38" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/@mixpanel/rrweb-types": { + "version": "2.0.0-alpha.18.2", + "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-types/-/rrweb-types-2.0.0-alpha.18.2.tgz", + "integrity": "sha512-ucIYe1mfJ2UksvXW+d3bOySTB2/0yUSqQJlUydvbBz6OO2Bhq3nJHyLXV9ExkgUMZm1ZyDcvvmNUd1+5tAXlpA==", + "license": "MIT" + }, + "node_modules/@mixpanel/rrweb-utils": { + "version": "2.0.0-alpha.18.2", + "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-utils/-/rrweb-utils-2.0.0-alpha.18.2.tgz", + "integrity": "sha512-OomKIB6GTx5xvCLJ7iic2khT/t/tnCJUex13aEqsbSqIT/UzUUsqf+LTrgUK5ex+f6odmkCNjre2y5jvpNqn+g==", + "license": "MIT", + "peer": true + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, - "dependencies": { - "p-try": "^2.0.0" - }, "engines": { - "node": ">=6" + "node": "^14.21.3 || >=16" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", "license": "MIT", - "peer": true, "dependencies": { - "p-limit": "^2.2.0" + "@noble/hashes": "1.8.0" }, "engines": { - "node": ">=8" + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/@noble/ed25519": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", + "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", - "peer": true, "engines": { - "node": ">=8" + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@jest/create-cache-key-function": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", - "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", "license": "MIT", - "peer": true, "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", "license": "MIT", - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "tslib": "^2.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8.0.0" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-inject": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", + "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], "license": "MIT", - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], "license": "MIT", - "engines": { - "node": ">=6.0.0" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "license": "MIT" - }, - "node_modules/@libp2p/autonat": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/autonat/-/autonat-3.0.10.tgz", - "integrity": "sha512-JGU2+sKU/6J4lxjNePjfcpus7fw1zf9STFr1MFHp0K8suyb3y3wvMPULNOPEVL4HlQqTkEH7J0PD3LWRnedtOQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/peer-collections": "^7.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "any-signal": "^4.1.1", - "main-event": "^1.0.1", - "multiformats": "^13.4.0", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/@libp2p/bootstrap": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@libp2p/bootstrap/-/bootstrap-12.0.11.tgz", - "integrity": "sha512-ZIG8QKS+4w7ugK7a1ftdopjIA+NvOPKUq7JY1OsRxaiLdCdxgghPTiNIbinYsVv5iHULBnFZe4o5l+5L7+Hssw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/peer-id": "^6.0.4", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "main-event": "^1.0.1" - } - }, - "node_modules/@libp2p/circuit-relay-v2": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@libp2p/circuit-relay-v2/-/circuit-relay-v2-4.1.3.tgz", - "integrity": "sha512-XDgzXu/zMjwHyRSh8xiWlsQk3vGDVSdlukFxb0Eg1VXB2c0ytWgIF5JoynyrNpwXa6Pe0SgGEcUMt9wMaF6/HQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/peer-collections": "^7.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/peer-record": "^9.0.5", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "any-signal": "^4.1.1", - "main-event": "^1.0.1", - "multiformats": "^13.4.0", - "nanoid": "^5.1.5", - "progress-events": "^1.0.1", - "protons-runtime": "^5.6.0", - "retimeable-signal": "^1.0.1", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/circuit-relay-v2/node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@libp2p/config": { - "version": "1.1.25", - "resolved": "https://registry.npmjs.org/@libp2p/config/-/config-1.1.25.tgz", - "integrity": "sha512-kscWoyxM0bR/eFxxLTDoryYe5jy2W6YgbgAADpUPfPwZlR4XsVGjI78zMx+si/LDLTyNmb74lW/g8zv8RN7Bww==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/keychain": "^6.0.10", - "@libp2p/logger": "^6.2.2", - "interface-datastore": "^9.0.1" - } + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@libp2p/crypto": { - "version": "5.1.13", - "resolved": "https://registry.npmjs.org/@libp2p/crypto/-/crypto-5.1.13.tgz", - "integrity": "sha512-8NN9cQP3jDn+p9+QE9ByiEoZ2lemDFf/unTgiKmS3JF93ph240EUVdbCyyEgOMfykzb0okTM4gzvwfx9osJebQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1", - "multiformats": "^13.4.0", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@libp2p/crypto/node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@libp2p/crypto/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@libp2p/dcutr": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/dcutr/-/dcutr-3.0.10.tgz", - "integrity": "sha512-rMBstMznxLgIGNvHFlEHo9Lvx0/+wD2RXB+H7VU58ov1CRQNwlSix38BaQ6PI94LOmVzDPHKl8x3mG6YKp5GEw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "delay": "^7.0.0", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8" - } + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@libp2p/http": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@libp2p/http/-/http-2.0.1.tgz", - "integrity": "sha512-NjTvXdpwlGNvPsjiumRWJ3jm+9euQkKLXzdHnE+cPCEjPWo6cyGGB541161Jgi8CZ5tNTudddlriwkZRb8Z6KQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/http-fetch": "^4.0.0", - "@libp2p/http-peer-id-auth": "^2.0.0", - "@libp2p/http-utils": "^2.0.0", - "@libp2p/http-websocket": "^2.0.0", - "@libp2p/interface": "^3.0.2", - "@libp2p/interface-internal": "^3.0.4", - "@multiformats/multiaddr": "^13.0.1", - "cookie": "^1.0.2", - "undici": "^7.16.0" - } - }, - "node_modules/@libp2p/http-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@libp2p/http-fetch/-/http-fetch-4.0.1.tgz", - "integrity": "sha512-7vtJVOfyGol6CWrNm9HhjlYOmCsJVLKWYdhpmjdpS6pGWtpkTMrHJLznSJ7PYkMq7OnhzhXNFq0FhWygP6mmPQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@achingbrain/http-parser-js": "^0.5.9", - "@libp2p/http-utils": "^2.0.0", - "@libp2p/interface": "^3.0.2", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/http-peer-id-auth": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@libp2p/http-peer-id-auth/-/http-peer-id-auth-2.0.0.tgz", - "integrity": "sha512-GKs0DXK/JVKKH57IGQDiWsC6hYsLY+cwKNRMuX1FY6FZo09zc1QPwvgr0FNtIB2c5WJFf/vja4M4QekLsWU+xw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.12", - "@libp2p/interface": "^3.0.2", - "@libp2p/peer-id": "^6.0.3", - "uint8-varint": "^2.0.4", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/http-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@libp2p/http-utils/-/http-utils-2.0.1.tgz", - "integrity": "sha512-dJFRV2gAzPkF5NOnGMdWXXO3PFK0cMSn5uDbW55n5Usnrx6hHQmDCRfKh3ClQUzjG66pFjXM3zFXLKORyasl3A==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@achingbrain/http-parser-js": "^0.5.9", - "@libp2p/interface": "^3.0.2", - "@libp2p/peer-id": "^6.0.3", - "@libp2p/utils": "^7.0.4", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-to-uri": "^12.0.0", - "@multiformats/uri-to-multiaddr": "^10.0.0", - "it-to-browser-readablestream": "^2.0.12", - "multiformats": "^13.4.1", - "race-event": "^1.6.1", - "readable-stream": "^4.7.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/http-utils/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@libp2p/http-websocket": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@libp2p/http-websocket/-/http-websocket-2.0.1.tgz", - "integrity": "sha512-hMMWVKAK3P3oAmatUB8SQ4mUMhkkLdERAjgZUoKdohIPumPGQ6ADFSJMYsSWv9ZwyBiXMHBbwluYEBZUw85GCw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@achingbrain/http-parser-js": "^0.5.9", - "@libp2p/http-utils": "^2.0.0", - "@libp2p/interface": "^3.0.2", - "@libp2p/interface-internal": "^3.0.4", - "@libp2p/utils": "^7.0.4", - "@multiformats/multiaddr": "^13.0.1", - "multiformats": "^13.4.1", - "race-event": "^1.6.1", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/identify": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/identify/-/identify-4.0.10.tgz", - "integrity": "sha512-DROyV+bZIlz9czCCHJdeVtm1+hEOKUigJHyTzzA/cuwwyvtm8Dco8F+VRYcrwpafuVtjv7yN7CskN4oIys56jw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/peer-record": "^9.0.5", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "it-drain": "^3.0.10", - "it-parallel": "^3.0.13", - "main-event": "^1.0.1", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/interface": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@libp2p/interface/-/interface-3.1.0.tgz", - "integrity": "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@multiformats/dns": "^1.0.6", - "@multiformats/multiaddr": "^13.0.1", - "main-event": "^1.0.1", - "multiformats": "^13.4.0", - "progress-events": "^1.0.1", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/@libp2p/interface-internal": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/interface-internal/-/interface-internal-3.0.10.tgz", - "integrity": "sha512-Gd/eQAoAlXqeCRJ6wOwcnTQ/SDe95bQow8osY8zq0nbfFBu26aChQHjAd+CjcCADJRh+Sd+7+dYG7BrhpxGt1A==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/peer-collections": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "progress-events": "^1.0.1" - } - }, - "node_modules/@libp2p/kad-dht": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/@libp2p/kad-dht/-/kad-dht-16.1.3.tgz", - "integrity": "sha512-yM9UumHkN8Dd+nFUllOio3/0uuzzpPgc/+PouDAABWs2ut36VfizhWVWAiqlLpzkpCquIzPUd0doRu0GKztdXA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/peer-collections": "^7.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/ping": "^3.0.10", - "@libp2p/record": "^4.0.9", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "any-signal": "^4.1.1", - "interface-datastore": "^9.0.1", - "it-all": "^3.0.9", - "it-drain": "^3.0.10", - "it-length": "^3.0.9", - "it-map": "^3.1.4", - "it-merge": "^3.0.12", - "it-parallel": "^3.0.13", - "it-pipe": "^3.0.1", - "it-pushable": "^3.2.3", - "it-take": "^3.0.9", - "main-event": "^1.0.1", - "multiformats": "^13.4.0", - "p-defer": "^4.0.1", - "p-event": "^7.0.0", - "progress-events": "^1.0.1", - "protons-runtime": "^5.6.0", - "race-signal": "^2.0.0", - "uint8-varint": "^2.0.4", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/keychain": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/keychain/-/keychain-6.0.10.tgz", - "integrity": "sha512-f80yJSzKb3Vh8KtdNCxiPUu8qjyT6b+nQlS+jSmSDnMGXI8z49wdtfKuigQsKft64qt2mKMNq/9OBWyhUMYPFQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@noble/hashes": "^2.0.1", - "asn1js": "^3.0.6", - "interface-datastore": "^9.0.1", - "multiformats": "^13.4.0", - "sanitize-filename": "^1.6.3", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/keychain/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@libp2p/logger": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@libp2p/logger/-/logger-6.2.2.tgz", - "integrity": "sha512-XtanXDT+TuMuZoCK760HGV1AmJsZbwAw5AiRUxWDbsZPwAroYq64nb41AHRu9Gyc0TK9YD+p72+5+FIxbw0hzw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@multiformats/multiaddr": "^13.0.1", - "interface-datastore": "^9.0.1", - "multiformats": "^13.4.0", - "weald": "^1.1.0" - } - }, - "node_modules/@libp2p/mdns": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@libp2p/mdns/-/mdns-12.0.11.tgz", - "integrity": "sha512-OB6am5A21Yc5c7KBZONQhTao4BHRDc3MurZ1qHzqU4FQidi719cNRw4ac6TVk4dcdtOYx+1ef8pvvLX+57hXAQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@types/multicast-dns": "^7.2.4", - "dns-packet": "^5.6.1", - "main-event": "^1.0.1", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/@libp2p/mplex": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@libp2p/mplex/-/mplex-12.0.11.tgz", - "integrity": "sha512-jD77lX3FkgHM4FdznF5G2aN8G6BoQrPZPTdox56KvFOUjUKs04KoiC58kuSHVuEo/25gBTpjTLnYJN07ixC3vg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/utils": "^7.0.10", - "it-pushable": "^3.2.3", - "uint8-varint": "^2.0.4", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/multistream-select": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/multistream-select/-/multistream-select-7.0.10.tgz", - "integrity": "sha512-6RAFctqWzwQ/qPaN3CxoueSs1b7pBVMZ+0n6G0kcsqVBj0wc4eB+dcJyUNrTV1NGgMCAl6tVAGztZaE8XZc9lw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/utils": "^7.0.10", - "it-length-prefixed": "^10.0.1", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/peer-collections": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/peer-collections/-/peer-collections-7.0.10.tgz", - "integrity": "sha512-OvlSY5N3J6q8U+EbTrQGbW8zdyOa3y7nz9Y3IbuE55tIiMd7pwm1U3Lknfb6IPkOWkHNfQDfCGGfGVQcMRodvQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/utils": "^7.0.10", - "multiformats": "^13.4.0" - } - }, - "node_modules/@libp2p/peer-id": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@libp2p/peer-id/-/peer-id-6.0.4.tgz", - "integrity": "sha512-Z3xK0lwwKn4bPg3ozEpPr1HxsRi2CxZdghOL+MXoFah/8uhJJHxHFA8A/jxtKn4BB8xkk6F8R5vKNIS05yaCYw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "multiformats": "^13.4.0", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/peer-record": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@libp2p/peer-record/-/peer-record-9.0.5.tgz", - "integrity": "sha512-disk23OO00yD52O4VmItbDkjJZ/YZJsKbMsqNgVhr+D3PcM+KRpu9VVbiCnN5Tzn9XvFEHhrMJY7BPE+rvT5MQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/peer-id": "^6.0.4", - "@multiformats/multiaddr": "^13.0.1", - "multiformats": "^13.4.0", - "protons-runtime": "^5.6.0", - "uint8-varint": "^2.0.4", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/peer-store": { - "version": "12.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/peer-store/-/peer-store-12.0.10.tgz", - "integrity": "sha512-fe/6m0vXny9pvCyaSjg2GisdSVgxtHYZtp6op1WNm8dBvYqRXLuqSYi0QGEbLtSDSL4SeE8BKZyadyk/tYAqfg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/peer-collections": "^7.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/peer-record": "^9.0.5", - "@multiformats/multiaddr": "^13.0.1", - "interface-datastore": "^9.0.1", - "it-all": "^3.0.9", - "main-event": "^1.0.1", - "mortice": "^3.3.1", - "multiformats": "^13.4.0", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/ping": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/ping/-/ping-3.0.10.tgz", - "integrity": "sha512-XkwQOOrmIa1/9t2xq0+Zm3rWkyO+Q0SavlM3t6WkDjxC4F3h0MaYep2CX5BBWD2mZWyy8YdeQTF3N9YhRr4irg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@multiformats/multiaddr": "^13.0.1", - "p-event": "^7.0.0", - "race-signal": "^2.0.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/record": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@libp2p/record/-/record-4.0.9.tgz", - "integrity": "sha512-ITxntqQ2GDK/yA1NhzEQc2dXpxgox96xZ1cqO507choY5z5Czhz2BxfyElVO/XYjOXvylu1XN66uh3VuGHrfkQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/tcp": { - "version": "11.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/tcp/-/tcp-11.0.10.tgz", - "integrity": "sha512-vp1XvbRUU6JyVZMDfrr8UX+xs1sybT2r3PFoN5m07r3GSrMMPOKpWN2HkhT2pCBZWJG6ADQOy5+K0tBRE782oA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "@types/sinon": "^20.0.0", - "main-event": "^1.0.1", - "p-event": "^7.0.0", - "progress-events": "^1.0.1", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/@libp2p/tls": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/tls/-/tls-3.0.10.tgz", - "integrity": "sha512-O/e/kEzXZPgHb1asyN1P4hCcECQnFEiGAQCgjkKU/nTjHYCvWG0CAU5uJuJkj9RXLpDFPVZ38FMN3dSzx0Ny7Q==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/utils": "^7.0.10", - "@peculiar/asn1-schema": "^2.4.0", - "@peculiar/asn1-x509": "^2.4.0", - "@peculiar/webcrypto": "^1.5.0", - "@peculiar/x509": "^1.13.0", - "asn1js": "^3.0.6", - "p-event": "^7.0.0", - "protons-runtime": "^5.6.0", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/upnp-nat": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/upnp-nat/-/upnp-nat-4.0.10.tgz", - "integrity": "sha512-pEVLzDI7hY37vxjQyPvY6naWavUB5icTTLUtu/mHLvlb79jYX/NspIhUlbPcYFGH5dTD4NBaqHn6k3otOHssiw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@achingbrain/nat-port-mapper": "^4.0.4", - "@chainsafe/is-ip": "^2.1.0", - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "main-event": "^1.0.1", - "p-defer": "^4.0.1", - "race-signal": "^2.0.0" - } - }, - "node_modules/@libp2p/utils": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@libp2p/utils/-/utils-7.0.10.tgz", - "integrity": "sha512-+mzD+7yLMoZ8+34y/iS9d1CnwHjJJ/qEsao9FckHf9T9tnVXEyLLu9TpzBCcGRm4fUK/QCSHK2AcZH50kkAFkw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@chainsafe/is-ip": "^2.1.0", - "@chainsafe/netmask": "^2.0.0", - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/logger": "^6.2.2", - "@multiformats/multiaddr": "^13.0.1", - "@sindresorhus/fnv1a": "^3.1.0", - "any-signal": "^4.1.1", - "cborg": "^4.2.14", - "delay": "^7.0.0", - "is-loopback-addr": "^2.0.2", - "it-length-prefixed": "^10.0.1", - "it-pipe": "^3.0.1", - "it-pushable": "^3.2.3", - "it-stream-types": "^2.0.2", - "main-event": "^1.0.1", - "netmask": "^2.0.2", - "p-defer": "^4.0.1", - "p-event": "^7.0.0", - "race-signal": "^2.0.0", - "uint8-varint": "^2.0.4", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/webrtc": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/@libp2p/webrtc/-/webrtc-6.0.11.tgz", - "integrity": "sha512-7Y1w3zA5625N/myagH/bWFq6PzFxadhYGvQMPOjO7mLcRgq/mmeBuP6ZrHWGIMJT5FsftrlJDI0S8//pieA9Xg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@chainsafe/is-ip": "^2.1.0", - "@chainsafe/libp2p-noise": "^17.0.0", - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/keychain": "^6.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "@peculiar/webcrypto": "^1.5.0", - "@peculiar/x509": "^1.13.0", - "detect-browser": "^5.3.0", - "get-port": "^7.1.0", - "interface-datastore": "^9.0.1", - "it-length-prefixed": "^10.0.1", - "it-protobuf-stream": "^2.0.3", - "it-pushable": "^3.2.3", - "it-stream-types": "^2.0.2", - "main-event": "^1.0.1", - "multiformats": "^13.4.0", - "node-datachannel": "^0.29.0", - "p-defer": "^4.0.1", - "p-event": "^7.0.0", - "p-timeout": "^7.0.0", - "p-wait-for": "^6.0.0", - "progress-events": "^1.0.1", - "protons-runtime": "^5.6.0", - "race-signal": "^2.0.0", - "react-native-webrtc": "^124.0.6", - "uint8-varint": "^2.0.4", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/@libp2p/websockets": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@libp2p/websockets/-/websockets-10.1.3.tgz", - "integrity": "sha512-TzH7ja1Ay7zIXif5eYSRUAupqtRotUyNegumRPFV+DjiqOYK2DiZd8Z6QTG1iVUsUXMXrWihbFkR96zyQ9eajw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/interface": "^3.1.0", - "@libp2p/utils": "^7.0.10", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "@multiformats/multiaddr-to-uri": "^12.0.0", - "main-event": "^1.0.1", - "p-event": "^7.0.0", - "progress-events": "^1.0.1", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0", - "ws": "^8.18.3" - } - }, - "node_modules/@mixpanel/rrdom": { - "version": "2.0.0-alpha.18.2", - "resolved": "https://registry.npmjs.org/@mixpanel/rrdom/-/rrdom-2.0.0-alpha.18.2.tgz", - "integrity": "sha512-vX/tbnS14ZzzatC7vOyvAm9tOLU8tof0BuppBlphzEx1YHTSw8DQiAmyAc0AmXidchLV0W+cUHV/WsehPLh2hQ==", - "license": "MIT", - "dependencies": { - "@mixpanel/rrweb-snapshot": "^2.0.0-alpha.18" - } - }, - "node_modules/@mixpanel/rrweb": { - "version": "2.0.0-alpha.18.2", - "resolved": "https://registry.npmjs.org/@mixpanel/rrweb/-/rrweb-2.0.0-alpha.18.2.tgz", - "integrity": "sha512-J3dVTEu6Z4p8di7y9KKvUooNuBjX97DdG6XGWoPEPi07A9512h9M8MEtvlY3mK0PGfuC0Mz5Pv/Ws6gjGYfKQg==", - "license": "MIT", - "dependencies": { - "@mixpanel/rrdom": "^2.0.0-alpha.18", - "@mixpanel/rrweb-snapshot": "^2.0.0-alpha.18", - "@mixpanel/rrweb-types": "^2.0.0-alpha.18", - "@mixpanel/rrweb-utils": "^2.0.0-alpha.18", - "@types/css-font-loading-module": "0.0.7", - "@xstate/fsm": "^1.4.0", - "base64-arraybuffer": "^1.0.1", - "mitt": "^3.0.0" - } - }, - "node_modules/@mixpanel/rrweb-plugin-console-record": { - "version": "2.0.0-alpha.18.2", - "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.18.2.tgz", - "integrity": "sha512-Xkwh2gSdLqHRkWSXv8CPVCPQj5L85KnWc5DZQ0CXNRFgm2hTl5/YP6zfUubVs2JVXZHGcSGU+g7JVO2WcFJyyg==", - "license": "MIT", - "peerDependencies": { - "@mixpanel/rrweb": "^2.0.0-alpha.18", - "@mixpanel/rrweb-utils": "^2.0.0-alpha.18" - } - }, - "node_modules/@mixpanel/rrweb-snapshot": { - "version": "2.0.0-alpha.18.2", - "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.2.tgz", - "integrity": "sha512-2kSnjZZ3QZ9zOz/isOt8s54mXUUDgXk/u0eEi/rE0xBWDeuA0NHrBcqiMc+w4F/yWWUpo5F5zcuPeYpc6ufAsw==", - "license": "MIT", - "dependencies": { - "postcss": "^8.4.38" - } - }, - "node_modules/@mixpanel/rrweb-types": { - "version": "2.0.0-alpha.18.2", - "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-types/-/rrweb-types-2.0.0-alpha.18.2.tgz", - "integrity": "sha512-ucIYe1mfJ2UksvXW+d3bOySTB2/0yUSqQJlUydvbBz6OO2Bhq3nJHyLXV9ExkgUMZm1ZyDcvvmNUd1+5tAXlpA==", - "license": "MIT" - }, - "node_modules/@mixpanel/rrweb-utils": { - "version": "2.0.0-alpha.18.2", - "resolved": "https://registry.npmjs.org/@mixpanel/rrweb-utils/-/rrweb-utils-2.0.0-alpha.18.2.tgz", - "integrity": "sha512-OomKIB6GTx5xvCLJ7iic2khT/t/tnCJUex13aEqsbSqIT/UzUUsqf+LTrgUK5ex+f6odmkCNjre2y5jvpNqn+g==", - "license": "MIT" - }, - "node_modules/@multiformats/dns": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.13.tgz", - "integrity": "sha512-yr4bxtA3MbvJ+2461kYIYMsiiZj/FIqKI64hE4SdvWJUdWF9EtZLar38juf20Sf5tguXKFUruluswAO6JsjS2w==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@dnsquery/dns-packet": "^6.1.1", - "@libp2p/interface": "^3.1.0", - "hashlru": "^2.3.0", - "p-queue": "^9.0.0", - "progress-events": "^1.0.0", - "uint8arrays": "^5.0.2" - } - }, - "node_modules/@multiformats/multiaddr": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@multiformats/multiaddr/-/multiaddr-13.0.1.tgz", - "integrity": "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@chainsafe/is-ip": "^2.0.1", - "multiformats": "^13.0.0", - "uint8-varint": "^2.0.1", - "uint8arrays": "^5.0.0" - } - }, - "node_modules/@multiformats/multiaddr-matcher": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@multiformats/multiaddr-matcher/-/multiaddr-matcher-3.0.1.tgz", - "integrity": "sha512-jvjwzCPysVTQ53F4KqwmcqZw73BqHMk0UUZrMP9P4OtJ/YHrfs122ikTqhVA2upe0P/Qz9l8HVlhEifVYB2q9A==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@multiformats/multiaddr": "^13.0.0" - } - }, - "node_modules/@multiformats/multiaddr-to-uri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@multiformats/multiaddr-to-uri/-/multiaddr-to-uri-12.0.0.tgz", - "integrity": "sha512-3uIEBCiy8tfzxYYBl81x1tISiNBQ7mHU4pGjippbJRoQYHzy/ZdZM/7JvTldr8pc/dzpkaNJxnsuxxlhsPOJsA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@multiformats/multiaddr": "^13.0.0" - } - }, - "node_modules/@multiformats/uri-to-multiaddr": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@multiformats/uri-to-multiaddr/-/uri-to-multiaddr-10.0.0.tgz", - "integrity": "sha512-QsmwLmY6iB1wDU1e1wyctqF0eP/2KD1QPLQ+APISuqETbCTSpaq159S/K/ssmWlBpSEkhH0SUfBUgGi014Ttfw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@multiformats/multiaddr": "^13.0.0", - "is-ip": "^5.0.0" - } - }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/ed25519": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", - "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", - "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-csr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", - "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-ecc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", - "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pfx": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", - "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", - "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", - "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pfx": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-rsa": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", - "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", - "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", - "license": "MIT", - "dependencies": { - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", - "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", - "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/webcrypto": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", - "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/json-schema": "^1.1.12", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2", - "webcrypto-core": "^1.8.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", - "tslib": "^2.8.1", - "tsyringe": "^4.10.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@react-native/assets-registry": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.1.tgz", - "integrity": "sha512-AT7/T6UwQqO39bt/4UL5EXvidmrddXrt0yJa7ENXndAv+8yBzMsZn6fyiax6+ERMt9GLzAECikv3lj22cn2wJA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/codegen": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.83.1.tgz", - "integrity": "sha512-FpRxenonwH+c2a5X5DZMKUD7sCudHxB3eSQPgV9R+uxd28QWslyAWrpnJM/Az96AEksHnymDzEmzq2HLX5nb+g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/parser": "^7.25.3", - "glob": "^7.1.1", - "hermes-parser": "0.32.0", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "yargs": "^17.6.2" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/@react-native/community-cli-plugin": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.83.1.tgz", - "integrity": "sha512-FqR1ftydr08PYlRbrDF06eRiiiGOK/hNmz5husv19sK6iN5nHj1SMaCIVjkH/a5vryxEddyFhU6PzO/uf4kOHg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@react-native/dev-middleware": "0.83.1", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "metro": "^0.83.3", - "metro-config": "^0.83.3", - "metro-core": "^0.83.3", - "semver": "^7.1.3" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@react-native-community/cli": "*", - "@react-native/metro-config": "*" - }, - "peerDependenciesMeta": { - "@react-native-community/cli": { - "optional": true - }, - "@react-native/metro-config": { - "optional": true - } - } - }, - "node_modules/@react-native/community-cli-plugin/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@react-native/debugger-frontend": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.83.1.tgz", - "integrity": "sha512-01Rn3goubFvPjHXONooLmsW0FLxJDKIUJNOlOS0cPtmmTIx9YIjxhe/DxwHXGk7OnULd7yl3aYy7WlBsEd5Xmg==", - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/debugger-shell": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.83.1.tgz", - "integrity": "sha512-d+0w446Hxth5OP/cBHSSxOEpbj13p2zToUy6e5e3tTERNJ8ueGlW7iGwGTrSymNDgXXFjErX+dY4P4/3WokPIQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "fb-dotslash": "0.5.8" - }, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/dev-middleware": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.83.1.tgz", - "integrity": "sha512-QJaSfNRzj3Lp7MmlCRgSBlt1XZ38xaBNXypXAp/3H3OdFifnTZOeYOpFmcpjcXYnDqkxetuwZg8VL65SQhB8dg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.83.1", - "@react-native/debugger-shell": "0.83.1", - "chrome-launcher": "^0.15.2", - "chromium-edge-launcher": "^0.2.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "open": "^7.0.3", - "serve-static": "^1.16.2", - "ws": "^7.5.10" - }, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@react-native/gradle-plugin": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.83.1.tgz", - "integrity": "sha512-6ESDnwevp1CdvvxHNgXluil5OkqbjkJAkVy7SlpFsMGmVhrSxNAgD09SSRxMNdKsnLtzIvMsFCzyHLsU/S4PtQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/js-polyfills": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.83.1.tgz", - "integrity": "sha512-qgPpdWn/c5laA+3WoJ6Fak8uOm7CG50nBsLlPsF8kbT7rUHIVB9WaP6+GPsoKV/H15koW7jKuLRoNVT7c3Ht3w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/normalize-colors": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.1.tgz", - "integrity": "sha512-84feABbmeWo1kg81726UOlMKAhcQyFXYz2SjRKYkS78QmfhVDhJ2o/ps1VjhFfBz0i/scDwT1XNv9GwmRIghkg==", - "license": "MIT", - "peer": true - }, - "node_modules/@react-native/virtualized-lists": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.83.1.tgz", - "integrity": "sha512-MdmoAbQUTOdicCocm5XAFDJWsswxk7hxa6ALnm6Y88p01HFML0W593hAn6qOt9q6IM1KbAcebtH6oOd4gcQy8w==", - "license": "MIT", - "peer": true, - "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@types/react": "^19.2.0", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-inject": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", - "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "license": "MIT", - "peer": true - }, - "node_modules/@sindresorhus/fnv1a": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/fnv1a/-/fnv1a-3.1.0.tgz", - "integrity": "sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@stablelib/binary": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", - "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", - "license": "MIT", - "dependencies": { - "@stablelib/int": "^1.0.1" - } - }, - "node_modules/@stablelib/hash": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-1.0.1.tgz", - "integrity": "sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==", - "license": "MIT" - }, - "node_modules/@stablelib/int": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", - "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", - "license": "MIT" - }, - "node_modules/@stablelib/sha3": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/sha3/-/sha3-1.0.1.tgz", - "integrity": "sha512-82OHZcxWsJAS34L64VItIbqZdcdYgBJmeToYaou9lUA+iMjajdfOVZDDrditfV8C8yXUDrlS3BuMRWmKf9NQhQ==", - "license": "MIT", - "dependencies": { - "@stablelib/binary": "^1.0.1", - "@stablelib/hash": "^1.0.1", - "@stablelib/wipe": "^1.0.1" - } - }, - "node_modules/@stablelib/wipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", - "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.90.11", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", - "integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.90.11", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz", - "integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.90.11" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/bn.js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", - "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/crypto-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", - "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/css-font-loading-module": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", - "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", - "license": "MIT" - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/dns-packet": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/@types/dns-packet/-/dns-packet-5.6.5.tgz", - "integrity": "sha512-qXOC7XLOEe43ehtWJCMnQXvgcIpv6rPmQ1jXT98Ad8A3TB1Ue50jsCbSSSyuazScEuZ/Q026vHbrOTVkmwA+7Q==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/elliptic": { - "version": "6.4.18", - "resolved": "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.18.tgz", - "integrity": "sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/bn.js": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/katex": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", - "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", - "license": "MIT" - }, - "node_modules/@types/multicast-dns": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/@types/multicast-dns/-/multicast-dns-7.2.4.tgz", - "integrity": "sha512-ib5K4cIDR4Ro5SR3Sx/LROkMDa0BHz0OPaCBL/OSPDsAXEGZ3/KQeS6poBKYVN7BfjXDL9lWNwzyHVgt/wkyCw==", - "license": "MIT", - "dependencies": { - "@types/dns-packet": "*", - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/sinon": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-20.0.0.tgz", - "integrity": "sha512-etYGUC6IEevDGSWvR9WrECRA01ucR2/Oi9XMBUAdV0g4bLkNf4HlZWGiGlDOq5lgwXRwcV+PSeKgFcW4QzzYOg==", - "license": "MIT", - "dependencies": { - "@types/sinonjs__fake-timers": "*" - } - }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", - "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", - "license": "MIT" - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/uuid": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", - "integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==", - "deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "uuid": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", - "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/type-utils": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", - "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", - "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", - "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", - "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", - "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", - "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", - "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.48.0", - "@typescript-eslint/tsconfig-utils": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", - "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", - "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@unicitylabs/nostr-js-sdk": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@unicitylabs/nostr-js-sdk/-/nostr-js-sdk-0.3.2.tgz", - "integrity": "sha512-t4zx9XgsfC1ZHRl17LfbY9zWdiLmSU2AG+CCi7FVR7NewIQbPG6+4eiizaqWSSdIgkHs1LfPlR3xeYKKNwswzA==", - "license": "MIT", - "dependencies": { - "@noble/ciphers": "^1.0.0", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/base": "^1.1.9", - "libphonenumber-js": "^1.11.14" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@unicitylabs/sphere-sdk": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-sdk/-/sphere-sdk-0.2.3.tgz", - "integrity": "sha512-lEAY8Q5wLR1obBatWqIzEX6dXJsykY6QMm8Nbl9lTX3e7pKyy+vUdgQwtYaWc3Sw6vjfUHC7Ju5ZW/BrMjX0qg==", - "license": "MIT", - "dependencies": { - "@noble/curves": "^1.9.7", - "@noble/hashes": "^2.0.1", - "@unicitylabs/nostr-js-sdk": "^0.3.2", - "@unicitylabs/state-transition-sdk": "1.6.1-rc.f37cb85", - "bip39": "^3.1.0", - "buffer": "^6.0.3", - "crypto-js": "^4.2.0", - "elliptic": "^6.6.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@helia/ipns": "^9.1.3", - "@helia/json": "^5.0.3", - "helia": "^6.0.11" - }, - "peerDependencies": { - "@helia/ipns": ">=9.0.0", - "@helia/json": ">=5.0.0", - "helia": ">=6.0.0", - "ws": ">=8.0.0" - }, - "peerDependenciesMeta": { - "@helia/ipns": { - "optional": true - }, - "@helia/json": { - "optional": true - }, - "helia": { - "optional": true - }, - "ws": { - "optional": true - } - } - }, - "node_modules/@unicitylabs/sphere-sdk/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@unicitylabs/sphere-sdk/node_modules/@unicitylabs/state-transition-sdk": { - "version": "1.6.1-rc.f37cb85", - "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-1.6.1-rc.f37cb85.tgz", - "integrity": "sha512-6chybquV+sZPdaqluJhAeceCWyO5SO2K2j8QI/RhN6cbX4wHILumfG3GKm20ubQZTL80yTfj85kMxsbKeUIGUQ==", - "license": "ISC", - "dependencies": { - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "uuid": "13.0.0" - } - }, - "node_modules/@unicitylabs/sphere-sdk/node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@unicitylabs/state-transition-sdk": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-1.6.0.tgz", - "integrity": "sha512-hf837MFBGQuUZoWdVHYyWohH6M/0O8oc3w8OjupvOml54D/NMTWK8ifajh5CvfmrrAoEqJxeX3uAkY/wB6Ik8Q==", - "license": "ISC", - "dependencies": { - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "uuid": "13.0.0" - } - }, - "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", - "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.47", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", - "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", - "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.15", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", - "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", - "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.15", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", - "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.15", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", - "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", - "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.15", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@xstate/fsm": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", - "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", - "license": "MIT" - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/abort-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/abort-error/-/abort-error-1.0.1.tgz", - "integrity": "sha512-fxqCblJiIPdSXIUrxI0PL+eJG49QdP9SQ70qtB65MVAoMr2rASlOyAbJFOylfB467F/f+5BCLJJq58RYi7mGfg==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acme-client": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", - "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", - "license": "MIT", - "dependencies": { - "@peculiar/x509": "^1.11.0", - "asn1js": "^3.0.5", - "axios": "^1.7.2", - "debug": "^4.3.5", - "node-forge": "^1.3.1" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], "license": "MIT", - "engines": { - "node": ">= 14" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/anser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", - "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], "license": "MIT", - "peer": true + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], "license": "MIT", - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-signal": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/any-signal/-/any-signal-4.2.0.tgz", - "integrity": "sha512-LndMvYuAPf4rC195lk7oSFuHOYFpOszIYrNYv0gHAvz+aEhE9qPZLhmrIz5pXP2BSsPOXvsuHDXEGaiQhIh9wA==", - "license": "Apache-2.0 OR MIT", - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "peer": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], "license": "MIT", - "peer": true - }, - "node_modules/asmcrypto.js": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", - "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", - "license": "MIT" + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/asn1.js": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", - "dev": true, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/asn1js": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", - "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", - "license": "BSD-3-Clause", - "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/assert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "dev": true, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], "license": "MIT", - "engines": { - "node": ">=12" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" ], "license": "MIT", - "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", - "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "node_modules/@stablelib/binary": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", + "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "@stablelib/int": "^1.0.1" } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } + "node_modules/@stablelib/hash": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-1.0.1.tgz", + "integrity": "sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==", + "license": "MIT" }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/@stablelib/int": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", + "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", + "license": "MIT" }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "node_modules/@stablelib/sha3": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/sha3/-/sha3-1.0.1.tgz", + "integrity": "sha512-82OHZcxWsJAS34L64VItIbqZdcdYgBJmeToYaou9lUA+iMjajdfOVZDDrditfV8C8yXUDrlS3BuMRWmKf9NQhQ==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@stablelib/binary": "^1.0.1", + "@stablelib/hash": "^1.0.1", + "@stablelib/wipe": "^1.0.1" } }, - "node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", - "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", - "license": "MIT", - "peer": true, - "dependencies": { - "hermes-parser": "0.32.0" - } + "node_modules/@stablelib/wipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", + "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", + "license": "MIT" }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" } }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "license": "MIT", - "peer": true, - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 10" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 0.6.0" + "node": ">= 10" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", - "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/bip39": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", - "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", - "license": "ISC", - "dependencies": { - "@noble/hashes": "^1.2.0" + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" ], "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/blockstore-core": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/blockstore-core/-/blockstore-core-6.1.2.tgz", - "integrity": "sha512-yWU38RM8DJ6C7Y2shIeTNVgGiJX/ko2RXqDyNlxMakOc+aVS7k1SCiakMlh6ix0juRNPtj0ySMTXU8UBDXXRCQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/logger": "^6.0.0", - "interface-blockstore": "^6.0.0", - "interface-store": "^7.0.0", - "it-all": "^3.0.9", - "it-filter": "^3.1.3", - "it-merge": "^3.0.11", - "multiformats": "^13.3.6" + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], "license": "MIT", - "peer": true, + "optional": true, "dependencies": { - "fill-range": "^7.1.1" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" }, "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, - "node_modules/browser-readablestream-to-it": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/browser-readablestream-to-it/-/browser-readablestream-to-it-2.0.10.tgz", - "integrity": "sha512-I/9hEcRtjct8CzD9sVo9Mm4ntn0D+7tOVrjbPl69XAoOfgJ8NBdOQU+WX+5SHhcELJDb14mWt7zuvyqha+MEAQ==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/browser-resolve": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", - "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", - "dev": true, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "resolve": "^1.17.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dev": true, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dev": true, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "license": "MIT", "dependencies": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" } }, - "node_modules/browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dev": true, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/browserify-rsa": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", - "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", - "dev": true, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", "dependencies": { - "bn.js": "^5.2.1", - "randombytes": "^2.1.0", - "safe-buffer": "^5.2.1" + "@tanstack/query-core": "5.90.20" }, - "engines": { - "node": ">= 0.10" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" } }, - "node_modules/browserify-rsa/node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/browserify-sign": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", - "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, - "license": "ISC", + "license": "MIT", + "peer": true, "dependencies": { - "bn.js": "^5.2.2", - "browserify-rsa": "^4.1.1", - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "elliptic": "^6.6.1", - "inherits": "^2.0.4", - "parse-asn1": "^5.1.9", - "readable-stream": "^2.3.8", - "safe-buffer": "^5.2.1" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" }, "engines": { - "node": ">= 0.10" + "node": ">=18" } - }, - "node_modules/browserify-sign/node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/browserify-sign/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/browserify-sign/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT" }, - "node_modules/browserify-sign/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/browserify-zlib": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", - "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { - "pako": "~1.0.5" + "@babel/types": "^7.0.0" } }, - "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "license": "Apache-2.0", - "peer": true, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", "dependencies": { - "node-int64": "^0.4.0" + "@babel/types": "^7.28.2" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "dev": true, "license": "MIT", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "@types/node": "*" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } }, - "node_modules/buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", "dev": true, "license": "MIT" }, - "node_modules/builtin-status-codes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", - "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", - "dev": true, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "license": "MIT" }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } + "license": "MIT" }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "node_modules/@types/elliptic": { + "version": "6.4.18", + "resolved": "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.18.tgz", + "integrity": "sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@types/bn.js": "*" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" }, - "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } }, - "node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" } }, - "node_modules/chai": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@types/uuid": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", + "integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==", + "deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.", + "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "uuid": "*" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/chrome-launcher": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", - "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", - "license": "Apache-2.0", - "peer": true, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0" - }, - "bin": { - "print-chrome-path": "bin/print-chrome-path.js" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/chromium-edge-launcher": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", - "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0", - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=8" + "node": ">= 4" } }, - "node_modules/cipher-base": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", - "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.2" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://polar.sh/cva" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "peer": true, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/clone-regexp": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz", - "integrity": "sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, "license": "MIT", "dependencies": { - "is-regexp": "^3.0.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": ">=7.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">= 0.8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">= 0.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ms": "2.0.0" + "balanced-match": "^1.0.0" } }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true - }, - "node_modules/console-browserify": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", - "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", - "dev": true - }, - "node_modules/constants-browserify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", - "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "MIT" - }, - "node_modules/convert-hrtime": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", - "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", - "license": "MIT", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/core-js": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", - "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", - "hasInstallScript": true, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/core-js" + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-ecdh": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", - "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", - "dev": true, + "node_modules/@unicitylabs/nostr-js-sdk": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@unicitylabs/nostr-js-sdk/-/nostr-js-sdk-0.3.3.tgz", + "integrity": "sha512-1COxkSZI5ENSAO1LZaDZPmplmLjIZXPiyDhkGU2rM7GV9FAP1Qk+xsZNmW7vxgaUB7sftBWMXc2uhHn6LDTOWA==", "license": "MIT", "dependencies": { - "bn.js": "^4.1.0", - "elliptic": "^6.5.3" + "@noble/ciphers": "^1.0.0", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/base": "^1.1.9", + "libphonenumber-js": "^1.11.14" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dev": true, + "node_modules/@unicitylabs/sphere-sdk": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-sdk/-/sphere-sdk-0.2.3.tgz", + "integrity": "sha512-lEAY8Q5wLR1obBatWqIzEX6dXJsykY6QMm8Nbl9lTX3e7pKyy+vUdgQwtYaWc3Sw6vjfUHC7Ju5ZW/BrMjX0qg==", "license": "MIT", "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" + "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.0.1", + "@unicitylabs/nostr-js-sdk": "^0.3.2", + "@unicitylabs/state-transition-sdk": "1.6.1-rc.f37cb85", + "bip39": "^3.1.0", + "buffer": "^6.0.3", + "crypto-js": "^4.2.0", + "elliptic": "^6.6.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@helia/ipns": "^9.1.3", + "@helia/json": "^5.0.3", + "helia": "^6.0.11" + }, + "peerDependencies": { + "@helia/ipns": ">=9.0.0", + "@helia/json": ">=5.0.0", + "helia": ">=6.0.0", + "ws": ">=8.0.0" + }, + "peerDependenciesMeta": { + "@helia/ipns": { + "optional": true + }, + "@helia/json": { + "optional": true + }, + "helia": { + "optional": true + }, + "ws": { + "optional": true + } } }, - "node_modules/create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "dev": true, + "node_modules/@unicitylabs/sphere-sdk/node_modules/@helia/ipns": { + "optional": true + }, + "node_modules/@unicitylabs/sphere-sdk/node_modules/@helia/json": { + "optional": true + }, + "node_modules/@unicitylabs/sphere-sdk/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" + "node_modules/@unicitylabs/sphere-sdk/node_modules/helia": { + "optional": true }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/@unicitylabs/state-transition-sdk": { + "version": "1.6.1-rc.f37cb85", + "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-1.6.1-rc.f37cb85.tgz", + "integrity": "sha512-6chybquV+sZPdaqluJhAeceCWyO5SO2K2j8QI/RhN6cbX4wHILumfG3GKm20ubQZTL80yTfj85kMxsbKeUIGUQ==", + "license": "ISC", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "uuid": "13.0.0" } }, - "node_modules/crypto-browserify": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", - "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", - "dev": true, + "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", "license": "MIT", "dependencies": { - "browserify-cipher": "^1.0.1", - "browserify-sign": "^4.2.3", - "create-ecdh": "^4.0.4", - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "diffie-hellman": "^5.0.3", - "hash-base": "~3.0.4", - "inherits": "^2.0.4", - "pbkdf2": "^3.1.2", - "public-encrypt": "^4.0.3", - "randombytes": "^2.1.0", - "randomfill": "^1.0.4" + "@noble/hashes": "2.0.1" }, "engines": { - "node": ">= 0.10" + "node": ">= 20.19.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, + "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, - "engines": { - "node": ">=20" - } - }, - "node_modules/datastore-core": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/datastore-core/-/datastore-core-11.0.2.tgz", - "integrity": "sha512-0pN4hMcaCWcnUBo5OL/8j14Lt1l/p1v2VvzryRYeJAKRLqnFrzy2FhAQ7y0yTA63ki760ImQHfm2XlZrfIdFpQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/logger": "^6.0.0", - "interface-datastore": "^9.0.0", - "interface-store": "^7.0.0", - "it-drain": "^3.0.9", - "it-filter": "^3.1.3", - "it-map": "^3.1.3", - "it-merge": "^3.0.11", - "it-pipe": "^3.0.1", - "it-sort": "^3.0.8", - "it-take": "^3.0.8" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" }, - "engines": { - "node": ">=6.0" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { - "supports-color": { + "msw": { + "optional": true + }, + "vite": { "optional": true } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, "license": "MIT", "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" + "tinyrainbow": "^3.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=4.0.0" + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/vitest" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/vitest" } }, - "node_modules/delay": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/delay/-/delay-7.0.0.tgz", - "integrity": "sha512-C3vaGs818qzZjCvVJ98GQUMVyWeg7dr5w2Nwwb2t5K8G98jOyyVO2ti2bKYk5yoYElqH3F2yA53ykuEnwD6MCg==", + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, "license": "MIT", "dependencies": { - "random-int": "^3.1.0", - "unlimited-timeout": "^0.1.0" - }, - "engines": { - "node": ">=20" + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, "engines": { "node": ">=0.4.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 14" } }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-browser": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz", - "integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==", - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/diffie-hellman": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "bn.js": "^4.1.0", - "miller-rabin": "^4.0.0", - "randombytes": "^2.0.0" - } - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=6" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/domain-browser": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", - "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "node": ">=8" }, "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/electron-to-chromium": { - "version": "1.5.262", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", - "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", - "license": "ISC" + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "license": "MIT", + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" + "dequal": "^2.0.3" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "peer": true - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } + "node_modules/asmcrypto.js": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", + "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", + "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, "license": "MIT", "dependencies": { - "once": "^1.4.0" + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "license": "MIT", + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" }, "engines": { - "node": ">=10.13.0" + "node": ">=12.0.0" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/err-code": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", - "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==", - "license": "MIT" - }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "license": "MIT", - "peer": true, "dependencies": { - "stackframe": "^1.3.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" }, "engines": { - "node": ">= 0.4" + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "possible-typed-array-names": "^1.0.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.6.0" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "require-from-string": "^2.0.2" + } + }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^1.2.0" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "dev": true, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/browser-resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", + "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" + "dependencies": { + "resolve": "^1.17.0" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" } }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "license": "MIT", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "peer": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" }, "engines": { - "node": ">=0.10" + "node": ">= 0.10" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/browserify-rsa/node_modules/bn.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", + "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT" + }, + "node_modules/browserify-sign": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "dev": true, + "license": "ISC", "dependencies": { - "estraverse": "^5.2.0" + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.6.1", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" }, "engines": { - "node": ">=4.0" + "node": ">= 0.10" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/browserify-sign/node_modules/bn.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", + "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } + "license": "MIT" }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/browserify-sign/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, "license": "MIT" }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/browserify-sign/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "node_modules/browserify-sign/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", "dependencies": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" + "safe-buffer": "~5.1.0" } }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } }, - "node_modules/fb-dotslash": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", - "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", - "license": "(MIT OR Apache-2.0)", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, "bin": { - "dotslash": "bin/dotslash" + "browserslist": "cli.js" }, "engines": { - "node": ">=20" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "license": "Apache-2.0", - "peer": true, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", "dependencies": { - "bser": "2.1.1" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true, + "license": "MIT" }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" }, "engines": { - "node": ">=16.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "peer": true, "dependencies": { - "to-regex-range": "^5.0.1" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=6" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=18" + } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" }, "engines": { - "node": ">=16" + "node": ">= 0.10" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/flow-enums-runtime": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", - "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", - "license": "MIT", - "peer": true - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "funding": { + "url": "https://polar.sh/cva" } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "color-name": "~1.1.4" }, "engines": { - "node": ">= 6" + "node": ">=7.0.0" } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } + "license": "MIT" }, - "node_modules/framer-motion": { - "version": "12.23.24", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", - "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.23", - "motion-utils": "^12.23.6", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "delayed-stream": "~1.0.0" }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/freeport-promise": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/freeport-promise/-/freeport-promise-2.0.0.tgz", - "integrity": "sha512-dwWpT1DdQcwrhmRwnDnPM/ZFny+FtzU+k50qF2eid3KxaQDsMiBrwo1i0G3qSugkN5db6Cb0zgfc68QeTOpEFg==", - "license": "Apache-2.0 OR MIT", "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" + "node": ">= 0.8" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", "license": "MIT", - "peer": true, "engines": { - "node": ">= 0.6" + "node": ">= 12" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, - "node_modules/fs.realpath": { + "node_modules/console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "node_modules/constants-browserify": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC", - "peer": true + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", + "dev": true, + "license": "MIT" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/function-timeout": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-0.1.1.tgz", - "integrity": "sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==", + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, "engines": { - "node": ">= 0.4" + "node": ">= 8" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "dev": true, "license": "MIT", + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "peer": true, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20" } }, - "node_modules/get-iterator": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/get-iterator/-/get-iterator-2.0.1.tgz", - "integrity": "sha512-7HuY/hebu4gryTDT7O/XY/fvY9wRByEGdK6QOa4of8npTcv0+NS6frFKABcf6S9EBAsveTuKTsZQQBFMMNILIg==", + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, "engines": { - "node": ">=8.0.0" + "node": ">=20" } }, - "node_modules/get-port": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=20" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "ms": "^2.1.3" }, "engines": { - "node": ">= 0.4" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, "license": "MIT" }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "peer": true, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": "*" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.4.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/domain-browser": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", + "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://bevry.me/fund" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hash-base": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", - "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, + "license": "ISC" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", "license": "MIT", "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", - "safe-buffer": "^5.2.1" + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 0.4" } }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" + "engines": { + "node": ">= 0.4" } }, - "node_modules/hashlru": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/hashlru/-/hashlru-2.3.0.tgz", - "integrity": "sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, "license": "MIT" }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "es-errors": "^1.3.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/helia": { - "version": "6.0.20", - "resolved": "https://registry.npmjs.org/helia/-/helia-6.0.20.tgz", - "integrity": "sha512-9UTrDT71tKYTdf/4P6DhsxL1mwCPEq+Zmqp5b2582YzUvdpIydItORuXaP+yqOk3N6EP6vgcQOnHUo31drFWVQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@chainsafe/libp2p-noise": "^17.0.0", - "@chainsafe/libp2p-yamux": "^8.0.0", - "@helia/block-brokers": "^5.1.2", - "@helia/delegated-routing-v1-http-api-client": "^6.0.0", - "@helia/interface": "^6.1.1", - "@helia/routers": "^5.0.3", - "@helia/utils": "^2.4.2", - "@ipshipyard/libp2p-auto-tls": "^2.0.1", - "@libp2p/autonat": "^3.0.5", - "@libp2p/bootstrap": "^12.0.6", - "@libp2p/circuit-relay-v2": "^4.0.5", - "@libp2p/config": "^1.1.20", - "@libp2p/dcutr": "^3.0.5", - "@libp2p/http": "^2.0.0", - "@libp2p/identify": "^4.0.5", - "@libp2p/interface": "^3.1.0", - "@libp2p/kad-dht": "^16.1.0", - "@libp2p/keychain": "^6.0.5", - "@libp2p/mdns": "^12.0.6", - "@libp2p/mplex": "^12.0.6", - "@libp2p/ping": "^3.0.5", - "@libp2p/tcp": "^11.0.5", - "@libp2p/tls": "^3.0.5", - "@libp2p/upnp-nat": "^4.0.5", - "@libp2p/webrtc": "^6.0.6", - "@libp2p/websockets": "^10.0.6", - "@multiformats/dns": "^1.0.9", - "blockstore-core": "^6.1.1", - "datastore-core": "^11.0.2", - "interface-datastore": "^9.0.2", - "ipns": "^10.1.2", - "libp2p": "^3.0.6", - "multiformats": "^13.4.1" - } - }, - "node_modules/hermes-compiler": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.14.0.tgz", - "integrity": "sha512-clxa193o+GYYwykWVFfpHduCATz8fR5jvU7ngXpfKHj+E9hr9vjLNtdLSEe8MUbObvVexV3wcyxQ00xTPIrB1Q==", - "license": "MIT", - "peer": true - }, - "node_modules/hermes-estree": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", - "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", - "peer": true + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", - "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, "license": "MIT", - "peer": true, - "dependencies": { - "hermes-estree": "0.32.0" + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" + "engines": { + "node": ">=6" } }, - "node_modules/html-encoding-sniffer": { + "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">= 0.8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">= 0.8" + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/https-browserify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", - "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">= 14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=0.10" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">= 4" + "node": ">=4.0" } }, - "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", - "license": "MIT", - "peer": true, - "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" - }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=16.x" + "node": ">=0.10.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.x" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=0.8.19" + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "peer": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" }, - "node_modules/interface-blockstore": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/interface-blockstore/-/interface-blockstore-6.0.1.tgz", - "integrity": "sha512-AVcUbMwrhiO4RqDljUitUt3aoon6MD2fblsN7vEVBDsmHFQT0LIOODVK5Qxe28h1uUvVykyZqmo09f6w55KiJg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "interface-store": "^7.0.0", - "multiformats": "^13.3.6" - } + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" }, - "node_modules/interface-datastore": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/interface-datastore/-/interface-datastore-9.0.2.tgz", - "integrity": "sha512-jebn+GV/5LTDDoyicNIB4D9O0QszpPqT09Z/MpEWvf3RekjVKpXJCDguM5Au2fwIFxFDAQMZe5bSla0jMamCNg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "interface-store": "^7.0.0", - "uint8arrays": "^5.1.0" + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/interface-store": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/interface-store/-/interface-store-7.0.1.tgz", - "integrity": "sha512-OPRRUO3Cs6Jr/t98BrJLQp1jUTPgrRH0PqFfuNoPAqd+J7ABN1tjFVjQdaOBiybYJTS/AyBSZnZVWLPvp3dW3w==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "loose-envify": "^1.0.0" + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/ip-regex": { + "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz", - "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ipns": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/ipns/-/ipns-10.1.3.tgz", - "integrity": "sha512-b2Zeh8+7qOV11NjnTsYLpG8K6T13uBMndpzk9N9E2Qjz/u80qsxvKpspSP32sErOLr/GWjdFVVc02E9PMojQNA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.0.0", - "@libp2p/interface": "^3.0.2", - "@libp2p/logger": "^6.0.4", - "cborg": "^4.2.3", - "interface-datastore": "^9.0.2", - "multiformats": "^13.2.2", - "protons-runtime": "^5.5.0", - "timestamp-nano": "^1.0.1", - "uint8arraylist": "^2.4.8", - "uint8arrays": "^5.1.0" - } - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=16" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=4.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -7898,112 +4430,122 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", - "peer": true, - "bin": { - "is-docker": "cli.js" + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 6" } }, - "node_modules/is-electron": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", - "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", - "license": "MIT" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/framer-motion": { + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz", + "integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==", "license": "MIT", - "peer": true, + "dependencies": { + "motion-dom": "^12.34.0", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/is-generator-function": { + "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "dev": true, "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-ip": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-5.0.1.tgz", - "integrity": "sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", - "dependencies": { - "ip-regex": "^5.0.0", - "super-regex": "^0.2.0" - }, "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6.9.0" } }, - "node_modules/is-loopback-addr": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-loopback-addr/-/is-loopback-addr-2.0.2.tgz", - "integrity": "sha512-26POf2KRCno/KTNL5Q0b/9TYnL00xEsSaLfiFRmjM7m7Lw7ZMmFybzzuX4CcsLAluZGd+niLUiMRxEooVE3aqg==", - "license": "MIT" - }, - "node_modules/is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "dev": true, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8012,84 +4554,50 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-network-error": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "engines": { - "node": ">=16" + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10.13.0" } }, - "node_modules/is-regexp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", - "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, "engines": { "node": ">= 0.4" }, @@ -8097,571 +4605,388 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/isomorphic-timers-promises": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz", - "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=8" } }, - "node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "license": "BSD-3-Clause", - "peer": true, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" + "es-define-property": "^1.0.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/it-all": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/it-all/-/it-all-3.0.9.tgz", - "integrity": "sha512-fz1oJJ36ciGnu2LntAlE6SA97bFZpW7Rnt0uEc1yazzR2nKokZLr8lIRtgnpex4NsmaBcvHF+Z9krljWFy/mmg==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/it-byte-stream": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/it-byte-stream/-/it-byte-stream-2.0.4.tgz", - "integrity": "sha512-8pS0OvkBYwQ206pRLgoLDAiHP6c8wYZJ1ig8KDmP5NOrzMxeH2Wv2ktXIjYHwdu7RPOsnxQb0vKo+O784L/m5g==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "abort-error": "^1.0.1", - "it-queueless-pushable": "^2.0.0", - "it-stream-types": "^2.0.2", - "race-signal": "^2.0.0", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/it-drain": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/it-drain/-/it-drain-3.0.10.tgz", - "integrity": "sha512-0w/bXzudlyKIyD1+rl0xUKTI7k4cshcS43LTlBiGFxI8K1eyLydNPxGcsVLsFVtKh1/ieS8AnVWt6KwmozxyEA==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/it-filter": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/it-filter/-/it-filter-3.1.4.tgz", - "integrity": "sha512-80kWEKgiFEa4fEYD3mwf2uygo1dTQ5Y5midKtL89iXyjinruA/sNXl6iFkTcdNedydjvIsFhWLiqRPQP4fAwWQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "it-peekable": "^3.0.0" - } - }, - "node_modules/it-first": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/it-first/-/it-first-3.0.9.tgz", - "integrity": "sha512-ZWYun273Gbl7CwiF6kK5xBtIKR56H1NoRaiJek2QzDirgen24u8XZ0Nk+jdnJSuCTPxC2ul1TuXKxu/7eK6NuA==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/it-foreach": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/it-foreach/-/it-foreach-2.1.5.tgz", - "integrity": "sha512-9tIp+NFVODmGV/49JUKVxW3+8RrPkYrmUaXUM4W6lMC5POM/1gegckNjBmDe5xgBa7+RE9HKBmRTAdY5V+bWSQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "it-peekable": "^3.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/it-length": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/it-length/-/it-length-3.0.9.tgz", - "integrity": "sha512-cPhRPzyulYqyL7x4sX4MOjG/xu3vvEIFAhJ1aCrtrnbfxloCOtejOONib5oC3Bz8tLL6b6ke6+YHu4Bm6HCG7A==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/it-length-prefixed": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/it-length-prefixed/-/it-length-prefixed-10.0.1.tgz", - "integrity": "sha512-BhyluvGps26u9a7eQIpOI1YN7mFgi8lFwmiPi07whewbBARKAG9LE09Odc8s1Wtbt2MB6rNUrl7j9vvfXTJwdQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "it-reader": "^6.0.1", - "it-stream-types": "^2.0.1", - "uint8-varint": "^2.0.1", - "uint8arraylist": "^2.0.0", - "uint8arrays": "^5.0.1" - }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/it-length-prefixed-stream": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/it-length-prefixed-stream/-/it-length-prefixed-stream-2.0.4.tgz", - "integrity": "sha512-ugHDOQCkC2Dx2pQaJ+W4OIM6nZFBwlpgdQVVOfdX4c1Os47d6PMsfrkTrzRwZdBCMZb+JISZNP2gjU/DHN/z9A==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "abort-error": "^1.0.1", - "it-byte-stream": "^2.0.0", - "it-stream-types": "^2.0.2", - "uint8-varint": "^2.0.4", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/it-map": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/it-map/-/it-map-3.1.4.tgz", - "integrity": "sha512-QB9PYQdE9fUfpVFYfSxBIyvKynUCgblb143c+ktTK6ZuKSKkp7iH58uYFzagqcJ5HcqIfn1xbfaralHWam+3fg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "it-peekable": "^3.0.0" - } - }, - "node_modules/it-merge": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/it-merge/-/it-merge-3.0.12.tgz", - "integrity": "sha512-nnnFSUxKlkZVZD7c0jYw6rDxCcAQYcMsFj27thf7KkDhpj0EA0g9KHPxbFzHuDoc6US2EPS/MtplkNj8sbCx4Q==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "it-queueless-pushable": "^2.0.0" - } - }, - "node_modules/it-ndjson": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/it-ndjson/-/it-ndjson-1.1.4.tgz", - "integrity": "sha512-ZMgTUrNo/UQCeRUT3KqnC0UaClzU6D+ItSmzVt7Ks7pcJ7DboYeYBSPeFLAaEthf5zlvaApDuACLmOWepgkrRg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/it-parallel": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/it-parallel/-/it-parallel-3.0.13.tgz", - "integrity": "sha512-85PPJ/O8q97Vj9wmDTSBBXEkattwfQGruXitIzrh0RLPso6RHfiVqkuTqBNufYYtB1x6PSkh0cwvjmMIkFEPHA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "p-defer": "^4.0.1" - } - }, - "node_modules/it-peekable": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/it-peekable/-/it-peekable-3.0.8.tgz", - "integrity": "sha512-7IDBQKSp/dtBxXV3Fj0v3qM1jftJ9y9XrWLRIuU1X6RdKqWiN60syNwP0fiDxZD97b8SYM58dD3uklIk1TTQAw==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/it-pipe": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/it-pipe/-/it-pipe-3.0.1.tgz", - "integrity": "sha512-sIoNrQl1qSRg2seYSBH/3QxWhJFn9PKYvOf/bHdtCBF0bnghey44VyASsWzn5dAx0DCDDABq1hZIuzKmtBZmKA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "it-merge": "^3.0.0", - "it-pushable": "^3.1.2", - "it-stream-types": "^2.0.1" + "node": ">= 0.4" }, - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/it-protobuf-stream": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/it-protobuf-stream/-/it-protobuf-stream-2.0.3.tgz", - "integrity": "sha512-Dus9qyylOSnC7l75/3qs6j3Fe9MCM2K5luXi9o175DYijFRne5FPucdOGIYdwaDBDQ4Oy34dNCuFobOpcusvEQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "abort-error": "^1.0.1", - "it-length-prefixed-stream": "^2.0.0", - "it-stream-types": "^2.0.2", - "uint8arraylist": "^2.4.8" - } - }, - "node_modules/it-pushable": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/it-pushable/-/it-pushable-3.2.3.tgz", - "integrity": "sha512-gzYnXYK8Y5t5b/BnJUr7glfQLO4U5vyb05gPx/TyTw+4Bv1zM9gFk4YsOrnulWefMewlphCjKkakFvj1y99Tcg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "p-defer": "^4.0.0" - } - }, - "node_modules/it-queue": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/it-queue/-/it-queue-1.1.1.tgz", - "integrity": "sha512-yeYCV22WF1QDyb3ylw+g3TGEdkmnoHUH2mc12QoGOQuxW4XP1V7Zd3BfsEF1iq2IFBwIK7wCPUcRLTAQVeZ3SQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "abort-error": "^1.0.1", - "it-pushable": "^3.2.3", - "main-event": "^1.0.0", - "race-event": "^1.3.0", - "race-signal": "^2.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/it-queueless-pushable": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/it-queueless-pushable/-/it-queueless-pushable-2.0.3.tgz", - "integrity": "sha512-USa5EzTvmQswOcVE7+o6qsj2o2G+6KHCxSogPOs23sGYkDWFidhqVO7dAvv6ve/Z+Q+nvxpEa9rrRo6VEK7w4Q==", - "license": "Apache-2.0 OR MIT", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", "dependencies": { - "abort-error": "^1.0.1", - "p-defer": "^4.0.1", - "race-signal": "^2.0.0" + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/it-reader": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/it-reader/-/it-reader-6.0.4.tgz", - "integrity": "sha512-XCWifEcNFFjjBHtor4Sfaj8rcpt+FkY0L6WdhD578SCDhV4VUm7fCkF3dv5a+fTcfQqvN9BsxBTvWbYO6iCjTg==", - "license": "Apache-2.0 OR MIT", + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "dev": true, + "license": "MIT", "dependencies": { - "it-stream-types": "^2.0.1", - "uint8arraylist": "^2.0.0" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" }, "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" + "node": ">= 0.10" } }, - "node_modules/it-sort": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/it-sort/-/it-sort-3.0.9.tgz", - "integrity": "sha512-jsM6alGaPiQbcAJdzMsuMh00uJcI+kD9TBoScB8TR75zUFOmHvhSsPi+Dmh2zfVkcoca+14EbfeIZZXTUGH63w==", - "license": "Apache-2.0 OR MIT", + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", "dependencies": { - "it-all": "^3.0.0" + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" } }, - "node_modules/it-stream-types": { + "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/it-stream-types/-/it-stream-types-2.0.2.tgz", - "integrity": "sha512-Rz/DEZ6Byn/r9+/SBCuJhpPATDF9D+dz5pbgSUyBsCDtza6wtNATrz/jz1gDyNanC3XdLboriHnOC925bZRBww==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/it-take": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/it-take/-/it-take-3.0.9.tgz", - "integrity": "sha512-XMeUbnjOcgrhFXPUqa7H0VIjYSV/BvyxxjCp76QHVAFDJw2LmR1SHxUFiqyGeobgzJr7P2ZwSRRJQGn4D2BVlA==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/it-to-browser-readablestream": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/it-to-browser-readablestream/-/it-to-browser-readablestream-2.0.12.tgz", - "integrity": "sha512-9pcVGxY8jrfMUgCqPrxjVN0bl6fQXCK1NEbUq5Bi+APlr3q0s2AsQINBPcWYgJbMnSHAfoRDthsi4GHqtkvHgw==", - "license": "Apache-2.0 OR MIT", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { - "get-iterator": "^2.0.1" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/it-to-buffer": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/it-to-buffer/-/it-to-buffer-4.0.10.tgz", - "integrity": "sha512-dXNHSILSPVv+31nxav+egNxWA/RpSuAHCSurJCLxkFDpmzAyYPJwIkPfLkYiHLoJqyE6Z5nVFILp6aDvz9V5pw==", - "license": "Apache-2.0 OR MIT", + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", "dependencies": { - "uint8arrays": "^5.1.0" + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" } }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 14" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "node_modules/https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" + "node": ">= 14" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 4" } }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=6" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "license": "MIT", - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.8.19" } }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "hasown": "^2.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=0.10.0" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" + "dependencies": { + "is-extglob": "^2.1.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "which-typed-array": "^1.1.16" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isomorphic-timers-promises": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz", + "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/jiti": { @@ -8677,6 +5002,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8692,26 +5018,21 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsc-safe-url": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", - "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", - "license": "0BSD", - "peer": true - }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", @@ -8721,7 +5042,6 @@ "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", @@ -8743,6 +5063,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -8776,6 +5097,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -8785,9 +5107,9 @@ } }, "node_modules/katex": { - "version": "0.16.27", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", - "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", + "version": "0.16.28", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", + "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -8800,15 +5122,6 @@ "katex": "cli.js" } }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8833,16 +5146,6 @@ "node": "*" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8857,75 +5160,12 @@ "node": ">= 0.8.0" } }, - "node_modules/libp2p": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/libp2p/-/libp2p-3.1.3.tgz", - "integrity": "sha512-Jgl6Km1PfFTKR7krDNDxuuxQ6ya3D6VHFOi/XYJA539F62PmbxOQLd+nqbqozwB9BgJVTxaXRVmGTKo7dyrdQw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@chainsafe/is-ip": "^2.1.0", - "@chainsafe/netmask": "^2.0.0", - "@libp2p/crypto": "^5.1.13", - "@libp2p/interface": "^3.1.0", - "@libp2p/interface-internal": "^3.0.10", - "@libp2p/logger": "^6.2.2", - "@libp2p/multistream-select": "^7.0.10", - "@libp2p/peer-collections": "^7.0.10", - "@libp2p/peer-id": "^6.0.4", - "@libp2p/peer-store": "^12.0.10", - "@libp2p/utils": "^7.0.10", - "@multiformats/dns": "^1.0.6", - "@multiformats/multiaddr": "^13.0.1", - "@multiformats/multiaddr-matcher": "^3.0.1", - "any-signal": "^4.1.1", - "datastore-core": "^11.0.1", - "interface-datastore": "^9.0.1", - "it-merge": "^3.0.12", - "it-parallel": "^3.0.13", - "main-event": "^1.0.1", - "multiformats": "^13.4.0", - "p-defer": "^4.0.1", - "p-event": "^7.0.0", - "p-retry": "^7.0.0", - "progress-events": "^1.0.1", - "race-signal": "^2.0.0", - "uint8arrays": "^5.1.0" - } - }, "node_modules/libphonenumber-js": { - "version": "1.12.34", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.34.tgz", - "integrity": "sha512-v/Ip8k8eYdp7bINpzqDh46V/PaQ8sK+qi97nMQgjZzFlb166YFqlR/HVI+MzsI9JqcyyVWCOipmmretiaSyQyw==", + "version": "1.12.36", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.36.tgz", + "integrity": "sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==", "license": "MIT" }, - "node_modules/lighthouse-logger": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", - "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "debug": "^2.6.9", - "marky": "^1.2.2" - } - }, - "node_modules/lighthouse-logger/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/lighthouse-logger/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true - }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -9198,26 +5438,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "license": "MIT", - "peer": true - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -9249,427 +5469,39 @@ "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/main-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/main-event/-/main-event-1.0.1.tgz", - "integrity": "sha512-NWtdGrAca/69fm6DIVd8T9rtfDII4Q8NQbIbsKQq2VzS9eqOGYs8uaNQjcuaCq/d9H/o625aOTJX2Qoxzqw0Pw==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/marky": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", - "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", - "license": "MIT", - "peer": true - }, - "node_modules/merge-options": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", - "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true - }, - "node_modules/metro": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", - "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/parser": "^7.25.3", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.3", - "@babel/types": "^7.25.2", - "accepts": "^1.3.7", - "chalk": "^4.0.0", - "ci-info": "^2.0.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "error-stack-parser": "^2.0.6", - "flow-enums-runtime": "^0.0.6", - "graceful-fs": "^4.2.4", - "hermes-parser": "0.32.0", - "image-size": "^1.0.2", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "jsc-safe-url": "^0.2.2", - "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.83.3", - "metro-cache": "0.83.3", - "metro-cache-key": "0.83.3", - "metro-config": "0.83.3", - "metro-core": "0.83.3", - "metro-file-map": "0.83.3", - "metro-resolver": "0.83.3", - "metro-runtime": "0.83.3", - "metro-source-map": "0.83.3", - "metro-symbolicate": "0.83.3", - "metro-transform-plugins": "0.83.3", - "metro-transform-worker": "0.83.3", - "mime-types": "^2.1.27", - "nullthrows": "^1.1.1", - "serialize-error": "^2.1.0", - "source-map": "^0.5.6", - "throat": "^5.0.0", - "ws": "^7.5.10", - "yargs": "^17.6.2" - }, - "bin": { - "metro": "src/cli.js" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-babel-transformer": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", - "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "flow-enums-runtime": "^0.0.6", - "hermes-parser": "0.32.0", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-cache": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", - "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "exponential-backoff": "^3.1.1", - "flow-enums-runtime": "^0.0.6", - "https-proxy-agent": "^7.0.5", - "metro-core": "0.83.3" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-cache-key": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", - "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-config": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", - "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", - "license": "MIT", - "peer": true, - "dependencies": { - "connect": "^3.6.5", - "flow-enums-runtime": "^0.0.6", - "jest-validate": "^29.7.0", - "metro": "0.83.3", - "metro-cache": "0.83.3", - "metro-core": "0.83.3", - "metro-runtime": "0.83.3", - "yaml": "^2.6.1" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-core": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", - "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "lodash.throttle": "^4.1.1", - "metro-resolver": "0.83.3" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-file-map": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", - "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "fb-watchman": "^2.0.0", - "flow-enums-runtime": "^0.0.6", - "graceful-fs": "^4.2.4", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "nullthrows": "^1.1.1", - "walker": "^1.0.7" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-minify-terser": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", - "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "terser": "^5.15.0" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-resolver": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", - "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-runtime": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", - "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.25.0", - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-source-map": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", - "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.25.3", - "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", - "@babel/types": "^7.25.2", - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-symbolicate": "0.83.3", - "nullthrows": "^1.1.1", - "ob1": "0.83.3", - "source-map": "^0.5.6", - "vlq": "^1.0.0" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-symbolicate": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", - "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-source-map": "0.83.3", - "nullthrows": "^1.1.1", - "source-map": "^0.5.6", - "vlq": "^1.0.0" - }, - "bin": { - "metro-symbolicate": "src/index.js" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-transform-plugins": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", - "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.3", - "flow-enums-runtime": "^0.0.6", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro-transform-worker": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", - "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/parser": "^7.25.3", - "@babel/types": "^7.25.2", - "flow-enums-runtime": "^0.0.6", - "metro": "0.83.3", - "metro-babel-transformer": "0.83.3", - "metro-cache": "0.83.3", - "metro-cache-key": "0.83.3", - "metro-minify-terser": "0.83.3", - "metro-source-map": "0.83.3", - "metro-transform-plugins": "0.83.3", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/metro/node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", - "peer": true + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } }, - "node_modules/metro/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "peer": true, "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" }, "node_modules/miller-rabin": { "version": "4.0.1", @@ -9685,19 +5517,6 @@ "miller-rabin": "bin/miller-rabin" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -9719,18 +5538,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -9755,15 +5562,6 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -9771,85 +5569,40 @@ "license": "MIT" }, "node_modules/mixpanel-browser": { - "version": "2.72.0", - "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.72.0.tgz", - "integrity": "sha512-Olc+1ebVBSVBjtR/Pp4t8Pc1wAI9AfA5e668B0MsI/gKJ43QcndzfQ/AT/TiP1Klup8O1C9vwykoWjvPqX+SRA==", + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.74.0.tgz", + "integrity": "sha512-FWifdAaI+8zKEROb9T+gy0NRZB0gaCC5iy20rDjZ3C+KCwCsJcITT6lAYErWbwwmk3Ei34JBjLsnrDLPYj+hOw==", "license": "Apache-2.0", "dependencies": { "@mixpanel/rrweb": "2.0.0-alpha.18.2", "@mixpanel/rrweb-plugin-console-record": "2.0.0-alpha.18.2" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "peer": true, - "bin": { - "mkdirp": "bin/cmd.js" }, "engines": { - "node": ">=10" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/mortice": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/mortice/-/mortice-3.3.1.tgz", - "integrity": "sha512-t3oESfijIPGsmsdLEKjF+grHfrbnKSXflJtgb1wY14cjxZpS6GnhHRXTxxzCAoCCnq1YYfpEPwY3gjiCPhOufQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "abort-error": "^1.0.0", - "it-queue": "^1.1.0", - "main-event": "^1.0.0" + "node": ">=20 <26" } }, "node_modules/motion-dom": { - "version": "12.23.23", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", - "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz", + "integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==", "license": "MIT", "dependencies": { - "motion-utils": "^12.23.6" + "motion-utils": "^12.29.2" } }, "node_modules/motion-utils": { - "version": "12.23.6", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", - "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, - "node_modules/multiformats": { - "version": "13.4.2", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", - "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", - "license": "Apache-2.0 OR MIT" - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -9868,12 +5621,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9881,82 +5628,11 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-datachannel": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.29.0.tgz", - "integrity": "sha512-aCRJA5uZRqxMvQAl2QtOnCkodF1qJa1dCUVaXW9D7rku2p6F7PWe5OuRLcIgOYe+e2ZyJu0LefIQ95TtCn6xxA==", - "hasInstallScript": true, - "license": "MPL 2.0", - "dependencies": { - "prebuild-install": "^7.1.3" - }, - "engines": { - "node": ">=18.20.0" - } - }, - "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "license": "MIT", - "peer": true - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, "license": "MIT" }, "node_modules/node-stdlib-browser": { @@ -10030,26 +5706,6 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm": { "version": "2.15.12", "resolved": "https://registry.npmjs.org/npm/-/npm-2.15.12.tgz", @@ -10127,6 +5783,7 @@ "wrappy", "write-file-atomic" ], + "license": "Artistic-2.0", "dependencies": { "abbrev": "~1.0.9", "ansi": "~0.3.1", @@ -11856,26 +7513,6 @@ "slide": "^1.1.5" } }, - "node_modules/nullthrows": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "license": "MIT", - "peer": true - }, - "node_modules/ob1": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", - "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", - "license": "MIT", - "peer": true, - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": ">=20.19.4" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11948,45 +7585,6 @@ ], "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12005,132 +7603,40 @@ "node": ">= 0.8.0" } }, - "node_modules/os-browserify": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", - "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/p-defer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-4.0.1.tgz", - "integrity": "sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-event": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-7.1.0.tgz", - "integrity": "sha512-/lkPs5W1aC3cp6vqZefpdosOn65J571sWodyfOQiF0+tmDCpU+H8Atwpu0vQROCVUlZuToDN5eyTLsMLLc54mg==", - "license": "MIT", - "dependencies": { - "p-timeout": "^7.0.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", - "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", + "dev": true, + "license": "MIT" }, - "node_modules/p-retry": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", - "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { - "is-network-error": "^1.1.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=20" + "dependencies": { + "p-limit": "^3.0.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/p-wait-for": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-wait-for/-/p-wait-for-6.0.0.tgz", - "integrity": "sha512-2kKzMtjS8TVcpCOU/gr3vZ4K/WIyS1AsEFXFWapM/0lERCdyTbB6ZeuCIp+cL1aeLZfQoMdZFCBTHiK4I9UtOw==", - "license": "MIT", "engines": { - "node": ">=20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12186,16 +7692,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -12207,25 +7703,17 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12281,16 +7769,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/pkg-dir": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", @@ -12333,6 +7811,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12349,32 +7828,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12417,6 +7870,7 @@ "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -12429,33 +7883,6 @@ "dev": true, "license": "MIT" }, - "node_modules/progress-events": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/progress-events/-/progress-events-1.0.1.tgz", - "integrity": "sha512-MOzLIwhpt64KIVN64h1MwdKWiyKFNc/S6BoYKPIVUHFg0/eIEyBulhWCgn678v/4c0ri3FdGuzXymNCv02MUIw==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/promise": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", - "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", - "license": "MIT", - "peer": true, - "dependencies": { - "asap": "~2.0.6" - } - }, - "node_modules/protons-runtime": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/protons-runtime/-/protons-runtime-5.6.0.tgz", - "integrity": "sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "uint8-varint": "^2.0.2", - "uint8arraylist": "^2.4.3", - "uint8arrays": "^5.0.1" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -12475,430 +7902,130 @@ "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" - } - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/qr-code-styling": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz", - "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", - "license": "MIT", - "dependencies": { - "qrcode-generator": "^1.4.4" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/qrcode-generator": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz", - "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/querystring-es3": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", - "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", - "dev": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "~2.0.3" - } - }, - "node_modules/race-event": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/race-event/-/race-event-1.6.1.tgz", - "integrity": "sha512-vi7WH5g5KoTFpu2mme/HqZiWH14XSOtg5rfp6raBskBHl7wnmy3F/biAIyY5MsK+BHWhoPhxtZ1Y2R7OHHaWyQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "abort-error": "^1.0.1" - } - }, - "node_modules/race-signal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/race-signal/-/race-signal-2.0.0.tgz", - "integrity": "sha512-P31bLhE4ByBX/70QDXMutxnqgwrF1WUXea1O8DXuviAgkdbQ1iQMQotNgzJIBC9yUSn08u/acZrMUhgw7w6GpA==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/random-int": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/random-int/-/random-int-3.1.0.tgz", - "integrity": "sha512-h8CRz8cpvzj0hC/iH/1Gapgcl2TQ6xtnCpyOI5WvWfXf/yrDx2DOU+tD9rX23j36IF11xg1KqB9W11Z18JPMdw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/randomfill": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", - "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "randombytes": "^2.0.5", - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-devtools-core": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", - "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", - "license": "MIT", - "peer": true, - "dependencies": { - "shell-quote": "^1.6.1", - "ws": "^7" - } - }, - "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" + } }, - "node_modules/react-native": { - "version": "0.83.1", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.1.tgz", - "integrity": "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/create-cache-key-function": "^29.7.0", - "@react-native/assets-registry": "0.83.1", - "@react-native/codegen": "0.83.1", - "@react-native/community-cli-plugin": "0.83.1", - "@react-native/gradle-plugin": "0.83.1", - "@react-native/js-polyfills": "0.83.1", - "@react-native/normalize-colors": "0.83.1", - "@react-native/virtualized-lists": "0.83.1", - "abort-controller": "^3.0.0", - "anser": "^1.4.9", - "ansi-regex": "^5.0.0", - "babel-jest": "^29.7.0", - "babel-plugin-syntax-hermes-parser": "0.32.0", - "base64-js": "^1.5.1", - "commander": "^12.0.0", - "flow-enums-runtime": "^0.0.6", - "glob": "^7.1.1", - "hermes-compiler": "0.14.0", - "invariant": "^2.2.4", - "jest-environment-node": "^29.7.0", - "memoize-one": "^5.0.0", - "metro-runtime": "^0.83.3", - "metro-source-map": "^0.83.3", - "nullthrows": "^1.1.1", - "pretty-format": "^29.7.0", - "promise": "^8.3.0", - "react-devtools-core": "^6.1.5", - "react-refresh": "^0.14.0", - "regenerator-runtime": "^0.13.2", - "scheduler": "0.27.0", - "semver": "^7.1.3", - "stacktrace-parser": "^0.1.10", - "whatwg-fetch": "^3.0.0", - "ws": "^7.5.10", - "yargs": "^17.6.2" - }, - "bin": { - "react-native": "cli.js" - }, "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@types/react": "^19.1.1", - "react": "^19.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=6" } }, - "node_modules/react-native-webrtc": { - "version": "124.0.7", - "resolved": "https://registry.npmjs.org/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz", - "integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==", + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", "license": "MIT", "dependencies": { - "base64-js": "1.5.1", - "debug": "4.3.4", - "event-target-shim": "6.0.2" - }, - "peerDependencies": { - "react-native": ">=0.60.0" + "tslib": "^2.8.1" } }, - "node_modules/react-native-webrtc/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=16.0.0" } }, - "node_modules/react-native-webrtc/node_modules/event-target-shim": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", - "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==", + "node_modules/qr-code-styling": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz", + "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", "license": "MIT", - "engines": { - "node": ">=10.13.0" + "dependencies": { + "qrcode-generator": "^1.4.4" }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" + "engines": { + "node": ">=18.18.0" } }, - "node_modules/react-native-webrtc/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "node_modules/qrcode-generator": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz", + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", "license": "MIT" }, - "node_modules/react-native/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "peer": true, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, "engines": { - "node": ">=10" + "node": ">=0.6" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/react-native/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, + "node_modules/querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", + "dev": true, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.4.x" } }, - "node_modules/react-native/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "safe-buffer": "^5.1.0" + } }, - "node_modules/react-native/node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" } }, - "node_modules/react-native/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", "peer": true, - "bin": { - "semver": "bin/semver.js" - }, "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, - "node_modules/react-native/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "peer": true, - "engines": { - "node": ">=8.3.0" + "dependencies": { + "scheduler": "^0.27.0" }, "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -12910,9 +8037,9 @@ } }, "node_modules/react-router": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", - "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -12932,12 +8059,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", - "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "license": "MIT", "dependencies": { - "react-router": "7.9.6" + "react-router": "7.13.0" }, "engines": { "node": ">=20.0.0" @@ -12961,29 +8088,6 @@ "node": ">= 6" } }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "peer": true - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -13025,29 +8129,6 @@ "node": ">=4" } }, - "node_modules/retimeable-signal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/retimeable-signal/-/retimeable-signal-1.0.1.tgz", - "integrity": "sha512-Cy26CYfbWnYu8HMoJeDhaMpW/EYFIbne3vMf6G9RSrOyWYXbPehja/BEdzpqmM84uy2bfBD7NPZhoQ4GZEtgvg==", - "license": "Apache-2.0 OR MIT" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -13126,10 +8207,11 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13141,28 +8223,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -13188,184 +8273,48 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", - "license": "WTFPL OR ISC", - "dependencies": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true - }, - "node_modules/send/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serialize-error": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", - "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "peer": true, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "xmlchars": "^2.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=v12.22.7" } }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/set-cookie-parser": { @@ -13399,13 +8348,6 @@ "dev": true, "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "peer": true - }, "node_modules/sha.js": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", @@ -13431,6 +8373,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -13443,24 +8386,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -13544,78 +8475,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "peer": true - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -13625,57 +8484,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -13683,36 +8491,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "license": "MIT", - "peer": true - }, - "node_modules/stacktrace-parser": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", - "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", - "license": "MIT", - "peer": true, - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -13753,34 +8531,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13794,169 +8544,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/super-regex": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz", - "integrity": "sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==", - "license": "MIT", - "dependencies": { - "clone-regexp": "^3.0.0", - "function-timeout": "^0.1.0", - "time-span": "^5.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", - "peer": true - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "license": "ISC", - "peer": true, "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "has-flag": "^4.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, "license": "MIT" }, - "node_modules/time-span": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", - "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", - "dependencies": { - "convert-hrtime": "^5.0.0" - }, "engines": { - "node": ">=12" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/timers-browserify": { @@ -13972,15 +8609,6 @@ "node": ">=0.6.0" } }, - "node_modules/timestamp-nano": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/timestamp-nano/-/timestamp-nano-1.0.1.tgz", - "integrity": "sha512-4oGOVZWTu5sl89PtCDnhQBSt7/vL1zVEwAfxH1p49JhTosxzVQWYBYFRFZ8nJmo0G6f824iyP/44BFAwIoKvIA==", - "license": "MIT", - "engines": { - "node": ">= 4.5.0" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -14025,32 +8653,25 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -14066,29 +8687,6 @@ "node": ">= 0.4" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -14115,19 +8713,10 @@ "node": ">=20" } }, - "node_modules/truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", - "license": "WTFPL", - "dependencies": { - "utf8-byte-length": "^1.0.1" - } - }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -14143,24 +8732,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsyringe": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "license": "MIT", - "dependencies": { - "tslib": "^1.9.3" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/tsyringe/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", @@ -14168,18 +8739,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -14193,26 +8752,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "license": "(MIT OR CC0-1.0)", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -14234,6 +8773,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14243,16 +8783,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", - "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0" + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14266,75 +8806,18 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/uint8-varint": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.4.tgz", - "integrity": "sha512-FwpTa7ZGA/f/EssWAb5/YV6pHgVF1fViKdW8cWaEarjB8t7NyofSWBdOTyFPaGuUG4gx3v1O3PQ8etsiOs3lcw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "uint8arraylist": "^2.0.0", - "uint8arrays": "^5.0.0" - } - }, - "node_modules/uint8arraylist": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-2.4.8.tgz", - "integrity": "sha512-vc1PlGOzglLF0eae1M8mLRTBivsvrGsdmJ5RbK3e+QRvRLOZfZhQROTwH/OfyF3+ZVUg9/8hE8bmKP2CvP9quQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "uint8arrays": "^5.0.1" - } - }, - "node_modules/uint8arrays": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", - "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "multiformats": "^13.0.0" - } - }, - "node_modules/undici": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", - "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, "license": "MIT" }, - "node_modules/unlimited-timeout": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unlimited-timeout/-/unlimited-timeout-0.1.0.tgz", - "integrity": "sha512-D4g+mxFeQGQHzCfnvij+R35ukJ0658Zzudw7j16p4tBBbNasKkKM4SocYxqhwT5xA7a9JYWDzKkEFyMlRi5sng==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -14392,18 +8875,6 @@ "dev": true, "license": "MIT" }, - "node_modules/utf8-byte-length": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", - "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", - "license": "(WTFPL OR MIT)" - }, - "node_modules/utf8-codec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/utf8-codec/-/utf8-codec-1.0.0.tgz", - "integrity": "sha512-S/QSLezp3qvG4ld5PUfXiH7mCFxLKjSVZRFkB3DOjgwHuJPFDkInAXc/anf7BAbHt/D38ozDzL+QMZ6/7gsI6w==", - "license": "MIT" - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -14424,16 +8895,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -14448,12 +8909,13 @@ } }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -14539,19 +9001,19 @@ } }, "node_modules/vitest": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", - "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.15", - "@vitest/mocker": "4.0.15", - "@vitest/pretty-format": "4.0.15", - "@vitest/runner": "4.0.15", - "@vitest/snapshot": "4.0.15", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -14579,10 +9041,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.15", - "@vitest/browser-preview": "4.0.15", - "@vitest/browser-webdriverio": "4.0.15", - "@vitest/ui": "4.0.15", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -14616,13 +9078,6 @@ } } }, - "node_modules/vlq": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", - "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", - "license": "MIT", - "peer": true - }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -14643,47 +9098,6 @@ "node": ">=18" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/weald": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/weald/-/weald-1.1.1.tgz", - "integrity": "sha512-PaEQShzMCz8J/AD2N3dJMc1hTZWkJeLKS2NMeiVkV5KDHwgZe7qXLEzyodsT/SODxWDdXJJqocuwf3kHzcXhSQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "ms": "^3.0.0-canary.1", - "supports-color": "^10.0.0" - } - }, - "node_modules/weald/node_modules/ms": { - "version": "3.0.0-canary.202508261828", - "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.202508261828.tgz", - "integrity": "sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/weald/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/webcrypto-core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", @@ -14731,35 +9145,15 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-fetch": { - "version": "3.6.20", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", - "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", - "license": "MIT", - "peer": true - }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -14784,19 +9178,6 @@ "node": ">=20" } }, - "node_modules/wherearewe": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wherearewe/-/wherearewe-2.0.1.tgz", - "integrity": "sha512-XUguZbDxCA2wBn2LoFtcEhXL6AXo+hVjGonwhSTTTU9SzbWG8Xu3onNIpzf9j/mYUcJQ0f+m37SzG77G851uFw==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "is-electron": "^2.2.0" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14813,9 +9194,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -14861,49 +9242,13 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "license": "ISC", - "peer": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -14930,28 +9275,6 @@ "node": ">=18" } }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -14969,67 +9292,12 @@ "node": ">=0.4" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "peer": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -15044,9 +9312,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index e7a62ede..6f1f0fec 100644 --- a/package.json +++ b/package.json @@ -12,20 +12,14 @@ "test:run": "vitest run" }, "dependencies": { + "@noble/ed25519": "^3.0.0", "@tailwindcss/vite": "^4.1.16", "@tanstack/react-query": "^5.90.10", "@types/katex": "^0.16.7", - "@helia/ipns": "^9.1.3", - "@helia/json": "^5.0.3", - "@noble/ed25519": "^3.0.0", "@unicitylabs/nostr-js-sdk": "^0.3.2", "@unicitylabs/sphere-sdk": "0.2.3", - "@unicitylabs/state-transition-sdk": "1.6.0", - "helia": "^6.0.11", "asmcrypto.js": "^2.3.2", "axios": "^1.13.2", - "bip39": "^3.1.0", - "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "crypto-js": "^4.2.0", @@ -37,12 +31,19 @@ "mixpanel-browser": "^2.72.0", "qr-code-styling": "^1.9.2", "react": "^19.1.1", - "react-dom": "^19.1.1", + "react-dom": "^19.2.4", "react-router-dom": "^7.9.6", "uuid": "^13.0.0", "webcrypto-liner": "^1.4.3", "zod": "^4.1.13" }, + "overrides": { + "@unicitylabs/sphere-sdk": { + "helia": "-", + "@helia/ipns": "-", + "@helia/json": "-" + } + }, "devDependencies": { "@eslint/js": "^9.36.0", "@testing-library/dom": "^10.4.1", diff --git a/src/components/agents/shared/AgentChat.tsx b/src/components/agents/shared/AgentChat.tsx index 4bbea8b2..db65ee59 100644 --- a/src/components/agents/shared/AgentChat.tsx +++ b/src/components/agents/shared/AgentChat.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, X, PanelLeftClose, Search, Trash2, Clock, MessageSquare, Activity, ChevronDown, Cloud, Check, Loader2, AlertCircle } from 'lucide-react'; +import { Plus, X, PanelLeftClose, Search, Trash2, Clock, MessageSquare, Activity, ChevronDown, Check } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import type { AgentConfig } from '../../../config/activities'; import { useAgentChat, type ChatMessage } from '../../../hooks/useAgentChat'; @@ -11,7 +11,6 @@ import { ChatHeader, ChatBubble, ChatInput, QuickActions } from './index'; import { useChatHistory } from './useChatHistory'; import { useUrlSession } from './useUrlSession'; import { useMentionNavigation } from '../../../hooks/useMentionNavigation'; -import type { SyncState } from './useChatHistorySync'; // Generic sidebar item (for custom agent-specific items like bets, purchases, orders) export interface SidebarItem { @@ -153,8 +152,6 @@ export function AgentChat({ showDeleteSuccess, saveCurrentMessages, searchSessions, - syncState, - syncImmediately, justDeleted, } = useChatHistory({ agentId: agent.id, @@ -489,31 +486,14 @@ export function AgentChat({ handleNewChat(); } - // Wait for IPFS sync then show success - try { - await syncImmediately(); - if (isMountedRef.current) { - showDeleteSuccess(); - } - } catch (error) { - console.error('Failed to sync after deleting session:', error); - } + showDeleteSuccess(); }; - const handleClearAllHistory = async () => { + const handleClearAllHistory = () => { clearAllHistory(); setShowClearAllConfirm(false); handleNewChat(); - - // Sync in background, show success after completion - try { - await syncImmediately(); - if (isMountedRef.current) { - showDeleteSuccess(); - } - } catch (error) { - console.error('Failed to sync after clearing history:', error); - } + showDeleteSuccess(); }; const getActionLabel = (cardData: TCardData): string => { @@ -538,85 +518,8 @@ export function AgentChat({ return new Date(timestamp).toLocaleDateString(); }; - // Get sync display info based on detailed step from IPFS service - const getSyncDisplayInfo = (state: SyncState): { label: string; icon: ReactNode; color: string } => { - // Use detailed step for more granular status - switch (state.currentStep) { - case 'initializing': - return { - label: 'Initializing...', - icon: , - color: 'text-blue-500' - }; - case 'resolving-ipns': - return { - label: 'Looking up...', - icon: , - color: 'text-blue-500' - }; - case 'fetching-content': - return { - label: 'Downloading...', - icon: , - color: 'text-blue-500' - }; - case 'importing-data': - return { - label: 'Importing...', - icon: , - color: 'text-blue-500' - }; - case 'building-data': - return { - label: 'Preparing...', - icon: , - color: 'text-amber-500' - }; - case 'uploading': - return { - label: 'Uploading...', - icon: , - color: 'text-amber-500' - }; - case 'publishing-ipns': - return { - label: 'Publishing...', - icon: , - color: 'text-amber-500' - }; - case 'complete': - return { - label: 'Synced', - icon: , - color: 'text-green-500' - }; - case 'error': - return { - label: 'Sync error', - icon: , - color: 'text-red-500' - }; - case 'idle': - default: - // Check TanStack Query states for additional context - if (state.isError) { - return { - label: 'Sync error', - icon: , - color: 'text-red-500' - }; - } - return { - label: 'Synced', - icon: , - color: 'text-neutral-400' - }; - } - }; - - // Render sync status indicator (always visible) + // Render status indicator (deletion success feedback) const renderSyncIndicator = () => { - // Show success message after deletion if (justDeleted) { return (
@@ -625,15 +528,7 @@ export function AgentChat({
); } - - const { label, icon, color } = getSyncDisplayInfo(syncState); - - return ( -
- {icon} - {syncState.stepProgress || label} -
- ); + return null; }; // Render left sidebar with tabs (history and activity) @@ -694,7 +589,7 @@ export function AgentChat({ initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} - className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg overflow-hidden z-10 min-w-[160px]" + className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg overflow-hidden z-10 min-w-40" > - )} - - - - {/* Action Buttons */} -
- - {isScanning && !l1Complete ? ( - - ) : ( - selectedAddresses.size > 0 && ( - - ) - )} -
- - - ); -} diff --git a/src/components/wallet/L1/components/modals/index.ts b/src/components/wallet/L1/components/modals/index.ts index 67119498..34a4904f 100644 --- a/src/components/wallet/L1/components/modals/index.ts +++ b/src/components/wallet/L1/components/modals/index.ts @@ -4,5 +4,4 @@ export { LoadPasswordModal } from "./LoadPasswordModal"; export { DeleteConfirmationModal } from "./DeleteConfirmationModal"; export { TransactionConfirmationModal } from "./TransactionConfirmationModal"; export { BridgeModal } from "./BridgeModal"; -export { WalletScanModal } from "./WalletScanModal"; export { SendModal } from "./SendModal"; diff --git a/src/components/wallet/L1/hooks/index.ts b/src/components/wallet/L1/hooks/index.ts index 4f5a7fb4..3333e0ef 100644 --- a/src/components/wallet/L1/hooks/index.ts +++ b/src/components/wallet/L1/hooks/index.ts @@ -1,7 +1 @@ -export { useWalletOperations } from "./useWalletOperations"; -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"; +// L1 hooks barrel - legacy hooks removed, see SDK hooks in src/sdk/hooks/ diff --git a/src/components/wallet/L1/hooks/useAddressNametags.ts b/src/components/wallet/L1/hooks/useAddressNametags.ts deleted file mode 100644 index 53d33060..00000000 --- a/src/components/wallet/L1/hooks/useAddressNametags.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; -import { fetchNametagFromIpns } from '../../L3/services/IpnsNametagFetcher'; -import { IdentityManager } from '../../L3/services/IdentityManager'; -import { checkNametagForAddress, hasTokensForAddress } from '../../L3/services/InventorySyncService'; -import type { WalletAddress } from '../sdk/types'; - -// Session key for IdentityManager (same as useWallet.ts) -const SESSION_KEY = "user-pin-1234"; - -// Polling intervals (in milliseconds) -const INITIAL_POLL_INTERVAL = 5000; // 5 seconds for first minute -const SUBSEQUENT_POLL_INTERVAL = 30000; // 30 seconds after first minute -const FREQUENT_POLL_DURATION = 60000; // 1 minute of frequent polling - -/** - * Extended address info with IPNS fetching state - */ -export interface AddressWithNametag { - address: string; - path: string; // PRIMARY KEY - BIP32 derivation path - index: number; // For display purposes only - isChange?: boolean; // For display purposes only - ipnsLoading: boolean; // True while fetching from IPFS - hasNametag: boolean; - nametag?: string; - ipnsName?: string; - ipnsError?: string; - firstFetchTime?: number; // Timestamp of first fetch attempt (for backoff) - lastFetchTime?: number; // Timestamp of last fetch attempt - // L3 inventory fields - hasL3Inventory?: boolean; // True if has L3 inventory (tokens/nametag) - l3Address?: string; // L3 address for inventory lookup -} - -/** - * Hook to fetch nametags for wallet addresses from IPNS - * - * IMPORTANT: Uses L3 identity private key (from UnifiedKeyManager) for IPNS derivation, - * NOT the L1 wallet's private key. This ensures consistency with how nametags are published. - */ -export function useAddressNametags(addresses: WalletAddress[] | undefined) { - const [addressesWithNametags, setAddressesWithNametags] = useState([]); - const pollTimerRef = useRef(null); - const mountedRef = useRef(true); - const initializedAddressesRef = useRef>(new Set()); - const fetchInProgressRef = useRef>(new Set()); - - // Cleanup on unmount - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - if (pollTimerRef.current) { - clearTimeout(pollTimerRef.current); - } - }; - }, []); - - // Fetch a single address's nametag and check L3 inventory - // Uses PATH as the single identifier - no index/isChange ambiguity - const fetchSingleNametag = useCallback(async ( - path: string // Use path as the ONLY identifier - ): Promise<{ - hasNametag: boolean; - nametag?: string; - ipnsName?: string; - ipnsError?: string; - hasL3Inventory?: boolean; - l3Address?: string; - }> => { - try { - const identityManager = IdentityManager.getInstance(SESSION_KEY); - // Use path-based derivation for unambiguous L3 identity - const l3Identity = await identityManager.deriveIdentityFromPath(path); - const l3Address = l3Identity.address; - const result = await fetchNametagFromIpns(l3Identity.privateKey); - - // Check L3 inventory from localStorage (instant check) - const localNametag = checkNametagForAddress(l3Address); - const localTokens = hasTokensForAddress(l3Address); - const hasL3Inventory = !!result.nametag || !!localNametag || localTokens; - - return { - hasNametag: !!result.nametag, - nametag: result.nametag || localNametag?.name || undefined, - ipnsName: result.ipnsName, - ipnsError: result.error, - hasL3Inventory, - l3Address, - }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return { - hasNametag: false, - ipnsError: errorMsg, - hasL3Inventory: false, - }; - } - }, []); - - // Initialize and fetch nametags for addresses - // Uses PATH as the primary key for all lookups - useEffect(() => { - if (!addresses || addresses.length === 0) { - setAddressesWithNametags([]); - initializedAddressesRef.current.clear(); - return; - } - - // Find addresses that haven't been initialized yet (use path as key) - const newAddresses = addresses.filter( - addr => addr.path && !initializedAddressesRef.current.has(addr.path) - ); - - if (newAddresses.length === 0) { - return; - } - - console.log(`🔍 [L1] Initializing ${newAddresses.length} new addresses for nametag fetch...`); - - // Mark as initialized immediately to prevent duplicate processing (use path as key) - newAddresses.forEach(addr => { - if (addr.path) initializedAddressesRef.current.add(addr.path); - }); - - // 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 = checkNametagForAddress(l3Identity.address); - const localTokens = hasTokensForAddress(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(), - }; - })); - - if (!mountedRef.current) return; - - 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; - - 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; - fetchInProgressRef.current.add(addr.path); - - console.log(`🔍 [L1] Fetching nametag for ${addr.address.slice(0, 12)}... (path: ${addr.path})`); - - // Use path for L3 derivation - unambiguous! - const result = await fetchSingleNametag(addr.path); - - if (!mountedRef.current) return; - - console.log(`🔍 [L1] IPNS result for ${addr.address.slice(0, 12)}...: ${result.nametag || 'none'}`); - - // Match by path - unambiguous! - setAddressesWithNametags(prev => - prev.map(a => - a.path === addr.path - ? { - ...a, - ipnsLoading: false, - hasNametag: result.hasNametag, - nametag: result.nametag, - ipnsName: result.ipnsName, - ipnsError: result.ipnsError, - lastFetchTime: Date.now(), - hasL3Inventory: result.hasL3Inventory, - l3Address: result.l3Address, - } - : a - ) - ); - - fetchInProgressRef.current.delete(addr.path); - } - }; - - initializeAndFetch(); - }, [addresses, fetchSingleNametag]); - - // Continuous polling for addresses without nametags - // Uses PATH as the key for all lookups - useEffect(() => { - const scheduleNextPoll = () => { - if (pollTimerRef.current) { - clearTimeout(pollTimerRef.current); - } - - // Find addresses that need polling (no nametag, not currently loading) - use path as key - const addressesNeedingPoll = addressesWithNametags.filter( - (addr) => !addr.hasNametag && !addr.ipnsLoading && addr.firstFetchTime && !fetchInProgressRef.current.has(addr.path) - ); - - if (addressesNeedingPoll.length === 0) { - return; - } - - // Determine next poll time based on oldest address's first fetch time - const now = Date.now(); - - // Check if any address is still in the "frequent poll" window - const hasRecentAddress = addressesNeedingPoll.some( - (addr) => addr.firstFetchTime && (now - addr.firstFetchTime) < FREQUENT_POLL_DURATION - ); - - const nextPollInterval = hasRecentAddress ? INITIAL_POLL_INTERVAL : SUBSEQUENT_POLL_INTERVAL; - - // Check if enough time has passed since last fetch for any address - const addressReadyForPoll = addressesNeedingPoll.filter((addr) => { - if (!addr.lastFetchTime) return true; - const timeSinceLastFetch = now - addr.lastFetchTime; - const isRecent = addr.firstFetchTime && (now - addr.firstFetchTime) < FREQUENT_POLL_DURATION; - const requiredInterval = isRecent ? INITIAL_POLL_INTERVAL : SUBSEQUENT_POLL_INTERVAL; - return timeSinceLastFetch >= requiredInterval; - }); - - if (addressReadyForPoll.length > 0) { - // Poll now - pollTimerRef.current = setTimeout(async () => { - if (!mountedRef.current) return; - - console.log(`🔄 [L1] Polling ${addressReadyForPoll.length} addresses for nametags...`); - - for (const addr of addressReadyForPoll) { - if (!mountedRef.current) return; - // Use path as key - if (fetchInProgressRef.current.has(addr.path)) continue; - - fetchInProgressRef.current.add(addr.path); - - // Mark as loading - match by path - setAddressesWithNametags((prev) => - prev.map((a) => - a.path === addr.path ? { ...a, ipnsLoading: true } : a - ) - ); - - // Use path for L3 derivation - unambiguous! - const result = await fetchSingleNametag(addr.path); - - if (!mountedRef.current) return; - - if (result.hasNametag) { - console.log(`✅ [L1] Found nametag for ${addr.address.slice(0, 12)}...: ${result.nametag} (path=${addr.path})`); - } - - // Match by path - unambiguous! - setAddressesWithNametags((prev) => - prev.map((a) => - a.path === addr.path - ? { - ...a, - ipnsLoading: false, - hasNametag: result.hasNametag, - nametag: result.nametag, - ipnsName: result.ipnsName, - ipnsError: result.ipnsError, - lastFetchTime: Date.now(), - hasL3Inventory: result.hasL3Inventory, - l3Address: result.l3Address, - } - : a - ) - ); - - fetchInProgressRef.current.delete(addr.path); - } - - // Schedule next poll - if (mountedRef.current) { - scheduleNextPoll(); - } - }, 100); - } else { - // Schedule next check - pollTimerRef.current = setTimeout(() => { - if (mountedRef.current) { - scheduleNextPoll(); - } - }, nextPollInterval); - } - }; - - // Start polling after we have addresses - if (addressesWithNametags.length > 0) { - // Start polling after a short delay - const startTimer = setTimeout(() => { - if (mountedRef.current) { - scheduleNextPoll(); - } - }, 1000); - - return () => { - clearTimeout(startTimer); - if (pollTimerRef.current) { - clearTimeout(pollTimerRef.current); - } - }; - } - }, [addressesWithNametags, fetchSingleNametag]); - - /** - * Force refresh nametag for a specific address - * @param path - BIP32 derivation path (the ONLY identifier needed) - */ - const refreshNametag = useCallback(async (path: string) => { - // Use path as the key - if (fetchInProgressRef.current.has(path)) return; - - fetchInProgressRef.current.add(path); - - // Mark as loading - match by path - setAddressesWithNametags((prev) => - prev.map((a) => - a.path === path - ? { ...a, ipnsLoading: true, hasNametag: false, nametag: undefined } - : a - ) - ); - - // Use path for L3 derivation - unambiguous! - const result = await fetchSingleNametag(path); - - // Match by path - unambiguous! - setAddressesWithNametags((prev) => - prev.map((a) => - a.path === path - ? { - ...a, - ipnsLoading: false, - hasNametag: result.hasNametag, - nametag: result.nametag, - ipnsName: result.ipnsName, - ipnsError: result.ipnsError, - lastFetchTime: Date.now(), - hasL3Inventory: result.hasL3Inventory, - l3Address: result.l3Address, - } - : a - ) - ); - - fetchInProgressRef.current.delete(path); - }, [fetchSingleNametag]); - - // Convert to lookup object for easy access by address - const nametagState: { [address: string]: AddressWithNametag } = {}; - for (const addr of addressesWithNametags) { - nametagState[addr.address] = addr; - } - - return { nametagState, addressesWithNametags, refreshNametag }; -} diff --git a/src/components/wallet/L1/hooks/useBalance.ts b/src/components/wallet/L1/hooks/useBalance.ts deleted file mode 100644 index b51ec9a4..00000000 --- a/src/components/wallet/L1/hooks/useBalance.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useState, useCallback, useEffect, useRef } from "react"; -import { getBalance } from "../sdk"; -import { subscribeBlocks } from "../sdk/network"; - -interface UseBalanceOptions { - onNewBlock?: (address: string) => void; -} - -export function useBalance(initialAddress?: string, options?: UseBalanceOptions) { - const [balance, setBalance] = useState(0); - const selectedAddressRef = useRef(initialAddress || ""); - const onNewBlockRef = useRef(options?.onNewBlock); - - // Keep onNewBlock ref updated - useEffect(() => { - onNewBlockRef.current = options?.onNewBlock; - }, [options?.onNewBlock]); - - const refreshBalance = useCallback(async (addr: string) => { - if (!addr) return; - const bal = await getBalance(addr); - setBalance(bal); - }, []); - - useEffect(() => { - selectedAddressRef.current = initialAddress || ""; - }, [initialAddress]); - - useEffect(() => { - let mounted = true; - let unsubscribe: (() => void) | null = null; - - (async () => { - try { - const unsub = (await subscribeBlocks(() => { - if (mounted && selectedAddressRef.current) { - refreshBalance(selectedAddressRef.current); - // Call onNewBlock callback if provided - onNewBlockRef.current?.(selectedAddressRef.current); - } - }) as unknown) as () => void; - - if (mounted) { - unsubscribe = unsub; - } else { - // If component unmounted before subscription completed, unsubscribe immediately - unsub(); - } - } catch (error) { - console.error("Error subscribing to blocks:", error); - } - })(); - - return () => { - mounted = false; - if (unsubscribe) { - unsubscribe(); - } - }; - }, [refreshBalance]); - - return { - balance, - refreshBalance, - selectedAddressRef, - }; -} diff --git a/src/components/wallet/L1/hooks/useConnectionStatus.ts b/src/components/wallet/L1/hooks/useConnectionStatus.ts deleted file mode 100644 index 781c7939..00000000 --- a/src/components/wallet/L1/hooks/useConnectionStatus.ts +++ /dev/null @@ -1,115 +0,0 @@ -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/hooks/useL1Wallet.ts b/src/components/wallet/L1/hooks/useL1Wallet.ts deleted file mode 100644 index 90490f3f..00000000 --- a/src/components/wallet/L1/hooks/useL1Wallet.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef, useMemo } from "react"; -import { - importWallet, - exportWallet, - downloadWalletFile, - generateHDAddress, - generateHDAddressBIP32, - getBalance, - getTransactionHistory, - getTransaction, - getCurrentBlockHeight, - createTransactionPlan, - createAndSignTransaction, - broadcast, - getUtxo, - vestingState, - type Wallet, - type TransactionHistoryItem, - type TransactionDetail, - type VestingMode, - type VestingBalances, -} from "../sdk"; -import { subscribeBlocks } from "../sdk/network"; -import { loadWalletFromUnifiedKeyManager, getUnifiedKeyManager } from "../sdk/unifiedWalletBridge"; -import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; -import { dispatchWalletUpdated } from "../../L3/services/InventorySyncService"; -import { QUERY_KEYS as L3_KEYS } from "../../../../config/queryKeys"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; - -// Query keys for L1 wallet -export const L1_KEYS = { - WALLET: ["l1", "wallet"], - BALANCE: (address: string) => ["l1", "balance", address], - TOTAL_BALANCE: ["l1", "totalBalance"], - TRANSACTIONS: (address: string) => ["l1", "transactions", address], - BLOCK_HEIGHT: ["l1", "blockHeight"], - VESTING: (address: string) => ["l1", "vesting", address], -}; - - -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 - useEffect(() => { - selectedAddressRef.current = selectedAddress || ""; - }, [selectedAddress]); - - // Subscribe to new blocks for auto-refresh - useEffect(() => { - let mounted = true; - let unsubscribe: (() => void) | null = null; - - (async () => { - try { - const unsub = (await subscribeBlocks(() => { - if (mounted && selectedAddressRef.current) { - // Invalidate balance, transactions and vesting on new block - queryClient.invalidateQueries({ - queryKey: L1_KEYS.BALANCE(selectedAddressRef.current), - }); - queryClient.invalidateQueries({ - queryKey: L1_KEYS.TOTAL_BALANCE, - }); - queryClient.invalidateQueries({ - queryKey: L1_KEYS.TRANSACTIONS(selectedAddressRef.current), - }); - queryClient.invalidateQueries({ - queryKey: L1_KEYS.VESTING(selectedAddressRef.current), - }); - queryClient.invalidateQueries({ - queryKey: L1_KEYS.BLOCK_HEIGHT, - }); - } - }) as unknown) as () => void; - - if (mounted) { - unsubscribe = unsub; - } else { - unsub(); - } - } catch (error) { - console.error("Error subscribing to blocks:", error); - } - })(); - - return () => { - mounted = false; - if (unsubscribe) { - unsubscribe(); - } - }; - }, [queryClient]); - - // Query: Balance for selected address - const balanceQuery = useQuery({ - queryKey: L1_KEYS.BALANCE(selectedAddress || ""), - queryFn: () => getBalance(selectedAddress!), - enabled: !!selectedAddress, - staleTime: 30000, // 30 seconds - }); - - // Query: Total balance for all addresses - const totalBalanceQuery = useQuery({ - queryKey: [...L1_KEYS.TOTAL_BALANCE, walletQuery.data?.addresses.map(a => a.address).join(",")], - queryFn: async () => { - const wallet = walletQuery.data; - if (!wallet || wallet.addresses.length === 0) return 0; - - const balances = await Promise.all( - wallet.addresses.map(addr => getBalance(addr.address)) - ); - return balances.reduce((sum, bal) => sum + bal, 0); - }, - enabled: !!walletQuery.data && walletQuery.data.addresses.length > 0, - staleTime: 30000, // 30 seconds - }); - - // Query: Current block height - const blockHeightQuery = useQuery({ - queryKey: L1_KEYS.BLOCK_HEIGHT, - queryFn: getCurrentBlockHeight, - staleTime: 60000, // 1 minute - }); - - // Query: Transaction history - const transactionsQuery = useQuery({ - queryKey: L1_KEYS.TRANSACTIONS(selectedAddress || ""), - queryFn: async () => { - if (!selectedAddress) return { transactions: [], details: {} }; - - const history = await getTransactionHistory(selectedAddress); - const sorted = [...history].sort((a, b) => { - if (a.height === 0 && b.height === 0) return 0; - if (a.height === 0) return -1; - if (b.height === 0) return 1; - return b.height - a.height; - }); - - // Fetch details for each transaction - const details: Record = {}; - for (const tx of sorted) { - try { - const detail = (await getTransaction(tx.tx_hash)) as TransactionDetail; - details[tx.tx_hash] = detail; - } catch (err) { - console.error(`Error loading transaction ${tx.tx_hash}:`, err); - } - } - - return { transactions: sorted, details }; - }, - enabled: !!selectedAddress, - staleTime: 30000, - }); - - // Query: Vesting balances for selected address - const vestingQuery = useQuery({ - queryKey: L1_KEYS.VESTING(selectedAddress || ""), - queryFn: async (): Promise<{ - balances: VestingBalances; - mode: VestingMode; - isClassifying: boolean; - }> => { - if (!selectedAddress) { - return { - balances: { vested: 0n, unvested: 0n, all: 0n }, - mode: vestingState.getMode(), - isClassifying: false, - }; - } - - // Get UTXOs and classify them - const utxos = await getUtxo(selectedAddress); - - if (utxos.length > 0) { - await vestingState.classifyAddressUtxos(selectedAddress, utxos); - } - - return { - balances: vestingState.getAllBalances(selectedAddress), - mode: vestingState.getMode(), - isClassifying: vestingState.isClassifying(), - }; - }, - enabled: !!selectedAddress, - staleTime: 60000, // 1 minute - vesting classification is expensive - }); - - // Mutation: Create new wallet via UnifiedKeyManager - const createWalletMutation = useMutation({ - mutationFn: async () => { - // Clear all wallet data first (ensures clean slate) - UnifiedKeyManager.clearAll(); - - // Generate new wallet - const keyManager = getUnifiedKeyManager(); - await keyManager.generateNew(12); - // Load the wallet from UnifiedKeyManager - const wallet = await loadWalletFromUnifiedKeyManager(); - if (!wallet) { - throw new Error("Failed to create wallet"); - } - return wallet; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: L1_KEYS.WALLET }); - // Also invalidate L3 queries since wallet changed - queryClient.invalidateQueries({ queryKey: ["l3", "identity"] }); - queryClient.invalidateQueries({ queryKey: ["l3", "nametag"] }); - queryClient.invalidateQueries({ queryKey: ["l3", "tokens"] }); - }, - }); - - // Mutation: Import wallet from file via UnifiedKeyManager - const importWalletMutation = useMutation({ - mutationFn: async ({ - file, - password, - }: { - file: File; - password?: string; - }) => { - // Clear all wallet data first (ensures clean slate) - UnifiedKeyManager.clearAll(); - - const keyManager = getUnifiedKeyManager(); - - // Read file content - const content = await file.text(); - - // Use UnifiedKeyManager's import (handles decryption if needed) - if (password) { - // For encrypted files, use the SDK's importWallet to decrypt first - const result = await importWallet(file, password); - if (!result.success || !result.wallet) { - throw new Error(result.error || "Import failed"); - } - // Then import the decrypted content via UnifiedKeyManager - // Construct a text file format from the decrypted wallet - const masterKey = result.wallet.masterPrivateKey; - const chainCode = result.wallet.chainCode; - let textContent = `MASTER PRIVATE KEY:\n${masterKey}`; - if (chainCode) { - textContent += `\n\nMASTER CHAIN CODE:\n${chainCode}`; - } - await keyManager.importFromFileContent(textContent); - } else { - // For unencrypted txt files, import directly - await keyManager.importFromFileContent(content); - } - - // Load the wallet from UnifiedKeyManager - const wallet = await loadWalletFromUnifiedKeyManager(); - if (!wallet) { - throw new Error("Failed to import wallet"); - } - return wallet; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: L1_KEYS.WALLET }); - // Also invalidate L3 queries since wallet changed - queryClient.invalidateQueries({ queryKey: ["l3", "identity"] }); - queryClient.invalidateQueries({ queryKey: ["l3", "nametag"] }); - queryClient.invalidateQueries({ queryKey: ["l3", "tokens"] }); - }, - }); - - // Mutation: Delete wallet via UnifiedKeyManager - const deleteWalletMutation = useMutation({ - mutationFn: async () => { - // Clear all wallet data from localStorage and reset singletons - UnifiedKeyManager.clearAll(); - - // Notify UI components of wallet change - dispatchWalletUpdated(); - }, - onSuccess: () => { - // Set identity to null immediately - this triggers WalletPanel to show onboarding - queryClient.setQueryData(L3_KEYS.IDENTITY, null); - queryClient.setQueryData(L3_KEYS.NAMETAG, null); - - // Clear all other queries - queryClient.removeQueries({ queryKey: ["wallet"] }); - queryClient.removeQueries({ queryKey: ["l1"] }); - }, - }); - - // Mutation: Send transaction - const sendTransactionMutation = useMutation({ - mutationFn: async ({ - wallet, - destination, - amount, - fromAddress, - }: { - wallet: Wallet; - destination: string; - amount: string; - fromAddress?: string; - }) => { - const amountAlpha = Number(amount); - if (isNaN(amountAlpha) || amountAlpha <= 0) { - throw new Error("Invalid amount"); - } - - const plan = await createTransactionPlan( - wallet, - destination, - amountAlpha, - fromAddress - ); - - if (!plan.success) { - throw new Error(plan.error || "Failed to create transaction plan"); - } - - const results = []; - const errors = []; - - for (const tx of plan.transactions) { - try { - const signed = createAndSignTransaction(wallet, tx); - const result = await broadcast(signed.raw); - results.push({ txid: signed.txid, raw: signed.raw, result }); - } catch (e: unknown) { - console.error("Broadcast failed for tx", e); - errors.push(e instanceof Error ? e.message : String(e)); - } - } - - if (errors.length > 0) { - throw new Error(`Some transactions failed:\n${errors.join("\n")}`); - } - - return results; - }, - onSuccess: () => { - // 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), - }); - } - }, - }); - - // Export wallet utility (not a mutation since it doesn't change state) - const handleExportWallet = ( - wallet: Wallet, - filename: string, - password?: string - ) => { - try { - const content = exportWallet(wallet, { - password: password || undefined, - filename: filename, - }); - downloadWalletFile(content, filename); - return { success: true }; - } catch (err: unknown) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - } - }; - - // Analyze transaction helper - const analyzeTransaction = ( - _tx: TransactionHistoryItem, - detail: TransactionDetail | undefined, - wallet: Wallet, - currentAddress?: string - ) => { - if (!detail || !wallet) { - return { - direction: "unknown" as const, - amount: 0, - fromAddresses: [] as string[], - toAddresses: [] as string[], - }; - } - - const walletAddresses = new Set( - wallet.addresses.map((a) => a.address.toLowerCase()) - ); - const selectedAddr = currentAddress?.toLowerCase(); - - // Include potential change addresses - if (wallet.masterPrivateKey && wallet.chainCode) { - // Use descriptorPath if available for BIP32 wallets - const basePath = wallet.descriptorPath - ? `m/${wallet.descriptorPath}` - : wallet.isImportedAlphaWallet - ? "m/84'/1'/0'" - : null; - - for (let i = 0; i < 20; i++) { - try { - let changeAddr; - if (basePath) { - // BIP32 change addresses use chain 1 - changeAddr = generateHDAddressBIP32( - wallet.masterPrivateKey, - wallet.chainCode, - i, - basePath, - true // isChange = true - ); - } else { - // Legacy derivation - changeAddr = generateHDAddress( - wallet.masterPrivateKey, - wallet.chainCode, - 1000000 + i - ); - } - walletAddresses.add(changeAddr.address.toLowerCase()); - } catch (err) { - console.error(`Failed to generate change address ${i}:`, err); - } - } - } - - // Get cached transaction details for input analysis - const cachedDetails = transactionsQuery.data?.details || {}; - - let isOurInput = false; - let totalInputAmount = 0; - const allInputAddresses: string[] = []; - - if (detail.vin) { - for (const input of detail.vin) { - if (!input.txid) continue; - - const prevTx = cachedDetails[input.txid]; - if (prevTx && prevTx.vout && prevTx.vout[input.vout]) { - const prevOutput = prevTx.vout[input.vout]; - const addresses = - prevOutput.scriptPubKey.addresses || - (prevOutput.scriptPubKey.address - ? [prevOutput.scriptPubKey.address] - : []); - - allInputAddresses.push(...addresses); - - const isFromCurrentAddress = selectedAddr - ? addresses.some((addr) => addr.toLowerCase() === selectedAddr) - : addresses.some((addr) => walletAddresses.has(addr.toLowerCase())); - - if (isFromCurrentAddress) { - isOurInput = true; - totalInputAmount += prevOutput.value; - } - } - } - } - - let amountToUs = 0; - let amountToOthers = 0; - const toAddresses: string[] = []; - const allOutputAddresses: string[] = []; - - for (const output of detail.vout) { - const addresses = - output.scriptPubKey.addresses || - (output.scriptPubKey.address ? [output.scriptPubKey.address] : []); - - allOutputAddresses.push(...addresses); - - const isToCurrentAddress = selectedAddr - ? addresses.some((addr) => addr.toLowerCase() === selectedAddr) - : addresses.some((addr) => walletAddresses.has(addr.toLowerCase())); - - if (isToCurrentAddress) { - amountToUs += output.value; - } else { - amountToOthers += output.value; - toAddresses.push(...addresses); - } - } - - const direction: "sent" | "received" = isOurInput ? "sent" : "received"; - let amount: number; - - if (direction === "sent") { - if (amountToOthers > 0) { - amount = amountToOthers; - } else { - const totalOutputAmount = amountToUs; - const fee = totalInputAmount - totalOutputAmount; - amount = fee > 0 ? fee : 0; - } - } else { - amount = amountToUs; - } - - let finalFromAddresses: string[] = []; - let finalToAddresses: string[] = []; - - if (direction === "sent") { - finalToAddresses = - toAddresses.length > 0 ? toAddresses : allOutputAddresses; - } else { - finalFromAddresses = selectedAddr - ? allInputAddresses - : allInputAddresses.filter( - (addr) => !walletAddresses.has(addr.toLowerCase()) - ); - } - - return { - direction, - amount, - fromAddresses: finalFromAddresses, - toAddresses: finalToAddresses, - }; - }; - - // Set vesting mode - const setVestingMode = (mode: VestingMode) => { - vestingState.setMode(mode); - if (selectedAddress) { - queryClient.invalidateQueries({ - queryKey: L1_KEYS.VESTING(selectedAddress), - }); - } - }; - - // Manual refresh function - const refreshAll = () => { - queryClient.invalidateQueries({ queryKey: L1_KEYS.WALLET }); - if (selectedAddress) { - queryClient.invalidateQueries({ - queryKey: L1_KEYS.BALANCE(selectedAddress), - }); - queryClient.invalidateQueries({ - queryKey: L1_KEYS.TRANSACTIONS(selectedAddress), - }); - queryClient.invalidateQueries({ - queryKey: L1_KEYS.VESTING(selectedAddress), - }); - } - }; - - return { - // Wallet state - wallet: walletQuery.data, - isLoadingWallet: walletQuery.isLoading, - - // Balance state - balance: balanceQuery.data ?? 0, - totalBalance: totalBalanceQuery.data ?? 0, - isLoadingBalance: balanceQuery.isLoading, - - // Transaction state - transactions: transactionsQuery.data?.transactions ?? [], - transactionDetails: transactionsQuery.data?.details ?? {}, - isLoadingTransactions: transactionsQuery.isLoading, - - // Block height - currentBlockHeight: blockHeightQuery.data ?? 0, - - // Vesting state - vestingBalances: vestingQuery.data?.balances ?? { vested: 0n, unvested: 0n, all: 0n }, - vestingMode: vestingQuery.data?.mode ?? "all", - isLoadingVesting: vestingQuery.isLoading, - isClassifyingVesting: vestingQuery.data?.isClassifying ?? false, - - // Mutations - createWallet: createWalletMutation.mutateAsync, - isCreatingWallet: createWalletMutation.isPending, - - importWallet: importWalletMutation.mutateAsync, - isImportingWallet: importWalletMutation.isPending, - importError: importWalletMutation.error, - - deleteWallet: deleteWalletMutation.mutateAsync, - isDeletingWallet: deleteWalletMutation.isPending, - - sendTransaction: sendTransactionMutation.mutateAsync, - isSendingTransaction: sendTransactionMutation.isPending, - sendError: sendTransactionMutation.error, - - // Utilities - exportWallet: handleExportWallet, - analyzeTransaction, - refreshAll, - setVestingMode, - - // Direct query client access for advanced use cases - invalidateWallet: () => - queryClient.invalidateQueries({ queryKey: L1_KEYS.WALLET }), - invalidateBalance: () => - selectedAddress && - queryClient.invalidateQueries({ - queryKey: L1_KEYS.BALANCE(selectedAddress), - }), - invalidateTransactions: () => - selectedAddress && - queryClient.invalidateQueries({ - queryKey: L1_KEYS.TRANSACTIONS(selectedAddress), - }), - invalidateVesting: () => - selectedAddress && - queryClient.invalidateQueries({ - queryKey: L1_KEYS.VESTING(selectedAddress), - }), - }; -} diff --git a/src/components/wallet/L1/hooks/useTransactions.ts b/src/components/wallet/L1/hooks/useTransactions.ts deleted file mode 100644 index 4aebbbee..00000000 --- a/src/components/wallet/L1/hooks/useTransactions.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { useState, useCallback } from "react"; -import { - createTransactionPlan, - createAndSignTransaction, - broadcast, - getTransactionHistory, - getTransaction, - getCurrentBlockHeight, - generateHDAddress, - type Wallet, - type TransactionPlan, - type TransactionHistoryItem, - type TransactionDetail, -} from "../sdk"; - -export function useTransactions() { - const [txPlan, setTxPlan] = useState(null); - const [isSending, setIsSending] = useState(false); - const [transactions, setTransactions] = useState([]); - const [loadingTransactions, setLoadingTransactions] = useState(false); - const [currentBlockHeight, setCurrentBlockHeight] = useState(0); - const [transactionDetails, setTransactionDetails] = useState>({}); - const [transactionDetailsCache, setTransactionDetailsCache] = useState>({}); - - const createTxPlan = useCallback( - async (wallet: Wallet, destination: string, amount: string, fromAddress?: string) => { - try { - if (!wallet) return { success: false, error: "No wallet" }; - if (!destination || !amount) { - return { success: false, error: "Enter destination and amount" }; - } - - const amountAlpha = Number(amount); - if (isNaN(amountAlpha) || amountAlpha <= 0) { - return { success: false, error: "Invalid amount" }; - } - - const plan = await createTransactionPlan(wallet, destination, amountAlpha, fromAddress); - - if (!plan.success) { - return { success: false, error: plan.error }; - } - - setTxPlan(plan); - return { success: true, plan }; - } catch (err: unknown) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - } - }, - [] - ); - - const sendTransaction = useCallback( - async (wallet: Wallet, plan: TransactionPlan) => { - if (!plan || !wallet) return { success: false, error: "No plan or wallet" }; - - setIsSending(true); - try { - const results = []; - const errors = []; - - for (const tx of plan.transactions) { - try { - const signed = createAndSignTransaction(wallet, tx); - const result = await broadcast(signed.raw); - results.push({ txid: signed.txid, raw: signed.raw, result }); - } catch (e: unknown) { - console.error("Broadcast failed for tx", e); - errors.push(e instanceof Error ? e.message : String(e)); - } - } - - setTxPlan(null); - - if (errors.length > 0) { - return { - success: false, - error: `Some transactions failed:\n${errors.join("\n")}`, - results, - }; - } - - return { success: true, results }; - } catch (err: unknown) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - } finally { - setIsSending(false); - } - }, - [] - ); - - const loadTransactionHistory = useCallback(async (address: string) => { - if (!address) return; - - setLoadingTransactions(true); - try { - const height = await getCurrentBlockHeight(); - setCurrentBlockHeight(height); - - const history = await getTransactionHistory(address); - const sorted = [...history].sort((a, b) => { - if (a.height === 0 && b.height === 0) return 0; - if (a.height === 0) return -1; - if (b.height === 0) return 1; - return b.height - a.height; - }); - setTransactions(sorted); - - const details: Record = {}; - for (const tx of sorted) { - try { - const detail = (await getTransaction(tx.tx_hash)) as TransactionDetail; - details[tx.tx_hash] = detail; - } catch (err) { - console.error(`Error loading transaction ${tx.tx_hash}:`, err); - } - } - setTransactionDetails(details); - - // Collect all previous transaction IDs from inputs that we need to fetch - const requiredPrevTxIds = new Set(); - for (const tx of sorted) { - const detail = details[tx.tx_hash]; - if (detail?.vin) { - for (const input of detail.vin) { - if (input.txid && !transactionDetailsCache[input.txid]) { - requiredPrevTxIds.add(input.txid); - } - } - } - } - - - // Fetch missing previous transactions and add to cache - const newCache = { ...transactionDetailsCache }; - - for (const txid of requiredPrevTxIds) { - try { - const prevTxDetail = (await getTransaction(txid)) as TransactionDetail; - newCache[txid] = prevTxDetail; - } catch (err) { - console.error(`Failed to fetch prev tx ${txid}:`, err); - } - } - - setTransactionDetailsCache(newCache); - } catch (err) { - console.error("Error loading transactions:", err); - setTransactions([]); - } finally { - setLoadingTransactions(false); - } - }, [transactionDetailsCache]); - - const analyzeTransaction = useCallback( - (_tx: TransactionHistoryItem, detail: TransactionDetail | undefined, wallet: Wallet, selectedAddress?: string) => { - if (!detail || !wallet) { - return { - direction: "unknown" as const, - amount: 0, - fromAddresses: [] as string[], - toAddresses: [] as string[], - }; - } - - // Build set of all our wallet addresses (for change detection) - const walletAddresses = new Set(wallet.addresses.map((a) => a.address.toLowerCase())); - - // Current address we're viewing (if specified) - const currentAddress = selectedAddress?.toLowerCase(); - - - // IMPORTANT: Also include potential change addresses (first 20) - // The wallet uses HD derivation for change, we need to check these too - // Otherwise, change outputs will be counted as "sent to others" - if (wallet.masterPrivateKey && wallet.chainCode) { - // Generate first 20 potential change addresses and add them to our address set - // This matches the behavior of the old wallet (index.html:7939-7952) - for (let i = 0; i < 20; i++) { - try { - // Change addresses use indices 1000000 + i (standard HD wallet gap) - // Or we might need to check the actual derivation path used by the wallet - const changeAddr = generateHDAddress( - wallet.masterPrivateKey, - wallet.chainCode, - 1000000 + i // Standard change address offset - ); - walletAddresses.add(changeAddr.address.toLowerCase()); - } catch (err) { - console.error(`Failed to generate change address ${i}:`, err); - } - } - } - - // Check inputs to determine if this is our transaction (we're spending) - let isOurInput = false; - let hasMissingInputData = false; - let totalInputAmount = 0; // Sum of all input amounts (for fee calculation) - const allInputAddresses: string[] = []; // All input addresses (for "from" field) - - if (detail.vin) { - for (const input of detail.vin) { - // Skip coinbase transactions (mining/generation) - they have no txid - if (!input.txid) { - // This is a coinbase input (newly mined coins) - we're receiving, not spending - continue; - } - - // Check cache for the previous transaction - const prevTx = transactionDetailsCache[input.txid]; - if (prevTx && prevTx.vout && prevTx.vout[input.vout]) { - const prevOutput = prevTx.vout[input.vout]; - const addresses = - prevOutput.scriptPubKey.addresses || - (prevOutput.scriptPubKey.address ? [prevOutput.scriptPubKey.address] : []); - - // Collect all input addresses - allInputAddresses.push(...addresses); - - // Check if input is from the CURRENT selected address (if specified) - // or from any wallet address (if no specific address selected) - const isFromCurrentAddress = currentAddress - ? addresses.some((addr) => addr.toLowerCase() === currentAddress) - : addresses.some((addr) => walletAddresses.has(addr.toLowerCase())); - - if (isFromCurrentAddress) { - isOurInput = true; - // Add to total input amount for fee calculation - totalInputAmount += prevOutput.value; - } - } else { - // Previous transaction not in cache - cannot determine input ownership - hasMissingInputData = true; - console.warn(`[analyzeTransaction] Missing prev tx ${input.txid} in cache for tx ${detail.txid}`); - } - } - } - - // Analyze outputs to calculate amounts - let amountToUs = 0; // What we received (outputs to current address) - let amountToOthers = 0; // What was sent to others (outputs not to current address) - const toAddresses: string[] = []; // Addresses that are NOT the current address - const allOutputAddresses: string[] = []; // All output addresses (including ours) - - for (const output of detail.vout) { - const addresses = - output.scriptPubKey.addresses || - (output.scriptPubKey.address ? [output.scriptPubKey.address] : []); - - // Collect ALL output addresses - allOutputAddresses.push(...addresses); - - // Check if output is to the CURRENT selected address (if specified) - // or to any wallet address (if no specific address selected) - const isToCurrentAddress = currentAddress - ? addresses.some((addr) => addr.toLowerCase() === currentAddress) - : addresses.some((addr) => walletAddresses.has(addr.toLowerCase())); - - if (isToCurrentAddress) { - amountToUs += output.value; - } else { - amountToOthers += output.value; - toAddresses.push(...addresses); - } - - } - - // Determine direction based on inputs (like old wallet): - // If ANY input is ours -> SENT (outgoing) - // If NO inputs are ours -> RECEIVED (incoming) - let direction: "sent" | "received"; - let amount: number; - - if (hasMissingInputData) { - // Fallback: if we're missing input data, try to infer from outputs - // If we have outputs to us but none to others -> likely RECEIVED - // If we have outputs to others -> likely SENT - if (amountToUs > 0 && amountToOthers === 0) { - direction = "received"; - amount = amountToUs; - } else if (amountToOthers > 0) { - direction = "sent"; - amount = amountToOthers; - } else { - // Unknown case - direction = "received"; - amount = amountToUs; - } - console.warn(`[analyzeTransaction] Using fallback direction for tx ${detail.txid}: ${direction}, amountToUs=${amountToUs}, amountToOthers=${amountToOthers}`); - } else { - // Normal case: we have complete input data - direction = isOurInput ? "sent" : "received"; - - if (direction === "sent") { - if (amountToOthers > 0) { - // Normal send: we sent to other addresses - amount = amountToOthers; - } else { - // Special case: all outputs are back to us (internal transfer/consolidation) - // Show only the fee amount (like old wallet does at index.html:8144-8148) - // Fee = total inputs - total outputs - const totalOutputAmount = amountToUs; // All outputs are to us - const fee = totalInputAmount - totalOutputAmount; - amount = fee > 0 ? fee : 0; - } - } else { - // Incoming transaction - amount = amountToUs; - } - - // Debug logging for problematic transactions - if (amount === 0 || amount < 0.00001) { - console.warn(`[analyzeTransaction] Small/zero amount for tx ${detail.txid}:`, { - direction, - isOurInput, - amountToUs, - amountToOthers, - finalAmount: amount, - voutCount: detail.vout.length, - vinCount: detail.vin?.length || 0 - }); - } - } - - // Determine from/to addresses based on direction: - // - If SENT: fromAddresses = our addresses (inputs), toAddresses = external outputs - // - If RECEIVED: fromAddresses = external inputs, toAddresses = our addresses (outputs) - let finalFromAddresses: string[] = []; - let finalToAddresses: string[] = []; - - if (direction === "sent") { - // We sent: show TO addresses (where we sent to) - if (toAddresses.length > 0) { - // Normal send to external addresses - finalToAddresses = toAddresses; - } else { - // Internal transfer: all outputs are to our addresses - finalToAddresses = allOutputAddresses; - } - } else { - // We received: show FROM addresses (who sent to us) - if (currentAddress) { - // When viewing a specific address, show ALL input addresses (including our other wallet addresses) - finalFromAddresses = allInputAddresses; - } else { - // When viewing entire wallet, filter out our own addresses - finalFromAddresses = allInputAddresses.filter( - (addr) => !walletAddresses.has(addr.toLowerCase()) - ); - } - } - - return { - direction, - amount: amount, // output.value is already in ALPHA (decimal), not satoshis - fromAddresses: finalFromAddresses, - toAddresses: finalToAddresses, - }; - }, - [transactionDetailsCache] - ); - - return { - txPlan, - setTxPlan, - isSending, - transactions, - loadingTransactions, - currentBlockHeight, - transactionDetails, - createTxPlan, - sendTransaction, - loadTransactionHistory, - analyzeTransaction, - }; -} diff --git a/src/components/wallet/L1/hooks/useWalletOperations.ts b/src/components/wallet/L1/hooks/useWalletOperations.ts deleted file mode 100644 index 9db5a5e0..00000000 --- a/src/components/wallet/L1/hooks/useWalletOperations.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useState, useCallback } from "react"; -import { - createWallet, - deleteWallet, - importWallet, - exportWallet, - downloadWalletFile, - generateHDAddress, - saveWalletToStorage, - type Wallet, -} from "../sdk"; - -export function useWalletOperations() { - const [pendingFile, setPendingFile] = useState(null); - - const handleCreateWallet = useCallback(async (): Promise => { - const w = createWallet(); - return w; - }, []); - - const handleDeleteWallet = useCallback(() => { - deleteWallet(); - }, []); - - const handleImportWallet = useCallback( - async (file: File, password?: string): Promise<{ success: boolean; wallet?: Wallet; error?: string }> => { - try { - const result = await importWallet(file, password); - - if (result.success && result.wallet) { - // Regenerate addresses for BIP32 wallets - if (result.wallet.isImportedAlphaWallet && result.wallet.chainCode) { - const addresses = []; - for (let i = 0; i < (result.wallet.addresses.length || 1); i++) { - const addr = generateHDAddress( - result.wallet.masterPrivateKey, - result.wallet.chainCode, - i - ); - addresses.push(addr); - } - result.wallet.addresses = addresses; - } - - // Save to localStorage - saveWalletToStorage("main", result.wallet); - - return { success: true, wallet: result.wallet }; - } else { - return { success: false, error: result.error }; - } - } catch (err: unknown) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - } - }, - [] - ); - - const handleExportWallet = useCallback( - (wallet: Wallet, filename: string, password?: string) => { - try { - const content = exportWallet(wallet, { - password: password || undefined, - filename: filename, - }); - - downloadWalletFile(content, filename); - - return { success: true }; - } catch (err: unknown) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - } - }, - [] - ); - - return { - pendingFile, - setPendingFile, - handleCreateWallet, - handleDeleteWallet, - handleImportWallet, - handleExportWallet, - }; -} diff --git a/src/components/wallet/L1/modals/L1WalletModal.tsx b/src/components/wallet/L1/modals/L1WalletModal.tsx index 1c3ec443..d13161e9 100644 --- a/src/components/wallet/L1/modals/L1WalletModal.tsx +++ b/src/components/wallet/L1/modals/L1WalletModal.tsx @@ -115,6 +115,11 @@ export function L1WalletModal({ isOpen, onClose, showBalances }: L1WalletModalPr setIsSwitching(true); try { await sphere.switchToAddress(index); + // Remove cached data so stale values from the previous address aren't shown + queryClient.removeQueries({ queryKey: SPHERE_KEYS.identity.all }); + queryClient.removeQueries({ queryKey: SPHERE_KEYS.l1.all }); + queryClient.removeQueries({ queryKey: SPHERE_KEYS.payments.all }); + // Re-fetch fresh data for the new address queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.identity.all }); queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.l1.all }); queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.all }); diff --git a/src/components/wallet/L1/sdk/address.ts b/src/components/wallet/L1/sdk/address.ts deleted file mode 100644 index ccc6a932..00000000 --- a/src/components/wallet/L1/sdk/address.ts +++ /dev/null @@ -1,221 +0,0 @@ -import CryptoJS from "crypto-js"; -import { generateAddressInfo, ec } from "../../shared/utils/cryptoUtils"; - -// secp256k1 curve order -const CURVE_ORDER = BigInt( - "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" -); - -/** - * Standard BIP32 child key derivation - * @param parentPrivKey - Parent private key (hex string, 64 chars) - * @param parentChainCode - Parent chain code (hex string, 64 chars) - * @param index - Child index (use >= 0x80000000 for hardened) - * @returns Child private key and chain code - */ -export function deriveChildKeyBIP32( - parentPrivKey: string, - parentChainCode: string, - index: number -): { privateKey: string; chainCode: string } { - const isHardened = index >= 0x80000000; - let data: string; - - if (isHardened) { - // Hardened derivation: 0x00 || parentPrivKey || index - const indexHex = index.toString(16).padStart(8, "0"); - data = "00" + parentPrivKey + indexHex; - } else { - // Non-hardened derivation: compressedPubKey || index - const keyPair = ec.keyFromPrivate(parentPrivKey, "hex"); - const compressedPubKey = keyPair.getPublic(true, "hex"); - const indexHex = index.toString(16).padStart(8, "0"); - data = compressedPubKey + indexHex; - } - - // HMAC-SHA512 with chain code as key - const I = CryptoJS.HmacSHA512( - CryptoJS.enc.Hex.parse(data), - CryptoJS.enc.Hex.parse(parentChainCode) - ).toString(); - - const IL = I.substring(0, 64); // Left 32 bytes - const IR = I.substring(64); // Right 32 bytes (new chain code) - - // Add IL to parent key mod n (curve order) - const ilBigInt = BigInt("0x" + IL); - const parentKeyBigInt = BigInt("0x" + parentPrivKey); - - // Check IL is valid (less than curve order) - if (ilBigInt >= CURVE_ORDER) { - throw new Error("Invalid key: IL >= curve order"); - } - - const childKeyBigInt = (ilBigInt + parentKeyBigInt) % CURVE_ORDER; - - // Check child key is valid (not zero) - if (childKeyBigInt === 0n) { - throw new Error("Invalid key: child key is zero"); - } - - const childPrivKey = childKeyBigInt.toString(16).padStart(64, "0"); - - return { - privateKey: childPrivKey, - chainCode: IR, - }; -} - -/** - * Derive key at a full BIP44 path - * @param masterPrivKey - Master private key - * @param masterChainCode - Master chain code - * @param path - BIP44 path like "m/44'/0'/0'/0/0" - */ -export function deriveKeyAtPath( - masterPrivKey: string, - masterChainCode: string, - path: string -): { privateKey: string; chainCode: string } { - const pathParts = path.replace("m/", "").split("/"); - - let currentKey = masterPrivKey; - let currentChainCode = masterChainCode; - - for (const part of pathParts) { - const isHardened = part.endsWith("'") || part.endsWith("h"); - const indexStr = part.replace(/['h]$/, ""); - let index = parseInt(indexStr, 10); - - if (isHardened) { - index += 0x80000000; // Add hardened offset - } - - const derived = deriveChildKeyBIP32(currentKey, currentChainCode, index); - currentKey = derived.privateKey; - currentChainCode = derived.chainCode; - } - - return { - privateKey: currentKey, - chainCode: currentChainCode, - }; -} - -/** - * Generate master key and chain code from seed (BIP32 standard) - * @param seedHex - Random seed (typically 64 bytes from BIP39 mnemonic) - */ -export function generateMasterKeyFromSeed(seedHex: string): { - masterPrivateKey: string; - masterChainCode: string; -} { - // BIP32: HMAC-SHA512 with key "Bitcoin seed" - const I = CryptoJS.HmacSHA512( - CryptoJS.enc.Hex.parse(seedHex), - CryptoJS.enc.Utf8.parse("Bitcoin seed") - ).toString(); - - const IL = I.substring(0, 64); // Master private key - const IR = I.substring(64); // Master chain code - - // Validate master key - const masterKeyBigInt = BigInt("0x" + IL); - if (masterKeyBigInt === 0n || masterKeyBigInt >= CURVE_ORDER) { - throw new Error("Invalid master key generated"); - } - - return { - masterPrivateKey: IL, - masterChainCode: IR, - }; -} - -/** - * Generate HD address using standard BIP32 - * Standard path: m/44'/0'/0'/0/{index} (external chain, non-hardened) - * For change addresses, use isChange = true to get m/44'/0'/0'/1/{index} - */ -export function generateHDAddressBIP32( - masterPriv: string, - chainCode: string, - index: number, - basePath: string = "m/44'/0'/0'", - isChange: boolean = false -) { - // Chain: 0 = external (receiving), 1 = internal (change) - const chain = isChange ? 1 : 0; - const fullPath = `${basePath}/${chain}/${index}`; - - const derived = deriveKeyAtPath(masterPriv, chainCode, fullPath); - - return generateAddressInfo(derived.privateKey, index, fullPath); -} - -// ============================================ -// Original index.html compatible derivation -// ============================================ - -/** - * Generate address from master private key using HMAC-SHA512 derivation - * This matches exactly the original index.html implementation - * @param masterPrivateKey - 32-byte hex private key (64 chars) - * @param index - Address index - */ -export function generateAddressFromMasterKey( - masterPrivateKey: string, - index: number -) { - const derivationPath = `m/44'/0'/${index}'`; - - // HMAC-SHA512 with path as key (matching index.html exactly) - const hmacInput = CryptoJS.enc.Hex.parse(masterPrivateKey); - const hmacKey = CryptoJS.enc.Utf8.parse(derivationPath); - const hmacOutput = CryptoJS.HmacSHA512(hmacInput, hmacKey).toString(); - - // Use left 32 bytes for private key - const childPrivateKey = hmacOutput.substring(0, 64); - - return generateAddressInfo(childPrivateKey, index, derivationPath); -} - -// ============================================ -// Legacy functions for backward compatibility -// ============================================ - -/** - * @deprecated Use deriveChildKeyBIP32 for new wallets - * Legacy HMAC-SHA512 derivation (non-standard) - */ -export function deriveChildKey( - masterPriv: string, - chainCode: string, - index: number -) { - const data = masterPriv + index.toString(16).padStart(8, "0"); - - const I = CryptoJS.HmacSHA512( - CryptoJS.enc.Hex.parse(data), - CryptoJS.enc.Hex.parse(chainCode) - ).toString(); - - return { - privateKey: I.substring(0, 64), - nextChainCode: I.substring(64), - }; -} - -/** - * @deprecated Use generateHDAddressBIP32 for new wallets - * Legacy HD address generation (non-standard derivation) - */ -export function generateHDAddress( - masterPriv: string, - chainCode: string, - index: number -) { - const child = deriveChildKey(masterPriv, chainCode, index); - const path = `m/44'/0'/0'/${index}`; - - return generateAddressInfo(child.privateKey, index, path); -} diff --git a/src/components/wallet/L1/sdk/addressHelpers.ts b/src/components/wallet/L1/sdk/addressHelpers.ts deleted file mode 100644 index 05b67203..00000000 --- a/src/components/wallet/L1/sdk/addressHelpers.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * WalletAddressHelper - Path-based address lookup and mutation utilities - * - * Key principle: A BIP32 path ALWAYS derives the same address from a given master key. - * - If we try to add a different address for an existing path → FATAL ERROR - * - This indicates corruption, wrong derivation, or data integrity issue - * - * Performance: O(n) lookup is negligible for typical wallet sizes (5-100 addresses) - */ - -import type { Wallet, WalletAddress } from "./types"; - -export class WalletAddressHelper { - /** - * Find address by BIP32 derivation path - * @param wallet - The wallet to search - * @param path - Full BIP32 path like "m/84'/1'/0'/0/5" - * @returns The address if found, undefined otherwise - */ - static findByPath(wallet: Wallet, path: string): WalletAddress | undefined { - return wallet.addresses.find((a) => a.path === path); - } - - /** - * Get the default address (first external/non-change address) - * This replaces `wallet.addresses[0]` pattern for safer access - * - * @param wallet - The wallet - * @returns First non-change address, or first address if all are change - */ - static getDefault(wallet: Wallet): WalletAddress { - return wallet.addresses.find((a) => !a.isChange) ?? wallet.addresses[0]; - } - - /** - * Get the default address, or undefined if wallet has no addresses - * Safe version that doesn't throw on empty wallet - */ - static getDefaultOrNull(wallet: Wallet): WalletAddress | undefined { - if (!wallet.addresses || wallet.addresses.length === 0) { - return undefined; - } - return wallet.addresses.find((a) => !a.isChange) ?? wallet.addresses[0]; - } - - /** - * Add new address to wallet (immutable operation) - * - * THROWS if address with same path but different address string already exists. - * This indicates a serious derivation or data corruption issue. - * - * If the same path+address already exists, returns wallet unchanged (idempotent). - * - * @param wallet - The wallet to add to - * @param newAddress - The address to add - * @returns New wallet object with address added - * @throws Error if path exists with different address (corruption indicator) - */ - static add(wallet: Wallet, newAddress: WalletAddress): Wallet { - if (!newAddress.path) { - throw new Error("Cannot add address without a path"); - } - - const existing = this.findByPath(wallet, newAddress.path); - - if (existing) { - // Path exists - verify it's the SAME address - if (existing.address !== newAddress.address) { - throw new Error( - `CRITICAL: Attempted to overwrite address for path ${newAddress.path}\n` + - `Existing: ${existing.address}\n` + - `New: ${newAddress.address}\n` + - `This indicates master key corruption or derivation logic error.` - ); - } - - // Same path + same address = idempotent, return unchanged - return wallet; - } - - // New path - add to array - return { - ...wallet, - addresses: [...wallet.addresses, newAddress], - }; - } - - /** - * Remove address by path (immutable operation) - * @param wallet - The wallet to modify - * @param path - The path of the address to remove - * @returns New wallet object with address removed - */ - static removeByPath(wallet: Wallet, path: string): Wallet { - return { - ...wallet, - addresses: wallet.addresses.filter((a) => a.path !== path), - }; - } - - /** - * Get all external (non-change) addresses - * @param wallet - The wallet - * @returns Array of external addresses - */ - static getExternal(wallet: Wallet): WalletAddress[] { - return wallet.addresses.filter((a) => !a.isChange); - } - - /** - * Get all change addresses - * @param wallet - The wallet - * @returns Array of change addresses - */ - static getChange(wallet: Wallet): WalletAddress[] { - return wallet.addresses.filter((a) => a.isChange); - } - - /** - * Check if wallet has an address with the given path - * @param wallet - The wallet to check - * @param path - The path to look for - * @returns true if path exists - */ - static hasPath(wallet: Wallet, path: string): boolean { - return wallet.addresses.some((a) => a.path === path); - } - - /** - * Validate wallet address array integrity - * Checks for duplicate paths which indicate data corruption - * - * @param wallet - The wallet to validate - * @throws Error if duplicate paths found - */ - static validate(wallet: Wallet): void { - const paths = wallet.addresses.map((a) => a.path).filter(Boolean); - const uniquePaths = new Set(paths); - - if (paths.length !== uniquePaths.size) { - // Find duplicates for error message - const duplicates = paths.filter((p, i) => paths.indexOf(p) !== i); - throw new Error( - `CRITICAL: Wallet has duplicate paths: ${duplicates.join(", ")}\n` + - `This indicates data corruption. Please restore from backup.` - ); - } - } - - /** - * Sort addresses with external first, then change, each sorted by index - * Useful for display purposes - * - * @param wallet - The wallet - * @returns New wallet with sorted addresses - */ - static sortAddresses(wallet: Wallet): Wallet { - const sorted = [...wallet.addresses].sort((a, b) => { - // External addresses first (isChange = false/undefined) - const aIsChange = a.isChange ? 1 : 0; - const bIsChange = b.isChange ? 1 : 0; - if (aIsChange !== bIsChange) return aIsChange - bIsChange; - // Then by index - return a.index - b.index; - }); - - return { - ...wallet, - addresses: sorted, - }; - } -} diff --git a/src/components/wallet/L1/sdk/addressToScriptHash.ts b/src/components/wallet/L1/sdk/addressToScriptHash.ts deleted file mode 100644 index 66991938..00000000 --- a/src/components/wallet/L1/sdk/addressToScriptHash.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { decodeBech32 } from "./bech32"; -import CryptoJS from "crypto-js"; - -/** Convert bytes to hex */ -function bytesToHex(buf: Uint8Array) { - return Array.from(buf) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -/** - * Convert "alpha1xxxx" Bech32 → Electrum script hash - * Required for: - * - blockchain.scripthash.get_history - * - blockchain.scripthash.listunspent - */ -export function addressToScriptHash(address: string): string { - const decoded = decodeBech32(address); - if (!decoded) throw new Error("Invalid bech32 address: " + address); - - // witness program always starts with OP_0 + PUSH20 (for P2WPKH) - const scriptHex = "0014" + bytesToHex(decoded.data); - - // SHA256 - const sha = CryptoJS.SHA256(CryptoJS.enc.Hex.parse(scriptHex)).toString(); - - // Electrum requires reversed byte order - return sha.match(/../g)!.reverse().join(""); -} diff --git a/src/components/wallet/L1/sdk/bech32.ts b/src/components/wallet/L1/sdk/bech32.ts deleted file mode 100644 index e8cd5e0c..00000000 --- a/src/components/wallet/L1/sdk/bech32.ts +++ /dev/null @@ -1,148 +0,0 @@ -// sdk/l1/bech32.ts - -// CHARSET from BIP-173 -export const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; - -// ----------------------------- -// Convert bit arrays (8→5 / 5→8) -// ----------------------------- -export function convertBits( - data: number[], - fromBits: number, - toBits: number, - pad: boolean -) { - let acc = 0; - let bits = 0; - const ret = []; - const maxv = (1 << toBits) - 1; - - for (let i = 0; i < data.length; i++) { - const value = data[i]; - if (value < 0 || value >> fromBits !== 0) return null; - acc = (acc << fromBits) | value; - bits += fromBits; - while (bits >= toBits) { - bits -= toBits; - ret.push((acc >> bits) & maxv); - } - } - - if (pad) { - if (bits > 0) { - ret.push((acc << (toBits - bits)) & maxv); - } - } else if (bits >= fromBits || (acc << (toBits - bits)) & maxv) { - return null; - } - - return ret; -} - -// ----------------------------- -// HRP Expand -// ----------------------------- -function hrpExpand(hrp: string) { - const ret = []; - for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5); - ret.push(0); - for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31); - return ret; -} - -// ----------------------------- -// Polymod (checksum core) -// ----------------------------- -function bech32Polymod(values: number[]) { - const GENERATOR = [ - 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3, - ]; - - let chk = 1; - for (let p = 0; p < values.length; p++) { - const top = chk >> 25; - chk = ((chk & 0x1ffffff) << 5) ^ values[p]; - for (let i = 0; i < 5; i++) { - if ((top >> i) & 1) chk ^= GENERATOR[i]; - } - } - return chk; -} - -// ----------------------------- -// Create checksum -// ----------------------------- -function bech32Checksum(hrp: string, data: number[]) { - const values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]); - const mod = bech32Polymod(values) ^ 1; - - const ret = []; - for (let p = 0; p < 6; p++) { - ret.push((mod >> (5 * (5 - p))) & 31); - } - return ret; -} - -// ----------------------------- -// ENCODE (create address) -// ----------------------------- -export function createBech32( - hrp: string, - version: number, - program: Uint8Array -) { - if (version < 0 || version > 16) { - throw new Error("Invalid witness version"); - } - - const data = [version].concat(convertBits(Array.from(program), 8, 5, true)!); - - const checksum = bech32Checksum(hrp, data); - const combined = data.concat(checksum); - - let out = hrp + "1"; - for (let i = 0; i < combined.length; i++) { - out += CHARSET[combined[i]]; - } - - return out; -} - -// ----------------------------- -// DECODE (parse address) -// ----------------------------- -export function decodeBech32(addr: string) { - addr = addr.toLowerCase(); - - const pos = addr.lastIndexOf("1"); - if (pos < 1) return null; - - const hrp = addr.substring(0, pos); - const dataStr = addr.substring(pos + 1); - - const data = []; - for (let i = 0; i < dataStr.length; i++) { - const val = CHARSET.indexOf(dataStr[i]); - if (val === -1) return null; - data.push(val); - } - - // Validate checksum - const checksum = bech32Checksum(hrp, data.slice(0, -6)); - for (let i = 0; i < 6; i++) { - if (checksum[i] !== data[data.length - 6 + i]) { - console.error("Invalid bech32 checksum"); - return null; - } - } - - const version = data[0]; - const program = convertBits(data.slice(1, -6), 5, 8, false); - if (!program) return null; - - return { - hrp, - witnessVersion: version, - data: Uint8Array.from(program), - }; -} diff --git a/src/components/wallet/L1/sdk/crypto.ts b/src/components/wallet/L1/sdk/crypto.ts deleted file mode 100644 index eeace8f9..00000000 --- a/src/components/wallet/L1/sdk/crypto.ts +++ /dev/null @@ -1,97 +0,0 @@ -import CryptoJS from "crypto-js"; - -const SALT = "alpha_wallet_salt"; -const PBKDF2_ITERATIONS = 100000; - -export function encrypt(text: string, password: string): string { - return CryptoJS.AES.encrypt(text, password).toString(); -} - -export function decrypt(encrypted: string, password: string): string { - const bytes = CryptoJS.AES.decrypt(encrypted, password); - return bytes.toString(CryptoJS.enc.Utf8); -} - -export function generatePrivateKey(): string { - return CryptoJS.lib.WordArray.random(32).toString(); -} - -/** - * Encrypt wallet master key with password using PBKDF2 + AES - */ -export function encryptWallet( - masterPrivateKey: string, - password: string -): string { - const passwordKey = CryptoJS.PBKDF2(password, SALT, { - keySize: 256 / 32, - iterations: PBKDF2_ITERATIONS, - }).toString(); - - const encrypted = CryptoJS.AES.encrypt( - masterPrivateKey, - passwordKey - ).toString(); - - return encrypted; -} - -/** - * Decrypt wallet master key with password - */ -export function decryptWallet( - encryptedData: string, - password: string -): string { - const passwordKey = CryptoJS.PBKDF2(password, SALT, { - keySize: 256 / 32, - iterations: PBKDF2_ITERATIONS, - }).toString(); - - const decrypted = CryptoJS.AES.decrypt(encryptedData, passwordKey); - return decrypted.toString(CryptoJS.enc.Utf8); -} - -/** - * Convert hex private key to WIF format - */ -export function hexToWIF(hexKey: string): string { - // Alpha mainnet version byte is 0x80 - const versionByte = "80"; - const extendedKey = versionByte + hexKey; - - // Calculate checksum - const hash1 = CryptoJS.SHA256(CryptoJS.enc.Hex.parse(extendedKey)).toString(); - const hash2 = CryptoJS.SHA256(CryptoJS.enc.Hex.parse(hash1)).toString(); - const checksum = hash2.substring(0, 8); - - // Combine and encode - const finalHex = extendedKey + checksum; - - // Convert to Base58 - return base58Encode(finalHex); -} - -/** - * Base58 encoding - */ -function base58Encode(hex: string): string { - const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - - // Convert hex to big integer - let num = BigInt("0x" + hex); - let encoded = ""; - - while (num > 0n) { - const remainder = Number(num % 58n); - num = num / 58n; - encoded = ALPHABET[remainder] + encoded; - } - - // Add leading 1s for leading 0s in hex - for (let i = 0; i < hex.length && hex.substring(i, i + 2) === "00"; i += 2) { - encoded = "1" + encoded; - } - - return encoded; -} diff --git a/src/components/wallet/L1/sdk/import-export.ts b/src/components/wallet/L1/sdk/import-export.ts deleted file mode 100644 index d1fdd8ab..00000000 --- a/src/components/wallet/L1/sdk/import-export.ts +++ /dev/null @@ -1,1678 +0,0 @@ -/** - * Wallet Import/Export - Strict copy of index.html logic - */ -import CryptoJS from "crypto-js"; -import { hexToWIF } from "./crypto"; -import { deriveKeyAtPath } from "./address"; -import type { - Wallet, - WalletAddress, - RestoreWalletResult, - ExportOptions, - WalletJSON, - WalletJSONSource, - WalletJSONDerivationMode, - WalletJSONAddress, - WalletJSONExportOptions, - WalletJSONImportResult, -} from "./types"; -import { publicKeyToAddress, ec } from "../../shared/utils/cryptoUtils"; - -// Re-export types -export type { - RestoreWalletResult, - ExportOptions, - WalletJSON, - WalletJSONSource, - WalletJSONDerivationMode, - WalletJSONAddress, - WalletJSONExportOptions, - WalletJSONImportResult, -}; - -/** - * Helper: bytes to hex string - */ -function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); -} - -/** - * Helper: read binary file as Uint8Array - */ -function readBinaryFile(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = e => resolve(new Uint8Array(e.target?.result as ArrayBuffer)); - reader.onerror = reject; - reader.readAsArrayBuffer(file); - }); -} - -/** - * Helper: find pattern in Uint8Array - */ -function findPattern(data: Uint8Array, pattern: Uint8Array, startIndex: number = 0): number { - for (let i = startIndex; i <= data.length - pattern.length; i++) { - let found = true; - for (let j = 0; j < pattern.length; j++) { - if (data[i + j] !== pattern[j]) { - found = false; - break; - } - } - if (found) return i; - } - return -1; -} - -/** - * Validate if a hex string is a valid secp256k1 private key - */ -function isValidPrivateKey(hex: string): boolean { - try { - const n = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); - const key = BigInt('0x' + hex); - return key > 0n && key < n; - } catch { - return false; - } -} - -/** - * Yield to the event loop to prevent UI freeze - */ -function yieldToMain(): Promise { - return new Promise(resolve => setTimeout(resolve, 0)); -} - -/** - * Convert Uint8Array to CryptoJS WordArray via hex encoding - * This is the most reliable cross-platform method - */ -function uint8ArrayToWordArray(u8arr: Uint8Array): CryptoJS.lib.WordArray { - // Convert to hex string first, then parse - this is unambiguous - const hex = bytesToHex(u8arr); - return CryptoJS.enc.Hex.parse(hex); -} - -/** - * Decrypt master key from CMasterKey structure - * Uses iterative SHA-512 (Bitcoin Core's method from crypter.cpp) - * Async to prevent UI freeze during heavy computation - */ -async function decryptMasterKey( - encryptedKey: Uint8Array, - salt: Uint8Array, - iterations: number, - password: string -): Promise { - // Derive key and IV using iterative SHA-512 (Bitcoin Core's BytesToKeySHA512AES method) - // First hash: SHA512(password + salt) - const passwordHex = bytesToHex(new TextEncoder().encode(password)); - const saltHex = bytesToHex(salt); - const inputHex = passwordHex + saltHex; - - // Parse hex to WordArray for SHA512 - let hash = CryptoJS.SHA512(CryptoJS.enc.Hex.parse(inputHex)); - - // Process remaining iterations in batches to avoid blocking UI - const BATCH_SIZE = 1000; - for (let i = 0; i < iterations - 1; i++) { - hash = CryptoJS.SHA512(hash); - // Yield every BATCH_SIZE iterations to keep UI responsive - if (i % BATCH_SIZE === 0) { - await yieldToMain(); - } - } - - // Key is first 32 bytes (8 words), IV is next 16 bytes (4 words) - const derivedKey = CryptoJS.lib.WordArray.create(hash.words.slice(0, 8)); - const derivedIv = CryptoJS.lib.WordArray.create(hash.words.slice(8, 12)); - - // Decrypt master key using AES-256-CBC - const encryptedWords = uint8ArrayToWordArray(encryptedKey); - - const decrypted = CryptoJS.AES.decrypt( - { ciphertext: encryptedWords } as CryptoJS.lib.CipherParams, - derivedKey, - { iv: derivedIv, padding: CryptoJS.pad.Pkcs7, mode: CryptoJS.mode.CBC } - ); - - const result = CryptoJS.enc.Hex.stringify(decrypted); - - if (!result || result.length !== 64) { - throw new Error('Master key decryption failed - incorrect password'); - } - - return result; -} - -interface CMasterKeyData { - encryptedKey: Uint8Array; - salt: Uint8Array; - derivationMethod: number; - iterations: number; - position: number; -} - -/** - * Find ALL CMasterKey structures in wallet.dat - * Returns array of all found structures (wallet may have multiple) - * - * CMasterKey format: - * - vchCryptedKey: compact_size (1 byte = 0x30) + encrypted_key (48 bytes) - * - vchSalt: compact_size (1 byte = 0x08) + salt (8 bytes) - * - nDerivationMethod: uint32 (4 bytes) - * - nDeriveIterations: uint32 (4 bytes) - */ -function findAllCMasterKeys(data: Uint8Array): CMasterKeyData[] { - const results: CMasterKeyData[] = []; - - // Search for CMasterKey structure pattern: - // 0x30 (encrypted key length = 48) followed by 48 bytes, - // then 0x08 (salt length = 8) followed by 8 bytes, - // then derivation method (4 bytes) and iterations (4 bytes) - - for (let pos = 0; pos < data.length - 70; pos++) { - if (data[pos] === 0x30) { // 48 = encrypted key length - const saltLenPos = pos + 1 + 48; - if (saltLenPos < data.length && data[saltLenPos] === 0x08) { // 8 = salt length - const iterPos = saltLenPos + 1 + 8 + 4; // after salt + derivation method - if (iterPos + 4 <= data.length) { - const iterations = data[iterPos] | (data[iterPos + 1] << 8) | - (data[iterPos + 2] << 16) | (data[iterPos + 3] << 24); - // Bitcoin Core typically uses 25000-500000 iterations - if (iterations >= 1000 && iterations <= 10000000) { - const encryptedKey = data.slice(pos + 1, pos + 1 + 48); - const salt = data.slice(saltLenPos + 1, saltLenPos + 1 + 8); - const derivationMethod = data[saltLenPos + 1 + 8] | (data[saltLenPos + 1 + 8 + 1] << 8) | - (data[saltLenPos + 1 + 8 + 2] << 16) | (data[saltLenPos + 1 + 8 + 3] << 24); - - console.log(`Found CMasterKey at position ${pos}, iterations: ${iterations}`); - results.push({ encryptedKey, salt, derivationMethod, iterations, position: pos }); - } - } - } - } - } - - console.log(`Total CMasterKey structures found: ${results.length}`); - return results; -} - -/** - * Decrypt encrypted wallet.dat file and extract keys - * Port of webwallet's decryptAndImportWallet() function - * Async to prevent UI freeze during heavy computation - */ -async function decryptEncryptedWalletDat(data: Uint8Array, password: string): Promise<{ - masterKey: string; - chainCode: string; - descriptorPath: string; -} | null> { - try { - // Step 1: Find ALL CMasterKey structures (wallet may have multiple) - const cmasterKeys = findAllCMasterKeys(data); - - if (cmasterKeys.length === 0) { - throw new Error('Could not find CMasterKey structure in wallet'); - } - - // Try to decrypt each CMasterKey until one succeeds - let masterKeyHex: string | null = null; - for (const cmk of cmasterKeys) { - try { - console.log(`Trying CMasterKey at position ${cmk.position}...`); - masterKeyHex = await decryptMasterKey( - cmk.encryptedKey, - cmk.salt, - cmk.iterations, - password - ); - if (masterKeyHex && masterKeyHex.length === 64) { - console.log(`✓ Successfully decrypted with CMasterKey at position ${cmk.position}`); - break; - } - } catch (e) { - console.log(`✗ Failed with CMasterKey at position ${cmk.position}: ${e instanceof Error ? e.message : e}`); - // Continue to next CMasterKey - } - } - - if (!masterKeyHex || masterKeyHex.length !== 64) { - throw new Error('Master key decryption failed - incorrect password'); - } - - // Step 2: Find wpkh descriptor with /0/* (receive addresses) - const descriptorPattern = new TextEncoder().encode('walletdescriptor'); - let descriptorIndex = 0; - let descriptorId: Uint8Array | null = null; - let xpubString: string | null = null; - - while ((descriptorIndex = findPattern(data, descriptorPattern, descriptorIndex)) !== -1) { - // Skip descriptor ID (32 bytes) - it's between the prefix and the value - let scanPos = descriptorIndex + descriptorPattern.length + 32; - - // Read the descriptor value (starts with compact size) - const descLen = data[scanPos]; - scanPos++; - - const descBytes = data.slice(scanPos, scanPos + Math.min(descLen, 200)); - let descStr = ''; - for (let i = 0; i < descBytes.length && descBytes[i] >= 32 && descBytes[i] <= 126; i++) { - descStr += String.fromCharCode(descBytes[i]); - } - - // Look for native SegWit receive descriptor: wpkh(...84h/1h/0h/0/*) - if (descStr.startsWith('wpkh(xpub') && descStr.includes('/0/*)')) { - // Extract xpub - const xpubMatch = descStr.match(/xpub[1-9A-HJ-NP-Za-km-z]{100,}/); - if (xpubMatch) { - xpubString = xpubMatch[0]; - - // Extract descriptor ID (32 bytes after "walletdescriptor" prefix) - const descIdStart = descriptorIndex + descriptorPattern.length; - descriptorId = data.slice(descIdStart, descIdStart + 32); - break; - } - } - - descriptorIndex++; - } - - if (!descriptorId || !xpubString) { - throw new Error('Could not find native SegWit receive descriptor'); - } - - // Step 3: Extract chain code from xpub - const xpubDecoded = base58Decode(xpubString); - const chainCode = bytesToHex(xpubDecoded.slice(13, 45)); - - // Step 4: Find and decrypt the BIP32 master private key - const ckeyPattern = new TextEncoder().encode('walletdescriptorckey'); - let ckeyIndex = findPattern(data, ckeyPattern, 0); - let bip32MasterKey: string | null = null; - - while (ckeyIndex !== -1 && !bip32MasterKey) { - // Check if this record matches our descriptor ID - const recordDescId = data.slice(ckeyIndex + ckeyPattern.length, ckeyIndex + ckeyPattern.length + 32); - - if (Array.from(recordDescId).every((b, i) => b === descriptorId![i])) { - // Found the matching record - extract and decrypt the private key - let keyPos = ckeyIndex + ckeyPattern.length + 32; - const pubkeyLen = data[keyPos]; - keyPos++; - const pubkey = data.slice(keyPos, keyPos + pubkeyLen); - - // Find the value field (encrypted key) - search forward - for (let searchPos = keyPos + pubkeyLen; searchPos < Math.min(keyPos + pubkeyLen + 100, data.length - 50); searchPos++) { - // Look for a compact size followed by encrypted data (typically 48 bytes) - const valueLen = data[searchPos]; - if (valueLen >= 32 && valueLen <= 64) { - const encryptedPrivKey = data.slice(searchPos + 1, searchPos + 1 + valueLen); - - // Decrypt using master key with IV derived from pubkey hash (double SHA256) - const pubkeyWords = uint8ArrayToWordArray(pubkey); - const pubkeyHashWords = CryptoJS.SHA256(CryptoJS.SHA256(pubkeyWords)); - const ivWords = CryptoJS.lib.WordArray.create(pubkeyHashWords.words.slice(0, 4)); - const masterKeyWords = CryptoJS.enc.Hex.parse(masterKeyHex); - const encryptedWords = uint8ArrayToWordArray(encryptedPrivKey); - - const decrypted = CryptoJS.AES.decrypt( - { ciphertext: encryptedWords } as CryptoJS.lib.CipherParams, - masterKeyWords, - { iv: ivWords, padding: CryptoJS.pad.Pkcs7, mode: CryptoJS.mode.CBC } - ); - - bip32MasterKey = CryptoJS.enc.Hex.stringify(decrypted); - - if (bip32MasterKey.length === 64) { - console.log(`✓ BIP32 master key decrypted: ${bip32MasterKey.substring(0, 16)}...`); - break; - } - } - } - break; - } - - ckeyIndex = findPattern(data, ckeyPattern, ckeyIndex + 1); - } - - if (!bip32MasterKey || bip32MasterKey.length !== 64) { - throw new Error('Could not decrypt BIP32 master private key'); - } - - return { - masterKey: bip32MasterKey, - chainCode: chainCode, - descriptorPath: "84'/1'/0'" // BIP84 for Alpha network - }; - } catch (error) { - console.error('Error decrypting encrypted wallet.dat:', error); - return null; - } -} - -/** - * Base58 decode function for decoding extended keys - */ -function base58Decode(str: string): Uint8Array { - const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - const ALPHABET_MAP: Record = {}; - for (let i = 0; i < ALPHABET.length; i++) { - ALPHABET_MAP[ALPHABET[i]] = i; - } - - // Count leading zeros (represented as '1' in base58) - let zeros = 0; - for (let i = 0; i < str.length && str[i] === '1'; i++) { - zeros++; - } - - // Decode from base58 to number - let num = BigInt(0); - for (let i = 0; i < str.length; i++) { - const char = str[i]; - if (!(char in ALPHABET_MAP)) { - throw new Error('Invalid base58 character: ' + char); - } - num = num * BigInt(58) + BigInt(ALPHABET_MAP[char]); - } - - // Convert to bytes - const bytes: number[] = []; - while (num > 0) { - bytes.unshift(Number(num % BigInt(256))); - num = num / BigInt(256); - } - - // Add leading zeros - for (let i = 0; i < zeros; i++) { - bytes.unshift(0); - } - - return new Uint8Array(bytes); -} - -/** - * Restore wallet from wallet.dat (SQLite BIP32 format) - * Supports both encrypted and unencrypted wallet.dat files - * Exact port of index.html restoreFromWalletDat() logic with encryption support - */ -async function restoreFromWalletDat(file: File, password?: string): Promise { - try { - const data = await readBinaryFile(file); - - // Check SQLite header - const header = new TextDecoder().decode(data.slice(0, 16)); - if (!header.startsWith('SQLite format 3')) { - return { - success: false, - wallet: {} as Wallet, - error: 'Invalid wallet.dat file - not an SQLite database' - }; - } - - // Look for different wallet record types - const walletInfo: { - descriptorKeys: string[]; - hdChain: boolean | null; - legacyKeys: string[]; - isDescriptorWallet: boolean; - } = { - descriptorKeys: [], - hdChain: null, - legacyKeys: [], - isDescriptorWallet: false - }; - - // Pattern 1: Search for walletdescriptorkey records (modern descriptor wallets) - const descriptorKeyPattern = new TextEncoder().encode('walletdescriptorkey'); - - let index = 0; - while ((index = findPattern(data, descriptorKeyPattern, index)) !== -1) { - walletInfo.isDescriptorWallet = true; - - // Search for DER-encoded private key directly after walletdescriptorkey - for (let checkPos = index + descriptorKeyPattern.length; - checkPos < Math.min(index + descriptorKeyPattern.length + 200, data.length - 40); - checkPos++) { - - // Look for DER sequence markers - // Pattern: d30201010420 (the pattern that actually works) - if (data[checkPos] === 0xd3 && - data[checkPos + 1] === 0x02 && - data[checkPos + 2] === 0x01 && - data[checkPos + 3] === 0x01 && - data[checkPos + 4] === 0x04 && - data[checkPos + 5] === 0x20) { - - // Extract the 32-byte private key - const privKey = data.slice(checkPos + 6, checkPos + 38); - const privKeyHex = bytesToHex(privKey); - - if (isValidPrivateKey(privKeyHex)) { - walletInfo.descriptorKeys.push(privKeyHex); - break; - } - } - } - - index++; - } - - // Pattern 2: Search for hdchain records (legacy HD wallets) - const hdChainPattern = new TextEncoder().encode('hdchain'); - index = findPattern(data, hdChainPattern); - if (index !== -1) { - walletInfo.hdChain = true; - } - - // Check if wallet is encrypted before trying legacy extraction - const mkeyPattern = new TextEncoder().encode('mkey'); - const hasMkey = findPattern(data, mkeyPattern, 0) !== -1; - - if (hasMkey && walletInfo.descriptorKeys.length === 0) { - console.warn('Wallet appears to be encrypted - legacy key extraction may fail'); - } - - // Pattern 3: Search for regular key records (legacy format) - const keyPattern = new TextEncoder().encode('key'); - index = 0; - while ((index = findPattern(data, keyPattern, index)) !== -1) { - // Extract private key using simple pattern search - const searchPattern = new Uint8Array([0x04, 0x20]); // DER encoding for 32-byte octet string - for (let i = index; i < Math.min(index + 200, data.length - 34); i++) { - if (data[i] === searchPattern[0] && data[i + 1] === searchPattern[1]) { - const privKey = data.slice(i + 2, i + 34); - const privKeyHex = bytesToHex(privKey); - - if (isValidPrivateKey(privKeyHex)) { - walletInfo.legacyKeys.push(privKeyHex); - break; - } - } - } - index++; - } - - // Look for wpkh descriptor to extract derivation path - let descriptorPath: string | null = null; - const wpkhPattern = new TextEncoder().encode('wpkh(['); - const wpkhIndex = findPattern(data, wpkhPattern, 0); - if (wpkhIndex !== -1) { - // Read the descriptor (up to 200 bytes should be enough) - const descriptorArea = data.slice(wpkhIndex, Math.min(wpkhIndex + 200, data.length)); - let descriptorStr = ''; - - // Convert to string until we hit a non-printable character or closing parenthesis - for (let i = 0; i < descriptorArea.length; i++) { - const byte = descriptorArea[i]; - if (byte >= 32 && byte <= 126) { // Printable ASCII - descriptorStr += String.fromCharCode(byte); - if (descriptorStr.includes('*))')) break; // End of descriptor - } - } - - console.log('Found descriptor:', descriptorStr); - - // Parse the descriptor path - // Format: wpkh([fingerprint/84'/0'/0']xpub.../0/*) - const pathMatch = descriptorStr.match(/\[[\da-f]+\/(\d+'\/\d+'\/\d+')\]/); - if (pathMatch) { - descriptorPath = pathMatch[1]; - console.log('Extracted descriptor path:', descriptorPath); - } - } - - // Extract chain code from xpub for BIP32 wallets - let masterChainCode: string | null = null; - const xpubPattern = new TextEncoder().encode('xpub'); - const base58Chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - let searchPos = 0; - let foundMasterChainCode = false; - - while (!foundMasterChainCode && searchPos < data.length) { - const xpubIndex = findPattern(data, xpubPattern, searchPos); - if (xpubIndex === -1) break; - - // Extract the full xpub - let xpubStr = 'xpub'; - let pos = xpubIndex + 4; - - while (pos < data.length && xpubStr.length < 120) { - const char = String.fromCharCode(data[pos]); - if (base58Chars.includes(char)) { - xpubStr += char; - pos++; - } else { - break; - } - } - - if (xpubStr.length > 100) { - try { - // Decode the xpub to check depth and extract chain code - const decoded = base58Decode(xpubStr); - const depth = decoded[4]; - - // We want the master key at depth 0 - if (depth === 0) { - // Chain code is at bytes 13-45 (32 bytes) - const chainCodeBytes = decoded.slice(13, 45); - masterChainCode = bytesToHex(chainCodeBytes); - console.log('Extracted master chain code from depth 0 xpub:', masterChainCode); - foundMasterChainCode = true; - } - } catch (e) { - console.error('Failed to decode xpub:', e); - } - } - - searchPos = xpubIndex + 4; - } - - if (!masterChainCode) { - console.warn('Could not extract chain code from wallet.dat - BIP32 derivation will not work correctly'); - } - - // Determine what we found - let masterKey: string | null = null; - let importType = ''; - - if (walletInfo.isDescriptorWallet && walletInfo.descriptorKeys.length > 0) { - // Modern descriptor wallet - masterKey = walletInfo.descriptorKeys[0]; // Use first key found - importType = 'descriptor wallet'; - console.log(`Found ${walletInfo.descriptorKeys.length} key(s) in descriptor wallet`); - } else if (walletInfo.legacyKeys.length > 0) { - // Legacy wallet with individual keys - masterKey = walletInfo.legacyKeys[0]; // Use first key found - importType = walletInfo.hdChain ? 'HD wallet' : 'legacy wallet'; - console.log(`Found ${walletInfo.legacyKeys.length} key(s) in ${importType}`); - } else { - // Check if this is an encrypted wallet - if (hasMkey) { - // Wallet is encrypted - try to decrypt if password is provided - if (!password) { - return { - success: false, - wallet: {} as Wallet, - error: 'This wallet.dat file is encrypted. Please provide a password to decrypt it.', - isEncryptedDat: true - }; - } - - // Try to decrypt the encrypted wallet (async to prevent UI freeze) - const decryptedData = await decryptEncryptedWalletDat(data, password); - if (!decryptedData) { - return { - success: false, - wallet: {} as Wallet, - error: 'Failed to decrypt wallet.dat. The password may be incorrect.', - isEncryptedDat: true - }; - } - - // Successfully decrypted - create wallet with decrypted data - const wallet: Wallet = { - masterPrivateKey: decryptedData.masterKey, - addresses: [], - isEncrypted: false, - encryptedMasterKey: '', - childPrivateKey: null, - isImportedAlphaWallet: true, - masterChainCode: decryptedData.chainCode, - chainCode: decryptedData.chainCode, - descriptorPath: decryptedData.descriptorPath, - }; - - return { - success: true, - wallet, - message: 'Encrypted wallet.dat decrypted and imported successfully!' - }; - } else { - return { - success: false, - wallet: {} as Wallet, - error: 'No valid private keys found in wallet.dat file. The wallet might use an unsupported format.' - }; - } - } - - // Create wallet with the extracted key - const wallet: Wallet = { - masterPrivateKey: masterKey, - addresses: [], - isEncrypted: false, - encryptedMasterKey: '', - childPrivateKey: null, - isImportedAlphaWallet: true, // Mark as imported from Alpha wallet.dat - masterChainCode: masterChainCode, - chainCode: masterChainCode || undefined, - descriptorPath: descriptorPath || "84'/1'/0'", // Default to BIP84 for Alpha network if not found - }; - - return { - success: true, - wallet, - message: `Wallet imported successfully from Alpha ${importType}! Note: The first address generated may differ from your original wallet's addresses due to derivation path differences.` - }; - - } catch (e) { - console.error('Error importing wallet.dat:', e); - return { - success: false, - wallet: {} as Wallet, - error: 'Error importing wallet.dat: ' + (e instanceof Error ? e.message : String(e)) - }; - } -} - -/** - * Import wallet from backup file - * Exact copy of index.html restoreWallet() logic - */ -export async function importWallet( - file: File, - password?: string -): Promise { - try { - // Check for wallet.dat - use binary parser - if (file.name.endsWith(".dat")) { - return restoreFromWalletDat(file, password); - } - - const fileContent = await file.text(); - - let masterKey = ""; - let isEncrypted = false; - let encryptedMasterKey = ""; - - // Check if encrypted wallet - if (fileContent.includes("ENCRYPTED MASTER KEY")) { - isEncrypted = true; - console.log("Loading encrypted wallet..."); - - // Extract encrypted master key - exact regex from index.html - const encryptedKeyMatch = fileContent.match( - /ENCRYPTED MASTER KEY \(password protected\):\s*([^\n]+)/ - ); - - if (encryptedKeyMatch && encryptedKeyMatch[1]) { - encryptedMasterKey = encryptedKeyMatch[1].trim(); - console.log("Found encrypted master key"); - - if (!password) { - return { - success: false, - wallet: {} as Wallet, - error: "This is an encrypted wallet. Please enter the decryption password.", - }; - } - - // Decrypt - exact method from index.html - // Use explicit parameters for cross-version CryptoJS compatibility - try { - console.log("Attempting to decrypt with provided password..."); - const salt = "alpha_wallet_salt"; - const passwordKey = CryptoJS.PBKDF2(password, salt, { - keySize: 256 / 32, - iterations: 100000, - hasher: CryptoJS.algo.SHA1, // Explicitly specify SHA1 hasher for compatibility - }).toString(); - - const decryptedBytes = CryptoJS.AES.decrypt(encryptedMasterKey, passwordKey); - masterKey = decryptedBytes.toString(CryptoJS.enc.Utf8); - - if (!masterKey) { - return { - success: false, - wallet: {} as Wallet, - error: "Failed to decrypt the wallet. The password may be incorrect.", - }; - } - console.log("Successfully decrypted master key:", masterKey.substring(0, 8) + "..."); - } catch (e) { - return { - success: false, - wallet: {} as Wallet, - error: "Error decrypting wallet: " + (e instanceof Error ? e.message : String(e)), - }; - } - } else { - return { - success: false, - wallet: {} as Wallet, - error: "Could not find the encrypted master key in the backup file.", - }; - } - } else { - // Unencrypted - exact regex from index.html - const masterKeyMatch = fileContent.match( - /MASTER PRIVATE KEY \(keep secret!\):\s*([^\n]+)/ - ); - if (masterKeyMatch && masterKeyMatch[1]) { - masterKey = masterKeyMatch[1].trim(); - } else { - return { - success: false, - wallet: {} as Wallet, - error: "Could not find the master private key in the backup file.", - }; - } - } - - // Check for chain code - exact regex from index.html - let masterChainCode: string | null = null; - let isImportedAlphaWallet = false; - - const chainCodeMatch = fileContent.match( - /MASTER CHAIN CODE \(for (?:BIP32 HD|Alpha) wallet compatibility\):\s*([^\n]+)/ - ); - if (chainCodeMatch && chainCodeMatch[1]) { - masterChainCode = chainCodeMatch[1].trim(); - isImportedAlphaWallet = true; - } - - // Check wallet type explicitly - if ( - fileContent.includes("WALLET TYPE: BIP32 hierarchical deterministic wallet") || - fileContent.includes("WALLET TYPE: Alpha descriptor wallet") - ) { - isImportedAlphaWallet = true; - } - - // Parse descriptor path for BIP32 wallets - let descriptorPath: string | null = null; - const descriptorPathMatch = fileContent.match(/DESCRIPTOR PATH:\s*([^\n]+)/); - if (descriptorPathMatch && descriptorPathMatch[1]) { - descriptorPath = descriptorPathMatch[1].trim(); - } - - // Parse addresses - exact regex from index.html - const parsedAddresses: WalletAddress[] = []; - const addressSection = fileContent.match( - /YOUR ADDRESSES:\s*\n([\s\S]*?)(?:\n\nGenerated on:|$)/ - ); - - if (addressSection && addressSection[1]) { - const addressLines = addressSection[1].trim().split("\n"); - for (const line of addressLines) { - // Exact regex from index.html - const addressMatch = line.match( - /Address\s+(\d+):\s+(\w+)\s*(?:\(Path:\s*([^)]*)\))?/ - ); - if (addressMatch) { - const index = parseInt(addressMatch[1]) - 1; - const address = addressMatch[2]; - const path = addressMatch[3] === "undefined" ? null : addressMatch[3] || null; - parsedAddresses.push({ - index, - address, - path, - createdAt: new Date().toISOString(), - }); - } - } - } - - // Create wallet - exact structure from index.html - const wallet: Wallet = { - masterPrivateKey: masterKey, - addresses: parsedAddresses, - isEncrypted: isEncrypted, - encryptedMasterKey: encryptedMasterKey, - childPrivateKey: null, - isImportedAlphaWallet: isImportedAlphaWallet, - masterChainCode: masterChainCode, - chainCode: masterChainCode || undefined, - descriptorPath: descriptorPath || (isImportedAlphaWallet ? "84'/1'/0'" : null), - }; - - // For standard wallets, recover private keys for all addresses - if (!isImportedAlphaWallet && parsedAddresses.length > 0) { - const hmacInput = CryptoJS.enc.Hex.parse(wallet.masterPrivateKey); - const witnessVersion = 0; - - // Recover private key for each address - for (let addrIdx = 0; addrIdx < wallet.addresses.length; addrIdx++) { - const addr = wallet.addresses[addrIdx]; - let recovered = false; - - // Try to find the correct derivation index for this address - for (let i = 0; i < 100; i++) { - const testPath = `m/44'/0'/${i}'`; - const testHmac = CryptoJS.HmacSHA512( - hmacInput, - CryptoJS.enc.Utf8.parse(testPath) - ).toString(); - const testChildKey = testHmac.substring(0, 64); - const testKeyPair = ec.keyFromPrivate(testChildKey); - const testPublicKey = testKeyPair.getPublic(true, "hex"); - const testAddress = publicKeyToAddress(testPublicKey, "alpha", witnessVersion); - - if (testAddress === addr.address) { - console.log(`✓ Found correct derivation for address ${addrIdx + 1} at index ${i}!`); - addr.privateKey = testChildKey; - addr.publicKey = testPublicKey; - addr.path = testPath; - addr.index = i; - recovered = true; - - // Set childPrivateKey for first address (for backward compatibility) - if (addrIdx === 0) { - wallet.childPrivateKey = testChildKey; - } - break; - } - } - - if (!recovered) { - // CRITICAL: Address verification failed - abort import for security - // This indicates the wallet file may be corrupted or from a different wallet - console.error('WALLET INTEGRITY CHECK FAILED'); - console.error('Address from file:', addr.address); - console.error('Recovery scan (0-99) failed to find matching key'); - return { - success: false, - wallet: {} as Wallet, - error: `Wallet integrity check failed: Address ${addr.address} does not match any key derived from the master private key. This wallet file may be corrupted or from a different wallet.`, - }; - } - } - } - - // For BIP32 wallets (Alpha wallet), recover private keys using path info - if (isImportedAlphaWallet && masterChainCode && parsedAddresses.length > 0) { - const witnessVersion = 0; - - for (let addrIdx = 0; addrIdx < wallet.addresses.length; addrIdx++) { - const addr = wallet.addresses[addrIdx]; - - // If address has path info, derive the key directly - if (addr.path && addr.path.startsWith("m/")) { - try { - const derived = deriveKeyAtPath(masterKey, masterChainCode, addr.path); - const keyPair = ec.keyFromPrivate(derived.privateKey); - const publicKey = keyPair.getPublic(true, "hex"); - - // Verify the derived address matches - const derivedAddress = publicKeyToAddress(publicKey, "alpha", witnessVersion); - - if (derivedAddress === addr.address) { - console.log(`✓ BIP32: Recovered key for address ${addrIdx + 1} at path ${addr.path}`); - addr.privateKey = derived.privateKey; - addr.publicKey = publicKey; - - // Check if this is a change address (chain 1) - const pathParts = addr.path.split("/"); - if (pathParts.length >= 5) { - const chain = parseInt(pathParts[pathParts.length - 2], 10); - addr.isChange = chain === 1; - } - } else { - console.error(`BIP32: Address mismatch at path ${addr.path}`); - console.error(` Expected: ${addr.address}`); - console.error(` Derived: ${derivedAddress}`); - return { - success: false, - wallet: {} as Wallet, - error: `Wallet integrity check failed: Address ${addr.address} does not match derived address at path ${addr.path}`, - }; - } - } catch (e) { - console.error(`Error deriving key at path ${addr.path}:`, e); - return { - success: false, - wallet: {} as Wallet, - error: `Failed to derive key at path ${addr.path}: ${e instanceof Error ? e.message : String(e)}`, - }; - } - } else { - // No path info - need to scan to find the correct derivation - console.warn(`BIP32: Address ${addrIdx + 1} has no path info, scanning...`); - const basePath = descriptorPath || "84'/1'/0'"; - let recovered = false; - - // Scan both chains (0=external, 1=change) and first 100 indices - for (const chain of [0, 1]) { - if (recovered) break; - for (let i = 0; i < 100; i++) { - const testPath = `m/${basePath}/${chain}/${i}`; - try { - const derived = deriveKeyAtPath(masterKey, masterChainCode, testPath); - const keyPair = ec.keyFromPrivate(derived.privateKey); - const publicKey = keyPair.getPublic(true, "hex"); - const testAddress = publicKeyToAddress(publicKey, "alpha", witnessVersion); - - if (testAddress === addr.address) { - console.log(`✓ BIP32: Found address ${addrIdx + 1} at ${testPath}`); - addr.privateKey = derived.privateKey; - addr.publicKey = publicKey; - addr.path = testPath; - addr.index = i; - addr.isChange = chain === 1; - recovered = true; - break; - } - } catch { - // Continue on derivation errors - } - } - } - - if (!recovered) { - console.error(`BIP32: Could not find derivation for address ${addr.address}`); - return { - success: false, - wallet: {} as Wallet, - error: `Could not find BIP32 derivation path for address ${addr.address}`, - }; - } - } - } - } - - return { - success: true, - wallet, - message: "Wallet restored successfully!", - }; - } catch (e) { - console.error("Error restoring wallet:", e); - return { - success: false, - wallet: {} as Wallet, - error: e instanceof Error ? e.message : String(e), - }; - } -} - -/** - * Export wallet to text format - * Exact copy of index.html saveWallet() logic - */ -export function exportWallet(wallet: Wallet, options: ExportOptions = {}): string { - const { password } = options; - - if (!wallet || !wallet.masterPrivateKey) { - throw new Error("Invalid wallet - missing master private key"); - } - - let content: string; - - if (password) { - // Encrypted wallet - exact method from index.html - // Use explicit parameters for cross-version CryptoJS compatibility - const salt = "alpha_wallet_salt"; - const passwordKey = CryptoJS.PBKDF2(password, salt, { - keySize: 256 / 32, - iterations: 100000, - hasher: CryptoJS.algo.SHA1, // Explicitly specify SHA1 hasher for compatibility - }).toString(); - - const encryptedMasterKey = CryptoJS.AES.encrypt( - wallet.masterPrivateKey, - passwordKey - ).toString(); - - // Get addresses text - let addressesText: string; - if (wallet.isImportedAlphaWallet) { - addressesText = wallet.addresses - .map((addr, index) => { - const path = addr.path || `m/84'/1'/0'/${addr.isChange ? 1 : 0}/${addr.index}`; - return `Address ${index + 1}: ${addr.address} (Path: ${path})`; - }) - .join("\n"); - } else { - addressesText = wallet.addresses - .map((a) => `Address ${a.index + 1}: ${a.address} (Path: ${a.path})`) - .join("\n"); - } - - // Build encrypted content - let encryptedContent = `ENCRYPTED MASTER KEY (password protected): -${encryptedMasterKey}`; - - if (wallet.isImportedAlphaWallet && wallet.masterChainCode) { - // Match webwallet format exactly - no DESCRIPTOR PATH in encrypted format - encryptedContent += ` - -MASTER CHAIN CODE (for BIP32 HD wallet compatibility): -${wallet.masterChainCode} - -WALLET TYPE: BIP32 hierarchical deterministic wallet`; - } else { - encryptedContent += ` - -WALLET TYPE: Standard wallet (HMAC-based)`; - } - - content = `UNICITY WALLET DETAILS -=========================== - -${encryptedContent} - -ENCRYPTION STATUS: Encrypted with password -To use this key, you will need the password you set in the wallet. - -YOUR ADDRESSES: -${addressesText} - -Generated on: ${new Date().toLocaleString()} - -WARNING: Keep your master private key safe and secure. -Anyone with your master private key can access all your funds.`; - } else { - // Unencrypted wallet - exact method from index.html - const masterKeyWIF = hexToWIF(wallet.masterPrivateKey); - - let masterKeySection: string; - let addressesText: string; - - if (wallet.isImportedAlphaWallet && wallet.masterChainCode) { - masterKeySection = `MASTER PRIVATE KEY (keep secret!): -${wallet.masterPrivateKey} - -MASTER PRIVATE KEY IN WIF FORMAT (for importprivkey command): -${masterKeyWIF} - -MASTER CHAIN CODE (for BIP32 HD wallet compatibility): -${wallet.masterChainCode} - -DESCRIPTOR PATH: ${wallet.descriptorPath || "84'/1'/0'"} - -WALLET TYPE: BIP32 hierarchical deterministic wallet - -ENCRYPTION STATUS: Not encrypted -This key is in plaintext and not protected. Anyone with this file can access your wallet.`; - - addressesText = wallet.addresses - .map((addr, index) => { - const path = addr.path || `m/84'/1'/0'/${addr.isChange ? 1 : 0}/${addr.index}`; - return `Address ${index + 1}: ${addr.address} (Path: ${path})`; - }) - .join("\n"); - } else { - addressesText = wallet.addresses - .map((a) => `Address ${a.index + 1}: ${a.address} (Path: ${a.path})`) - .join("\n"); - - masterKeySection = `MASTER PRIVATE KEY (keep secret!): -${wallet.masterPrivateKey} - -MASTER PRIVATE KEY IN WIF FORMAT (for importprivkey command): -${masterKeyWIF} - -WALLET TYPE: Standard wallet (HMAC-based) - -ENCRYPTION STATUS: Not encrypted -This key is in plaintext and not protected. Anyone with this file can access your wallet.`; - } - - content = `UNICITY WALLET DETAILS -=========================== - -${masterKeySection} - -YOUR ADDRESSES: -${addressesText} - -Generated on: ${new Date().toLocaleString()} - -WARNING: Keep your master private key safe and secure. -Anyone with your master private key can access all your funds.`; - } - - return content; -} - -/** - * Download wallet file - */ -export function downloadWalletFile( - content: string, - filename: string = "alpha_wallet_backup.txt" -): void { - const blob = new Blob([content], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.href = url; - const finalFilename = filename.endsWith(".txt") ? filename : filename + ".txt"; - a.download = finalFilename; - document.body.appendChild(a); - a.click(); - - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 100); -} - -// ========================================== -// JSON Export/Import Functions (v1.0) -// ========================================== - -const JSON_WALLET_VERSION = "1.0" as const; -const JSON_WALLET_WARNING = "Keep this file secure! Anyone with this data can access your funds."; -const PBKDF2_ITERATIONS = 100000; -const PBKDF2_SALT_PREFIX = "unicity_wallet_json_"; - -/** - * Generate a random salt for encryption - */ -function generateSalt(): string { - const randomBytes = new Uint8Array(16); - crypto.getRandomValues(randomBytes); - return PBKDF2_SALT_PREFIX + bytesToHex(randomBytes); -} - -/** - * Derive encryption key from password using PBKDF2 - */ -function deriveEncryptionKey(password: string, salt: string): string { - return CryptoJS.PBKDF2(password, salt, { - keySize: 256 / 32, - iterations: PBKDF2_ITERATIONS, - hasher: CryptoJS.algo.SHA256, - }).toString(); -} - -/** - * Encrypt sensitive data with password - */ -function encryptWithPassword(data: string, password: string, salt: string): string { - const key = deriveEncryptionKey(password, salt); - return CryptoJS.AES.encrypt(data, key).toString(); -} - -/** - * Decrypt data with password - */ -function decryptWithPassword(encrypted: string, password: string, salt: string): string | null { - try { - const key = deriveEncryptionKey(password, salt); - const decrypted = CryptoJS.AES.decrypt(encrypted, key); - const result = decrypted.toString(CryptoJS.enc.Utf8); - return result || null; - } catch { - return null; - } -} - -/** - * Determine derivation mode from wallet - * IMPORTANT: chainCode is the definitive indicator for BIP32 mode. - * Without chainCode, BIP32 derivation is impossible regardless of flags. - */ -function determineDerivationMode(wallet: Wallet): WalletJSONDerivationMode { - // ChainCode is REQUIRED for BIP32 derivation - check this first - if (wallet.chainCode || wallet.masterChainCode) { - return "bip32"; - } - // Without chainCode, can only use WIF HMAC mode (even if isBIP32 flag is set) - return "wif_hmac"; -} - -/** - * Determine source type from wallet - */ -function determineSource( - wallet: Wallet, - mnemonic?: string, - importSource?: "dat" | "file" -): WalletJSONSource { - // If mnemonic is provided, it's from mnemonic - if (mnemonic) { - return "mnemonic"; - } - - // If imported from dat file - if (importSource === "dat") { - if (wallet.descriptorPath) { - return "dat_descriptor"; - } - if (wallet.isBIP32 || wallet.chainCode || wallet.masterChainCode) { - return "dat_hd"; - } - return "dat_legacy"; - } - - // Imported from txt file - if (wallet.chainCode || wallet.masterChainCode) { - return "file_bip32"; - } - return "file_standard"; -} - -/** - * Generate address from master key for JSON export - */ -function generateAddressForExport( - masterKey: string, - chainCode: string | null | undefined, - derivationMode: WalletJSONDerivationMode, - index: number, - descriptorPath?: string | null -): WalletJSONAddress { - const witnessVersion = 0; - - if (derivationMode === "bip32" && chainCode) { - // BIP32 derivation - const basePath = descriptorPath || "44'/0'/0'"; - const fullPath = `m/${basePath}/0/${index}`; - const derived = deriveKeyAtPath(masterKey, chainCode, fullPath); - const keyPair = ec.keyFromPrivate(derived.privateKey); - const publicKey = keyPair.getPublic(true, "hex"); - const address = publicKeyToAddress(publicKey, "alpha", witnessVersion); - - return { - address, - publicKey, - path: fullPath, - index, - }; - } else { - // WIF HMAC derivation - const derivationPath = `m/44'/0'/${index}'`; - const hmacInput = CryptoJS.enc.Hex.parse(masterKey); - const hmac = CryptoJS.HmacSHA512(hmacInput, CryptoJS.enc.Utf8.parse(derivationPath)).toString(); - const childKey = hmac.substring(0, 64); - const keyPair = ec.keyFromPrivate(childKey); - const publicKey = keyPair.getPublic(true, "hex"); - const address = publicKeyToAddress(publicKey, "alpha", witnessVersion); - - return { - address, - publicKey, - path: derivationPath, - index, - }; - } -} - -export interface ExportToJSONParams { - /** The wallet to export */ - wallet: Wallet; - /** BIP39 mnemonic phrase (if available) */ - mnemonic?: string; - /** Source of import: "dat" for wallet.dat, "file" for txt file */ - importSource?: "dat" | "file"; - /** Export options */ - options?: WalletJSONExportOptions; -} - -/** - * Export wallet to JSON format - * - * Supports all wallet types: - * - Mnemonic-based (new BIP32 standard) - * - File import with chain code (BIP32) - * - File import without chain code (HMAC) - * - wallet.dat import (descriptor/HD/legacy) - */ -export function exportWalletToJSON(params: ExportToJSONParams): WalletJSON { - const { wallet, mnemonic, importSource, options = {} } = params; - const { password, includeAllAddresses = false, addressCount = 1 } = options; - - if (!wallet || !wallet.masterPrivateKey) { - throw new Error("Invalid wallet - missing master private key"); - } - - const chainCode = wallet.chainCode || wallet.masterChainCode || undefined; - const derivationMode = determineDerivationMode(wallet); - const source = determineSource(wallet, mnemonic, importSource); - - // Generate first address for verification - const firstAddress = generateAddressForExport( - wallet.masterPrivateKey, - chainCode, - derivationMode, - 0, - wallet.descriptorPath - ); - - // Build base JSON structure - const json: WalletJSON = { - version: JSON_WALLET_VERSION, - generated: new Date().toISOString(), - warning: JSON_WALLET_WARNING, - masterPrivateKey: wallet.masterPrivateKey, - derivationMode, - source, - firstAddress, - }; - - // Add chain code if available - if (chainCode) { - json.chainCode = chainCode; - } - - // Add mnemonic if available (and not encrypted) - if (mnemonic && !password) { - json.mnemonic = mnemonic; - } - - // Add descriptor path for BIP32 wallets - if (wallet.descriptorPath) { - json.descriptorPath = wallet.descriptorPath; - } - - // Handle encryption - if (password) { - const salt = generateSalt(); - json.encrypted = { - masterPrivateKey: encryptWithPassword(wallet.masterPrivateKey, password, salt), - salt, - iterations: PBKDF2_ITERATIONS, - }; - - if (mnemonic) { - json.encrypted.mnemonic = encryptWithPassword(mnemonic, password, salt); - } - - // Remove plaintext sensitive data when encrypted - delete (json as Partial).masterPrivateKey; - delete (json as Partial).mnemonic; - } - - // Add additional addresses if requested - if (includeAllAddresses && wallet.addresses.length > 0) { - json.addresses = wallet.addresses.map((addr, idx) => ({ - address: addr.address, - publicKey: addr.publicKey || "", - path: addr.path || `m/44'/0'/${idx}'`, - index: addr.index, - isChange: addr.isChange, - })); - } else if (addressCount > 1) { - const additionalAddresses: WalletJSONAddress[] = []; - for (let i = 1; i < addressCount; i++) { - additionalAddresses.push( - generateAddressForExport( - wallet.masterPrivateKey, - chainCode, - derivationMode, - i, - wallet.descriptorPath - ) - ); - } - if (additionalAddresses.length > 0) { - json.addresses = additionalAddresses; - } - } - - return json; -} - -/** - * Import wallet from JSON format - * - * Supports: - * - New JSON format (v1.0) - * - Encrypted JSON files - * - All source types (mnemonic, file_bip32, file_standard, dat_*) - */ -export async function importWalletFromJSON( - jsonContent: string, - password?: string -): Promise { - try { - const json = JSON.parse(jsonContent) as WalletJSON; - - // Validate version - if (json.version !== "1.0") { - return { - success: false, - error: `Unsupported wallet JSON version: ${json.version}. Expected 1.0`, - }; - } - - let masterPrivateKey: string; - let mnemonic: string | undefined; - - // Handle encrypted wallet - if (json.encrypted) { - if (!password) { - return { - success: false, - error: "This wallet is encrypted. Please provide a password.", - }; - } - - const decryptedKey = decryptWithPassword( - json.encrypted.masterPrivateKey, - password, - json.encrypted.salt - ); - - if (!decryptedKey) { - return { - success: false, - error: "Failed to decrypt wallet. The password may be incorrect.", - }; - } - - masterPrivateKey = decryptedKey; - - // Decrypt mnemonic if present - if (json.encrypted.mnemonic) { - const decryptedMnemonic = decryptWithPassword( - json.encrypted.mnemonic, - password, - json.encrypted.salt - ); - if (decryptedMnemonic) { - mnemonic = decryptedMnemonic; - } - } - } else { - // Unencrypted wallet - if (!json.masterPrivateKey) { - return { - success: false, - error: "Invalid wallet JSON - missing master private key", - }; - } - masterPrivateKey = json.masterPrivateKey; - mnemonic = json.mnemonic; - } - - // Validate private key - if (!isValidPrivateKey(masterPrivateKey)) { - return { - success: false, - error: "Invalid master private key in wallet JSON", - }; - } - - // Verify first address matches - const verifyAddress = generateAddressForExport( - masterPrivateKey, - json.chainCode, - json.derivationMode, - 0, - json.descriptorPath - ); - - if (verifyAddress.address !== json.firstAddress.address) { - return { - success: false, - error: `Wallet verification failed: derived address (${verifyAddress.address}) does not match expected (${json.firstAddress.address})`, - }; - } - - // Determine wallet properties based on source - const isBIP32 = json.derivationMode === "bip32"; - const isImportedAlphaWallet = json.source.startsWith("dat_") || json.source === "file_bip32"; - - // Build wallet object - const wallet: Wallet = { - masterPrivateKey, - addresses: [], - isEncrypted: false, - childPrivateKey: null, - isBIP32, - isImportedAlphaWallet, - }; - - if (json.chainCode) { - wallet.chainCode = json.chainCode; - wallet.masterChainCode = json.chainCode; - } - - if (json.descriptorPath) { - wallet.descriptorPath = json.descriptorPath; - } - - // Add addresses - wallet.addresses.push({ - address: json.firstAddress.address, - publicKey: json.firstAddress.publicKey, - path: json.firstAddress.path, - index: json.firstAddress.index ?? 0, - isChange: json.firstAddress.isChange, - createdAt: new Date().toISOString(), - }); - - if (json.addresses) { - for (const addr of json.addresses) { - wallet.addresses.push({ - address: addr.address, - publicKey: addr.publicKey, - path: addr.path, - index: addr.index ?? wallet.addresses.length, - isChange: addr.isChange, - createdAt: new Date().toISOString(), - }); - } - } - - return { - success: true, - wallet, - source: json.source, - derivationMode: json.derivationMode, - hasMnemonic: !!mnemonic, - mnemonic, // Return decrypted mnemonic if available - message: `Wallet imported successfully from JSON (source: ${json.source}, mode: ${json.derivationMode})`, - }; - } catch (e) { - if (e instanceof SyntaxError) { - return { - success: false, - error: "Invalid JSON format. Please provide a valid wallet JSON file.", - }; - } - return { - success: false, - error: `Error importing wallet: ${e instanceof Error ? e.message : String(e)}`, - }; - } -} - -/** - * Download wallet as JSON file - */ -export function downloadWalletJSON( - json: WalletJSON, - filename: string = "alpha_wallet_backup.json" -): void { - const content = JSON.stringify(json, null, 2); - const blob = new Blob([content], { type: "application/json" }); - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.href = url; - const finalFilename = filename.endsWith(".json") ? filename : filename + ".json"; - a.download = finalFilename; - document.body.appendChild(a); - a.click(); - - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 100); -} - -/** - * Check if file content is JSON wallet format - */ -export function isJSONWalletFormat(content: string): boolean { - try { - const json = JSON.parse(content); - return json.version === "1.0" && (json.masterPrivateKey || json.encrypted); - } catch { - return false; - } -} - -/** - * Universal wallet import function - * Automatically detects format (JSON, txt, dat) and imports accordingly - */ -export async function importWalletUniversal( - file: File, - password?: string -): Promise { - try { - // Check file extension - const filename = file.name.toLowerCase(); - - // Handle wallet.dat files - if (filename.endsWith(".dat")) { - const result = await importWallet(file, password); - if (result.success) { - return { - success: true, - wallet: result.wallet, - source: result.wallet.descriptorPath ? "dat_descriptor" : "dat_hd", - derivationMode: "bip32", - hasMnemonic: false, - message: result.message, - }; - } - return { - success: false, - error: result.error, - }; - } - - // Read file content - const content = await file.text(); - - // Try JSON format first - if (filename.endsWith(".json") || isJSONWalletFormat(content)) { - return importWalletFromJSON(content, password); - } - - // Fall back to txt format - const result = await importWallet(file, password); - if (result.success) { - const hasChainCode = !!(result.wallet.chainCode || result.wallet.masterChainCode); - return { - success: true, - wallet: result.wallet, - source: hasChainCode ? "file_bip32" : "file_standard", - derivationMode: hasChainCode ? "bip32" : "wif_hmac", - hasMnemonic: false, - message: result.message, - }; - } - return { - success: false, - error: result.error, - }; - } catch (e) { - return { - success: false, - error: `Error importing wallet: ${e instanceof Error ? e.message : String(e)}`, - }; - } -} diff --git a/src/components/wallet/L1/sdk/index.ts b/src/components/wallet/L1/sdk/index.ts deleted file mode 100644 index 4609fd7d..00000000 --- a/src/components/wallet/L1/sdk/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from './wallet' -export * from './address' -export * from './network' -export * from './storage' -export * from './types' -export * from './tx' -export * from './crypto' -export * from './import-export' -export * from './vesting' -export * from './vestingState' -export * from './scan' diff --git a/src/components/wallet/L1/sdk/network.ts b/src/components/wallet/L1/sdk/network.ts deleted file mode 100644 index 4217f2f3..00000000 --- a/src/components/wallet/L1/sdk/network.ts +++ /dev/null @@ -1,480 +0,0 @@ -// sdk/l1/network.ts - -import { addressToScriptHash } from "./addressToScriptHash"; -import type { UTXO } from "./types"; - -const DEFAULT_ENDPOINT = "wss://fulcrum.unicity.network:50004"; - -interface PendingRequest { - resolve: (result: unknown) => void; - reject: (err: unknown) => void; -} - -export interface BlockHeader { - height: number; - hex: string; - [key: string]: unknown; -} - -interface BalanceResult { - confirmed: number; - unconfirmed: number; -} - -let ws: WebSocket | null = null; -let isConnected = false; -let isConnecting = false; -let requestId = 0; -let intentionalClose = false; -let reconnectAttempts = 0; -let isBlockSubscribed = false; -let lastBlockHeader: BlockHeader | null = null; - -// Store timeout IDs for pending requests -interface PendingRequestWithTimeout extends PendingRequest { - timeoutId?: ReturnType; -} - -const pending: Record = {}; -const blockSubscribers: ((header: BlockHeader) => void)[] = []; - -// Connection state callbacks with cleanup support -interface ConnectionCallback { - resolve: () => void; - reject: (err: Error) => void; - timeoutId?: ReturnType; -} -const connectionCallbacks: ConnectionCallback[] = []; - -// Reconnect configuration -const MAX_RECONNECT_ATTEMPTS = 10; -const BASE_DELAY = 2000; -const MAX_DELAY = 60000; // 1 minute - -// Timeout configuration -const RPC_TIMEOUT = 30000; // 30 seconds -const CONNECTION_TIMEOUT = 30000; // 30 seconds - -// ---------------------------------------- -// HMR CLEANUP -// ---------------------------------------- -if (import.meta.hot) { - import.meta.hot.dispose(() => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close(); - } - ws = null; - isConnected = false; - isConnecting = false; - intentionalClose = false; - reconnectAttempts = 0; - isBlockSubscribed = false; - lastBlockHeader = null; - // Clear pending request timeouts - Object.values(pending).forEach(req => { - if (req.timeoutId) clearTimeout(req.timeoutId); - }); - Object.keys(pending).forEach(key => delete pending[Number(key)]); - // Clear connection callback timeouts - connectionCallbacks.forEach(cb => { - if (cb.timeoutId) clearTimeout(cb.timeoutId); - }); - blockSubscribers.length = 0; - connectionCallbacks.length = 0; - }); -} - -// ---------------------------------------- -// CONNECTION STATE -// ---------------------------------------- -export function isWebSocketConnected(): boolean { - return isConnected && ws !== null && ws.readyState === WebSocket.OPEN; -} - -export function waitForConnection(): Promise { - if (isWebSocketConnected()) { - return Promise.resolve(); - } - - return new Promise((resolve, reject) => { - const callback: ConnectionCallback = { - resolve: () => { - if (callback.timeoutId) clearTimeout(callback.timeoutId); - resolve(); - }, - reject: (err: Error) => { - if (callback.timeoutId) clearTimeout(callback.timeoutId); - reject(err); - }, - }; - - callback.timeoutId = setTimeout(() => { - // Remove from callbacks array - const idx = connectionCallbacks.indexOf(callback); - if (idx > -1) connectionCallbacks.splice(idx, 1); - reject(new Error("Connection timeout")); - }, CONNECTION_TIMEOUT); - - connectionCallbacks.push(callback); - }); -} - -// ---------------------------------------- -// SINGLETON CONNECT — prevents double connect -// ---------------------------------------- -export function connect(endpoint: string = DEFAULT_ENDPOINT): Promise { - if (isConnected) { - return Promise.resolve(); - } - - if (isConnecting) { - return waitForConnection(); - } - - isConnecting = true; - - return new Promise((resolve, reject) => { - let hasResolved = false; - - try { - ws = new WebSocket(endpoint); - } catch (err) { - console.error("[L1] WebSocket constructor threw exception:", err); - isConnecting = false; - reject(err); - return; - } - - ws.onopen = () => { - isConnected = true; - isConnecting = false; - reconnectAttempts = 0; // Reset reconnect counter on successful connection - hasResolved = true; - resolve(); - - // Notify all waiting callbacks (clear their timeouts first) - connectionCallbacks.forEach((cb) => { - if (cb.timeoutId) clearTimeout(cb.timeoutId); - cb.resolve(); - }); - connectionCallbacks.length = 0; - }; - - ws.onclose = () => { - isConnected = false; - isBlockSubscribed = false; // Reset block subscription on disconnect - - // Reject all pending requests and clear their timeouts - Object.values(pending).forEach(req => { - if (req.timeoutId) clearTimeout(req.timeoutId); - req.reject(new Error('WebSocket connection closed')); - }); - Object.keys(pending).forEach(key => delete pending[Number(key)]); - - // Don't reconnect if this was an intentional close - if (intentionalClose) { - intentionalClose = false; - isConnecting = false; - reconnectAttempts = 0; - - // Reject if we haven't resolved yet - if (!hasResolved) { - hasResolved = true; - reject(new Error("WebSocket connection closed intentionally")); - } - return; - } - - // Check if we've exceeded max reconnect attempts - if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - console.error('[L1] Max reconnect attempts reached. Giving up.'); - isConnecting = false; - - // Reject all waiting callbacks - const error = new Error("Max reconnect attempts reached"); - connectionCallbacks.forEach(cb => { - if (cb.timeoutId) clearTimeout(cb.timeoutId); - cb.reject(error); - }); - connectionCallbacks.length = 0; - - // Reject if we haven't resolved yet - if (!hasResolved) { - hasResolved = true; - reject(error); - } - return; - } - - // Calculate exponential backoff delay - const delay = Math.min( - BASE_DELAY * Math.pow(2, reconnectAttempts), - MAX_DELAY - ); - - reconnectAttempts++; - console.warn(`[L1] WebSocket closed unexpectedly. Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`); - - // Keep isConnecting true so callers know reconnection is in progress - // The resolve/reject will happen when reconnection succeeds or fails - setTimeout(() => { - connect(endpoint) - .then(() => { - if (!hasResolved) { - hasResolved = true; - resolve(); - } - }) - .catch((err) => { - if (!hasResolved) { - hasResolved = true; - reject(err); - } - }); - }, delay); - }; - - ws.onerror = (err: Event) => { - console.error("[L1] WebSocket error:", err); - console.error("[L1] WebSocket error - readyState:", ws?.readyState); - console.error("[L1] WebSocket error - url:", endpoint); - // Note: Browser WebSocket errors don't provide detailed error info for security reasons - // The actual connection error details are only visible in browser DevTools Network tab - // Error alone doesn't mean connection failed - onclose will be called - }; - - ws.onmessage = (msg) => handleMessage(msg); - }); -} - -function handleMessage(event: MessageEvent) { - const data = JSON.parse(event.data); - - if (data.id && pending[data.id]) { - const request = pending[data.id]; - delete pending[data.id]; - if (data.error) { - request.reject(data.error); - } else { - request.resolve(data.result); - } - } - - if (data.method === "blockchain.headers.subscribe") { - const header = data.params[0] as BlockHeader; - lastBlockHeader = header; // Cache for late subscribers - blockSubscribers.forEach((cb) => cb(header)); - } -} - -// ---------------------------------------- -// SAFE RPC - Auto-connects and waits if needed -// ---------------------------------------- -export async function rpc(method: string, params: unknown[] = []): Promise { - // Auto-connect if not connected - if (!isConnected && !isConnecting) { - await connect(); - } - - // Wait for connection if connecting - if (!isWebSocketConnected()) { - await waitForConnection(); - } - - return new Promise((resolve, reject) => { - if (!ws || ws.readyState !== WebSocket.OPEN) { - return reject(new Error("WebSocket not connected (OPEN)")); - } - - const id = ++requestId; - - // Set up timeout for this request - const timeoutId = setTimeout(() => { - if (pending[id]) { - delete pending[id]; - reject(new Error(`RPC timeout: ${method}`)); - } - }, RPC_TIMEOUT); - - pending[id] = { - resolve: (result) => { - clearTimeout(timeoutId); - resolve(result); - }, - reject: (err) => { - clearTimeout(timeoutId); - reject(err); - }, - timeoutId, - }; - - ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params })); - }); -} - -// ---------------------------------------- -// API METHODS -// ---------------------------------------- - -export async function getUtxo(address: string) { - const scripthash = addressToScriptHash(address); - - const result = await rpc("blockchain.scripthash.listunspent", [scripthash]); - - if (!Array.isArray(result)) { - console.warn("listunspent returned non-array:", result); - return []; - } - - return result.map((u: UTXO) => ({ - tx_hash: u.tx_hash, - tx_pos: u.tx_pos, - value: u.value, - height: u.height, - address, - })); -} - -export async function getBalance(address: string) { - const scriptHash = addressToScriptHash(address); - const result = await rpc("blockchain.scripthash.get_balance", [scriptHash]) as BalanceResult; - - const confirmed = result.confirmed || 0; - const unconfirmed = result.unconfirmed || 0; - - const totalSats = confirmed + unconfirmed; - - // Convert sats → ALPHA - const alpha = totalSats / 100_000_000; - - return alpha; -} - -export async function broadcast(rawHex: string) { - return await rpc("blockchain.transaction.broadcast", [rawHex]); -} - -export async function subscribeBlocks(cb: (header: BlockHeader) => void): Promise<() => void> { - // Auto-connect if not connected (same as rpc()) - if (!isConnected && !isConnecting) { - await connect(); - } - - // Wait for connection to be established - if (!isWebSocketConnected()) { - await waitForConnection(); - } - - blockSubscribers.push(cb); - - // Only send RPC subscription if not already subscribed - // This prevents duplicate server-side subscriptions - if (!isBlockSubscribed) { - isBlockSubscribed = true; - const header = await rpc("blockchain.headers.subscribe", []) as BlockHeader; - if (header) { - lastBlockHeader = header; - // Notify ALL current subscribers with the initial header - blockSubscribers.forEach(subscriber => subscriber(header)); - } - } else if (lastBlockHeader) { - // For late subscribers, immediately notify with cached header - cb(lastBlockHeader); - } - - // Return unsubscribe function - return () => { - const index = blockSubscribers.indexOf(cb); - if (index > -1) { - blockSubscribers.splice(index, 1); - } - }; -} - -export interface TransactionHistoryItem { - tx_hash: string; - height: number; - fee?: number; -} - -export interface TransactionDetail { - txid: string; - version: number; - locktime: number; - vin: Array<{ - txid: string; - vout: number; - scriptSig?: { - hex: string; - }; - sequence: number; - }>; - vout: Array<{ - value: number; - n: number; - scriptPubKey: { - hex: string; - type: string; - addresses?: string[]; - address?: string; - }; - }>; - blockhash?: string; - confirmations?: number; - time?: number; - blocktime?: number; -} - -export async function getTransactionHistory(address: string): Promise { - const scriptHash = addressToScriptHash(address); - const result = await rpc("blockchain.scripthash.get_history", [scriptHash]); - - if (!Array.isArray(result)) { - console.warn("get_history returned non-array:", result); - return []; - } - - return result as TransactionHistoryItem[]; -} - -export async function getTransaction(txid: string) { - return await rpc("blockchain.transaction.get", [txid, true]); -} - -export async function getBlockHeader(height: number) { - return await rpc("blockchain.block.header", [height, height]); -} - -export async function getCurrentBlockHeight(): Promise { - try { - const header = await rpc("blockchain.headers.subscribe", []) as BlockHeader; - return header?.height || 0; - } catch (err) { - console.error("Error getting current block height:", err); - return 0; - } -} - -export function disconnect() { - if (ws) { - intentionalClose = true; - ws.close(); - ws = null; - } - isConnected = false; - isConnecting = false; - reconnectAttempts = 0; - isBlockSubscribed = false; - - // Clear all pending request timeouts - Object.values(pending).forEach(req => { - if (req.timeoutId) clearTimeout(req.timeoutId); - }); - Object.keys(pending).forEach(key => delete pending[Number(key)]); - - // Clear connection callback timeouts - connectionCallbacks.forEach(cb => { - if (cb.timeoutId) clearTimeout(cb.timeoutId); - }); - connectionCallbacks.length = 0; -} diff --git a/src/components/wallet/L1/sdk/scan.ts b/src/components/wallet/L1/sdk/scan.ts deleted file mode 100644 index a85ab791..00000000 --- a/src/components/wallet/L1/sdk/scan.ts +++ /dev/null @@ -1,468 +0,0 @@ -/** - * Wallet address scanning for BIP32 HD wallets - * Port of index.html scanning functionality - * - * Enhanced to include L3 inventory checking: - * - Addresses with L1 balance OR L3 inventory are included - * - First 10 addresses get active IPFS sync in parallel - * - Remaining addresses use lazy sync (on-demand) - * - Cached nametags from localStorage displayed immediately - */ - -import { deriveKeyAtPath } from "./address"; -import { getBalance } from "./network"; -import type { Wallet } from "./types"; -// L3 inventory checking imports -import { IdentityManager } from "../../L3/services/IdentityManager"; -import { checkNametagForAddress, hasTokensForAddress } from "../../L3/services/InventorySyncService"; -import { fetchNametagFromIpns } from "../../L3/services/IpnsNametagFetcher"; -import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; -import { publicKeyToAddress, ec } from "../../shared/utils/cryptoUtils"; - -/** - * Generate address at specific BIP32 path (supports both external and change chains) - */ -function generateAddressAtPath( - masterPrivKey: string, - chainCode: string, - path: string -) { - const derived = deriveKeyAtPath(masterPrivKey, chainCode, path); - - const keyPair = ec.keyFromPrivate(derived.privateKey); - const publicKey = keyPair.getPublic(true, "hex"); - const address = publicKeyToAddress(publicKey); - - return { - address, - privateKey: derived.privateKey, - publicKey, - path, - }; -} - -export interface ScannedAddress { - index: number; - address: string; - path: string; - balance: number; - privateKey: string; - publicKey: string; - isChange?: boolean; - // L3 inventory fields - l3Nametag?: string; // Nametag (Unicity ID) if found - hasL3Inventory?: boolean; // True if has L3 inventory - l3Synced?: boolean; // True if IPFS sync completed for this address -} - -// Number of addresses to actively sync IPFS in parallel -const ACTIVE_SYNC_LIMIT = 10; - -/** - * Get cached L3 info from localStorage (instant, no network) - * - * Uses PATH as the single identifier for unambiguous address derivation. - * This ensures consistent L3 addresses regardless of whether the address - * is external or change. - * - * @param path - Full BIP32 path like "m/84'/1'/0'/0/0" or "m/84'/1'/0'/1/3" - */ -async function getCachedL3Info( - path: string -): Promise<{ - nametag?: string; - hasInventory: boolean; - l3Address?: string; - l3PrivateKey?: string; -}> { - try { - // Use path-based derivation for unambiguous L3 identity - const identityManager = IdentityManager.getInstance("user-pin-1234"); - const identity = await identityManager.deriveIdentityFromPath(path); - const l3Address = identity.address; - - // Check localStorage (instant) - const localNametag = checkNametagForAddress(l3Address); - const localTokens = hasTokensForAddress(l3Address); - - return { - nametag: localNametag?.name, - hasInventory: !!localNametag || localTokens, - l3Address, - l3PrivateKey: identity.privateKey, - }; - } catch (error) { - console.warn("Error getting cached L3 info:", error); - return { hasInventory: false }; - } -} - -export interface ScanProgress { - current: number; - total: number; - found: number; - totalBalance: number; - foundAddresses: ScannedAddress[]; - l1ScanComplete?: boolean; // True when L1 balance scan is done (IPNS may still be running) -} - -export interface ScanResult { - addresses: ScannedAddress[]; - totalBalance: number; - scannedCount: number; -} - -/** - * Scan wallet addresses to find those with balances - * @param wallet - Wallet with masterPrivateKey and chainCode - * @param maxAddresses - Maximum addresses to scan (default 200) - * @param onProgress - Progress callback - * @param shouldStop - Function to check if scan should stop - */ -export async function scanWalletAddresses( - wallet: Wallet, - maxAddresses: number = 200, - onProgress?: (progress: ScanProgress) => void, - shouldStop?: () => boolean -): Promise { - const foundAddresses: ScannedAddress[] = []; - let totalBalance = 0; - let l1ScanComplete = false; // Track when L1 balance scan completes - - if (!wallet.masterPrivateKey) { - throw new Error("No master private key in wallet"); - } - - // For BIP32 wallets, we need chainCode - const chainCode = wallet.masterChainCode || wallet.chainCode; - if (!chainCode) { - throw new Error("No chain code found - cannot derive BIP32 addresses"); - } - - // Use descriptorPath from wallet if available (from .dat file) - // Otherwise default to BIP44 mainnet (standard for Alpha) - const basePaths = wallet.descriptorPath - ? [`m/${wallet.descriptorPath}`] // Single path from wallet file - : ["m/44'/0'/0'"]; // Default: BIP44 mainnet - - console.log(`[Scan] Using base path: ${basePaths[0]}`); - console.log(`[Scan] Master key prefix: ${wallet.masterPrivateKey.slice(0, 16)}...`); - console.log(`[Scan] Chain code prefix: ${chainCode.slice(0, 16)}...`); - - // Initialize UnifiedKeyManager with wallet's basePath before deriving L3 identities - // This ensures getCachedL3Info uses the same derivation path as Select Address window - const keyManager = UnifiedKeyManager.getInstance("user-pin-1234"); - await keyManager.importWithMode( - wallet.masterPrivateKey, - chainCode, - "bip32", - basePaths[0] // Use the detected/specified base path - ); - console.log(`[Scan] UnifiedKeyManager initialized with basePath: ${basePaths[0]}`); - - // Scan both external (0) and change (1) chains - const chains = [0, 1]; - - // IPNS nametag cache - populated by background prefetch - // Key: L1 private key (since L3 uses same key) - const ipnsNametagCache = new Map(); - // Track which addresses need nametag discovery (for background fetch) - const addressesForIpnsFetch: { privateKey: string; index: number; chain: number; basePath: string }[] = []; - // Store full address info for prefetched addresses (to add if found after main loop) - const prefetchedAddressInfo = new Map(); - - // Start IPNS prefetch in background (non-blocking) - // This runs concurrently with the main L1 balance scan - console.log(`[Scan] Starting IPNS nametag prefetch in background...`); - - const runIpnsPrefetch = async () => { - // IMPORTANT: L3 identity uses the SAME private key as the L1 address - // Use the wallet's base path for both external (chain 0) and change (chain 1) addresses - for (const basePath of basePaths) { - for (const prefetchChain of [0, 1]) { - for (let i = 0; i < Math.min(ACTIVE_SYNC_LIMIT, maxAddresses); i++) { - const fullPath = `${basePath}/${prefetchChain}/${i}`; - try { - const addrInfo = generateAddressAtPath(wallet.masterPrivateKey, chainCode, fullPath); - // Only add if not already added (same private key from different path) - if (!prefetchedAddressInfo.has(addrInfo.privateKey)) { - addressesForIpnsFetch.push({ privateKey: addrInfo.privateKey, index: i, chain: prefetchChain, basePath }); - // Store full address info for later (in case we need to add it after main scan) - prefetchedAddressInfo.set(addrInfo.privateKey, { - address: addrInfo.address, - path: fullPath, - privateKey: addrInfo.privateKey, - publicKey: addrInfo.publicKey, - index: i, - chain: prefetchChain, - }); - } - } catch { - // Ignore derivation errors - } - } - } - } - - // Fetch nametags in parallel (with 30s timeout for all) - // When a nametag is found, immediately add the address to create a feeling of progress - const fetchPromises = addressesForIpnsFetch.map(async ({ privateKey, index, chain, basePath: addrBasePath }) => { - try { - const result = await fetchNametagFromIpns(privateKey); - if (result.nametag) { - ipnsNametagCache.set(privateKey, result.nametag); - const chainLabel = chain === 1 ? "change" : "external"; - const fullPath = `${addrBasePath}/${chain}/${index}`; - console.log(`[Scan] Found nametag from IPNS for ${chainLabel} index ${index} (path: ${fullPath}, key: ${privateKey.slice(0, 8)}...): ${result.nametag}`); - - // Progressive addition: Add address immediately if not already found - // Check by privateKey since that uniquely identifies the address (same key = same address) - const addrInfo = prefetchedAddressInfo.get(privateKey); - if (addrInfo) { - const isChangeAddr = addrInfo.chain === 1; - const existingIdx = foundAddresses.findIndex(a => a.privateKey === privateKey); - - if (existingIdx >= 0) { - // Entry exists, just update nametag if not set - if (!foundAddresses[existingIdx].l3Nametag) { - foundAddresses[existingIdx].l3Nametag = result.nametag; - foundAddresses[existingIdx].hasL3Inventory = true; - console.log(`[Scan] Updated ${chainLabel} index ${addrInfo.index} with nametag @${result.nametag}`); - } - } else { - // No entry for this address yet, add L3-only entry - foundAddresses.push({ - index: addrInfo.index, - address: addrInfo.address, - path: addrInfo.path, - balance: 0, - privateKey: addrInfo.privateKey, - publicKey: addrInfo.publicKey, - isChange: isChangeAddr, - l3Nametag: result.nametag, - hasL3Inventory: true, - l3Synced: false, - }); - console.log(`[Scan] Added L3 ${chainLabel} address ${addrInfo.index} with nametag @${result.nametag}`); - } - - // Report progress immediately so UI shows the new address - onProgress?.({ - current: Math.max(...foundAddresses.map(a => a.index), 0) + 1, - total: maxAddresses, - found: foundAddresses.length, - totalBalance, - foundAddresses: [...foundAddresses], - l1ScanComplete, // Preserve L1 scan status - }); - } - } - } catch { - // Ignore fetch errors - } - }); - - await Promise.race([ - Promise.all(fetchPromises), - new Promise(resolve => setTimeout(resolve, 30000)) - ]); - - console.log(`[Scan] IPNS prefetch complete, found ${ipnsNametagCache.size} nametags`); - }; - - // Start prefetch (don't await - runs in background) - const prefetchPromise = runIpnsPrefetch(); - - for (let i = 0; i < maxAddresses; i++) { - // Check if scan should stop - if (shouldStop?.()) { - break; - } - - // Try each base path and both chains - for (const basePath of basePaths) { - for (const chain of chains) { - try { - // Build full path: basePath/chain/index - const fullPath = `${basePath}/${chain}/${i}`; - - const addrInfo = generateAddressAtPath( - wallet.masterPrivateKey, - chainCode, - fullPath - ); - - // Check balance - const balance = await getBalance(addrInfo.address); - - // Get cached L3 info (from localStorage, instant) - // Uses path-based derivation for unambiguous L3 identity - const cachedL3 = await getCachedL3Info(fullPath); - - // Check if we have a pre-fetched nametag from IPNS - // Use the L1 private key since L3 uses the same key - const prefetchedNametag = ipnsNametagCache.get(addrInfo.privateKey); - - // Include address if has L1 balance OR cached L3 inventory OR prefetched nametag - const hasL3 = cachedL3.hasInventory || !!prefetchedNametag; - const includeAddress = balance > 0 || hasL3; - - // Check for existing entry (by privateKey since that uniquely identifies the address) - // The prefetch may have already added this address when a nametag was found - const existingIndex = foundAddresses.findIndex(a => a.privateKey === addrInfo.privateKey); - - if (includeAddress) { - if (existingIndex >= 0) { - // Update existing entry (e.g., L3-only entry gets L1 balance) - // If L1 has balance, prefer L1 address; otherwise keep existing - const existing = foundAddresses[existingIndex]; - if (balance > 0 && existing.balance === 0) { - // L1 has balance, update to use L1 address - foundAddresses[existingIndex] = { - ...existing, - address: addrInfo.address, - path: addrInfo.path, - balance, - privateKey: addrInfo.privateKey, - publicKey: addrInfo.publicKey, - l3Nametag: prefetchedNametag || cachedL3.nametag || existing.l3Nametag, - hasL3Inventory: hasL3 || existing.hasL3Inventory, - }; - totalBalance += balance; - } else if (balance === 0 && existing.balance === 0) { - // Both L3-only, merge nametag info - foundAddresses[existingIndex] = { - ...existing, - l3Nametag: prefetchedNametag || cachedL3.nametag || existing.l3Nametag, - hasL3Inventory: hasL3 || existing.hasL3Inventory, - }; - } - // If existing already has balance, don't replace - } else { - // New entry - foundAddresses.push({ - index: i, - address: addrInfo.address, - path: addrInfo.path, - balance, - privateKey: addrInfo.privateKey, - publicKey: addrInfo.publicKey, - isChange: chain === 1, - // L3 inventory info (from cache or prefetch) - l3Nametag: prefetchedNametag || cachedL3.nametag, - hasL3Inventory: hasL3, - l3Synced: false, // Not yet synced from IPFS - }); - totalBalance += balance; - } - } - } catch (e) { - // Continue on derivation errors - console.warn(`Error deriving address at ${basePath}/${chain}/${i}:`, e); - } - } - } - - // Report progress with found addresses - onProgress?.({ - current: i + 1, - total: maxAddresses, - found: foundAddresses.length, - totalBalance, - foundAddresses: [...foundAddresses], - }); - - // Small delay to avoid overwhelming the server - if (i % 10 === 0 && i > 0) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - } - - // Mark L1 scan as complete (IPNS may still be running in background) - l1ScanComplete = true; - - // Report L1 scan complete - UI can now show "Load Selected" button - onProgress?.({ - current: maxAddresses, - total: maxAddresses, - found: foundAddresses.length, - totalBalance, - foundAddresses: [...foundAddresses], - l1ScanComplete: true, - }); - - // Wait for IPNS prefetch to complete (with 30s max timeout) - console.log(`[Scan] Main scan complete. Waiting for IPNS prefetch to finish...`); - await prefetchPromise; - - // Safety net: Check if any addresses with nametags were missed - // (Most addresses should have been added progressively during prefetch) - // Check by privateKey since that uniquely identifies the address - let addedFromPrefetch = 0; - for (const [privateKey, nametag] of ipnsNametagCache) { - if (!nametag) continue; - - const addrInfo = prefetchedAddressInfo.get(privateKey); - if (!addrInfo) continue; - - const isChangeAddr = addrInfo.chain === 1; - const chainLabel = isChangeAddr ? "change" : "external"; - - // Check if this address already has an entry (by privateKey) - const existingIndex = foundAddresses.findIndex(a => a.privateKey === privateKey); - - if (existingIndex >= 0) { - // Entry exists, ensure nametag is set - if (!foundAddresses[existingIndex].l3Nametag) { - foundAddresses[existingIndex].l3Nametag = nametag; - foundAddresses[existingIndex].hasL3Inventory = true; - } - } else { - // No entry for this address, add L3-only entry - foundAddresses.push({ - index: addrInfo.index, - address: addrInfo.address, - path: addrInfo.path, - balance: 0, - privateKey: addrInfo.privateKey, - publicKey: addrInfo.publicKey, - isChange: isChangeAddr, - l3Nametag: nametag, - hasL3Inventory: true, - l3Synced: false, - }); - addedFromPrefetch++; - console.log(`[Scan] Safety net: Added ${chainLabel} address ${addrInfo.index} with nametag @${nametag}`); - } - } - - // Report final progress if we added addresses from prefetch - if (addedFromPrefetch > 0) { - onProgress?.({ - current: maxAddresses, - total: maxAddresses, - found: foundAddresses.length, - totalBalance, - foundAddresses: [...foundAddresses], - l1ScanComplete: true, // L1 scan is definitely complete at this point - }); - } - - // Report final results - if (foundAddresses.length > 0) { - console.log(`[Scan] Scan complete: found ${foundAddresses.length} addresses (${addedFromPrefetch} from IPNS prefetch)`); - } - - return { - addresses: foundAddresses, - totalBalance, - scannedCount: Math.min(maxAddresses, shouldStop?.() ? 0 : maxAddresses), - }; -} diff --git a/src/components/wallet/L1/sdk/storage.ts b/src/components/wallet/L1/sdk/storage.ts deleted file mode 100644 index b1d58cc1..00000000 --- a/src/components/wallet/L1/sdk/storage.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { StoredWallet, Wallet } from "./types"; -import { STORAGE_KEY_GENERATORS, STORAGE_KEY_PREFIXES } from "../../../../config/storageKeys"; - -export function saveWalletToStorage(key: string, wallet: Wallet) { - localStorage.setItem(STORAGE_KEY_GENERATORS.l1WalletByKey(key), JSON.stringify(wallet)); -} - -export function loadWalletFromStorage(key: string): Wallet | null { - const raw = localStorage.getItem(STORAGE_KEY_GENERATORS.l1WalletByKey(key)); - if (!raw) return null; - try { - return JSON.parse(raw); - } catch { - console.error(`[Storage] Failed to parse wallet data for key: ${key}`); - return null; - } -} - -export function deleteWalletFromStorage(key: string) { - localStorage.removeItem(STORAGE_KEY_GENERATORS.l1WalletByKey(key)); -} - -export function getAllStoredWallets(): StoredWallet[] { - const wallets: StoredWallet[] = []; - for (const k of Object.keys(localStorage)) { - if (!k.startsWith(STORAGE_KEY_PREFIXES.L1_WALLET)) continue; - const raw = localStorage.getItem(k); - if (!raw) continue; - try { - wallets.push({ - key: k.replace(STORAGE_KEY_PREFIXES.L1_WALLET, ""), - data: JSON.parse(raw) - }); - } catch { - console.error(`[Storage] Failed to parse wallet: ${k}`); - } - } - return wallets; -} diff --git a/src/components/wallet/L1/sdk/tx.ts b/src/components/wallet/L1/sdk/tx.ts deleted file mode 100644 index ff1e6221..00000000 --- a/src/components/wallet/L1/sdk/tx.ts +++ /dev/null @@ -1,499 +0,0 @@ -/** - * Transaction handling - Strict copy of index.html logic - */ -import { getUtxo, broadcast } from "./network"; -import { decodeBech32 } from "./bech32"; -import CryptoJS from "crypto-js"; -import elliptic from "elliptic"; -import type { Wallet, TransactionPlan, Transaction, UTXO } from "./types"; -import { vestingState } from "./vestingState"; -import { WalletAddressHelper } from "./addressHelpers"; - -const ec = new elliptic.ec("secp256k1"); - -// Constants -const FEE = 10_000; // sats per transaction -const DUST = 546; // dust threshold -const SAT = 100_000_000; // sats in 1 ALPHA - -/** - * Create scriptPubKey for address (P2WPKH for bech32) - * Exact copy from index.html - */ -export function createScriptPubKey(address: string): string { - if (!address || typeof address !== "string") { - throw new Error("Invalid address: must be a string"); - } - - const decoded = decodeBech32(address); - if (!decoded) { - throw new Error("Invalid bech32 address: " + address); - } - - // Convert data array to hex string - const dataHex = Array.from(decoded.data) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); - - // P2WPKH scriptPubKey: OP_0 <20-byte-key-hash> - return "0014" + dataHex; -} - -/** - * Create signature hash for SegWit (BIP143) - * Exact copy from index.html createSignatureHash() - */ -function createSignatureHash( - txPlan: { input: { tx_hash: string; tx_pos: number; value: number }; outputs: Array<{ value: number; address: string }> }, - publicKey: string -): string { - let preimage = ""; - - // 1. nVersion (4 bytes, little-endian) - preimage += "02000000"; - - // 2. hashPrevouts (32 bytes) - const txidBytes = txPlan.input.tx_hash.match(/../g)!.reverse().join(""); - const voutBytes = ("00000000" + txPlan.input.tx_pos.toString(16)).slice(-8).match(/../g)!.reverse().join(""); - const prevouts = txidBytes + voutBytes; - const hashPrevouts = CryptoJS.SHA256(CryptoJS.SHA256(CryptoJS.enc.Hex.parse(prevouts))).toString(); - preimage += hashPrevouts; - - // 3. hashSequence (32 bytes) - const sequence = "feffffff"; - const hashSequence = CryptoJS.SHA256(CryptoJS.SHA256(CryptoJS.enc.Hex.parse(sequence))).toString(); - preimage += hashSequence; - - // 4. outpoint (36 bytes) - preimage += txPlan.input.tx_hash.match(/../g)!.reverse().join(""); - preimage += ("00000000" + txPlan.input.tx_pos.toString(16)).slice(-8).match(/../g)!.reverse().join(""); - - // 5. scriptCode for P2WPKH (includes length prefix) - const pubKeyHash = CryptoJS.RIPEMD160(CryptoJS.SHA256(CryptoJS.enc.Hex.parse(publicKey))).toString(); - const scriptCode = "1976a914" + pubKeyHash + "88ac"; - preimage += scriptCode; - - // 6. amount (8 bytes, little-endian) - const amountHex = txPlan.input.value.toString(16).padStart(16, "0"); - preimage += amountHex.match(/../g)!.reverse().join(""); - - // 7. nSequence (4 bytes, little-endian) - preimage += sequence; - - // 8. hashOutputs (32 bytes) - let outputs = ""; - for (const output of txPlan.outputs) { - const outAmountHex = output.value.toString(16).padStart(16, "0"); - outputs += outAmountHex.match(/../g)!.reverse().join(""); - const scriptPubKey = createScriptPubKey(output.address); - const scriptLength = (scriptPubKey.length / 2).toString(16).padStart(2, "0"); - outputs += scriptLength; - outputs += scriptPubKey; - } - const hashOutputs = CryptoJS.SHA256(CryptoJS.SHA256(CryptoJS.enc.Hex.parse(outputs))).toString(); - preimage += hashOutputs; - - // 9. nLocktime (4 bytes, little-endian) - preimage += "00000000"; - - // 10. sighash type (4 bytes, little-endian) - preimage += "01000000"; // SIGHASH_ALL - - // Double SHA256 - const hash1 = CryptoJS.SHA256(CryptoJS.enc.Hex.parse(preimage)); - const hash2 = CryptoJS.SHA256(hash1); - return hash2.toString(); -} - -/** - * Create witness data for the transaction - * Exact copy from index.html createWitnessData() - */ -function createWitnessData( - txPlan: { input: { tx_hash: string; tx_pos: number; value: number }; outputs: Array<{ value: number; address: string }> }, - keyPair: elliptic.ec.KeyPair, - publicKey: string -): string { - // Create signature hash for witness - const sigHash = createSignatureHash(txPlan, publicKey); - - // Sign the hash - const signature = keyPair.sign(sigHash); - - // Ensure low-S canonical signature (BIP62) - const halfOrder = ec.curve.n!.shrn(1); - if (signature.s.cmp(halfOrder) > 0) { - signature.s = ec.curve.n!.sub(signature.s); - } - - const derSig = signature.toDER("hex") + "01"; // SIGHASH_ALL - - // Build witness - let witness = ""; - witness += "02"; // 2 stack items - - // Signature - const sigLen = (derSig.length / 2).toString(16).padStart(2, "0"); - witness += sigLen; - witness += derSig; - - // Public key - const pubKeyLen = (publicKey.length / 2).toString(16).padStart(2, "0"); - witness += pubKeyLen; - witness += publicKey; - - return witness; -} - -/** - * Build a proper SegWit transaction - * Exact copy from index.html buildSegWitTransaction() - */ -export function buildSegWitTransaction( - txPlan: { input: { tx_hash: string; tx_pos: number; value: number }; outputs: Array<{ value: number; address: string }> }, - keyPair: elliptic.ec.KeyPair, - publicKey: string -): { hex: string; txid: string } { - let txHex = ""; - - // Version (4 bytes, little-endian) - txHex += "02000000"; // version 2 - - // Marker and flag for SegWit - txHex += "00"; // marker - txHex += "01"; // flag - - // Number of inputs (varint) - txHex += "01"; // 1 input - - // Input - Previous tx hash (32 bytes, reversed for little-endian) - const prevTxHash = txPlan.input.tx_hash; - const reversedHash = prevTxHash.match(/../g)!.reverse().join(""); - txHex += reversedHash; - - // Previous output index (4 bytes, little-endian) - const vout = txPlan.input.tx_pos; - txHex += ("00000000" + vout.toString(16)).slice(-8).match(/../g)!.reverse().join(""); - - // Script length (varint) - 0 for witness transactions - txHex += "00"; - - // Sequence (4 bytes) - txHex += "feffffff"; - - // Number of outputs (varint) - const outputCount = txPlan.outputs.length; - txHex += ("0" + outputCount.toString(16)).slice(-2); - - // Outputs - for (const output of txPlan.outputs) { - // Amount (8 bytes, little-endian) - const amountHex = output.value.toString(16).padStart(16, "0"); - txHex += amountHex.match(/../g)!.reverse().join(""); - - // Script pubkey - const scriptPubKey = createScriptPubKey(output.address); - const scriptLength = (scriptPubKey.length / 2).toString(16).padStart(2, "0"); - txHex += scriptLength; - txHex += scriptPubKey; - } - - // Witness data - const witnessData = createWitnessData(txPlan, keyPair, publicKey); - txHex += witnessData; - - // Locktime (4 bytes) - txHex += "00000000"; - - // Calculate transaction ID (double SHA256 of tx without witness data) - let txForId = ""; - - // Version (4 bytes) - txForId += "02000000"; - - // Input count (1 byte) - txForId += "01"; - - // Input: txid (32 bytes reversed) + vout (4 bytes) - const inputTxidBytes = txPlan.input.tx_hash.match(/../g)!.reverse().join(""); - txForId += inputTxidBytes; - txForId += ("00000000" + txPlan.input.tx_pos.toString(16)).slice(-8).match(/../g)!.reverse().join(""); - - // Script sig (empty for P2WPKH) - txForId += "00"; - - // Sequence (4 bytes) - txForId += "feffffff"; - - // Output count - txForId += ("0" + txPlan.outputs.length.toString(16)).slice(-2); - - // Add all outputs - for (const output of txPlan.outputs) { - const amountHex = ("0000000000000000" + output.value.toString(16)).slice(-16); - const amountLittleEndian = amountHex.match(/../g)!.reverse().join(""); - txForId += amountLittleEndian; - - const scriptPubKey = createScriptPubKey(output.address); - const scriptLength = ("0" + (scriptPubKey.length / 2).toString(16)).slice(-2); - txForId += scriptLength; - txForId += scriptPubKey; - } - - // Locktime (4 bytes) - txForId += "00000000"; - - // Calculate the correct txid - const hash1 = CryptoJS.SHA256(CryptoJS.enc.Hex.parse(txForId)); - const hash2 = CryptoJS.SHA256(hash1); - const txid = hash2.toString().match(/../g)!.reverse().join(""); - - return { - hex: txHex, - txid: txid, - }; -} - -/** - * Create and sign a transaction - * Uses the private key for the specific address being spent from - */ -export function createAndSignTransaction( - wallet: Wallet, - txPlan: Transaction -): { raw: string; txid: string } { - // Find the address entry that matches the input address - const fromAddress = txPlan.input.address; - const addressEntry = wallet.addresses.find(a => a.address === fromAddress); - - // Use the private key from the address entry, or fall back to childPrivateKey/masterPrivateKey - let privateKeyHex: string | undefined; - - if (addressEntry?.privateKey) { - // Use the specific private key for this address - privateKeyHex = addressEntry.privateKey; - } else if (wallet.childPrivateKey) { - // Fall back to childPrivateKey (first address) - privateKeyHex = wallet.childPrivateKey; - } else { - // Last resort: use master key - privateKeyHex = wallet.masterPrivateKey; - } - - if (!privateKeyHex) { - throw new Error("No private key available for address: " + fromAddress); - } - - const keyPair = ec.keyFromPrivate(privateKeyHex, "hex"); - const publicKey = keyPair.getPublic(true, "hex"); // compressed - - // Convert Transaction to the format expected by buildSegWitTransaction - const txPlanForBuild = { - input: { - tx_hash: txPlan.input.txid, - tx_pos: txPlan.input.vout, - value: txPlan.input.value, - }, - outputs: txPlan.outputs, - }; - - const tx = buildSegWitTransaction(txPlanForBuild, keyPair, publicKey); - - return { - raw: tx.hex, - txid: tx.txid, - }; -} - -/** - * 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[], - amountSats: number, - recipientAddress: string, - senderAddress: string -): TransactionPlan { - const totalAvailable = utxoList.reduce((sum, u) => sum + u.value, 0); - - if (totalAvailable < amountSats + FEE) { - return { - success: false, - transactions: [], - 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 sortedDescending) { - if (remainingAmount <= 0) break; - - const utxoValue = utxo.value; - let txAmount = 0; - let changeAmount = 0; - - if (utxoValue >= remainingAmount + 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; - } - - const tx: Transaction = { - input: { - txid: utxo.txid ?? utxo.tx_hash ?? "", - vout: utxo.vout ?? utxo.tx_pos ?? 0, - value: utxo.value, - address: utxo.address ?? senderAddress, - }, - outputs: [{ address: recipientAddress, value: txAmount }], - fee: FEE, - changeAmount: changeAmount, - changeAddress: senderAddress, - }; - - // Add change output if above dust - if (changeAmount > DUST) { - tx.outputs.push({ value: changeAmount, address: senderAddress }); - } - - transactions.push(tx); - } - - if (remainingAmount > 0) { - return { - success: false, - transactions: [], - error: `Unable to collect enough UTXOs. Short by ${remainingAmount / SAT} ALPHA after fees.`, - }; - } - - return { - success: true, - transactions, - }; -} - -/** - * Create transaction plan from wallet - * @param wallet - The wallet - * @param toAddress - Recipient address - * @param amountAlpha - Amount in ALPHA - * @param fromAddress - Optional: specific address to send from (defaults to first address) - */ -export async function createTransactionPlan( - wallet: Wallet, - toAddress: string, - amountAlpha: number, - fromAddress?: string -): Promise { - if (!decodeBech32(toAddress)) { - throw new Error("Invalid recipient address"); - } - - // Use specified fromAddress or default to first external address - const defaultAddr = WalletAddressHelper.getDefault(wallet); - const senderAddress = fromAddress || defaultAddr.address; - const amountSats = Math.floor(amountAlpha * SAT); - - // Get UTXOs filtered by current vesting mode (set in SendModal) - let utxos: UTXO[]; - const currentMode = vestingState.getMode(); - - if (vestingState.hasClassifiedData(senderAddress)) { - // Use vesting-filtered UTXOs based on selected mode - utxos = vestingState.getFilteredUtxos(senderAddress); - console.log(`Using ${utxos.length} ${currentMode} UTXOs`); - } else { - // Fall back to all UTXOs if not yet classified - utxos = await getUtxo(senderAddress); - console.log(`Using ${utxos.length} UTXOs (vesting not classified yet)`); - } - - if (!Array.isArray(utxos) || utxos.length === 0) { - const modeText = currentMode !== 'all' ? ` (${currentMode} coins)` : ''; - throw new Error(`No UTXOs available${modeText} for address: ` + senderAddress); - } - - return collectUtxosForAmount(utxos, amountSats, toAddress, senderAddress); -} - -/** - * Send ALPHA to address - * @param wallet - The wallet - * @param toAddress - Recipient address - * @param amountAlpha - Amount in ALPHA - * @param fromAddress - Optional: specific address to send from - */ -export async function sendAlpha( - wallet: Wallet, - toAddress: string, - amountAlpha: number, - fromAddress?: string -) { - const plan = await createTransactionPlan(wallet, toAddress, amountAlpha, fromAddress); - - if (!plan.success) { - throw new Error(plan.error || "Transaction planning failed"); - } - - const results = []; - - for (const tx of plan.transactions) { - const signed = createAndSignTransaction(wallet, tx); - const result = await broadcast(signed.raw); - results.push({ - txid: signed.txid, - raw: signed.raw, - broadcastResult: result, - }); - } - - return results; -} diff --git a/src/components/wallet/L1/sdk/types.ts b/src/components/wallet/L1/sdk/types.ts deleted file mode 100644 index 8bef96f6..00000000 --- a/src/components/wallet/L1/sdk/types.ts +++ /dev/null @@ -1,282 +0,0 @@ -export interface Wallet { - masterPrivateKey: string; - chainCode?: string; - addresses: WalletAddress[]; - createdAt?: number; - isEncrypted?: boolean; - encryptedMasterKey?: string; - childPrivateKey?: string | null; - isImportedAlphaWallet?: boolean; - masterChainCode?: string | null; - isBIP32?: boolean; - descriptorPath?: string | null; -} - -export interface WalletAddress { - address: string; - publicKey?: string; - privateKey?: string; - path: string | null; - index: number; - createdAt?: string; - isChange?: boolean; // true for change addresses (BIP32 chain 1) -} - -export interface StoredWallet { - key: string; - data: Wallet; -} - -export interface TransactionInput { - txid: string; - vout: number; - value: number; - address: string; -} - -export interface TransactionOutput { - value: number; - address: string; -} - -export interface Transaction { - input: TransactionInput; - outputs: TransactionOutput[]; - fee: number; - changeAmount: number; - changeAddress: string; -} - -export interface TransactionPlan { - success: boolean; - transactions: Transaction[]; - error?: string; -} - -export interface UTXO { - txid?: string; - tx_hash?: string; - vout?: number; - tx_pos?: number; - value: number; - height?: number; - address?: string; -} - -export interface RestoreWalletResult { - success: boolean; - wallet: Wallet; - message?: string; - error?: string; - /** Indicates that the wallet.dat file is encrypted and requires a password */ - isEncryptedDat?: boolean; -} - -export interface ExportOptions { - password?: string; - filename?: string; -} - -/** - * JSON Wallet Export Format v1.0 - * - * Supports multiple wallet sources: - * - "mnemonic": Created from BIP39 mnemonic phrase (new standard) - * - "file_bip32": Imported from file with chain code (BIP32 HD wallet) - * - "file_standard": Imported from file without chain code (HMAC-based) - * - "dat_descriptor": Imported from wallet.dat descriptor wallet - * - "dat_hd": Imported from wallet.dat HD wallet - * - "dat_legacy": Imported from wallet.dat legacy wallet - */ -export type WalletJSONSource = - | "mnemonic" // New standard - has mnemonic phrase - | "file_bip32" // Imported from txt with chain code - | "file_standard" // Imported from txt without chain code (HMAC) - | "dat_descriptor" // Imported from wallet.dat (descriptor format) - | "dat_hd" // Imported from wallet.dat (HD format) - | "dat_legacy"; // Imported from wallet.dat (legacy format) - -export type WalletJSONDerivationMode = "bip32" | "wif_hmac" | "legacy_hmac"; - -export interface WalletJSONAddress { - address: string; - publicKey: string; - path: string; - index?: number; - isChange?: boolean; -} - -/** - * JSON Wallet Export structure - */ -export interface WalletJSON { - /** Format version */ - version: "1.0"; - - /** Generation timestamp ISO 8601 */ - generated: string; - - /** Security warning */ - warning: string; - - /** Master private key (hex, 64 chars) */ - masterPrivateKey: string; - - /** Master chain code for BIP32 (hex, 64 chars) - optional for HMAC wallets */ - chainCode?: string; - - /** BIP39 mnemonic phrase - only present if source is "mnemonic" */ - mnemonic?: string; - - /** Derivation mode used */ - derivationMode: WalletJSONDerivationMode; - - /** Source of the wallet */ - source: WalletJSONSource; - - /** First address for verification */ - firstAddress: WalletJSONAddress; - - /** Descriptor path for BIP32 wallets (e.g., "84'/0'/0'") */ - descriptorPath?: string; - - /** Encrypted fields (when password protected) */ - encrypted?: { - /** Encrypted master private key (AES-256) */ - masterPrivateKey: string; - /** Encrypted mnemonic (AES-256) - only if source is "mnemonic" */ - mnemonic?: string; - /** Salt used for key derivation */ - salt: string; - /** Number of PBKDF2 iterations */ - iterations: number; - }; - - /** Additional addresses beyond first (optional) */ - addresses?: WalletJSONAddress[]; -} - -export interface WalletJSONExportOptions { - /** Password for encryption (optional) */ - password?: string; - /** Include all addresses (default: only first address) */ - includeAllAddresses?: boolean; - /** Number of addresses to include (if includeAllAddresses is false) */ - addressCount?: number; -} - -export interface WalletJSONImportResult { - success: boolean; - wallet?: Wallet; - source?: WalletJSONSource; - derivationMode?: WalletJSONDerivationMode; - /** Indicates if mnemonic was found in the JSON */ - hasMnemonic?: boolean; - /** The decrypted mnemonic phrase (if available) */ - mnemonic?: string; - message?: string; - error?: string; -} - -// Vesting types -export type VestingMode = "all" | "vested" | "unvested"; - -export interface ClassifiedUTXO extends UTXO { - vestingStatus?: "vested" | "unvested" | "error"; - coinbaseHeight?: number | null; -} - -export interface VestingBalances { - vested: bigint; - unvested: bigint; - all: bigint; -} - -export interface ClassificationResult { - isVested: boolean; - coinbaseHeight: number | null; - error?: string; -} - -// ========================================== -// Path-based address utilities -// ========================================== - -/** - * Parse BIP32 path components from a derivation path string - * @param path - Full path like "m/84'/1'/0'/0/5" or "m/44'/0'/0'/1/3" - * @returns { chain: number, index: number } where chain=0 is external, chain=1 is change - * Returns null if path is invalid - * - * Examples: - * "m/84'/1'/0'/0/5" -> { chain: 0, index: 5 } (external address 5) - * "m/84'/1'/0'/1/3" -> { chain: 1, index: 3 } (change address 3) - */ -export function parsePathComponents(path: string): { chain: number; index: number } | null { - // Match paths like m/84'/1'/0'/0/5 or m/44'/0'/0'/1/3 - const match = path.match(/m\/\d+'\/\d+'\/\d+'\/(\d+)\/(\d+)/); - if (!match) return null; - return { chain: parseInt(match[1], 10), index: parseInt(match[2], 10) }; -} - -/** - * Check if a BIP32 path represents a change address (chain=1) - * @param path - Full BIP32 path string - * @returns true if this is a change address path - */ -export function isChangePath(path: string): boolean { - const parsed = parsePathComponents(path); - return parsed?.chain === 1; -} - -/** - * Get display-friendly index from path (for UI display only) - * @param path - Full BIP32 path string - * @returns The address index number, or 0 if invalid - */ -export function getIndexFromPath(path: string): number { - const parsed = parsePathComponents(path); - return parsed?.index ?? 0; -} - -/** - * Convert a BIP32 path to a DOM-safe ID string - * Replaces characters that are invalid in DOM IDs: - * - ' (apostrophe) -> 'h' (hardened marker) - * - / (forward slash) -> '-' (dash) - * - * @param path - Full BIP32 path like "m/84'/1'/0'/0/5" - * @returns DOM-safe ID like "m-84h-1h-0h-0-5" - * - * Examples: - * "m/84'/1'/0'/0/5" -> "m-84h-1h-0h-0-5" - * "m/44'/0'/0'/1/3" -> "m-44h-0h-0h-1-3" - */ -export function pathToDOMId(path: string): string { - return path.replace(/'/g, "h").replace(/\//g, "-"); -} - -/** - * Convert a DOM-safe ID back to a BIP32 path string - * Reverses the transformation done by pathToDOMId: - * - 'h' -> ' (apostrophe for hardened) - * - '-' -> / (forward slash) - * - * @param encoded - DOM-safe ID like "m-84h-1h-0h-0-5" - * @returns BIP32 path like "m/84'/1'/0'/0/5" - * - * Examples: - * "m-84h-1h-0h-0-5" -> "m/84'/1'/0'/0/5" - * "m-44h-0h-0h-1-3" -> "m/44'/0'/0'/1/3" - */ -export function domIdToPath(encoded: string): string { - // Split by dash, then restore path format - const parts = encoded.split("-"); - return parts - .map((part, idx) => { - if (idx === 0) return part; // 'm' stays as-is - // Restore hardened marker: ends with 'h' -> ends with "'" - return part.endsWith("h") ? `${part.slice(0, -1)}'` : part; - }) - .join("/"); -} diff --git a/src/components/wallet/L1/sdk/unifiedWalletBridge.ts b/src/components/wallet/L1/sdk/unifiedWalletBridge.ts deleted file mode 100644 index 66a70e1c..00000000 --- a/src/components/wallet/L1/sdk/unifiedWalletBridge.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * UnifiedWalletBridge - Bridge between UnifiedKeyManager and L1 Wallet interface - * - * Provides functions to load/build L1 Wallet objects from the shared UnifiedKeyManager. - * This enables L1 and L3 wallets to use the same keys. - */ - -import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; -import type { Wallet, WalletAddress } from "./types"; -import { loadWalletFromStorage } from "./storage"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; - -// Same session key as L3 (from useWallet.ts) -const SESSION_KEY = "user-pin-1234"; - -/** - * Load wallet from UnifiedKeyManager and convert to L1 Wallet interface - * Returns null if UnifiedKeyManager is not initialized - * - * Priority: - * 1. If L1 wallet exists in storage with addresses (e.g., from import/scan), use those - * 2. Otherwise, derive addresses from UnifiedKeyManager (for new/restore wallets) - */ -export async function loadWalletFromUnifiedKeyManager(): Promise { - const keyManager = UnifiedKeyManager.getInstance(SESSION_KEY); - const initialized = await keyManager.initialize(); - - if (!initialized || !keyManager.isInitialized()) { - return null; - } - - // Check if L1 wallet exists in storage with addresses (from import/scan) - const storedWallet = loadWalletFromStorage("main"); - if (storedWallet && storedWallet.addresses && storedWallet.addresses.length > 0) { - console.log(`📋 Loading L1 wallet from storage with ${storedWallet.addresses.length} addresses`); - return storedWallet; - } - - // No stored wallet with addresses - derive from UnifiedKeyManager - const walletInfo = keyManager.getWalletInfo(); - const masterKey = keyManager.getMasterKeyHex(); - const chainCode = keyManager.getChainCodeHex(); - - if (!masterKey) { - return null; - } - - // Get selected address path (same as L3 uses) - PATH is the ONLY reliable identifier - const selectedPath = localStorage.getItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - - // Get base path for default address derivation - const basePath = keyManager.getBasePath(); - const defaultPath = `${basePath}/0/0`; // First external address - - // Derive the selected address (or default if none selected) - const targetPath = selectedPath || defaultPath; - const derived = keyManager.deriveAddressFromPath(targetPath); - - // Build addresses array with just the selected address - // Additional addresses will be added from storage or scanning - const addresses: WalletAddress[] = [{ - address: derived.l1Address, - publicKey: derived.publicKey, - privateKey: derived.privateKey, - path: derived.path, - index: derived.index, - }]; - - // Build L1 Wallet from UnifiedKeyManager data - // Use derived address directly instead of addresses[0] for clarity - const wallet: Wallet = { - masterPrivateKey: masterKey, - chainCode: chainCode || undefined, - addresses, - createdAt: Date.now(), - isImportedAlphaWallet: walletInfo.source === "file", - isBIP32: walletInfo.derivationMode === "bip32", - childPrivateKey: derived.privateKey || null, - }; - - return wallet; -} - -/** - * Get the UnifiedKeyManager instance - * Use this when you need to call methods on the key manager directly - */ -export function getUnifiedKeyManager(): UnifiedKeyManager { - return UnifiedKeyManager.getInstance(SESSION_KEY); -} - -/** - * Check if UnifiedKeyManager has an initialized wallet - */ -export async function isUnifiedWalletInitialized(): Promise { - const keyManager = UnifiedKeyManager.getInstance(SESSION_KEY); - await keyManager.initialize(); - return keyManager.isInitialized(); -} diff --git a/src/components/wallet/L1/sdk/vesting.ts b/src/components/wallet/L1/sdk/vesting.ts deleted file mode 100644 index a763025d..00000000 --- a/src/components/wallet/L1/sdk/vesting.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * VestingClassifier - Traces UTXOs to their coinbase origin to determine vesting status - * VESTED: Coins from coinbase transactions in blocks <= VESTING_THRESHOLD (280000) - * UNVESTED: Coins from coinbase transactions in blocks > VESTING_THRESHOLD - * - * Direct port from index.html VestingClassifier - */ -import { getTransaction, getCurrentBlockHeight } from "./network"; -import type { UTXO, ClassifiedUTXO, ClassificationResult } from "./types"; - -export const VESTING_THRESHOLD = 280000; - -// Current block height - updated during classification -let currentBlockHeight: number | null = null; - -interface CacheEntry { - blockHeight: number | null; // null means "not computed yet" - isCoinbase: boolean; - inputTxId: string | null; -} - -interface TransactionData { - txid: string; - confirmations?: number; - height?: number; - vin?: Array<{ - txid?: string; - coinbase?: string; - }>; -} - -class VestingClassifier { - private memoryCache = new Map(); - private dbName = "SphereVestingCacheV5"; // V5 - new cache with proper null handling - private storeName = "vestingCache"; - private db: IDBDatabase | null = null; - - /** - * Initialize IndexedDB for persistent caching - */ - async initDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, 1); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName, { keyPath: "txHash" }); - } - }; - - request.onsuccess = (event) => { - this.db = (event.target as IDBOpenDBRequest).result; - resolve(); - }; - - request.onerror = () => reject(request.error); - }); - } - - /** - * Check if transaction is coinbase - */ - private isCoinbaseTransaction(txData: TransactionData): boolean { - if (txData.vin && txData.vin.length === 1) { - const vin = txData.vin[0]; - // Check for coinbase field or missing txid - if (vin.coinbase || (!vin.txid && vin.coinbase !== undefined)) { - return true; - } - // Some formats use empty txid for coinbase - if (vin.txid === "0000000000000000000000000000000000000000000000000000000000000000") { - return true; - } - } - return false; - } - - /** - * Load from IndexedDB cache - */ - private async loadFromDB(txHash: string): Promise { - if (!this.db) return null; - - return new Promise((resolve) => { - const tx = this.db!.transaction(this.storeName, "readonly"); - const store = tx.objectStore(this.storeName); - const request = store.get(txHash); - - request.onsuccess = () => { - if (request.result) { - resolve({ - blockHeight: request.result.blockHeight, - isCoinbase: request.result.isCoinbase, - inputTxId: request.result.inputTxId, - }); - } else { - resolve(null); - } - }; - request.onerror = () => resolve(null); - }); - } - - /** - * Save to IndexedDB cache - */ - private async saveToDB(txHash: string, entry: CacheEntry): Promise { - if (!this.db) return; - - return new Promise((resolve) => { - const tx = this.db!.transaction(this.storeName, "readwrite"); - const store = tx.objectStore(this.storeName); - store.put({ txHash, ...entry }); - tx.oncomplete = () => resolve(); - tx.onerror = () => resolve(); - }); - } - - /** - * Trace a transaction to its coinbase origin - * Alpha blockchain has single-input transactions, making this a linear trace - */ - async traceToOrigin(txHash: string): Promise<{ coinbaseHeight: number | null; error?: string }> { - let currentTxHash = txHash; - let iterations = 0; - const MAX_ITERATIONS = 10000; - - while (iterations < MAX_ITERATIONS) { - iterations++; - - // Check memory cache first - const cached = this.memoryCache.get(currentTxHash); - if (cached) { - if (cached.isCoinbase) { - // Skip cache if blockHeight is null - needs re-fetch - if (cached.blockHeight !== null && cached.blockHeight !== undefined) { - return { coinbaseHeight: cached.blockHeight }; - } - // Fall through to re-fetch - } else if (cached.inputTxId) { - // Follow the input chain - currentTxHash = cached.inputTxId; - continue; - } - } - - // Check IndexedDB cache - const dbCached = await this.loadFromDB(currentTxHash); - if (dbCached) { - // Also store in memory cache - this.memoryCache.set(currentTxHash, dbCached); - if (dbCached.isCoinbase) { - // Skip cache if blockHeight is null - needs re-fetch - if (dbCached.blockHeight !== null && dbCached.blockHeight !== undefined) { - return { coinbaseHeight: dbCached.blockHeight }; - } - // Fall through to re-fetch - } else if (dbCached.inputTxId) { - currentTxHash = dbCached.inputTxId; - continue; - } - } - - // Fetch from network - const txData = await getTransaction(currentTxHash) as TransactionData; - if (!txData || !txData.txid) { - return { coinbaseHeight: null, error: `Failed to fetch tx ${currentTxHash}` }; - } - - // Determine if this is a coinbase transaction - const isCoinbase = this.isCoinbaseTransaction(txData); - - // Calculate block height from confirmations (like index.html does) - let blockHeight: number | null = null; - if (txData.confirmations && currentBlockHeight !== null && currentBlockHeight !== undefined) { - blockHeight = currentBlockHeight - txData.confirmations + 1; - } - - // Get input transaction ID (if not coinbase) - let inputTxId: string | null = null; - if (!isCoinbase && txData.vin && txData.vin.length > 0 && txData.vin[0].txid) { - inputTxId = txData.vin[0].txid; - } - - // Cache the result - const cacheEntry: CacheEntry = { - blockHeight, // Can be null if confirmations not available - isCoinbase, - inputTxId, - }; - this.memoryCache.set(currentTxHash, cacheEntry); - await this.saveToDB(currentTxHash, cacheEntry); - - if (isCoinbase) { - return { coinbaseHeight: blockHeight }; - } - - if (!inputTxId) { - return { coinbaseHeight: null, error: "Could not find input transaction" }; - } - - currentTxHash = inputTxId; - } - - return { coinbaseHeight: null, error: "Max iterations exceeded" }; - } - - /** - * Classify a single UTXO - */ - async classifyUtxo(utxo: UTXO): Promise { - const txHash = utxo.tx_hash || utxo.txid; - if (!txHash) { - return { isVested: false, coinbaseHeight: null, error: "No transaction hash" }; - } - - try { - const result = await this.traceToOrigin(txHash); - if (result.error || result.coinbaseHeight === null) { - return { isVested: false, coinbaseHeight: null, error: result.error || "Could not trace to origin" }; - } - return { - isVested: result.coinbaseHeight <= VESTING_THRESHOLD, - coinbaseHeight: result.coinbaseHeight, - }; - } catch (err) { - return { - isVested: false, - coinbaseHeight: null, - error: err instanceof Error ? err.message : String(err), - }; - } - } - - /** - * Classify multiple UTXOs with progress callback - */ - async classifyUtxos( - utxos: UTXO[], - onProgress?: (current: number, total: number) => void - ): Promise<{ - vested: ClassifiedUTXO[]; - unvested: ClassifiedUTXO[]; - errors: Array<{ utxo: UTXO; error: string }>; - }> { - // Get current block height before classification - currentBlockHeight = await getCurrentBlockHeight(); - - // Clear memory cache to force re-fetch with current block height - this.memoryCache.clear(); - - const vested: ClassifiedUTXO[] = []; - const unvested: ClassifiedUTXO[] = []; - const errors: Array<{ utxo: UTXO; error: string }> = []; - - for (let i = 0; i < utxos.length; i++) { - const utxo = utxos[i]; - const result = await this.classifyUtxo(utxo); - - if (result.error) { - errors.push({ utxo, error: result.error }); - // Default to unvested on error for safety - unvested.push({ - ...utxo, - vestingStatus: "error", - coinbaseHeight: null, - }); - } else if (result.isVested) { - vested.push({ - ...utxo, - vestingStatus: "vested", - coinbaseHeight: result.coinbaseHeight, - }); - } else { - unvested.push({ - ...utxo, - vestingStatus: "unvested", - coinbaseHeight: result.coinbaseHeight, - }); - } - - // Report progress - if (onProgress) { - onProgress(i + 1, utxos.length); - } - - // Yield every 5 UTXOs - if (i % 5 === 0) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - - return { vested, unvested, errors }; - } - - /** - * Clear all caches - */ - clearCaches(): void { - this.memoryCache.clear(); - if (this.db) { - const tx = this.db.transaction(this.storeName, "readwrite"); - tx.objectStore(this.storeName).clear(); - } - } -} - -export const vestingClassifier = new VestingClassifier(); diff --git a/src/components/wallet/L1/sdk/vestingState.ts b/src/components/wallet/L1/sdk/vestingState.ts deleted file mode 100644 index de821df4..00000000 --- a/src/components/wallet/L1/sdk/vestingState.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { vestingClassifier } from "./vesting"; -import type { - UTXO, - ClassifiedUTXO, - VestingMode, - VestingBalances, -} from "./types"; - -interface AddressVestingCache { - classifiedUtxos: { - vested: ClassifiedUTXO[]; - unvested: ClassifiedUTXO[]; - all: ClassifiedUTXO[]; - }; - vestingBalances: VestingBalances; -} - -class VestingStateManager { - private currentMode: VestingMode = "all"; - private addressCache = new Map(); - private classificationInProgress = false; - - /** - * Set the current vesting mode - */ - setMode(mode: VestingMode): void { - if (!["all", "vested", "unvested"].includes(mode)) { - throw new Error(`Invalid vesting mode: ${mode}`); - } - this.currentMode = mode; - } - - getMode(): VestingMode { - return this.currentMode; - } - - /** - * Classify all UTXOs for an address - */ - async classifyAddressUtxos( - address: string, - utxos: UTXO[], - onProgress?: (current: number, total: number) => void - ): Promise { - if (this.classificationInProgress) return; - - this.classificationInProgress = true; - - try { - await vestingClassifier.initDB(); - - const result = await vestingClassifier.classifyUtxos(utxos, onProgress); - - // Calculate balances - const vestedBalance = result.vested.reduce( - (sum, utxo) => sum + BigInt(utxo.value), - 0n - ); - const unvestedBalance = result.unvested.reduce( - (sum, utxo) => sum + BigInt(utxo.value), - 0n - ); - - // Store in cache - this.addressCache.set(address, { - classifiedUtxos: { - vested: result.vested, - unvested: result.unvested, - all: [...result.vested, ...result.unvested], - }, - vestingBalances: { - vested: vestedBalance, - unvested: unvestedBalance, - all: vestedBalance + unvestedBalance, - }, - }); - - // Log any errors - if (result.errors.length > 0) { - console.warn(`Vesting classification errors: ${result.errors.length}`); - result.errors.slice(0, 5).forEach((err) => { - const txHash = err.utxo.tx_hash || err.utxo.txid; - console.warn(` ${txHash}: ${err.error}`); - }); - } - } finally { - this.classificationInProgress = false; - } - } - - /** - * Get filtered UTXOs based on current vesting mode - */ - getFilteredUtxos(address: string): ClassifiedUTXO[] { - const cache = this.addressCache.get(address); - if (!cache) return []; - - switch (this.currentMode) { - case "vested": - return cache.classifiedUtxos.vested; - case "unvested": - return cache.classifiedUtxos.unvested; - default: - return cache.classifiedUtxos.all; - } - } - - /** - * Get all UTXOs regardless of vesting mode (for transactions) - */ - getAllUtxos(address: string): ClassifiedUTXO[] { - const cache = this.addressCache.get(address); - if (!cache) return []; - return cache.classifiedUtxos.all; - } - - /** - * Get balance for current vesting mode (in satoshis) - */ - getBalance(address: string): bigint { - const cache = this.addressCache.get(address); - if (!cache) return 0n; - - return cache.vestingBalances[this.currentMode]; - } - - /** - * Get all balances for display - */ - getAllBalances(address: string): VestingBalances { - const cache = this.addressCache.get(address); - if (!cache) { - return { vested: 0n, unvested: 0n, all: 0n }; - } - return cache.vestingBalances; - } - - /** - * Check if address has been classified - */ - hasClassifiedData(address: string): boolean { - return this.addressCache.has(address); - } - - /** - * Check if classification is in progress - */ - isClassifying(): boolean { - return this.classificationInProgress; - } - - /** - * Clear cache for an address - */ - clearAddressCache(address: string): void { - this.addressCache.delete(address); - } - - /** - * Clear all caches - */ - clearAllCaches(): void { - this.addressCache.clear(); - vestingClassifier.clearCaches(); - } -} - -export const vestingState = new VestingStateManager(); diff --git a/src/components/wallet/L1/sdk/wallet.ts b/src/components/wallet/L1/sdk/wallet.ts deleted file mode 100644 index 0b316a5f..00000000 --- a/src/components/wallet/L1/sdk/wallet.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { saveWalletToStorage, loadWalletFromStorage } from "./storage"; -import { - generateHDAddressBIP32, - generateAddressFromMasterKey, -} from "./address"; -import type { Wallet } from "./types"; -import CryptoJS from "crypto-js"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; - -/** - * Create a new wallet matching the original index.html implementation - * Uses 32-byte random private key with HMAC-SHA512 derivation - */ -export function createWallet(): Wallet { - // Generate 32 random bytes (256 bits) for the private key - same as index.html - const randomBytes = CryptoJS.lib.WordArray.random(32); - const masterPrivateKey = randomBytes.toString(); - - // Generate first address using HMAC-SHA512 derivation (matching index.html) - const firstAddress = generateAddressFromMasterKey(masterPrivateKey, 0); - - const wallet: Wallet = { - masterPrivateKey, - addresses: [firstAddress], - createdAt: Date.now(), - childPrivateKey: firstAddress.privateKey, // Store for transactions - }; - - saveWalletToStorage("main", wallet); - return wallet; -} - -export function deleteWallet() { - localStorage.removeItem(STORAGE_KEYS.WALLET_MAIN); -} - -export function loadWallet(): Wallet | null { - return loadWalletFromStorage("main"); -} - -/** - * Generate a new address for the wallet - * For standard wallets: uses HMAC-SHA512 derivation (index.html compatible) - * For imported BIP32 wallets: uses proper BIP32 derivation - */ -export function generateAddress(wallet: Wallet) { - // Find the next external address index - // This accounts for wallets that have change addresses mixed in - // External addresses have isChange=false or undefined - const externalAddresses = wallet.addresses.filter(addr => !addr.isChange); - const maxExternalIndex = externalAddresses.length > 0 - ? Math.max(...externalAddresses.map(addr => addr.index ?? 0)) - : -1; - const index = maxExternalIndex + 1; - - // For imported BIP32 wallets with chainCode, use BIP32 derivation (external chain=0) - // For standard wallets created in this app, use HMAC-SHA512 derivation - const addr = wallet.isImportedAlphaWallet && wallet.chainCode - ? generateHDAddressBIP32( - wallet.masterPrivateKey, - wallet.chainCode, - index, - wallet.descriptorPath ? `m/${wallet.descriptorPath}` : undefined, - false // isChange=false - always generate external addresses - ) - : generateAddressFromMasterKey(wallet.masterPrivateKey, index); - - wallet.addresses.push(addr); - - saveWalletToStorage("main", wallet); - return addr; -} diff --git a/src/components/wallet/L1/views/HistoryView.tsx b/src/components/wallet/L1/views/HistoryView.tsx deleted file mode 100644 index 5195297b..00000000 --- a/src/components/wallet/L1/views/HistoryView.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { ArrowLeft } from "lucide-react"; -import { motion } from "framer-motion"; -import type { - TransactionHistoryItem, - TransactionDetail, - Wallet, -} from "../sdk"; - -interface HistoryViewProps { - wallet: Wallet; - selectedAddress: string; - transactions: TransactionHistoryItem[]; - loadingTransactions: boolean; - currentBlockHeight: number; - transactionDetails: Record; - analyzeTransaction: ( - tx: TransactionHistoryItem, - detail: TransactionDetail | undefined, - wallet: Wallet, - selectedAddress?: string - ) => { - direction: string; - amount: number; - fromAddresses: string[]; - toAddresses: string[]; - }; - onBackToMain: () => void; - hideBackButton?: boolean; -} - -function formatTimestamp(time: number | undefined) { - if (!time) return ""; - const date = new Date(time * 1000); - return date.toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -export function HistoryView({ - wallet, - selectedAddress, - transactions, - loadingTransactions, - currentBlockHeight, - transactionDetails, - analyzeTransaction, - onBackToMain, - hideBackButton, -}: HistoryViewProps) { - return ( -
-
- {!hideBackButton && ( -
- - - -

Transaction History

-
- )} - -

- {selectedAddress.slice(0, 10)}...{selectedAddress.slice(-6)} -

-
- -
- {loadingTransactions ? ( - - -

Loading transactions...

-
- ) : transactions.length === 0 ? ( - -

No transactions found

-
- ) : ( -
- {transactions.map((tx, index) => { - const confirmations = - tx.height > 0 && currentBlockHeight > 0 - ? Math.max(0, currentBlockHeight - tx.height + 1) - : 0; - const statusColor = confirmations > 0 ? "#10b981" : "#fbbf24"; - const statusText = - confirmations > 0 - ? `${confirmations} confirmations` - : "Unconfirmed"; - const truncatedTxid = - tx.tx_hash.substring(0, 6) + - "..." + - tx.tx_hash.substring(tx.tx_hash.length - 6); - - const detail = transactionDetails[tx.tx_hash]; - const analysis = analyzeTransaction(tx, detail, wallet, selectedAddress); - const isSent = analysis.direction === "sent"; - const directionText = isSent ? "Sent" : "Received"; - const directionColor = isSent ? "#ef4444" : "#10b981"; - - return ( - -
-
- - {isSent ? "↑" : "↓"} {directionText} - - - {truncatedTxid} - -
-
-
- {isSent ? "-" : ""} - {analysis.amount.toFixed(8)} ALPHA -
-
-
- -
- {statusText} - {tx.height > 0 && ( - <> - - - Block {tx.height} - - - )} - {detail?.blocktime && ( - <> - - - {formatTimestamp(detail.blocktime)} - - - )} -
- - {detail && ( - - )} -
- ); - })} -
- )} -
-
- ); -} diff --git a/src/components/wallet/L1/views/MainWalletView.tsx b/src/components/wallet/L1/views/MainWalletView.tsx deleted file mode 100644 index b3d753a1..00000000 --- a/src/components/wallet/L1/views/MainWalletView.tsx +++ /dev/null @@ -1,379 +0,0 @@ -import { useState, useEffect } from "react"; -import { - ArrowDownLeft, - Send, - Trash2, - Copy, - Download, - History, - ExternalLink, - Check, - ArrowRightLeft, - Plus, -} from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import type { TransactionPlan, VestingBalances } from "../sdk"; -import { - QRModal, - SaveWalletModal, - DeleteConfirmationModal, - TransactionConfirmationModal, - BridgeModal, - SendModal, -} from "../components/modals"; -import { VestingDisplay } from "../components/VestingDisplay"; -import { AddressSelector } from "../../shared/components/AddressSelector"; -import { CreateAddressModal } from "../../shared/modals/CreateAddressModal"; - -// Animated balance display component -function AnimatedBalance({ value, show }: { value: number; show: boolean }) { - const [displayValue, setDisplayValue] = useState(value); - const [isAnimating, setIsAnimating] = useState(false); - - useEffect(() => { - if (value === displayValue) return; - - setIsAnimating(true); - const startValue = displayValue; - const endValue = value; - const duration = 600; - const startTime = Date.now(); - - const animate = () => { - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / duration, 1); - - // Easing function for smooth animation - const easeOutExpo = 1 - Math.pow(2, -10 * progress); - - const currentValue = startValue + (endValue - startValue) * easeOutExpo; - setDisplayValue(Math.round(currentValue * 100) / 100); - - if (progress < 1) { - requestAnimationFrame(animate); - } else { - setDisplayValue(endValue); - setIsAnimating(false); - } - }; - - requestAnimationFrame(animate); - }, [value, displayValue]); - - if (!show) { - return ••••••; - } - - return ( - - {displayValue} ALPHA - - ); -} - -interface MainWalletViewProps { - selectedAddress: string; - selectedPrivateKey: string; - addresses: string[]; - balance: number; - totalBalance: number; - showBalances: boolean; - onShowHistory: () => void; - onSaveWallet: (filename: string, password?: string) => void; - /** Whether mnemonic is available for export */ - hasMnemonic?: boolean; - onDeleteWallet: () => void; - onSendTransaction: (destination: string, amount: string) => Promise; - txPlan: TransactionPlan | null; - isSending: boolean; - onConfirmSend: () => Promise; - vestingBalances?: VestingBalances; -} - -export function MainWalletView({ - selectedAddress, - selectedPrivateKey, - addresses, - balance, - totalBalance, - showBalances, - onShowHistory, - onSaveWallet, - hasMnemonic, - onDeleteWallet, - onSendTransaction, - txPlan, - isSending, - onConfirmSend, - vestingBalances, -}: MainWalletViewProps) { - const [showQR, setShowQR] = useState(false); - const [showConfirmation, setShowConfirmation] = useState(false); - const [showSaveModal, setShowSaveModal] = useState(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [copied, setCopied] = useState(false); - const [showSendModal, setShowSendModal] = useState(false); - const [showBridgeModal, setShowBridgeModal] = useState(false); - const [showCreateAddressModal, setShowCreateAddressModal] = useState(false); - const [pendingDestination, setPendingDestination] = useState(""); - const [pendingAmount, setPendingAmount] = useState(""); - - const handleSendFromModal = async (destination: string, amount: string) => { - setPendingDestination(destination); - setPendingAmount(amount); - await onSendTransaction(destination, amount); - setShowConfirmation(true); - }; - - const handleConfirmSend = async () => { - await onConfirmSend(); - setShowConfirmation(false); - }; - - const handleSave = (filename: string, password?: string) => { - onSaveWallet(filename, password); - setShowSaveModal(false); - }; - - const handleDelete = () => { - onDeleteWallet(); - setShowDeleteModal(false); - }; - - return ( -
- setShowConfirmation(false)} - /> - - setShowQR(false)} - /> - - {/* Address Selector - uses shared component with nametag modal */} -
-
-
- -
- - - - - - - - -
-
- - {/* Balance */} -
-
-

Mainnet Balance

- - - - -
- - - - - - - - {/* Total balance across all addresses */} - {addresses.length > 1 && ( -

- Total ({addresses.length} addresses):{" "} - - {showBalances ? `${totalBalance} ALPHA` : "••••••"} - -

- )} -
- - {/* Vesting Display */} -
- -
- - {/* Action Buttons */} -
-
- setShowQR(true)} - whileHover={{ scale: 1.02, y: -2 }} - whileTap={{ scale: 0.98 }} - className="relative px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg sm:rounded-xl bg-linear-to-br from-blue-500 to-blue-600 text-white text-xs sm:text-sm shadow-xl shadow-blue-500/20 flex items-center justify-center gap-1.5 sm:gap-2 overflow-hidden group" - > -
- - Receive - - - setShowSendModal(true)} - className="relative px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg sm:rounded-xl bg-linear-to-br from-green-600 to-green-700 text-white text-xs sm:text-sm shadow-xl shadow-green-500/20 flex items-center justify-center gap-1.5 sm:gap-2 overflow-hidden group" - > -
- - Send - -
-
- - {/* Bridge Button */} -
- setShowBridgeModal(true)} - whileHover={{ scale: 1.01, y: -1 }} - whileTap={{ scale: 0.99 }} - className="w-full relative px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg sm:rounded-xl bg-linear-to-r from-purple-600/80 to-blue-600/80 text-white text-xs sm:text-sm border border-purple-500/30 flex items-center justify-center gap-1.5 sm:gap-2 overflow-hidden group hover:from-purple-500/80 hover:to-blue-500/80 transition-all" - > -
- - Bridge to L3 - (Demo) - -

- Clone L1 ALPHA tokens to L3 ALPHT for testing -

-
- - {/* Transaction History Button */} -
- - - Transaction History - -
- - - setShowSaveModal(true)} - className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-neutral-500 hover:text-blue-400 transition-colors group" - > - - - - Backup Wallet - - - setShowDeleteModal(true)} - className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-neutral-500 hover:text-red-400 transition-colors group" - > - - - - Delete Wallet - - - - setShowSaveModal(false)} - hasMnemonic={hasMnemonic} - /> - - { - setShowDeleteModal(false); - setShowSaveModal(true); - }} - onCancel={() => setShowDeleteModal(false)} - /> - - setShowBridgeModal(false)} - /> - - setShowSendModal(false)} - onSend={handleSendFromModal} - vestingBalances={vestingBalances} - /> - - setShowCreateAddressModal(false)} - /> -
- ); -} diff --git a/src/components/wallet/L1/views/index.ts b/src/components/wallet/L1/views/index.ts index a19202d0..7b40d7ef 100644 --- a/src/components/wallet/L1/views/index.ts +++ b/src/components/wallet/L1/views/index.ts @@ -1,2 +1 @@ -export { HistoryView } from "./HistoryView"; -export { MainWalletView } from "./MainWalletView"; +// L1 views barrel - legacy views removed, see L1WalletModal for current L1 UI diff --git a/src/components/wallet/L3/components/L1BalanceDisplay.tsx b/src/components/wallet/L3/components/L1BalanceDisplay.tsx index 1f6e00a5..47b73731 100644 --- a/src/components/wallet/L3/components/L1BalanceDisplay.tsx +++ b/src/components/wallet/L3/components/L1BalanceDisplay.tsx @@ -1,6 +1,7 @@ import { Loader2, ChevronRight } from 'lucide-react'; import { motion } from 'framer-motion'; -import { useL1Wallet } from '../../L1/hooks/useL1Wallet'; +import { useL1Balance } from '../../../../sdk/hooks/l1/useL1Balance'; +import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; interface L1BalanceDisplayProps { showBalances: boolean; @@ -8,15 +9,18 @@ interface L1BalanceDisplayProps { } export function L1BalanceDisplay({ showBalances, onClick }: L1BalanceDisplayProps) { - const { totalBalance, isLoadingBalance, wallet } = useL1Wallet(); + const { isInitialized } = useSphereContext(); + const { balance, isLoading } = useL1Balance(); - // Don't render if no wallet loaded - if (!wallet) { + // Don't render if wallet not initialized + if (!isInitialized) { return null; } - const formatBalance = (balance: number) => { - return balance.toLocaleString('en-US', { + const totalAlpha = balance ? Number(balance.total) / 1e8 : 0; + + const formatBalance = (val: number) => { + return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8, }); @@ -34,11 +38,11 @@ export function L1BalanceDisplay({ showBalances, onClick }: L1BalanceDisplayProp L1:
- {isLoadingBalance ? ( + {isLoading ? ( ) : ( - {showBalances ? `${formatBalance(totalBalance)} ALPHA` : '••••••'} + {showBalances ? `${formatBalance(totalAlpha)} ALPHA` : '••••••'} )} diff --git a/src/components/wallet/L3/components/SyncModeSelector.tsx b/src/components/wallet/L3/components/SyncModeSelector.tsx deleted file mode 100644 index f5d7d4a8..00000000 --- a/src/components/wallet/L3/components/SyncModeSelector.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Sync Mode Selector Component - * - * Displays current sync mode and provides controls for LOCAL mode recovery. - * Per TOKEN_INVENTORY_SPEC.md Section 10.7 and Section 12 - */ - -import { useState, useEffect, useCallback } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Cloud, CloudOff, RefreshCw, Clock, AlertTriangle, Zap } from 'lucide-react'; -import type { SyncMode, CircuitBreakerState, SyncResult } from '../types/SyncTypes'; - -interface SyncModeSelectorProps { - /** Current sync mode */ - mode: SyncMode; - /** Circuit breaker state for LOCAL mode recovery */ - circuitBreaker?: CircuitBreakerState; - /** Last sync result for status display */ - lastSyncResult?: SyncResult | null; - /** Whether a sync is currently in progress */ - isSyncing: boolean; - /** Callback to trigger manual sync/retry */ - onRetrySync?: () => Promise; - /** Compact display mode for header */ - compact?: boolean; -} - -/** - * Mode configuration for display - */ -const MODE_CONFIG: Record = { - NORMAL: { - label: 'Synced', - icon: Cloud, - color: 'text-green-400', - bgColor: 'bg-green-500/10', - description: 'Full sync with IPFS' - }, - FAST: { - label: 'Quick Sync', - icon: Zap, - color: 'text-yellow-400', - bgColor: 'bg-yellow-500/10', - description: 'Fast sync (skipping spent detection)' - }, - NAMETAG: { - label: 'Nametag Only', - icon: Cloud, - color: 'text-blue-400', - bgColor: 'bg-blue-500/10', - description: 'Fetching nametag only' - }, - LOCAL: { - label: 'Offline', - icon: CloudOff, - color: 'text-orange-400', - bgColor: 'bg-orange-500/10', - description: 'Changes saved locally only' - } -}; - -/** - * Format time remaining for countdown display - */ -function formatTimeRemaining(ms: number): string { - if (ms <= 0) return 'now'; - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - return `${seconds}s`; -} - -export function SyncModeSelector({ - mode, - circuitBreaker, - lastSyncResult, - isSyncing, - onRetrySync, - compact = false -}: SyncModeSelectorProps) { - const [isRetrying, setIsRetrying] = useState(false); - const [timeUntilRetry, setTimeUntilRetry] = useState(null); - - // Update countdown timer for LOCAL mode auto-recovery - useEffect(() => { - if (circuitBreaker?.localModeActive && circuitBreaker.nextRecoveryAttempt) { - const updateTimer = () => { - const remaining = circuitBreaker.nextRecoveryAttempt! - Date.now(); - setTimeUntilRetry(remaining > 0 ? remaining : 0); - }; - - updateTimer(); - const interval = setInterval(updateTimer, 1000); - return () => clearInterval(interval); - } else { - setTimeUntilRetry(null); - } - }, [circuitBreaker?.localModeActive, circuitBreaker?.nextRecoveryAttempt]); - - const handleRetrySync = useCallback(async () => { - if (!onRetrySync || isRetrying || isSyncing) return; - setIsRetrying(true); - try { - await onRetrySync(); - } finally { - setIsRetrying(false); - } - }, [onRetrySync, isRetrying, isSyncing]); - - const config = MODE_CONFIG[mode]; - const Icon = config.icon; - const isLocalMode = mode === 'LOCAL'; - const showRetryButton = isLocalMode && onRetrySync && !isSyncing; - - // Compact mode - just show an icon with tooltip - if (compact) { - return ( -
- - {isSyncing ? ( - - ) : ( - - )} - - {isSyncing ? 'Syncing' : config.label} - - - - {/* Tooltip */} -
-
-
{config.description}
- {isLocalMode && timeUntilRetry !== null && ( -
- - Auto-retry in {formatTimeRemaining(timeUntilRetry)} -
- )} -
-
-
- ); - } - - // Full mode - expanded card - return ( -
-
-
- - {isSyncing ? ( - - ) : ( - - )} - - -
-
- - {isSyncing ? 'Syncing...' : config.label} - - {lastSyncResult?.ipnsPublishPending && !isLocalMode && ( - - - IPNS pending - - )} -
-

{config.description}

-
-
- - {/* Retry button for LOCAL mode */} - - {showRetryButton && ( - - - {isRetrying ? 'Retrying...' : 'Retry IPFS'} - - )} - -
- - {/* LOCAL mode recovery info */} - {isLocalMode && circuitBreaker && ( -
-
-
- {circuitBreaker.consecutiveIpfsFailures > 0 && ( - IPFS failures: {circuitBreaker.consecutiveIpfsFailures} - )} - {circuitBreaker.consecutiveConflicts > 0 && ( - Conflicts: {circuitBreaker.consecutiveConflicts} - )} -
- {timeUntilRetry !== null && ( -
- - Auto-retry in {formatTimeRemaining(timeUntilRetry)} -
- )} -
-
- )} - - {/* Last sync result stats */} - {lastSyncResult && !isSyncing && lastSyncResult.status !== 'ERROR' && ( -
-
-
Imported
-
{lastSyncResult.operationStats.tokensImported}
-
-
-
Updated
-
{lastSyncResult.operationStats.tokensUpdated}
-
-
-
Validated
-
{lastSyncResult.operationStats.tokensValidated}
-
-
- )} -
- ); -} - -export default SyncModeSelector; diff --git a/src/components/wallet/L3/components/SyncProgressIndicator.tsx b/src/components/wallet/L3/components/SyncProgressIndicator.tsx deleted file mode 100644 index f0d52016..00000000 --- a/src/components/wallet/L3/components/SyncProgressIndicator.tsx +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Sync Progress Indicator Component - * - * Shows sync progress and user notifications. - * Per TOKEN_INVENTORY_SPEC.md Section 10.3 (User Notifications) - */ - -import { useState, useEffect, useCallback } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { - Loader2, - CheckCircle2, - XCircle, - AlertTriangle, - CloudOff, - WifiOff, - X, - RefreshCw, - Upload, - Download, - Shield, - Search -} from 'lucide-react'; -import type { SyncResult, SyncErrorCode } from '../types/SyncTypes'; - -interface SyncProgressIndicatorProps { - /** Last sync result */ - lastSyncResult?: SyncResult | null; - /** Whether sync is in progress */ - isSyncing: boolean; - /** Current sync step (0-10) */ - currentStep?: number; - /** Auto-dismiss success notifications after ms (0 = no auto-dismiss) */ - autoDismissMs?: number; - /** Callback when user dismisses notification */ - onDismiss?: () => void; - /** Show as minimal inline indicator */ - inline?: boolean; -} - -/** - * Sync step descriptions for progress display - */ -const SYNC_STEPS = [ - { step: 1, label: 'Loading local data', icon: Download }, - { step: 2, label: 'Fetching from IPFS', icon: CloudOff }, - { step: 3, label: 'Normalizing proofs', icon: Shield }, - { step: 4, label: 'Validating commitments', icon: Shield }, - { step: 5, label: 'Validating tokens', icon: Shield }, - { step: 6, label: 'Deduplicating', icon: Search }, - { step: 7, label: 'Checking spent status', icon: Search }, - { step: 8, label: 'Merging inventory', icon: RefreshCw }, - { step: 9, label: 'Preparing upload', icon: Upload }, - { step: 10, label: 'Publishing to IPFS', icon: Upload }, -]; - -/** - * Status-based notification config - */ -interface NotificationConfig { - title: string; - message: string; - icon: typeof CheckCircle2; - color: string; - bgColor: string; - borderColor: string; -} - -function getNotificationConfig(result: SyncResult): NotificationConfig { - switch (result.status) { - case 'SUCCESS': - return { - title: 'Sync Complete', - message: `${result.operationStats.tokensImported} tokens imported, ${result.operationStats.tokensValidated} validated`, - icon: CheckCircle2, - color: 'text-green-400', - bgColor: 'bg-green-500/10', - borderColor: 'border-green-500/30' - }; - case 'PARTIAL_SUCCESS': - return { - title: 'Sync Pending', - message: 'Changes saved locally. IPFS publish will retry automatically.', - icon: AlertTriangle, - color: 'text-yellow-400', - bgColor: 'bg-yellow-500/10', - borderColor: 'border-yellow-500/30' - }; - case 'LOCAL_ONLY': - return { - title: 'Offline Mode', - message: 'Changes saved locally only. Some features unavailable.', - icon: CloudOff, - color: 'text-orange-400', - bgColor: 'bg-orange-500/10', - borderColor: 'border-orange-500/30' - }; - case 'NAMETAG_ONLY': - return { - title: 'Nametag Loaded', - message: 'Minimal sync completed', - icon: CheckCircle2, - color: 'text-blue-400', - bgColor: 'bg-blue-500/10', - borderColor: 'border-blue-500/30' - }; - case 'ERROR': - return getErrorNotificationConfig(result.errorCode, result.errorMessage); - default: - return { - title: 'Unknown Status', - message: result.errorMessage || 'Sync completed with unknown status', - icon: AlertTriangle, - color: 'text-gray-400', - bgColor: 'bg-gray-500/10', - borderColor: 'border-gray-500/30' - }; - } -} - -function getErrorNotificationConfig(errorCode?: SyncErrorCode, errorMessage?: string): NotificationConfig { - switch (errorCode) { - case 'IPFS_UNAVAILABLE': - case 'IPNS_RESOLUTION_FAILED': - return { - title: 'IPFS Unavailable', - message: 'Unable to connect to IPFS. Working in offline mode.', - icon: CloudOff, - color: 'text-orange-400', - bgColor: 'bg-orange-500/10', - borderColor: 'border-orange-500/30' - }; - case 'IPNS_PUBLISH_FAILED': - return { - title: 'Sync Pending', - message: 'Tokens saved locally. IPFS publish will retry.', - icon: AlertTriangle, - color: 'text-yellow-400', - bgColor: 'bg-yellow-500/10', - borderColor: 'border-yellow-500/30' - }; - case 'INTEGRITY_FAILURE': - return { - title: 'Critical Error', - message: 'Data integrity issue detected. Please contact support.', - icon: XCircle, - color: 'text-red-400', - bgColor: 'bg-red-500/10', - borderColor: 'border-red-500/30' - }; - case 'AGGREGATOR_UNREACHABLE': - return { - title: 'Network Error', - message: 'Unable to reach Unicity aggregator. Will retry.', - icon: WifiOff, - color: 'text-orange-400', - bgColor: 'bg-orange-500/10', - borderColor: 'border-orange-500/30' - }; - default: - return { - title: 'Sync Error', - message: errorMessage || 'An error occurred during sync', - icon: XCircle, - color: 'text-red-400', - bgColor: 'bg-red-500/10', - borderColor: 'border-red-500/30' - }; - } -} - -export function SyncProgressIndicator({ - lastSyncResult, - isSyncing, - currentStep = 0, - autoDismissMs = 5000, - onDismiss, - inline = false -}: SyncProgressIndicatorProps) { - const [isVisible, setIsVisible] = useState(false); - const [displayedResult, setDisplayedResult] = useState(null); - - // Show notification when sync completes or result changes - useEffect(() => { - if (lastSyncResult && !isSyncing) { - setDisplayedResult(lastSyncResult); - setIsVisible(true); - - // Auto-dismiss success notifications - if (autoDismissMs > 0 && lastSyncResult.status === 'SUCCESS') { - const timer = setTimeout(() => { - setIsVisible(false); - }, autoDismissMs); - return () => clearTimeout(timer); - } - } - }, [lastSyncResult, isSyncing, autoDismissMs]); - - // Show syncing state - useEffect(() => { - if (isSyncing) { - setIsVisible(true); - } - }, [isSyncing]); - - const handleDismiss = useCallback(() => { - setIsVisible(false); - onDismiss?.(); - }, [onDismiss]); - - // Inline mode - minimal indicator - if (inline) { - if (isSyncing) { - const stepInfo = SYNC_STEPS.find(s => s.step === currentStep) || SYNC_STEPS[0]; - return ( -
- - {stepInfo.label}... -
- ); - } - - if (displayedResult && isVisible) { - const config = getNotificationConfig(displayedResult); - const Icon = config.icon; - return ( -
- - {config.title} -
- ); - } - - return null; - } - - // Full notification mode - return ( - - {isVisible && ( - - {isSyncing ? ( - // Syncing progress card -
-
- -
-
Syncing...
-
- Step {currentStep}/10 -
-
-
- - {/* Progress bar */} -
- -
- - {/* Current step label */} - {currentStep > 0 && currentStep <= 10 && ( -
- {SYNC_STEPS[currentStep - 1]?.label}... -
- )} -
- ) : displayedResult ? ( - // Result notification card - (() => { - const config = getNotificationConfig(displayedResult); - const Icon = config.icon; - return ( -
-
-
- -
-
{config.title}
-
{config.message}
- - {/* Stats for successful sync */} - {displayedResult.status === 'SUCCESS' && displayedResult.inventoryStats && ( -
- {displayedResult.inventoryStats.activeTokens} active - {displayedResult.inventoryStats.sentTokens} sent - {displayedResult.inventoryStats.outboxTokens} pending -
- )} - - {/* Duration */} -
- Completed in {(displayedResult.syncDurationMs / 1000).toFixed(1)}s -
-
-
- - {/* Dismiss button */} - -
-
- ); - })() - ) : null} -
- )} -
- ); -} - -export default SyncProgressIndicator; diff --git a/src/components/wallet/L3/components/index.ts b/src/components/wallet/L3/components/index.ts index 75f251cb..fba61337 100644 --- a/src/components/wallet/L3/components/index.ts +++ b/src/components/wallet/L3/components/index.ts @@ -1,8 +1,3 @@ /** * L3 Wallet UI Components - * - * Re-exports all UI components for token inventory management */ - -export { SyncModeSelector } from './SyncModeSelector'; -export { SyncProgressIndicator } from './SyncProgressIndicator'; diff --git a/src/components/wallet/L3/data/model/index.ts b/src/components/wallet/L3/data/model/index.ts deleted file mode 100644 index 293f9894..00000000 --- a/src/components/wallet/L3/data/model/index.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; - -// ========================================== -// 1. Enums & Constants -// ========================================== - -export const TokenStatus = { - PENDING: 'PENDING', - SUBMITTED: 'SUBMITTED', - TRANSFERRED: 'TRANSFERRED', - CONFIRMED: 'CONFIRMED', - BURNED: 'BURNED', - FAILED: 'FAILED' -} as const; - -export type TokenStatus = typeof TokenStatus[keyof typeof TokenStatus]; - -export const TransactionType = { - RECEIVED: 'RECEIVED', - SENT: 'SENT' -} as const; - -export type TransactionType = typeof TransactionType[keyof typeof TransactionType]; - -// ========================================== -// 2. Token Model -// ========================================== - -export class Token { - id: string; - name: string; - type: string; - timestamp: number; - unicityAddress?: string; - jsonData?: string; - sizeBytes: number; - status: TokenStatus; - transactionId?: string; - isOfflineTransfer: boolean; - pendingOfflineData?: string; - amount?: string; // String to hold BigInteger safe - coinId?: string; - symbol?: string; - iconUrl?: string; - transferredAt?: number; - splitSourceTokenId?: string; - splitSentAmount?: string; - senderPubkey?: string; // Pubkey of sender (for received tokens) - - constructor(data: Partial) { - this.id = data.id || uuidv4(); - this.name = data.name || "Unknown Token"; - this.type = data.type || "Unknown"; - this.timestamp = data.timestamp || Date.now(); - this.unicityAddress = data.unicityAddress; - this.jsonData = data.jsonData; - this.sizeBytes = data.sizeBytes || 0; - this.status = data.status || TokenStatus.CONFIRMED; - this.transactionId = data.transactionId; - this.isOfflineTransfer = data.isOfflineTransfer || false; - this.pendingOfflineData = data.pendingOfflineData; - this.amount = data.amount; - this.coinId = data.coinId; - this.symbol = data.symbol; - this.iconUrl = data.iconUrl; - this.transferredAt = data.transferredAt; - this.splitSourceTokenId = data.splitSourceTokenId; - this.splitSentAmount = data.splitSentAmount; - this.senderPubkey = data.senderPubkey; - } - - getFormattedSize(): string { - if (this.sizeBytes < 1024) return `${this.sizeBytes}B`; - if (this.sizeBytes < 1024 * 1024) return `${Math.floor(this.sizeBytes / 1024)}KB`; - return `${Math.floor(this.sizeBytes / (1024 * 1024))}MB`; - } - - getAmountAsBigInteger(): bigint | null { - try { - return this.amount ? BigInt(this.amount) : null; - } catch { - return null; - } - } -} - -// ========================================== -// 3. Transaction Event -// ========================================== - -export class TransactionEvent { - token: Token; - type: TransactionType; - timestamp: number; - - constructor(token: Token, type: TransactionType, timestamp: number) { - this.token = token; - this.type = type; - this.timestamp = timestamp; - } -} - -// ========================================== -// 6. User Identity & Wallet -// ========================================== - -/** - * User identity for L3 Unicity wallet. - * - * NOTE: The wallet address is derived using UnmaskedPredicateReference (no nonce/salt). - * This creates a stable, reusable DirectAddress from publicKey + tokenType. - */ -export interface UserIdentity { - privateKey: string; - publicKey: string; - address: string; - nametag?: string; // Optional field for local storage convenience -} - -export class Wallet { - id: string; - name: string; - address: string; - tokens: Token[]; - - constructor(id: string, name: string, address: string, tokens: Token[] = []) { - this.id = id; - this.name = name; - this.address = address; - this.tokens = tokens; - } -} - -export const PaymentRequestStatus = { - PENDING: 'PENDING', - ACCEPTED: 'ACCEPTED', - REJECTED: 'REJECTED', - PAID: 'PAID' -} as const - -export type PaymentRequestStatus = typeof PaymentRequestStatus[keyof typeof PaymentRequestStatus]; - -export interface IncomingPaymentRequest { - id: string; - senderPubkey: string; - amount: bigint; - coinId: string; - symbol: string; - message?: string; - recipientNametag: string; - requestId: string; - timestamp: number; - status: PaymentRequestStatus; -} \ No newline at end of file diff --git a/src/components/wallet/L3/hooks/useIncomingPaymentRequests.ts b/src/components/wallet/L3/hooks/useIncomingPaymentRequests.ts index 0232e948..394d47a0 100644 --- a/src/components/wallet/L3/hooks/useIncomingPaymentRequests.ts +++ b/src/components/wallet/L3/hooks/useIncomingPaymentRequests.ts @@ -1,8 +1,29 @@ import { useState, useEffect, useCallback } from 'react'; -import { type IncomingPaymentRequest, PaymentRequestStatus } from '../data/model/'; import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; import type { IncomingPaymentRequest as SDKPaymentRequest } from '@unicitylabs/sphere-sdk'; +export const PaymentRequestStatus = { + PENDING: 'PENDING', + ACCEPTED: 'ACCEPTED', + REJECTED: 'REJECTED', + PAID: 'PAID' +} as const; + +export type PaymentRequestStatus = typeof PaymentRequestStatus[keyof typeof PaymentRequestStatus]; + +export interface IncomingPaymentRequest { + id: string; + senderPubkey: string; + amount: bigint; + coinId: string; + symbol: string; + message?: string; + recipientNametag: string; + requestId: string; + timestamp: number; + status: PaymentRequestStatus; +} + /** Bridge SDK payment request to legacy IncomingPaymentRequest model */ function bridgeRequest(sdk: SDKPaymentRequest): IncomingPaymentRequest { return { diff --git a/src/components/wallet/L3/hooks/useInventorySync.ts b/src/components/wallet/L3/hooks/useInventorySync.ts deleted file mode 100644 index f2f7c888..00000000 --- a/src/components/wallet/L3/hooks/useInventorySync.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * React Hook for Inventory Sync Operations - * - * Provides integration between InventorySyncService and React components. - * Per TOKEN_INVENTORY_SPEC.md Section 6 and Section 12 - */ - -import { useState, useEffect, useCallback } from 'react'; -import { inventorySync, type SyncParams } from '../services/InventorySyncService'; -import type { SyncResult, SyncMode, CircuitBreakerState } from '../types/SyncTypes'; -import { IdentityManager } from '../services/IdentityManager'; - -// Session key (same as useWallet.ts) -const SESSION_KEY = 'user-pin-1234'; - -// Event name for sync state changes -const SYNC_STATE_EVENT = 'inventory-sync-state'; - -export interface SyncState { - /** Whether a sync is currently in progress */ - isSyncing: boolean; - /** Current sync step (1-10) */ - currentStep: number; - /** Current sync mode */ - mode: SyncMode; - /** Last sync result */ - lastResult: SyncResult | null; - /** Circuit breaker state */ - circuitBreaker: CircuitBreakerState | null; - /** Error message if sync failed */ - error: string | null; -} - -interface SyncStateEvent { - isSyncing: boolean; - currentStep: number; - mode: SyncMode; -} - -/** - * Hook for inventory sync operations - * - * @returns Sync state and control functions - */ -export function useInventorySync() { - const identityManager = IdentityManager.getInstance(SESSION_KEY); - - const [syncState, setSyncState] = useState({ - isSyncing: false, - currentStep: 0, - mode: 'NORMAL', - lastResult: null, - circuitBreaker: null, - error: null - }); - - // Listen for sync state events from other tabs/components - useEffect(() => { - const handleSyncState = (e: CustomEvent) => { - setSyncState(prev => ({ - ...prev, - isSyncing: e.detail.isSyncing, - currentStep: e.detail.currentStep, - mode: e.detail.mode - })); - }; - - window.addEventListener(SYNC_STATE_EVENT, handleSyncState as EventListener); - return () => { - window.removeEventListener(SYNC_STATE_EVENT, handleSyncState as EventListener); - }; - }, []); - - /** - * Emit sync state change event - */ - const emitSyncState = useCallback((state: SyncStateEvent) => { - window.dispatchEvent(new CustomEvent(SYNC_STATE_EVENT, { detail: state })); - }, []); - - /** - * Trigger a sync operation - * - * @param params - Optional sync parameters - * @returns Sync result - */ - const triggerSync = useCallback(async (params?: Partial): Promise => { - // Use functional state update to avoid stale closure - let shouldProceed = true; - setSyncState(prev => { - if (prev.isSyncing) { - console.warn('[useInventorySync] Sync already in progress'); - shouldProceed = false; - return prev; - } - return prev; - }); - - if (!shouldProceed) { - return null; - } - - const identity = await identityManager.getCurrentIdentity(); - if (!identity) { - console.warn('[useInventorySync] No identity available'); - setSyncState(prev => ({ - ...prev, - error: 'No identity available' - })); - return null; - } - - // Determine sync mode - const hasIncoming = params?.incomingTokens && params.incomingTokens.length > 0; - const hasOutbox = params?.outboxTokens && params.outboxTokens.length > 0; - const mode: SyncMode = params?.local ? 'LOCAL' : - params?.nametag ? 'NAMETAG' : - (hasIncoming || hasOutbox) ? 'FAST' : 'NORMAL'; - - setSyncState(prev => ({ - ...prev, - isSyncing: true, - currentStep: 1, - mode, - error: null - })); - - emitSyncState({ isSyncing: true, currentStep: 1, mode }); - - try { - const syncParams: SyncParams = { - address: identity.address, - publicKey: identity.publicKey, - ipnsName: identity.ipnsName || '', - ...params - }; - - const result = await inventorySync(syncParams); - - setSyncState(prev => ({ - ...prev, - isSyncing: false, - currentStep: 0, - lastResult: result, - circuitBreaker: result.circuitBreaker || null, - error: result.status === 'ERROR' ? result.errorMessage || 'Sync failed' : null - })); - - emitSyncState({ isSyncing: false, currentStep: 0, mode: result.syncMode }); - - // NOTE: Don't invalidate queries here - InventorySyncService.dispatchWalletUpdated() - // already calls invalidateWalletQueries(). Duplicate invalidation causes cascading - // refetches and infinite loops when multiple useWallet instances are mounted. - - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - console.error('[useInventorySync] Sync failed:', error); - - setSyncState(prev => ({ - ...prev, - isSyncing: false, - currentStep: 0, - error: errorMessage - })); - - emitSyncState({ isSyncing: false, currentStep: 0, mode }); - return null; - } - }, [identityManager, emitSyncState]); - - /** - * Trigger NORMAL mode sync (full validation) - */ - const syncNormal = useCallback(async (): Promise => { - return triggerSync(); - }, [triggerSync]); - - /** - * Trigger LOCAL mode sync (skip IPFS) - */ - const syncLocal = useCallback(async (): Promise => { - return triggerSync({ local: true }); - }, [triggerSync]); - - /** - * Trigger NAMETAG mode sync (minimal fetch) - */ - const syncNametag = useCallback(async (): Promise => { - return triggerSync({ nametag: true }); - }, [triggerSync]); - - /** - * Retry IPFS sync (for LOCAL mode recovery) - */ - const retryIpfsSync = useCallback(async (): Promise => { - // Clear LOCAL mode flag and try NORMAL sync - return triggerSync(); - }, [triggerSync]); - - /** - * Cancel ongoing sync - * NOTE: Currently only resets UI state. Actual sync operations - * cannot be cancelled until inventorySync supports abort signals. - */ - const cancelSync = useCallback(() => { - setSyncState(prev => { - if (!prev.isSyncing) return prev; - emitSyncState({ isSyncing: false, currentStep: 0, mode: prev.mode }); - return { - ...prev, - isSyncing: false, - currentStep: 0 - }; - }); - }, [emitSyncState]); - - return { - // State - isSyncing: syncState.isSyncing, - currentStep: syncState.currentStep, - mode: syncState.mode, - lastResult: syncState.lastResult, - circuitBreaker: syncState.circuitBreaker, - error: syncState.error, - - // Actions - triggerSync, - syncNormal, - syncLocal, - syncNametag, - retryIpfsSync, - cancelSync - }; -} - -export default useInventorySync; diff --git a/src/components/wallet/L3/hooks/useIpfsStorage.ts b/src/components/wallet/L3/hooks/useIpfsStorage.ts deleted file mode 100644 index d01284c1..00000000 --- a/src/components/wallet/L3/hooks/useIpfsStorage.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { useEffect, useState, useCallback } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { - IpfsStorageService, - type StorageResult, - type RestoreResult, - type StorageEvent, - type StorageStatus, -} from "../services/IpfsStorageService"; -import { IdentityManager } from "../services/IdentityManager"; -import { addToken, getTokensForAddress, removeToken, setNametagForAddress } from "../services/InventorySyncService"; -import { getTokenValidationService } from "../services/TokenValidationService"; -import type { Token } from "../data/model"; -import { tokenToTxf, getCurrentStateHash } from "../services/TxfSerializer"; - -// Query keys -export const IPFS_STORAGE_KEYS = { - STATUS: ["ipfs", "storage", "status"], - IPNS_NAME: ["ipfs", "storage", "ipnsName"], -}; - -// Session key (same as useWallet.ts) -const SESSION_KEY = "user-pin-1234"; -const identityManager = IdentityManager.getInstance(SESSION_KEY); - -/** - * React hook for IPFS storage operations - * Provides storage status, manual sync/restore, and event listening - */ -export function useIpfsStorage() { - const queryClient = useQueryClient(); - const [lastEvent, setLastEvent] = useState(null); - const [isServiceReady, setIsServiceReady] = useState(false); - const [isSyncingRealtime, setIsSyncingRealtime] = useState(false); - - // Check if IPFS is disabled via environment variable - const isEnabled = import.meta.env.VITE_ENABLE_IPFS !== 'false'; - - // 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" || - e.detail.type === "storage:failed" - ) { - queryClient.invalidateQueries({ queryKey: IPFS_STORAGE_KEYS.STATUS }); - } - }; - - window.addEventListener( - "ipfs-storage-event", - handleEvent as EventListener - ); - return () => { - window.removeEventListener( - "ipfs-storage-event", - handleEvent as EventListener - ); - }; - }, [queryClient]); - - // Mark service as ready on mount - // NOTE: Auto-sync is now handled by useWallet.ts via InventorySyncService.inventorySync() - // This hook only provides UI state and manual sync operations - useEffect(() => { - if (!storageService) return; - 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 - }; - }, [storageService]); - - // Query: Storage status - const statusQuery = useQuery({ - queryKey: IPFS_STORAGE_KEYS.STATUS, - queryFn: (): StorageStatus => storageService!.getStatus(), - refetchInterval: 30000, // Refresh every 30 seconds - enabled: isServiceReady && !!storageService, - }); - - // Query: IPNS name - const ipnsNameQuery = useQuery({ - queryKey: IPFS_STORAGE_KEYS.IPNS_NAME, - queryFn: () => storageService!.getIpnsName(), - staleTime: Infinity, // IPNS name is deterministic, doesn't change - enabled: isServiceReady && !!storageService, - }); - - // Mutation: Manual sync - const syncMutation = useMutation({ - mutationFn: (): Promise => storageService!.syncNow(), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: IPFS_STORAGE_KEYS.STATUS }); - }, - }); - - // Mutation: Restore from CID - const restoreMutation = useMutation({ - mutationFn: async (cid: string): Promise => { - const result = await storageService!.restore(cid); - - // If successful, add tokens to wallet and restore nametag - if (result.success && result.tokens) { - // Get identity context for InventorySyncService - const identity = await identityManager.getCurrentIdentity(); - if (!identity?.address || !identity?.publicKey || !identity?.ipnsName) { - console.error(`📦 Cannot restore: missing identity context`); - return result; - } - - // Add tokens using InventorySyncService - for (const token of result.tokens) { - await addToken(identity.address, identity.publicKey, identity.ipnsName, token, { local: true }); - } - - // Restore nametag if present - if (result.nametag) { - setNametagForAddress(identity.address, result.nametag); - } - - // CRITICAL: Validate restored tokens against aggregator to detect spent tokens - // that bypassed tombstone checks (e.g., tokens with different state hashes) - console.log(`📦 Running post-restore spent token validation...`); - const validationService = getTokenValidationService(); - const allTokens = await getTokensForAddress(identity.address); - const validationResult = await validationService.checkSpentTokens(allTokens, identity.publicKey); - - if (validationResult.spentTokens.length > 0) { - console.log(`📦 Found ${validationResult.spentTokens.length} spent token(s) during restore validation:`); - for (const spent of validationResult.spentTokens) { - console.log(`📦 - Removing spent token ${spent.tokenId.slice(0, 8)}...`); - // Find the actual token from localStorage using localId - const token = allTokens.find(t => t.id === spent.localId); - if (token) { - const txf = tokenToTxf(token); - if (txf) { - const stateHash = getCurrentStateHash(txf); - if (stateHash) { - await removeToken(identity.address, identity.publicKey, identity.ipnsName, spent.localId, stateHash); - } - } - } - } - window.dispatchEvent(new Event("wallet-updated")); - } else { - console.log(`📦 Post-restore validation: all ${allTokens.length} token(s) are valid`); - } - } - - return result; - }, - onSuccess: (result) => { - if (result.success) { - // Invalidate wallet queries to refresh UI - queryClient.invalidateQueries({ queryKey: ["wallet"] }); - } - }, - }); - - // Mutation: Restore from last known CID - const restoreFromLastMutation = useMutation({ - mutationFn: async (): Promise => { - const result = await storageService!.restoreFromLastCid(); - - // If successful, add tokens to wallet - if (result.success && result.tokens) { - // Get identity context for InventorySyncService - const identity = await identityManager.getCurrentIdentity(); - if (!identity?.address || !identity?.publicKey || !identity?.ipnsName) { - console.error(`📦 Cannot restore: missing identity context`); - return result; - } - - // Add tokens using InventorySyncService - for (const token of result.tokens) { - await addToken(identity.address, identity.publicKey, identity.ipnsName, token, { local: true }); - } - - // Restore nametag if present - if (result.nametag) { - setNametagForAddress(identity.address, result.nametag); - } - - // CRITICAL: Validate restored tokens against aggregator to detect spent tokens - // that bypassed tombstone checks (e.g., tokens with different state hashes) - console.log(`📦 Running post-restore spent token validation...`); - const validationService = getTokenValidationService(); - const allTokens = await getTokensForAddress(identity.address); - const validationResult = await validationService.checkSpentTokens(allTokens, identity.publicKey); - - if (validationResult.spentTokens.length > 0) { - console.log(`📦 Found ${validationResult.spentTokens.length} spent token(s) during restore validation:`); - for (const spent of validationResult.spentTokens) { - console.log(`📦 - Removing spent token ${spent.tokenId.slice(0, 8)}...`); - // Find the actual token from localStorage using localId - const token = allTokens.find(t => t.id === spent.localId); - if (token) { - const txf = tokenToTxf(token); - if (txf) { - const stateHash = getCurrentStateHash(txf); - if (stateHash) { - await removeToken(identity.address, identity.publicKey, identity.ipnsName, spent.localId, stateHash); - } - } - } - } - window.dispatchEvent(new Event("wallet-updated")); - } else { - console.log(`📦 Post-restore validation: all ${allTokens.length} token(s) are valid`); - } - } - - return result; - }, - onSuccess: (result) => { - if (result.success) { - queryClient.invalidateQueries({ queryKey: ["wallet"] }); - } - }, - }); - - // Mutation: Export as TXF - const exportTxfMutation = useMutation({ - mutationFn: async (): Promise<{ success: boolean; data?: string; filename?: string; error?: string }> => { - const result = await storageService!.exportAsTxf(); - - // If successful, trigger browser download - if (result.success && result.data && result.filename) { - const blob = new Blob([result.data], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = result.filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - return result; - }, - }); - - // Mutation: Import from TXF file - const importTxfMutation = useMutation({ - mutationFn: async (content: string): Promise<{ - success: boolean; - tokens?: Token[]; - imported?: number; - skipped?: number; - error?: string; - }> => { - const result = await storageService!.importFromTxf(content); - - // If successful, add tokens to wallet - if (result.success && result.tokens) { - // Get identity context for InventorySyncService - const identity = await identityManager.getCurrentIdentity(); - if (!identity?.address || !identity?.publicKey || !identity?.ipnsName) { - console.error(`📦 Cannot import: missing identity context`); - return result; - } - - // Add tokens using InventorySyncService - for (const token of result.tokens) { - await addToken(identity.address, identity.publicKey, identity.ipnsName, token, { local: true }); - } - } - - return result; - }, - onSuccess: (result) => { - if (result.success) { - queryClient.invalidateQueries({ queryKey: ["wallet"] }); - } - }, - }); - - // Register event callback for external integrations - const onStorageEvent = useCallback( - (callback: (event: StorageEvent) => void | Promise) => { - if (!storageService) return () => {}; - return storageService.onEvent(callback); - }, - [storageService] - ); - - return { - // Enabled state - isEnabled, - - // Status - status: statusQuery.data, - isLoadingStatus: statusQuery.isLoading, - isServiceReady: isEnabled && isServiceReady, - currentVersion: statusQuery.data?.currentVersion ?? 0, - lastCid: statusQuery.data?.lastCid ?? null, - - // IPNS name - ipnsName: ipnsNameQuery.data, - isLoadingIpnsName: ipnsNameQuery.isLoading, - - // Sync operations - sync: syncMutation.mutateAsync, - isSyncing: isEnabled && (syncMutation.isPending || isSyncingRealtime || (storageService?.isCurrentlySyncing() ?? false)), - syncError: syncMutation.error, - - // Restore operations - restore: restoreMutation.mutateAsync, - isRestoring: restoreMutation.isPending, - restoreError: restoreMutation.error, - restoreFromLast: restoreFromLastMutation.mutateAsync, - isRestoringFromLast: restoreFromLastMutation.isPending, - - // TXF Import/Export - exportTxf: exportTxfMutation.mutateAsync, - isExportingTxf: exportTxfMutation.isPending, - exportTxfError: exportTxfMutation.error, - importTxf: importTxfMutation.mutateAsync, - isImportingTxf: importTxfMutation.isPending, - importTxfError: importTxfMutation.error, - - // Events - lastEvent, - onStorageEvent, - }; -} diff --git a/src/components/wallet/L3/hooks/useTransactionHistory.ts b/src/components/wallet/L3/hooks/useTransactionHistory.ts index 72b12012..071f181a 100644 --- a/src/components/wallet/L3/hooks/useTransactionHistory.ts +++ b/src/components/wallet/L3/hooks/useTransactionHistory.ts @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getTransactionHistory } from "../../../../services/TransactionHistoryService"; import { useEffect } from "react"; -import { QUERY_KEYS } from "../../../../config/queryKeys"; +import { SPHERE_KEYS } from "../../../../sdk/queryKeys"; export const useTransactionHistory = () => { const queryClient = useQueryClient(); @@ -9,7 +9,7 @@ export const useTransactionHistory = () => { useEffect(() => { const handleHistoryUpdate = () => { console.log("♻️ Transaction history update detected! Refreshing..."); - queryClient.refetchQueries({ queryKey: QUERY_KEYS.TRANSACTION_HISTORY }); + queryClient.refetchQueries({ queryKey: SPHERE_KEYS.payments.transactions.history }); }; // Listen to both wallet updates (legacy) and transaction-history-updated (new) @@ -23,7 +23,7 @@ export const useTransactionHistory = () => { }, [queryClient]); const historyQuery = useQuery({ - queryKey: QUERY_KEYS.TRANSACTION_HISTORY, + queryKey: SPHERE_KEYS.payments.transactions.history, queryFn: () => getTransactionHistory(), staleTime: 30000, }); diff --git a/src/components/wallet/L3/modals/LookupModal.tsx b/src/components/wallet/L3/modals/LookupModal.tsx new file mode 100644 index 00000000..25d319a4 --- /dev/null +++ b/src/components/wallet/L3/modals/LookupModal.tsx @@ -0,0 +1,192 @@ +import { useState, useCallback, useEffect } from 'react'; +import { Key, Search, Loader2, Copy, Check } from 'lucide-react'; +import { BaseModal, ModalHeader } from '../../ui'; +import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; +import { useIdentity } from '../../../../sdk'; + +interface ResolvedInfo { + nametag?: string; + transportPubkey?: string; + chainPubkey?: string; + l1Address?: string; + directAddress?: string; + proxyAddress?: string; +} + +interface LookupModalProps { + isOpen: boolean; + onClose: () => void; +} + +function CopyableField({ label, value, prefix, copied, onCopy }: { + label: string; + value: string; + prefix?: string; + copied: boolean; + onCopy: () => void; +}) { + const display = prefix ? `${prefix}${value}` : value; + return ( +
+ {label} + {display} + +
+ ); +} + +export function LookupModal({ isOpen, onClose }: LookupModalProps) { + const { sphere } = useSphereContext(); + const { nametag, directAddress, l1Address } = useIdentity(); + const [query, setQuery] = useState(''); + const [result, setResult] = useState(null); + const [myInfo, setMyInfo] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [copied, setCopied] = useState(false); + + // Auto-resolve own keys when modal opens + useEffect(() => { + if (isOpen && sphere && directAddress) { + sphere.resolve(directAddress).then((info: ResolvedInfo | null) => { + if (info) setMyInfo(info); + }).catch(() => { /* ignore */ }); + setQuery(''); + setResult(null); + setError(null); + } + }, [isOpen, sphere, directAddress]); + + const handleLookup = useCallback(async () => { + const input = query.trim(); + if (!input || !sphere) return; + + setIsLoading(true); + setError(null); + setResult(null); + + try { + const info = await sphere.resolve(input); + if (!info) { + setError(`Not found: "${input}"`); + } else { + setResult(info as ResolvedInfo); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Lookup failed'); + } finally { + setIsLoading(false); + } + }, [query, sphere]); + + const handleCopy = useCallback(async (value: string, field: string) => { + try { + await navigator.clipboard.writeText(value); + setCopied(field); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleLookup(); + } + }; + + const toFields = (info: ResolvedInfo, prefix: string) => [ + { label: 'Nametag', value: info.nametag, key: `${prefix}-nametag`, displayPrefix: '@' }, + { label: 'Direct Address', value: info.directAddress, key: `${prefix}-direct` }, + { label: 'Proxy Address', value: info.proxyAddress, key: `${prefix}-proxy` }, + { label: 'L1 Address', value: info.l1Address, key: `${prefix}-l1` }, + { label: 'Chain Pubkey', value: info.chainPubkey, key: `${prefix}-chain` }, + { label: 'Transport Pubkey', value: info.transportPubkey, key: `${prefix}-transport` }, + ].filter((f): f is { label: string; value: string; key: string; displayPrefix?: string } => !!f.value); + + // Fall back to identity data if resolve didn't return full info + const myFields = myInfo ? toFields(myInfo, 'my') : [ + nametag && { label: 'Nametag', value: nametag, key: 'my-nametag', displayPrefix: '@' }, + directAddress && { label: 'Direct Address', value: directAddress, key: 'my-direct' }, + l1Address && { label: 'L1 Address', value: l1Address, key: 'my-l1' }, + ].filter((f): f is { label: string; value: string; key: string; displayPrefix?: string } => !!f); + + const lookupFields = result ? toFields(result, 'lookup') : []; + + return ( + + + +
+ {/* My Keys */} + {myFields.length > 0 && ( +
+
+ {myFields.map(({ label, value, key, displayPrefix }) => ( + handleCopy(displayPrefix ? `${displayPrefix}${value}` : value, key)} + /> + ))} +
+
+ )} + + {/* Lookup */} +
+

+ Lookup +

+ +
+
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="@nametag, DIRECT://..., alpha1..." + className="w-full pl-8 pr-3 py-2.5 text-sm bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white placeholder-neutral-400 rounded-xl border border-neutral-200 dark:border-neutral-700/50 focus:outline-none focus:border-orange-500 transition-colors" + /> +
+ +
+ + {error && ( +

{error}

+ )} + + {lookupFields.length > 0 && ( +
+ {lookupFields.map(({ label, value, key, displayPrefix }) => ( + handleCopy(displayPrefix ? `${displayPrefix}${value}` : value, key)} + /> + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/wallet/L3/modals/PaymentRequestModal.tsx b/src/components/wallet/L3/modals/PaymentRequestModal.tsx index 439cc6e8..87dee5fc 100644 --- a/src/components/wallet/L3/modals/PaymentRequestModal.tsx +++ b/src/components/wallet/L3/modals/PaymentRequestModal.tsx @@ -1,6 +1,5 @@ import { Check, Sparkles, Trash2, Loader2, XIcon, ArrowRight, Clock, Receipt, AlertCircle } from 'lucide-react'; -import { useIncomingPaymentRequests } from '../hooks/useIncomingPaymentRequests'; -import { type IncomingPaymentRequest, PaymentRequestStatus } from '../data/model'; +import { useIncomingPaymentRequests, type IncomingPaymentRequest, PaymentRequestStatus } from '../hooks/useIncomingPaymentRequests'; import { useTransfer } from '../../../../sdk'; import { AnimatePresence, motion } from 'framer-motion'; import { useState } from 'react'; diff --git a/src/components/wallet/L3/modals/SettingsModal.tsx b/src/components/wallet/L3/modals/SettingsModal.tsx index 00d67ba6..337b3376 100644 --- a/src/components/wallet/L3/modals/SettingsModal.tsx +++ b/src/components/wallet/L3/modals/SettingsModal.tsx @@ -1,5 +1,7 @@ -import { Settings, Layers, Download, LogOut } from 'lucide-react'; +import { useState } from 'react'; +import { Settings, Layers, Download, LogOut, Key } from 'lucide-react'; import { BaseModal, ModalHeader, MenuButton } from '../../ui'; +import { LookupModal } from './LookupModal'; interface SettingsModalProps { isOpen: boolean; @@ -18,45 +20,63 @@ export function SettingsModal({ onLogout, l1Balance, }: SettingsModalProps) { + const [isLookupOpen, setIsLookupOpen] = useState(false); + return ( - - - - {/* Menu Items */} -
- { - onClose(); - onOpenL1Wallet(); - }} - /> - - { - onClose(); - onBackupWallet(); - }} - /> - - { - onClose(); - onLogout(); - }} - /> -
-
+ <> + + + +
+ { + onClose(); + onOpenL1Wallet(); + }} + /> + + { + onClose(); + setIsLookupOpen(true); + }} + /> + + { + onClose(); + onBackupWallet(); + }} + /> + + { + onClose(); + onLogout(); + }} + /> +
+
+ + setIsLookupOpen(false)} + /> + ); } diff --git a/src/components/wallet/L3/modals/SwapModal.tsx b/src/components/wallet/L3/modals/SwapModal.tsx index b99ad44d..2c8b7972 100644 --- a/src/components/wallet/L3/modals/SwapModal.tsx +++ b/src/components/wallet/L3/modals/SwapModal.tsx @@ -5,9 +5,9 @@ import { ArrowDownUp, Loader2, TrendingUp, CheckCircle, ArrowDown } from 'lucide import { useIdentity, useAssets, useTransfer } from '../../../../sdk'; import type { Asset } from '@unicitylabs/sphere-sdk'; import { toSmallestUnit, toHumanReadable } from '@unicitylabs/sphere-sdk'; -import { FaucetService } from '../services/FaucetService'; -import { RegistryService } from '../services/RegistryService'; -import { ApiService } from '../services/api'; +import { TokenRegistry } from '@unicitylabs/sphere-sdk'; +import { FaucetService } from '../../../../services/FaucetService'; +import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; import { BaseModal, ModalHeader, Button } from '../../ui'; type Step = 'swap' | 'processing' | 'success'; @@ -26,6 +26,7 @@ export function SwapModal({ isOpen, onClose }: SwapModalProps) { const { nametag } = useIdentity(); const { assets } = useAssets(); const { transfer } = useTransfer(); + const { providers } = useSphereContext(); // State const [step, setStep] = useState('swap'); @@ -40,11 +41,8 @@ export function SwapModal({ isOpen, onClose }: SwapModalProps) { // Load all available swappable coins from registry useEffect(() => { const loadSwappableCoins = async () => { - const registryService = RegistryService.getInstance(); - await registryService.ensureInitialized(); - const prices = await ApiService.fetchPrices(); - - const definitions = registryService.getAllDefinitions(); + const registry = TokenRegistry.getInstance(); + const definitions = registry.getAllDefinitions(); // Only include coins supported by the faucet for swapping const SUPPORTED_SWAP_COINS = ['bitcoin', 'ethereum', 'solana', 'unicity', 'tether', 'usd-coin', 'unicity-usd']; @@ -54,12 +52,21 @@ export function SwapModal({ isOpen, onClose }: SwapModalProps) { def.assetKind === 'fungible' && SUPPORTED_SWAP_COINS.includes(def.name.toLowerCase()) ); + // Fetch prices from SDK price provider if available + const tokenNames = fungibleDefs.map(def => def.name.toLowerCase()); + let pricesMap = new Map(); + if (providers?.price) { + try { + pricesMap = await providers.price.getPrices(tokenNames); + } catch (e) { + console.warn('Failed to fetch prices:', e); + } + } + const swappableAssets: Asset[] = fungibleDefs.map(def => { const symbol = def.symbol || def.name.toUpperCase(); - const priceKey = def.name.toLowerCase() - - const priceData = prices[priceKey]; - const iconUrl = registryService.getIconUrl(def); + const priceData = pricesMap.get(def.name.toLowerCase()); + const iconUrl = registry.getIconUrl(def.id); return { coinId: def.id, diff --git a/src/components/wallet/L3/modals/TransactionHistoryModal.tsx b/src/components/wallet/L3/modals/TransactionHistoryModal.tsx index 5ac15ef7..0f32dd8c 100644 --- a/src/components/wallet/L3/modals/TransactionHistoryModal.tsx +++ b/src/components/wallet/L3/modals/TransactionHistoryModal.tsx @@ -1,11 +1,11 @@ import { motion } from 'framer-motion'; import { ArrowUpRight, ArrowDownLeft, Loader2, Clock } from 'lucide-react'; import { useTransactionHistory } from '../../../../sdk'; -import { RegistryService } from '../services/RegistryService'; +import { TokenRegistry } from '@unicitylabs/sphere-sdk'; import { useMemo } from 'react'; import { BaseModal, ModalHeader, EmptyState } from '../../ui'; -const registryService = RegistryService.getInstance(); +const registry = TokenRegistry.getInstance(); interface TransactionHistoryModalProps { isOpen: boolean; @@ -17,7 +17,7 @@ export function TransactionHistoryModal({ isOpen, onClose }: TransactionHistoryM const formattedHistory = useMemo(() => { return history.map(entry => { - const def = registryService.getCoinDefinition(entry.coinId); + const def = registry.getDefinition(entry.coinId); const decimals = def?.decimals || 0; // Convert amount from smallest unit to human readable @@ -35,7 +35,7 @@ export function TransactionHistoryModal({ isOpen, onClose }: TransactionHistoryM return { ...entry, formattedAmount, - iconUrl: def ? registryService.getIconUrl(def) : null, + iconUrl: def ? registry.getIconUrl(entry.coinId) : null, date: new Date(entry.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', diff --git a/src/components/wallet/L3/services/ConflictResolutionService.ts b/src/components/wallet/L3/services/ConflictResolutionService.ts deleted file mode 100644 index a585470f..00000000 --- a/src/components/wallet/L3/services/ConflictResolutionService.ts +++ /dev/null @@ -1,576 +0,0 @@ -/** - * Conflict Resolution Service - * Handles merging of local and remote IPFS storage data when versions conflict - */ - -import type { - TxfStorageData, - TxfMeta, - TxfToken, - TokenConflict, - MergeResult, - TxfTransaction, - TombstoneEntry, - NametagData, -} from "./types/TxfTypes"; -import { - isTokenKey, - isArchivedKey, - isForkedKey, - tokenIdFromKey, - tokenIdFromArchivedKey, - parseForkedKey, - keyFromTokenId, - archivedKeyFromTokenId, - forkedKeyFromTokenIdAndState, -} from "./types/TxfTypes"; -import { getCurrentStateHash } from "./TxfSerializer"; -import { isNametagCorrupted, sanitizeNametagForLogging } from "../../../../utils/tokenValidation"; - -// ========================================== -// ConflictResolutionService -// ========================================== - -export class ConflictResolutionService { - // ========================================== - // Public API - // ========================================== - - /** - * Resolve conflicts between local and remote storage data - * Returns merged data and list of conflicts that were resolved - */ - resolveConflict( - local: TxfStorageData, - remote: TxfStorageData - ): MergeResult { - const localVersion = local._meta.version; - const remoteVersion = remote._meta.version; - - console.log( - `📦 Resolving conflict: local v${localVersion} vs remote v${remoteVersion}` - ); - - // Determine base version to use - let baseMeta: TxfMeta; - let baseTokens: Map; - let otherTokens: Map; - let baseIsLocal: boolean; - - if (remoteVersion > localVersion) { - // Remote is newer - use remote as base - baseMeta = { - ...remote._meta, - version: remoteVersion + 1, // Increment for merged version - }; - baseTokens = this.extractTokens(remote); - otherTokens = this.extractTokens(local); - baseIsLocal = false; - console.log(`📦 Remote is newer, using remote as base`); - } else if (localVersion > remoteVersion) { - // Local is newer - use local as base - baseMeta = { - ...local._meta, - version: localVersion + 1, - }; - baseTokens = this.extractTokens(local); - otherTokens = this.extractTokens(remote); - baseIsLocal = true; - console.log(`📦 Local is newer, using local as base`); - } else { - // Same version - use local as base (local wins on tie) - baseMeta = { - ...local._meta, - version: localVersion + 1, - }; - baseTokens = this.extractTokens(local); - otherTokens = this.extractTokens(remote); - baseIsLocal = true; - console.log(`📦 Same version, using local as base (local wins on tie)`); - } - - // Merge tokens - const conflicts: TokenConflict[] = []; - const newTokens: string[] = []; - const removedTokens: string[] = []; - - // Add all tokens from base - const mergedTokens = new Map(baseTokens); - - // Process tokens from other source - for (const [tokenId, otherToken] of otherTokens) { - if (baseTokens.has(tokenId)) { - // Token exists in both - resolve conflict - const baseToken = baseTokens.get(tokenId)!; - const { winner, resolution, reason } = this.resolveTokenConflict( - baseIsLocal ? baseToken : otherToken, - baseIsLocal ? otherToken : baseToken - ); - - mergedTokens.set(tokenId, winner); - - if (resolution !== (baseIsLocal ? "local" : "remote")) { - // Winner was from the "other" source - conflicts.push({ - tokenId, - localVersion: baseIsLocal ? baseToken : otherToken, - remoteVersion: baseIsLocal ? otherToken : baseToken, - resolution, - reason, - }); - } - } else { - // Token only in other source - add it - mergedTokens.set(tokenId, otherToken); - newTokens.push(tokenId); - console.log(`📦 Adding token ${tokenId.slice(0, 8)}... from other source`); - } - } - - // Build merged storage data - const merged: TxfStorageData = { - _meta: baseMeta, - }; - - // Add nametag (prefer local if available) - const localNametag = local._nametag; - const remoteNametag = remote._nametag; - if (localNametag || remoteNametag) { - merged._nametag = this.mergeNametags(localNametag, remoteNametag); - } - - // Merge tombstones (union of local and remote by tokenId+stateHash) - // This ensures deleted token states stay deleted across devices - const localTombstones: TombstoneEntry[] = local._tombstones || []; - const remoteTombstones: TombstoneEntry[] = remote._tombstones || []; - - // Use Map for deduplication by tokenId+stateHash key - const tombstoneMap = new Map(); - for (const t of [...localTombstones, ...remoteTombstones]) { - const key = `${t.tokenId}:${t.stateHash}`; - if (!tombstoneMap.has(key)) { - tombstoneMap.set(key, t); - } - } - const mergedTombstones = [...tombstoneMap.values()]; - - if (mergedTombstones.length > 0) { - merged._tombstones = mergedTombstones; - console.log(`📦 Merged ${mergedTombstones.length} tombstone(s) (${localTombstones.length} local + ${remoteTombstones.length} remote)`); - } - - // Build tombstone lookup set (tokenId:stateHash) - const tombstoneKeySet = new Set(tombstoneMap.keys()); - - // Add all tokens (excluding tombstoned states) - for (const [tokenId, token] of mergedTokens) { - // Get the token's current state hash - const stateHash = getCurrentStateHash(token); - if (!stateHash) { - console.warn(`📦 Token ${tokenId.slice(0, 8)}... has undefined stateHash, skipping tombstone check`); - merged[keyFromTokenId(tokenId)] = token; - continue; - } - const tombstoneKey = `${tokenId}:${stateHash}`; - - // Don't include tokens whose current state is tombstoned - if (tombstoneKeySet.has(tombstoneKey)) { - console.log(`📦 Excluding tombstoned token ${tokenId.slice(0, 8)}... (state ${stateHash.slice(0, 12)}...) from merge`); - removedTokens.push(tokenId); - continue; - } - merged[keyFromTokenId(tokenId)] = token; - } - - // Merge archived tokens (union of local and remote, prefer more transactions) - const localArchived = this.extractArchivedTokens(local); - const remoteArchived = this.extractArchivedTokens(remote); - const mergedArchived = this.mergeArchivedTokenMaps(localArchived, remoteArchived); - for (const [tokenId, token] of mergedArchived) { - merged[archivedKeyFromTokenId(tokenId)] = token; - } - if (mergedArchived.size > 0) { - console.log(`📦 Merged ${mergedArchived.size} archived token(s)`); - } - - // Merge forked tokens (union of local and remote) - const localForked = this.extractForkedTokens(local); - const remoteForked = this.extractForkedTokens(remote); - const mergedForked = this.mergeForkedTokenMaps(localForked, remoteForked); - for (const [key, token] of mergedForked) { - // Parse key to get tokenId and stateHash - const parts = key.split("_"); - if (parts.length >= 2) { - const tokenId = parts[0]; - const stateHash = parts.slice(1).join("_"); // stateHash may contain underscores - merged[forkedKeyFromTokenIdAndState(tokenId, stateHash)] = token; - } - } - if (mergedForked.size > 0) { - console.log(`📦 Merged ${mergedForked.size} forked token(s)`); - } - - console.log( - `📦 Merge complete: ${mergedTokens.size - removedTokens.length} active, ${mergedArchived.size} archived, ${mergedForked.size} forked, ${conflicts.length} conflicts resolved, ${newTokens.length} new, ${removedTokens.length} tombstoned` - ); - - return { - merged, - conflicts, - newTokens, - removedTokens, - }; - } - - // ========================================== - // Token Conflict Resolution - // ========================================== - - /** - * Resolve conflict between two versions of the same token - * Priority: 1) Longer chain, 2) More proofs, 3) Newer timestamp - */ - private resolveTokenConflict( - localToken: TxfToken, - remoteToken: TxfToken - ): { winner: TxfToken; resolution: "local" | "remote"; reason: string } { - // 1. Longer chain wins - const localChainLength = localToken.transactions.length; - const remoteChainLength = remoteToken.transactions.length; - - if (localChainLength !== remoteChainLength) { - if (localChainLength > remoteChainLength) { - return { - winner: localToken, - resolution: "local", - reason: `Longer chain (${localChainLength} vs ${remoteChainLength})`, - }; - } else { - return { - winner: remoteToken, - resolution: "remote", - reason: `Longer chain (${remoteChainLength} vs ${localChainLength})`, - }; - } - } - - // 2. More proofs wins - const localProofCount = this.countProofs(localToken); - const remoteProofCount = this.countProofs(remoteToken); - - if (localProofCount !== remoteProofCount) { - if (localProofCount > remoteProofCount) { - return { - winner: localToken, - resolution: "local", - reason: `More proofs (${localProofCount} vs ${remoteProofCount})`, - }; - } else { - return { - winner: remoteToken, - resolution: "remote", - reason: `More proofs (${remoteProofCount} vs ${localProofCount})`, - }; - } - } - - // 3. Deterministic tiebreaker: use genesis data hash for consistency - const localGenesisHash = localToken._integrity?.genesisDataJSONHash || ""; - const remoteGenesisHash = remoteToken._integrity?.genesisDataJSONHash || ""; - - if (localGenesisHash !== remoteGenesisHash) { - // Use lexicographic comparison for determinism - if (localGenesisHash > remoteGenesisHash) { - return { - winner: localToken, - resolution: "local", - reason: "Deterministic tiebreaker (hash comparison)", - }; - } else { - return { - winner: remoteToken, - resolution: "remote", - reason: "Deterministic tiebreaker (hash comparison)", - }; - } - } - - // 4. Final fallback: prefer local - return { - winner: localToken, - resolution: "local", - reason: "Identical tokens, preferring local", - }; - } - - /** - * Count total inclusion proofs in a token - */ - private countProofs(token: TxfToken): number { - let count = 0; - - // Genesis always has a proof - if (token.genesis?.inclusionProof) { - count++; - } - - // Count transaction proofs - if (token.transactions) { - count += token.transactions.filter( - (tx: TxfTransaction) => tx.inclusionProof !== null - ).length; - } - - return count; - } - - // ========================================== - // Helper Methods - // ========================================== - - /** - * Extract tokens from storage data into a Map - */ - private extractTokens(data: TxfStorageData): Map { - const tokens = new Map(); - - for (const key of Object.keys(data)) { - if (isTokenKey(key)) { - const tokenId = tokenIdFromKey(key); - const token = data[key] as TxfToken; - if (token && token.genesis) { - tokens.set(tokenId, token); - } - } - } - - return tokens; - } - - /** - * Merge nametag data, preferring valid data over corrupted data - * - * CRITICAL: Detects corrupted nametags (with empty token: {}) and - * prefers valid data over corrupted data. This fixes the bug where - * local corrupted data would overwrite valid remote data. - */ - private mergeNametags( - local: NametagData | undefined, - remote: NametagData | undefined - ): NametagData { - const localCorrupted = isNametagCorrupted(local); - const remoteCorrupted = isNametagCorrupted(remote); - - if (local && remote) { - // Both exist - check for corruption - if (localCorrupted && !remoteCorrupted) { - // Local is corrupted, remote is valid - use remote - console.warn("📦 Local nametag is corrupted, using remote:", { - local: sanitizeNametagForLogging(local), - remote: sanitizeNametagForLogging(remote), - }); - return remote; - } - if (!localCorrupted && remoteCorrupted) { - // Local is valid, remote is corrupted - use local - console.warn("📦 Remote nametag is corrupted, using local:", { - local: sanitizeNametagForLogging(local), - remote: sanitizeNametagForLogging(remote), - }); - return local; - } - if (localCorrupted && remoteCorrupted) { - // Both corrupted - prefer local but warn - console.error("📦 CRITICAL: Both local and remote nametags are corrupted!", { - local: sanitizeNametagForLogging(local), - remote: sanitizeNametagForLogging(remote), - }); - return local; - } - // Both valid - use local (user's current choice) - return local; - } - - // Only one exists - const result = (local || remote)!; - const resultCorrupted = isNametagCorrupted(result); - if (resultCorrupted) { - console.warn("📦 Warning: Only available nametag is corrupted:", sanitizeNametagForLogging(result)); - } - return result; - } - - // ========================================== - // Conflict Detection - // ========================================== - - /** - * Check if two storage data sets have conflicting versions - */ - hasConflict(local: TxfStorageData, remote: TxfStorageData): boolean { - return local._meta.version !== remote._meta.version; - } - - /** - * Check if remote is strictly newer (no merge needed, just accept remote) - */ - isRemoteNewer(local: TxfStorageData, remote: TxfStorageData): boolean { - const localVersion = local._meta.version; - const remoteVersion = remote._meta.version; - - // Remote is newer if version is higher - // AND all local tokens are also in remote (no local-only changes) - if (remoteVersion <= localVersion) { - return false; - } - - const localTokens = this.extractTokens(local); - const remoteTokens = this.extractTokens(remote); - - for (const [tokenId] of localTokens) { - if (!remoteTokens.has(tokenId)) { - return false; // Local has token that remote doesn't - } - } - - return true; - } - - /** - * Check if local is strictly newer (just push local, no fetch needed) - */ - isLocalNewer(local: TxfStorageData, remote: TxfStorageData): boolean { - const localVersion = local._meta.version; - const remoteVersion = remote._meta.version; - - // Local is newer if version is higher - // AND all remote tokens are also in local - if (localVersion <= remoteVersion) { - return false; - } - - const localTokens = this.extractTokens(local); - const remoteTokens = this.extractTokens(remote); - - for (const [tokenId] of remoteTokens) { - if (!localTokens.has(tokenId)) { - return false; // Remote has token that local doesn't - } - } - - return true; - } - - // ========================================== - // Archived Token Methods - // ========================================== - - /** - * Extract archived tokens from storage data into a Map - */ - private extractArchivedTokens(data: TxfStorageData): Map { - const archived = new Map(); - - for (const key of Object.keys(data)) { - if (isArchivedKey(key)) { - const tokenId = tokenIdFromArchivedKey(key); - const token = data[key] as TxfToken; - if (token && token.genesis) { - archived.set(tokenId, token); - } - } - } - - return archived; - } - - /** - * Extract forked tokens from storage data into a Map - * Map key format: tokenId_stateHash - */ - private extractForkedTokens(data: TxfStorageData): Map { - const forked = new Map(); - - for (const key of Object.keys(data)) { - if (isForkedKey(key)) { - const parsed = parseForkedKey(key); - if (parsed) { - const token = data[key] as TxfToken; - if (token && token.genesis) { - // Use tokenId_stateHash as map key - const mapKey = `${parsed.tokenId}_${parsed.stateHash}`; - forked.set(mapKey, token); - } - } - } - } - - return forked; - } - - /** - * Merge archived token maps - * Prefers token with more transactions (more complete history) - */ - private mergeArchivedTokenMaps( - local: Map, - remote: Map - ): Map { - const merged = new Map(local); - - for (const [tokenId, remoteToken] of remote) { - const localToken = merged.get(tokenId); - - if (!localToken) { - // Only in remote - add it - merged.set(tokenId, remoteToken); - } else { - // Both have it - prefer one with more transactions (more complete history) - const localTxnCount = localToken.transactions?.length || 0; - const remoteTxnCount = remoteToken.transactions?.length || 0; - - if (remoteTxnCount > localTxnCount) { - merged.set(tokenId, remoteToken); - } - // else keep local (ties go to local) - } - } - - return merged; - } - - /** - * Merge forked token maps (union merge) - * Each fork is unique by tokenId_stateHash - */ - private mergeForkedTokenMaps( - local: Map, - remote: Map - ): Map { - const merged = new Map(local); - - for (const [key, token] of remote) { - if (!merged.has(key)) { - merged.set(key, token); - } - } - - return merged; - } -} - -// ========================================== -// Singleton Instance -// ========================================== - -let conflictServiceInstance: ConflictResolutionService | null = null; - -/** - * Get singleton instance of ConflictResolutionService - */ -export function getConflictResolutionService(): ConflictResolutionService { - if (!conflictServiceInstance) { - conflictServiceInstance = new ConflictResolutionService(); - } - return conflictServiceInstance; -} diff --git a/src/components/wallet/L3/services/IdentityManager.ts b/src/components/wallet/L3/services/IdentityManager.ts deleted file mode 100644 index a06a9a03..00000000 --- a/src/components/wallet/L3/services/IdentityManager.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { SigningService } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService"; -import { TokenType } from "@unicitylabs/state-transition-sdk/lib/token/TokenType"; -import * as bip39 from "bip39"; -import CryptoJS from "crypto-js"; -import { HashAlgorithm } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm"; -import type { DirectAddress } from "@unicitylabs/state-transition-sdk/lib/address/DirectAddress"; -import { UnmaskedPredicateReference } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference"; -import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; -import { deriveIpnsNameFromPrivateKey } from "./IpnsUtils"; -const UNICITY_TOKEN_TYPE_HEX = - "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"; -const DEFAULT_SESSION_KEY = "user-pin-1234"; - -/** - * User identity for L3 Unicity wallet. - * - * NOTE: The wallet address is derived using UnmaskedPredicateReference (no nonce/salt). - * This creates a stable, reusable DirectAddress from publicKey + tokenType. - * The SDK's UnmaskedPredicate (which uses salt) is only used for token ownership - * predicates during transfers, where the salt comes from the transaction itself. - */ -export interface UserIdentity { - privateKey: string; - publicKey: string; - address: string; - mnemonic?: string; - l1Address?: string; // Alpha L1 address (if derived from unified wallet) - addressIndex?: number; // BIP44 address index - ipnsName?: string; // IPNS name derived from privateKey (for inventory sync) -} - -export class IdentityManager { - private static instance: IdentityManager; - private sessionKey: string; - - private constructor(sessionKey: string) { - this.sessionKey = sessionKey; - } - - static getInstance(sessionKey: string = DEFAULT_SESSION_KEY): IdentityManager { - if (!IdentityManager.instance) { - IdentityManager.instance = new IdentityManager(sessionKey); - } - return IdentityManager.instance; - } - - /** - * Get the UnifiedKeyManager instance - * NOTE: Always fetch fresh from singleton to avoid stale references after resetInstance() - */ - getUnifiedKeyManager(): UnifiedKeyManager { - // Always get fresh instance from singleton - don't cache! - // UnifiedKeyManager.resetInstance() can make cached references stale - return UnifiedKeyManager.getInstance(this.sessionKey); - } - - /** - * Get the selected address PATH for identity derivation - * Returns null if not set (caller should use default first address) - */ - getSelectedAddressPath(): string | null { - return localStorage.getItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - } - - /** - * Set the selected address PATH for identity derivation - * @param path - Full BIP32 path like "m/84'/1'/0'/0/0" - */ - setSelectedAddressPath(path: string): void { - localStorage.setItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH, path); - // Clean up legacy index key - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_INDEX_LEGACY); - } - - /** - * Clear the selected address path (for wallet reset) - */ - clearSelectedAddressPath(): void { - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_INDEX_LEGACY); - } - - /** - * Generate a new identity using the UnifiedKeyManager (standard BIP32) - * This creates a unified wallet where L1 and L3 share the same keypairs - */ - async generateNewIdentity(): Promise { - const keyManager = this.getUnifiedKeyManager(); - const mnemonic = await keyManager.generateNew(12); - // Use path-based derivation - PATH is the single identifier - const basePath = keyManager.getBasePath(); - const defaultPath = `${basePath}/0/0`; - const identity = await this.deriveIdentityFromPath(defaultPath); - // Save mnemonic for legacy compatibility - if (mnemonic) { - this.saveSeed(mnemonic); - } - return { ...identity, mnemonic }; - } - - /** - * Derive L3 identity from a BIP32 path - * This is the PREFERRED method - use path as the single identifier - * @param path - Full BIP32 path like "m/84'/1'/0'/0/0" - */ - async deriveIdentityFromPath(path: string): Promise { - const keyManager = this.getUnifiedKeyManager(); - - if (!keyManager.isInitialized()) { - throw new Error("Unified wallet not initialized"); - } - - const derived = keyManager.deriveAddressFromPath(path); - const secret = Buffer.from(derived.privateKey, "hex"); - - const l3Address = await this.deriveL3Address(secret); - - const signingService = await SigningService.createFromSecret(secret); - const publicKey = Buffer.from(signingService.publicKey).toString("hex"); - - // Derive IPNS name for inventory sync (critical for IPFS initialization) - const ipnsName = await deriveIpnsNameFromPrivateKey(derived.privateKey); - - // Parse path to get index for addressIndex field - const match = path.match(/\/(\d+)$/); - const index = match ? parseInt(match[1], 10) : 0; - - return { - privateKey: derived.privateKey, - publicKey: publicKey, - address: l3Address, - mnemonic: keyManager.getMnemonic() || undefined, - l1Address: derived.l1Address, - addressIndex: index, - ipnsName: ipnsName, - }; - } - - /** - * Derive identity from a raw private key - * Useful for external integrations - */ - async deriveIdentityFromPrivateKey(privateKey: string): Promise { - const secret = Buffer.from(privateKey, "hex"); - - const l3Address = await this.deriveL3Address(secret); - - const signingService = await SigningService.createFromSecret(secret); - const publicKey = Buffer.from(signingService.publicKey).toString("hex"); - - // Derive IPNS name for inventory sync - const ipnsName = await deriveIpnsNameFromPrivateKey(privateKey); - - return { - privateKey, - publicKey, - address: l3Address, - ipnsName: ipnsName, - }; - } - - /** - * Derive identity from mnemonic using UnifiedKeyManager - * This always uses BIP32 derivation for consistency with L1 - */ - async deriveIdentityFromMnemonic(mnemonic: string): Promise { - // Validate mnemonic phrase - const isValid = bip39.validateMnemonic(mnemonic); - if (!isValid) { - throw new Error("Invalid recovery phrase. Please check your words and try again."); - } - - // Use UnifiedKeyManager for BIP32 derivation - PATH is the single identifier - const keyManager = this.getUnifiedKeyManager(); - await keyManager.createFromMnemonic(mnemonic); - - // Use path-based derivation for the default first external address - const basePath = keyManager.getBasePath(); - const defaultPath = `${basePath}/0/0`; - const identity = await this.deriveIdentityFromPath(defaultPath); - - // Save mnemonic for legacy compatibility - this.saveSeed(mnemonic); - - return { ...identity, mnemonic }; - } - - /** - * Derive L3 Unicity address from secret - * Uses UnmaskedPredicateReference (no nonce) for a stable, reusable address - */ - private async deriveL3Address(secret: Buffer): Promise { - try { - const signingService = await SigningService.createFromSecret(secret); - - const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX, "hex"); - const tokenType = new TokenType(tokenTypeBytes); - - // Use UnmaskedPredicateReference for stable wallet address (no nonce) - // This matches getWalletAddress() and is the correct approach per SDK - const predicateRef = UnmaskedPredicateReference.create( - tokenType, - signingService.algorithm, - signingService.publicKey, - HashAlgorithm.SHA256 - ); - - return (await (await predicateRef).toAddress()).toString(); - } catch (error) { - console.error("Error deriving address", error); - throw error; - } - } - - private saveSeed(mnemonic: string) { - const encrypted = CryptoJS.AES.encrypt( - mnemonic, - this.sessionKey - ).toString(); - localStorage.setItem(STORAGE_KEYS.ENCRYPTED_SEED, encrypted); - } - - async getCurrentIdentity(): Promise { - // ONLY use UnifiedKeyManager - L1 and L3 share the same keys - // Legacy mnemonic-only wallets are no longer supported - const keyManager = this.getUnifiedKeyManager(); - const initialized = await keyManager.initialize(); - - if (initialized) { - // Use path-based derivation (not index-based) - PATH is the ONLY reliable identifier - const selectedPath = this.getSelectedAddressPath(); - if (selectedPath) { - return this.deriveIdentityFromPath(selectedPath); - } - - // Fallback to first external address if no path stored - const basePath = keyManager.getBasePath(); - const defaultPath = `${basePath}/0/0`; - return this.deriveIdentityFromPath(defaultPath); - } - - // No wallet initialized - user must create or import a wallet - return null; - } - - /** - * Get the L1 Alpha address associated with current identity - */ - async getL1Address(): Promise { - const identity = await this.getCurrentIdentity(); - return identity?.l1Address || null; - } - - async getWalletAddress(): Promise { - const identity = await this.getCurrentIdentity(); - if (!identity) return null; - - try { - const secret = Buffer.from(identity.privateKey, "hex"); - const signingService = await SigningService.createFromSecret(secret); - const publicKey = signingService.publicKey; - const tokenType = new TokenType( - Buffer.from(UNICITY_TOKEN_TYPE_HEX, "hex") - ); - - // UnmaskedPredicateReference creates a stable, reusable DirectAddress - // This does NOT use nonce - the address is derived only from publicKey + tokenType - const predicateRef = UnmaskedPredicateReference.create( - tokenType, - signingService.algorithm, - publicKey, - HashAlgorithm.SHA256 - ); - - return (await predicateRef).toAddress(); - } catch (error) { - console.error("Failed to derive wallet address", error); - return null; - } - } -} diff --git a/src/components/wallet/L3/services/InventoryBackgroundLoops.ts b/src/components/wallet/L3/services/InventoryBackgroundLoops.ts deleted file mode 100644 index 024f26d5..00000000 --- a/src/components/wallet/L3/services/InventoryBackgroundLoops.ts +++ /dev/null @@ -1,739 +0,0 @@ -/** - * Background Loops for Token Inventory - * Per TOKEN_INVENTORY_SPEC.md Section 7 - * - * Three independent loops: - * 1. ReceiveTokensToInventoryLoop - Batches incoming Nostr tokens (Section 7.1) - * 2. NostrDeliveryQueue - Sends tokens via Nostr with parallelism (Section 7.3) - * 3. InventoryBackgroundLoopsManager - Lifecycle management - */ - -import type { Token } from '../data/model'; -import type { IdentityManager } from './IdentityManager'; -import type { NostrService } from './NostrService'; -import type { - ReceiveTokenBatchItem, - ReceiveTokenBatch, - NostrDeliveryQueueEntry, - DeliveryQueueStatus, - LoopConfig, - CompletedTransfer, -} from './types/QueueTypes'; -import { DEFAULT_LOOP_CONFIG } from './types/QueueTypes'; -import { inventorySync, type SyncParams } from './InventorySyncService'; - -/** - * Batches incoming Nostr tokens with 3-second idle detection - * Per TOKEN_INVENTORY_SPEC.md Section 7.1 - * - * Flow: - * 1. Tokens arrive via queueIncomingToken() - * 2. Wait until 3 seconds of no new tokens - * 3. Call inventorySync(incomingTokens) in FAST mode - * 4. Wait 3 seconds, call inventorySync() in NORMAL mode - * - * AMENDMENT 1: Tokens are saved to localStorage IMMEDIATELY before batching - */ -export class ReceiveTokensToInventoryLoop { - private batchBuffer: ReceiveTokenBatchItem[] = []; - private batchId: string | null = null; - private idleTimer: ReturnType | null = null; - private isProcessing = false; - private completedBatches: ReceiveTokenBatch[] = []; - private identityManager: IdentityManager; - private config: LoopConfig; - private eventToTokenMap: Map = new Map(); // eventId -> tokenId - private onEventProcessed: ((eventId: string) => void) | null = null; - - constructor(identityManager: IdentityManager, config: LoopConfig = DEFAULT_LOOP_CONFIG) { - this.identityManager = identityManager; - this.config = config; - } - - /** - * Set callback for marking Nostr events as processed - * Called after IPFS sync succeeds - */ - setEventProcessedCallback(callback: (eventId: string) => void): void { - this.onEventProcessed = callback; - } - - /** - * Queue a token received from Nostr for batch processing - * NOTE: Token should already be saved to localStorage BEFORE calling this - * - * @param token - UI token (already saved to localStorage) - * @param eventId - Nostr event ID - * @param senderPubkey - Sender's public key - */ - async queueIncomingToken(token: Token, eventId: string, senderPubkey: string): Promise { - // If already processing, add to buffer for next batch - const item: ReceiveTokenBatchItem = { - token, - eventId, - timestamp: Date.now(), - senderPubkey, - }; - - this.batchBuffer.push(item); - this.eventToTokenMap.set(eventId, token.id); - - // Create batch ID if not exists - if (!this.batchId) { - this.batchId = crypto.randomUUID(); - } - - console.log(`📥 [ReceiveLoop] Queued token ${token.id.slice(0, 8)} (batch ${this.batchId.slice(0, 8)}, ${this.batchBuffer.length} items)`); - - // Reset idle timer - this.resetIdleTimer(); - - // Force process if at max size - if (this.batchBuffer.length >= this.config.receiveTokenMaxBatchSize) { - console.log(`📥 [ReceiveLoop] Max batch size reached, processing immediately`); - this.clearIdleTimer(); - await this.processBatch(); - } - } - - /** - * Reset the 3-second idle timer - */ - private resetIdleTimer(): void { - this.clearIdleTimer(); - this.idleTimer = setTimeout(() => { - this.processBatch().catch(err => { - console.error('📥 [ReceiveLoop] Batch processing failed:', err); - }); - }, this.config.receiveTokenBatchWindowMs); - } - - /** - * Clear the idle timer - */ - private clearIdleTimer(): void { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - this.idleTimer = null; - } - } - - /** - * Process the current batch of tokens - * Implements TOKEN_INVENTORY_SPEC.md Section 7.1 - */ - private async processBatch(): Promise { - // Prevent concurrent processing - if (this.isProcessing || this.batchBuffer.length === 0) { - return; - } - - this.isProcessing = true; - const batchId = this.batchId || crypto.randomUUID(); - const items = [...this.batchBuffer]; - const eventIds = items.map(i => i.eventId); - - // Clear buffer for next batch - this.batchBuffer = []; - this.batchId = null; - - console.log(`📥 [ReceiveLoop] Processing batch ${batchId.slice(0, 8)} with ${items.length} tokens`); - - const batch: ReceiveTokenBatch = { - items, - batchId, - createdAt: items[0]?.timestamp || Date.now(), - finalizedAt: Date.now(), - }; - - try { - // Phase 2: FAST sync to persist incoming tokens - batch.syncStartedAt = Date.now(); - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - throw new Error('No identity available'); - } - - const tokens = items.map(i => i.token); - console.log(`📥 [ReceiveLoop] Calling inventorySync(FAST) with ${tokens.length} incoming tokens`); - - const syncParams: SyncParams = { - incomingTokens: tokens, - address: identity.address, - publicKey: identity.publicKey, - ipnsName: identity.ipnsName || '', - }; - - const result = await inventorySync(syncParams); - batch.syncCompletedAt = Date.now(); - batch.syncResult = result; - - if (result.status === 'SUCCESS' || result.status === 'PARTIAL_SUCCESS') { - console.log(`✅ [ReceiveLoop] FAST sync completed: ${result.operationStats?.tokensImported || 0} tokens imported`); - - // Mark Nostr events as processed - if (this.onEventProcessed) { - for (const eventId of eventIds) { - this.onEventProcessed(eventId); - } - console.log(`✅ [ReceiveLoop] Marked ${eventIds.length} Nostr events as processed`); - } - } else { - console.warn(`⚠️ [ReceiveLoop] FAST sync failed: ${result.errorMessage}`); - // Don't mark events as processed - they will be retried on next connect - } - - // Phase 3: Wait 3 seconds then run NORMAL sync for spent detection - console.log(`📥 [ReceiveLoop] Waiting ${this.config.receiveTokenBatchWindowMs}ms before NORMAL sync`); - await this.sleep(this.config.receiveTokenBatchWindowMs); - - console.log(`📥 [ReceiveLoop] Calling inventorySync(NORMAL) for spent detection`); - const normalParams: SyncParams = { - address: identity.address, - publicKey: identity.publicKey, - ipnsName: identity.ipnsName || '', - }; - - const normalResult = await inventorySync(normalParams); - if (normalResult.status === 'SUCCESS' || normalResult.status === 'PARTIAL_SUCCESS') { - console.log(`✅ [ReceiveLoop] NORMAL sync completed`); - } else { - console.warn(`⚠️ [ReceiveLoop] NORMAL sync had issues: ${normalResult.errorMessage}`); - } - - } catch (error) { - console.error(`❌ [ReceiveLoop] Batch processing error:`, error); - batch.syncCompletedAt = Date.now(); - } finally { - // Store completed batch (keep last 10) - this.completedBatches.push(batch); - if (this.completedBatches.length > 10) { - this.completedBatches.shift(); - } - - this.isProcessing = false; - - // Clear event mappings for processed events - for (const eventId of eventIds) { - this.eventToTokenMap.delete(eventId); - } - } - } - - /** - * Sleep helper for async delays - */ - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Get current batch status for debugging - */ - getBatchStatus(): { pending: number; batchId: string | null; isProcessing: boolean } { - return { - pending: this.batchBuffer.length, - batchId: this.batchId, - isProcessing: this.isProcessing, - }; - } - - /** - * Get completed batches (last 10) for debugging - */ - getCompletedBatches(): ReceiveTokenBatch[] { - return [...this.completedBatches]; - } - - /** - * Cleanup on app shutdown - */ - destroy(): void { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - this.idleTimer = null; - } - this.batchBuffer = []; - console.log('🛑 [ReceiveLoop] Destroyed'); - } -} - -/** - * Sends tokens via Nostr with 12-way parallelism and exponential backoff - * Per TOKEN_INVENTORY_SPEC.md Section 7.3 - * - * Flow: - * 1. Entries queued via queueForDelivery() - * 2. Up to 12 sent in parallel - * 3. Exponential backoff on errors: 1s, 3s, 10s, 30s, 60s - * 4. After 3s empty queue, call inventorySync(completedList) - * - * AMENDMENT 2: CompletedList includes stateHash for multi-version architecture - * AMENDMENT 3: Extended backoff schedule per spec max 1 minute - */ -export class NostrDeliveryQueue { - private queue: Map = new Map(); - private activeDeliveries: Map> = new Map(); - private processTimer: ReturnType | null = null; - private emptyQueueTimer: ReturnType | null = null; - private completedEntries: NostrDeliveryQueueEntry[] = []; - private nostrService: NostrService | null = null; - private identityManager: IdentityManager; - private config: LoopConfig; - private isProcessing = false; - private completedSinceLastSync: NostrDeliveryQueueEntry[] = []; - - constructor(identityManager: IdentityManager, config: LoopConfig = DEFAULT_LOOP_CONFIG) { - this.identityManager = identityManager; - this.config = config; - } - - /** - * Set NostrService reference (lazy initialization to avoid circular deps) - */ - setNostrService(nostrService: NostrService): void { - this.nostrService = nostrService; - } - - /** - * Add entry to delivery queue - * Called by sendTokensFromInventory() when setting up transfer - */ - async queueForDelivery(entry: NostrDeliveryQueueEntry): Promise { - // Add to queue - this.queue.set(entry.id, entry); - console.log(`📤 [DeliveryQueue] Queued ${entry.id.slice(0, 8)} for ${entry.recipientNametag} (${this.queue.size} pending)`); - - // Start processing if not already running - this.startProcessing(); - } - - /** - * Start the processing loop - */ - private startProcessing(): void { - if (this.processTimer) return; // Already running - - this.processTimer = setInterval(() => { - this.processQueue().catch(err => { - console.error('📤 [DeliveryQueue] Process error:', err); - }); - }, this.config.deliveryCheckIntervalMs); - - console.log('📤 [DeliveryQueue] Started processing'); - } - - /** - * Stop the processing loop - */ - private stopProcessing(): void { - if (this.processTimer) { - clearInterval(this.processTimer); - this.processTimer = null; - console.log('📤 [DeliveryQueue] Stopped processing'); - } - } - - /** - * Process queue: send ready entries in parallel - */ - private async processQueue(): Promise { - if (this.isProcessing) return; - this.isProcessing = true; - - try { - const now = Date.now(); - const availableSlots = this.config.deliveryMaxParallel - this.activeDeliveries.size; - - if (availableSlots <= 0) return; - - // Get entries ready for delivery (not in backoff) - const readyEntries = [...this.queue.values()] - .filter(e => !e.backoffUntil || e.backoffUntil <= now) - .filter(e => !this.activeDeliveries.has(e.id)) - .slice(0, availableSlots); - - if (readyEntries.length === 0) { - // Check if queue is empty (including active deliveries) - if (this.queue.size === 0 && this.activeDeliveries.size === 0) { - this.checkEmptyQueueWindow(); - } - return; - } - - // Clear empty queue timer since we have work - this.clearEmptyQueueTimer(); - - // Send entries in parallel - for (const entry of readyEntries) { - const promise = this.sendEntry(entry); - this.activeDeliveries.set(entry.id, promise); - promise.finally(() => { - this.activeDeliveries.delete(entry.id); - }); - } - - console.log(`📤 [DeliveryQueue] Sending ${readyEntries.length} entries (${this.activeDeliveries.size}/${this.config.deliveryMaxParallel} active)`); - } finally { - this.isProcessing = false; - } - } - - /** - * Send a single entry via Nostr - */ - private async sendEntry(entry: NostrDeliveryQueueEntry): Promise { - if (!this.nostrService) { - console.error('📤 [DeliveryQueue] NostrService not set'); - return; - } - - entry.attemptedAt = entry.attemptedAt || Date.now(); - - try { - console.log(`📤 [DeliveryQueue] Sending ${entry.id.slice(0, 8)} to ${entry.recipientNametag} (attempt ${entry.retryCount + 1})`); - - // Send via NostrService - const eventId = await this.nostrService.sendTokenToRecipient( - entry.recipientPubkey, - entry.payloadJson - ); - - // Validate return value - eventId should be non-empty string - if (!eventId || typeof eventId !== 'string' || eventId.length === 0) { - throw new Error(`Invalid eventId returned from sendTokenToRecipient: ${eventId}`); - } - - // Success! - entry.completedAt = Date.now(); - entry.nostrEventId = eventId; - - console.log(`✅ [DeliveryQueue] Sent ${entry.id.slice(0, 8)} - event ${eventId.slice(0, 8)}`); - - // Move to completed - this.queue.delete(entry.id); - this.completedEntries.push(entry); - this.completedSinceLastSync.push(entry); - - // Trim completed list - if (this.completedEntries.length > 100) { - this.completedEntries.shift(); - } - - } catch (error) { - entry.retryCount++; - entry.lastError = error instanceof Error ? error.message : String(error); - - if (entry.retryCount >= this.config.deliveryMaxRetries) { - console.error(`❌ [DeliveryQueue] Max retries reached for ${entry.id.slice(0, 8)}`); - // Keep in queue but mark as permanently failed - // UI can display these for manual intervention - } else { - // Calculate backoff - const backoffIndex = Math.min(entry.retryCount - 1, this.config.deliveryBackoffMs.length - 1); - const backoffMs = this.config.deliveryBackoffMs[backoffIndex]; - entry.backoffUntil = Date.now() + backoffMs; - - console.warn(`⚠️ [DeliveryQueue] Retry ${entry.retryCount}/${this.config.deliveryMaxRetries} for ${entry.id.slice(0, 8)} in ${backoffMs}ms`); - } - } - } - - /** - * Clear the empty queue timer - */ - private clearEmptyQueueTimer(): void { - if (this.emptyQueueTimer) { - clearTimeout(this.emptyQueueTimer); - this.emptyQueueTimer = null; - } - } - - /** - * Start 3-second empty queue timer - */ - private checkEmptyQueueWindow(): void { - // Don't start timer if already running - if (this.emptyQueueTimer) return; - - console.log(`📤 [DeliveryQueue] Queue empty, waiting ${this.config.deliveryEmptyQueueWaitMs}ms before finalizing`); - - this.emptyQueueTimer = setTimeout(async () => { - this.emptyQueueTimer = null; - - // Double-check queue is still empty - if (this.queue.size > 0 || this.activeDeliveries.size > 0) { - return; - } - - // Stop processing loop - this.stopProcessing(); - - // Finalize completed transfers - await this.finalizeCompletedTransfers(); - }, this.config.deliveryEmptyQueueWaitMs); - } - - /** - * Finalize completed transfers by calling inventorySync(completedList) - */ - private async finalizeCompletedTransfers(): Promise { - if (this.completedSinceLastSync.length === 0) { - console.log(`📤 [DeliveryQueue] No completed transfers to finalize`); - return; - } - - console.log(`📤 [DeliveryQueue] Finalizing ${this.completedSinceLastSync.length} completed transfers`); - - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.error('📤 [DeliveryQueue] No identity available for finalize'); - return; - } - - // Build completedList with stateHash (Amendment 2) - const completedList: CompletedTransfer[] = []; - for (const entry of this.completedSinceLastSync) { - // Parse payload to extract token info - try { - const payload = JSON.parse(entry.payloadJson); - const tokenId = payload.tokenId || payload.sourceToken?.id; - const stateHash = payload.stateHash || ''; - const inclusionProof = payload.inclusionProof || {}; - - if (!tokenId) { - console.warn(`📤 [DeliveryQueue] Missing tokenId in payload for entry ${entry.id.slice(0, 8)}`); - continue; - } - - if (!stateHash) { - // CRITICAL: stateHash required for multi-version architecture (Amendment 2) - console.warn(`📤 [DeliveryQueue] Missing stateHash for token ${tokenId.slice(0, 8)} - cannot finalize`); - continue; - } - - completedList.push({ - tokenId, - stateHash, - inclusionProof, - }); - } catch (err) { - console.warn(`📤 [DeliveryQueue] Failed to parse payload for ${entry.id}:`, err); - } - } - - if (completedList.length > 0) { - try { - const result = await inventorySync({ - completedList, - address: identity.address, - publicKey: identity.publicKey, - ipnsName: identity.ipnsName || '', - }); - - if (result.status === 'SUCCESS' || result.status === 'PARTIAL_SUCCESS') { - console.log(`✅ [DeliveryQueue] Finalized ${completedList.length} transfers`); - this.completedSinceLastSync = []; // Clear after successful sync - } else { - console.warn(`⚠️ [DeliveryQueue] Finalize sync had issues: ${result.errorMessage}`); - } - } catch (error) { - console.error('📤 [DeliveryQueue] Finalize error:', error); - } - } - } - - /** - * Get queue status for UI/debugging - */ - getQueueStatus(): DeliveryQueueStatus { - const byRetryCount: Record = {}; - let oldestAge = 0; - const now = Date.now(); - - for (const entry of this.queue.values()) { - byRetryCount[entry.retryCount] = (byRetryCount[entry.retryCount] || 0) + 1; - const age = now - entry.createdAt; - if (age > oldestAge) oldestAge = age; - } - - return { - totalPending: this.queue.size, - totalCompleted: this.completedEntries.length, - totalFailed: [...this.queue.values()].filter(e => e.retryCount >= this.config.deliveryMaxRetries).length, - byRetryCount, - oldestEntryAge: oldestAge, - activeDeliveries: this.activeDeliveries.size, - }; - } - - /** - * Get completed entries (last 100) for debugging - */ - getCompletedEntries(): NostrDeliveryQueueEntry[] { - return [...this.completedEntries]; - } - - /** - * Cleanup on app shutdown - */ - destroy(): void { - if (this.processTimer) { - clearInterval(this.processTimer); // CRITICAL: processTimer uses setInterval, not setTimeout - this.processTimer = null; - } - if (this.emptyQueueTimer) { - clearTimeout(this.emptyQueueTimer); // emptyQueueTimer uses setTimeout - this.emptyQueueTimer = null; - } - this.queue.clear(); - console.log('🛑 [DeliveryQueue] Destroyed'); - } -} - -/** - * Singleton manager for background loop lifecycle - */ -export class InventoryBackgroundLoopsManager { - private static instance: InventoryBackgroundLoopsManager | null = null; - private receiveLoop: ReceiveTokensToInventoryLoop | null = null; - private deliveryQueue: NostrDeliveryQueue | null = null; - private identityManager: IdentityManager; - private config: LoopConfig; - private isInitialized = false; - private initializationPromise: Promise | null = null; // Guard against concurrent init - - private constructor(identityManager: IdentityManager, config: LoopConfig = DEFAULT_LOOP_CONFIG) { - this.identityManager = identityManager; - this.config = config; - } - - /** - * Get singleton instance - * @param identityManager - Required on first call - */ - static getInstance(identityManager?: IdentityManager): InventoryBackgroundLoopsManager { - if (!InventoryBackgroundLoopsManager.instance) { - if (!identityManager) { - throw new Error('IdentityManager required for first getInstance() call'); - } - InventoryBackgroundLoopsManager.instance = new InventoryBackgroundLoopsManager(identityManager); - } - return InventoryBackgroundLoopsManager.instance; - } - - /** - * Reset singleton (for testing) - */ - static resetInstance(): void { - if (InventoryBackgroundLoopsManager.instance) { - InventoryBackgroundLoopsManager.instance.shutdown(); - } - InventoryBackgroundLoopsManager.instance = null; - } - - /** - * Initialize loops - * Called from DashboardLayout on mount - * - * Race-condition safe: Returns existing promise if initialization in progress - */ - async initialize(): Promise { - // Already initialized - return immediately - if (this.isInitialized) { - console.log('⚡ [LoopsManager] Already initialized'); - return; - } - - // Initialization in progress - return existing promise to avoid duplicate init - if (this.initializationPromise) { - console.log('⚡ [LoopsManager] Initialization already in progress, waiting...'); - return this.initializationPromise; - } - - // Start initialization and store promise - this.initializationPromise = this.doInitialize(); - return this.initializationPromise; - } - - /** - * Internal initialization logic - */ - private async doInitialize(): Promise { - try { - this.receiveLoop = new ReceiveTokensToInventoryLoop(this.identityManager, this.config); - this.deliveryQueue = new NostrDeliveryQueue(this.identityManager, this.config); - this.isInitialized = true; - console.log('✅ [LoopsManager] Background loops initialized'); - } finally { - // Clear promise after completion (success or failure) - this.initializationPromise = null; - } - } - - /** - * Gracefully shutdown all loops - * Called from DashboardLayout on unmount - */ - shutdown(): void { - if (this.receiveLoop) { - this.receiveLoop.destroy(); - this.receiveLoop = null; - } - if (this.deliveryQueue) { - this.deliveryQueue.destroy(); - this.deliveryQueue = null; - } - this.isInitialized = false; - console.log('🛑 [LoopsManager] Background loops shutdown'); - } - - /** - * Get receive loop (throws if not initialized) - */ - getReceiveLoop(): ReceiveTokensToInventoryLoop { - if (!this.receiveLoop) { - throw new Error('ReceiveLoop not initialized - call initialize() first'); - } - return this.receiveLoop; - } - - /** - * Get delivery queue (throws if not initialized) - */ - getDeliveryQueue(): NostrDeliveryQueue { - if (!this.deliveryQueue) { - throw new Error('DeliveryQueue not initialized - call initialize() first'); - } - return this.deliveryQueue; - } - - /** - * Get combined status of all loops - */ - getStatus(): { - receive: { pending: number; batchId: string | null; isProcessing: boolean }; - delivery: DeliveryQueueStatus; - isInitialized: boolean; - } { - return { - receive: this.receiveLoop?.getBatchStatus() || { pending: 0, batchId: null, isProcessing: false }, - delivery: this.deliveryQueue?.getQueueStatus() || { - totalPending: 0, - totalCompleted: 0, - totalFailed: 0, - byRetryCount: {}, - oldestEntryAge: 0, - activeDeliveries: 0, - }, - isInitialized: this.isInitialized, - }; - } - - /** - * Check if loops are initialized - */ - isReady(): boolean { - return this.isInitialized; - } -} diff --git a/src/components/wallet/L3/services/InventorySyncService.ts b/src/components/wallet/L3/services/InventorySyncService.ts deleted file mode 100644 index 44c61a77..00000000 --- a/src/components/wallet/L3/services/InventorySyncService.ts +++ /dev/null @@ -1,2657 +0,0 @@ -/** - * Inventory Sync Service - * - * Implements the 10-step sync flow per TOKEN_INVENTORY_SPEC.md Section 6.1 - * This is the central orchestrator for all token inventory operations. - */ - -import type { Token } from '../data/model'; -import type { - SyncMode, SyncResult, - SyncOperationStats, TokenInventoryStats, CircuitBreakerState, -} from '../types/SyncTypes'; -import type { TxfToken, TxfStorageData, TxfMeta, SentTokenEntry, InvalidTokenEntry, TombstoneEntry, InvalidatedNametagEntry } from './types/TxfTypes'; -import type { OutboxEntry } from './types/OutboxTypes'; -import type { NametagData } from './types/TxfTypes'; -import { - detectSyncMode, - shouldSkipIpfs, - shouldSkipSpentDetection, - shouldAcquireSyncLock -} from './utils/SyncModeDetector'; -import { - createDefaultSyncOperationStats, - createDefaultCircuitBreakerState -} from '../types/SyncTypes'; -import { - isTokenKey, keyFromTokenId, tokenIdFromKey, - isArchivedKey, isForkedKey, - archivedKeyFromTokenId, tokenIdFromArchivedKey, - forkedKeyFromTokenIdAndState, parseForkedKey -} from './types/TxfTypes'; -import type { TxfInclusionProof } from './types/TxfTypes'; -import { tokenToTxf, txfToToken, getCurrentStateHash } from './TxfSerializer'; -import { STORAGE_KEY_GENERATORS } from '../../../../config/storageKeys'; -import { getIpfsHttpResolver } from './IpfsHttpResolver'; -import { getIpfsTransport } from './IpfsStorageService'; -import type { IpfsTransport } from './types/IpfsTransport'; -import { getTokenValidationService } from './TokenValidationService'; -import type { InvalidReasonCode } from '../types/SyncTypes'; -import { NostrService } from './NostrService'; -import { IdentityManager } from './IdentityManager'; -import { queryClient } from '../../../../lib/queryClient'; - -function invalidateWalletQueries() { - queryClient.invalidateQueries({ queryKey: ['wallet'] }); -} - -// ============================================ -// Sync Lock State (moved from WalletRepository) -// ============================================ -// Per TOKEN_INVENTORY_SPEC.md Section 6.1: "Only inventorySync should be allowed to access the inventory in localStorage!" -// This module-level state manages the sync lock to prevent concurrent writes. - -/** Flag indicating sync is in progress */ -let _syncInProgress = false; - -/** Tokens queued during sync for next sync cycle */ -let _pendingTokens: Token[] = []; - -/** - * Coalescing mutex for inventorySync - * - If no sync in progress: start new sync - * - If sync in progress AND caller has no new data: return current sync's result (coalesce) - * - If sync in progress AND caller HAS new data: queue data, wait, return sync result - * - * This prevents the "infinite sync loop" where multiple components trigger syncs - * on startup and each waiter runs their own redundant sync after waiting. - */ -let _currentSyncPromise: Promise | null = null; - -/** - * Set the sync-in-progress flag. - * Called at the start of inventorySync(). - * While set, external token additions will be queued for next sync. - */ -export function setSyncInProgress(value: boolean): void { - console.log(`🔒 [SYNC LOCK] setSyncInProgress(${value})`); - _syncInProgress = value; -} - -/** - * Check if sync is currently in progress. - */ -export function isSyncInProgress(): boolean { - return _syncInProgress; -} - -/** - * Get tokens that were queued during sync. - * Called at the start of inventorySync to include pending tokens. - */ -export function getPendingTokens(): Token[] { - const tokens = [..._pendingTokens]; - _pendingTokens = []; // Clear after retrieval - return tokens; -} - -/** - * Queue a token for the next sync cycle. - * Called when addToken is blocked by sync lock. - */ -export function queuePendingToken(token: Token): void { - console.log(`📥 [SYNC LOCK] Queuing token ${token.id.slice(0, 8)}... for next sync`); - _pendingTokens.push(token); -} - -// ============================================ -// Types -// ============================================ - -/** - * Parameters for inventorySync() call - */ -export interface SyncParams { - /** Force LOCAL mode */ - local?: boolean; - - /** Force NAMETAG mode */ - nametag?: boolean; - - /** Incoming tokens from Nostr/peer transfer */ - incomingTokens?: Token[] | null; - - /** Outbox tokens pending send */ - outboxTokens?: OutboxEntry[] | null; - - /** Completed transfers to mark as SPENT */ - completedList?: CompletedTransfer[] | null; - - /** Wallet address (required) */ - address: string; - - /** Public key (required) */ - publicKey: string; - - /** IPNS name (required) */ - ipnsName: string; -} - -/** - * Completed transfer to mark as SPENT - */ -export interface CompletedTransfer { - tokenId: string; - stateHash: string; - inclusionProof: object; -} - -/** - * Internal sync context - accumulated state passed through pipeline - */ -interface SyncContext { - // Configuration - mode: SyncMode; - address: string; - publicKey: string; - ipnsName: string; - startTime: number; - - // Token collections (keyed by tokenId) - tokens: Map; - - // Archived tokens: tokens that were spent/transferred (keyed by tokenId) - // Used for recovery if a tombstone is found to be incorrect (BFT rollback) - archivedTokens: Map; - - // Forked tokens: tokens saved at specific states for conflict resolution - // Keyed by `${tokenId}_${stateHash}` for exact state matching - forkedTokens: Map; - - // Folder collections - sent: SentTokenEntry[]; - invalid: InvalidTokenEntry[]; - outbox: OutboxEntry[]; - tombstones: TombstoneEntry[]; - nametags: NametagData[]; - - // Completed transfers to mark as SPENT (from Step 0) - completedList: CompletedTransfer[]; - - // Sync state - localVersion: number; - remoteCid: string | null; - remoteVersion: number; - uploadNeeded: boolean; - ipnsPublished: boolean; - hasLocalOnlyContent: boolean; // Content in local that's not in remote (needs upload) - preparedStorageData: TxfStorageData | null; // Storage data prepared in step 9 for step 10 - - // Statistics - stats: SyncOperationStats; - - // Circuit breaker - circuitBreaker: CircuitBreakerState; - - // Errors - errors: string[]; -} - -// ============================================ -// Main Entry Point -// ============================================ - -/** - * Performs inventory sync operation - * - * This is the central function that orchestrates the 10-step sync flow. - * Only one instance may run at a time (except NAMETAG mode). - * - * @param params - Sync parameters - * @returns SyncResult with status and statistics - */ -export async function inventorySync(params: SyncParams): Promise { - // COALESCING MUTEX: Check if caller has new data to contribute - const hasNewData = (params.incomingTokens && params.incomingTokens.length > 0) || - (params.completedList && params.completedList.length > 0) || - (params.outboxTokens && params.outboxTokens.length > 0); - - // If a sync is already in progress... - if (_currentSyncPromise) { - // If caller has new data, save it immediately - no need for follow-up sync - // since saveTokenImmediately writes directly to localStorage - if (hasNewData) { - if (params.incomingTokens) { - for (const token of params.incomingTokens) { - // IMMEDIATE: Save token to localStorage right away so UI shows it - saveTokenImmediately(params.address, token); - } - console.log(`📥 [InventorySync] Saved ${params.incomingTokens.length} token(s) immediately during coalescing`); - // Update UI immediately - don't wait for sync - dispatchWalletUpdated(); - } - // Note: completedList and outboxTokens are less common; for now they'll wait - } - - // Wait for the current sync to complete and return its result - // This COALESCES multiple callers into one sync instead of running them sequentially - console.log(`⏳ [InventorySync] Coalescing: waiting for ongoing sync (hasNewData=${hasNewData})...`); - try { - const result = await _currentSyncPromise; - // NOTE: No follow-up sync needed - tokens were already saved via saveTokenImmediately - // Follow-up syncs were causing infinite loops when multiple useWallet instances - // each triggered refetches on sync completion - return result; - } catch { - // If previous sync failed, fall through to start a new sync - console.log(`⏳ [InventorySync] Previous sync failed, starting new sync...`); - } - } - - // No sync in progress - start a new one - // Create a deferred promise for this sync - let resolveCurrentSync: (result: SyncResult) => void; - _currentSyncPromise = new Promise((resolve) => { - resolveCurrentSync = resolve; - }); - - const startTime = Date.now(); - - // Detect sync mode based on inputs - const mode = detectSyncMode({ - local: params.local, - nametag: params.nametag, - incomingTokens: params.incomingTokens as Token[] | undefined, - outboxTokens: params.outboxTokens - }); - - console.log(`🔄 [InventorySync] Starting sync in ${mode} mode`); - - // Dispatch sync start event to lock wallet refetches - window.dispatchEvent(new Event('inventory-sync-start')); - - // Dispatch sync state event for useInventorySync hook (real-time UI updates) - window.dispatchEvent(new CustomEvent('inventory-sync-state', { - detail: { isSyncing: true, currentStep: 1, mode } - })); - - // SYNC LOCK: Prevent concurrent writes during sync - // Per TOKEN_INVENTORY_SPEC.md Section 6.1: "Only inventorySync should be allowed to access the inventory in localStorage!" - setSyncInProgress(true); - - // Initialize context - const ctx = initializeContext(params, mode, startTime); - - try { - // Collect any tokens that were queued during previous sync - const pendingTokens = getPendingTokens(); - if (pendingTokens.length > 0) { - console.log(`📥 [InventorySync] Processing ${pendingTokens.length} pending token(s) from queue`); - // Add pending tokens to incoming tokens - const existingIncoming = params.incomingTokens || []; - params.incomingTokens = [...existingIncoming, ...pendingTokens]; - } - - // NAMETAG mode: simplified flow (Steps 1, 2, 8.4 only) - if (mode === 'NAMETAG') { - const result = await executeNametagSync(ctx, params); - resolveCurrentSync!(result); - return result; - } - - // All other modes: acquire sync lock - if (shouldAcquireSyncLock(mode)) { - // TODO: Integrate with SyncCoordinator - // For now, proceed without lock - } - - // Execute full sync pipeline - const result = await executeFullSync(ctx, params); - resolveCurrentSync!(result); - - // Notify UI of wallet changes after successful sync - if (result.status === 'SUCCESS' || result.status === 'PARTIAL_SUCCESS') { - dispatchWalletUpdated(); - } - - return result; - - } catch (error) { - console.error(`❌ [InventorySync] Error:`, error); - const errorResult = buildErrorResult(ctx, error); - resolveCurrentSync!(errorResult); - return errorResult; - } finally { - // CRITICAL: Clear the mutex promise so next sync can proceed - _currentSyncPromise = null; - // CRITICAL: Always release sync lock, even on error (prevent deadlock) - setSyncInProgress(false); - // CRITICAL: Always dispatch sync-end, even on error (prevent deadlock) - window.dispatchEvent(new Event('inventory-sync-end')); - // Dispatch sync state event for useInventorySync hook (real-time UI updates) - window.dispatchEvent(new CustomEvent('inventory-sync-state', { - detail: { isSyncing: false, currentStep: 0, mode } - })); - } -} - -// ============================================ -// Sync Execution Flows -// ============================================ - -/** - * Execute NAMETAG mode sync (simplified flow) - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function executeNametagSync(ctx: SyncContext, _params: SyncParams): Promise { - // Step 1: Load nametags from localStorage only - await step1_loadLocalStorage(ctx); - - // Step 2: Load nametags from IPFS - await step2_loadIpfs(ctx); - - // Step 8.4: Extract nametags for current user (filters for ownership) - const nametags = await step8_4_extractNametags(ctx); - - return buildNametagResult(ctx, nametags); -} - -/** - * Execute full sync (NORMAL/FAST/LOCAL modes) - */ -async function executeFullSync(ctx: SyncContext, params: SyncParams): Promise { - // Step 0: Input Processing - step0_inputProcessing(ctx, params); - - // Step 1: Load from localStorage - await step1_loadLocalStorage(ctx); - - // Step 2: Load from IPFS (skip in LOCAL mode) - if (!shouldSkipIpfs(ctx.mode)) { - await step2_loadIpfs(ctx); - } - - // Step 3: Proof Normalization - step3_normalizeProofs(ctx); - - // Step 4: Commitment Validation - await step4_validateCommitments(ctx); - - // Step 5: Token Validation - await step5_validateTokens(ctx); - - // Step 6: Token Deduplication - step6_deduplicateTokens(ctx); - - // Step 7: Spent Token Detection (skip in FAST/LOCAL mode) - if (!shouldSkipSpentDetection(ctx.mode)) { - await step7_detectSpentTokens(ctx); - } - - // Step 7.5: Verify Tombstones (skip in FAST/LOCAL mode) - if (!shouldSkipSpentDetection(ctx.mode)) { - await step7_5_verifyTombstones(ctx); - } - - // Step 8: Folder Assignment / Merge Inventory - step8_mergeInventory(ctx); - - // Step 8.4: Filter nametags for current user ownership - ctx.nametags = await step8_4_extractNametags(ctx); - - // Step 8.5: Ensure nametag bindings are registered with Nostr - // Best-effort, non-blocking - failures don't stop sync - await step8_5_ensureNametagNostrBinding(ctx); - - // Step 9: Prepare for Storage - step9_prepareStorage(ctx); - - // Step 10: Upload to IPFS (skip in LOCAL mode) - if (ctx.uploadNeeded && !shouldSkipIpfs(ctx.mode)) { - await step10_uploadIpfs(ctx); - } - - return buildSuccessResult(ctx); -} - -// ============================================ -// Step Implementations (Stubs - to be filled in) -// ============================================ - -function initializeContext(params: SyncParams, mode: SyncMode, startTime: number): SyncContext { - return { - mode, - address: params.address, - publicKey: params.publicKey, - ipnsName: params.ipnsName, - startTime, - tokens: new Map(), - archivedTokens: new Map(), // Archived tokens for recovery - forkedTokens: new Map(), // Forked tokens at specific states - sent: [], - invalid: [], - outbox: [], - tombstones: [], - nametags: [], - completedList: [], - localVersion: 0, - remoteCid: null, - remoteVersion: 0, - uploadNeeded: false, - ipnsPublished: false, - hasLocalOnlyContent: false, - preparedStorageData: null, - stats: createDefaultSyncOperationStats(), - circuitBreaker: createDefaultCircuitBreakerState(), - errors: [] - }; -} - -function step0_inputProcessing(ctx: SyncContext, params: SyncParams): void { - console.log(`📥 [Step 0] Input Processing`); - - // Process incoming tokens from Nostr/peer transfers - if (params.incomingTokens && params.incomingTokens.length > 0) { - console.log(` Processing ${params.incomingTokens.length} incoming tokens`); - for (const token of params.incomingTokens) { - const txf = tokenToTxf(token); - if (txf) { - const tokenId = txf.genesis.data.tokenId; - ctx.tokens.set(tokenId, txf); - ctx.stats.tokensImported++; - } else { - console.warn(` Failed to convert incoming token ${token.id} to TXF format`); - } - } - } - - // Process outbox tokens (pending transfers) - if (params.outboxTokens && params.outboxTokens.length > 0) { - console.log(` Processing ${params.outboxTokens.length} outbox entries`); - ctx.outbox.push(...params.outboxTokens); - } - - // Process completed transfers (to mark as SPENT) - if (params.completedList && params.completedList.length > 0) { - console.log(` Processing ${params.completedList.length} completed transfers`); - ctx.completedList.push(...params.completedList); - } - - console.log(` Input processing complete: ${ctx.tokens.size} tokens, ${ctx.outbox.length} outbox, ${ctx.completedList.length} completed`); -} - -async function step1_loadLocalStorage(ctx: SyncContext): Promise { - console.log(`💾 [Step 1] Load from localStorage`); - - // Per TOKEN_INVENTORY_SPEC.md Section 6.1: - // "Only inventorySync should be allowed to access the inventory in localStorage!" - // - // We load directly from localStorage in TxfStorageData format (the canonical format). - // However, we also detect StoredWallet format (from legacy WalletRepository) and convert it. - // This ensures backward compatibility while maintaining spec compliance. - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(ctx.address); - const json = localStorage.getItem(storageKey); - - if (!json) { - console.log(` No wallet data found for address ${ctx.address.slice(0, 20)}...`); - return; - } - - let data: Record; - try { - data = JSON.parse(json); - } catch { - console.warn(` Failed to parse localStorage data`); - return; - } - - // Detect format: TxfStorageData has _meta and _ keys - // StoredWallet has tokens: Token[] array - const isTxfFormat = data._meta !== undefined || Object.keys(data).some(k => isTokenKey(k)); - const isStoredWalletFormat = Array.isArray(data.tokens); - - if (isStoredWalletFormat && !isTxfFormat) { - console.log(` Detected StoredWallet format, converting...`); - // Convert StoredWallet format to TxfStorageData format - await loadFromStoredWalletFormat(ctx, data); - } else { - // Load from TxfStorageData format (the canonical format) - await loadFromTxfStorageDataFormat(ctx, data); - } -} - -// Load from TxfStorageData format (canonical format per spec) -async function loadFromTxfStorageDataFormat(ctx: SyncContext, data: Record): Promise { - // Load metadata - const meta = data._meta as TxfMeta | undefined; - if (meta?.version) { - ctx.localVersion = meta.version; - console.log(` Loaded metadata: version=${ctx.localVersion}`); - } - - // Load nametag - if (data._nametag) { - const nametag = data._nametag as NametagData; - ctx.nametags.push(nametag); - console.log(` Loaded nametag: ${nametag.name}`); - - // NAMETAG mode: only load nametag, skip rest - if (ctx.mode === 'NAMETAG') { - return; - } - } else if (ctx.mode === 'NAMETAG') { - return; // NAMETAG mode but no nametag found - } - - // Load tombstones - if (data._tombstones && Array.isArray(data._tombstones)) { - ctx.tombstones.push(...(data._tombstones as TombstoneEntry[])); - console.log(` Loaded ${ctx.tombstones.length} tombstones`); - } - - // Load active tokens from _ keys - let tokenCount = 0; - for (const key of Object.keys(data)) { - if (isTokenKey(key)) { - const txf = data[key] as TxfToken; - const tokenId = tokenIdFromKey(key); - // Don't overwrite incoming tokens from Step 0 - if (!ctx.tokens.has(tokenId)) { - ctx.tokens.set(tokenId, txf); - tokenCount++; - } - } - } - console.log(` Loaded ${tokenCount} active tokens from localStorage`); - - // Load sent, invalid, outbox (for IPFS round-trip) - if (data._sent && Array.isArray(data._sent)) { - ctx.sent.push(...(data._sent as SentTokenEntry[])); - console.log(` Loaded ${ctx.sent.length} sent tokens`); - } - if (data._invalid && Array.isArray(data._invalid)) { - ctx.invalid.push(...(data._invalid as InvalidTokenEntry[])); - console.log(` Loaded ${ctx.invalid.length} invalid tokens`); - } - if (data._outbox && Array.isArray(data._outbox)) { - ctx.outbox.push(...(data._outbox as OutboxEntry[])); - console.log(` Loaded ${ctx.outbox.length} outbox entries`); - } - - // Load archived tokens from _archived_ keys - // These are tokens that were spent/transferred but kept for recovery - let archivedCount = 0; - for (const key of Object.keys(data)) { - if (isArchivedKey(key)) { - const txf = data[key] as TxfToken; - const tokenId = tokenIdFromArchivedKey(key); - ctx.archivedTokens.set(tokenId, txf); - archivedCount++; - } - } - if (archivedCount > 0) { - console.log(` Loaded ${archivedCount} archived tokens from localStorage`); - } - - // Load forked tokens from _forked__ keys - // These are tokens saved at specific states for conflict resolution - let forkedCount = 0; - for (const key of Object.keys(data)) { - if (isForkedKey(key)) { - const txf = data[key] as TxfToken; - // Store with the full key (includes tokenId and stateHash) - const parsed = parseForkedKey(key); - if (parsed) { - const forkedKey = `${parsed.tokenId}_${parsed.stateHash}`; - ctx.forkedTokens.set(forkedKey, txf); - forkedCount++; - } - } - } - if (forkedCount > 0) { - console.log(` Loaded ${forkedCount} forked tokens from localStorage`); - } -} - -// Load from StoredWallet format (legacy WalletRepository format) and convert to TxfStorageData -async function loadFromStoredWalletFormat(ctx: SyncContext, data: Record): Promise { - // Load nametag - if (data.nametag) { - const nametag = data.nametag as NametagData; - ctx.nametags.push(nametag); - console.log(` Loaded nametag: ${nametag.name}`); - - // NAMETAG mode: only load nametag, skip rest - if (ctx.mode === 'NAMETAG') { - return; - } - } else if (ctx.mode === 'NAMETAG') { - return; - } - - // Load tombstones - if (data.tombstones && Array.isArray(data.tombstones)) { - ctx.tombstones.push(...(data.tombstones as TombstoneEntry[])); - console.log(` Loaded ${ctx.tombstones.length} tombstones`); - } - - // Load tokens from tokens: Token[] array and convert to TxfToken format - const tokens = data.tokens as Token[] | undefined; - if (tokens && Array.isArray(tokens)) { - let tokenCount = 0; - for (const token of tokens) { - const txf = tokenToTxf(token); - if (txf && txf.genesis?.data?.tokenId) { - const tokenId = txf.genesis.data.tokenId; - // Don't overwrite incoming tokens from Step 0 - if (!ctx.tokens.has(tokenId)) { - ctx.tokens.set(tokenId, txf); - tokenCount++; - } - } - } - console.log(` Loaded ${tokenCount} active tokens (converted from StoredWallet format)`); - } - - // Note: StoredWallet format doesn't have _sent, _invalid, _outbox - // These will be loaded from IPFS in Step 2 -} - -async function step2_loadIpfs(ctx: SyncContext): Promise { - console.log(`🌐 [Step 2] Load from IPFS`); - - // Early validation: skip IPFS loading if IPNS name is not available - // This is normal for new wallets that haven't published to IPNS yet - if (!ctx.ipnsName || ctx.ipnsName.trim().length === 0) { - console.log(` ⏭️ Skipping IPFS load: no IPNS name configured (new wallet or LOCAL mode)`); - return; // Continue with local-only data - } - - // Try to use IpfsTransport if available (provides better sequence tracking) - let transport: IpfsTransport | null = null; - try { - transport = getIpfsTransport(); - } catch { - // Fall back to HTTP resolver only - } - - let remoteData: TxfStorageData | null = null; - - if (transport) { - // Initialize transport with identity so cachedIpnsName gets set - // This ensures IPFS is ready even if we don't need to upload later - const initialized = await transport.ensureInitialized(); - if (!initialized) { - console.log(` ⚠️ Transport initialization failed, falling back to HTTP resolver`); - transport = null; - } - } - - if (transport) { - // Use full transport API (better sequence tracking, dual DHT+HTTP) - console.log(` Using IpfsTransport for IPNS resolution...`); - const resolution = await transport.resolveIpns(); - - if (resolution.cid) { - ctx.remoteCid = resolution.cid; - ctx.remoteVersion = resolution.content?._meta?.version || 0; - remoteData = resolution.content || null; - console.log(` Transport resolved: CID=${resolution.cid.slice(0, 16)}..., seq=${resolution.sequence}, version=${ctx.remoteVersion}`); - } else { - // Transport returned no CID - this can happen if IPFS isn't fully initialized yet - // (cachedIpnsName not set). Fall through to HTTP resolver which doesn't require Helia. - console.log(` Transport IPNS resolution returned no CID, trying HTTP resolver...`); - } - } - - // Fallback to HTTP resolver if transport didn't return data - // (either transport not available, or cachedIpnsName not set yet) - if (!remoteData) { - // Fallback to HTTP resolver (transport unavailable or not initialized yet) - console.log(` Using HTTP resolver for IPNS resolution...`); - const resolver = getIpfsHttpResolver(); - - // 1. Resolve IPNS name to get CID and content - const resolution = await resolver.resolveIpnsName(ctx.ipnsName); - - if (!resolution.success) { - console.warn(` IPNS resolution failed: ${resolution.error || 'unknown error'}`); - return; // Continue with local-only data - } - - if (!resolution.content) { - console.log(` IPNS resolved but no content (new wallet or empty IPNS)`); - return; - } - - ctx.remoteCid = resolution.cid || null; - remoteData = resolution.content; - } - - if (!remoteData) { - console.log(` No remote data available`); - return; - } - - // Extract remote version - if (remoteData._meta) { - ctx.remoteVersion = remoteData._meta.version || 0; - console.log(` Remote version: ${ctx.remoteVersion}, Local version: ${ctx.localVersion}`); - // Log warning if remote version seems stale (much lower than expected for active wallet) - // This can indicate IPNS propagation issues - if (ctx.localVersion > 0 && ctx.remoteVersion < ctx.localVersion) { - console.warn(` ⚠️ Remote version (${ctx.remoteVersion}) is LOWER than local (${ctx.localVersion}) - possible stale IPNS data`); - } - } - - // 2. Merge remote tokens into context - // Track which tokens were only in local (for upload detection) - const localOnlyTokenIds = new Set(ctx.tokens.keys()); - - let tokensImported = 0; - for (const key of Object.keys(remoteData)) { - if (isTokenKey(key)) { - const remoteTxf = remoteData[key] as TxfToken; - if (!remoteTxf || !remoteTxf.genesis?.data?.tokenId) continue; - - // Use the storage key (tokenIdFromKey) for consistency with Step 1 - // Step 1 uses tokenIdFromKey(key) to extract tokenId, so we must match that - const tokenId = tokenIdFromKey(key); - const localTxf = ctx.tokens.get(tokenId); - - // Mark this token as not local-only (exists in remote) - localOnlyTokenIds.delete(tokenId); - - // Prefer remote if: no local, or remote has more transactions - if (!localTxf || shouldPreferRemote(localTxf, remoteTxf)) { - ctx.tokens.set(tokenId, remoteTxf); - if (!localTxf) tokensImported++; - } - } - } - - // Any tokens still in localOnlyTokenIds are local-only (not in remote) - if (localOnlyTokenIds.size > 0) { - ctx.hasLocalOnlyContent = true; - console.log(` 📤 ${localOnlyTokenIds.size} local-only token(s) not in remote - will upload`); - } - - // 3. Merge remote tombstones (union merge) - if (remoteData._tombstones && Array.isArray(remoteData._tombstones)) { - const existingKeys = new Set( - ctx.tombstones.map(t => `${t.tokenId}:${t.stateHash}`) - ); - for (const tombstone of remoteData._tombstones as TombstoneEntry[]) { - const key = `${tombstone.tokenId}:${tombstone.stateHash}`; - if (!existingKeys.has(key)) { - ctx.tombstones.push(tombstone); - } - } - } - - // 4. Merge remote sent tokens (union merge by tokenId:stateHash) - // Multiple entries with same tokenId but different stateHash are allowed - // (supports boomerang scenarios where token returns at different states) - // NOTE: If stateHash is unavailable (getCurrentStateHash returns undefined), - // we still import the token using tokenId-only key to avoid losing sent history. - console.log(` 📤 IPFS _sent folder: ${remoteData._sent ? (Array.isArray(remoteData._sent) ? remoteData._sent.length : 'not-array') : 'undefined'} entries, local: ${ctx.sent.length}`); - if (remoteData._sent && Array.isArray(remoteData._sent)) { - const existingKeys = new Set( - ctx.sent.map(s => { - const tokenId = s.token.genesis?.data?.tokenId || ''; - const stateHash = getCurrentStateHash(s.token) || ''; - return `${tokenId}:${stateHash}`; - }) - ); - // Also track by tokenId-only for fallback deduplication - const existingTokenIds = new Set( - ctx.sent.map(s => s.token.genesis?.data?.tokenId || '') - ); - let sentImported = 0; - for (const sentEntry of remoteData._sent as SentTokenEntry[]) { - const tokenId = sentEntry.token?.genesis?.data?.tokenId; - if (!tokenId) continue; // Skip invalid entries - - const stateHash = getCurrentStateHash(sentEntry.token); - const key = `${tokenId}:${stateHash || 'unknown'}`; - - // Primary dedup: tokenId:stateHash (when stateHash available) - // Fallback dedup: tokenId-only (when stateHash unavailable) - const isDuplicate = stateHash - ? existingKeys.has(key) - : existingTokenIds.has(tokenId); - - if (!isDuplicate) { - ctx.sent.push(sentEntry); - existingKeys.add(key); - // Only add to tokenId-only set when using fallback (stateHash unavailable) - // This prevents incorrectly blocking entries with same tokenId but different stateHash - if (!stateHash) { - existingTokenIds.add(tokenId); - } - sentImported++; - } - } - if (sentImported > 0) { - console.log(` 📤 Imported ${sentImported} sent token(s) from IPFS`); - } - } - - // 5. Merge remote invalid tokens (union merge by tokenId:stateHash) - // Multiple entries with same tokenId but different stateHash are allowed - // (a token may fail validation at different states for different reasons) - // NOTE: If stateHash is unavailable, we still import using tokenId-only key. - if (remoteData._invalid && Array.isArray(remoteData._invalid)) { - const existingKeys = new Set( - ctx.invalid.map(i => { - const tokenId = i.token.genesis?.data?.tokenId || ''; - const stateHash = getCurrentStateHash(i.token) || ''; - return `${tokenId}:${stateHash}`; - }) - ); - // Also track by tokenId-only for fallback deduplication - const existingTokenIds = new Set( - ctx.invalid.map(i => i.token.genesis?.data?.tokenId || '') - ); - let invalidImported = 0; - for (const invalidEntry of remoteData._invalid as InvalidTokenEntry[]) { - const tokenId = invalidEntry.token?.genesis?.data?.tokenId; - if (!tokenId) continue; // Skip invalid entries - - const stateHash = getCurrentStateHash(invalidEntry.token); - const key = `${tokenId}:${stateHash || 'unknown'}`; - - // Primary dedup: tokenId:stateHash (when stateHash available) - // Fallback dedup: tokenId-only (when stateHash unavailable) - const isDuplicate = stateHash - ? existingKeys.has(key) - : existingTokenIds.has(tokenId); - - if (!isDuplicate) { - ctx.invalid.push(invalidEntry); - existingKeys.add(key); - // Only add to tokenId-only set when using fallback (stateHash unavailable) - if (!stateHash) { - existingTokenIds.add(tokenId); - } - invalidImported++; - } - } - if (invalidImported > 0) { - console.log(` ⚠️ Imported ${invalidImported} invalid token(s) from IPFS`); - } - } - - // 6. Merge remote nametag if present - if (remoteData._nametag && ctx.nametags.length === 0) { - ctx.nametags.push(remoteData._nametag); - console.log(` Imported nametag: ${remoteData._nametag.name}`); - } else if (!remoteData._nametag && ctx.nametags.length > 0) { - // Local has nametag, remote doesn't - need to upload - ctx.hasLocalOnlyContent = true; - console.log(` 📤 Local nametag "${ctx.nametags[0].name}" not in remote - will upload`); - } - - ctx.stats.tokensImported = tokensImported; - console.log(` ✓ Loaded from IPFS: ${tokensImported} new tokens, ${ctx.tombstones.length} tombstones`); -} - -/** - * Determine if remote token should be preferred over local - * Prefers token with more transactions (more advanced state) - */ -function shouldPreferRemote(local: TxfToken, remote: TxfToken): boolean { - const localTxCount = local.transactions?.length || 0; - const remoteTxCount = remote.transactions?.length || 0; - - // Prefer remote if it has more transactions - if (remoteTxCount > localTxCount) { - return true; - } - - // If same transaction count, prefer the one with more committed transactions - if (remoteTxCount === localTxCount && remoteTxCount > 0) { - const localCommitted = local.transactions.filter(tx => tx.inclusionProof !== null).length; - const remoteCommitted = remote.transactions.filter(tx => tx.inclusionProof !== null).length; - return remoteCommitted > localCommitted; - } - - return false; -} - -function step3_normalizeProofs(ctx: SyncContext): void { - console.log(`📋 [Step 3] Normalize Proofs`); - - let normalizedCount = 0; - - for (const txf of ctx.tokens.values()) { - // Normalize genesis proof - if (txf.genesis?.inclusionProof) { - if (normalizeInclusionProof(txf.genesis.inclusionProof)) { - normalizedCount++; - } - } - - // Normalize transaction proofs - if (txf.transactions) { - for (const tx of txf.transactions) { - if (tx.inclusionProof) { - if (normalizeInclusionProof(tx.inclusionProof)) { - normalizedCount++; - } - } - } - } - } - - if (normalizedCount > 0) { - console.log(` Normalized ${normalizedCount} inclusion proof(s)`); - } else { - console.log(` No proofs needed normalization`); - } -} - -/** - * Normalize an inclusion proof to ensure consistent format. - * Returns true if any normalization was applied. - * - * - Ensures stateHash has "0000" prefix (Unicity hash format) - * - Ensures merkle root has "0000" prefix - */ -function normalizeInclusionProof(proof: TxfInclusionProof): boolean { - let normalized = false; - - // Normalize authenticator stateHash - if (proof.authenticator?.stateHash) { - if (!proof.authenticator.stateHash.startsWith('0000')) { - proof.authenticator.stateHash = '0000' + proof.authenticator.stateHash; - normalized = true; - } - } - - // Normalize merkle tree root - if (proof.merkleTreePath?.root) { - if (!proof.merkleTreePath.root.startsWith('0000')) { - proof.merkleTreePath.root = '0000' + proof.merkleTreePath.root; - normalized = true; - } - } - - return normalized; -} - -async function step4_validateCommitments(ctx: SyncContext): Promise { - console.log(`✓ [Step 4] Validate Commitments`); - - const invalidTokenIds: string[] = []; - let validatedCount = 0; - - for (const [tokenId, txf] of ctx.tokens) { - // Step 4.1: Validate genesis commitment - const genesisValid = validateGenesisCommitment(txf); - if (!genesisValid.valid) { - console.warn(` Token ${tokenId.slice(0, 8)}... failed genesis validation: ${genesisValid.reason}`); - invalidTokenIds.push(tokenId); - ctx.invalid.push({ - token: txf, - timestamp: Date.now(), - invalidatedAt: Date.now(), - reason: 'PROOF_MISMATCH' as InvalidReasonCode, - details: `Genesis: ${genesisValid.reason}` - }); - continue; - } - - // Step 4.2: Validate each transaction commitment - let txValid = true; - if (txf.transactions && txf.transactions.length > 0) { - for (let i = 0; i < txf.transactions.length; i++) { - const tx = txf.transactions[i]; - if (tx.inclusionProof) { - const txResult = validateTransactionCommitment(txf, i); - if (!txResult.valid) { - console.warn(` Token ${tokenId.slice(0, 8)}... failed transaction ${i} validation: ${txResult.reason}`); - invalidTokenIds.push(tokenId); - ctx.invalid.push({ - token: txf, - timestamp: Date.now(), - invalidatedAt: Date.now(), - reason: 'PROOF_MISMATCH' as InvalidReasonCode, - details: `Transaction ${i}: ${txResult.reason}` - }); - txValid = false; - break; - } - } - } - } - - if (txValid) { - validatedCount++; - } - } - - // Remove invalid tokens from active set - for (const tokenId of invalidTokenIds) { - ctx.tokens.delete(tokenId); - ctx.stats.tokensRemoved++; - } - - console.log(` ✓ Validated ${validatedCount} tokens, ${invalidTokenIds.length} moved to Invalid folder`); -} - -/** - * Validate hex string format (with optional "0000" prefix) - */ -function isValidHexString(value: string, minLength: number = 64): boolean { - if (!value || typeof value !== 'string') return false; - // Strip "0000" prefix if present - const hex = value.startsWith('0000') ? value.slice(4) : value; - // Check it's valid hex of sufficient length - return /^[0-9a-fA-F]+$/.test(hex) && hex.length >= minLength - 4; -} - -/** - * Validate genesis commitment matches inclusion proof. - * - * Step 4 Validation (per TOKEN_INVENTORY_SPEC.md): - * - Structural integrity: All required fields present and properly formatted - * - State hash chain: Genesis stateHash establishes chain root - * - Format validation: Transaction hash and state hash are valid hex strings - * - Genesis tokenId derivation: Verify hash(genesis.data) === tokenId (TODO) - * - Proof payload integrity: Verify hash(proof.transaction) === transactionHash (TODO) - * - * Note: Full cryptographic proof verification (signature validation, merkle path) - * is performed by the Unicity SDK in Step 5 via TokenValidationService. - * - * TODO (AMENDMENT 2): Enhanced validation requires SDK integration: - * - Use Token.fromJSON() to reconstruct genesis transaction - * - Calculate hash of genesis transaction data - * - Verify calculated hash matches txf.genesis.data.tokenId - * - Verify proof.transactionHash matches calculated transaction hash - * - * For now, we perform structural validation only. Full cryptographic validation - * happens in Step 5 via SDK's token.verify(trustBase). - */ -function validateGenesisCommitment(txf: TxfToken): { valid: boolean; reason?: string } { - if (!txf.genesis) { - return { valid: false, reason: 'Missing genesis' }; - } - - if (!txf.genesis.inclusionProof) { - return { valid: false, reason: 'Missing genesis inclusion proof' }; - } - - const proof = txf.genesis.inclusionProof; - - // Verify authenticator is present and has stateHash - if (!proof.authenticator?.stateHash) { - return { valid: false, reason: 'Missing authenticator stateHash' }; - } - - // Verify stateHash format (should be hex, typically with "0000" prefix) - if (!isValidHexString(proof.authenticator.stateHash, 64)) { - return { valid: false, reason: 'Invalid stateHash format' }; - } - - // Verify merkle path is present - if (!proof.merkleTreePath?.root) { - return { valid: false, reason: 'Missing merkle tree root' }; - } - - // Verify merkle root format - if (!isValidHexString(proof.merkleTreePath.root, 64)) { - return { valid: false, reason: 'Invalid merkle root format' }; - } - - // Verify transactionHash format if present (optional field) - // Note: transactionHash is not required because: - // 1. Some SDK versions/faucet tokens don't populate this field - // 2. Full cryptographic validation happens in Step 5 via SDK's token.verify() - // 3. Making this required causes valid tokens to be incorrectly invalidated - if (proof.transactionHash && !isValidHexString(proof.transactionHash, 64)) { - return { valid: false, reason: 'Invalid transactionHash format' }; - } - - // Verify genesis data is present (needed to verify transaction hash) - if (!txf.genesis.data?.tokenId) { - return { valid: false, reason: 'Missing genesis data tokenId' }; - } - - // TODO (AMENDMENT 2): Add cryptographic validation - // 1. Verify hash(genesis.data) === tokenId (genesis tokenId derivation) - // 2. Verify hash(proof.transaction) === transactionHash (inclusion proof payload) - // - // This requires SDK integration: - // - const sdkToken = await Token.fromJSON(txf); - // - const genesisTransaction = sdkToken.getGenesisTransaction(); - // - const calculatedHash = await genesisTransaction.calculateHash(); - // - if (calculatedHash.toJSON() !== txf.genesis.data.tokenId) return false; - // - if (proof.transactionHash !== calculatedHash.toJSON()) return false; - - return { valid: true }; -} - -/** - * Validate transaction commitment matches inclusion proof. - * - * Step 4 Validation for state transitions: - * - State hash chain integrity: previousStateHash links correctly - * - Format validation: All hashes are valid hex strings - * - Structural integrity: Required proof fields present - * - Transaction hash integrity: Verify hash(proof.transaction) === tx.transactionHash (TODO) - * - * Note: Full cryptographic proof verification (signature validation, merkle path) - * is performed by the Unicity SDK in Step 5 via TokenValidationService. - * - * TODO (AMENDMENT 2): Enhanced validation requires SDK integration: - * - Use Token.fromJSON() to reconstruct transaction object - * - Calculate hash of transaction data - * - Verify proof.transactionHash matches calculated transaction hash - * - * For now, we perform structural validation only. Full cryptographic validation - * happens in Step 5 via SDK's token.verify(trustBase). - */ -function validateTransactionCommitment(txf: TxfToken, txIndex: number): { valid: boolean; reason?: string } { - const tx = txf.transactions[txIndex]; - if (!tx) { - return { valid: false, reason: `Transaction ${txIndex} not found` }; - } - - if (!tx.inclusionProof) { - // Uncommitted transaction - no proof to validate - return { valid: true }; - } - - const proof = tx.inclusionProof; - - // Verify authenticator is present - if (!proof.authenticator?.stateHash) { - return { valid: false, reason: 'Missing authenticator stateHash' }; - } - - // Verify stateHash format - if (!isValidHexString(proof.authenticator.stateHash, 64)) { - return { valid: false, reason: 'Invalid authenticator stateHash format' }; - } - - // Verify merkle path is present - if (!proof.merkleTreePath?.root) { - return { valid: false, reason: 'Missing merkle tree root' }; - } - - // Verify merkle root format - if (!isValidHexString(proof.merkleTreePath.root, 64)) { - return { valid: false, reason: 'Invalid merkle root format' }; - } - - // Verify transactionHash if present - if (proof.transactionHash && !isValidHexString(proof.transactionHash, 64)) { - return { valid: false, reason: 'Invalid transactionHash format' }; - } - - // Verify state hash chain integrity - // Note: Some tokens from faucet/SDK may not have previousStateHash populated. - // For the first transaction, we can derive it from genesis stateHash. - // Full cryptographic validation is done by SDK in Step 5. - if (txIndex === 0) { - // First transaction should reference genesis state - const genesisStateHash = txf.genesis?.inclusionProof?.authenticator?.stateHash; - if (!genesisStateHash) { - return { valid: false, reason: 'Cannot verify chain - missing genesis stateHash' }; - } - - // If previousStateHash is present, validate it matches genesis - if (tx.previousStateHash) { - if (!isValidHexString(tx.previousStateHash, 64)) { - return { valid: false, reason: 'Invalid previousStateHash format' }; - } - if (tx.previousStateHash !== genesisStateHash) { - return { valid: false, reason: `Chain break: previousStateHash doesn't match genesis (expected ${genesisStateHash.slice(0, 16)}..., got ${tx.previousStateHash.slice(0, 16)}...)` }; - } - } - // If previousStateHash is missing on first transaction, that's OK - we know it should be genesis stateHash - // Full SDK validation in Step 5 will verify the actual cryptographic proof - } else { - // Subsequent transactions MUST have previousStateHash - if (!tx.previousStateHash || !isValidHexString(tx.previousStateHash, 64)) { - return { valid: false, reason: 'Invalid or missing previousStateHash' }; - } - // Subsequent transactions should reference previous tx's new state - const prevTx = txf.transactions[txIndex - 1]; - if (prevTx?.newStateHash && tx.previousStateHash !== prevTx.newStateHash) { - return { valid: false, reason: `Chain break: previousStateHash doesn't match tx ${txIndex - 1}` }; - } - } - - return { valid: true }; -} - -async function step5_validateTokens(ctx: SyncContext): Promise { - console.log(`🔍 [Step 5] Validate Tokens`); - - if (ctx.tokens.size === 0) { - console.log(` No tokens to validate`); - return; - } - - const validationService = getTokenValidationService(); - - // Convert TxfTokens to LocalTokens for validation - const tokensToValidate: Token[] = []; - const tokenIdMap = new Map(); // localId -> txfTokenId - - for (const [tokenId, txf] of ctx.tokens) { - const localToken = txfToToken(tokenId, txf); - if (localToken) { - tokensToValidate.push(localToken); - tokenIdMap.set(localToken.id, tokenId); - } - } - - if (tokensToValidate.length === 0) { - console.log(` No tokens could be converted for validation`); - return; - } - - // Validate all tokens with progress callback - try { - const result = await validationService.validateAllTokens(tokensToValidate, { - batchSize: 5, - onProgress: (completed, total) => { - if (completed % 10 === 0 || completed === total) { - console.log(` Validated ${completed}/${total} tokens`); - } - } - }); - - // Process issues - move invalid tokens to Invalid folder - for (const issue of result.issues) { - const txfTokenId = tokenIdMap.get(issue.tokenId) || issue.tokenId; - const txf = ctx.tokens.get(txfTokenId); - - if (txf) { - ctx.invalid.push({ - token: txf, - timestamp: Date.now(), - invalidatedAt: Date.now(), - reason: 'SDK_VALIDATION' as InvalidReasonCode, - details: issue.reason - }); - ctx.tokens.delete(txfTokenId); - ctx.stats.tokensRemoved++; - console.warn(` Token ${txfTokenId.slice(0, 8)}... failed SDK validation: ${issue.reason}`); - } - } - - ctx.stats.tokensValidated = tokensToValidate.length; - console.log(` ✓ SDK validation: ${result.validTokens.length} valid, ${result.issues.length} invalid`); - - } catch (error) { - // SDK validation failure is non-fatal - log and continue - console.warn(` SDK validation error (non-fatal):`, error); - ctx.errors.push(`SDK validation error: ${error instanceof Error ? error.message : String(error)}`); - } -} - -function step6_deduplicateTokens(ctx: SyncContext): void { - console.log(`🔀 [Step 6] Deduplicate Tokens`); - - const beforeCount = ctx.tokens.size; - const uniqueTokens = new Map(); - - // Deduplication strategy: - // - Group tokens by tokenId (the actual token identity) - // - For each tokenId, keep the most advanced version (more transactions or more committed) - // - Track unique tokenId:stateHash combinations seen for logging - - const seenStates = new Set(); - - for (const [tokenId, txf] of ctx.tokens) { - const stateHash = getCurrentStateHash(txf) || 'NO_STATE'; - const stateKey = `${tokenId}:${stateHash}`; - - // Track unique states seen - seenStates.add(stateKey); - - const existing = uniqueTokens.get(tokenId); - if (!existing) { - // First time seeing this tokenId - uniqueTokens.set(tokenId, txf); - continue; - } - - // Compare and keep the more advanced version - if (shouldPreferRemote(existing, txf)) { - // Current txf is more advanced - replace - uniqueTokens.set(tokenId, txf); - } - // Otherwise keep existing (more advanced) - } - - // Replace ctx.tokens with deduplicated map - ctx.tokens.clear(); - for (const [tokenId, txf] of uniqueTokens) { - ctx.tokens.set(tokenId, txf); - } - - const afterCount = ctx.tokens.size; - const duplicatesRemoved = beforeCount - afterCount; - const uniqueStates = seenStates.size; - - if (duplicatesRemoved > 0) { - console.log(` Removed ${duplicatesRemoved} duplicate tokens (${beforeCount} → ${afterCount})`); - console.log(` Unique tokenId:stateHash combinations: ${uniqueStates}`); - } else { - console.log(` No duplicates found (${afterCount} tokens)`); - } -} - -async function step7_detectSpentTokens(ctx: SyncContext): Promise { - console.log(`💸 [Step 7] Detect Spent Tokens`); - - if (ctx.tokens.size === 0) { - console.log(` No tokens to check for spent status`); - return; - } - - const validationService = getTokenValidationService(); - - // Convert TxfTokens to LocalTokens - const tokensToCheck: Token[] = []; - const tokenIdMap = new Map(); // localId -> txfTokenId - - for (const [tokenId, txf] of ctx.tokens) { - const localToken = txfToToken(tokenId, txf); - if (localToken) { - tokensToCheck.push(localToken); - tokenIdMap.set(localToken.id, tokenId); - } - } - - if (tokensToCheck.length === 0) { - console.log(` No tokens could be converted for spent checking`); - return; - } - - try { - // Check spent status against aggregator - const result = await validationService.checkSpentTokens( - tokensToCheck, - ctx.publicKey, - { - batchSize: 3, - onProgress: (completed, total) => { - if (completed % 5 === 0 || completed === total) { - console.log(` Checked ${completed}/${total} tokens for spent status`); - } - } - } - ); - - // Move spent tokens to Sent folder and add tombstones - for (const spentInfo of result.spentTokens) { - const txfTokenId = tokenIdMap.get(spentInfo.localId) || spentInfo.tokenId; - const txf = ctx.tokens.get(txfTokenId); - - if (txf) { - // Move to Sent folder - ctx.sent.push({ - token: txf, - timestamp: Date.now(), - spentAt: Date.now() - }); - - // Add tombstone - ctx.tombstones.push({ - tokenId: spentInfo.tokenId, - stateHash: spentInfo.stateHash, - timestamp: Date.now() - }); - - // Remove from active - ctx.tokens.delete(txfTokenId); - ctx.stats.tokensRemoved++; - ctx.stats.tombstonesAdded++; - - console.log(` 💸 Token ${spentInfo.tokenId.slice(0, 8)}... is SPENT, moved to Sent folder`); - } - } - - // Log errors (non-fatal) - for (const error of result.errors) { - ctx.errors.push(error); - } - - console.log(` ✓ Spent detection: ${result.spentTokens.length} spent, ${tokensToCheck.length - result.spentTokens.length} unspent`); - - } catch (error) { - // Spent detection failure is non-fatal - log and continue - console.warn(` Spent detection error (non-fatal):`, error); - ctx.errors.push(`Spent detection error: ${error instanceof Error ? error.message : String(error)}`); - } -} - -/** - * Step 7.5: Verify Tombstones Against Aggregator - * - * Tombstones track spent states (tokenId:stateHash). - * Multi-device sync requires tombstone verification to prevent: - * - False tombstones from network forks - * - BFT finality rollbacks before PoW finality - * - * For each tombstone: - * 1. Query aggregator for inclusion proof - * 2. If NO proof found: remove tombstone, recover token to Active - * 3. If proof found: tombstone is valid, keep - */ -async function step7_5_verifyTombstones(ctx: SyncContext): Promise { - console.log(`🔍 [Step 7.5] Verify tombstones against aggregator`); - - if (ctx.tombstones.length === 0) { - console.log(` No tombstones to verify`); - return; - } - - const validationService = getTokenValidationService(); - const tombstonesToRemove: number[] = []; - - for (let i = 0; i < ctx.tombstones.length; i++) { - const tombstone = ctx.tombstones[i]; - - // Query aggregator to verify this state was actually spent - const isSpent = await validationService.isTokenStateSpent( - tombstone.tokenId, - tombstone.stateHash, - ctx.publicKey - ); - - if (!isSpent) { - console.warn(` 🔄 False tombstone detected: ${tombstone.tokenId.slice(0, 8)}... stateHash=${tombstone.stateHash.slice(0, 16)}...`); - tombstonesToRemove.push(i); - - // Attempt to recover token from archived/forked storage - const recoveredToken = await attemptTokenRecovery(ctx, tombstone); - if (recoveredToken) { - ctx.tokens.set(tombstone.tokenId, recoveredToken); - if (!ctx.stats.tokensRecovered) { - ctx.stats.tokensRecovered = 0; - } - ctx.stats.tokensRecovered++; - } - } - } - - // Remove invalid tombstones (reverse order to maintain indices) - for (const idx of tombstonesToRemove.reverse()) { - ctx.tombstones.splice(idx, 1); - } - - console.log(` ✓ Verified ${ctx.tombstones.length} tombstones, removed ${tombstonesToRemove.length} false positives`); -} - -/** - * Attempt to recover a token that was falsely tombstoned - * - * Checks archived and forked token storage in SyncContext for a matching token. - * If found, returns the token for restoration to active inventory. - * - * NOTE: Per TOKEN_INVENTORY_SPEC.md Section 6.1, we now read from SyncContext - * instead of WalletRepository, eliminating the dual-ownership race condition. - */ -async function attemptTokenRecovery(ctx: SyncContext, tombstone: TombstoneEntry): Promise { - // Check archived tokens in SyncContext (loaded in Step 1) - const archivedToken = ctx.archivedTokens.get(tombstone.tokenId); - if (archivedToken) { - console.log(` ♻️ Recovered from archived: ${tombstone.tokenId.slice(0, 8)}...`); - return archivedToken; - } - - // Check forked tokens with exact state match - const forkedKey = `${tombstone.tokenId}_${tombstone.stateHash}`; - const forkedToken = ctx.forkedTokens.get(forkedKey); - if (forkedToken) { - console.log(` ♻️ Recovered from forked: ${tombstone.tokenId.slice(0, 8)}... (state: ${tombstone.stateHash.slice(0, 12)}...)`); - return forkedToken; - } - - console.log(` ⚠️ Cannot recover token ${tombstone.tokenId.slice(0, 8)}... - not found in SyncContext archives`); - return null; -} - -function step8_mergeInventory(ctx: SyncContext): void { - console.log(`📦 [Step 8] Merge Inventory`); - - // Step 8.1: Handle completed transfers (mark as SPENT, move to Sent) - if (ctx.completedList.length > 0) { - console.log(` Processing ${ctx.completedList.length} completed transfers`); - for (const completed of ctx.completedList) { - const token = ctx.tokens.get(completed.tokenId); - - if (token) { - // Verify state hash matches - const currentStateHash = getCurrentStateHash(token); - if (currentStateHash === completed.stateHash) { - // Move to Sent folder - ctx.sent.push({ - token, - timestamp: Date.now(), - spentAt: Date.now(), - }); - ctx.tokens.delete(completed.tokenId); - - // Add tombstone - ctx.tombstones.push({ - tokenId: completed.tokenId, - stateHash: completed.stateHash, - timestamp: Date.now(), - }); - ctx.stats.tombstonesAdded++; - - console.log(` ✓ Marked ${completed.tokenId.slice(0, 8)}... as SPENT`); - } else { - console.warn(` State hash mismatch for ${completed.tokenId.slice(0, 8)}... (expected ${completed.stateHash.slice(0, 12)}..., got ${currentStateHash?.slice(0, 12)}...)`); - } - } - } - } - - // Step 8.2: Detect boomerang tokens (outbox tokens that returned to us) - // - // A "boomerang" occurs when: - // 1. We created an outbox entry to send a token (commitment with previousStateHash = S1) - // 2. The send succeeded and token was transferred to recipient (state became S2) - // 3. Recipient sent the token back to us (state became S3) - // 4. We now have the token again, but with a different state than when we sent it - // - // Detection: If we have a token in our inventory that matches an outbox entry's sourceTokenId, - // AND the token's current state differs from the commitment's previousStateHash, - // then the token has "boomeranged" back to us and the outbox entry should be removed. - // - // Note: If currentStateHash === previousStateHash, the send is still pending (token hasn't moved) - - const boomerangTokens: string[] = []; - for (const outboxEntry of ctx.outbox) { - // Check if we have a token matching this outbox entry's source - const token = ctx.tokens.get(outboxEntry.sourceTokenId); - if (!token) { - // Token not in our inventory - send may have succeeded and it's with recipient - continue; - } - - const currentStateHash = getCurrentStateHash(token); - if (!currentStateHash) { - continue; - } - - try { - const commitment = JSON.parse(outboxEntry.commitmentJson); - const sentFromStateHash = commitment.transactionData?.previousStateHash; - - if (!sentFromStateHash) { - continue; - } - - // If current state differs from the state we sent FROM, token has changed - // This means either: send completed and token came back, OR token was spent elsewhere - if (currentStateHash !== sentFromStateHash) { - boomerangTokens.push(outboxEntry.id); - console.log(` 🪃 Detected boomerang: ${outboxEntry.sourceTokenId.slice(0, 8)}... (state changed from ${sentFromStateHash.slice(0, 12)}... to ${currentStateHash.slice(0, 12)}...)`); - } - // If currentStateHash === sentFromStateHash, send is still pending (token hasn't moved yet) - } catch { - // Ignore parse errors - commitment might be malformed - } - } - - // Remove boomerang entries from outbox - if (boomerangTokens.length > 0) { - for (const outboxId of boomerangTokens) { - const index = ctx.outbox.findIndex(e => e.id === outboxId); - if (index !== -1) { - ctx.outbox.splice(index, 1); - } - } - } - - console.log(` Merge complete: ${ctx.tokens.size} active, ${ctx.sent.length} sent, ${ctx.invalid.length} invalid, ${ctx.outbox.length} outbox, ${boomerangTokens.length} boomerangs removed`); -} - -/** - * Step 8.4: Extract Nametags - * - * Filters nametags to only include those owned by the current user. - * Uses predicate ownership verification to ensure security. - */ -async function step8_4_extractNametags(ctx: SyncContext): Promise { - console.log(`🏷️ [Step 8.4] Extract Nametags`); - - if (ctx.nametags.length === 0) { - console.log(` No nametags to filter`); - return []; - } - - const userNametags: NametagData[] = []; - const pubKeyBytes = Buffer.from(ctx.publicKey, 'hex'); - - for (const nametag of ctx.nametags) { - if (!nametag.token) { - console.warn(` Skipping nametag ${nametag.name}: missing token data`); - continue; - } - - try { - // Parse the token and verify ownership - const tokenJson = nametag.token as Record; - - // Check if token has state with predicate (required for ownership check) - const stateJson = tokenJson.state as { predicate: string; data: string | null } | undefined; - if (!stateJson || !stateJson.predicate) { - console.warn(` Skipping nametag ${nametag.name}: missing state predicate`); - continue; - } - - // Use TokenState.fromJSON to properly parse the hex-encoded CBOR predicate - const { TokenState } = await import( - '@unicitylabs/state-transition-sdk/lib/token/TokenState' - ); - const tokenState = TokenState.fromJSON(stateJson); - - // Use PredicateEngineService to verify ownership - const { PredicateEngineService } = await import( - '@unicitylabs/state-transition-sdk/lib/predicate/PredicateEngineService' - ); - const predicate = await PredicateEngineService.createPredicate(tokenState.predicate); - const isOwner = await predicate.isOwner(pubKeyBytes); - - if (isOwner) { - userNametags.push(nametag); - console.log(` ✓ ${nametag.name}: owned by current user`); - } else { - console.log(` ✗ ${nametag.name}: not owned by current user (filtered)`); - } - } catch (error) { - // On parse error, include the nametag but log warning - // This prevents data loss if token format is unexpected - console.warn(` ⚠️ ${nametag.name}: ownership check failed, including anyway:`, error); - userNametags.push(nametag); - } - } - - console.log(` Filtered ${userNametags.length}/${ctx.nametags.length} nametags for current user`); - return userNametags; -} - -/** - * Step 8.5: Ensure Nametag-Nostr Consistency - * - * For each nametag token in the inventory, verify that the nametag binding - * is registered with Nostr relays. This ensures relays can route token - * transfer events to the correct identity. - * - * Per spec Section 8.5: - * - Query Nostr relay(s) for existing binding - * - If binding missing or pubkey mismatch, publish binding - * - Best-effort, non-blocking (failures don't stop sync) - * - Security: On-chain ownership is source of truth, Nostr is routing optimization - * - Skip in NAMETAG mode (read-only operation) - */ -async function step8_5_ensureNametagNostrBinding(ctx: SyncContext): Promise { - console.log(`🏷️ [Step 8.5] Ensure Nametag-Nostr Consistency`); - - // Skip in NAMETAG mode (read-only operation per spec section 8.5) - if (ctx.mode === 'NAMETAG') { - console.log(` Skipping in NAMETAG mode (read-only)`); - return; - } - - if (ctx.nametags.length === 0) { - console.log(` No nametags to process`); - return; - } - - // Initialize NostrService (fail gracefully if unavailable) - let nostrService: NostrService; - try { - nostrService = NostrService.getInstance(IdentityManager.getInstance()); - } catch (err) { - console.warn(` Failed to initialize NostrService:`, err); - return; - } - - let published = 0; - let skipped = 0; - let failed = 0; - - for (const nametag of ctx.nametags) { - if (!nametag.name) { - console.warn(` Skipping nametag without name`); - skipped++; - continue; - } - - // Clean the nametag name (remove @ prefix if present) - const cleanName = nametag.name.replace(/^@/, '').trim(); - - // Validate cleaned name is not empty - if (!cleanName) { - console.warn(` Skipping nametag with empty name after cleanup`); - skipped++; - continue; - } - - try { - // Derive the proxy address from nametag name - // IMPORTANT: Proxy address (where transfers go) is DIFFERENT from owner address (who controls token) - // The proxy address is deterministically derived from the nametag name itself - const { ProxyAddress } = await import('@unicitylabs/state-transition-sdk/lib/address/ProxyAddress'); - const proxyAddress = await ProxyAddress.fromNameTag(cleanName); - const proxyAddressStr = proxyAddress.address; - - // Query relay for existing binding - const existingPubkey = await nostrService.queryPubkeyByNametag(cleanName); - - if (existingPubkey && existingPubkey === proxyAddressStr) { - // Binding exists and matches proxy address - no action needed - console.log(` ✓ ${cleanName}: binding already registered -> ${proxyAddressStr.slice(0, 12)}...`); - skipped++; - continue; - } - - // Binding missing or address mismatch - publish binding - // Publish the PROXY ADDRESS (where transfers to @nametag should go), not owner address - console.log(` Publishing binding for ${cleanName} -> ${proxyAddressStr.slice(0, 12)}...`); - const success = await nostrService.publishNametagBinding(cleanName, proxyAddressStr); - - if (success) { - console.log(` ✓ ${cleanName}: binding published successfully`); - published++; - ctx.stats.nametagsPublished++; - } else { - console.warn(` ✗ ${cleanName}: binding publish failed`); - failed++; - } - } catch (error) { - // Best-effort - don't fail sync on Nostr errors - console.warn(` ✗ ${cleanName}: error ensuring binding:`, error); - failed++; - } - } - - console.log(` Nametag binding summary: ${published} published, ${skipped} skipped, ${failed} failed (total: ${ctx.nametags.length})`); -} - -/** - * Compare two TxfStorageData objects for content equality. - * Ignores _meta.version and _meta.lastCid since those change every sync. - * Returns true if content (tokens, nametags, tombstones, etc.) is identical. - */ -function isContentEqual(a: TxfStorageData, b: TxfStorageData): boolean { - // Compare nametag - const nametagA = JSON.stringify(a._nametag || null); - const nametagB = JSON.stringify(b._nametag || null); - if (nametagA !== nametagB) return false; - - // Compare tombstones - const tombstonesA = JSON.stringify(a._tombstones || []); - const tombstonesB = JSON.stringify(b._tombstones || []); - if (tombstonesA !== tombstonesB) return false; - - // Compare sent - const sentA = JSON.stringify(a._sent || []); - const sentB = JSON.stringify(b._sent || []); - if (sentA !== sentB) return false; - - // Compare invalid - const invalidA = JSON.stringify(a._invalid || []); - const invalidB = JSON.stringify(b._invalid || []); - if (invalidA !== invalidB) return false; - - // Compare outbox - const outboxA = JSON.stringify(a._outbox || []); - const outboxB = JSON.stringify(b._outbox || []); - if (outboxA !== outboxB) return false; - - // Collect token keys (entries starting with _ that aren't special keys) - const specialKeys = new Set(['_meta', '_nametag', '_tombstones', '_sent', '_invalid', '_outbox', '_invalidatedNametags', '_mintOutbox']); - const getTokenKeys = (data: TxfStorageData): string[] => { - return Object.keys(data).filter(k => !specialKeys.has(k)).sort(); - }; - - const tokensKeysA = getTokenKeys(a); - const tokensKeysB = getTokenKeys(b); - - // Compare token count - if (tokensKeysA.length !== tokensKeysB.length) return false; - - // Compare token keys - if (tokensKeysA.join(',') !== tokensKeysB.join(',')) return false; - - // Compare each token's content - for (const key of tokensKeysA) { - const tokenA = JSON.stringify(a[key]); - const tokenB = JSON.stringify(b[key]); - if (tokenA !== tokenB) return false; - } - - return true; -} - -/** - * Build TxfStorageData from sync context - * - * Helper function to construct storage data structure from current sync state. - */ -function buildStorageDataFromContext(ctx: SyncContext): TxfStorageData { - // Build TxfStorageData structure - // Note: timestamp is excluded from _meta for CID stability (same content = same CID) - - // CRITICAL: Use max(localVersion, remoteVersion) + 1 for version calculation - // This ensures proper version progression when: - // 1. Recovering from IPFS after localStorage corruption (local=1, remote=5 -> new=6) - // 2. Normal sync progression (local=5, remote=5 -> new=6) - // 3. Local-only changes (local=5, remote=0 -> new=6) - const baseVersion = Math.max(ctx.localVersion, ctx.remoteVersion); - const newVersion = baseVersion + 1; - - const storageData: TxfStorageData = { - _meta: { - version: newVersion, - address: ctx.address, - ipnsName: ctx.ipnsName, - formatVersion: '2.0', - lastCid: ctx.remoteCid || undefined, - }, - }; - - // Add nametag if present - if (ctx.nametags.length > 0) { - storageData._nametag = ctx.nametags[0]; - } - - // Add tombstones - deduplicate by tokenId:stateHash to prevent duplicates - // Duplicates can accumulate from multiple sync cycles detecting the same spent token - if (ctx.tombstones.length > 0) { - const seenKeys = new Set(); - const deduped: TombstoneEntry[] = []; - for (const t of ctx.tombstones) { - const key = `${t.tokenId}:${t.stateHash}`; - if (!seenKeys.has(key)) { - seenKeys.add(key); - deduped.push(t); - } - } - storageData._tombstones = deduped; - if (deduped.length < ctx.tombstones.length) { - console.log(` 🧹 Deduplicated tombstones: ${ctx.tombstones.length} → ${deduped.length}`); - } - } - - // Add sent tokens - deduplicate by tokenId:stateHash to prevent duplicates - // Uses getCurrentStateHash to get state from token structure - if (ctx.sent.length > 0) { - const seenKeys = new Set(); - const deduped: SentTokenEntry[] = []; - for (const s of ctx.sent) { - const tokenId = s.token?.genesis?.data?.tokenId || ''; - const stateHash = getCurrentStateHash(s.token) || 'unknown'; - const key = `${tokenId}:${stateHash}`; - if (!seenKeys.has(key)) { - seenKeys.add(key); - deduped.push(s); - } - } - storageData._sent = deduped; - if (deduped.length < ctx.sent.length) { - console.log(` 🧹 Deduplicated sent tokens: ${ctx.sent.length} → ${deduped.length}`); - } - } - - // Add invalid tokens - deduplicate by tokenId:stateHash to prevent duplicates - if (ctx.invalid.length > 0) { - const seenKeys = new Set(); - const deduped: InvalidTokenEntry[] = []; - for (const i of ctx.invalid) { - const tokenId = i.token?.genesis?.data?.tokenId || ''; - const stateHash = getCurrentStateHash(i.token) || 'unknown'; - const key = `${tokenId}:${stateHash}`; - if (!seenKeys.has(key)) { - seenKeys.add(key); - deduped.push(i); - } - } - storageData._invalid = deduped; - if (deduped.length < ctx.invalid.length) { - console.log(` 🧹 Deduplicated invalid tokens: ${ctx.invalid.length} → ${deduped.length}`); - } - } - - // Add outbox entries - if (ctx.outbox.length > 0) { - storageData._outbox = ctx.outbox; - } - - // Add active tokens with _ keys - for (const [tokenId, txf] of ctx.tokens) { - storageData[keyFromTokenId(tokenId)] = txf; - } - - // Add archived tokens with _archived_ keys - for (const [tokenId, txf] of ctx.archivedTokens) { - storageData[archivedKeyFromTokenId(tokenId)] = txf; - } - - // Add forked tokens with _forked__ keys - for (const [forkedKey, txf] of ctx.forkedTokens) { - // forkedKey is already in format: tokenId_stateHash - const [tokenId, stateHash] = forkedKey.split('_'); - if (tokenId && stateHash) { - storageData[forkedKeyFromTokenIdAndState(tokenId, stateHash)] = txf; - } - } - - return storageData; -} - -function step9_prepareStorage(ctx: SyncContext): void { - console.log(`📤 [Step 9] Prepare for Storage`); - - // Per TOKEN_INVENTORY_SPEC.md Section 6.1: - // "Only inventorySync should be allowed to access the inventory in localStorage!" - // - // We write directly to localStorage in TxfStorageData format (the canonical format). - // WalletRepository should NOT be used here - it uses a different format (StoredWallet) - // that would conflict with the TxfStorageData format we need for IPFS sync. - - // Build TxfStorageData from context - // Note: buildStorageDataFromContext increments version internally (ctx.localVersion + 1) - const storageData = buildStorageDataFromContext(ctx); - - // Read current localStorage to compare content - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(ctx.address); - const existingJson = localStorage.getItem(storageKey); - let existingData: TxfStorageData | null = null; - if (existingJson) { - try { - existingData = JSON.parse(existingJson); - } catch { - // Malformed JSON - treat as no existing data (will overwrite) - console.warn(` ⚠️ Malformed JSON in localStorage, will overwrite`); - } - } - - // Compare content (excluding version and lastCid which change every sync) - // Only write if content actually changed - if (existingData && isContentEqual(existingData, storageData)) { - // Content same as localStorage, but check if we have local-only content that needs upload - if (ctx.hasLocalOnlyContent) { - console.log(` 📤 Local-only content detected - forcing upload to IPFS`); - // Fall through to write localStorage and mark upload needed - } else { - console.log(` ⏭️ No content changes detected`); - ctx.uploadNeeded = false; - - // If remote version > local version, update local to match remote - // This prevents re-fetching IPFS data on every reload - if (ctx.remoteVersion > existingData._meta.version) { - console.log(` 📥 Updating local version to match remote: ${existingData._meta.version} → ${ctx.remoteVersion}`); - existingData._meta.version = ctx.remoteVersion; - localStorage.setItem(storageKey, JSON.stringify(existingData)); - ctx.localVersion = ctx.remoteVersion; - } else { - console.log(` ⏭️ Skipping localStorage write (version ${existingData._meta.version} is current)`); - ctx.localVersion = existingData._meta.version; - } - return; - } - } - - // Content changed - update version and write - ctx.localVersion = storageData._meta.version; - localStorage.setItem(storageKey, JSON.stringify(storageData)); - ctx.uploadNeeded = true; - - // CRITICAL: Store the prepared data for step 10 to avoid double-increment bug - // Previously, step 10 called buildStorageDataFromContext() again which incremented version, - // causing localStorage to have version N but IPFS to have version N+1. - ctx.preparedStorageData = storageData; - - // Dispatch wallet-updated event so UI components refresh - if (typeof window !== 'undefined') { - window.dispatchEvent(new Event('wallet-updated')); - } - - console.log(` ✓ Wrote to localStorage: version=${storageData._meta.version}, ${ctx.tokens.size} tokens`); - console.log(` ✓ Folders: ${ctx.sent.length} sent, ${ctx.invalid.length} invalid, ${ctx.tombstones.length} tombstones`); -} - -async function step10_uploadIpfs(ctx: SyncContext): Promise { - console.log(`📤 [Step 10] Upload to IPFS`); - - if (!ctx.uploadNeeded) { - console.log(` ⏭️ Skipping IPFS upload: no changes to upload`); - return; - } - - // Try to get transport (gracefully skip if not available) - let transport: IpfsTransport | null = null; - try { - transport = getIpfsTransport(); - } catch { - console.log(` ⏭️ Skipping IPFS upload: transport not available`); - return; - } - - // Check if transport is initialized - const initialized = await transport.ensureInitialized(); - if (!initialized) { - console.log(` ⏭️ Skipping IPFS upload: transport not initialized`); - return; - } - - // CRITICAL FIX: Reuse storage data from step 9 instead of rebuilding - // This prevents the version double-increment bug where step 10 would call - // buildStorageDataFromContext() again, incrementing version a second time. - if (!ctx.preparedStorageData) { - console.error(` ❌ BUG: preparedStorageData is null - step 9 didn't run correctly`); - ctx.errors.push('Internal error: preparedStorageData is null'); - return; - } - const storageData = ctx.preparedStorageData; - - // Diagnostic logging: show exactly what we're uploading - // Token keys are _ (e.g., "_abc123..."), not to be confused with - // special keys like _meta, _sent, etc. - const tokenKeys = Object.keys(storageData).filter(k => isTokenKey(k)); - const tokenCount = tokenKeys.length; - const sentCount = storageData._sent?.length || 0; - const tombstoneCount = storageData._tombstones?.length || 0; - console.log(` 📦 Upload payload: version=${storageData._meta?.version}, tokens=${tokenCount}, sent=${sentCount}, tombstones=${tombstoneCount}`); - // Log token IDs to help trace missing tokens (e.g., change tokens from splits) - if (tokenCount <= 15) { - const tokenIds = tokenKeys.map(k => tokenIdFromKey(k).slice(0, 8)).join(', '); - console.log(` 📦 Token IDs: ${tokenIds}`); - } - - // Upload content to IPFS - console.log(` 📤 Uploading content to IPFS...`); - const uploadResult = await transport.uploadContent(storageData); - if (!uploadResult.success) { - console.warn(` ❌ IPFS upload failed: ${uploadResult.error}`); - ctx.errors.push(uploadResult.error || 'IPFS upload failed'); - return; - } - - ctx.remoteCid = uploadResult.cid; - transport.setLastCid(uploadResult.cid); - console.log(` ✅ Content uploaded: CID=${uploadResult.cid.slice(0, 16)}...`); - - // NOTE: We do NOT overwrite localStorage here. - // WalletRepository is the authoritative local store (StoredWallet format). - // The CID is tracked by the transport layer and context. - - // Publish to IPNS - console.log(` 📡 Publishing to IPNS...`); - const publishResult = await transport.publishIpns(uploadResult.cid); - if (publishResult.success) { - ctx.ipnsPublished = true; - console.log(` ✅ IPNS published: seq=${publishResult.sequence}`); - } else { - console.warn(` ⚠️ IPNS publish failed (will retry in background): ${publishResult.error}`); - } -} - -// ============================================ -// Result Builders -// ============================================ - -function buildSuccessResult(ctx: SyncContext): SyncResult { - // Determine status based on IPFS/IPNS state: - // - SUCCESS: Either no upload needed, or upload + IPNS publish both succeeded - // - PARTIAL_SUCCESS: Content uploaded but IPNS publish failed - const hasUploadedContent = ctx.remoteCid !== null && ctx.uploadNeeded; - const ipnsPublishPending = hasUploadedContent && !ctx.ipnsPublished; - - return { - status: ipnsPublishPending ? 'PARTIAL_SUCCESS' : 'SUCCESS', - syncMode: ctx.mode, - operationStats: ctx.stats, - inventoryStats: buildInventoryStats(ctx), - lastCid: ctx.remoteCid || undefined, - ipnsName: ctx.ipnsName, - ipnsPublished: ctx.ipnsPublished, - ipnsPublishPending, - syncDurationMs: Date.now() - ctx.startTime, - timestamp: Date.now(), - version: ctx.localVersion, - circuitBreaker: ctx.circuitBreaker, - validationIssues: ctx.errors.length > 0 ? ctx.errors : undefined - }; -} - -function buildNametagResult(ctx: SyncContext, nametags: NametagData[]): SyncResult { - return { - status: 'NAMETAG_ONLY', - syncMode: 'NAMETAG', - operationStats: ctx.stats, - syncDurationMs: Date.now() - ctx.startTime, - timestamp: Date.now(), - nametags, - ipnsPublishPending: false - }; -} - -function buildErrorResult(ctx: SyncContext, error: unknown): SyncResult { - const errorMessage = error instanceof Error ? error.message : String(error); - return { - status: 'ERROR', - syncMode: ctx.mode, - errorCode: 'UNKNOWN', - errorMessage, - operationStats: ctx.stats, - syncDurationMs: Date.now() - ctx.startTime, - timestamp: Date.now(), - circuitBreaker: ctx.circuitBreaker, - ipnsPublishPending: false - }; -} - -function buildInventoryStats(ctx: SyncContext): TokenInventoryStats { - return { - activeTokens: ctx.tokens.size, - sentTokens: ctx.sent.length, - outboxTokens: ctx.outbox.length, - invalidTokens: ctx.invalid.length, - nametagTokens: ctx.nametags.length, - tombstoneCount: ctx.tombstones.length - }; -} - -// ============================================ -// READ-ONLY QUERY API -// ============================================ -// These functions provide direct read access to localStorage in TxfStorageData format. -// Per TOKEN_INVENTORY_SPEC.md Section 6.1, all writes should go through inventorySync(). -// However, read-only queries can access localStorage directly for UI display purposes. - -/** - * Get all active tokens for an address - * Read-only query - does not trigger sync - * Supports both TxfStorageData format (new) and StoredWallet format (legacy) - */ -export function getTokensForAddress(address: string): Token[] { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - if (!json) return []; - - try { - const data = JSON.parse(json) as Record; - const tokens: Token[] = []; - const allKeys = Object.keys(data); - const tokenKeys = allKeys.filter(k => isTokenKey(k)); - - console.log(`📦 [getTokensForAddress] Found ${tokenKeys.length} token keys out of ${allKeys.length} total keys`); - - // Check for new TxfStorageData format (tokens stored as _ keys) - for (const key of allKeys) { - if (isTokenKey(key)) { - const txf = data[key] as TxfToken; - const token = txfToToken(tokenIdFromKey(key), txf); - if (token) { - tokens.push(token); - } else { - console.warn(`📦 [getTokensForAddress] txfToToken returned null for key ${key}`); - } - } - } - - // If no tokens found, check for legacy StoredWallet format (tokens in array) - if (tokens.length === 0 && data.tokens && Array.isArray(data.tokens)) { - // Legacy format: { id, name, address, tokens: Token[], nametag: {...} } - for (const token of data.tokens as Token[]) { - if (token && token.id) { - tokens.push(token); - } - } - } - - console.log(`📦 [getTokensForAddress] Returning ${tokens.length} tokens`); - return tokens; - } catch (e) { - console.warn(`[getTokensForAddress] Failed to parse localStorage data for ${address.slice(0, 20)}...: ${e}`); - return []; - } -} - -/** - * Get nametag data for an address - * Read-only query - does not trigger sync - * Supports both TxfStorageData format (new) and StoredWallet format (legacy) - */ -export function getNametagForAddress(address: string): NametagData | null { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - if (!json) return null; - - try { - const data = JSON.parse(json) as Record; - // Check new TxfStorageData format first (_nametag) - if (data._nametag) { - return data._nametag as NametagData; - } - // Fall back to legacy StoredWallet format (nametag) - if (data.nametag) { - return data.nametag as NametagData; - } - return null; - } catch { - return null; - } -} - -/** - * Get tombstones for an address - * Read-only query - does not trigger sync - * Supports both TxfStorageData format (new) and StoredWallet format (legacy) - */ -export function getTombstonesForAddress(address: string): TombstoneEntry[] { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - if (!json) return []; - - try { - const data = JSON.parse(json) as Record; - // Check new TxfStorageData format first (_tombstones) - if (data._tombstones && Array.isArray(data._tombstones)) { - return data._tombstones as TombstoneEntry[]; - } - // Fall back to legacy StoredWallet format (tombstones) - if (data.tombstones && Array.isArray(data.tombstones)) { - return data.tombstones as TombstoneEntry[]; - } - return []; - } catch { - return []; - } -} - -/** - * Get invalidated nametags for an address - * These are nametags that failed Nostr validation (owned by different pubkey) - * Read-only query - does not trigger sync - * Supports both TxfStorageData format (new) and StoredWallet format (legacy) - */ -export function getInvalidatedNametagsForAddress(address: string): InvalidatedNametagEntry[] { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - if (!json) return []; - - try { - const data = JSON.parse(json) as Record; - // Check new TxfStorageData format first (_invalidatedNametags) - if (data._invalidatedNametags && Array.isArray(data._invalidatedNametags)) { - return data._invalidatedNametags as InvalidatedNametagEntry[]; - } - // Fall back to legacy StoredWallet format (invalidatedNametags) - if (data.invalidatedNametags && Array.isArray(data.invalidatedNametags)) { - return data.invalidatedNametags as InvalidatedNametagEntry[]; - } - return []; - } catch { - return []; - } -} - -/** - * Check if an address has any tokens - * Read-only query - does not trigger sync - */ -export function hasTokensForAddress(address: string): boolean { - return getTokensForAddress(address).length > 0; -} - -/** - * Check if an address has a nametag - * Read-only query - does not trigger sync - */ -export function checkNametagForAddress(address: string): NametagData | null { - return getNametagForAddress(address); -} - -/** - * Get metadata version for an address - * Read-only query - useful for version comparison - */ -export function getVersionForAddress(address: string): number { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - if (!json) return 0; - - try { - const data = JSON.parse(json) as TxfStorageData; - return data._meta?.version || 0; - } catch { - return 0; - } -} - -/** - * Get archived tokens for an address - * Archived tokens are spent tokens kept for recovery purposes - * Read-only query - does not trigger sync - * Supports both TxfStorageData format (new) and StoredWallet format (legacy) - */ -export function getArchivedTokensForAddress(address: string): Map { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - const result = new Map(); - - if (!json) return result; - - try { - const data = JSON.parse(json) as Record; - - // Check new TxfStorageData format first (_archived_ keys) - for (const key of Object.keys(data)) { - if (isArchivedKey(key)) { - const txf = data[key] as TxfToken; - const tokenId = tokenIdFromArchivedKey(key); - result.set(tokenId, txf); - } - } - - // If no archived tokens found, check legacy StoredWallet format (archivedTokens object) - if (result.size === 0 && data.archivedTokens && typeof data.archivedTokens === 'object') { - const legacyArchived = data.archivedTokens as Record; - for (const [tokenId, txf] of Object.entries(legacyArchived)) { - if (txf) { - result.set(tokenId, txf); - } - } - } - - return result; - } catch { - return result; - } -} - -/** - * Get forked tokens for an address - * Forked tokens are tokens saved at specific states for conflict resolution - * Read-only query - does not trigger sync - */ -export function getForkedTokensForAddress(address: string): Map { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - const result = new Map(); - - if (!json) return result; - - try { - const data = JSON.parse(json) as Record; - - for (const key of Object.keys(data)) { - if (isForkedKey(key)) { - const txf = data[key] as TxfToken; - const parsed = parseForkedKey(key); - if (parsed) { - const forkedKey = `${parsed.tokenId}_${parsed.stateHash}`; - result.set(forkedKey, txf); - } - } - } - - return result; - } catch { - return result; - } -} - -/** - * Get a specific archived token by tokenId - * Read-only query - does not trigger sync - */ -export function getArchivedTokenForAddress(address: string, tokenId: string): TxfToken | null { - const archived = getArchivedTokensForAddress(address); - return archived.get(tokenId) || null; -} - -/** - * Get a specific forked token by tokenId and stateHash - * Read-only query - does not trigger sync - */ -export function getForkedTokenForAddress( - address: string, - tokenId: string, - stateHash: string -): TxfToken | null { - const forked = getForkedTokensForAddress(address); - return forked.get(`${tokenId}_${stateHash}`) || null; -} - -// ============================================ -// WRITE API (Wrappers around inventorySync) -// ============================================ -// These functions provide convenient write operations that delegate to inventorySync(). -// All writes go through the centralized sync pipeline to prevent race conditions. - -/** - * Add a token to the inventory - * Triggers inventorySync with the token as incoming - */ -export async function addToken( - address: string, - publicKey: string, - ipnsName: string, - token: Token, - options?: { local?: boolean } -): Promise { - return inventorySync({ - address, - publicKey, - ipnsName, - incomingTokens: [token], - local: options?.local ?? false, - }); -} - -/** - * Remove a token from the inventory (mark as spent) - * Triggers inventorySync with the completed transfer info - */ -export async function removeToken( - address: string, - publicKey: string, - ipnsName: string, - tokenId: string, - stateHash: string, - options?: { local?: boolean } -): Promise { - return inventorySync({ - address, - publicKey, - ipnsName, - completedList: [{ - tokenId, - stateHash, - inclusionProof: {}, // Minimal proof for removal - }], - local: options?.local ?? false, - }); -} - -/** - * Set nametag for an address - * This is a direct localStorage write since nametag is not part of token sync - */ -export function setNametagForAddress(address: string, nametag: NametagData): void { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - - let data: TxfStorageData; - if (json) { - try { - data = JSON.parse(json) as TxfStorageData; - } catch { - // Parse error - create fresh structure - // Note: This preserves existing behavior. If corruption is a concern, - // the data should be recovered from IPFS on next sync. - data = { - _meta: { - version: 1, - address, - ipnsName: '', - formatVersion: '2.0', - }, - }; - } - } else { - data = { - _meta: { - version: 1, - address, - ipnsName: '', - formatVersion: '2.0', - }, - }; - } - - data._nametag = nametag; - localStorage.setItem(storageKey, JSON.stringify(data)); - - // Dispatch wallet-updated event so UI refreshes - window.dispatchEvent(new Event('wallet-updated')); -} - -/** - * Clear nametag for an address - */ -export function clearNametagForAddress(address: string): void { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - if (!json) return; - - try { - const data = JSON.parse(json) as TxfStorageData; - delete data._nametag; - localStorage.setItem(storageKey, JSON.stringify(data)); - window.dispatchEvent(new Event('wallet-updated')); - } catch { - // Ignore parse errors - } -} - -/** - * Save a token directly to localStorage without waiting for sync. - * Use this for immediate UI updates when sync is blocked. - * Validation happens in background sync. - */ -export function saveTokenImmediately(address: string, token: Token): void { - console.log(`💾 [IMMEDIATE] saveTokenImmediately called for token ${token.id.slice(0, 8)}...`); - console.log(`💾 [IMMEDIATE] Token has jsonData: ${!!token.jsonData}, length: ${token.jsonData?.length || 0}`); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - const json = localStorage.getItem(storageKey); - - let data: TxfStorageData; - if (json) { - try { - data = JSON.parse(json) as TxfStorageData; - } catch { - data = { - _meta: { - version: 1, - address, - ipnsName: '', - formatVersion: '2.0', - }, - }; - } - } else { - data = { - _meta: { - version: 1, - address, - ipnsName: '', - formatVersion: '2.0', - }, - }; - } - - // Convert Token to TxfToken and save - const txf = tokenToTxf(token); - if (!txf) { - console.warn(`💾 [IMMEDIATE] Token ${token.id.slice(0, 8)}... could not be converted to TXF format`); - // Log more details to understand why - if (token.jsonData) { - try { - const parsed = JSON.parse(token.jsonData); - console.warn(`💾 [IMMEDIATE] jsonData keys: ${Object.keys(parsed).join(', ')}`); - console.warn(`💾 [IMMEDIATE] has genesis: ${!!parsed.genesis}, has state: ${!!parsed.state}`); - } catch (e) { - console.warn(`💾 [IMMEDIATE] Failed to parse jsonData: ${e}`); - } - } - return; - } - - // CRITICAL: Use SDK token ID (txf.genesis.data.tokenId) as key, NOT the UI UUID (token.id) - // This ensures consistency with the normal sync path which uses SDK token ID - const sdkTokenId = txf.genesis.data.tokenId; - const tokenKey = `_${sdkTokenId}`; - - // Only save if not already present - if (!data[tokenKey]) { - data[tokenKey] = txf; - data._meta = data._meta || { version: 0, address, ipnsName: '', formatVersion: '2.0' }; - data._meta.version = (data._meta.version || 0) + 1; - localStorage.setItem(storageKey, JSON.stringify(data)); - console.log(`💾 [IMMEDIATE] Token ${sdkTokenId.slice(0, 8)}... saved directly to localStorage`); - } -} - -/** - * Notify UI components of wallet changes via TanStack Query invalidation. - * This triggers refetch of token and aggregated queries. - */ -export function dispatchWalletUpdated(): void { - // Use TanStack Query for reactive updates instead of custom events - // Direct import (not dynamic) to avoid potential memory leaks from repeated imports - invalidateWalletQueries(); -} - -// ============================================ -// IMPORT FLOW FLAGS -// ============================================ -// Session flags for import flow management - -const IMPORT_SESSION_FLAG = "sphere_import_in_progress"; - -/** - * Mark that we're in an active import flow. - * During import, credentials are saved BEFORE wallet data, so the safeguard - * that prevents wallet creation when credentials exist needs to be bypassed. - * This flag is stored in sessionStorage so it's cleared on browser close. - */ -export function setImportInProgress(): void { - console.log("📦 [IMPORT] Setting import-in-progress flag"); - sessionStorage.setItem(IMPORT_SESSION_FLAG, "true"); -} - -/** - * Clear the import-in-progress flag. - * Should be called when import completes (success or failure). - */ -export function clearImportInProgress(): void { - console.log("📦 [IMPORT] Clearing import-in-progress flag"); - sessionStorage.removeItem(IMPORT_SESSION_FLAG); -} - -/** - * Check if we're currently in an import flow. - */ -export function isImportInProgress(): boolean { - return sessionStorage.getItem(IMPORT_SESSION_FLAG) === "true"; -} diff --git a/src/components/wallet/L3/services/IpfsCache.ts b/src/components/wallet/L3/services/IpfsCache.ts deleted file mode 100644 index 780a3620..00000000 --- a/src/components/wallet/L3/services/IpfsCache.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * IPFS Cache Layer - * - * Implements intelligent caching for IPNS records and content - * with TTL management to reduce network calls. - * - * Cache structure: - * - IPNS records: 60-second TTL (short-lived, changes during sync) - * - Content (by CID): Infinite TTL (immutable by definition) - * - Failure tracking: 30-second TTL (exponential backoff) - */ - -import type { TxfStorageData } from "./types/TxfTypes"; - -// Re-export TxfStorageData for convenience -export type { TxfStorageData }; - -export interface IpnsGatewayResult { - cid: string; - sequence: bigint; - gateway?: string; - recordData?: Uint8Array; - _cachedContent?: TxfStorageData; -} - -interface CacheEntry { - data: T; - timestamp: number; - source: "http" | "dht" | "local"; - sequenceNumber?: bigint; -} - -export class IpfsCache { - private recordCache = new Map>(); - private contentCache = new Map>(); - private failureCache = new Set(); - - private readonly IPNS_RECORD_TTL_MS = 60000; // 1 minute - private readonly FAILURE_CACHE_TTL_MS = 30000; // 30 seconds - - /** - * Get cached IPNS record if fresh - */ - getIpnsRecord(ipnsName: string): IpnsGatewayResult | null { - const cached = this.recordCache.get(ipnsName); - if (!cached) return null; - - const isExpired = Date.now() - cached.timestamp > this.IPNS_RECORD_TTL_MS; - if (isExpired) { - this.recordCache.delete(ipnsName); - return null; - } - - return cached.data; - } - - /** - * Store resolved IPNS record with TTL - */ - setIpnsRecord( - ipnsName: string, - result: IpnsGatewayResult, - ttlMs: number = this.IPNS_RECORD_TTL_MS - ): void { - this.recordCache.set(ipnsName, { - data: result, - timestamp: Date.now(), - source: result.gateway ? "http" : "dht", - sequenceNumber: result.sequence, - }); - - // Clear failure cache on success - this.failureCache.delete(ipnsName); - - // Auto-expire cached record - setTimeout(() => { - const entry = this.recordCache.get(ipnsName); - if (entry && entry.timestamp === (this.recordCache.get(ipnsName)?.timestamp || 0)) { - this.recordCache.delete(ipnsName); - } - }, ttlMs); - } - - /** - * Get immutable content from cache (always valid) - */ - getContent(cid: string): TxfStorageData | null { - return this.contentCache.get(cid)?.data || null; - } - - /** - * Store immutable content (no expiration) - */ - setContent(cid: string, content: TxfStorageData): void { - this.contentCache.set(cid, { - data: content, - timestamp: Date.now(), - source: "http", - }); - } - - /** - * Track failed resolution attempts for backoff - */ - recordFailure(ipnsName: string): void { - this.failureCache.add(ipnsName); - - // Auto-clear after TTL - setTimeout(() => { - this.failureCache.delete(ipnsName); - }, this.FAILURE_CACHE_TTL_MS); - } - - /** - * Check if we recently failed to resolve (for backoff) - */ - hasRecentFailure(ipnsName: string): boolean { - return this.failureCache.has(ipnsName); - } - - /** - * Get cache statistics for monitoring - */ - getStats(): { - recordCacheSize: number; - contentCacheSize: number; - failureCacheSize: number; - } { - return { - recordCacheSize: this.recordCache.size, - contentCacheSize: this.contentCache.size, - failureCacheSize: this.failureCache.size, - }; - } - - /** - * Clear all caches (on logout, account switch) - */ - clear(): void { - this.recordCache.clear(); - this.contentCache.clear(); - this.failureCache.clear(); - } - - /** - * Clear only IPNS records (for forced re-sync) - */ - clearIpnsRecords(): void { - this.recordCache.clear(); - this.failureCache.clear(); - } - - /** - * Clear only content cache - */ - clearContentCache(): void { - this.contentCache.clear(); - } -} - -// Singleton instance -let cacheInstance: IpfsCache | null = null; - -/** - * Get or create the singleton cache instance - */ -export function getIpfsCache(): IpfsCache { - if (!cacheInstance) { - cacheInstance = new IpfsCache(); - } - return cacheInstance; -} diff --git a/src/components/wallet/L3/services/IpfsHttpResolver.ts b/src/components/wallet/L3/services/IpfsHttpResolver.ts deleted file mode 100644 index d2d8a42f..00000000 --- a/src/components/wallet/L3/services/IpfsHttpResolver.ts +++ /dev/null @@ -1,623 +0,0 @@ -/** - * IPFS HTTP Resolver - * - * Fast IPNS resolution and content fetching via HTTP API - * to dedicated IPFS nodes, using parallel multi-node racing. - * - * Resolves IPNS names in 100-300ms (vs DHT 10-30+ seconds) - * - * Two resolution strategies: - * 1. Gateway path (/ipns/{name}?format=dag-json) - Returns content directly (~30-100ms) - * 2. Routing API (/api/v0/routing/get) - Returns IPNS record details (~200-300ms) - * - * Strategy: Race gateway path on all nodes, fallback to routing API if needed. - */ - -import { getIpfsCache, type TxfStorageData } from "./IpfsCache"; -import { getAllBackendGatewayUrls } from "../../../../config/ipfs.config"; -import { unmarshalIPNSRecord } from "ipns"; -import { CID } from "multiformats/cid"; -import * as jsonCodec from "multiformats/codecs/json"; -import { sha256 } from "multiformats/hashes/sha2"; - -export interface IpnsResolutionResult { - success: boolean; - cid?: string; - content?: TxfStorageData | null; - sequence?: bigint; - source: "cache" | "http-gateway" | "http-routing" | "dht" | "none"; - error?: string; - latencyMs: number; -} - -/** - * Fetch with timeout support - */ -async function fetchWithTimeout( - url: string, - timeoutMs: number, - options: RequestInit = {} -): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response = await fetch(url, { - ...options, - signal: controller.signal, - }); - return response; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Try resolving IPNS via gateway path (fast path) - * Returns both CID and content - */ -async function tryGatewayPath( - ipnsName: string, - gatewayUrl: string, - timeoutMs: number = 5000 -): Promise<{ content: TxfStorageData; cid?: string } | null> { - try { - // Request raw JSON (not dag-json) to preserve original encoding for CID verification - const url = `${gatewayUrl}/ipns/${ipnsName}`; - - const response = await fetchWithTimeout(url, timeoutMs, { - headers: { - Accept: "application/json", - }, - }); - - if (!response.ok) { - return null; - } - - const content = (await response.json()) as TxfStorageData; - return { content, cid: content._cid as string | undefined }; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - console.debug(`Gateway path timeout for ${ipnsName} on ${gatewayUrl}`); - } - return null; - } -} - -/** - * Try resolving IPNS via routing API (fallback path) - * Returns IPNS record with CID and sequence number - */ -async function tryRoutingApi( - ipnsName: string, - gatewayUrl: string, - timeoutMs: number = 5000 -): Promise<{ - cid: string; - sequence: bigint; - recordData: Uint8Array; -} | null> { - try { - const url = `${gatewayUrl}/api/v0/routing/get?arg=/ipns/${ipnsName}`; - - const response = await fetchWithTimeout(url, timeoutMs, { - method: "POST", - }); - - if (!response.ok) { - return null; - } - - const json = (await response.json()) as { Extra?: string }; - if (!json.Extra) { - return null; - } - - // Decode base64 IPNS record - const recordData = Uint8Array.from( - atob(json.Extra), - (c) => c.charCodeAt(0) - ); - - // Parse IPNS record to extract CID and sequence - const record = unmarshalIPNSRecord(recordData); - const cidMatch = record.value.match(/^\/ipfs\/(.+)$/); - - if (!cidMatch) { - return null; - } - - return { - cid: cidMatch[1], - sequence: record.sequence, - recordData, - }; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - console.debug(`Routing API timeout for ${ipnsName} on ${gatewayUrl}`); - } - return null; - } -} - -/** - * Compute CID from content for integrity verification. - * Uses the same approach as @helia/json: - * - Encode with multiformats/codecs/json (JSON.stringify as bytes) - * - Hash with SHA-256 - * - Create CIDv1 with json codec (0x0200) - */ -export async function computeCidFromContent(content: TxfStorageData): Promise { - // Encode content as JSON (same as @helia/json uses) - const encoded = jsonCodec.encode(content); - // Hash with SHA-256 (same as @helia/json default) - const hash = await sha256.digest(encoded); - // Create CIDv1 with json codec (0x0200) - same as @helia/json - const computedCid = CID.createV1(jsonCodec.code, hash); - return computedCid.toString(); -} - -/** - * Fetch content by CID with integrity verification - * - * CRITICAL: Fetches raw bytes first, verifies CID from those bytes, - * then parses as JSON. This avoids CID mismatch from JSON key reordering. - * - * @param cid - The CID to fetch - * @param gatewayUrl - IPFS gateway URL - * @param timeoutMs - Timeout in milliseconds - * @returns Object with content (if verified) and raw bytes, or null on failure - */ -async function fetchContentByCidWithVerification( - cid: string, - gatewayUrl: string, - timeoutMs: number = 3000 -): Promise<{ content: TxfStorageData; rawBytes: Uint8Array } | null> { - try { - const url = `${gatewayUrl}/ipfs/${cid}`; - - // Request raw bytes - DO NOT use Accept: application/json - // JSON content negotiation can cause gateways to re-serialize JSON, - // which changes property order and breaks CID verification - const response = await fetchWithTimeout(url, timeoutMs, { - headers: { - Accept: "application/octet-stream, */*", - }, - }); - - if (!response.ok) { - return null; - } - - // Get raw bytes for CID verification - const rawBytes = new Uint8Array(await response.arrayBuffer()); - - // Verify CID from raw bytes - // IPFS backend uses raw codec (0x55) by default, so try that first - const hash = await sha256.digest(rawBytes); - const rawCodec = 0x55; // raw codec (bafkrei... prefix) - const computedCidRaw = CID.createV1(rawCodec, hash); - - if (computedCidRaw.toString() !== cid) { - // Also try with json codec (0x0200) for legacy Helia-created CIDs - const computedCidJson = CID.createV1(jsonCodec.code, hash); - - if (computedCidJson.toString() !== cid) { - console.warn(`⚠️ CID mismatch: expected ${cid}, got raw=${computedCidRaw.toString()}, json=${computedCidJson.toString()}`); - // Don't fail - content may still be valid, just different codec - // Log for debugging but continue with the content - } - } - - // Parse the verified bytes as JSON - const textDecoder = new TextDecoder(); - const jsonString = textDecoder.decode(rawBytes); - const content = JSON.parse(jsonString) as TxfStorageData; - - return { content, rawBytes }; - } catch { - return null; - } -} - -/** - * Main HTTP resolver using parallel multi-node racing - */ -export class IpfsHttpResolver { - private cache = getIpfsCache(); - - /** - * Resolve IPNS name across all configured nodes in parallel - * - * Execution flow: - * 1. Check cache for fresh record - * 2. Query all nodes with gateway path (fast) - * 3. If all fail, query all nodes with routing API (reliable) - * 4. Return first success or fail after timeout - */ - async resolveIpnsName(ipnsName: string): Promise { - // Step 0: Validate IPNS name is non-empty - // New wallets or wallets not yet published to IPNS will have empty names - if (!ipnsName || typeof ipnsName !== 'string' || ipnsName.trim().length === 0) { - return { - success: false, - error: 'IPNS name is empty - wallet not fully initialized yet', - source: 'none', - latencyMs: 0, - }; - } - - // Step 1: Check cache first - const cached = this.cache.getIpnsRecord(ipnsName); - if (cached) { - return { - success: true, - cid: cached.cid, - content: cached._cachedContent || null, - sequence: cached.sequence, - source: "cache", - latencyMs: 0, - }; - } - - // Step 2: Check if we recently failed (backoff) - if (this.cache.hasRecentFailure(ipnsName)) { - return { - success: false, - error: "Recent resolution failure, backing off", - source: "cache", - latencyMs: 0, - }; - } - - const startTime = performance.now(); - const gateways = getAllBackendGatewayUrls(); - - if (gateways.length === 0) { - return { - success: false, - error: "No IPFS gateways configured", - source: "none", - latencyMs: 0, - }; - } - - try { - // Query BOTH gateway path (fast content) AND routing API (authoritative sequence) in parallel - // This ensures we get the correct sequence number for publishing - const [gatewayResult, routingResult] = await Promise.all([ - this.resolveViaGatewayPath(ipnsName, gateways), - this.resolveViaRoutingApi(ipnsName, gateways), - ]); - - const latencyMs = performance.now() - startTime; - - // Prefer routing API sequence (authoritative), gateway path content (fast) - const sequence = routingResult?.sequence ?? 0n; - const authoritativeCid = routingResult?.cid ?? gatewayResult?.cid ?? "unknown"; - let content = gatewayResult?.content ?? null; - let contentSource = "gateway-path"; - - // CRITICAL FIX: Verify gateway content matches authoritative CID - // Gateway path returns cached content from gateway's stale IPNS record - // Routing API returns authoritative seq/CID - // If mismatch, gateway content is stale - fetch fresh content by CID - if (content && routingResult?.cid && gatewayResult?.cid) { - // Gateway returned content with its own CID - check if it matches routing API CID - if (gatewayResult.cid !== routingResult.cid) { - console.warn(`⚠️ Gateway content CID mismatch: gateway=${gatewayResult.cid.slice(0, 16)}..., routing=${routingResult.cid.slice(0, 16)}...`); - console.log(`📦 Fetching fresh content by authoritative CID...`); - - // Fetch content by the authoritative CID - for (const gateway of gateways) { - const freshResult = await fetchContentByCidWithVerification(routingResult.cid, gateway); - if (freshResult) { - content = freshResult.content; - contentSource = "cid-fetch"; - console.log(`📦 Fetched fresh content from ${gateway}: version=${content?._meta?.version}`); - break; - } - } - - if (contentSource !== "cid-fetch") { - // Couldn't fetch by CID - clear stale content to prevent using incorrect version - console.warn(`⚠️ Could not fetch content by CID ${routingResult.cid.slice(0, 16)}... - returning null content`); - content = null; - } - } - } - - const cid = authoritativeCid; - - if (gatewayResult || routingResult) { - // Store in cache with authoritative sequence - this.cache.setIpnsRecord(ipnsName, { - cid, - sequence, - _cachedContent: content ?? undefined, - }); - - console.log( - `📦 IPNS resolved: ${ipnsName.slice(0, 16)}... -> seq=${sequence}, cid=${cid.slice(0, 16)}..., content=${contentSource}` - ); - - return { - success: true, - cid, - content, - sequence, - source: routingResult ? "http-routing" : "http-gateway", - latencyMs, - }; - } - - // Both methods failed - this.cache.recordFailure(ipnsName); - - return { - success: false, - error: "All IPFS gateways failed", - source: "none", - latencyMs, - }; - } catch (error) { - const latencyMs = performance.now() - startTime; - this.cache.recordFailure(ipnsName); - - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - source: "none", - latencyMs, - }; - } - } - - /** - * Query all gateways in parallel with gateway path - * Returns as soon as ANY gateway responds successfully - */ - private async resolveViaGatewayPath( - ipnsName: string, - gateways: string[] - ): Promise<{ cid: string; content: TxfStorageData } | null> { - const promises = gateways.map((gateway) => - tryGatewayPath(ipnsName, gateway) - .then((result) => ({ - success: result !== null, - data: result, - gateway, - })) - .catch(() => ({ success: false, data: null, gateway })) - ); - - // Use Promise.any to get first success - try { - const result = await Promise.any( - promises.map((p) => - p.then((r) => { - if (!r.success) throw new Error("Failed"); - return r; - }) - ) - ); - - return { - cid: result.data?.cid || "unknown", - content: result.data!.content, - }; - } catch { - return null; - } - } - - /** - * Query all gateways in parallel with routing API - * Returns detailed IPNS record with sequence number - */ - private async resolveViaRoutingApi( - ipnsName: string, - gateways: string[] - ): Promise<{ - cid: string; - sequence: bigint; - recordData: Uint8Array; - } | null> { - const promises = gateways.map((gateway) => - tryRoutingApi(ipnsName, gateway) - .then((result) => ({ - success: result !== null, - record: result, - gateway, - })) - .catch(() => ({ success: false, record: null, gateway })) - ); - - try { - const result = await Promise.any( - promises.map((p) => - p.then((r) => { - if (!r.success) throw new Error("Failed"); - return r; - }) - ) - ); - - return result.record!; - } catch { - return null; - } - } - - /** - * Fetch token content by CID - * Cache is checked first, then all gateways queried in parallel. - * Returns immediately when first gateway responds with valid content. - * - * NOTE: CID verification is done during fetch using raw bytes to avoid - * JSON key reordering issues that cause CID mismatch. - */ - async fetchContentByCid(cid: string): Promise { - // Check immutable content cache - const cached = this.cache.getContent(cid); - if (cached) { - return cached; - } - - const gateways = getAllBackendGatewayUrls(); - if (gateways.length === 0) { - return null; - } - - // Use verification-enabled fetch for all gateways - const promises = gateways.map((gateway) => - fetchContentByCidWithVerification(cid, gateway) - ); - - // Return first successful fetch (verification done inside fetch function) - try { - const result = await Promise.any( - promises.map((p) => - p.then((fetchResult) => { - if (fetchResult === null) throw new Error("No content"); - return fetchResult.content; - }) - ) - ); - - // Store in cache (verified immutable content) - this.cache.setContent(cid, result); - console.log(`📦 Content fetched from first responding node`); - return result; - } catch { - // All nodes failed or returned invalid content - return null; - } - } - - /** - * Verify IPNS record was persisted by querying the node directly - * BYPASSES CACHE - used for post-publish verification - * - * @param ipnsName The IPNS name to verify - * @param expectedSeq The sequence number we expect to see - * @param expectedCid The CID we expect the record to point to - * @param retries Number of retries (with delay between) - * @returns Verification result with actual values from node - */ - async verifyIpnsRecord( - ipnsName: string, - expectedSeq: bigint, - expectedCid: string, - retries: number = 3 - ): Promise<{ - verified: boolean; - actualSeq?: bigint; - actualCid?: string; - error?: string; - }> { - const gateways = getAllBackendGatewayUrls(); - if (gateways.length === 0) { - return { verified: false, error: "No IPFS gateways configured" }; - } - - // Clear cache for this IPNS name to force fresh query - this.cache.clearIpnsRecords(); - - for (let attempt = 1; attempt <= retries; attempt++) { - // Small delay before verification to allow node to persist - // Increase delay on retries - const delayMs = attempt === 1 ? 300 : 500 * attempt; - await new Promise(resolve => setTimeout(resolve, delayMs)); - - console.log(`📦 [Verify] Attempt ${attempt}/${retries}: Verifying IPNS seq=${expectedSeq}...`); - - // Use routing API for authoritative sequence number (bypass gateway path cache) - const routingResult = await this.resolveViaRoutingApi(ipnsName, gateways); - - if (routingResult) { - const actualSeq = routingResult.sequence; - const actualCid = routingResult.cid; - - console.log(`📦 [Verify] Node returned: seq=${actualSeq}, cid=${actualCid.slice(0, 16)}...`); - - if (actualSeq === expectedSeq) { - // Sequence matches - verify CID too - if (actualCid === expectedCid) { - console.log(`✅ [Verify] IPNS record verified: seq=${actualSeq}, CID matches`); - - // Update cache with verified record - this.cache.setIpnsRecord(ipnsName, { - cid: actualCid, - sequence: actualSeq, - }); - - return { verified: true, actualSeq, actualCid }; - } else { - // Sequence matches but CID doesn't - this is unexpected - console.warn(`⚠️ [Verify] Sequence matches but CID differs: expected=${expectedCid.slice(0, 16)}..., actual=${actualCid.slice(0, 16)}...`); - return { - verified: false, - actualSeq, - actualCid, - error: `CID mismatch: expected ${expectedCid}, got ${actualCid}` - }; - } - } else if (actualSeq > expectedSeq) { - // Node has higher sequence - another device published - console.warn(`⚠️ [Verify] Node has higher sequence: expected=${expectedSeq}, actual=${actualSeq}`); - return { - verified: false, - actualSeq, - actualCid, - error: `Node has higher sequence ${actualSeq} (expected ${expectedSeq})` - }; - } else { - // Node has lower sequence - our publish didn't persist! - console.warn(`❌ [Verify] Attempt ${attempt}: Node still has old sequence: expected=${expectedSeq}, actual=${actualSeq}`); - // Continue retrying - } - } else { - console.warn(`❌ [Verify] Attempt ${attempt}: Failed to query IPNS record`); - } - } - - // All retries exhausted - return { - verified: false, - error: `IPNS record not verified after ${retries} attempts - publish may not have persisted` - }; - } - - /** - * Force invalidate IPNS cache (for manual sync) - */ - invalidateIpnsCache(ipnsName?: string): void { - if (ipnsName) { - this.cache.clearIpnsRecords(); - } else { - this.cache.clear(); - } - } - - /** - * Get cache statistics - */ - getCacheStats() { - return this.cache.getStats(); - } -} - -// Singleton instance -let resolverInstance: IpfsHttpResolver | null = null; - -/** - * Get or create the singleton resolver instance - */ -export function getIpfsHttpResolver(): IpfsHttpResolver { - if (!resolverInstance) { - resolverInstance = new IpfsHttpResolver(); - } - return resolverInstance; -} diff --git a/src/components/wallet/L3/services/IpfsMetrics.ts b/src/components/wallet/L3/services/IpfsMetrics.ts deleted file mode 100644 index f224d231..00000000 --- a/src/components/wallet/L3/services/IpfsMetrics.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * IPFS Metrics Collector - * - * Tracks performance metrics for IPFS operations to identify - * bottlenecks and validate sub-2-second sync target. - * - * Metrics tracked: - * - Operation latency (resolve, publish, fetch) - * - Success/failure rates by source - * - Cache hit rates - * - Node performance - */ - -export type IpfsOperation = "resolve" | "publish" | "fetch" | "cache"; -export type IpfsSource = - | "http-gateway" - | "http-routing" - | "dht" - | "cache" - | "none"; - -export interface IpfsOperationMetric { - operation: IpfsOperation; - source: IpfsSource; - latencyMs: number; - success: boolean; - timestamp: number; - nodeCount?: number; - failedNodes?: number; - cacheHit?: boolean; - error?: string; -} - -export interface IpfsMetricsSnapshot { - totalOperations: number; - successRate: number; - avgLatencyMs: number; - p50LatencyMs: number; - p95LatencyMs: number; - p99LatencyMs: number; - maxLatencyMs: number; - minLatencyMs: number; - cacheHitRate: number; - operationBreakdown: Record; - sourceBreakdown: Record; - slowOperations: IpfsOperationMetric[]; -} - -export class IpfsMetricsCollector { - private metrics: IpfsOperationMetric[] = []; - private readonly maxMetrics = 1000; - private readonly slowOperationThresholdMs = 1000; - - /** - * Record an IPFS operation - */ - recordOperation(metric: IpfsOperationMetric): void { - this.metrics.push(metric); - - // Keep only recent metrics (sliding window) - if (this.metrics.length > this.maxMetrics) { - this.metrics.shift(); - } - - // Log warnings for slow operations - if (metric.latencyMs > this.slowOperationThresholdMs) { - console.warn( - `Slow IPFS operation: ${metric.operation} via ${metric.source} took ${metric.latencyMs}ms`, - { - success: metric.success, - nodeCount: metric.nodeCount, - error: metric.error, - } - ); - } - } - - /** - * Get comprehensive metrics snapshot - */ - getSnapshot(): IpfsMetricsSnapshot { - if (this.metrics.length === 0) { - return { - totalOperations: 0, - successRate: 0, - avgLatencyMs: 0, - p50LatencyMs: 0, - p95LatencyMs: 0, - p99LatencyMs: 0, - maxLatencyMs: 0, - minLatencyMs: 0, - cacheHitRate: 0, - operationBreakdown: { - resolve: 0, - publish: 0, - fetch: 0, - cache: 0, - }, - sourceBreakdown: { - "http-gateway": 0, - "http-routing": 0, - dht: 0, - cache: 0, - none: 0, - }, - slowOperations: [], - }; - } - - // Calculate latency percentiles - const latencies = this.metrics - .map((m) => m.latencyMs) - .sort((a, b) => a - b); - - const successCount = this.metrics.filter((m) => m.success).length; - const cacheHits = this.metrics.filter((m) => m.cacheHit).length; - - // Count by operation type - const operationBreakdown: Record = { - resolve: 0, - publish: 0, - fetch: 0, - cache: 0, - }; - - for (const m of this.metrics) { - operationBreakdown[m.operation]++; - } - - // Count by source - const sourceBreakdown: Record = { - "http-gateway": 0, - "http-routing": 0, - dht: 0, - cache: 0, - none: 0, - }; - - for (const m of this.metrics) { - sourceBreakdown[m.source]++; - } - - // Find slow operations - const slowOperations = this.metrics - .filter((m) => m.latencyMs > this.slowOperationThresholdMs) - .slice(-10); // Last 10 slow ops - - return { - totalOperations: this.metrics.length, - successRate: successCount / this.metrics.length, - avgLatencyMs: - latencies.reduce((a, b) => a + b, 0) / latencies.length, - p50LatencyMs: latencies[Math.floor(latencies.length * 0.5)], - p95LatencyMs: latencies[Math.floor(latencies.length * 0.95)], - p99LatencyMs: latencies[Math.floor(latencies.length * 0.99)], - maxLatencyMs: Math.max(...latencies), - minLatencyMs: Math.min(...latencies), - cacheHitRate: cacheHits / this.metrics.length, - operationBreakdown, - sourceBreakdown, - slowOperations, - }; - } - - /** - * Get metrics for specific operation - */ - getOperationMetrics(operation: IpfsOperation): { - count: number; - avgLatencyMs: number; - successRate: number; - preferredSource: IpfsSource; - } { - const relevant = this.metrics.filter((m) => m.operation === operation); - - if (relevant.length === 0) { - return { - count: 0, - avgLatencyMs: 0, - successRate: 0, - preferredSource: "none", - }; - } - - const latencies = relevant.map((m) => m.latencyMs); - const successCount = relevant.filter((m) => m.success).length; - - // Find most successful source - const sourceSuccess = new Map(); - for (const m of relevant) { - if (m.success) { - sourceSuccess.set(m.source, (sourceSuccess.get(m.source) || 0) + 1); - } - } - - const preferredSource = Array.from(sourceSuccess.entries()).sort( - (a, b) => b[1] - a[1] - )[0]?.[0] || "none"; - - return { - count: relevant.length, - avgLatencyMs: - latencies.reduce((a, b) => a + b, 0) / latencies.length, - successRate: successCount / relevant.length, - preferredSource, - }; - } - - /** - * Get node performance metrics (if tracking) - */ - getNodePerformance(): Record< - string, - { - successRate: number; - avgLatencyMs: number; - operationCount: number; - } - > { - const byNode = new Map< - string, - { latencies: number[]; successCount: number; totalCount: number } - >(); - - for (const m of this.metrics) { - if (!m.nodeCount) continue; - - const key = `node-${m.timestamp}`; - if (!byNode.has(key)) { - byNode.set(key, { latencies: [], successCount: 0, totalCount: 0 }); - } - - const node = byNode.get(key)!; - node.latencies.push(m.latencyMs); - node.totalCount++; - if (m.success) node.successCount++; - } - - const result: Record< - string, - { - successRate: number; - avgLatencyMs: number; - operationCount: number; - } - > = {}; - - for (const [key, data] of byNode) { - result[key] = { - successRate: data.successCount / data.totalCount, - avgLatencyMs: - data.latencies.reduce((a, b) => a + b, 0) / data.latencies.length, - operationCount: data.totalCount, - }; - } - - return result; - } - - /** - * Reset metrics (on logout or clear) - */ - reset(): void { - this.metrics = []; - } - - /** - * Export metrics as JSON - */ - export(): { - snapshot: IpfsMetricsSnapshot; - rawMetrics: IpfsOperationMetric[]; - } { - return { - snapshot: this.getSnapshot(), - rawMetrics: [...this.metrics], - }; - } - - /** - * Get target achievement status - */ - getTargetStatus(): { - targetMet: boolean; - p95AboveTarget: boolean; - message: string; - } { - const snapshot = this.getSnapshot(); - const target = 2000; // 2 seconds - - const p95AboveTarget = snapshot.p95LatencyMs > target; - - return { - targetMet: !p95AboveTarget && snapshot.successRate > 0.95, - p95AboveTarget, - message: p95AboveTarget - ? `P95 latency (${snapshot.p95LatencyMs}ms) exceeds target (${target}ms)` - : `Target achieved: P95=${snapshot.p95LatencyMs.toFixed(0)}ms, Success=${(snapshot.successRate * 100).toFixed(1)}%`, - }; - } -} - -// Singleton instance -let metricsInstance: IpfsMetricsCollector | null = null; - -/** - * Get or create the singleton metrics collector - */ -export function getIpfsMetrics(): IpfsMetricsCollector { - if (!metricsInstance) { - metricsInstance = new IpfsMetricsCollector(); - } - return metricsInstance; -} - -/** - * Reset the singleton (useful for testing) - */ -export function resetIpfsMetrics(): void { - if (metricsInstance) { - metricsInstance.reset(); - } -} diff --git a/src/components/wallet/L3/services/IpfsPublisher.ts b/src/components/wallet/L3/services/IpfsPublisher.ts deleted file mode 100644 index 29fc03e5..00000000 --- a/src/components/wallet/L3/services/IpfsPublisher.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * IPFS Publisher - * - * Handles fast publishing of token data to all configured IPFS nodes - * using parallel multi-node strategy. - * - * Publishing flow: - * 1. Store content on all nodes in parallel (~50-200ms) - * 2. Publish IPNS records on all nodes in parallel (~100-300ms) - * 3. Return after any successful publish - * - * Target: Complete publish in under 500ms - */ - -import { getAllBackendGatewayUrls } from "../../../../config/ipfs.config"; -import type { TxfStorageData } from "./types/TxfTypes"; - -/** - * Race to first success: Returns immediately when ANY promise succeeds, - * while letting remaining promises continue in the background. - * - * This is critical for IPFS sync performance - we don't need to wait for - * all nodes when one has already succeeded. IPFS nodes sync between themselves. - */ -async function raceToFirstSuccess( - promises: Promise<{ success: boolean; result: T; gateway: string }>[] -): Promise<{ - success: boolean; - result: T | null; - gateway: string | null; - backgroundPromises: Promise<{ success: boolean; result: T | null; gateway: string }>[]; -}> { - return new Promise((resolve) => { - let resolved = false; - - // Track all promises for the fallback case - const allPromises = promises.map((p) => - p.catch(() => ({ success: false, result: null as T, gateway: "" })) - ); - - // Attach success handlers to each promise - promises.forEach((promise) => { - promise - .then((outcome) => { - if (!resolved && outcome.success) { - resolved = true; - resolve({ - success: true, - result: outcome.result, - gateway: outcome.gateway, - backgroundPromises: allPromises, - }); - } - }) - .catch(() => { - // Individual failures are fine - we just need ONE success - }); - }); - - // Fallback: if ALL fail, resolve with failure after all complete - Promise.allSettled(allPromises).then(() => { - if (!resolved) { - resolve({ - success: false, - result: null, - gateway: null, - backgroundPromises: [], - }); - } - }); - }); -} - -export interface PublishResult { - success: boolean; - cid?: string; - ipnsName?: string; - publishedNodes: number; - totalNodes: number; - failedNodes: string[]; - latencyMs: number; -} - -/** - * Store content on a single gateway - */ -async function storeContentOnGateway( - content: TxfStorageData, - gatewayUrl: string, - timeoutMs: number = 3000 -): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const formData = new FormData(); - const blob = new Blob([JSON.stringify(content)], { - type: "application/json", - }); - formData.append("file", blob); - - const response = await fetch(`${gatewayUrl}/api/v0/add`, { - method: "POST", - body: formData, - signal: controller.signal, - }); - - if (!response.ok) { - console.warn(`Failed to store on ${gatewayUrl}: ${response.status}`); - return null; - } - - const json = (await response.json()) as { Hash?: string }; - return json.Hash || null; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - console.warn(`Store content timeout on ${gatewayUrl}`); - } - return null; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Publish IPNS record on a single gateway - */ -async function publishIpnsOnGateway( - cid: string, - gatewayUrl: string, - options: { - keyName?: string; - lifetime?: string; // e.g., "87660h" for 10 years - } = {}, - timeoutMs: number = 5000 -): Promise<{ - name: string; - value: string; -} | null> { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const lifetime = options.lifetime ?? "87660h"; - const keyParam = options.keyName ? `&key=${options.keyName}` : ""; - - const url = - `${gatewayUrl}/api/v0/name/publish?arg=/ipfs/${cid}` + - `&lifetime=${lifetime}${keyParam}`; - - const response = await fetch(url, { - method: "POST", - signal: controller.signal, - }); - - if (!response.ok) { - console.warn(`Failed to publish IPNS on ${gatewayUrl}: ${response.status}`); - return null; - } - - const json = (await response.json()) as { - Name?: string; - Value?: string; - }; - - if (!json.Name || !json.Value) { - console.warn(`Invalid IPNS publish response from ${gatewayUrl}`); - return null; - } - - return { - name: json.Name, - value: json.Value, - }; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - console.warn(`IPNS publish timeout on ${gatewayUrl}`); - } - return null; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Main publisher using parallel multi-node strategy - */ -export class IpfsPublisher { - /** - * Publish token data to all configured IPFS nodes in parallel - * - * Flow: - * 1. Store content on all nodes in parallel - * 2. Check if any succeeded - * 3. Publish IPNS to all nodes using the stored CID - * 4. Return result after any successful publish - */ - async publishTokenData( - tokenData: TxfStorageData, - options: { - lifetime?: string; // Default: "87660h" (10 years) - timeoutMs?: number; // Default: 5000ms - } = {} - ): Promise { - const startTime = performance.now(); - const gateways = getAllBackendGatewayUrls(); - - if (gateways.length === 0) { - return { - success: false, - publishedNodes: 0, - totalNodes: 0, - failedNodes: [], - latencyMs: 0, - }; - } - - const lifetime = options.lifetime ?? "87660h"; - const timeoutMs = options.timeoutMs ?? 5000; - - // Step 1: Store content - return immediately on first success - const storePromises = gateways.map((gateway) => - storeContentOnGateway(tokenData, gateway, 3000) - .then((cid) => ({ - result: cid, - gateway, - success: cid !== null, - })) - .catch(() => ({ - result: null as string | null, - gateway, - success: false, - })) - ); - - const storeRace = await raceToFirstSuccess(storePromises); - - if (!storeRace.success || !storeRace.result) { - const latencyMs = performance.now() - startTime; - return { - success: false, - publishedNodes: 0, - totalNodes: gateways.length, - failedNodes: gateways, - latencyMs, - }; - } - - const cid = storeRace.result; - console.log(`📦 Content stored on ${storeRace.gateway}, returning immediately`); - - // Step 2: Publish IPNS - return immediately on first success - const publishPromises = gateways.map((gateway) => - publishIpnsOnGateway(cid, gateway, { lifetime }, timeoutMs) - .then((result) => ({ - result, - gateway, - success: result !== null, - })) - .catch(() => ({ - result: null as { name: string; value: string } | null, - gateway, - success: false, - })) - ); - - const publishRace = await raceToFirstSuccess(publishPromises); - - const latencyMs = performance.now() - startTime; - - if (publishRace.success && publishRace.result) { - console.log(`📢 IPNS published on ${publishRace.gateway}, returning immediately (${latencyMs.toFixed(0)}ms)`); - } - - return { - success: publishRace.success, - cid, - ipnsName: publishRace.result?.name, - publishedNodes: publishRace.success ? 1 : 0, // We returned on first success - totalNodes: gateways.length, - failedNodes: publishRace.success ? [] : gateways, // Unknown - others still running - latencyMs, - }; - } - - /** - * Publish IPNS record only (content already exists) - * Useful for re-publishing with different TTL or after recovery - */ - async publishIpns( - cid: string, - options: { - lifetime?: string; - timeoutMs?: number; - } = {} - ): Promise { - const startTime = performance.now(); - const gateways = getAllBackendGatewayUrls(); - - if (gateways.length === 0) { - return { - success: false, - cid, - publishedNodes: 0, - totalNodes: 0, - failedNodes: [], - latencyMs: 0, - }; - } - - const lifetime = options.lifetime ?? "87660h"; - const timeoutMs = options.timeoutMs ?? 5000; - - // Race to first success - return immediately when one node succeeds - const publishPromises = gateways.map((gateway) => - publishIpnsOnGateway(cid, gateway, { lifetime }, timeoutMs) - .then((result) => ({ - result, - gateway, - success: result !== null, - })) - .catch(() => ({ - result: null as { name: string; value: string } | null, - gateway, - success: false, - })) - ); - - const publishRace = await raceToFirstSuccess(publishPromises); - - const latencyMs = performance.now() - startTime; - - if (publishRace.success && publishRace.result) { - console.log(`📢 IPNS published on ${publishRace.gateway}, returning immediately (${latencyMs.toFixed(0)}ms)`); - } - - return { - success: publishRace.success, - cid, - ipnsName: publishRace.result?.name, - publishedNodes: publishRace.success ? 1 : 0, - totalNodes: gateways.length, - failedNodes: publishRace.success ? [] : gateways, - latencyMs, - }; - } -} - -// Singleton instance -let publisherInstance: IpfsPublisher | null = null; - -/** - * Get or create the singleton publisher instance - */ -export function getIpfsPublisher(): IpfsPublisher { - if (!publisherInstance) { - publisherInstance = new IpfsPublisher(); - } - return publisherInstance; -} diff --git a/src/components/wallet/L3/services/IpfsStorageService.ts b/src/components/wallet/L3/services/IpfsStorageService.ts deleted file mode 100644 index 625466de..00000000 --- a/src/components/wallet/L3/services/IpfsStorageService.ts +++ /dev/null @@ -1,4520 +0,0 @@ -import { createHelia, type Helia } from "helia"; -import { json } from "@helia/json"; -import { bootstrap } from "@libp2p/bootstrap"; -import { generateKeyPairFromSeed } from "@libp2p/crypto/keys"; -import { peerIdFromPrivateKey } from "@libp2p/peer-id"; -import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey } from "ipns"; -import { hkdf } from "@noble/hashes/hkdf"; -import { sha256 } from "@noble/hashes/sha256"; -import { sha512 } from "@noble/hashes/sha512"; -import * as ed from "@noble/ed25519"; -import type { CID } from "multiformats/cid"; -import type { PrivateKey, ConnectionGater, PeerId } from "@libp2p/interface"; -import type { NametagData } from "./types/TxfTypes"; -import { OutboxRepository } from "../../../../repositories/OutboxRepository"; -import { WalletRepository } from "../../../../repositories/WalletRepository"; // For deprecated methods only -import { IdentityManager } from "./IdentityManager"; -import type { Token } from "../data/model"; -import { - getTokensForAddress, - getArchivedTokensForAddress, - getTombstonesForAddress, - getNametagForAddress, - clearNametagForAddress, -} from "./InventorySyncService"; -import type { TxfStorageData, TxfMeta, TxfToken, TombstoneEntry } from "./types/TxfTypes"; -import { isTokenKey, tokenIdFromKey } from "./types/TxfTypes"; -import type { IpfsTransport, IpnsResolutionResult, IpfsUploadResult, IpnsPublishResult, GatewayHealth } from "./types/IpfsTransport"; -import { buildTxfStorageData, parseTxfStorageData, txfToToken, tokenToTxf, getCurrentStateHash, hasMissingNewStateHash, repairMissingStateHash, computeAndPatchStateHash } from "./TxfSerializer"; -import { getTokenValidationService } from "./TokenValidationService"; -import { getConflictResolutionService } from "./ConflictResolutionService"; -import { getSyncCoordinator } from "./SyncCoordinator"; -import { getTokenBackupService } from "./TokenBackupService"; -import { SyncQueue, SyncPriority, type SyncOptions } from "./SyncQueue"; -// Re-export for callers -export { SyncPriority, type SyncOptions } from "./SyncQueue"; -// Note: retryWithBackoff was used for DHT publish, now handled by HTTP primary path -import { getBootstrapPeers, getConfiguredCustomPeers, getBackendPeerId, getAllBackendGatewayUrls, IPNS_RESOLUTION_CONFIG, IPFS_CONFIG } from "../../../../config/ipfs.config"; -// Fast HTTP-based IPNS resolution and content fetching (target: <2s sync) -import { getIpfsHttpResolver, computeCidFromContent } from "./IpfsHttpResolver"; -import { getIpfsMetrics, type IpfsMetricsSnapshot, type IpfsSource } from "./IpfsMetrics"; -import { getIpfsCache } from "./IpfsCache"; -import { STORAGE_KEY_PREFIXES } from "../../../../config/storageKeys"; -import { isNametagCorrupted } from "../../../../utils/tokenValidation"; - -// Configure @noble/ed25519 to use sync sha512 (required for getPublicKey without WebCrypto) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(ed.hashes as any).sha512 = (message: Uint8Array) => sha512(message); - -// ========================================== -// Types -// ========================================== - -export type StorageEventType = - | "storage:started" - | "storage:completed" - | "storage:failed" - | "ipns:published" - | "sync:state-changed"; - -export interface StorageEvent { - type: StorageEventType; - timestamp: number; - data?: { - cid?: string; - ipnsName?: string; - tokenCount?: number; - error?: string; - isSyncing?: boolean; - }; -} - -export type StorageEventCallback = (event: StorageEvent) => void | Promise; - -export interface StorageResult { - success: boolean; - cid?: string; - ipnsName?: string; - timestamp: number; - version?: number; - tokenCount?: number; - validationIssues?: string[]; - conflictsResolved?: number; - ipnsPublished?: boolean; - ipnsPublishPending?: boolean; // True if IPNS publish failed and will be retried - error?: string; -} - -export interface RestoreResult { - success: boolean; - tokens?: Token[]; - nametag?: NametagData; - version?: number; - timestamp: number; - error?: string; -} - -export interface StorageStatus { - initialized: boolean; - isSyncing: boolean; - lastSync: StorageResult | null; - ipnsName: string | null; - webCryptoAvailable: boolean; - currentVersion: number; - lastCid: string | null; -} - -interface StorageData { - version: number; - timestamp: number; - address: string; - tokens: SerializedToken[]; - nametag?: NametagData; // One nametag per identity (synced with tokens) -} - -interface SerializedToken { - id: string; - name: string; - symbol?: string; - amount?: string; - coinId?: string; - jsonData?: string; - status: string; - timestamp: number; - type: string; - iconUrl?: string; -} - -/** - * Result of IPNS resolution from a single gateway - */ -interface IpnsGatewayResult { - cid: string; - sequence: bigint; - gateway: string; - recordData: Uint8Array; - /** Cached content from gateway path (avoids re-fetch) */ - _cachedContent?: TxfStorageData; -} - -/** - * Result of progressive IPNS resolution across multiple gateways - */ -interface IpnsProgressiveResult { - best: IpnsGatewayResult | null; - allResults: IpnsGatewayResult[]; - respondedCount: number; - totalGateways: number; -} - -// ========================================== -// Constants -// ========================================== - -const HKDF_INFO = "ipfs-storage-ed25519-v1"; -const SYNC_DEBOUNCE_MS = 5000; - -// ========================================== -// IpfsStorageService -// ========================================== - -/** - * IPFS Storage Service - Pure IPFS/IPNS transport layer - * - * Implements IpfsTransport interface for low-level IPFS operations. - * InventorySyncService orchestrates the high-level sync logic. - */ -export class IpfsStorageService implements IpfsTransport { - private static instance: IpfsStorageService | null = null; - - private helia: Helia | null = null; - private ed25519PrivateKey: Uint8Array | null = null; - private ed25519PublicKey: Uint8Array | null = null; - private cachedIpnsName: string | null = null; - private ipnsKeyPair: PrivateKey | null = null; - private ipnsSequenceNumber: bigint = 0n; - - private identityManager: IdentityManager; - private eventCallbacks: StorageEventCallback[] = []; - - private isInitializing = false; - private isSyncing = false; - private isInitialSyncing = false; // Tracks initial IPNS-based sync on startup - private isInsideSyncFromIpns = false; // Tracks if we're inside syncFromIpns (to avoid deadlock) - private initialSyncCompletePromise: Promise | null = null; // Resolves when initial sync finishes - private initialSyncCompleteResolver: (() => void) | null = null; // Resolver for the above promise - private syncQueue: SyncQueue | null = null; // Lazy-initialized queue for sync requests - private syncTimer: ReturnType | null = null; - private lastSync: StorageResult | null = null; - private autoSyncEnabled = false; - private boundSyncHandler: (() => void) | null = null; - private connectionMaintenanceInterval: ReturnType | null = null; - - // IPNS polling state - private ipnsPollingInterval: ReturnType | null = null; - private boundVisibilityHandler: (() => void) | null = null; - private lastKnownRemoteSequence: bigint = 0n; - private isTabVisible: boolean = true; // Track tab visibility for adaptive polling - private currentIdentityAddress: string | null = null; // Track current identity for key re-derivation on switch - - // IPNS sync retry state - retries until verification succeeds - private ipnsSyncRetryActive: boolean = false; - private ipnsSyncRetryCount: number = 0; - private readonly MAX_IPNS_RETRY_DELAY_MS = 30000; // Max 30 seconds between retries - private readonly BASE_IPNS_RETRY_DELAY_MS = 1000; // Start with 1 second - - // Gateway health tracking (for IpfsTransport interface) - private gatewayHealth: Map = new Map(); - - private constructor(identityManager: IdentityManager) { - this.identityManager = identityManager; - } - - static getInstance(identityManager: IdentityManager): IpfsStorageService { - if (!IpfsStorageService.instance) { - IpfsStorageService.instance = new IpfsStorageService(identityManager); - } - return IpfsStorageService.instance; - } - - /** - * Reset the singleton instance. - * Must be called when the user switches to a different identity/address - * so that the new identity's IPFS storage is used. - */ - static async resetInstance(): Promise { - if (IpfsStorageService.instance) { - console.log("📦 Resetting IpfsStorageService instance for identity switch..."); - await IpfsStorageService.instance.shutdown(); - IpfsStorageService.instance = null; - } - } - - // ========================================== - // Lifecycle - // ========================================== - - /** - * Start listening for wallet changes and enable auto-sync - * Safe to call multiple times - will only initialize once - */ - startAutoSync(): void { - if (this.autoSyncEnabled) { - return; - } - - // DEPRECATED: wallet-updated listener removed to prevent dual-publish race conditions - // Auto-sync is now handled by InventorySyncService.inventorySync() - // this.boundSyncHandler = () => this.scheduleSync(); - // window.addEventListener("wallet-updated", this.boundSyncHandler); - - this.autoSyncEnabled = true; - console.log("📦 IPFS auto-sync enabled (auto-triggers disabled - use InventorySyncService)"); - console.warn("⚠️ [DEPRECATED] IpfsStorageService.startAutoSync() - auto-sync delegated to InventorySyncService"); - - // DEPRECATED: IPNS polling disabled to prevent dual-publish - // See setupVisibilityListener() for detailed rationale - // this.setupVisibilityListener(); - - // On startup, run IPNS-based sync once to discover remote state - this.syncFromIpns().catch(console.error); - } - - /** - * Graceful shutdown - */ - async shutdown(): Promise { - // NOTE: boundSyncHandler is null in new implementation (startAutoSync doesn't set it) - // Keeping defensive cleanup for backward compatibility - if (this.boundSyncHandler) { - window.removeEventListener("wallet-updated", this.boundSyncHandler); - this.boundSyncHandler = null; - } - this.autoSyncEnabled = false; - - // Clean up IPNS polling and visibility listener - this.cleanupVisibilityListener(); - - // Shutdown sync queue - if (this.syncQueue) { - this.syncQueue.shutdown(); - this.syncQueue = null; - } - - if (this.syncTimer) { - clearTimeout(this.syncTimer); - this.syncTimer = null; - } - if (this.connectionMaintenanceInterval) { - clearInterval(this.connectionMaintenanceInterval); - this.connectionMaintenanceInterval = null; - } - if (this.helia) { - await this.helia.stop(); - this.helia = null; - } - console.log("📦 IPFS storage service stopped"); - } - - // ========================================== - // Event System (for future Nostr integration) - // ========================================== - - /** - * Register callback for storage events - * Returns unsubscribe function - */ - onEvent(callback: StorageEventCallback): () => void { - this.eventCallbacks.push(callback); - return () => { - this.eventCallbacks = this.eventCallbacks.filter((cb) => cb !== callback); - }; - } - - private async emitEvent(event: StorageEvent): Promise { - // Dispatch browser event for React components - window.dispatchEvent( - new CustomEvent("ipfs-storage-event", { detail: event }) - ); - - // Update sync timestamp on successful storage completion - // This is used by TokenBackupService to determine if backup is needed - if (event.type === "storage:completed") { - try { - getTokenBackupService().updateSyncTimestamp(); - } catch { - // Ignore errors from backup service - } - } - - // Call registered callbacks (for future Nostr integration) - for (const callback of this.eventCallbacks) { - try { - await callback(event); - } catch (error) { - console.error("📦 Storage event callback error:", error); - } - } - } - - /** - * Emit sync state change event for React components to update UI in real-time - */ - private emitSyncStateChange(): void { - const isSyncing = this.isSyncing || this.isInitialSyncing; - console.log(`📦 Sync state changed: isSyncing=${isSyncing}`); - window.dispatchEvent( - new CustomEvent("ipfs-storage-event", { - detail: { - type: "sync:state-changed", - timestamp: Date.now(), - data: { isSyncing }, - } as StorageEvent, - }) - ); - } - - // ========================================== - // Initialization - // ========================================== - - /** - * Check if WebCrypto is available (required by Helia/libp2p) - */ - // ========================================== - // IpfsTransport Interface - STABLE API - // ========================================== - // These methods form the core IPFS transport layer - // and are called by InventorySyncService in Step 10. - // Do NOT deprecate - these are the canonical transport methods. - // ========================================== - - // ========================================== - // IpfsTransport Interface - Initialization - // ========================================== - - /** - * Check if WebCrypto API is available (required for IPNS) - * Part of IpfsTransport interface - */ - public isWebCryptoAvailable(): boolean { - try { - return typeof crypto !== "undefined" && - crypto.subtle !== undefined && - typeof crypto.subtle.digest === "function"; - } catch { - return false; - } - } - - /** - * Lazy initialization of Helia and key derivation - * Detects identity changes and re-derives keys automatically - * Part of IpfsTransport interface - */ - public async ensureInitialized(): Promise { - // Check if IPFS is disabled via environment variable - if (import.meta.env.VITE_ENABLE_IPFS === 'false') { - console.log("📦 IPFS disabled via VITE_ENABLE_IPFS=false"); - return false; - } - - // First, check if identity changed - we need to do this BEFORE the early return - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.warn("📦 No wallet identity - skipping IPFS init"); - return false; - } - - // If identity changed since last init, clear cached keys to force re-derivation - // This ensures we sync to the correct IPNS name when switching addresses - if (this.currentIdentityAddress && this.currentIdentityAddress !== identity.address) { - console.log(`📦 Identity changed: ${this.currentIdentityAddress.slice(0, 20)}... → ${identity.address.slice(0, 20)}...`); - console.log(`📦 Clearing cached IPNS keys for re-derivation`); - this.ed25519PrivateKey = null; - this.ed25519PublicKey = null; - this.ipnsKeyPair = null; - this.cachedIpnsName = null; - this.ipnsSequenceNumber = 0n; - // Keep helia alive - only re-derive cryptographic keys - } - - if (this.helia && this.ed25519PrivateKey) { - return true; - } - - if (this.isInitializing) { - // Wait for ongoing initialization - await new Promise((resolve) => setTimeout(resolve, 100)); - return this.ensureInitialized(); - } - - this.isInitializing = true; - - try { - // 0. Check WebCrypto availability (required by Helia/libp2p) - if (!this.isWebCryptoAvailable()) { - console.warn("📦 WebCrypto (crypto.subtle) not available - IPFS sync disabled"); - console.warn("📦 This typically happens in non-secure contexts (HTTP instead of HTTPS)"); - console.warn("📦 Wallet will continue to work, but IPFS backup/sync is unavailable"); - return false; - } - - // Identity already fetched above, no need to fetch again - - // 2. Derive Ed25519 key from secp256k1 private key using HKDF - const walletSecret = this.hexToBytes(identity.privateKey); - const derivedKey = hkdf( - sha256, - walletSecret, - undefined, // no salt for deterministic derivation - HKDF_INFO, - 32 - ); - this.ed25519PrivateKey = derivedKey; - this.ed25519PublicKey = ed.getPublicKey(derivedKey); - - // 3. Generate libp2p key pair for IPNS from the derived key - this.ipnsKeyPair = await generateKeyPairFromSeed("Ed25519", derivedKey); - const ipnsPeerId = peerIdFromPrivateKey(this.ipnsKeyPair); - - // 4. Compute proper IPNS name from peer ID and migrate old storage keys - const oldIpnsName = `ipns-${this.bytesToHex(this.ed25519PublicKey).slice(0, 32)}`; - const newIpnsName = ipnsPeerId.toString(); - this.cachedIpnsName = newIpnsName; - this.currentIdentityAddress = identity.address; // Track which identity we initialized for - this.migrateStorageKeys(oldIpnsName, newIpnsName); - - // Load last IPNS sequence number from storage - this.ipnsSequenceNumber = this.getIpnsSequenceNumber(); - - // 4. Initialize Helia (browser IPFS) with custom bootstrap peers - const bootstrapPeers = getBootstrapPeers(); - const customPeerCount = getConfiguredCustomPeers().length; - - console.log("📦 Initializing Helia with restricted peer connections..."); - console.log(`📦 Bootstrap peers: ${bootstrapPeers.length} total (${customPeerCount} custom, ${bootstrapPeers.length - customPeerCount} fallback)`); - - // Create connection gater to restrict connections to bootstrap peers only - const connectionGater = this.createConnectionGater(bootstrapPeers); - - this.helia = await createHelia({ - libp2p: { - connectionGater, - peerDiscovery: [ - bootstrap({ list: bootstrapPeers }), - // No mDNS - don't discover local network peers - ], - connectionManager: { - maxConnections: IPFS_CONFIG.maxConnections, - }, - }, - }); - - // Log browser's peer ID for debugging - const browserPeerId = this.helia.libp2p.peerId.toString(); - console.log("📦 IPFS storage service initialized"); - console.log("📦 Browser Peer ID:", browserPeerId); - console.log("📦 IPNS name:", this.cachedIpnsName); - console.log("📦 Identity address:", identity.address.slice(0, 30) + "..."); - - // Extract bootstrap peer IDs for filtering connection logs - const bootstrapPeerIds = new Set( - bootstrapPeers.map((addr) => { - const match = addr.match(/\/p2p\/([^/]+)$/); - return match ? match[1] : null; - }).filter(Boolean) as string[] - ); - - // Set up peer connection event handlers - only log bootstrap peers - this.helia.libp2p.addEventListener("peer:connect", (event) => { - const remotePeerId = event.detail.toString(); - if (bootstrapPeerIds.has(remotePeerId)) { - console.log(`📦 Connected to bootstrap peer: ${remotePeerId.slice(0, 16)}...`); - } - }); - - this.helia.libp2p.addEventListener("peer:disconnect", (event) => { - const remotePeerId = event.detail.toString(); - if (bootstrapPeerIds.has(remotePeerId)) { - console.log(`📦 Disconnected from bootstrap peer: ${remotePeerId.slice(0, 16)}...`); - } - }); - - // Log initial connections after a short delay - setTimeout(() => { - const connections = this.helia?.libp2p.getConnections() || []; - console.log(`📦 Active connections: ${connections.length}`); - connections.slice(0, 5).forEach((conn) => { - console.log(`📦 - ${conn.remotePeer.toString().slice(0, 16)}... via ${conn.remoteAddr.toString()}`); - }); - }, 5000); - - // Start connection maintenance for backend peer - this.startBackendConnectionMaintenance(); - - return true; - } catch (error) { - console.error("📦 Failed to initialize IPFS storage:", error); - // Provide helpful context for WebCrypto-related errors - if (error instanceof Error && error.message.includes("crypto")) { - console.warn("📦 This error is likely due to missing WebCrypto support"); - console.warn("📦 Consider using HTTPS or a secure development environment"); - } - return false; - } finally { - this.isInitializing = false; - } - } - - // ========================================== - // Key Derivation Utilities - // ========================================== - - private hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substr(i * 2, 2), 16); - } - return bytes; - } - - private bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } - - /** - * Migrate local storage keys from old IPNS name format to new PeerId format - */ - private migrateStorageKeys(oldIpnsName: string, newIpnsName: string): void { - if (oldIpnsName === newIpnsName) return; - - // Migrate version counter - const oldVersionKey = `${STORAGE_KEY_PREFIXES.IPFS_VERSION}${oldIpnsName}`; - const newVersionKey = `${STORAGE_KEY_PREFIXES.IPFS_VERSION}${newIpnsName}`; - const version = localStorage.getItem(oldVersionKey); - if (version && !localStorage.getItem(newVersionKey)) { - localStorage.setItem(newVersionKey, version); - localStorage.removeItem(oldVersionKey); - console.log(`📦 Migrated version key: ${oldIpnsName} -> ${newIpnsName}`); - } - - // Migrate last CID - const oldCidKey = `${STORAGE_KEY_PREFIXES.IPFS_LAST_CID}${oldIpnsName}`; - const newCidKey = `${STORAGE_KEY_PREFIXES.IPFS_LAST_CID}${newIpnsName}`; - const lastCid = localStorage.getItem(oldCidKey); - if (lastCid && !localStorage.getItem(newCidKey)) { - localStorage.setItem(newCidKey, lastCid); - localStorage.removeItem(oldCidKey); - console.log(`📦 Migrated CID key: ${oldIpnsName} -> ${newIpnsName}`); - } - } - - // ========================================== - // Connection Gater (Peer Filtering) - // ========================================== - - /** - * Create a connection gater that only allows connections to bootstrap peers. - * This restricts libp2p from connecting to random DHT-discovered peers, - * reducing browser traffic significantly. - * - * @param bootstrapPeers - List of bootstrap multiaddrs containing allowed peer IDs - */ - private createConnectionGater(bootstrapPeers: string[]): ConnectionGater { - // Extract peer IDs from bootstrap multiaddrs - const allowedPeerIds = new Set( - bootstrapPeers.map((addr) => { - const match = addr.match(/\/p2p\/([^/]+)$/); - return match ? match[1] : null; - }).filter((id): id is string => id !== null) - ); - - console.log(`📦 Connection gater: allowing ${allowedPeerIds.size} peer(s)`); - - return { - // Allow dialing any multiaddr (peer filtering happens at connection level) - denyDialMultiaddr: async () => false, - - // Block outbound connections to non-allowed peers - denyDialPeer: async (peerId: PeerId) => { - const peerIdStr = peerId.toString(); - const denied = !allowedPeerIds.has(peerIdStr); - if (denied) { - console.debug(`📦 Blocked dial to non-bootstrap peer: ${peerIdStr.slice(0, 16)}...`); - } - return denied; - }, - - // Allow inbound connections (rare in browser, but don't block) - denyInboundConnection: async () => false, - - // Block outbound connections to non-allowed peers - denyOutboundConnection: async (peerId: PeerId) => { - const peerIdStr = peerId.toString(); - return !allowedPeerIds.has(peerIdStr); - }, - - // Allow encrypted connections (peer already passed connection check) - denyInboundEncryptedConnection: async () => false, - denyOutboundEncryptedConnection: async () => false, - - // Allow upgraded connections - denyInboundUpgradedConnection: async () => false, - denyOutboundUpgradedConnection: async () => false, - - // Allow all multiaddrs for allowed peers - filterMultiaddrForPeer: async () => true, - }; - } - - // ========================================== - // IpfsTransport Interface - IPNS Name Management - // ========================================== - - /** - * Get the IPNS name for the current wallet - * Part of IpfsTransport interface - */ - public getIpnsName(): string | null { - return this.cachedIpnsName; - } - - // ========================================== - // IpfsTransport Interface - Version and CID Tracking - // ========================================== - - /** - * Get current version counter (monotonic) - * Part of IpfsTransport interface - */ - public getVersionCounter(): number { - if (!this.cachedIpnsName) return 0; - const key = `${STORAGE_KEY_PREFIXES.IPFS_VERSION}${this.cachedIpnsName}`; - return parseInt(localStorage.getItem(key) || "0", 10); - } - - /** - * Set version counter - * Part of IpfsTransport interface - */ - public setVersionCounter(version: number): void { - if (!this.cachedIpnsName) return; - const key = `${STORAGE_KEY_PREFIXES.IPFS_VERSION}${this.cachedIpnsName}`; - localStorage.setItem(key, String(version)); - } - - /** - * Get last published CID - * Part of IpfsTransport interface - */ - public getLastCid(): string | null { - if (!this.cachedIpnsName) return null; - const key = `${STORAGE_KEY_PREFIXES.IPFS_LAST_CID}${this.cachedIpnsName}`; - return localStorage.getItem(key); - } - - /** - * Set last published CID - * Part of IpfsTransport interface - */ - public setLastCid(cid: string): void { - if (!this.cachedIpnsName) return; - const key = `${STORAGE_KEY_PREFIXES.IPFS_LAST_CID}${this.cachedIpnsName}`; - localStorage.setItem(key, cid); - } - - // ========================================== - // IpfsTransport Interface - IPNS Sequence Tracking - // ========================================== - - /** - * Get current IPNS sequence number (for conflict detection) - * Part of IpfsTransport interface - */ - public getIpnsSequence(): bigint { - return this.ipnsSequenceNumber; - } - - /** - * Set IPNS sequence number - * Part of IpfsTransport interface - */ - public setIpnsSequence(seq: bigint): void { - this.ipnsSequenceNumber = seq; - this.setIpnsSequenceNumber(seq); - } - - // ========================================== - // IpfsTransport Interface - Gateway Health Monitoring - // ========================================== - - /** - * Get gateway health metrics - * Used for gateway selection and circuit breaking - * Part of IpfsTransport interface - */ - public getGatewayHealth(): Map { - return new Map(this.gatewayHealth); - } - - /** - * Update gateway health after an operation - * @internal Used internally to track gateway reliability - */ - private updateGatewayHealth(gateway: string, success: boolean): void { - const current = this.gatewayHealth.get(gateway) || { - lastSuccess: 0, - failureCount: 0, - }; - - if (success) { - this.gatewayHealth.set(gateway, { - lastSuccess: Date.now(), - failureCount: 0, - }); - } else { - this.gatewayHealth.set(gateway, { - ...current, - failureCount: current.failureCount + 1, - }); - } - } - - // ========================================== - // IpfsTransport Interface - IPNS Resolution - // ========================================== - - /** - * Resolve IPNS name to CID and fetch content - * Part of IpfsTransport interface - */ - public async resolveIpns(): Promise { - const result = await this.resolveIpnsProgressively(); - - if (!result.best) { - return { - cid: null, - sequence: 0n, - }; - } - - return { - cid: result.best.cid, - sequence: result.best.sequence, - content: result.best._cachedContent, - }; - } - - // ========================================== - // IpfsTransport Interface - IPFS Content Operations - // ========================================== - - /** - * Fetch content from IPFS by CID - * Part of IpfsTransport interface - */ - public async fetchContent(cid: string): Promise { - return this.fetchRemoteContent(cid); - } - - /** - * Upload content to IPFS - * Part of IpfsTransport interface - */ - public async uploadContent(data: TxfStorageData): Promise { - try { - // Ensure initialized - const initialized = await this.ensureInitialized(); - if (!initialized || !this.helia) { - return { - cid: "", - success: false, - error: "IPFS not initialized", - }; - } - - // Upload to local Helia node - const j = json(this.helia); - const cid = await j.add(data); - const cidString = cid.toString(); - - // Multi-node upload: directly upload content to all configured IPFS nodes - // IMPORTANT: Use the CID returned by the backend, not Helia's CID! - // Helia uses dag-json codec, but /api/v0/add uses raw codec. - // We must use a consistent CID for IPNS publishing. - let backendCid: string | null = null; - const gatewayUrls = getAllBackendGatewayUrls(); - if (gatewayUrls.length > 0) { - console.log(`📦 Uploading to ${gatewayUrls.length} IPFS node(s)...`); - - const jsonBlob = new Blob([JSON.stringify(data)], { - type: "application/json", - }); - - // Upload to all nodes in parallel - const uploadPromises = gatewayUrls.map(async (gatewayUrl) => { - try { - const formData = new FormData(); - formData.append("file", jsonBlob, "wallet.json"); - - const response = await fetch( - `${gatewayUrl}/api/v0/add?pin=true&cid-version=1`, - { method: "POST", body: formData } - ); - - const success = response.ok; - this.updateGatewayHealth(gatewayUrl, success); - - if (success) { - const result = await response.json(); - const hostname = new URL(gatewayUrl).hostname; - console.log(`📦 Uploaded to ${hostname}: ${result.Hash}`); - return { success: true, host: gatewayUrl, cid: result.Hash as string }; - } - return { success: false, host: gatewayUrl, error: response.status }; - } catch (error) { - this.updateGatewayHealth(gatewayUrl, false); - const hostname = new URL(gatewayUrl).hostname; - console.warn(`📦 Upload to ${hostname} failed:`, error); - return { success: false, host: gatewayUrl, error }; - } - }); - - const results = await Promise.allSettled(uploadPromises); - const successfulResults = results.filter( - (r): r is PromiseFulfilledResult<{ success: true; host: string; cid: string }> => - r.status === "fulfilled" && r.value.success === true - ); - console.log(`📦 Content uploaded to ${successfulResults.length}/${gatewayUrls.length} nodes`); - - // Use the first successful backend CID - this is the canonical CID - if (successfulResults.length > 0) { - backendCid = successfulResults[0].value.cid; - const uploadHost = successfulResults[0].value.host; - console.log(`📦 Using backend CID for IPNS: ${backendCid.slice(0, 16)}...`); - - // Verify content is retrievable (backend might need time to index) - // This prevents CID mismatch issues when content is fetched immediately after upload - const maxRetries = 3; - const retryDelay = 200; // ms - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const verifyResponse = await fetch( - `${uploadHost}/ipfs/${backendCid}`, - { - method: "HEAD", - signal: AbortSignal.timeout(2000), - } - ); - if (verifyResponse.ok) { - console.log(`📦 Content verified retrievable (attempt ${attempt})`); - break; - } - if (attempt < maxRetries) { - await new Promise((r) => setTimeout(r, retryDelay)); - } - } catch { - if (attempt < maxRetries) { - await new Promise((r) => setTimeout(r, retryDelay)); - } - } - } - } - } - - // Determine which CID to use: prefer backend CID (raw codec) over Helia CID (dag-json codec) - const canonicalCid = backendCid || cidString; - const canonicalCidSource = backendCid ? "backend (raw codec)" : "Helia (dag-json codec)"; - if (backendCid && backendCid !== cidString) { - console.log(`📦 CID codec note: backend=${backendCid.slice(0, 12)}... vs helia=${cidString.slice(0, 12)}... (using ${canonicalCidSource})`); - } - - // Announce content to DHT (non-blocking) - const PROVIDE_TIMEOUT = 10000; - try { - console.log(`📦 Announcing CID to network: ${canonicalCid.slice(0, 16)}...`); - // Announce the canonical CID (which may be different from Helia's) - const { CID: CIDClass } = await import("multiformats/cid"); - const cidToAnnounce = CIDClass.parse(canonicalCid); - await Promise.race([ - this.helia.routing.provide(cidToAnnounce), - new Promise((_, reject) => - setTimeout(() => reject(new Error("DHT provide timeout")), PROVIDE_TIMEOUT) - ), - ]); - console.log(`📦 CID announced to network`); - } catch (provideError) { - console.warn(`📦 Could not announce to DHT (non-fatal):`, provideError); - } - - return { - cid: canonicalCid, - success: true, - }; - } catch (error) { - return { - cid: "", - success: false, - error: error instanceof Error ? error.message : "Upload failed", - }; - } - } - - // ========================================== - // IpfsTransport Interface - IPNS Publishing - // ========================================== - - /** - * Publish CID to IPNS - * Part of IpfsTransport interface - */ - public async publishIpns(cid: string): Promise { - try { - const { CID } = await import("multiformats/cid"); - const parsedCid = CID.parse(cid); - - const ipnsName = await this.publishToIpns(parsedCid); - - if (ipnsName) { - return { - ipnsName, - success: true, - sequence: this.ipnsSequenceNumber, - verified: true, - }; - } else { - return { - ipnsName: this.cachedIpnsName, - success: false, - verified: false, - error: "IPNS publish failed or not verified", - }; - } - } catch (error) { - return { - ipnsName: this.cachedIpnsName, - success: false, - verified: false, - error: error instanceof Error ? error.message : "IPNS publish failed", - }; - } - } - - // ========================================== - // IPNS Publishing - // ========================================== - - /** - * Get the last IPNS sequence number from storage - */ - private getIpnsSequenceNumber(): bigint { - if (!this.cachedIpnsName) return 0n; - const key = `${STORAGE_KEY_PREFIXES.IPNS_SEQ}${this.cachedIpnsName}`; - const stored = localStorage.getItem(key); - return stored ? BigInt(stored) : 0n; - } - - /** - * Save the IPNS sequence number to storage - */ - private setIpnsSequenceNumber(seq: bigint): void { - if (!this.cachedIpnsName) return; - const key = `${STORAGE_KEY_PREFIXES.IPNS_SEQ}${this.cachedIpnsName}`; - localStorage.setItem(key, seq.toString()); - } - - /** - * Publish pre-signed IPNS record via Kubo HTTP API - * Much faster than browser DHT - server has better connectivity - * @param marshalledRecord The signed, marshalled IPNS record bytes - * @returns true if at least one backend accepted the record - */ - private async publishIpnsViaHttp( - marshalledRecord: Uint8Array - ): Promise { - const gatewayUrls = getAllBackendGatewayUrls(); - if (gatewayUrls.length === 0) { - console.warn("📦 No backend gateways configured for HTTP IPNS publish"); - return false; - } - - // For Kubo API, we pass the IPNS name (peer ID) as the first arg - const ipnsName = this.cachedIpnsName; - if (!ipnsName) { - console.warn("📦 No IPNS name cached - cannot publish via HTTP"); - return false; - } - - console.log(`📦 Publishing IPNS via HTTP to ${gatewayUrls.length} backend(s)...`); - - // Try all configured gateways in parallel - const results = await Promise.allSettled( - gatewayUrls.map(async (gatewayUrl) => { - try { - // Kubo /api/v0/routing/put expects: - // - arg: the routing key path (e.g., "/ipns/12D3KooW...") - // - body: the marshalled record bytes as multipart form - const formData = new FormData(); - // Create Blob from Uint8Array (spread to array for type compatibility) - formData.append( - "file", - new Blob([new Uint8Array(marshalledRecord)]), - "record" - ); - - // NOTE: Do NOT use allow-offline=true - it prevents DHT propagation! - // The call may take longer but ensures IPNS records reach the DHT - const response = await fetch( - `${gatewayUrl}/api/v0/routing/put?arg=/ipns/${ipnsName}`, - { - method: "POST", - body: formData, - signal: AbortSignal.timeout(30000), // 30s timeout - } - ); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new Error(`HTTP ${response.status}: ${errorText.slice(0, 100)}`); - } - - const hostname = new URL(gatewayUrl).hostname; - console.log(`📦 IPNS record accepted by ${hostname}`); - return gatewayUrl; - } catch (error) { - const hostname = new URL(gatewayUrl).hostname; - console.warn(`📦 HTTP IPNS publish to ${hostname} failed:`, error); - throw error; - } - }) - ); - - const successful = results.filter((r) => r.status === "fulfilled"); - if (successful.length > 0) { - console.log( - `📦 IPNS record published via HTTP to ${successful.length}/${gatewayUrls.length} backends` - ); - return true; - } - - console.warn("📦 HTTP IPNS publish failed on all backends"); - return false; - } - - /** - * Fire-and-forget IPNS publish via browser DHT - * Runs in background - doesn't block sync completion - * Provides redundancy alongside HTTP publish - * @param routingKey The DHT routing key - * @param marshalledRecord The signed, marshalled IPNS record bytes - */ - private publishIpnsViaDhtAsync( - routingKey: Uint8Array, - marshalledRecord: Uint8Array - ): void { - if (!this.helia) return; - - const helia = this.helia; - const DHT_BACKGROUND_TIMEOUT = 60000; // 60s - longer timeout since it's background - - // Don't await - let it run in background - (async () => { - try { - await Promise.race([ - helia.routing.put(routingKey, marshalledRecord), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("DHT background timeout")), - DHT_BACKGROUND_TIMEOUT - ) - ), - ]); - console.log("📦 IPNS record also propagated via browser DHT"); - } catch (error) { - // Non-fatal - HTTP publish is primary - console.debug("📦 Browser DHT IPNS publish completed with:", error); - } - })(); - } - - /** - * Publish CID to IPNS using dual strategy: - * 1. Primary: HTTP POST to Kubo backend (fast, reliable) - * 2. Fallback: Fire-and-forget browser DHT (slow but provides redundancy) - * @param cid The CID to publish - * @returns The IPNS name on success, null on failure (non-fatal) - */ - private async publishToIpns(cid: CID): Promise { - if (!this.helia || !this.ipnsKeyPair) { - console.warn("📦 IPNS key not initialized - skipping IPNS publish"); - return null; - } - - const IPNS_LIFETIME = 99 * 365 * 24 * 60 * 60 * 1000; // 99 years in ms - const ipnsKeyPair = this.ipnsKeyPair; - - try { - console.log( - `📦 Publishing to IPNS: ${this.cachedIpnsName?.slice(0, 16)}... -> ${cid.toString().slice(0, 16)}...` - ); - - // Use max of local and known remote sequence + 1 to ensure we're always ahead - // This handles the case where another device published with a higher sequence - const baseSeq = this.ipnsSequenceNumber > this.lastKnownRemoteSequence - ? this.ipnsSequenceNumber - : this.lastKnownRemoteSequence; - this.ipnsSequenceNumber = baseSeq + 1n; - console.log(`📦 IPNS sequence: local=${this.ipnsSequenceNumber - 1n}, remote=${this.lastKnownRemoteSequence}, using=${this.ipnsSequenceNumber}`); - - // 1. Create and sign IPNS record (once - used for both paths) - const record = await createIPNSRecord( - ipnsKeyPair, - `/ipfs/${cid.toString()}`, - this.ipnsSequenceNumber, - IPNS_LIFETIME - ); - - // Marshal the record for storage/transmission - const marshalledRecord = marshalIPNSRecord(record); - - // Create the routing key from the public key (needed for DHT path) - const routingKey = multihashToIPNSRoutingKey( - ipnsKeyPair.publicKey.toMultihash() - ); - - // 2. Publish via HTTP (primary, fast) - AWAIT this - // HTTP path uses cachedIpnsName internally, doesn't need routingKey - const httpSuccess = await this.publishIpnsViaHttp(marshalledRecord); - - // 3. Publish via browser DHT (fallback, fire-and-forget) - DON'T await - // This runs in background regardless of HTTP result for redundancy - this.publishIpnsViaDhtAsync(routingKey, marshalledRecord); - - if (httpSuccess) { - // CRITICAL: Verify the IPNS record was actually persisted - // HTTP 200 only means the node received the record, NOT that it persisted - const httpResolver = getIpfsHttpResolver(); - const cidString = cid.toString(); - const verification = await httpResolver.verifyIpnsRecord( - this.cachedIpnsName!, - this.ipnsSequenceNumber, - cidString, - 3 // retries - ); - - if (verification.verified) { - // Save sequence number only after verification confirms persistence - this.setIpnsSequenceNumber(this.ipnsSequenceNumber); - console.log( - `✅ IPNS record published AND verified (seq: ${this.ipnsSequenceNumber})` - ); - - // Clear IPNS cache to ensure next load fetches fresh content for the new CID - // Without this, the cache may return stale content from the old CID - getIpfsCache().clearIpnsRecords(); - - return this.cachedIpnsName; - } else { - // Verification failed - the record didn't persist! - console.error( - `❌ IPNS publish verification FAILED: ${verification.error}` - ); - console.error( - ` Expected: seq=${this.ipnsSequenceNumber}, cid=${cidString.slice(0, 16)}...` - ); - if (verification.actualSeq !== undefined) { - console.error( - ` Actual: seq=${verification.actualSeq}, cid=${verification.actualCid?.slice(0, 16) ?? 'unknown'}...` - ); - } - - // If node has higher sequence, update our tracking to avoid republish loops - if (verification.actualSeq !== undefined && verification.actualSeq > this.ipnsSequenceNumber) { - this.lastKnownRemoteSequence = verification.actualSeq; - console.log(`📦 Updated lastKnownRemoteSequence to ${verification.actualSeq}`); - } - - // Rollback our sequence since publish didn't persist - this.ipnsSequenceNumber--; - console.log(`📦 Rolled back local sequence to ${this.ipnsSequenceNumber}`); - - // Return null to indicate publish failed - return null; - } - } - - // HTTP failed - DHT is still trying in background - // We still consider this a partial success since DHT may succeed - console.warn( - "📦 HTTP IPNS publish failed, DHT attempting in background" - ); - // Don't rollback sequence - DHT may succeed with this sequence - // But don't persist it either - if DHT fails, we'll retry with same seq - return null; - } catch (error) { - // Rollback sequence number on failure - this.ipnsSequenceNumber--; - // Non-fatal - content is still stored and announced - console.warn(`📦 IPNS publish failed:`, error); - return null; - } - } - - /** - * Start the IPNS sync retry loop. - * This runs in the background, retrying until IPNS verification succeeds. - * Each retry: fetches latest IPNS, merges with local, republishes. - * - * Uses exponential backoff with jitter to avoid thundering herd. - */ - private startIpnsSyncRetryLoop(): void { - if (this.ipnsSyncRetryActive) { - console.log(`📦 [RetryLoop] Already active, skipping start`); - return; - } - - this.ipnsSyncRetryActive = true; - this.ipnsSyncRetryCount = 0; - console.log(`📦 [RetryLoop] Starting IPNS sync retry loop...`); - - // Run the loop (fire and forget - it manages itself) - this.runIpnsSyncRetryIteration(); - } - - /** - * Single iteration of the IPNS sync retry loop. - * Schedules the next iteration if needed. - */ - private async runIpnsSyncRetryIteration(): Promise { - if (!this.ipnsSyncRetryActive) { - console.log(`📦 [RetryLoop] Stopped (active=false)`); - return; - } - - this.ipnsSyncRetryCount++; - const attempt = this.ipnsSyncRetryCount; - - // Calculate delay with exponential backoff + jitter - const baseDelay = Math.min( - this.BASE_IPNS_RETRY_DELAY_MS * Math.pow(1.5, attempt - 1), - this.MAX_IPNS_RETRY_DELAY_MS - ); - // Add jitter: 50-150% of base delay - const jitter = 0.5 + Math.random(); - const delayMs = Math.round(baseDelay * jitter); - - console.log(`📦 [RetryLoop] Attempt ${attempt}: waiting ${delayMs}ms before retry...`); - - await new Promise(resolve => setTimeout(resolve, delayMs)); - - // Check if still active after delay - if (!this.ipnsSyncRetryActive) { - console.log(`📦 [RetryLoop] Stopped during delay`); - return; - } - - try { - console.log(`📦 [RetryLoop] Attempt ${attempt}: Fetching latest IPNS and resyncing...`); - - // Step 1: Fetch the latest IPNS record to get current sequence and content - const httpResolver = getIpfsHttpResolver(); - httpResolver.invalidateIpnsCache(); // Force fresh fetch - - const resolution = await this.resolveIpnsProgressively(); - - if (resolution.best) { - const remoteSeq = resolution.best.sequence; - const remoteCid = resolution.best.cid; - - console.log(`📦 [RetryLoop] Remote state: seq=${remoteSeq}, cid=${remoteCid.slice(0, 16)}...`); - - // Update our tracking of remote sequence - this.lastKnownRemoteSequence = remoteSeq; - - // Step 2: Fetch remote content and merge with local - const remoteData = await this.fetchRemoteContent(remoteCid); - if (remoteData) { - console.log(`📦 [RetryLoop] Fetched remote content, importing...`); - await this.importRemoteData(remoteData); - } - } - - // Step 3: Re-sync with merged data (this will publish with new sequence) - console.log(`📦 [RetryLoop] Re-syncing with merged data...`); - const result = await this.syncNow({ forceIpnsPublish: true, isRetryAttempt: true }); - - if (result.success && result.ipnsPublished) { - // Success! Stop the retry loop - console.log(`✅ [RetryLoop] IPNS sync succeeded after ${attempt} attempt(s)`); - this.ipnsSyncRetryActive = false; - this.ipnsSyncRetryCount = 0; - return; - } - - // Still pending - continue retrying - if (result.ipnsPublishPending) { - console.log(`📦 [RetryLoop] Attempt ${attempt} still pending, will retry...`); - } else { - console.log(`📦 [RetryLoop] Attempt ${attempt} completed but IPNS not published, will retry...`); - } - - } catch (error) { - console.error(`📦 [RetryLoop] Attempt ${attempt} failed with error:`, error); - } - - // Schedule next iteration - if (this.ipnsSyncRetryActive) { - // Use setTimeout to avoid blocking and allow the event loop to process other events - setTimeout(() => this.runIpnsSyncRetryIteration(), 0); - } - } - - /** - * Stop the IPNS sync retry loop (e.g., when component unmounts or on success) - */ - stopIpnsSyncRetryLoop(): void { - if (this.ipnsSyncRetryActive) { - console.log(`📦 [RetryLoop] Stopping IPNS sync retry loop`); - this.ipnsSyncRetryActive = false; - this.ipnsSyncRetryCount = 0; - } - } - - // ========================================== - // Progressive IPNS Resolution (Multi-Gateway) - // ========================================== - - /** - * Fetch IPNS record from a single HTTP gateway - * Returns the CID and sequence number, or null if failed - */ - private async resolveIpnsFromGateway(gatewayUrl: string): Promise { - if (!this.cachedIpnsName) { - return null; - } - - try { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - IPNS_RESOLUTION_CONFIG.perGatewayTimeoutMs - ); - - // Use Kubo's routing/get API to fetch the raw IPNS record - const response = await fetch( - `${gatewayUrl}/api/v0/routing/get?arg=/ipns/${this.cachedIpnsName}`, - { - method: "POST", - signal: controller.signal, - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned ${response.status}`); - return null; - } - - // Kubo returns JSON with base64-encoded record in "Extra" field: - // {"ID":"","Type":5,"Responses":null,"Extra":""} - const json = await response.json() as { Extra?: string; Type?: number }; - - if (!json.Extra) { - console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned no Extra field`); - return null; - } - - // Decode base64 Extra field to get raw IPNS record - const recordData = Uint8Array.from(atob(json.Extra), c => c.charCodeAt(0)); - const record = unmarshalIPNSRecord(recordData); - - // Extract CID from value path - const cidMatch = record.value.match(/^\/ipfs\/(.+)$/); - if (!cidMatch) { - console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned invalid IPNS value: ${record.value}`); - return null; - } - - return { - cid: cidMatch[1], - sequence: record.sequence, - gateway: gatewayUrl, - recordData, - }; - } catch (error) { - const hostname = new URL(gatewayUrl).hostname; - if (error instanceof Error && error.name === "AbortError") { - console.debug(`📦 Gateway ${hostname} timeout`); - } else { - console.debug(`📦 Gateway ${hostname} error:`, error); - } - return null; - } - } - - /** - * Resolve IPNS via gateway path (fast, ~30ms with cache) - * Uses /ipns/{name}?format=dag-json for cached resolution - * Returns CID and content directly, but no sequence number - */ - private async resolveIpnsViaGatewayPath( - gatewayUrl: string - ): Promise<{ cid: string; content: TxfStorageData; latency: number } | null> { - if (!this.cachedIpnsName) { - return null; - } - - const startTime = Date.now(); - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - IPNS_RESOLUTION_CONFIG.gatewayPathTimeoutMs - ); - - try { - const url = `${gatewayUrl}/ipns/${this.cachedIpnsName}?format=dag-json`; - const response = await fetch(url, { - signal: controller.signal, - headers: { - Accept: "application/vnd.ipld.dag-json, application/json", - }, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - return null; - } - - // Extract CID from X-Ipfs-Path header: "/ipfs/bafk..." - const ipfsPath = response.headers.get("X-Ipfs-Path"); - const cidMatch = ipfsPath?.match(/^\/ipfs\/(.+)$/); - const cid = cidMatch?.[1] || ""; - - const content = await response.json() as TxfStorageData; - const latency = Date.now() - startTime; - - if (!cid) { - console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned no X-Ipfs-Path header`); - } - - return { cid, content, latency }; - } catch (error) { - clearTimeout(timeoutId); - const hostname = new URL(gatewayUrl).hostname; - if (error instanceof Error && error.name === "AbortError") { - console.debug(`📦 Gateway path ${hostname} timeout`); - } else { - console.debug(`📦 Gateway path ${hostname} error:`, error); - } - return null; - } - } - - /** - * Resolve IPNS progressively from all gateways using dual-path racing - * - * Races both methods in parallel for each gateway: - * - Gateway path: /ipns/{name}?format=dag-json (fast ~30ms, returns content) - * - Routing API: /api/v0/routing/get (slow ~5s, returns sequence number) - * - * Returns best result after initial timeout, continues collecting late responses. - * Gateway path results include cached content to avoid re-fetch. - * Calls onLateHigherSequence if a late response has higher sequence. - */ - private async resolveIpnsProgressively( - onLateHigherSequence?: (result: IpnsGatewayResult) => void - ): Promise { - const gatewayUrls = getAllBackendGatewayUrls(); - if (gatewayUrls.length === 0 || !this.cachedIpnsName) { - return { best: null, allResults: [], respondedCount: 0, totalGateways: 0 }; - } - - const startTime = performance.now(); - const metrics = getIpfsMetrics(); - - // Fast path: Use HTTP resolver with caching (target: <100ms for cache hit) - const httpResolver = getIpfsHttpResolver(); - const httpResult = await httpResolver.resolveIpnsName(this.cachedIpnsName); - - if (httpResult.success && httpResult.cid) { - const latencyMs = performance.now() - startTime; - console.log(`📦 IPNS resolved via HTTP in ${latencyMs.toFixed(0)}ms (source: ${httpResult.source})`); - - // Record metrics - metrics.recordOperation({ - operation: "resolve", - source: httpResult.source as "cache" | "http-gateway" | "http-routing" | "dht" | "none", - latencyMs, - success: true, - timestamp: Date.now(), - cacheHit: httpResult.source === "cache", - }); - - // Convert HTTP result to internal format - const gatewayResult: IpnsGatewayResult = { - cid: httpResult.cid, - sequence: httpResult.sequence ?? 0n, - gateway: "http-resolver", - recordData: new Uint8Array(), - _cachedContent: httpResult.content ?? undefined, - }; - - // Update last known remote sequence if available - if (httpResult.sequence && httpResult.sequence > 0n) { - this.lastKnownRemoteSequence = httpResult.sequence; - } - - return { - best: gatewayResult, - allResults: [gatewayResult], - respondedCount: gatewayUrls.length, // HTTP resolver queries all nodes - totalGateways: gatewayUrls.length, - }; - } - - // Record HTTP failure, fall back to existing implementation - if (!httpResult.success) { - const latencyMs = performance.now() - startTime; - console.log(`📦 HTTP resolution failed (${httpResult.error}), falling back to direct gateway queries...`); - - metrics.recordOperation({ - operation: "resolve", - source: "http-gateway", - latencyMs, - success: false, - timestamp: Date.now(), - error: httpResult.error, - }); - } - - // Fallback: Use existing progressive resolution (slower but more reliable) - console.log(`📦 Racing IPNS resolution from ${gatewayUrls.length} gateways (gateway path + routing API)...`); - - const results: IpnsGatewayResult[] = []; - // Track which gateways have responded via gateway path (for fast results) - const gatewayPathResults = new Map(); - - // Create promises for each gateway - race both methods - const gatewayPromises = gatewayUrls.map(async (url) => { - const hostname = new URL(url).hostname; - - // Start both methods in parallel - const gatewayPathPromise = this.resolveIpnsViaGatewayPath(url); - const routingApiPromise = this.resolveIpnsFromGateway(url); - - // Wait for both to settle (we want results from both if available) - const [gatewayPathResult, routingApiResult] = await Promise.allSettled([ - gatewayPathPromise, - routingApiPromise, - ]); - - // Process gateway path result (fast, has content, no sequence) - let fastCid: string | null = null; - let fastContent: TxfStorageData | null = null; - if (gatewayPathResult.status === "fulfilled" && gatewayPathResult.value) { - const { cid, content, latency } = gatewayPathResult.value; - if (cid) { - fastCid = cid; - fastContent = content; - gatewayPathResults.set(url, { cid, content, latency }); - console.log(`📦 Gateway path ${hostname}: CID=${cid.slice(0, 16)}... (${latency}ms)`); - } - } - - // Process routing API result (slow, has sequence) - if (routingApiResult.status === "fulfilled" && routingApiResult.value) { - const result = routingApiResult.value; - // Merge cached content from gateway path if same CID - if (fastContent && fastCid === result.cid) { - result._cachedContent = fastContent; - } - results.push(result); - console.log(`📦 Routing API ${hostname}: seq=${result.sequence}, CID=${result.cid.slice(0, 16)}...`); - return result; - } - - // If only gateway path succeeded (no routing result), create result with sequence 0 - // This allows fast content fetch, sequence will be updated by late routing responses - if (fastCid && fastContent) { - const partialResult: IpnsGatewayResult = { - cid: fastCid, - sequence: 0n, // Unknown sequence - will be updated by late routing response - gateway: url, - recordData: new Uint8Array(), - _cachedContent: fastContent, - }; - results.push(partialResult); - console.log(`📦 Gateway path only ${hostname}: CID=${fastCid.slice(0, 16)}... (seq unknown)`); - return partialResult; - } - - return null; - }); - - // Wait for initial timeout to collect responses - await Promise.race([ - Promise.allSettled(gatewayPromises), - new Promise((resolve) => setTimeout(resolve, IPNS_RESOLUTION_CONFIG.initialTimeoutMs)), - ]); - - // Find best result (highest sequence, or first with content if no sequences) - const findBest = (arr: IpnsGatewayResult[]): IpnsGatewayResult | null => { - if (arr.length === 0) return null; - // Prefer results with known sequence (> 0) - const withSequence = arr.filter(r => r.sequence > 0n); - if (withSequence.length > 0) { - return withSequence.reduce((best, current) => - current.sequence > best.sequence ? current : best - ); - } - // Fall back to first result with cached content - const withContent = arr.find(r => r._cachedContent); - return withContent || arr[0]; - }; - - const initialBest = findBest(results); - const initialCount = results.length; - const initialSeq = initialBest?.sequence ?? 0n; - const hasContent = !!initialBest?._cachedContent; - - console.log( - `📦 Initial timeout: ${initialCount}/${gatewayUrls.length} responded, ` + - `best seq=${initialSeq.toString()}, hasContent=${hasContent}` - ); - - // Continue waiting for late responses in background - if (onLateHigherSequence && initialCount < gatewayUrls.length) { - // Don't await - let this run in background - Promise.allSettled(gatewayPromises).then(() => { - // Find the new best after all responses - const finalBest = findBest(results); - // Check if any late response has higher sequence than initial best - if (finalBest && finalBest.sequence > initialSeq) { - console.log( - `📦 Late response with higher sequence: seq=${finalBest.sequence} ` + - `from ${new URL(finalBest.gateway).hostname} (was seq=${initialSeq})` - ); - onLateHigherSequence(finalBest); - } - }); - } - - return { - best: initialBest, - allResults: [...results], // Snapshot at initial timeout - respondedCount: initialCount, - totalGateways: gatewayUrls.length, - }; - } - - /** - * Handle discovery of a higher IPNS sequence number - * Fetches the new content and merges with local state - */ - private async handleHigherSequenceDiscovered(result: IpnsGatewayResult): Promise { - console.log(`📦 Handling higher sequence discovery: seq=${result.sequence}, cid=${result.cid.slice(0, 16)}...`); - - // Don't process if already syncing - if (this.isSyncing) { - console.log(`📦 Sync in progress, deferring higher sequence handling`); - return; - } - - // Update last known remote sequence - this.lastKnownRemoteSequence = result.sequence; - - // Fetch the content from IPFS - const remoteData = await this.fetchRemoteContent(result.cid); - if (!remoteData) { - console.warn(`📦 Failed to fetch content for higher sequence CID: ${result.cid.slice(0, 16)}...`); - return; - } - - // Compare versions - const localVersion = this.getVersionCounter(); - const remoteVersion = remoteData._meta.version; - - if (remoteVersion > localVersion) { - console.log(`📦 Remote version ${remoteVersion} > local ${localVersion}, importing...`); - - // Import the remote data - const importedCount = await this.importRemoteData(remoteData); - - // Update local tracking - this.setVersionCounter(remoteVersion); - this.setLastCid(result.cid); - - console.log(`📦 Imported ${importedCount} token(s) from late-arriving higher sequence`); - - // Invalidate UNSPENT cache since inventory changed - if (importedCount > 0) { - getTokenValidationService().clearUnspentCacheEntries(); - } - - // Emit event to notify UI - await this.emitEvent({ - type: "storage:completed", - timestamp: Date.now(), - data: { - cid: result.cid, - tokenCount: importedCount, - }, - }); - - // Trigger wallet refresh - window.dispatchEvent(new Event("wallet-updated")); - - // CRITICAL: Check if local has unique tokens that weren't in remote - // This handles case where local tokens were minted but remote was ahead - // Without this sync, local-only tokens would be lost on next restart - if (await this.localDiffersFromRemote(remoteData)) { - console.log(`📦 Local has unique content after higher-sequence import - would need re-sync`); - console.warn(`⚠️ Skipping auto-sync to prevent dual-publish. Use syncNow() explicitly if needed.`); - // DEPRECATED: scheduleSync() removed - prevents dual-publish race condition - } - } else { - // Local version is same or higher, BUT remote might have new tokens we don't have - // (e.g., Browser 2 received token via Nostr while Browser 1 was offline) - console.log(`📦 Remote version ${remoteVersion} not newer than local ${localVersion}, checking for new tokens...`); - - // Still import remote data - importRemoteData handles deduplication - const importedCount = await this.importRemoteData(remoteData); - - if (importedCount > 0) { - console.log(`📦 Imported ${importedCount} new token(s) from remote despite lower version`); - - // Invalidate UNSPENT cache since inventory changed - getTokenValidationService().clearUnspentCacheEntries(); - - // Trigger wallet refresh - window.dispatchEvent(new Event("wallet-updated")); - } - - // Only sync if local differs from remote (has unique tokens or better versions) - // This prevents unnecessary re-publishing when local now matches remote - if (await this.localDiffersFromRemote(remoteData)) { - console.log(`📦 Local differs from remote - would need re-sync`); - console.warn(`⚠️ Skipping auto-sync to prevent dual-publish. Use syncNow() explicitly if needed.`); - // DEPRECATED: scheduleSync() removed - prevents dual-publish race condition - - // Emit event to notify UI - await this.emitEvent({ - type: "storage:completed", - timestamp: Date.now(), - data: { - cid: result.cid, - tokenCount: importedCount, - }, - }); - } else { - console.log(`📦 Local now matches remote after import, no sync needed`); - - // Update local tracking to match remote (we're in sync) - this.setLastCid(result.cid); - this.setVersionCounter(remoteVersion); - } - } - } - - // ========================================== - // IPNS Polling (Background Re-fetch) - // ========================================== - - /** - * Start periodic IPNS polling to detect cross-device updates - * Only runs when tab is visible - */ - private startIpnsPolling(): void { - if (this.ipnsPollingInterval) { - return; // Already running - } - - const poll = async () => { - if (!this.cachedIpnsName || this.isSyncing) { - return; - } - - console.log(`📦 IPNS poll: checking for remote updates...`); - - const result = await this.resolveIpnsProgressively(); - - if (result.best) { - const localSeq = this.ipnsSequenceNumber; - - // Check for higher sequence number - const hasHigherSequence = result.best.sequence > localSeq && - result.best.sequence > this.lastKnownRemoteSequence; - - // Also check for CID mismatch at same sequence (race condition between devices) - // This can happen when two devices publish with the same sequence number - const localCid = this.getLastCid(); - const hasDifferentCid = localCid && result.best.cid !== localCid && - result.best.sequence >= localSeq; - - if (hasHigherSequence) { - console.log( - `📦 IPNS poll detected higher sequence: remote=${result.best.sequence}, local=${localSeq}` - ); - await this.handleHigherSequenceDiscovered(result.best); - } else if (hasDifferentCid) { - console.log( - `📦 IPNS poll detected different CID at same sequence: ` + - `remote=${result.best.cid.slice(0, 16)}... != local=${localCid?.slice(0, 16)}...` - ); - await this.handleHigherSequenceDiscovered(result.best); - } else { - console.log( - `📦 IPNS poll: no updates (remote seq=${result.best.sequence}, local seq=${localSeq}, ` + - `cid match=${result.best.cid === localCid})` - ); - } - } - - // Run spent token sanity check after checking for remote updates - await this.runSpentTokenSanityCheck(); - }; - - // Calculate random interval with jitter (uses longer interval when tab is inactive) - const getRandomInterval = () => { - const config = IPNS_RESOLUTION_CONFIG; - const minMs = this.isTabVisible ? config.pollingIntervalMinMs : config.inactivePollingIntervalMinMs; - const maxMs = this.isTabVisible ? config.pollingIntervalMaxMs : config.inactivePollingIntervalMaxMs; - return minMs + Math.random() * (maxMs - minMs); - }; - - // Schedule next poll with jitter - const scheduleNextPoll = () => { - const interval = getRandomInterval(); - this.ipnsPollingInterval = setTimeout(async () => { - await poll(); - scheduleNextPoll(); - }, interval); - }; - - // Start polling - scheduleNextPoll(); - const intervalDesc = this.isTabVisible - ? `${IPNS_RESOLUTION_CONFIG.pollingIntervalMinMs/1000}-${IPNS_RESOLUTION_CONFIG.pollingIntervalMaxMs/1000}s` - : `${IPNS_RESOLUTION_CONFIG.inactivePollingIntervalMinMs/1000}-${IPNS_RESOLUTION_CONFIG.inactivePollingIntervalMaxMs/1000}s (inactive)`; - console.log(`📦 IPNS polling started (interval: ${intervalDesc})`); - - // Run first poll after a short delay - setTimeout(poll, 5000); - } - - /** - * Stop IPNS polling (when tab becomes hidden) - */ - private stopIpnsPolling(): void { - if (this.ipnsPollingInterval) { - clearTimeout(this.ipnsPollingInterval); - this.ipnsPollingInterval = null; - console.log(`📦 IPNS polling stopped`); - } - } - - /** - * Handle tab visibility changes - * Adjusts polling interval based on tab visibility (slower when inactive) - */ - private handleVisibilityChange = (): void => { - const wasVisible = this.isTabVisible; - this.isTabVisible = document.visibilityState === "visible"; - - if (this.isTabVisible !== wasVisible) { - // Restart polling with new interval - this.stopIpnsPolling(); - if (this.isTabVisible) { - console.log(`📦 Tab visible, switching to active polling interval (45-75s)`); - } else { - console.log(`📦 Tab hidden, switching to slower polling interval (4-4.5 min)`); - } - this.startIpnsPolling(); - } - }; - - /** - * Set up visibility change listener for polling control - * - * DEPRECATED IN PHASE 2 REFACTORING: This method is no longer called by startAutoSync() - * - * RATIONALE FOR DISABLING POLLING: - * ================================ - * During Phase 2, we identified a race condition between: - * - Fast HTTP publish to backend (~100-300ms) - * - Slow DHT publish via browser Helia (2-5 seconds) - * - * RACE CONDITION SCENARIO: - * T+0ms: Tab A saves token → triggers publish - * T+100ms: Tab A HTTP publish completes (seq=5) - * T+150ms: Tab B polling wakes up → resolves IPNS → sees seq=5 - * T+200ms: Tab B detects local diff → calls scheduleSync() - * T+250ms: Tab B publishes seq=5 via HTTP (DUPLICATE) - * - * SOLUTION (Changes 6 + 7): - * - Remove scheduleSync() from handleHigherSequenceDiscovered() [Change 6] - * - Disable continuous polling by not calling this method [Change 7] - * - Only import remote tokens, never auto-sync - * - User/code must explicitly call syncNow() when ready - * - * RE-ENABLEMENT CRITERIA (Phase 3): - * - Single publish transport (HTTP-only OR DHT-only) - * - Atomic sequence number increment - * - Distributed lock across tabs - */ - // @ts-expect-error - Method kept for documentation and potential re-enablement in Phase 3 - private setupVisibilityListener(): void { - if (this.boundVisibilityHandler) { - return; // Already set up - } - - // Initialize visibility state - this.isTabVisible = document.visibilityState === "visible"; - - this.boundVisibilityHandler = this.handleVisibilityChange; - document.addEventListener("visibilitychange", this.boundVisibilityHandler); - console.log(`📦 Visibility listener registered (tab ${this.isTabVisible ? "visible" : "hidden"})`); - - // Always start polling (with appropriate interval based on visibility) - this.startIpnsPolling(); - } - - /** - * Remove visibility listener and stop polling - */ - private cleanupVisibilityListener(): void { - if (this.boundVisibilityHandler) { - document.removeEventListener("visibilitychange", this.boundVisibilityHandler); - this.boundVisibilityHandler = null; - } - this.stopIpnsPolling(); - } - - // ========================================== - // Backend Connection Maintenance - // ========================================== - - /** - * Maintain a persistent connection to the backend IPFS node - * This ensures bitswap can function properly for content transfer - */ - private startBackendConnectionMaintenance(): void { - const backendPeerId = getBackendPeerId(); - if (!backendPeerId || !this.helia) { - return; - } - - // Import peerIdFromString dynamically - const maintainConnection = async () => { - if (!this.helia) return; - - try { - // Check if we're connected to the backend - const connections = this.helia.libp2p.getConnections(); - const isConnected = connections.some( - (conn) => conn.remotePeer.toString() === backendPeerId - ); - - if (!isConnected) { - console.log(`📦 Backend peer disconnected, reconnecting...`); - // The bootstrap will reconnect automatically, but we can also dial directly - const bootstrapPeers = getBootstrapPeers(); - const backendAddr = bootstrapPeers.find((addr) => - addr.includes(backendPeerId) - ); - if (backendAddr) { - try { - const { multiaddr } = await import("@multiformats/multiaddr"); - await this.helia.libp2p.dial(multiaddr(backendAddr)); - console.log(`📦 Reconnected to backend peer`); - } catch (dialError) { - console.warn(`📦 Failed to reconnect to backend:`, dialError); - } - } - } else { - // Connection exists, log status - const backendConn = connections.find( - (conn) => conn.remotePeer.toString() === backendPeerId - ); - if (backendConn) { - console.log(`📦 Backend connection alive: ${backendConn.remoteAddr.toString()}`); - } - } - } catch (error) { - console.warn(`📦 Connection maintenance error:`, error); - } - }; - - // Run immediately - setTimeout(maintainConnection, 2000); - - // Then periodically (every 30 seconds) - this.connectionMaintenanceInterval = setInterval(maintainConnection, 30000); - console.log(`📦 Backend connection maintenance started`); - } - - /** - * Ensure backend is connected before storing content - * Returns true if connected or successfully reconnected - */ - private async ensureBackendConnected(): Promise { - const backendPeerId = getBackendPeerId(); - if (!backendPeerId || !this.helia) { - return false; - } - - const connections = this.helia.libp2p.getConnections(); - const isConnected = connections.some( - (conn) => conn.remotePeer.toString() === backendPeerId - ); - - if (isConnected) { - return true; - } - - // Try to reconnect - console.log(`📦 Backend not connected, dialing...`); - const bootstrapPeers = getBootstrapPeers(); - const backendAddr = bootstrapPeers.find((addr) => - addr.includes(backendPeerId) - ); - - if (backendAddr) { - try { - const { multiaddr } = await import("@multiformats/multiaddr"); - await this.helia.libp2p.dial(multiaddr(backendAddr)); - console.log(`📦 Connected to backend for content transfer`); - return true; - } catch (error) { - console.warn(`📦 Failed to connect to backend:`, error); - return false; - } - } - - return false; - } - - // ========================================== - // Version Counter Management - // ========================================== - - /** - * Get current version counter for this wallet - */ - /** - * Increment and return new version counter - */ - private incrementVersionCounter(): number { - if (!this.cachedIpnsName) return 1; - const key = `${STORAGE_KEY_PREFIXES.IPFS_VERSION}${this.cachedIpnsName}`; - const current = this.getVersionCounter(); - const next = current + 1; - localStorage.setItem(key, String(next)); - return next; - } - - // ========================================== - // Pending IPNS Publish Tracking - // ========================================== - - /** - * Get pending IPNS publish CID (if previous publish failed) - */ - private getPendingIpnsPublish(): string | null { - if (!this.cachedIpnsName) return null; - const key = `${STORAGE_KEY_PREFIXES.IPFS_PENDING_IPNS}${this.cachedIpnsName}`; - return localStorage.getItem(key); - } - - /** - * Set pending IPNS publish CID for retry - */ - private setPendingIpnsPublish(cid: string): void { - if (!this.cachedIpnsName) return; - const key = `${STORAGE_KEY_PREFIXES.IPFS_PENDING_IPNS}${this.cachedIpnsName}`; - localStorage.setItem(key, cid); - console.log(`📦 IPNS publish marked as pending for CID: ${cid.slice(0, 16)}...`); - } - - /** - * Clear pending IPNS publish after successful publish - */ - private clearPendingIpnsPublish(): void { - if (!this.cachedIpnsName) return; - const key = `${STORAGE_KEY_PREFIXES.IPFS_PENDING_IPNS}${this.cachedIpnsName}`; - localStorage.removeItem(key); - } - - /** - * Retry any pending IPNS publish from previous failed sync - */ - private async retryPendingIpnsPublish(): Promise { - const pendingCid = this.getPendingIpnsPublish(); - if (!pendingCid) return true; // No pending publish - - console.log(`📦 Retrying pending IPNS publish for CID: ${pendingCid.slice(0, 16)}...`); - - try { - const { CID } = await import("multiformats/cid"); - const cid = CID.parse(pendingCid); - const result = await this.publishToIpns(cid); - - if (result) { - this.clearPendingIpnsPublish(); - this.setLastCid(pendingCid); - console.log(`📦 Pending IPNS publish succeeded`); - return true; - } - return false; - } catch (error) { - console.warn(`📦 Pending IPNS publish retry failed:`, error); - return false; - } - } - - // ========================================== - // IPNS Sync Helpers - // ========================================== - - /** - * Fetch remote content from IPFS by CID - * Returns the TXF storage data or null if fetch fails - */ - private async fetchRemoteContent(cidString: string): Promise { - const startTime = performance.now(); - const metrics = getIpfsMetrics(); - - // Fast path: Use HTTP resolver (parallel multi-node racing) - try { - console.log(`📦 Fetching content via HTTP: ${cidString.slice(0, 16)}...`); - const httpResolver = getIpfsHttpResolver(); - const content = await httpResolver.fetchContentByCid(cidString); - - if (content && typeof content === "object" && "_meta" in content) { - const latencyMs = performance.now() - startTime; - console.log(`📦 Content fetched via HTTP in ${latencyMs.toFixed(0)}ms`); - - // Record metrics - metrics.recordOperation({ - operation: "fetch", - source: "http-gateway", - latencyMs, - success: true, - timestamp: Date.now(), - }); - - return content as TxfStorageData; - } - } catch (error) { - console.debug(`📦 HTTP content fetch failed, trying Helia fallback:`, error); - } - - // Fallback: Use Helia/Bitswap (slow but reliable) - if (!this.helia) { - metrics.recordOperation({ - operation: "fetch", - source: "none", - latencyMs: performance.now() - startTime, - success: false, - timestamp: Date.now(), - error: "Helia not initialized", - }); - return null; - } - - const FETCH_TIMEOUT = 15000; // 15 seconds - - try { - console.log(`📦 Falling back to Helia for content: ${cidString.slice(0, 16)}...`); - const j = json(this.helia); - const { CID } = await import("multiformats/cid"); - const cid = CID.parse(cidString); - - const data = await Promise.race([ - j.get(cid), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Fetch timeout")), FETCH_TIMEOUT) - ), - ]); - - // Validate it's TXF format - if (data && typeof data === "object" && "_meta" in (data as object)) { - const latencyMs = performance.now() - startTime; - console.log(`📦 Content fetched via Helia in ${latencyMs.toFixed(0)}ms`); - - // Record metrics (DHT fallback) - metrics.recordOperation({ - operation: "fetch", - source: "dht", - latencyMs, - success: true, - timestamp: Date.now(), - }); - - return data as TxfStorageData; - } - - console.warn(`📦 Remote content is not valid TXF format`); - return null; - } catch (error) { - const latencyMs = performance.now() - startTime; - console.warn(`📦 Failed to fetch CID ${cidString.slice(0, 16)}...:`, error); - - // Record failure metrics - metrics.recordOperation({ - operation: "fetch", - source: "dht", - latencyMs, - success: false, - timestamp: Date.now(), - error: error instanceof Error ? error.message : "Unknown error", - }); - - return null; - } - } - - // ========================================== - // Sanity Check Methods (Token Loss Prevention) - // ========================================== - - /** - * Sanity check tombstones before applying deletions - * Verifies each tombstoned token is actually spent on Unicity - * Returns tokens that should NOT be deleted (false tombstones) - * - * @deprecated Use InventorySyncService instead. This method will be removed in a future release. - * Migration: Call inventorySync() which handles all merge/validation logic (Step 7 + 7.5). - */ - private async sanityCheckTombstones( - tombstonesToApply: TombstoneEntry[], - address: string - ): Promise<{ - validTombstones: TombstoneEntry[]; - invalidTombstones: TombstoneEntry[]; - tokensToRestore: Array<{ tokenId: string; txf: TxfToken }>; - }> { - console.warn('⚠️ [DEPRECATED] sanityCheckTombstones() is deprecated. Use InventorySyncService.inventorySync() instead.'); - const validTombstones: TombstoneEntry[] = []; - const invalidTombstones: TombstoneEntry[] = []; - const tokensToRestore: Array<{ tokenId: string; txf: TxfToken }> = []; - - if (tombstonesToApply.length === 0) { - return { validTombstones, invalidTombstones, tokensToRestore }; - } - - // Get identity for verification - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.warn("⚠️ No identity available, skipping tombstone verification (accepting all tombstones)"); - return { validTombstones: tombstonesToApply, invalidTombstones: [], tokensToRestore: [] }; - } - - // Build Map of tokenId -> TxfToken from archived versions - const tokensToCheck = new Map(); - const archivedTokens = getArchivedTokensForAddress(address); - for (const tombstone of tombstonesToApply) { - const archivedVersion = archivedTokens.get(tombstone.tokenId); - if (archivedVersion) { - tokensToCheck.set(tombstone.tokenId, archivedVersion); - } - } - - if (tokensToCheck.size === 0) { - console.warn("⚠️ No archived tokens available for verification, accepting all tombstones"); - return { validTombstones: tombstonesToApply, invalidTombstones: [], tokensToRestore: [] }; - } - - // Check which tokens are NOT spent (should not be deleted) - // CRITICAL: Use treatErrorsAsUnspent=false for tombstone recovery! - // When we can't verify, we should NOT restore tokens (keep tombstone intact) - // This prevents incorrectly restoring spent tokens when aggregator is down - const validationService = getTokenValidationService(); - const publicKey = identity.publicKey; - const unspentTokenIds = await validationService.checkUnspentTokens( - tokensToCheck, - publicKey, - { treatErrorsAsUnspent: false } // Errors → assume spent → don't restore - ); - const unspentSet = new Set(unspentTokenIds); - - // Categorize tombstones - for (const tombstone of tombstonesToApply) { - if (unspentSet.has(tombstone.tokenId)) { - // Token is NOT spent - tombstone is invalid - invalidTombstones.push(tombstone); - - // Find best version to restore - const bestVersion = archivedTokens.get(tombstone.tokenId); - if (bestVersion) { - tokensToRestore.push({ tokenId: tombstone.tokenId, txf: bestVersion }); - } - - console.log(`⚠️ Invalid tombstone for ${tombstone.tokenId.slice(0, 8)}... - token is NOT spent on Unicity`); - } else { - // Token is spent - tombstone is valid - validTombstones.push(tombstone); - } - } - - if (tombstonesToApply.length > 0) { - console.log(`📦 Tombstone sanity check: ${validTombstones.length} valid, ${invalidTombstones.length} invalid`); - } - - return { validTombstones, invalidTombstones, tokensToRestore }; - } - - /** - * Check for tokens missing from remote collection (not tombstoned, just absent) - * This handles case where remote "jumped over" a version - * Returns tokens that should be preserved (unspent on Unicity) - * - * @deprecated Use InventorySyncService instead. This method will be removed in a future release. - * Migration: Call inventorySync() which handles all merge/validation logic (Step 7 recovery). - */ - private async sanityCheckMissingTokens( - localTokens: Token[], - remoteTokenIds: Set, - remoteTombstoneIds: Set - ): Promise> { - console.warn('⚠️ [DEPRECATED] sanityCheckMissingTokens() is deprecated. Use InventorySyncService.inventorySync() instead.'); - const tokensToPreserve: Array<{ tokenId: string; txf: TxfToken }> = []; - - // Find tokens that are in local but missing from remote (and not tombstoned) - const missingTokens: Token[] = []; - for (const token of localTokens) { - const txf = tokenToTxf(token); - if (!txf) continue; - - const tokenId = txf.genesis.data.tokenId; - if (!remoteTokenIds.has(tokenId) && !remoteTombstoneIds.has(tokenId)) { - missingTokens.push(token); - } - } - - if (missingTokens.length === 0) return []; - - console.log(`📦 Found ${missingTokens.length} token(s) missing from remote (no tombstone)`); - - // Get identity for verification - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.warn("⚠️ No identity available, preserving all missing tokens (safe fallback)"); - // Safe fallback: preserve all missing tokens - for (const token of missingTokens) { - const txf = tokenToTxf(token); - if (txf) { - tokensToPreserve.push({ tokenId: txf.genesis.data.tokenId, txf }); - } - } - return tokensToPreserve; - } - - // Build Map of tokenId -> TxfToken for verification - const tokensToCheck = new Map(); - for (const token of missingTokens) { - const txf = tokenToTxf(token); - if (!txf) continue; - - const tokenId = txf.genesis.data.tokenId; - tokensToCheck.set(tokenId, txf); - } - - if (tokensToCheck.size === 0) return tokensToPreserve; - - // Check which are unspent (should be preserved) - const validationService = getTokenValidationService(); - const publicKey = identity.publicKey; - const unspentTokenIds = await validationService.checkUnspentTokens(tokensToCheck, publicKey); - const unspentSet = new Set(unspentTokenIds); - - for (const [tokenId, txf] of tokensToCheck) { - if (unspentSet.has(tokenId)) { - // Token is NOT spent - should be preserved - tokensToPreserve.push({ tokenId, txf }); - console.log(`📦 Preserving missing token ${tokenId.slice(0, 8)}... - NOT spent on Unicity`); - } else { - console.log(`📦 Token ${tokenId.slice(0, 8)}... legitimately removed (spent on Unicity)`); - } - } - - return tokensToPreserve; - } - - /** - * Check if any archived tokens should be restored to active status - * This is a safety net for IPNS eventual consistency issues where - * tokens may have been incorrectly removed due to stale IPNS data. - * - * Returns the number of tokens restored. - */ - private async checkArchivedTokensForRecovery( - walletRepo: WalletRepository - ): Promise { - const archivedTokens = walletRepo.getArchivedTokens(); - if (archivedTokens.size === 0) { - return 0; - } - - // Get current active token IDs - const activeTokens = walletRepo.getTokens(); - const activeTokenIds = new Set(); - for (const token of activeTokens) { - const txf = tokenToTxf(token); - if (txf) { - activeTokenIds.add(txf.genesis.data.tokenId); - } - } - - // Get tombstone keys (tokenId:stateHash) - const tombstones = walletRepo.getTombstones(); - const tombstoneKeys = new Set( - tombstones.map((t: TombstoneEntry) => `${t.tokenId}:${t.stateHash}`) - ); - const tombstoneTokenIds = new Set(tombstones.map((t: TombstoneEntry) => t.tokenId)); - - // Find candidates: ALL archived tokens not in active set - // IMPORTANT: Include tombstoned tokens - tombstones may be invalid and need verification - const candidatesForRecovery = new Map(); - const tombstonedCandidates = new Map(); // Track which are tombstoned - - for (const [tokenId, txfToken] of archivedTokens) { - // Skip if already active - if (activeTokenIds.has(tokenId)) continue; - - // Get current state hash from archived token - const stateHash = getCurrentStateHash(txfToken); - - // Check if tombstoned with this exact state - const isTombstoned = stateHash && tombstoneKeys.has(`${tokenId}:${stateHash}`); - - // Add ALL candidates (tombstoned or not) - we verify against Unicity - candidatesForRecovery.set(tokenId, txfToken); - if (isTombstoned) { - tombstonedCandidates.set(tokenId, txfToken); - } - } - - if (candidatesForRecovery.size === 0) { - return 0; - } - - // Log what we're checking - const tombstonedCount = tombstonedCandidates.size; - const nonTombstonedCount = candidatesForRecovery.size - tombstonedCount; - console.log(`📦 Checking ${candidatesForRecovery.size} archived token(s) for potential recovery...`); - console.log(`📦 - ${nonTombstonedCount} non-tombstoned, ${tombstonedCount} tombstoned (verifying against Unicity)`); - - // Get identity for verification - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.warn("⚠️ No identity available, skipping archive recovery check"); - return 0; - } - - // Check which are unspent (should be restored) - // CRITICAL: Use treatErrorsAsUnspent=false for safety - // If we can't verify, don't restore (prevents incorrectly restoring spent tokens) - const validationService = getTokenValidationService(); - const publicKey = identity.publicKey; - const unspentTokenIds = await validationService.checkUnspentTokens( - candidatesForRecovery, - publicKey, - { treatErrorsAsUnspent: false } // Errors → assume spent → don't restore - ); - const unspentSet = new Set(unspentTokenIds); - - // Restore unspent tokens - let restoredCount = 0; - for (const [tokenId, txfToken] of candidatesForRecovery) { - const wasTombstoned = tombstonedCandidates.has(tokenId); - - if (unspentSet.has(tokenId)) { - // Token is NOT spent - should be restored to active! - const tombstoneNote = wasTombstoned ? ' (was tombstoned - INVALID tombstone!)' : ''; - console.log(`📦 Restoring archived token ${tokenId.slice(0, 8)}... - NOT spent on Unicity${tombstoneNote}`); - - // Remove any tombstones for this token since it's unspent - if (tombstoneTokenIds.has(tokenId)) { - walletRepo.removeTombstonesForToken(tokenId); - console.log(`📦 Removed invalid tombstones for ${tokenId.slice(0, 8)}...`); - } - - const restored = walletRepo.restoreTokenFromArchive(tokenId, txfToken); - if (restored) { - restoredCount++; - } - } else { - // Token is spent - valid to stay archived/tombstoned - const tombstoneNote = wasTombstoned ? ' (tombstone valid)' : ''; - console.log(`📦 Archived token ${tokenId.slice(0, 8)}... is spent on Unicity - keeping archived${tombstoneNote}`); - } - } - - if (restoredCount > 0) { - console.log(`📦 Archive recovery: restored ${restoredCount} token(s)`); - // Invalidate UNSPENT cache since inventory changed - getTokenValidationService().clearUnspentCacheEntries(); - } - - return restoredCount; - } - - /** - * Verify integrity invariants after sync operations - * All spent tokens should have both tombstone and archive entry - */ - private verifyIntegrityInvariants(address: string): void { - const tombstones = getTombstonesForAddress(address); - const archivedTokens = getArchivedTokensForAddress(address); - const activeTokens = getTokensForAddress(address); - - let issues = 0; - - // Check 1: Every tombstoned token should have archive entry - for (const tombstone of tombstones) { - if (!archivedTokens.has(tombstone.tokenId)) { - console.warn(`⚠️ Integrity: Tombstone ${tombstone.tokenId.slice(0, 8)}... has no archive entry`); - issues++; - } - } - - // Check 2: Active tokens should not be tombstoned - const tombstoneKeySet = new Set( - tombstones.map(t => `${t.tokenId}:${t.stateHash}`) - ); - - for (const token of activeTokens) { - const txf = tokenToTxf(token); - if (!txf) continue; - - const tokenId = txf.genesis.data.tokenId; - const stateHash = getCurrentStateHash(txf); - if (!stateHash) { - console.warn(`⚠️ Integrity: Token ${tokenId.slice(0, 8)}... has undefined stateHash`); - issues++; - continue; - } - const key = `${tokenId}:${stateHash}`; - - if (tombstoneKeySet.has(key)) { - console.warn(`⚠️ Integrity: Active token ${tokenId.slice(0, 8)}... matches a tombstone`); - issues++; - } - } - - if (issues > 0) { - console.warn(`⚠️ Integrity check found ${issues} issue(s)`); - } else { - console.log(`✅ Integrity check passed`); - } - } - - // ========================================== - // Sync Decision Helpers - // ========================================== - - /** - * Compare two TXF tokens and determine which is "better" - * Returns: "local" if local wins, "remote" if remote wins, "equal" if identical - * - * CRITICAL: Committed transactions ALWAYS beat pending transactions! - * This prevents a device with 3 pending (unsubmittable) transactions from - * overwriting a device with 1 committed transaction. - * - * Rules: - * 1) Committed beats pending (committed transactions always win over pending-only) - * 2) Longer COMMITTED chain wins (not total chain length!) - * 3) More proofs wins (including genesis proof) - * 4) Identical state hashes = equal - * 5) Deterministic tiebreaker for forks - * - * @deprecated Use InventorySyncService instead. This method will be removed in a future release. - * Migration: InventorySyncService.shouldPreferRemote() implements the same logic. - */ - private compareTokenVersions(localTxf: TxfToken, remoteTxf: TxfToken): "local" | "remote" | "equal" { - console.warn('⚠️ [DEPRECATED] compareTokenVersions() is deprecated. Use InventorySyncService.shouldPreferRemote() instead.'); - // Helper to count COMMITTED transactions (those with inclusion proof) - const countCommitted = (txf: TxfToken): number => { - return txf.transactions.filter(tx => tx.inclusionProof !== null).length; - }; - - const localCommitted = countCommitted(localTxf); - const remoteCommitted = countCommitted(remoteTxf); - - // 1. COMMITTED transactions ALWAYS beat pending - // Token with committed transactions beats token with only pending transactions - const localHasPending = localTxf.transactions.some(tx => tx.inclusionProof === null); - const remoteHasPending = remoteTxf.transactions.some(tx => tx.inclusionProof === null); - - if (localCommitted > 0 && remoteCommitted === 0 && remoteHasPending) { - // Local has committed, remote has only pending - local wins - console.log(`📦 compareTokenVersions: Local wins (committed=${localCommitted} beats pending-only remote)`); - return "local"; - } - if (remoteCommitted > 0 && localCommitted === 0 && localHasPending) { - // Remote has committed, local has only pending - remote wins - console.log(`📦 compareTokenVersions: Remote wins (committed=${remoteCommitted} beats pending-only local)`); - return "remote"; - } - - // 2. Compare COMMITTED chain lengths (not total length!) - if (localCommitted > remoteCommitted) { - console.log(`📦 compareTokenVersions: Local wins (${localCommitted} committed > ${remoteCommitted} committed)`); - return "local"; - } - if (remoteCommitted > localCommitted) { - console.log(`📦 compareTokenVersions: Remote wins (${remoteCommitted} committed > ${localCommitted} committed)`); - return "remote"; - } - - // 3. Same committed count - check total proofs (including genesis) - const countProofs = (txf: TxfToken): number => { - let count = txf.genesis?.inclusionProof ? 1 : 0; - count += txf.transactions.filter(tx => tx.inclusionProof !== null).length; - return count; - }; - - const localProofs = countProofs(localTxf); - const remoteProofs = countProofs(remoteTxf); - - if (localProofs > remoteProofs) return "local"; - if (remoteProofs > localProofs) return "remote"; - - // 4. Check if last transaction states differ (fork detection) - const localStateHash = getCurrentStateHash(localTxf); - const remoteStateHash = getCurrentStateHash(remoteTxf); - - if (localStateHash === remoteStateHash) { - return "equal"; // Identical tokens - } - - // 5. Deterministic tiebreaker for forks (use genesis hash) - const localGenesisHash = localTxf._integrity?.genesisDataJSONHash || ""; - const remoteGenesisHash = remoteTxf._integrity?.genesisDataJSONHash || ""; - - if (localGenesisHash > remoteGenesisHash) return "local"; - if (remoteGenesisHash > localGenesisHash) return "remote"; - - return "local"; // Ultimate fallback: prefer local - } - - /** - * Check if local differs from remote in any way that requires sync - * Returns true if we need to sync local changes to remote - * - * @deprecated Use InventorySyncService instead. This method will be removed in a future release. - * Migration: InventorySyncService.inventorySync() handles version comparison internally. - */ - private async localDiffersFromRemote(remoteData: TxfStorageData): Promise { - console.warn('⚠️ [DEPRECATED] localDiffersFromRemote() is deprecated. Use InventorySyncService.inventorySync() instead.'); - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) return false; - - const localTokens = getTokensForAddress(identity.address); - - // Check if local nametag differs from remote - const localNametag = getNametagForAddress(identity.address); - const remoteNametag = remoteData._nametag; - - if (localNametag && !remoteNametag) { - console.log(`📦 Local has nametag "${localNametag.name}" not in remote`); - return true; - } - if (localNametag && remoteNametag && localNametag.name !== remoteNametag.name) { - console.log(`📦 Local nametag "${localNametag.name}" differs from remote "${remoteNametag.name}"`); - return true; - } - - // Extract remote tokens as TxfToken map - const remoteTokenMap = new Map(); - for (const key of Object.keys(remoteData)) { - if (isTokenKey(key)) { - const tokenId = tokenIdFromKey(key); - const remoteTxf = remoteData[key] as TxfToken; - if (remoteTxf?.genesis?.data?.tokenId) { - remoteTokenMap.set(tokenId, remoteTxf); - } - } - } - - // Check each local token - for (const token of localTokens) { - const localTxf = tokenToTxf(token); - if (!localTxf) continue; - - const tokenId = localTxf.genesis.data.tokenId; - const remoteTxf = remoteTokenMap.get(tokenId); - - if (!remoteTxf) { - // Local has token that remote doesn't - console.log(`📦 Local has token ${tokenId.slice(0, 8)}... not in remote`); - return true; - } - - // Compare versions - if local is better, we need to sync - const comparison = this.compareTokenVersions(localTxf, remoteTxf); - if (comparison === "local") { - const localCommitted = localTxf.transactions.filter(tx => tx.inclusionProof !== null).length; - const remoteCommitted = remoteTxf.transactions.filter(tx => tx.inclusionProof !== null).length; - console.log(`📦 Local token ${tokenId.slice(0, 8)}... is better than remote (local: ${localCommitted} committed, remote: ${remoteCommitted} committed)`); - return true; - } - } - - return false; - } - - // ========================================== - // Data Import Methods - // ========================================== - - /** - * Import remote data into local storage - * - Imports tokens that don't exist locally (unless tombstoned) - * - Removes local tokens that are tombstoned in remote (with Unicity verification) - * - Handles missing tokens (tokens in local but not in remote) - * - Merges tombstones from remote - * - Imports nametag if local doesn't have one - * - * @deprecated Use InventorySyncService instead. This method will be removed in a future release. - * Migration: Call inventorySync() which handles all merge/validation logic. - */ - private async importRemoteData(remoteTxf: TxfStorageData): Promise { - console.warn('⚠️ [DEPRECATED] importRemoteData() is deprecated. Use InventorySyncService.inventorySync() instead.'); - const walletRepo = WalletRepository.getInstance(); - - // Debug: Log raw tombstones from remote data - const rawTombstones = (remoteTxf as Record)._tombstones; - console.log(`📦 Raw remote _tombstones field:`, rawTombstones); - - const { tokens: remoteTokens, nametag, tombstones: remoteTombstones, archivedTokens: remoteArchived, forkedTokens: remoteForked, outboxEntries: remoteOutbox, mintOutboxEntries: remoteMintOutbox, invalidatedNametags: remoteInvalidatedNametags } = parseTxfStorageData(remoteTxf); - - // Import outbox entries from remote (CRITICAL for transfer recovery) - if (remoteOutbox && remoteOutbox.length > 0) { - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.importFromRemote(remoteOutbox); - console.log(`📦 Imported ${remoteOutbox.length} outbox entries from remote`); - } - - // Import mint outbox entries from remote (CRITICAL for mint recovery) - if (remoteMintOutbox && remoteMintOutbox.length > 0) { - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.importMintEntriesFromRemote(remoteMintOutbox); - console.log(`📦 Imported ${remoteMintOutbox.length} mint outbox entries from remote`); - } - - // Merge invalidated nametags from remote (preserves history across devices) - if (remoteInvalidatedNametags && remoteInvalidatedNametags.length > 0) { - const mergedCount = walletRepo.mergeInvalidatedNametags(remoteInvalidatedNametags); - if (mergedCount > 0) { - console.log(`📦 Merged ${mergedCount} invalidated nametag(s) from remote`); - } - } - - // Debug: Log parsed tombstones (now TombstoneEntry[]) - console.log(`📦 Parsed remote tombstones (${remoteTombstones.length}):`, - remoteTombstones.map(t => `${t.tokenId.slice(0, 8)}:${t.stateHash.slice(0, 8)}`)); - - // Get local tokens and tombstones - const localTokens = walletRepo.getWallet()?.tokens || []; - const localTokenIds = new Set(localTokens.map(t => t.id)); - const localTombstones = walletRepo.getTombstones(); - - // Debug: Log local token IDs for comparison - console.log(`📦 Local token IDs (${localTokenIds.size}):`, [...localTokenIds].map((id: string) => id.slice(0, 8) + '...')); - - let importedCount = 0; - - // ========================================== - // SANITY CHECKS - Prevent token loss from race conditions - // ========================================== - - // 1. Build remote token ID set for missing token detection - const remoteTokenIds = new Set(); - for (const token of remoteTokens) { - const txf = tokenToTxf(token); - if (txf) remoteTokenIds.add(txf.genesis.data.tokenId); - } - - // 2. Build remote tombstone ID set - const remoteTombstoneIds = new Set(remoteTombstones.map(t => t.tokenId)); - - // 3. Check for missing tokens (local tokens absent from remote without tombstone) - const tokensToPreserveFromMissing = await this.sanityCheckMissingTokens( - localTokens, - remoteTokenIds, - remoteTombstoneIds - ); - - // 4. Get new tombstones that would be applied - const localTombstoneKeys = new Set( - localTombstones.map((t: TombstoneEntry) => `${t.tokenId}:${t.stateHash}`) - ); - const newTombstones = remoteTombstones.filter( - (t: TombstoneEntry) => !localTombstoneKeys.has(`${t.tokenId}:${t.stateHash}`) - ); - - // 5. Sanity check new tombstones with Unicity - let tokensToRestore: Array<{ tokenId: string; txf: TxfToken }> = []; - let validTombstones = newTombstones; - - if (newTombstones.length > 0) { - console.log(`📦 Sanity checking ${newTombstones.length} new tombstone(s) with Unicity...`); - const walletAddress = walletRepo.getWallet()?.address ?? ''; - const result = await this.sanityCheckTombstones(newTombstones, walletAddress); - validTombstones = result.validTombstones; - tokensToRestore = result.tokensToRestore; - - if (result.invalidTombstones.length > 0) { - console.log(`⚠️ Rejected ${result.invalidTombstones.length} invalid tombstone(s)`); - } - } - - // 6. Combine tokens to preserve/restore - const allTokensToRestore = [...tokensToRestore, ...tokensToPreserveFromMissing]; - - // 7. Restore any tokens that should not be deleted - for (const { tokenId, txf } of allTokensToRestore) { - walletRepo.restoreTokenFromArchive(tokenId, txf); - } - - // 8. Apply only valid tombstones (not the rejected invalid ones) - const tombstonesToApply = [...localTombstones]; - for (const t of validTombstones) { - if (!localTombstoneKeys.has(`${t.tokenId}:${t.stateHash}`)) { - tombstonesToApply.push(t); - } - } - - // Merge valid tombstones - this removes local tokens whose state matches tombstones - if (tombstonesToApply.length > 0) { - console.log(`📦 Processing ${tombstonesToApply.length} valid tombstone(s)`); - const removedCount = walletRepo.mergeTombstones(tombstonesToApply); - if (removedCount > 0) { - console.log(`📦 Removed ${removedCount} tombstoned token(s) from local`); - // Invalidate UNSPENT cache since inventory changed - getTokenValidationService().clearUnspentCacheEntries(); - } - } - - // ========================================== - // IMPORT/UPDATE TOKENS FROM REMOTE - // ========================================== - - // Build combined tombstone lookup (tokenId:stateHash -> true) - const allTombstoneKeys = new Set(); - for (const t of walletRepo.getTombstones()) { - allTombstoneKeys.add(`${t.tokenId}:${t.stateHash}`); - } - - // Build local token map for comparison (re-get as they may have changed after restore) - const currentLocalTokens = walletRepo.getWallet()?.tokens || []; - const localTokenMap = new Map(); - for (const token of currentLocalTokens) { - const txf = tokenToTxf(token); - if (txf) { - localTokenMap.set(txf.genesis.data.tokenId, token); - } - } - - for (const remoteToken of remoteTokens) { - // Extract tokenId and stateHash from remote token - let remoteTxf = tokenToTxf(remoteToken); - if (!remoteTxf) continue; - - const tokenId = remoteTxf.genesis.data.tokenId; - let stateHash = getCurrentStateHash(remoteTxf); - - // Check if this is a genesis-only token (no transactions yet) - const isGenesisOnly = !remoteTxf.transactions || remoteTxf.transactions.length === 0; - - // Try to repair if state hash is undefined (token may be missing newStateHash from older version) - if (!stateHash && hasMissingNewStateHash(remoteTxf)) { - console.log(`📦 Token ${tokenId.slice(0, 8)}... has missing newStateHash, attempting repair...`); - try { - const repairedTxf = await repairMissingStateHash(remoteTxf); - if (repairedTxf) { - remoteTxf = repairedTxf; - stateHash = getCurrentStateHash(repairedTxf); - if (stateHash) { - console.log(`🔧 Token ${tokenId.slice(0, 8)}... repaired successfully`); - // Update the remoteToken with repaired data for import - remoteToken.jsonData = JSON.stringify(repairedTxf); - } - } - } catch (repairErr) { - console.warn(`📦 Failed to repair token ${tokenId.slice(0, 8)}...:`, repairErr); - } - } - - // Skip if state hash is undefined UNLESS it's a genesis-only token - // Genesis-only tokens (never transferred) don't have a stateHash from transactions - // and can't match any tombstone (tombstones are created on transfer) - if (!stateHash && !isGenesisOnly) { - console.warn(`📦 Token ${tokenId.slice(0, 8)}... has undefined stateHash after repair attempt, skipping import`); - continue; - } - - // For genesis-only tokens, compute and store the stateHash - if (isGenesisOnly) { - console.log(`📦 Token ${tokenId.slice(0, 8)}... is genesis-only (no transfers yet)`); - - // Compute the stateHash using SDK and patch the token - try { - const patchedTxf = await computeAndPatchStateHash(remoteTxf); - if (patchedTxf !== remoteTxf && patchedTxf._integrity?.currentStateHash) { - remoteTxf = patchedTxf; - stateHash = patchedTxf._integrity.currentStateHash; - remoteToken.jsonData = JSON.stringify(patchedTxf); - } - } catch (err) { - console.warn(`📦 Failed to compute stateHash for genesis token ${tokenId.slice(0, 8)}...:`, err); - } - } - - // Skip if this specific state is tombstoned - // Genesis-only tokens (stateHash undefined) can't be tombstoned since tombstones - // are created on transfer, and genesis-only tokens have never been transferred - if (stateHash) { - const tombstoneKey = `${tokenId}:${stateHash}`; - if (allTombstoneKeys.has(tombstoneKey)) { - console.log(`📦 Skipping tombstoned token ${tokenId.slice(0, 8)}... state ${stateHash.slice(0, 8)}... from remote`); - continue; - } - } - - const localToken = localTokenMap.get(tokenId); - - if (!localToken) { - // NEW token - import it (skip history since it was recorded on original device) - walletRepo.addToken(remoteToken, true); - console.log(`📦 Imported new token ${tokenId.slice(0, 8)}... from remote`); - importedCount++; - } else { - // Token EXISTS in both - compare versions - const localTxf = tokenToTxf(localToken); - if (!localTxf) continue; - - const comparison = this.compareTokenVersions(localTxf, remoteTxf); - - if (comparison === "remote") { - // Remote is BETTER - update local with remote version - const localLen = localTxf.transactions.length; - const remoteLen = remoteTxf.transactions.length; - console.log(`📦 Updating token ${tokenId.slice(0, 8)}... from remote (remote: ${remoteLen} txns > local: ${localLen} txns)`); - - // Archive local version before replacing (in case of fork) - const localStateHash = getCurrentStateHash(localTxf); - if (localStateHash && localStateHash !== stateHash) { - // Different state = fork, archive the losing local version - walletRepo.storeForkedToken(tokenId, localStateHash, localTxf); - console.log(`📦 Archived forked local version of ${tokenId.slice(0, 8)}... (state ${localStateHash.slice(0, 8)}...)`); - } - - // Update with remote version - walletRepo.updateToken(remoteToken); - importedCount++; - } else if (comparison === "local") { - // Local is better - keep local, but archive remote if it's a fork - const remoteStateHash = getCurrentStateHash(remoteTxf); - const localStateHash = getCurrentStateHash(localTxf); - if (remoteStateHash && localStateHash && remoteStateHash !== localStateHash) { - // Different state = fork, archive the remote version - walletRepo.storeForkedToken(tokenId, remoteStateHash, remoteTxf); - console.log(`📦 Archived forked remote version of ${tokenId.slice(0, 8)}... (state ${remoteStateHash.slice(0, 8)}...)`); - } - } - // If "equal", tokens are identical - nothing to do - } - } - - // ========================================== - // IMPORT METADATA & ARCHIVES - // ========================================== - - // Import nametag if local doesn't have one AND remote nametag is valid - if (nametag && !walletRepo.getNametag()) { - // Double-check validation (parseTxfStorageData already validates, but be defensive) - if (isNametagCorrupted(nametag)) { - console.warn("📦 Skipping corrupted nametag import from IPFS - will be cleared on next sync"); - } else { - // Check if this nametag was invalidated (e.g., Nostr pubkey mismatch) - // If so, don't re-import it - user needs to create a new nametag - const invalidatedNametags = walletRepo.getInvalidatedNametags(); - const isInvalidated = invalidatedNametags.some((inv: { name: string }) => inv.name === nametag.name); - if (isInvalidated) { - console.warn(`📦 Skipping invalidated nametag "${nametag.name}" import from IPFS - user must create new nametag`); - } else { - walletRepo.setNametag(nametag); - console.log(`📦 Imported nametag "${nametag.name}" from remote`); - } - } - } - - // Merge archived and forked tokens from remote - if (remoteArchived.size > 0) { - const archivedMergedCount = walletRepo.mergeArchivedTokens(remoteArchived); - if (archivedMergedCount > 0) { - console.log(`📦 Merged ${archivedMergedCount} archived token(s) from remote`); - } - } - if (remoteForked.size > 0) { - const forkedMergedCount = walletRepo.mergeForkedTokens(remoteForked); - if (forkedMergedCount > 0) { - console.log(`📦 Merged ${forkedMergedCount} forked token(s) from remote`); - } - } - - // Prune old tombstones and archives to prevent unlimited growth - walletRepo.pruneTombstones(); - walletRepo.pruneArchivedTokens(); - walletRepo.pruneForkedTokens(); - - // ========================================== - // INTEGRITY VERIFICATION - // ========================================== - const currentAddress = walletRepo.getWallet()?.address ?? ''; - this.verifyIntegrityInvariants(currentAddress); - - // ========================================== - // POST-IMPORT SPENT TOKEN VALIDATION - // ========================================== - // CRITICAL: Validate all tokens against aggregator to detect spent tokens - // that bypassed tombstone checks (e.g., tokens with different state hashes) - const allTokens = walletRepo.getTokens(); - const identity = await this.identityManager.getCurrentIdentity(); - if (allTokens.length > 0 && identity?.publicKey) { - console.log(`📦 Running post-import spent token validation (${allTokens.length} tokens)...`); - const validationService = getTokenValidationService(); - const result = await validationService.checkSpentTokens(allTokens, identity.publicKey); - - if (result.spentTokens.length > 0) { - console.log(`📦 Found ${result.spentTokens.length} spent token(s) during import validation:`); - for (const spent of result.spentTokens) { - console.log(`📦 - Removing spent token ${spent.tokenId.slice(0, 8)}...`); - walletRepo.removeToken(spent.localId, undefined, true); // skipHistory - } - // Emit wallet update after removing spent tokens - window.dispatchEvent(new Event("wallet-updated")); - } else { - console.log(`📦 Post-import validation: all ${allTokens.length} token(s) are valid`); - } - } - - // ========================================== - // ARCHIVE RECOVERY CHECK - // ========================================== - // Safety net for IPNS eventual consistency: check if any archived tokens - // should be restored (not active, not tombstoned, and still unspent on Unicity) - const archivedRecoveryCount = await this.checkArchivedTokensForRecovery(walletRepo); - if (archivedRecoveryCount > 0) { - importedCount += archivedRecoveryCount; - // Emit wallet update after restoring archived tokens - window.dispatchEvent(new Event("wallet-updated")); - } - - return importedCount; - } - - // ========================================== - // Storage Operations - // ========================================== - - /** - * Schedule a debounced sync using the queue with LOW priority (auto-coalesced) - * The SyncQueue handles coalescing of multiple LOW priority requests - */ - // @ts-expect-error - Method kept for backward compatibility and potential external callers - private scheduleSync(): void { - console.warn("⚠️ [DEPRECATED] IpfsStorageService.scheduleSync() is deprecated. Use InventorySyncService.inventorySync() instead."); - if (this.syncTimer) { - clearTimeout(this.syncTimer); - } - // Use a small delay to batch rapid-fire wallet-updated events - this.syncTimer = setTimeout(() => { - this.syncNow({ - priority: SyncPriority.LOW, - callerContext: 'auto-sync', - coalesce: true, - }).catch(console.error); - }, SYNC_DEBOUNCE_MS); - } - - /** - * Sync from IPNS on startup - resolves IPNS and merges with local state - * Uses progressive multi-gateway resolution for conflict detection - * - * Flow: - * 0. Retry any pending IPNS publishes from previous failed syncs - * 1. Resolve IPNS progressively from all gateways (highest sequence wins) - * 2. Compare with local CID - if different, fetch remote content - * 3. Version comparison: remote > local → import; local > remote → sync to update IPNS - * 4. Always verify remote is fetchable (handles interrupted syncs) - * 5. If fetch fails, fall back to normal sync (republish local) - * 6. Late-arriving higher sequences trigger automatic merge - */ - async syncFromIpns(): Promise { - console.log(`📦 Starting IPNS-based sync...`); - console.warn("⚠️ [DEPRECATED] IpfsStorageService.syncFromIpns() is deprecated. Use InventorySyncService.inventorySync() instead."); - - // Set initial syncing flag for UI feedback - this.isInitialSyncing = true; - this.isInsideSyncFromIpns = true; // Mark that we're inside this method (to avoid deadlock) - // Create a Promise that external callers can await to wait for initial sync to complete - this.initialSyncCompletePromise = new Promise((resolve) => { - this.initialSyncCompleteResolver = resolve; - }); - this.emitSyncStateChange(); - - // CRITICAL FIX: Detect localStorage corruption before version comparison - // If wallet is loaded but empty, and version counter is non-zero, - // we're in a localStorage corruption scenario - reset version to force recovery - const walletRepo = WalletRepository.getInstance(); - const localTokens = walletRepo.getTokens(); - const currentVersion = this.getVersionCounter(); - - if (localTokens.length === 0 && currentVersion > 0) { - console.warn(`⚠️ RECOVERY: localStorage corruption detected`); - console.warn(`⚠️ RECOVERY: Wallet has 0 tokens but version counter is v${currentVersion}`); - console.warn(`⚠️ RECOVERY: Resetting version to 0 to force IPFS import`); - - this.setVersionCounter(0); - // Continue with normal sync flow - version comparison will now trigger import - } - - try { - const initialized = await this.ensureInitialized(); - if (!initialized) { - console.warn(`📦 Not initialized, skipping IPNS sync`); - return { success: false, timestamp: Date.now(), error: "Not initialized" }; - } - - // 0. Retry any pending IPNS publishes from previous failed syncs - await this.retryPendingIpnsPublish(); - - // 1. Resolve IPNS progressively from all gateways - // Late arrivals with higher sequence will trigger handleHigherSequenceDiscovered - const resolution = await this.resolveIpnsProgressively( - (lateResult) => this.handleHigherSequenceDiscovered(lateResult) - ); - - const remoteCid = resolution.best?.cid || null; - const localCid = this.getLastCid(); - - // Update last known remote sequence - if (resolution.best) { - this.lastKnownRemoteSequence = resolution.best.sequence; - console.log( - `📦 IPNS resolved: seq=${resolution.best.sequence}, ` + - `${resolution.respondedCount}/${resolution.totalGateways} gateways responded` - ); - } - - console.log(`📦 IPNS sync: remote=${remoteCid?.slice(0, 16) || 'none'}..., local=${localCid?.slice(0, 16) || 'none'}...`); - - // Track if IPNS needs recovery (IPNS resolution returned nothing but we have local data) - // In this case, we need to force IPNS republish even if CID is unchanged - const ipnsNeedsRecovery = !remoteCid && !!localCid; - if (ipnsNeedsRecovery) { - console.log(`📦 IPNS recovery needed - IPNS empty but local CID exists`); - } - - // 2. Determine which CID to fetch - const cidToFetch = remoteCid || localCid; - - if (!cidToFetch) { - // No IPNS record and no local CID - could be fresh wallet OR failed resolution - // CRITICAL: Don't upload if IPNS resolution failed and we have no local data - // This prevents overwriting existing remote tokens on wallet restore - - const ipnsResolutionFailed = resolution.respondedCount === 0; - const localWallet = WalletRepository.getInstance(); - const localTokenCount = localWallet.getTokens().length; - const localNametag = localWallet.getNametag(); - - if (ipnsResolutionFailed && localTokenCount === 0 && !localNametag) { - // IPNS resolution failed AND we have no local tokens AND no nametag - // This is likely a wallet restore - DO NOT overwrite remote! - console.warn(`📦 IPNS resolution failed (0/${resolution.totalGateways} responded) and no local tokens`); - console.warn(`📦 Skipping upload to prevent overwriting existing remote tokens`); - console.warn(`📦 Will retry IPNS resolution on next poll`); - return { - success: false, - timestamp: Date.now(), - error: "IPNS resolution failed - waiting for successful resolution before sync" - }; - } - - console.log(`📦 No IPNS record or local CID - fresh wallet, triggering initial sync`); - return this.syncNow(); - } - - // 3. Check if remote CID differs from local (another device may have updated IPNS) - if (remoteCid && remoteCid !== localCid) { - console.log(`📦 IPNS CID differs from local! Remote may have been updated from another device`); - } - - // 4. Always try to fetch and verify remote content - // This handles cases where previous sync was interrupted - // Use cached content from gateway path if available (avoids re-fetch) - // CRITICAL: Must verify CID integrity - HTTP gateways may cache stale content - let remoteData: TxfStorageData | null = null; - - if (resolution.best?._cachedContent && resolution.best.cid === cidToFetch) { - // Verify cached content matches the CID before using it - // HTTP gateways may serve stale cached content for the IPNS name - const cachedContent = resolution.best._cachedContent; - try { - const computedCid = await computeCidFromContent(cachedContent); - if (computedCid === cidToFetch) { - // CID matches - safe to use cached content - remoteData = cachedContent; - console.log(`📦 Using cached content from gateway path (CID verified)`); - } else { - // CID mismatch - gateway has stale cache - console.warn(`⚠️ Gateway cached content CID mismatch: expected ${cidToFetch.slice(0, 16)}..., got ${computedCid.slice(0, 16)}...`); - console.log(`📦 Fetching fresh content by CID (gateway cache was stale)`); - remoteData = await this.fetchRemoteContent(cidToFetch); - } - } catch (error) { - console.warn(`⚠️ Failed to verify cached content CID:`, error); - remoteData = await this.fetchRemoteContent(cidToFetch); - } - } else { - // Fetch content via IPFS - remoteData = await this.fetchRemoteContent(cidToFetch); - } - - if (!remoteData) { - // Could not fetch remote content - // CRITICAL: Do NOT overwrite remote with empty local state! - // If local wallet is empty and remote has content, we must NOT publish empty state - const localTokenCount = WalletRepository.getInstance().getTokens().length; - - if (localTokenCount === 0 && remoteCid) { - // Local is empty but remote has content - DO NOT overwrite! - // This prevents data loss when we can't fetch remote due to connectivity issues - console.error(`🚨 BLOCKED: Cannot fetch remote content and local wallet is EMPTY!`); - console.error(`🚨 Remote CID exists (${remoteCid.slice(0, 16)}...) - refusing to overwrite with empty state`); - console.error(`🚨 Please retry sync when connectivity improves, or recover from backup`); - return { - success: false, - timestamp: Date.now(), - error: "Blocked: refusing to overwrite remote with empty local state" - }; - } - - // Local has content - safe to republish - // Force IPNS publish if IPNS was empty (recovery scenario) - console.warn(`📦 Failed to fetch remote content (CID: ${cidToFetch.slice(0, 16)}...), will republish local (${localTokenCount} tokens)`); - return this.syncNow({ forceIpnsPublish: ipnsNeedsRecovery }); - } - - // 5. Compare versions and decide action - const localVersion = this.getVersionCounter(); - const remoteVersion = remoteData._meta.version; - - console.log(`📦 Version comparison: local=v${localVersion}, remote=v${remoteVersion}`); - - if (remoteVersion > localVersion) { - // Remote is newer - import to local - console.log(`📦 Remote is newer (v${remoteVersion} > v${localVersion}), importing...`); - const importedCount = await this.importRemoteData(remoteData); - - // Update local version and CID to match remote - this.setVersionCounter(remoteVersion); - this.setLastCid(cidToFetch); - - console.log(`📦 Imported ${importedCount} token(s) from remote, now at v${remoteVersion}`); - - // Invalidate UNSPENT cache since inventory changed - if (importedCount > 0) { - getTokenValidationService().clearUnspentCacheEntries(); - } - - // If IPNS needs recovery, force publish even though we just imported - if (ipnsNeedsRecovery) { - console.log(`📦 Content imported but IPNS needs recovery - publishing to IPNS`); - return this.syncNow({ forceIpnsPublish: true }); - } - - // CRITICAL: Check if local has unique tokens that weren't in remote - // This handles the case where new tokens were minted locally but remote was ahead - // Without this, local-only tokens would never be synced to IPNS and could be lost - if (await this.localDiffersFromRemote(remoteData)) { - console.log(`📦 Local has unique content after import - syncing merged state to IPNS`); - return this.syncNow({ forceIpnsPublish: false }); - } - - // Run immediate sanity check after IPNS sync (don't wait for polling cycle) - await this.runSpentTokenSanityCheck(); - await this.runTombstoneRecoveryCheck(); - - return { - success: true, - cid: cidToFetch, - ipnsName: this.cachedIpnsName || undefined, - timestamp: Date.now(), - version: remoteVersion, - }; - } else if (remoteVersion < localVersion) { - // Local is newer - BUT remote might have new tokens we don't have - // (e.g., Browser 2 received token via Nostr while Browser 1 was offline) - console.log(`📦 Local is newer (v${localVersion} > v${remoteVersion}), checking for new remote tokens first...`); - - // Import any new tokens from remote before pushing local state - const importedCount = await this.importRemoteData(remoteData); - if (importedCount > 0) { - console.log(`📦 Imported ${importedCount} new token(s) from remote before updating IPNS`); - // Invalidate UNSPENT cache since inventory changed - getTokenValidationService().clearUnspentCacheEntries(); - window.dispatchEvent(new Event("wallet-updated")); - } - - // Only sync if local differs from remote (has unique tokens or better versions) - if (await this.localDiffersFromRemote(remoteData)) { - console.log(`📦 Local differs from remote, syncing merged state...`); - return this.syncNow({ forceIpnsPublish: ipnsNeedsRecovery }); - } else { - console.log(`📦 Local now matches remote after import, no sync needed`); - // Update local tracking to match remote - this.setLastCid(cidToFetch); - this.setVersionCounter(remoteVersion); - - // If IPNS needs recovery, force publish even though content is synced - if (ipnsNeedsRecovery) { - console.log(`📦 Content synced but IPNS needs recovery - publishing to IPNS`); - return this.syncNow({ forceIpnsPublish: true }); - } - - // Run immediate sanity check after IPNS sync (don't wait for polling cycle) - await this.runSpentTokenSanityCheck(); - await this.runTombstoneRecoveryCheck(); - - return { - success: true, - cid: cidToFetch, - ipnsName: this.cachedIpnsName || undefined, - timestamp: Date.now(), - version: remoteVersion, - }; - } - } else { - // Same version - remote is in sync - // Still update lastCid to match IPNS if resolved - if (remoteCid && remoteCid !== localCid) { - this.setLastCid(remoteCid); - console.log(`📦 Updated local CID to match IPNS`); - } - - console.log(`📦 Versions match (v${remoteVersion}), remote verified accessible`); - - // CRITICAL FIX: Detect missing tokens (localStorage corruption scenario) - // If localStorage is cleared but version counter survives, tokens would be lost. - // Check if local has tokens - if not but remote does, force recovery import. - const localWallet = WalletRepository.getInstance(); - const localTokenCount = localWallet.getTokens().length; - let remoteTokenCount = 0; - for (const key of Object.keys(remoteData)) { - if (isTokenKey(key)) { - remoteTokenCount++; - } - } - - if (localTokenCount === 0 && remoteTokenCount > 0) { - console.warn(`⚠️ RECOVERY: Versions match but localStorage is empty!`); - console.warn(`⚠️ RECOVERY: Detected tokens - local: ${localTokenCount}, remote: ${remoteTokenCount}`); - console.warn(`⚠️ RECOVERY: Recovering ${remoteTokenCount} token(s) from IPFS`); - - const importedCount = await this.importRemoteData(remoteData); - if (importedCount > 0) { - console.log(`✅ RECOVERY: Imported ${importedCount} token(s), wallet restored`); - // CRITICAL: Invalidate UNSPENT cache since inventory changed - getTokenValidationService().clearUnspentCacheEntries(); - window.dispatchEvent(new Event("wallet-updated")); - } - } - - // If IPNS needs recovery, force publish even though content is synced - if (ipnsNeedsRecovery) { - console.log(`📦 Content synced but IPNS needs recovery - publishing to IPNS`); - return this.syncNow({ forceIpnsPublish: true }); - } - - // Run immediate sanity check after IPNS sync (don't wait for polling cycle) - await this.runSpentTokenSanityCheck(); - await this.runTombstoneRecoveryCheck(); - - return { - success: true, - cid: cidToFetch, - ipnsName: this.cachedIpnsName || undefined, - timestamp: Date.now(), - version: remoteVersion, - }; - } - } finally { - this.isInitialSyncing = false; - this.isInsideSyncFromIpns = false; // Clear the deadlock-prevention flag - // Resolve the Promise so any waiting syncs can proceed - if (this.initialSyncCompleteResolver) { - this.initialSyncCompleteResolver(); - this.initialSyncCompleteResolver = null; - this.initialSyncCompletePromise = null; - } - this.emitSyncStateChange(); - } - } - - /** - * Get or initialize the sync queue (lazy initialization) - */ - private getSyncQueue(): SyncQueue { - if (!this.syncQueue) { - this.syncQueue = new SyncQueue((opts) => this.executeSyncInternal(opts)); - } - return this.syncQueue; - } - - /** - * Perform sync to IPFS using the priority queue - * Requests are queued and processed in priority order instead of being rejected - * - * @param options.forceIpnsPublish Force IPNS publish even if CID unchanged - * @param options.priority Priority level (default: MEDIUM) - * @param options.timeout Max time to wait in queue (default: 60s) - * @param options.callerContext Identifier for debugging - * @param options.coalesce For LOW priority: batch multiple requests (default: true) - */ - async syncNow(options?: SyncOptions): Promise { - // If IPFS is disabled, return success immediately (no-op) - if (import.meta.env.VITE_ENABLE_IPFS === 'false') { - return { - success: true, - timestamp: Date.now(), - // No CID when IPFS is disabled - }; - } - return this.getSyncQueue().enqueue(options ?? {}); - } - - /** - * Internal sync implementation - called by SyncQueue - * Uses SyncCoordinator for cross-tab coordination to prevent race conditions - */ - private async executeSyncInternal(options?: { forceIpnsPublish?: boolean; isRetryAttempt?: boolean }): Promise { - const { forceIpnsPublish = false, isRetryAttempt = false } = options || {}; - - // CRITICAL: Wait for initial IPNS sync to complete before proceeding - // This prevents race conditions where Nostr delivers tokens and triggers a sync - // BEFORE the startup sync has fetched remote content, causing token loss - // Skip the wait if we're inside syncFromIpns (to avoid deadlock on internal syncNow calls) - if (this.initialSyncCompletePromise && !this.isInsideSyncFromIpns) { - console.log(`📦 Waiting for initial IPNS sync to complete before proceeding...`); - await this.initialSyncCompletePromise; - console.log(`📦 Initial IPNS sync completed, proceeding with sync`); - } - - // Use SyncCoordinator to acquire distributed lock across browser tabs - const coordinator = getSyncCoordinator(); - - // Try to acquire cross-tab lock - const lockAcquired = await coordinator.acquireLock(); - if (!lockAcquired) { - console.log(`📦 Another tab is syncing, skipping this sync`); - return { - success: false, - timestamp: Date.now(), - error: "Another tab is syncing", - }; - } - - this.isSyncing = true; - this.emitSyncStateChange(); - - await this.emitEvent({ - type: "storage:started", - timestamp: Date.now(), - }); - - try { - const initialized = await this.ensureInitialized(); - if ( - !initialized || - !this.helia || - !this.ed25519PrivateKey || - !this.ed25519PublicKey - ) { - throw new Error("IPFS not initialized"); - } - - // 1. Get current tokens - const wallet = WalletRepository.getInstance().getWallet(); - if (!wallet) { - // For new wallets, WalletRepository._wallet may not be set yet - // This is OK - inventorySync() handles the real storage - // Return success since there's nothing to sync via this legacy path - console.log(`📦 [SYNC] No WalletRepository wallet loaded (new wallet?) - skipping legacy sync`); - this.isSyncing = false; - this.emitSyncStateChange(); - coordinator.releaseLock(); - return { - success: true, - timestamp: Date.now(), - version: 0, - }; - } - - // Validate wallet belongs to current identity - const currentIdentity = await this.identityManager.getCurrentIdentity(); - if (currentIdentity && wallet.address !== currentIdentity.address) { - throw new Error( - `Cannot sync: wallet address mismatch (wallet=${wallet.address}, identity=${currentIdentity.address})` - ); - } - - const walletRepo = WalletRepository.getInstance(); - const nametag = walletRepo.getNametag(); - - // DIAGNOSTIC: Log all tokens in wallet before validation - console.log(`📦 [SYNC] Wallet has ${wallet.tokens.length} token(s) before validation:`); - for (const t of wallet.tokens) { - let txfInfo = "no jsonData"; - if (t.jsonData) { - try { - const parsed = JSON.parse(t.jsonData); - const hasGenesis = !!parsed.genesis; - const hasState = !!parsed.state; - const txCount = Array.isArray(parsed.transactions) ? parsed.transactions.length : 0; - const tokenIdInTxf = parsed.genesis?.data?.tokenId?.slice(0, 8) || "unknown"; - txfInfo = `genesis=${hasGenesis}, state=${hasState}, tx=${txCount}, tokenId=${tokenIdInTxf}...`; - } catch { - txfInfo = "invalid JSON"; - } - } - console.log(`📦 - [${t.id.slice(0, 8)}...] ${t.symbol} ${t.amount}: ${txfInfo}`); - } - - // 2. Validate tokens before sync - const validationService = getTokenValidationService(); - const { validTokens, issues } = await validationService.validateAllTokens(wallet.tokens); - - if (issues.length > 0) { - console.warn(`📦 ${issues.length} token(s) failed validation and will be excluded:`); - for (const issue of issues) { - console.warn(`📦 - FAILED [${issue.tokenId.slice(0, 8)}...]: ${issue.reason}`); - } - } - - // DIAGNOSTIC: Log tokens that passed validation - console.log(`📦 [SYNC] ${validTokens.length} token(s) passed validation:`); - for (const t of validTokens) { - console.log(`📦 - VALID [${t.id.slice(0, 8)}...] ${t.symbol} ${t.amount}`); - } - - console.log(`📦 Syncing ${validTokens.length} tokens${nametag ? ` + nametag "${nametag.name}"` : ""} to IPFS (TXF format)...`); - - // 3. Check for remote conflicts before syncing - let tokensToSync = validTokens; - let conflictsResolved = 0; - const lastCid = this.getLastCid(); - - if (lastCid) { - try { - console.log(`📦 Checking for remote conflicts (last CID: ${lastCid.slice(0, 16)}...)...`); - const j = json(this.helia); - const { CID } = await import("multiformats/cid"); - const remoteCid = CID.parse(lastCid); - - // Add timeout to prevent hanging indefinitely when IPFS network is slow - const REMOTE_FETCH_TIMEOUT = 15000; // 15 seconds - const remoteData = await Promise.race([ - j.get(remoteCid), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Remote fetch timeout")), REMOTE_FETCH_TIMEOUT) - ), - ]) as unknown; - - if (remoteData && typeof remoteData === "object" && "_meta" in (remoteData as object)) { - const remoteTxf = remoteData as TxfStorageData; - const remoteVersion = remoteTxf._meta.version; - const localVersion = this.getVersionCounter(); - - if (remoteVersion !== localVersion) { - console.log(`📦 Version mismatch detected: local v${localVersion} vs remote v${remoteVersion}`); - - // Build local storage data for comparison (include tombstones) - const localMeta: Omit = { - version: localVersion, - address: wallet.address, - ipnsName: this.cachedIpnsName || "", - }; - const localTombstones = walletRepo.getTombstones(); - const localTxf = await buildTxfStorageData(validTokens, localMeta, nametag || undefined, localTombstones); - - // Resolve conflicts - const conflictService = getConflictResolutionService(); - const mergeResult = conflictService.resolveConflict(localTxf, remoteTxf); - - if (mergeResult.conflicts.length > 0) { - console.log(`📦 Resolved ${mergeResult.conflicts.length} token conflict(s):`); - for (const conflict of mergeResult.conflicts) { - console.log(` - ${conflict.tokenId.slice(0, 8)}...: ${conflict.reason} (${conflict.resolution} wins)`); - } - conflictsResolved = mergeResult.conflicts.length; - } - - if (mergeResult.newTokens.length > 0) { - console.log(`📦 Added ${mergeResult.newTokens.length} token(s) from remote`); - - // Save new tokens from remote to local storage (IPFS → localStorage sync) - for (const tokenId of mergeResult.newTokens) { - const tokenKey = `_${tokenId}`; - const txfToken = mergeResult.merged[tokenKey] as TxfToken; - if (txfToken) { - const token = txfToToken(tokenId, txfToken); - walletRepo.addToken(token, true); // skip history - recorded on original device - console.log(`📦 Synced token ${tokenId.slice(0, 8)}... from IPFS to local`); - } - } - } - - // Process tombstones: merge remote tombstones into local - // This removes local tokens that were deleted on other devices - const remoteTombstones = remoteTxf._tombstones || []; - if (remoteTombstones.length > 0) { - console.log(`📦 Processing ${remoteTombstones.length} remote tombstone(s)`); - const removedCount = walletRepo.mergeTombstones(remoteTombstones); - if (removedCount > 0) { - console.log(`📦 Removed ${removedCount} tombstoned token(s) from local during conflict resolution`); - // Invalidate UNSPENT cache since inventory changed - getTokenValidationService().clearUnspentCacheEntries(); - } - } - - // Merge archived, forked tokens, outbox entries, and invalidated nametags from remote - const { archivedTokens: remoteArchived, forkedTokens: remoteForked, outboxEntries: remoteOutbox, mintOutboxEntries: remoteMintOutbox, invalidatedNametags: remoteInvalidatedNametags } = parseTxfStorageData(remoteTxf); - if (remoteArchived.size > 0) { - const archivedMergedCount = walletRepo.mergeArchivedTokens(remoteArchived); - if (archivedMergedCount > 0) { - console.log(`📦 Merged ${archivedMergedCount} archived token(s) from remote`); - } - } - if (remoteForked.size > 0) { - const forkedMergedCount = walletRepo.mergeForkedTokens(remoteForked); - if (forkedMergedCount > 0) { - console.log(`📦 Merged ${forkedMergedCount} forked token(s) from remote`); - } - } - // Import outbox entries from remote (CRITICAL for transfer recovery) - if (remoteOutbox && remoteOutbox.length > 0) { - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.importFromRemote(remoteOutbox); - console.log(`📦 Imported ${remoteOutbox.length} outbox entries from remote during conflict resolution`); - } - // Import mint outbox entries from remote (CRITICAL for mint recovery) - if (remoteMintOutbox && remoteMintOutbox.length > 0) { - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.importMintEntriesFromRemote(remoteMintOutbox); - console.log(`📦 Imported ${remoteMintOutbox.length} mint outbox entries from remote during conflict resolution`); - } - // Merge invalidated nametags from remote (preserves history across devices) - if (remoteInvalidatedNametags && remoteInvalidatedNametags.length > 0) { - const mergedCount = walletRepo.mergeInvalidatedNametags(remoteInvalidatedNametags); - if (mergedCount > 0) { - console.log(`📦 Merged ${mergedCount} invalidated nametag(s) from remote during conflict resolution`); - } - } - - // Also sync nametag from remote if local doesn't have one - if (!nametag && mergeResult.merged._nametag) { - // Validate before setting - prevent importing corrupted nametag - if (isNametagCorrupted(mergeResult.merged._nametag)) { - console.warn("📦 Skipping corrupted nametag from conflict resolution - will be cleared on next sync"); - } else { - // Check if this nametag was invalidated (e.g., Nostr pubkey mismatch) - const invalidatedNametags = walletRepo.getInvalidatedNametags(); - const isInvalidated = invalidatedNametags.some((inv: { name: string }) => inv.name === mergeResult.merged._nametag!.name); - if (isInvalidated) { - console.warn(`📦 Skipping invalidated nametag "${mergeResult.merged._nametag.name}" from conflict resolution - user must create new nametag`); - } else { - walletRepo.setNametag(mergeResult.merged._nametag); - console.log(`📦 Synced nametag "${mergeResult.merged._nametag.name}" from IPFS to local`); - } - } - } - - // Extract tokens from merged data for re-sync - const { tokens: mergedTokens } = parseTxfStorageData(mergeResult.merged); - tokensToSync = mergedTokens; - - // Update local version to merged version - this.setVersionCounter(mergeResult.merged._meta.version); - } else { - // Remote is in sync - check if local has any changes worth uploading - // Extract genesis token IDs from local tokens (same as buildTxfStorageData uses) - const localTokenIds = validTokens.map(t => { - try { - const txf = JSON.parse(t.jsonData || "{}"); - return txf.genesis?.data?.tokenId || t.id; - } catch { - return t.id; - } - }).sort().join(","); - // TXF format stores tokens as _tokenId keys (genesis token IDs) - const remoteTokenIds = Object.keys(remoteTxf) - .filter(k => k.startsWith("_") && k !== "_meta" && k !== "_nametag" && k !== "_tombstones") - .map(k => k.slice(1)) - .sort() - .join(","); - - if (localTokenIds === remoteTokenIds && !forceIpnsPublish) { - // No changes - remote was verified accessible by startup syncFromIpns() - // Skip re-upload for this wallet-updated event - // BUT: don't skip if forceIpnsPublish is set (IPNS recovery needed) - console.log(`📦 Remote is in sync (v${remoteVersion}) - no changes to upload`); - this.isSyncing = false; - this.emitSyncStateChange(); - coordinator.releaseLock(); // Release cross-tab lock on early return - return { - success: true, - cid: lastCid || undefined, - ipnsName: this.cachedIpnsName || undefined, - timestamp: Date.now(), - version: remoteVersion, - tokenCount: validTokens.length, - }; - } - if (localTokenIds === remoteTokenIds && forceIpnsPublish) { - console.log(`📦 Remote is in sync but IPNS recovery needed - continuing to publish IPNS`); - } - console.log(`📦 Remote version matches but local has token changes - uploading...`); - } - } - } catch (err) { - console.warn(`📦 Could not fetch remote for conflict check:`, err instanceof Error ? err.message : err); - // Continue with local data - } - } - - // 4. Build TXF storage data with incremented version (include tombstones, archives, forks, outbox) - const newVersion = this.incrementVersionCounter(); - const tombstones = walletRepo.getTombstones(); - const archivedTokens = walletRepo.getArchivedTokens(); - const forkedTokens = walletRepo.getForkedTokens(); - - // Get outbox entries for IPFS sync (CRITICAL for transfer recovery) - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.setCurrentAddress(wallet.address); - const outboxEntries = outboxRepo.getAllForSync(); - const mintOutboxEntries = outboxRepo.getAllMintEntriesForSync(); - - // Get invalidated nametags (preserves history across devices) - const invalidatedNametags = walletRepo.getInvalidatedNametags(); - - const meta: Omit = { - version: newVersion, - address: wallet.address, - ipnsName: this.cachedIpnsName || "", - lastCid: this.getLastCid() || undefined, - }; - - const txfStorageData = await buildTxfStorageData(tokensToSync, meta, nametag || undefined, tombstones, archivedTokens, forkedTokens, outboxEntries, mintOutboxEntries, invalidatedNametags); - if (tombstones.length > 0 || archivedTokens.size > 0 || forkedTokens.size > 0 || outboxEntries.length > 0 || mintOutboxEntries.length > 0 || invalidatedNametags.length > 0) { - console.log(`📦 Including ${tombstones.length} tombstone(s), ${archivedTokens.size} archived, ${forkedTokens.size} forked, ${outboxEntries.length} outbox, ${mintOutboxEntries.length} mint outbox, ${invalidatedNametags.length} invalidated nametag(s) in sync`); - } - - // 4. Ensure backend is connected before storing - const backendConnected = await this.ensureBackendConnected(); - if (backendConnected) { - console.log(`📦 Backend connected - content will be available via bitswap`); - } - - // 4.1. Store to IPFS - const j = json(this.helia); - const cid = await j.add(txfStorageData); - const cidString = cid.toString(); - - // 4.2. Wait briefly for bitswap to have a chance to exchange blocks - // This gives the backend time to request blocks while we're connected - if (backendConnected) { - console.log(`📦 Waiting for bitswap block exchange...`); - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - - // 4.3. Multi-node upload: directly upload content to all configured IPFS nodes - // This bypasses bitswap limitations since browser can't be directly dialed - const gatewayUrls = getAllBackendGatewayUrls(); - if (gatewayUrls.length > 0) { - console.log(`📦 Uploading to ${gatewayUrls.length} IPFS node(s)...`); - - const jsonBlob = new Blob([JSON.stringify(txfStorageData)], { - type: "application/json", - }); - - // Upload to all nodes in parallel - const uploadPromises = gatewayUrls.map(async (gatewayUrl) => { - try { - const formData = new FormData(); - formData.append("file", jsonBlob, "wallet.json"); - - const response = await fetch( - `${gatewayUrl}/api/v0/add?pin=true&cid-version=1`, - { method: "POST", body: formData } - ); - if (response.ok) { - const result = await response.json(); - const hostname = new URL(gatewayUrl).hostname; - console.log(`📦 Uploaded to ${hostname}: ${result.Hash}`); - return { success: true, host: gatewayUrl, cid: result.Hash }; - } - return { success: false, host: gatewayUrl, error: response.status }; - } catch (error) { - const hostname = new URL(gatewayUrl).hostname; - console.warn(`📦 Upload to ${hostname} failed:`, error); - return { success: false, host: gatewayUrl, error }; - } - }); - - const results = await Promise.allSettled(uploadPromises); - const successful = results.filter( - (r) => r.status === "fulfilled" && r.value.success - ).length; - console.log(`📦 Content uploaded to ${successful}/${gatewayUrls.length} nodes`); - } - - // 4.4. Announce content to connected peers (DHT provide) - // This helps ensure our backend IPFS node can discover and fetch the content - // Use timeout since DHT operations can be slow in browser - const PROVIDE_TIMEOUT = 10000; // 10 seconds - try { - console.log(`📦 Announcing CID to network: ${cidString.slice(0, 16)}...`); - await Promise.race([ - this.helia.routing.provide(cid), - new Promise((_, reject) => - setTimeout(() => reject(new Error("DHT provide timeout")), PROVIDE_TIMEOUT) - ), - ]); - console.log(`📦 CID announced to network`); - } catch (provideError) { - // Non-fatal - content is still stored locally - console.warn(`📦 Could not announce to DHT (non-fatal):`, provideError); - } - - // 4.5. Publish to IPNS only if CID changed (or forced for IPNS recovery) - const previousCid = this.getLastCid(); - let ipnsPublished = false; - let ipnsPublishPending = false; - const shouldPublishIpns = cidString !== previousCid || forceIpnsPublish; - if (shouldPublishIpns) { - if (forceIpnsPublish && cidString === previousCid) { - console.log(`📦 Forcing IPNS republish (CID unchanged but IPNS may be expired)`); - } - const ipnsResult = await this.publishToIpns(cid); - if (ipnsResult) { - ipnsPublished = true; - this.clearPendingIpnsPublish(); // Clear any previous pending - // Stop any active retry loop since we succeeded - this.stopIpnsSyncRetryLoop(); - } else { - // IPNS publish failed - mark as pending for retry - this.setPendingIpnsPublish(cidString); - ipnsPublishPending = true; - // Start the infinite retry loop (unless this is already a retry attempt) - if (!isRetryAttempt) { - console.log(`📦 Starting IPNS sync retry loop due to publish failure...`); - this.startIpnsSyncRetryLoop(); - } - } - } else { - console.log(`📦 CID unchanged (${cidString.slice(0, 16)}...) - skipping IPNS publish`); - this.clearPendingIpnsPublish(); // Clear any stale pending - } - - // 5. Store CID for recovery (even if IPNS failed, content is stored) - this.setLastCid(cidString); - - console.log(`📦 Tokens stored to IPFS (v${newVersion}): ${cidString}`); - console.log(`📦 IPNS name: ${this.cachedIpnsName}`); - - const result: StorageResult = { - success: true, - cid: cidString, - ipnsName: this.cachedIpnsName || undefined, - timestamp: Date.now(), - version: newVersion, - tokenCount: tokensToSync.length, - validationIssues: issues.length > 0 ? issues.map(i => i.reason) : undefined, - conflictsResolved: conflictsResolved > 0 ? conflictsResolved : undefined, - ipnsPublished, - ipnsPublishPending: ipnsPublishPending || undefined, - }; - - this.lastSync = result; - - await this.emitEvent({ - type: "storage:completed", - timestamp: Date.now(), - data: { - cid: cidString, - ipnsName: this.cachedIpnsName || undefined, - tokenCount: validTokens.length, - }, - }); - - // Emit IPNS published event for future Nostr integration - await this.emitEvent({ - type: "ipns:published", - timestamp: Date.now(), - data: { - cid: cidString, - ipnsName: this.cachedIpnsName || undefined, - }, - }); - - return result; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - console.error("📦 Storage sync failed:", errorMessage); - - const result: StorageResult = { - success: false, - timestamp: Date.now(), - error: errorMessage, - }; - - this.lastSync = result; - - await this.emitEvent({ - type: "storage:failed", - timestamp: Date.now(), - data: { error: errorMessage }, - }); - - return result; - } finally { - this.isSyncing = false; - this.emitSyncStateChange(); - // Release cross-tab lock - coordinator.releaseLock(); - // Note: SyncQueue handles queuing of pending sync requests automatically - } - } - - // ========================================== - // Spent Token Sanity Check - // ========================================== - - /** - * Run sanity check to detect and remove spent tokens - * Called during each IPNS poll cycle - * - * @deprecated Use InventorySyncService instead. This method will be removed in a future release. - * Migration: TokenValidationService is called directly by InventorySyncService. - */ - private async runSpentTokenSanityCheck(): Promise { - console.warn('⚠️ [DEPRECATED] runSpentTokenSanityCheck() is deprecated. Use InventorySyncService.inventorySync() instead.'); - console.log("📦 Running spent token sanity check..."); - - try { - // Get current identity for public key - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.warn("📦 Sanity check: No identity, skipping"); - return; - } - - // Get all tokens from wallet - const walletRepo = WalletRepository.getInstance(); - const tokens = walletRepo.getTokens(); - - if (tokens.length === 0) { - console.log("📦 Sanity check: No tokens to check"); - return; - } - - // Run spent token check - const validationService = getTokenValidationService(); - const result = await validationService.checkSpentTokens(tokens, identity.publicKey, { - batchSize: 3, - onProgress: (completed, total) => { - if (completed % 5 === 0 || completed === total) { - console.log(`📦 Sanity check progress: ${completed}/${total}`); - } - }, - }); - - // Remove spent tokens - if (result.spentTokens.length > 0) { - console.log(`📦 Sanity check found ${result.spentTokens.length} spent token(s):`); - - for (const spent of result.spentTokens) { - const tokenIdStr = spent.tokenId || spent.localId || "unknown"; - const stateHashStr = spent.stateHash || "unknown"; - console.log( - `📦 - Removing spent token ${tokenIdStr.slice(0, 8)}... (state: ${stateHashStr.slice(0, 12)}...)` - ); - // Use skipHistory=true since this is cleanup, not a user-initiated transfer - if (spent.localId) { - walletRepo.removeToken(spent.localId, undefined, true); - } - } - - // Emit wallet-updated to refresh UI - window.dispatchEvent(new Event("wallet-updated")); - - console.log(`📦 Sanity check complete: removed ${result.spentTokens.length} spent token(s)`); - } else { - console.log("📦 Sanity check complete: no spent tokens found"); - } - - // Log any errors (non-fatal) - if (result.errors.length > 0) { - console.warn( - `📦 Sanity check had ${result.errors.length} error(s):`, - result.errors.slice(0, 3) - ); - } - } catch (error) { - // Non-fatal - sanity check failure shouldn't break sync - console.warn( - "📦 Sanity check failed (non-fatal):", - error instanceof Error ? error.message : error - ); - } - } - - /** - * Periodic tombstone recovery check - * Verifies existing local tombstones are still valid (token actually spent) - * Removes invalid tombstones and restores tokens from archive - * - * This is the inverse of runSpentTokenSanityCheck(): - * - runSpentTokenSanityCheck: finds active tokens that should be tombstoned - * - runTombstoneRecoveryCheck: finds tombstones that should be removed - * - * @deprecated Use InventorySyncService instead. This method will be removed in a future release. - * Migration: Recovery flow is handled by InventorySyncService.inventorySync(). - */ - private async runTombstoneRecoveryCheck(): Promise { - console.warn('⚠️ [DEPRECATED] runTombstoneRecoveryCheck() is deprecated. Use InventorySyncService.inventorySync() instead.'); - console.log("📦 Running tombstone recovery check..."); - - try { - const walletRepo = WalletRepository.getInstance(); - const tombstones = walletRepo.getTombstones(); - - if (tombstones.length === 0) { - console.log("📦 Tombstone recovery: no tombstones to check"); - return; - } - - // Reuse existing sanityCheckTombstones() logic - const walletAddress = walletRepo.getWallet()?.address ?? ''; - const result = await this.sanityCheckTombstones(tombstones, walletAddress); - - if (result.invalidTombstones.length === 0) { - console.log(`📦 Tombstone recovery: all ${tombstones.length} tombstone(s) are valid`); - return; - } - - console.log(`📦 Found ${result.invalidTombstones.length} invalid tombstone(s) - recovering...`); - - // Remove invalid tombstones - for (const invalid of result.invalidTombstones) { - console.log(`📦 - Removing invalid tombstone ${invalid.tokenId.slice(0, 8)}:${invalid.stateHash.slice(0, 8)}...`); - walletRepo.removeTombstone(invalid.tokenId, invalid.stateHash); - } - - // Restore tokens from archive - let restoredCount = 0; - for (const { tokenId, txf } of result.tokensToRestore) { - const restored = walletRepo.restoreTokenFromArchive(tokenId, txf); - if (restored) { - console.log(`📦 - Restored token ${tokenId.slice(0, 8)}... from archive`); - restoredCount++; - } - } - - if (restoredCount > 0) { - window.dispatchEvent(new Event("wallet-updated")); - console.log(`📦 Tombstone recovery complete: ${result.invalidTombstones.length} tombstone(s) removed, ${restoredCount} token(s) restored`); - } - - } catch (error) { - // Non-fatal - recovery check failure shouldn't break polling - console.warn( - "📦 Tombstone recovery check failed (non-fatal):", - error instanceof Error ? error.message : error - ); - } - } - - // ========================================== - // Restore Operations - // ========================================== - - /** - * Restore tokens from IPFS using CID - * Supports both legacy format and TXF format - */ - async restore(cid: string): Promise { - try { - const initialized = await this.ensureInitialized(); - if (!initialized || !this.helia) { - throw new Error("IPFS not initialized"); - } - - console.log(`📦 Restoring from CID: ${cid}`); - - const j = json(this.helia); - const { CID } = await import("multiformats/cid"); - const parsedCid = CID.parse(cid); - - const rawData = await j.get(parsedCid); - - // Detect format: TXF has _meta, legacy has version number - const isTxfFormat = rawData && typeof rawData === "object" && "_meta" in (rawData as object); - - if (isTxfFormat) { - // TXF Format - const txfData = rawData as TxfStorageData; - const { tokens, meta, nametag, validationErrors } = parseTxfStorageData(txfData); - - if (validationErrors.length > 0) { - console.warn(`📦 Validation warnings during restore:`, validationErrors); - } - - // Validate address - const currentIdentity = await this.identityManager.getCurrentIdentity(); - if (currentIdentity && meta && meta.address !== currentIdentity.address) { - console.warn( - `📦 Address mismatch: stored=${meta.address}, current=${currentIdentity.address}` - ); - throw new Error( - "Cannot restore tokens: address mismatch. This data belongs to a different identity." - ); - } - - // Update local version counter to match restored version - if (meta) { - this.setVersionCounter(meta.version); - } - - console.log(`📦 Restored ${tokens.length} tokens (TXF v${meta?.version || "?"})${nametag ? ` + nametag "${nametag.name}"` : ""} from IPFS`); - - return { - success: true, - tokens, - nametag: nametag || undefined, - version: meta?.version, - timestamp: Date.now(), - }; - } else { - // Legacy format - const storageData = rawData as StorageData; - - if (!storageData || !storageData.version) { - throw new Error("Invalid storage data format"); - } - - // Validate address - const currentIdentity = await this.identityManager.getCurrentIdentity(); - if (currentIdentity && storageData.address !== currentIdentity.address) { - console.warn( - `📦 Address mismatch: stored=${storageData.address}, current=${currentIdentity.address}` - ); - throw new Error( - "Cannot restore tokens: address mismatch. This data belongs to a different identity." - ); - } - - console.log(`📦 Restored ${storageData.tokens.length} tokens (legacy format)${storageData.nametag ? ` + nametag "${storageData.nametag.name}"` : ""} from IPFS`); - - // Convert serialized tokens back to Token objects - const { Token: TokenClass, TokenStatus } = await import("../data/model"); - const tokens = storageData.tokens.map( - (t) => - new TokenClass({ - id: t.id, - name: t.name, - symbol: t.symbol, - amount: t.amount, - coinId: t.coinId, - jsonData: t.jsonData, - status: (t.status as keyof typeof TokenStatus) in TokenStatus - ? t.status as typeof TokenStatus[keyof typeof TokenStatus] - : TokenStatus.CONFIRMED, - timestamp: t.timestamp, - type: t.type, - iconUrl: t.iconUrl, - }) - ) as Token[]; - - return { - success: true, - tokens, - nametag: storageData.nametag, - timestamp: Date.now(), - }; - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - console.error("📦 Restore failed:", errorMessage); - - return { - success: false, - timestamp: Date.now(), - error: errorMessage, - }; - } - } - - /** - * Restore from last known CID (recovery helper) - */ - async restoreFromLastCid(): Promise { - const lastCid = this.getLastCid(); - if (!lastCid) { - return { - success: false, - timestamp: Date.now(), - error: "No previous CID found for recovery", - }; - } - return this.restore(lastCid); - } - - // ========================================== - // Status & Getters - // ========================================== - - /** - * Get or compute the deterministic IPNS name for this wallet - * Returns a proper PeerId-based IPNS name - * Use getIpnsName() for sync access to cached value - */ - async getOrComputeIpnsName(): Promise { - if (this.cachedIpnsName) { - return this.cachedIpnsName; - } - - // Try to compute it without full initialization - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - return null; - } - - try { - const walletSecret = this.hexToBytes(identity.privateKey); - const derivedKey = hkdf(sha256, walletSecret, undefined, HKDF_INFO, 32); - - // Generate libp2p key pair and derive peer ID for proper IPNS name - const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey); - const peerId = peerIdFromPrivateKey(keyPair); - this.cachedIpnsName = peerId.toString(); - - return this.cachedIpnsName; - } catch (error) { - console.warn("📦 Failed to compute IPNS name:", error); - return null; - } - } - - /** - * Get current storage status - */ - getStatus(): StorageStatus { - return { - initialized: this.helia !== null, - isSyncing: this.isSyncing || this.isInitialSyncing, - lastSync: this.lastSync, - ipnsName: this.cachedIpnsName, - webCryptoAvailable: this.isWebCryptoAvailable(), - currentVersion: this.getVersionCounter(), - lastCid: this.getLastCid(), - }; - } - - /** - * Get current version counter - */ - getCurrentVersion(): number { - return this.getVersionCounter(); - } - - /** - * Check if currently syncing - */ - isCurrentlySyncing(): boolean { - return this.isSyncing || this.isInitialSyncing; - } - - /** - * Get IPFS performance metrics for monitoring and debugging - * Includes latency percentiles, success rates, and target achievement status - */ - getPerformanceMetrics(): { - snapshot: IpfsMetricsSnapshot; - targetStatus: { targetMet: boolean; p95AboveTarget: boolean; message: string }; - resolveMetrics: { count: number; avgLatencyMs: number; successRate: number; preferredSource: IpfsSource }; - fetchMetrics: { count: number; avgLatencyMs: number; successRate: number; preferredSource: IpfsSource }; - } { - const metrics = getIpfsMetrics(); - return { - snapshot: metrics.getSnapshot(), - targetStatus: metrics.getTargetStatus(), - resolveMetrics: metrics.getOperationMetrics("resolve"), - fetchMetrics: metrics.getOperationMetrics("fetch"), - }; - } - - /** - * Clear IPFS cache and metrics (useful for debugging) - */ - clearCacheAndMetrics(): void { - const httpResolver = getIpfsHttpResolver(); - httpResolver.invalidateIpnsCache(); - getIpfsMetrics().reset(); - console.log("📦 IPFS cache and metrics cleared"); - } - - /** - * Clear corrupted nametag from both local and IPFS storage. - * This breaks the import loop by publishing clean state to IPFS. - * - * Call this when corrupted nametag is detected to ensure the corruption - * is cleared from BOTH local storage AND the remote IPFS backup. - */ - async clearCorruptedNametagAndSync(): Promise { - console.log("🧹 Clearing corrupted nametag from local and IPFS storage..."); - - // Get current identity - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.error("No identity available for clearing nametag"); - return; - } - - // 1. Clear from local storage - try { - clearNametagForAddress(identity.address); - console.log("✅ Cleared corrupted nametag from local storage"); - } catch (error) { - console.error("Failed to clear local nametag:", error); - } - - // 2. Force sync to IPFS to overwrite remote with clean state (no nametag) - // This prevents the next sync from re-importing the corrupted data - try { - await this.syncNow({ forceIpnsPublish: true }); - console.log("✅ Published clean state to IPFS (corrupted nametag removed)"); - } catch (error) { - console.error("Failed to sync clean state to IPFS:", error); - // Even if IPFS sync fails, local is cleared - IPFS will be fixed on next successful sync - } - } - - // ========================================== - // TXF Import/Export - // ========================================== - - /** - * Export all tokens as TXF file content - */ - async exportAsTxf(): Promise<{ success: boolean; data?: string; filename?: string; error?: string }> { - try { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - return { success: false, error: "No identity available" }; - } - - const tokens = getTokensForAddress(identity.address); - if (tokens.length === 0) { - return { success: false, error: "No tokens found" }; - } - - // Import serializer - const { buildTxfExportFile } = await import("./TxfSerializer"); - const txfData = buildTxfExportFile(tokens); - - const filename = `tokens-${identity.address.slice(0, 8)}-${Date.now()}.txf`; - const jsonString = JSON.stringify(txfData, null, 2); - - console.log(`📦 Exported ${tokens.length} tokens as TXF`); - - return { - success: true, - data: jsonString, - filename, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error("📦 TXF export failed:", errorMessage); - return { success: false, error: errorMessage }; - } - } - - /** - * Import tokens from TXF file content - * Returns imported tokens that can be added to wallet - */ - async importFromTxf(content: string): Promise<{ - success: boolean; - tokens?: Token[]; - imported?: number; - skipped?: number; - error?: string; - }> { - try { - const txfData = JSON.parse(content); - - // Import serializer and validator - const { parseTxfFile } = await import("./TxfSerializer"); - const validationService = getTokenValidationService(); - - const { tokens: parsedTokens, errors: parseErrors } = parseTxfFile(txfData); - - if (parseErrors.length > 0) { - console.warn("📦 TXF file parsing warnings:", parseErrors); - } - - if (parsedTokens.length === 0) { - return { success: false, error: "No valid tokens found in TXF file" }; - } - - // Validate each token - const validTokens: Token[] = []; - let skipped = 0; - - for (const token of parsedTokens) { - const result = await validationService.validateToken(token); - if (result.isValid && result.token) { - validTokens.push(result.token); - } else { - skipped++; - console.warn(`📦 Skipping invalid token ${token.id.slice(0, 8)}...: ${result.reason}`); - } - } - - console.log(`📦 Imported ${validTokens.length} tokens from TXF (${skipped} skipped)`); - - return { - success: true, - tokens: validTokens, - imported: validTokens.length, - skipped, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error("📦 TXF import failed:", errorMessage); - return { success: false, error: errorMessage }; - } - } -} - -// ========================================== -// IpfsTransport Singleton Getter -// ========================================== - -/** - * Get the IpfsTransport singleton instance - * This provides access to the pure IPFS/IPNS transport layer - * for use by InventorySyncService and other high-level services. - */ -let transportInstance: IpfsTransport | null = null; - -export function getIpfsTransport(): IpfsTransport { - if (!transportInstance) { - transportInstance = IpfsStorageService.getInstance(IdentityManager.getInstance()); - } - return transportInstance; -} diff --git a/src/components/wallet/L3/services/IpnsNametagFetcher.ts b/src/components/wallet/L3/services/IpnsNametagFetcher.ts deleted file mode 100644 index ef61840d..00000000 --- a/src/components/wallet/L3/services/IpnsNametagFetcher.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * IPNS Nametag Fetcher - * - * Fetches nametag data from IPFS via IPNS resolution without requiring - * full IpfsStorageService initialization. Uses dual-path racing for optimal speed: - * - * Two resolution methods raced in parallel: - * 1. Gateway path (/ipns/{name}?format=dag-json) - Fast (~30ms with cache) - * 2. Routing API (/api/v0/routing/get) - Slower (~5s) but more reliable - * - * Flow: - * 1. Derive IPNS name from private key - * 2. Race both methods - gateway path and routing API - * 3. Return first successful result - * 4. Parse TXF content and extract _nametag.name - */ - -import { deriveIpnsNameFromPrivateKey } from "./IpnsUtils"; -import { unmarshalIPNSRecord } from "ipns"; -import { getBackendGatewayUrl, getAllBackendGatewayUrls, IPNS_RESOLUTION_CONFIG } from "../../../../config/ipfs.config"; -import { validateTokenJson } from "../../../../utils/tokenValidation"; - -export interface IpnsNametagResult { - ipnsName: string; - nametag: string | null; - nametagData?: { - name: string; - token: object; - timestamp?: number; - format?: string; - }; - source: "http" | "none"; - error?: string; -} - -/** - * Fetch nametag from IPFS using IPNS resolution - * - * @param privateKeyHex - The secp256k1 private key in hex format - * @returns Result containing IPNS name and resolved nametag (if found) - */ -export async function fetchNametagFromIpns( - privateKeyHex: string -): Promise { - let ipnsName = ""; - - try { - // 1. Derive IPNS name from private key - ipnsName = await deriveIpnsNameFromPrivateKey(privateKeyHex); - - // 2. Try HTTP gateway (fast path) - const result = await fetchViaHttpGateway(ipnsName); - if (result) { - // CRITICAL: Validate token data before returning - // Do NOT fallback to empty object - that causes data corruption! - const token = result.data.token; - - if (!token || typeof token !== 'object' || Object.keys(token).length === 0) { - console.warn(`IPNS nametag "${result.name}" has missing/empty token data - rejecting`); - return { - ipnsName, - nametag: null, - source: "none", - error: "Nametag token data is missing or empty", - }; - } - - // Validate token structure - const validation = validateTokenJson(token, { - context: `IPNS nametag "${result.name}"`, - requireInclusionProof: false, // IPNS data might have stripped proofs - }); - - if (!validation.isValid) { - console.warn(`IPNS nametag "${result.name}" has invalid token structure:`, validation.errors); - return { - ipnsName, - nametag: null, - source: "none", - error: `Invalid token structure: ${validation.errors[0]}`, - }; - } - - return { - ipnsName, - nametag: result.name, - nametagData: { - name: result.name, - token: token, - timestamp: result.data.timestamp, - format: result.data.format, - }, - source: "http", - }; - } - - // No nametag found - return { ipnsName, nametag: null, source: "none" }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn(`Failed to fetch nametag for IPNS ${ipnsName}:`, errorMessage); - return { - ipnsName: ipnsName || "unknown", - nametag: null, - source: "none", - error: errorMessage, - }; - } -} - -/** - * Fetch with timeout support and JSON headers - * Returns the response regardless of status code (let caller handle it) - */ -async function fetchWithTimeout( - url: string, - timeoutMs: number -): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response = await fetch(url, { - signal: controller.signal, - headers: { - // Request JSON format for DAG-JSON content - Accept: "application/json, application/vnd.ipld.dag-json", - }, - }); - // Don't throw for non-200 - let caller handle IPNS resolution failures - return response; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Fetch nametag via HTTP gateway using dual-path racing - * - * Races both methods in parallel for each gateway: - * - Gateway path: /ipns/{name}?format=dag-json (fast ~30ms) - * - Routing API: /api/v0/routing/get (slow ~5s, more reliable) - * - * Returns first successful result from any gateway. - */ -async function fetchViaHttpGateway(ipnsName: string): Promise { - // Get all configured gateway URLs - const gatewayUrls = getAllBackendGatewayUrls(); - if (gatewayUrls.length === 0) { - const fallbackUrl = getBackendGatewayUrl(); - if (!fallbackUrl) { - throw new Error("No IPFS gateway configured"); - } - gatewayUrls.push(fallbackUrl); - } - - // Race both methods across all gateways - // Create promise for each gateway that races gateway path vs routing API - const racePromises = gatewayUrls.flatMap((gatewayUrl) => [ - // Gateway path (fast) - tryGatewayPath(gatewayUrl, ipnsName).catch(() => null), - // Routing API (slow but reliable) - tryRoutingApi(gatewayUrl, ipnsName).catch(() => null), - ]); - - // Use Promise.any to return first successful result - try { - const result = await Promise.any( - racePromises.map(async (p) => { - const result = await p; - if (result === null) { - throw new Error("No result"); - } - return result; - }) - ); - return result; - } catch { - // All promises rejected - no result found - return null; - } -} - -interface NametagFetchResult { - name: string; - data: { - token?: object; - timestamp?: number; - format?: string; - }; -} - -/** - * Try fetching from a single gateway using IPNS gateway path (fast path) - * Uses /ipns/{name}?format=dag-json which lets the gateway resolve IPNS - * and return DAG-JSON content (since @helia/json stores in this format) - */ -async function tryGatewayPath( - gatewayUrl: string, - ipnsName: string -): Promise { - // Use IPNS gateway path with dag-json format - // The format parameter is needed because @helia/json stores content as DAG-JSON - const ipnsUrl = `${gatewayUrl}/ipns/${ipnsName}?format=dag-json`; - - let contentResponse: Response; - try { - contentResponse = await fetchWithTimeout(ipnsUrl, IPNS_RESOLUTION_CONFIG.gatewayPathTimeoutMs); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - throw new Error("IPNS gateway path timeout"); - } - throw error; - } - - // Check response status - 404/500 means IPNS name not found or resolution failed - if (!contentResponse.ok) { - return null; - } - - // Parse TXF content and extract nametag - let txfData; - try { - txfData = await contentResponse.json(); - } catch { - return null; - } - - // TXF format has _nametag at top level - if (txfData._nametag && typeof txfData._nametag.name === "string") { - // Return full nametag data for localStorage persistence - return { - name: txfData._nametag.name, - data: txfData._nametag, - }; - } - - return null; -} - -/** - * Try fetching from a single gateway using routing API (slow but reliable) - * Uses /api/v0/routing/get to get raw IPNS record, then fetches content via CID - */ -async function tryRoutingApi( - gatewayUrl: string, - ipnsName: string -): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - IPNS_RESOLUTION_CONFIG.perGatewayTimeoutMs - ); - - try { - // 1. Resolve IPNS to CID via routing API - const routingUrl = `${gatewayUrl}/api/v0/routing/get?arg=/ipns/${ipnsName}`; - const routingResponse = await fetch(routingUrl, { - method: "POST", - signal: controller.signal, - }); - - if (!routingResponse.ok) { - return null; - } - - // Parse routing response to get IPNS record - const json = await routingResponse.json() as { Extra?: string }; - if (!json.Extra) { - return null; - } - - // Decode base64 Extra field to get raw IPNS record - const recordData = Uint8Array.from(atob(json.Extra), c => c.charCodeAt(0)); - const record = unmarshalIPNSRecord(recordData); - - // Extract CID from value path - const cidMatch = record.value.match(/^\/ipfs\/(.+)$/); - if (!cidMatch) { - return null; - } - - const cid = cidMatch[1]; - - // 2. Fetch content via CID - const contentUrl = `${gatewayUrl}/ipfs/${cid}?format=dag-json`; - const contentResponse = await fetch(contentUrl, { - signal: controller.signal, - headers: { - Accept: "application/vnd.ipld.dag-json, application/json", - }, - }); - - if (!contentResponse.ok) { - return null; - } - - // Parse TXF content and extract nametag - let txfData; - try { - txfData = await contentResponse.json(); - } catch { - return null; - } - - // TXF format has _nametag at top level - if (txfData._nametag && typeof txfData._nametag.name === "string") { - return { - name: txfData._nametag.name, - data: txfData._nametag, - }; - } - - return null; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - throw new Error("IPNS routing API timeout"); - } - throw error; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Batch fetch nametags for multiple private keys in parallel - * - * @param privateKeys - Array of private keys in hex format - * @returns Array of results (same order as input) - */ -export async function fetchNametagsForKeys( - privateKeys: string[] -): Promise { - const promises = privateKeys.map((key) => fetchNametagFromIpns(key)); - return Promise.all(promises); -} diff --git a/src/components/wallet/L3/services/IpnsUtils.ts b/src/components/wallet/L3/services/IpnsUtils.ts deleted file mode 100644 index 7cbb8ead..00000000 --- a/src/components/wallet/L3/services/IpnsUtils.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * IPNS Name Derivation Utility - * - * Derives IPNS names from secp256k1 private keys without requiring - * full Helia/IPFS initialization. Uses the same derivation logic - * as IpfsStorageService for compatibility. - * - * Derivation path: - * secp256k1 privateKey (hex) - * → HKDF(sha256, key, info="ipfs-storage-ed25519-v1", 32 bytes) - * → Ed25519 key pair - * → libp2p PeerId - * → IPNS name (e.g., "12D3KooW...") - */ - -import { hkdf } from "@noble/hashes/hkdf"; -import { sha256 } from "@noble/hashes/sha256"; -import { generateKeyPairFromSeed } from "@libp2p/crypto/keys"; -import { peerIdFromPrivateKey } from "@libp2p/peer-id"; - -// Must match IpfsStorageService.HKDF_INFO for compatible IPNS names -const HKDF_INFO = "ipfs-storage-ed25519-v1"; - -/** - * Convert hex string to Uint8Array - */ -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substr(i * 2, 2), 16); - } - return bytes; -} - -/** - * Derive IPNS name from a secp256k1 private key - * - * @param privateKeyHex - The secp256k1 private key in hex format - * @returns The IPNS name (libp2p PeerId string, e.g., "12D3KooW...") - */ -export async function deriveIpnsNameFromPrivateKey( - privateKeyHex: string -): Promise { - // 1. Convert private key from hex to bytes - const walletSecret = hexToBytes(privateKeyHex); - - // 2. Derive Ed25519 key material using HKDF - const derivedKey = hkdf( - sha256, - walletSecret, - undefined, // no salt for deterministic derivation - HKDF_INFO, - 32 - ); - - // 3. Generate Ed25519 key pair from the derived key - const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey); - - // 4. Convert to libp2p PeerId which gives us the IPNS name - const peerId = peerIdFromPrivateKey(keyPair); - - return peerId.toString(); -} diff --git a/src/components/wallet/L3/services/NametagService.ts b/src/components/wallet/L3/services/NametagService.ts deleted file mode 100644 index de31121d..00000000 --- a/src/components/wallet/L3/services/NametagService.ts +++ /dev/null @@ -1,505 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Token } from "@unicitylabs/state-transition-sdk/lib/token/Token"; -import { IdentityManager } from "./IdentityManager"; -import { SigningService } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService"; -import { TokenType } from "@unicitylabs/state-transition-sdk/lib/token/TokenType"; -import { NostrService } from "./NostrService"; -import { ProxyAddress } from "@unicitylabs/state-transition-sdk/lib/address/ProxyAddress"; -import { ServiceProvider } from "./ServiceProvider"; -import { TokenId } from "@unicitylabs/state-transition-sdk/lib/token/TokenId"; -import { MintCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment"; -import type { DirectAddress } from "@unicitylabs/state-transition-sdk/lib/address/DirectAddress"; -import { MintTransactionData } from "@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData"; -import { waitInclusionProof } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils"; -import { UnmaskedPredicate } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate"; -import { HashAlgorithm } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm"; -import { TokenState } from "@unicitylabs/state-transition-sdk/lib/token/TokenState"; -import type { NametagData } from "./types/TxfTypes"; -import { - getNametagForAddress, - setNametagForAddress, -} from "./InventorySyncService"; -import { OutboxRepository } from "../../../../repositories/OutboxRepository"; -import { createMintOutboxEntry, type MintOutboxEntry } from "./types/OutboxTypes"; -import { IpfsStorageService, SyncPriority } from "./IpfsStorageService"; -import { normalizeSdkTokenToStorage } from "./TxfSerializer"; -import type { StateTransitionClient } from "@unicitylabs/state-transition-sdk/lib/StateTransitionClient"; -import type { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/transaction/InclusionProof"; - -const UNICITY_TOKEN_TYPE_HEX = - "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"; - -/** - * Wait for inclusion proof WITHOUT verification (dev mode only). - * This polls the aggregator until a proof is available, but skips trust base verification. - */ -async function waitInclusionProofNoVerify( - client: StateTransitionClient, - commitment: MintCommitment, - signal: AbortSignal = AbortSignal.timeout(10000), - interval: number = 1000 -): Promise { - while (!signal.aborted) { - try { - const response = await client.getInclusionProof(commitment.requestId); - if (response.inclusionProof) { - console.warn("⚠️ Returning inclusion proof WITHOUT verification (dev mode)"); - return response.inclusionProof; - } - } catch (err: any) { - // 404 means proof not ready yet, keep polling - if (err?.status !== 404) { - throw err; - } - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - throw new Error("Timeout waiting for inclusion proof"); -} - -export type MintResult = - | { status: "success"; token: Token } - | { status: "warning"; token: Token; message: string } - | { status: "error"; message: string }; - -export class NametagService { - private static instance: NametagService; - - private identityManager: IdentityManager; - - private constructor(identityManager: IdentityManager) { - this.identityManager = identityManager; - } - - static getInstance(identityManager: IdentityManager): NametagService { - if (!NametagService.instance) { - NametagService.instance = new NametagService(identityManager); - } - return NametagService.instance; - } - - async isNametagAvailable(nametag: string): Promise { - // Skip verification in dev mode if trust base verification is disabled - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.warn("⚠️ Skipping nametag availability check (trust base verification disabled)"); - return true; - } - - const nametagTokenId = await TokenId.fromNameTag(nametag); - const isAlreadyMinted = await ServiceProvider.stateTransitionClient.isMinted( - ServiceProvider.getRootTrustBase(), - nametagTokenId - ); - return !isAlreadyMinted; - } - - async mintNametagAndPublish(nametag: string): Promise { - try { - const cleanTag = nametag.replace("@unicity", "").replace("@", "").trim(); - console.log(`Starting mint process for: ${cleanTag}`); - - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) - return { status: "error", message: "Wallet identity not found" }; - - // Check if identity already has a nametag (prevent duplicates) - const existingNametag = getNametagForAddress(identity.address); - if (existingNametag) { - return { - status: "error", - message: `Identity already has a nametag: ${existingNametag.name}`, - }; - } - - // Check if there's already a pending mint for this nametag (prevent duplicate mints) - const outboxRepo = OutboxRepository.getInstance(); - if (outboxRepo.isNametagMintInProgress(cleanTag)) { - return { - status: "error", - message: `A mint for nametag "${cleanTag}" is already in progress`, - }; - } - - const secret = Buffer.from(identity.privateKey, "hex"); - - const ownerAddress = await this.identityManager.getWalletAddress(); - if (!ownerAddress) - return { status: "error", message: "Failed to derive owner address" }; - - const sdkToken = await this.mintNametagOnBlockchain( - cleanTag, - ownerAddress, - secret - ); - if (!sdkToken) { - return { - status: "error", - message: "Failed to mint nametag on blockchain", - }; - } - - await this.saveNametagToStorage(cleanTag, sdkToken); - - try { - const nostr = NostrService.getInstance(this.identityManager); - await nostr.start(); - - const proxyAddress = await ProxyAddress.fromNameTag(cleanTag); - console.log(`Publishing binding: ${cleanTag} -> ${proxyAddress}`); - - const published = await nostr.publishNametagBinding( - cleanTag, - proxyAddress.address - ); - - if (published) { - return { status: "success", token: sdkToken }; - } else { - return { - status: "warning", - token: sdkToken, - message: "Minted locally, but Nostr publish failed", - }; - } - } catch (e: any) { - console.error("Nostr error", e); - return { - status: "warning", - token: sdkToken, - message: `Nostr error: ${e.message}`, - }; - } - } catch (error) { - console.error("Critical error in mintNametagAndPublish", error); - return { status: "error", message: "Unknown error" }; - } - } - - /** - * Mint a nametag on the blockchain using the safe outbox pattern. - * - * CRITICAL SAFETY: The salt and commitment data are saved to the outbox - * and synced to IPFS BEFORE submitting to the aggregator. This ensures - * that if the app crashes after submission, the mint can be recovered. - * - * Flow: - * 1. Generate salt and create MintTransactionData + MintCommitment - * 2. Save to outbox IMMEDIATELY (before network calls) - * 3. Sync to IPFS and wait for success (abort if fails) - * 4. Submit to aggregator (with retries) - * 5. Wait for inclusion proof - * 6. Create final token with proof - * 7. Update outbox and save to storage - */ - private async mintNametagOnBlockchain( - nametag: string, - ownerAddress: DirectAddress, - secret: Buffer - ): Promise | null> { - const outboxRepo = OutboxRepository.getInstance(); - let outboxEntryId: string | null = null; - - try { - const client = ServiceProvider.stateTransitionClient; - const rootTrustBase = ServiceProvider.getRootTrustBase(); - - const nametagTokenId = await TokenId.fromNameTag(nametag); - const nametagTokenType = new TokenType( - Buffer.from(UNICITY_TOKEN_TYPE_HEX, "hex") - ); - - // 1. Generate salt ONCE (CRITICAL: must be saved before any network calls) - const salt = Buffer.alloc(32); - window.crypto.getRandomValues(salt); - - // 2. Create mint transaction data with the salt - const mintData = await MintTransactionData.createFromNametag( - nametag, - nametagTokenType, - ownerAddress, - salt, - ownerAddress - ); - - // 3. Create commitment (derives requestId) - const commitment = await MintCommitment.create(mintData); - - // 4. ⭐ SAVE TO OUTBOX BEFORE ANY NETWORK CALLS - // Note: ownerAddress is stored in mintDataJson, so we just store its string representation for reference - const outboxEntry: MintOutboxEntry = createMintOutboxEntry( - "MINT_NAMETAG", - UNICITY_TOKEN_TYPE_HEX, - ownerAddress.address, // Store the address string - salt.toString("hex"), - commitment.requestId.toString(), - JSON.stringify(mintData.toJSON()), - nametag - ); - - outboxRepo.addMintEntry(outboxEntry); - outboxEntryId = outboxEntry.id; - console.log(`📦 Saved mint commitment to outbox: ${outboxEntryId}`); - - // 5. ⭐ SYNC TO IPFS BEFORE SUBMITTING TO AGGREGATOR - // Uses HIGH priority so it jumps ahead of auto-syncs in the queue - try { - const ipfsService = IpfsStorageService.getInstance(this.identityManager); - await ipfsService.syncNow({ - forceIpnsPublish: true, - priority: SyncPriority.HIGH, - timeout: 60000, - callerContext: 'nametag-mint-pre-submit', - }); - outboxRepo.updateMintEntry(outboxEntryId, { status: "READY_TO_SUBMIT" }); - console.log(`📦 IPFS sync complete, ready to submit`); - } catch (ipfsError) { - console.error("IPFS sync failed, aborting mint:", ipfsError); - outboxRepo.removeMintEntry(outboxEntryId); - throw new Error("IPFS sync failed - mint aborted for safety"); - } - - // 6. Submit to aggregator (with retries - same commitment can be resubmitted) - const MAX_RETRIES = 3; - let submitSuccess = false; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - console.log(`Submitting commitment (attempt ${attempt})...`); - const response = await client.submitMintCommitment(commitment); - - if (response.status === "SUCCESS" || response.status === "REQUEST_ID_EXISTS") { - console.log(`Commitment ${response.status === "REQUEST_ID_EXISTS" ? "already exists" : "success"}!`); - submitSuccess = true; - break; - } else { - console.warn(`Commitment failed: ${response.status}`); - if (attempt === MAX_RETRIES) { - throw new Error(`Failed after ${MAX_RETRIES} attempts: ${response.status}`); - } - await new Promise((r) => setTimeout(r, 1000 * attempt)); - } - } catch (error) { - console.error(`Attempt ${attempt} error`, error); - if (attempt === MAX_RETRIES) throw error; - await new Promise((r) => setTimeout(r, 1000 * attempt)); - } - } - - if (!submitSuccess) { - throw new Error("Failed to submit commitment after retries"); - } - - outboxRepo.updateMintEntry(outboxEntryId, { status: "SUBMITTED" }); - console.log("Waiting for inclusion proof..."); - - // 7. Wait for inclusion proof - const inclusionProof = ServiceProvider.isTrustBaseVerificationSkipped() - ? await waitInclusionProofNoVerify(client, commitment) - : await waitInclusionProof(rootTrustBase, client, commitment); - - // 8. Create genesis transaction from proof - const genesisTransaction = commitment.toTransaction(inclusionProof); - - // Update outbox with proof - outboxRepo.updateMintEntry(outboxEntryId, { - status: "PROOF_RECEIVED", - inclusionProofJson: JSON.stringify(inclusionProof.toJSON()), - mintTransactionJson: JSON.stringify(genesisTransaction.toJSON()), - }); - - // 9. Create final token - const signingService = await SigningService.createFromSecret(secret); - const nametagPredicate = await UnmaskedPredicate.create( - nametagTokenId, - nametagTokenType, - signingService, - HashAlgorithm.SHA256, - salt - ); - - let token: Token; - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.warn("⚠️ Creating token WITHOUT verification (dev mode)"); - const tokenState = new TokenState(nametagPredicate, null); - const tokenJson = { - version: "2.0", - state: tokenState.toJSON(), - genesis: genesisTransaction.toJSON(), - transactions: [], - nametags: [], - }; - token = await Token.fromJSON(tokenJson); - } else { - token = await Token.mint( - rootTrustBase, - new TokenState(nametagPredicate, null), - genesisTransaction - ); - } - - // 10. Update outbox with final token and mark complete - outboxRepo.updateMintEntry(outboxEntryId, { - status: "COMPLETED", - tokenJson: JSON.stringify(normalizeSdkTokenToStorage(token.toJSON())), - }); - - console.log(`✅ Nametag minted: ${nametag}`); - return token; - } catch (error) { - console.error("Minting on blockchain failed", error); - if (outboxEntryId) { - const entry = outboxRepo.getMintEntry(outboxEntryId); - outboxRepo.updateMintEntry(outboxEntryId, { - lastError: error instanceof Error ? error.message : String(error), - retryCount: (entry?.retryCount || 0) + 1, - }); - } - return null; - } - } - - private async saveNametagToStorage(nametag: string, token: Token) { - const nametagData: NametagData = { - name: nametag, - token: token.toJSON(), - timestamp: Date.now(), - format: "txf", - version: "2.0", - }; - - // Ensure wallet is initialized for this identity - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.error("Cannot save nametag: no identity available"); - return; - } - - // Store nametag via InventorySyncService (per-identity, per TOKEN_INVENTORY_SPEC.md Section 6.1) - setNametagForAddress(identity.address, nametagData); - } - - async getActiveNametag(): Promise { - // Get nametag via InventorySyncService (per-identity, per TOKEN_INVENTORY_SPEC.md Section 6.1) - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) return null; - - const nametag = getNametagForAddress(identity.address); - return nametag?.name || null; - } - - /** - * Refresh the nametag token's inclusion proof from the aggregator. - * This is needed before using the nametag in token finalization, - * as the SDK verifies the nametag's proof against the current root trust base. - * - * @returns The refreshed token, or null if refresh failed - */ - async refreshNametagProof(): Promise | null> { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.log("📦 No identity - cannot refresh nametag proof"); - return null; - } - - const nametagData = getNametagForAddress(identity.address); - if (!nametagData || !nametagData.token) { - console.log("📦 No nametag token to refresh"); - return null; - } - - const nametagTxf = nametagData.token as any; - - // Validate token structure - if (!nametagTxf.genesis?.data?.salt) { - console.error("📦 Nametag token missing genesis data or salt"); - return null; - } - - try { - console.log(`📦 Refreshing nametag proof for "${nametagData.name}"...`); - - // Reconstruct the MintCommitment to get the correct requestId - const genesisData = nametagTxf.genesis.data; - const mintDataJson = { - tokenId: genesisData.tokenId, - tokenType: genesisData.tokenType, - tokenData: genesisData.tokenData || null, - coinData: genesisData.coinData && genesisData.coinData.length > 0 ? genesisData.coinData : null, - recipient: genesisData.recipient, - salt: genesisData.salt, - recipientDataHash: genesisData.recipientDataHash, - reason: genesisData.reason ? JSON.parse(genesisData.reason) : null, - }; - - const mintTransactionData = await MintTransactionData.fromJSON(mintDataJson); - const commitment = await MintCommitment.create(mintTransactionData); - const requestId = commitment.requestId; - - console.log(`📦 Fetching fresh proof for requestId: ${requestId.toJSON().slice(0, 16)}...`); - - // Fetch fresh proof from aggregator - const client = ServiceProvider.stateTransitionClient; - const response = await client.getInclusionProof(requestId); - - if (!response.inclusionProof) { - console.warn("📦 No inclusion proof available from aggregator"); - // Return the existing token without update - return await Token.fromJSON(nametagTxf); - } - - // Check if it's an inclusion proof (has authenticator) vs exclusion proof - if (response.inclusionProof.authenticator === null) { - console.warn("📦 Got exclusion proof - nametag may need re-minting"); - return await Token.fromJSON(nametagTxf); - } - - // Update the token with fresh proof - const newProofJson = response.inclusionProof.toJSON(); - nametagTxf.genesis.inclusionProof = newProofJson; - - // Save updated token back to storage via InventorySyncService - setNametagForAddress(identity.address, { ...nametagData, token: nametagTxf }); - - console.log(`✅ Nametag proof refreshed successfully`); - - // Return the updated token - return await Token.fromJSON(nametagTxf); - } catch (error) { - console.error("📦 Failed to refresh nametag proof:", error); - // Return existing token on error - let caller decide how to handle - try { - return await Token.fromJSON(nametagTxf); - } catch { - return null; - } - } - } - - /** - * Get the nametag token for the current identity - * Returns at most one token (one nametag per identity) - */ - async getNametagToken(): Promise | null> { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) return null; - - const nametagData = getNametagForAddress(identity.address); - if (!nametagData) return null; - - try { - return await Token.fromJSON(nametagData.token); - } catch (e) { - console.error("Failed to parse nametag token", e); - return null; - } - } - - /** - * Get all nametag tokens for the current identity - * @deprecated Use getNametagToken() instead - each identity has only one nametag - */ - async getAllNametagTokens(): Promise[]> { - const token = await this.getNametagToken(); - return token ? [token] : []; - } -} diff --git a/src/components/wallet/L3/services/NostrPinPublisher.ts b/src/components/wallet/L3/services/NostrPinPublisher.ts deleted file mode 100644 index 52faf181..00000000 --- a/src/components/wallet/L3/services/NostrPinPublisher.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * NostrPinPublisher - * - * Listens for IPFS storage events and publishes CID announcements - * to Nostr relays. Pin services subscribed to these relays will - * automatically pin the announced content. - * - * Event flow: - * 1. IpfsStorageService stores data to IPFS - * 2. Emits "ipfs-storage-event" with type "storage:completed" - * 3. NostrPinPublisher catches event and publishes to Nostr - * 4. Remote pin services receive and pin the CID - */ - -import { NOSTR_PIN_CONFIG } from "../../../../config/nostrPin.config"; -import { NostrService } from "./NostrService"; -import type { StorageEvent } from "./IpfsStorageService"; - -export class NostrPinPublisher { - private static instance: NostrPinPublisher | null = null; - private isStarted = false; - private boundHandler: ((e: Event) => void) | null = null; - - private constructor() {} - - /** - * Get singleton instance - */ - static getInstance(): NostrPinPublisher { - if (!NostrPinPublisher.instance) { - NostrPinPublisher.instance = new NostrPinPublisher(); - } - return NostrPinPublisher.instance; - } - - /** - * Start listening for IPFS storage events - */ - async start(): Promise { - if (this.isStarted) { - return; - } - - if (!NOSTR_PIN_CONFIG.enabled) { - if (NOSTR_PIN_CONFIG.debug) { - console.log("📌 NostrPinPublisher disabled by config"); - } - return; - } - - this.boundHandler = (e: Event) => { - this.handleStorageEvent(e as CustomEvent); - }; - - window.addEventListener("ipfs-storage-event", this.boundHandler); - this.isStarted = true; - - if (NOSTR_PIN_CONFIG.debug) { - console.log("📌 NostrPinPublisher started - listening for IPFS storage events"); - } - } - - /** - * Stop listening for events - */ - stop(): void { - if (!this.isStarted || !this.boundHandler) { - return; - } - - window.removeEventListener("ipfs-storage-event", this.boundHandler); - this.boundHandler = null; - this.isStarted = false; - - if (NOSTR_PIN_CONFIG.debug) { - console.log("📌 NostrPinPublisher stopped"); - } - } - - /** - * Check if publisher is running - */ - isRunning(): boolean { - return this.isStarted; - } - - /** - * Handle IPFS storage event - */ - private async handleStorageEvent(e: CustomEvent): Promise { - const event = e.detail; - - // Only process successful storage completions - if (event.type !== "storage:completed") { - return; - } - - const cid = event.data?.cid; - if (!cid) { - if (NOSTR_PIN_CONFIG.debug) { - console.log("📌 Storage event without CID, skipping"); - } - return; - } - - const ipnsName = event.data?.ipnsName; - const tokenCount = event.data?.tokenCount; - - if (NOSTR_PIN_CONFIG.debug) { - console.log(`📌 Publishing pin request for CID: ${cid.slice(0, 16)}...`); - } - - try { - await this.publishPinRequest(cid, ipnsName, tokenCount); - } catch (error) { - console.error("📌 Failed to publish pin request:", error); - } - } - - /** - * Publish CID pin request to Nostr - */ - private async publishPinRequest( - cid: string, - ipnsName?: string, - tokenCount?: number - ): Promise { - const nostrService = NostrService.getInstance(); - - // Build tags for NIP-78 app-specific event - const tags: string[][] = [ - ["d", NOSTR_PIN_CONFIG.dTag], - ["cid", cid], - ]; - - // Add optional IPNS name tag - if (ipnsName) { - tags.push(["ipns", ipnsName]); - } - - // Content can include metadata (optional) - const content = tokenCount !== undefined - ? JSON.stringify({ tokenCount, timestamp: Date.now() }) - : ""; - - const eventId = await nostrService.publishAppDataEvent( - NOSTR_PIN_CONFIG.eventKind, - tags, - content - ); - - if (eventId) { - if (NOSTR_PIN_CONFIG.debug) { - console.log(`📌 Pin request published: ${eventId.slice(0, 8)}... for CID ${cid.slice(0, 16)}...`); - } - } else { - console.warn("📌 Failed to publish pin request to Nostr"); - } - } -} diff --git a/src/components/wallet/L3/services/NostrService.ts b/src/components/wallet/L3/services/NostrService.ts deleted file mode 100644 index 8de93202..00000000 --- a/src/components/wallet/L3/services/NostrService.ts +++ /dev/null @@ -1,1256 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - NostrClient, - NostrKeyManager, - EventKinds, - Filter, - TokenTransferProtocol, - Event, - PaymentRequestProtocol, -} from "@unicitylabs/nostr-js-sdk"; - -// NIP-17 Private Message interface (from SDK) -interface PrivateMessage { - eventId: string; - senderPubkey: string; - recipientPubkey: string; - content: string; - timestamp: number; - kind: number; - replyToEventId?: string; -} -import { ChatRepository } from "../../../chat/data/ChatRepository"; -import { - ChatMessage, - MessageStatus, - MessageType, -} from "../../../chat/data/models"; -import { IdentityManager } from "./IdentityManager"; -import { Buffer } from "buffer"; -import { Token } from "@unicitylabs/state-transition-sdk/lib/token/Token"; -import { TransferTransaction } from "@unicitylabs/state-transition-sdk/lib/transaction/TransferTransaction"; -import { AddressScheme } from "@unicitylabs/state-transition-sdk/lib/address/AddressScheme"; -import { NametagService } from "./NametagService"; -import { ProxyAddress } from "@unicitylabs/state-transition-sdk/lib/address/ProxyAddress"; -import { addToken as addTokenToInventory } from "./InventorySyncService"; -import { deriveIpnsNameFromPrivateKey } from "./IpnsUtils"; -import { SigningService } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService"; -import { UnmaskedPredicate } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate"; -import { HashAlgorithm } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm"; -import { TokenState } from "@unicitylabs/state-transition-sdk/lib/token/TokenState"; -import { ServiceProvider } from "./ServiceProvider"; -import { RegistryService } from "./RegistryService"; -import { - PaymentRequestStatus, - TokenStatus, - Token as UiToken, - type IncomingPaymentRequest, -} from "../data/model"; -import { v4 as uuidv4 } from "uuid"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; -import { normalizeSdkTokenToStorage } from "./TxfSerializer"; -import { NOSTR_CONFIG } from "../../../../config/nostr.config"; -import { recordActivity } from "../../../../services/ActivityService"; -import { addReceivedTransaction } from "../../../../services/TransactionHistoryService"; - -export class NostrService { - private static instance: NostrService; - private client: NostrClient | null = null; - private identityManager: IdentityManager; - private isConnected: boolean = false; - private isConnecting: boolean = false; - private connectPromise: Promise | null = null; - private paymentRequests: IncomingPaymentRequest[] = []; - private chatRepository: ChatRepository; - private dmListeners: ((message: ChatMessage) => void)[] = []; - - private processedEventIds: Set = new Set(); // Persistent storage for all processed events - - private constructor(identityManager: IdentityManager) { - this.identityManager = identityManager; - this.chatRepository = ChatRepository.getInstance(); - this.loadProcessedEvents(); - } - - static getInstance(identityManager?: IdentityManager): NostrService { - if (!NostrService.instance) { - const manager = identityManager || IdentityManager.getInstance(); - NostrService.instance = new NostrService(manager); - } - return NostrService.instance; - } - - async start() { - // Already connected - if (this.isConnected) return; - - // Connection in progress - wait for it - if (this.isConnecting && this.connectPromise) { - return this.connectPromise; - } - - // Start connection - this.isConnecting = true; - this.connectPromise = this.doConnect(); - - try { - await this.connectPromise; - } finally { - this.isConnecting = false; - this.connectPromise = null; - } - } - - /** - * Reset the NostrService connection to reinitialize with current identity. - * Call this when wallet changes (new wallet created or restored) to ensure - * the correct keypair is used for encryption/decryption. - */ - async reset(): Promise { - console.log("🔄 Resetting NostrService connection..."); - - // Disconnect existing client - if (this.client) { - try { - await this.client.disconnect(); - } catch (err) { - console.warn("Error disconnecting Nostr client:", err); - } - this.client = null; - } - - this.isConnected = false; - this.isConnecting = false; - this.connectPromise = null; - - console.log("✅ NostrService reset complete, ready for reconnection"); - } - - private async doConnect(): Promise { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) throw new Error("No identity found for Nostr"); - - const secretKey = Buffer.from(identity.privateKey, "hex"); - const keyManager = NostrKeyManager.fromPrivateKey(secretKey); - - this.client = new NostrClient(keyManager); - - console.log("📡 Connecting to Nostr relays..."); - try { - await this.client.connect(...NOSTR_CONFIG.RELAYS); - this.isConnected = true; - console.log("✅ Connected to Nostr relays"); - - this.subscribeToPrivateEvents(keyManager.getPublicKeyHex()); - } catch (error) { - console.error("❌ Failed to connect to Nostr", error); - } - } - - private subscribeToPrivateEvents(publicKey: string) { - if (!this.client) return; - - // Subscribe to wallet events (token transfers, payment requests) with since filter - const lastSync = this.getOrInitLastSync(); - const walletFilter = new Filter(); - walletFilter.kinds = [ - EventKinds.TOKEN_TRANSFER, - EventKinds.PAYMENT_REQUEST, - ]; - walletFilter["#p"] = [publicKey]; - walletFilter.since = lastSync; - - this.client.subscribe(walletFilter, { - onEvent: (event) => this.handleSubscriptionEvent(event, true), - onEndOfStoredEvents: () => { - console.log("End of stored wallet events"); - }, - }); - - // Subscribe to chat events (NIP-17 gift wrap) without since filter - // Chat messages are deduplicated via ChatRepository (localStorage) - const chatFilter = new Filter(); - chatFilter.kinds = [EventKinds.GIFT_WRAP]; - chatFilter["#p"] = [publicKey]; - - this.client.subscribe(chatFilter, { - onEvent: (event) => this.handleSubscriptionEvent(event, false), - onEndOfStoredEvents: () => { - console.log("End of stored chat events"); - }, - }); - } - - private async handleSubscriptionEvent(event: Event, isWalletEvent: boolean) { - // Deduplicate by event ID (persistent storage - works across page reloads) - if (this.isEventProcessed(event.id)) { - console.log(`⏭️ Event ${event.id.slice(0, 8)} already processed (persistent check), skipping`); - return; - } - - // For wallet events, skip old events based on lastSync - if (isWalletEvent) { - const currentLastSync = this.getOrInitLastSync(); - if (event.created_at < currentLastSync) { - console.log( - `⏭️ Skipping old event (Time: ${event.created_at} <= Sync: ${currentLastSync})` - ); - return; - } - } - - console.log(`📥 Processing ${isWalletEvent ? 'wallet' : 'chat'} event kind=${event.kind}`); - - // Process the event - now returns token for TOKEN_TRANSFER - const result = await this.handleIncomingEvent(event); - - if (result.success) { - // For token transfers, use background loop for batched sync - if (event.kind === EventKinds.TOKEN_TRANSFER && result.token) { - try { - const { InventoryBackgroundLoopsManager } = await import("./InventoryBackgroundLoops"); - const loopsManager = InventoryBackgroundLoopsManager.getInstance(this.identityManager); - - // Ensure loops are initialized - if (!loopsManager.isReady()) { - await loopsManager.initialize(); - } - - const receiveLoop = loopsManager.getReceiveLoop(); - - // Set callback to mark events as processed (only need to do once) - receiveLoop.setEventProcessedCallback((eventId) => { - this.markEventAsProcessed(eventId); - }); - - // Queue token for batched sync - await receiveLoop.queueIncomingToken(result.token, event.id, event.pubkey); - - console.log(`📥 Token ${event.id.slice(0, 8)} queued for batch sync`); - } catch (err) { - console.error(`Failed to queue token for batch sync:`, err); - // Fallback: mark as processed anyway since token is saved locally - this.markEventAsProcessed(event.id); - } - } else { - // For non-token events (chat, payment requests), mark as processed immediately - this.markEventAsProcessed(event.id); - console.log(`✅ Event ${event.id.slice(0, 8)} processed`); - } - } else { - console.warn(`⚠️ Event ${event.id.slice(0, 8)} processing failed, will retry on next connect`); - } - - // Update lastSync for wallet events - if (isWalletEvent && result.success) { - this.updateLastSync(event.created_at); - } - } - - private getOrInitLastSync(): number { - const saved = localStorage.getItem(STORAGE_KEYS.NOSTR_LAST_SYNC); - if (saved) { - return parseInt(saved); - } else { - // For new wallets, set lastSync to 5 minutes ago to catch any tokens - // that were sent during wallet creation (e.g., from faucet) - const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 300; - localStorage.setItem(STORAGE_KEYS.NOSTR_LAST_SYNC, fiveMinutesAgo.toString()); - return fiveMinutesAgo; - } - } - - private updateLastSync(timestamp: number) { - const current = this.getOrInitLastSync(); - if (timestamp > current) { - localStorage.setItem(STORAGE_KEYS.NOSTR_LAST_SYNC, timestamp.toString()); - } - } - - // Note: waitForSyncCompletion() removed - SyncQueue handles queuing automatically - - private loadProcessedEvents() { - try { - const saved = localStorage.getItem(STORAGE_KEYS.NOSTR_PROCESSED_EVENTS); - if (saved) { - const ids = JSON.parse(saved) as string[]; - this.processedEventIds = new Set(ids); - console.log(`📋 Loaded ${ids.length} processed event IDs from persistent storage`); - } - } catch (error) { - console.error("Failed to load processed events", error); - this.processedEventIds = new Set(); - } - } - - private saveProcessedEvents() { - try { - let ids = Array.from(this.processedEventIds); - - // Keep only the last NOSTR_CONFIG.MAX_PROCESSED_EVENTS entries (FIFO) - if (ids.length > NOSTR_CONFIG.MAX_PROCESSED_EVENTS) { - ids = ids.slice(-NOSTR_CONFIG.MAX_PROCESSED_EVENTS); - this.processedEventIds = new Set(ids); - } - - localStorage.setItem(STORAGE_KEYS.NOSTR_PROCESSED_EVENTS, JSON.stringify(ids)); - } catch (error) { - console.error("Failed to save processed events", error); - } - } - - private markEventAsProcessed(eventId: string) { - this.processedEventIds.add(eventId); - - // If exceeding limit, remove oldest entry - if (this.processedEventIds.size > NOSTR_CONFIG.MAX_PROCESSED_EVENTS) { - const firstId = this.processedEventIds.values().next().value; - if (firstId) { - this.processedEventIds.delete(firstId); - } - } - - this.saveProcessedEvents(); - } - - private isEventProcessed(eventId: string): boolean { - return this.processedEventIds.has(eventId); - } - - private async handleIncomingEvent(event: Event): Promise<{ success: boolean; token?: UiToken }> { - console.log( - `Received event kind=${event.kind} from=${event.pubkey.slice(0, 16)}` - ); - if (event.kind === EventKinds.TOKEN_TRANSFER) { - const token = await this.handleTokenTransfer(event); - return { success: token !== null, token: token || undefined }; - } else if (event.kind === EventKinds.GIFT_WRAP) { - console.log("Received NIP-17 gift-wrapped message"); - this.handleGiftWrappedMessage(event); - return { success: true }; // Chat messages always succeed (stored in local chat repo) - } else if (event.kind === EventKinds.PAYMENT_REQUEST) { - this.handlePaymentRequest(event); - return { success: true }; // Payment requests are in-memory only - } else { - console.log(`Unhandled event kind - ${event.kind}`); - return { success: true }; // Unknown events - don't retry - } - } - - private async handlePaymentRequest(event: Event) { - try { - const keyManager = await this.getKeyManager(); - if (!keyManager) return; - - const request = await PaymentRequestProtocol.parsePaymentRequest( - event, - keyManager - ); - - const registry = RegistryService.getInstance(); - const def = registry.getCoinDefinition(request.coinId); - const symbol = def?.symbol || "UNKNOWN"; - - const incomingRequest: IncomingPaymentRequest = { - id: event.id, - senderPubkey: event.pubkey, - amount: request.amount, - coinId: request.coinId, - symbol: symbol, - message: request.message, - recipientNametag: request.recipientNametag, - requestId: request.requestId, - timestamp: event.created_at * 1000, - status: PaymentRequestStatus.PENDING, - }; - - if (!this.paymentRequests.find((r) => r.id === incomingRequest.id)) { - this.paymentRequests.unshift(incomingRequest); - this.notifyRequestsUpdated(); - - console.log("📬 Payment Request received:", request); - } - } catch (error) { - console.error("Failed to handle payment request", error); - } - } - - private notifyRequestsUpdated() { - window.dispatchEvent(new CustomEvent("payment-requests-updated")); - } - - acceptPaymentRequest(request: IncomingPaymentRequest) { - this.updateRequestStatus(request.id, PaymentRequestStatus.ACCEPTED); - } - - rejectPaymentRequest(request: IncomingPaymentRequest) { - this.updateRequestStatus(request.id, PaymentRequestStatus.REJECTED); - } - - paidPaymentRequest(request: IncomingPaymentRequest) { - this.updateRequestStatus(request.id, PaymentRequestStatus.PAID); - } - - clearPaymentRequest(requestId: string) { - const currentList = this.paymentRequests.filter((p) => p.id !== requestId); - this.paymentRequests = currentList; - } - - clearProcessedPaymentRequests() { - const currentList = this.paymentRequests.filter( - (p) => p.status === PaymentRequestStatus.PENDING - ); - this.paymentRequests = currentList; - } - - updateRequestStatus(id: string, status: PaymentRequestStatus) { - const req = this.paymentRequests.find((r) => r.id === id); - if (req) { - req.status = status; - this.notifyRequestsUpdated(); - } - } - - getPaymentRequests(): IncomingPaymentRequest[] { - return this.paymentRequests; - } - - private async handleTokenTransfer(event: Event): Promise { - try { - const keyManager = await this.getKeyManager(); - if (!keyManager) { - console.error("KeyManager is undefined"); - return null; - } - - const tokenJson = await TokenTransferProtocol.parseTokenTransfer( - event, - keyManager - ); - - console.log("Token transfer decrypted successfully!"); - - if ( - tokenJson.startsWith("{") && - tokenJson.includes("sourceToken") && - tokenJson.includes("transferTx") - ) { - console.log("Processing proper token transfer with finalization ..."); - - let payloadObj: Record; - try { - payloadObj = JSON.parse(tokenJson); - } catch (error) { - console.warn("Failed to parse JSON:", error); - return null; - } - return await this.handleProperTokenTransfer(payloadObj, event.pubkey); - } - return null; // Unknown transfer format - } catch (error) { - console.error("Failed to handle token transfer", error); - return null; - } - } - - private async handleProperTokenTransfer(payloadObj: Record, senderPubkey: string): Promise { - try { - let sourceTokenInput = payloadObj["sourceToken"]; - let transferTxInput = payloadObj["transferTx"]; - - if (typeof sourceTokenInput === "string") { - try { - sourceTokenInput = JSON.parse(sourceTokenInput); - } catch (e) { - console.error("Failed to parse sourceToken string", e); - } - } - - if (typeof transferTxInput === "string") { - try { - transferTxInput = JSON.parse(transferTxInput); - } catch (e) { - console.error("Failed to parse transferTx string", e); - } - } - - if (!sourceTokenInput || !transferTxInput) { - console.error("Missing sourceToken or transferTx in payload"); - return null; - } - - const sourceToken = await Token.fromJSON(sourceTokenInput); - const transferTx = await TransferTransaction.fromJSON(transferTxInput); - - return await this.finalizeTransfer(sourceToken, transferTx, senderPubkey); - } catch (error) { - console.error("Error handling proper token transfer", error); - return null; - } - } - - private async finalizeTransfer( - sourceToken: Token, - transferTx: TransferTransaction, - senderPubkey: string - ): Promise { - try { - const recipientAddress = transferTx.data.recipient; - console.log(`Recipient address: ${recipientAddress}`); - - const addressScheme = recipientAddress.scheme; - console.log(`Address scheme: ${addressScheme}`); - - if (addressScheme === AddressScheme.PROXY) { - console.log("Transfer is to PROXY address - finalization required"); - - const nametagService = NametagService.getInstance(this.identityManager); - const allNametags = await nametagService.getAllNametagTokens(); - - if (allNametags.length === 0) { - console.error("No nametags configured for this wallet"); - return null; - } - - let myNametagToken: Token | null = null; - - for (const nametag of allNametags) { - const proxy = await ProxyAddress.fromTokenId(nametag.id); - if (proxy.address === recipientAddress.address) { - myNametagToken = nametag; - } - } - - if (myNametagToken === null) { - console.error("Transfer is not for any of my nametags!"); - console.error(`Got: ${recipientAddress.address}`); - console.error(`My nametags: ${allNametags.toString()}`); - return null; - } - - console.log("Transfer is for my nametag!"); - - const identity = await this.identityManager.getCurrentIdentity(); - - if (identity === null) { - console.error( - "No wallet identity found, can't finalize the transfer!" - ); - return null; - } - - const secret = Buffer.from(identity.privateKey, "hex"); - const signingService = await SigningService.createFromSecret(secret); - - const transferSalt = transferTx.data.salt; - - const recipientPredicate = await UnmaskedPredicate.create( - sourceToken.id, - sourceToken.type, - signingService, - HashAlgorithm.SHA256, - transferSalt - ); - - const recipientState = new TokenState(recipientPredicate, null); - - const client = ServiceProvider.stateTransitionClient; - const rootTrustBase = ServiceProvider.getRootTrustBase(); - - let finalizedToken: Token; - - // DEV MODE: Skip nametag token verification if trust base verification is disabled - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.warn("⚠️ Finalizing transfer WITHOUT nametag verification (dev mode)"); - // Create token directly without SDK verification - // Get the source token's JSON and modify it for the finalized state - const sourceTxf = sourceToken.toJSON(); - const existingTransactions = sourceTxf.transactions || []; - - // Calculate the new state hash (required for token chain validity) - const newStateHash = await recipientState.calculateHash(); - const newStateHashStr = newStateHash.toJSON(); - - // Create the new transaction with the calculated state hash - const newTxJson = { - ...transferTx.toJSON(), - newStateHash: newStateHashStr, - }; - - const finalizedTxf = { - ...sourceTxf, - state: recipientState.toJSON(), - transactions: [...existingTransactions, newTxJson], - nametags: [myNametagToken.toJSON()], - }; - finalizedToken = await Token.fromJSON(finalizedTxf); - } else { - // Try finalization with existing nametag token first - // If it fails with "Nametag tokens verification failed", refresh proof and retry - try { - finalizedToken = await client.finalizeTransaction( - rootTrustBase, - sourceToken, - recipientState, - transferTx, - [myNametagToken] - ); - } catch (finalizeError: unknown) { - const errorMessage = finalizeError instanceof Error ? finalizeError.message : String(finalizeError); - - // Check if this is a nametag verification failure (stale proof) - if (errorMessage.includes("Nametag tokens verification failed")) { - console.log("📦 Nametag proof appears stale, refreshing and retrying..."); - - const refreshedNametag = await nametagService.refreshNametagProof(); - if (!refreshedNametag) { - console.error("Failed to refresh nametag proof"); - throw finalizeError; - } - - // Retry with refreshed nametag - myNametagToken = refreshedNametag; - finalizedToken = await client.finalizeTransaction( - rootTrustBase, - sourceToken, - recipientState, - transferTx, - [myNametagToken] - ); - console.log("✅ Finalization succeeded after proof refresh"); - } else { - // Different error, re-throw - throw finalizeError; - } - } - } - - console.log("Token finalized successfully!"); - return await this.saveReceivedToken(finalizedToken, senderPubkey); - } else { - console.log( - "Transfer is to DIRECT address - finalizing with direct predicate" - ); - - // For DIRECT addresses, we still need to finalize the transfer to update the token state - const identity = await this.identityManager.getCurrentIdentity(); - - if (identity === null) { - console.error( - "No wallet identity found, can't finalize the direct transfer!" - ); - return null; - } - - const secret = Buffer.from(identity.privateKey, "hex"); - const signingService = await SigningService.createFromSecret(secret); - - const transferSalt = transferTx.data.salt; - - // Create the recipient predicate using UnmaskedPredicate (same as PROXY but no proxy token reveal) - const recipientPredicate = await UnmaskedPredicate.create( - sourceToken.id, - sourceToken.type, - signingService, - HashAlgorithm.SHA256, - transferSalt - ); - - const recipientState = new TokenState(recipientPredicate, null); - - const client = ServiceProvider.stateTransitionClient; - const rootTrustBase = ServiceProvider.getRootTrustBase(); - - let finalizedToken: Token; - - // DEV MODE: Skip verification if trust base verification is disabled - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.warn("⚠️ Finalizing DIRECT transfer WITHOUT verification (dev mode)"); - // Create token directly without SDK verification - // Get the source token's JSON and modify it for the finalized state - const sourceTxf = sourceToken.toJSON(); - const existingTransactions = sourceTxf.transactions || []; - - // Calculate the new state hash (required for token chain validity) - const newStateHash = await recipientState.calculateHash(); - const newStateHashStr = newStateHash.toJSON(); - - // Create the new transaction with the calculated state hash - const newTxJson = { - ...transferTx.toJSON(), - newStateHash: newStateHashStr, - }; - - const finalizedTxf = { - ...sourceTxf, - state: recipientState.toJSON(), - transactions: [...existingTransactions, newTxJson], - nametags: [], // No nametag tokens for DIRECT addresses - }; - finalizedToken = await Token.fromJSON(finalizedTxf); - } else { - // Finalize with empty proxy tokens array for DIRECT addresses - finalizedToken = await client.finalizeTransaction( - rootTrustBase, - sourceToken, - recipientState, - transferTx, - [] // No proxy tokens for DIRECT addresses - ); - } - - console.log("Token finalized successfully (DIRECT address)!"); - return await this.saveReceivedToken(finalizedToken, senderPubkey); - } - } catch (error) { - console.error("Error occured while finalizing transfer:", error); - return null; - } - } - - private async saveReceivedToken(token: Token, senderPubkey: string): Promise { - let amount = undefined; - let coinId = undefined; - let symbol = undefined; - let iconUrl = undefined; - - const coinsOpt = token.coins; - - const coinData = coinsOpt; - - if (coinData) { - const rawCoins = coinData.coins; - console.log("🔍 Raw Coins:", rawCoins); - - let key: any = null; - let val: any = null; - - if (Array.isArray(rawCoins)) { - const firstItem = rawCoins[0]; - if (Array.isArray(firstItem) && firstItem.length === 2) { - key = firstItem[0]; - val = firstItem[1]; - } else { - console.warn("Unknown array format", firstItem); - } - } else if (typeof rawCoins === "object") { - const keys = Object.keys(rawCoins); - if (keys.length > 0) { - key = keys[0]; - val = (rawCoins as any)[key]; - } - } - - if (val) { - amount = val.toString(); - } - - if (key) { - console.log("🔑 Processing Key:", key); - const bytes = key.data || key; - coinId = Buffer.from(bytes).toString("hex"); - } - } - - console.log(`✅ FINAL PARSE: CoinID=${coinId}, Amount=${amount}`); - - if (!coinId || amount === "0" || coinId === "0" || coinId === "undefined") { - console.error("❌ Invalid token data. Skipping."); - return null; - } - - if (coinId) { - const registryService = RegistryService.getInstance(); - const def = registryService.getCoinDefinition(coinId); - if (def) { - symbol = def.symbol || "UNK"; - iconUrl = registryService.getIconUrl(def) || undefined; - } - } - - const uiToken = new UiToken({ - id: uuidv4(), - name: symbol ? symbol : "Unicity Token", - type: token.type.toString(), - symbol: symbol, - jsonData: JSON.stringify(normalizeSdkTokenToStorage(token.toJSON())), - status: TokenStatus.CONFIRMED, - amount: amount, - coinId: coinId, - iconUrl: iconUrl, - timestamp: Date.now(), - senderPubkey: senderPubkey, - }); - -// Save token via InventorySyncService (per TOKEN_INVENTORY_SPEC.md Section 6.1) - // Use local mode for fast storage - background sync will handle IPFS upload - const identity = await this.identityManager.getCurrentIdentity(); - if (identity) { - try { - const ipnsName = await deriveIpnsNameFromPrivateKey(identity.privateKey); - await addTokenToInventory( - identity.address, - identity.publicKey, - ipnsName, - uiToken, - { local: true } // Quick local storage, IPFS sync handled by background loop - ); - console.log(`💾 Token saved via InventorySyncService: ${uiToken.id}`); - - // Record to transaction history (deduplication handled by TransactionHistoryService) - if (amount && coinId) { - addReceivedTransaction( - amount, - coinId, - symbol || "UNK", - iconUrl, - senderPubkey, - Date.now() - ); - } - - // Record token transfer activity (fire and forget) - recordActivity("token_transfer", { - isPublic: false, - data: { amount, symbol }, - }); - } catch (syncError) { - console.error(`❌ Failed to save token via InventorySyncService:`, syncError); - // Token creation succeeded but storage failed - this is a critical error - throw syncError; - } - } else { - console.error(`❌ No identity available to save token`); - throw new Error("No identity available to save received token"); - } - - return uiToken; - } - - async queryPubkeyByNametag(nametag: string): Promise { - if (!this.client) await this.start(); - - try { - const cleanTag = nametag.replace("@unicity", "").replace("@", ""); - console.log(`Querying pubkey for: ${cleanTag}`); - - const pubkey = await this.client?.queryPubkeyByNametag(cleanTag); - return pubkey || null; - } catch (error) { - console.error("Failed to query nametag", error); - return null; - } - } - - async sendTokenTransfer( - recipientPubkey: string, - payloadJson: string, - amount?: bigint, - symbol?: string, - replyToEventId?: string - ): Promise { - if (!this.client) await this.start(); - - try { - console.log(`Sending token transfer to ${recipientPubkey}...`); - await this.client?.sendTokenTransfer(recipientPubkey, payloadJson, { - amount, - symbol, - replyToEventId, - }); - return true; - } catch (error) { - console.error("Failed to send token transfer", error); - return false; - } - } - - /** - * Send token payload to recipient via Nostr - * Used by NostrDeliveryQueue for background delivery - * @returns Event ID of the sent transfer - */ - async sendTokenToRecipient(recipientPubkey: string, payloadJson: string): Promise { - if (!this.client) await this.start(); - - // Parse payload to extract amount/symbol for metadata - let amount: bigint | undefined; - let symbol: string | undefined; - - try { - const payload = JSON.parse(payloadJson); - if (payload.amount) { - amount = BigInt(payload.amount); - } - if (payload.symbol) { - symbol = payload.symbol; - } - } catch { - // Ignore parsing errors - amount/symbol are optional metadata - } - - // Send token transfer and get event ID - // The SDK's sendTokenTransfer returns the event ID - const eventId = await this.client?.sendTokenTransfer(recipientPubkey, payloadJson, { - amount, - symbol, - }); - - if (!eventId) { - throw new Error('Failed to send token transfer - no event ID returned'); - } - - return eventId; - } - - async publishNametagBinding( - nametag: string, - unicityAddress: string - ): Promise { - if (!this.client) await this.start(); - - try { - await this.client?.publishNametagBinding(nametag, unicityAddress); - return true; - } catch (error) { - console.error("Failed to publish nametag", error); - return false; - } - } - - private async getKeyManager(): Promise { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity || !this.client) return; - - const secretKey = Buffer.from(identity.privateKey, "hex"); - return NostrKeyManager.fromPrivateKey(secretKey); - } - - // ========================================== - // DM Chat Methods (NIP-17) - // ========================================== - - /** - * Wrapper format for messages that includes sender's nametag. - * Messages are sent as JSON: {"senderNametag": "name", "text": "message"} - */ - private wrapMessageContent(content: string, senderNametag: string | null): string { - if (senderNametag) { - return JSON.stringify({ - senderNametag: senderNametag, - text: content, - }); - } - return content; - } - - /** - * Unwrap message content and extract sender's nametag if present. - */ - private unwrapMessageContent(content: string): { text: string; senderNametag: string | null } { - try { - const parsed = JSON.parse(content); - if (typeof parsed === "object" && parsed.text !== undefined) { - return { - text: parsed.text, - senderNametag: parsed.senderNametag || null, - }; - } - } catch { - // Not JSON, return original content - } - return { text: content, senderNametag: null }; - } - - private async handleGiftWrappedMessage(event: Event) { - try { - if (!this.client) { - console.error("No client for unwrapping message"); - return; - } - - // Unwrap NIP-17 gift-wrapped message - const privateMessage: PrivateMessage = this.client.unwrapPrivateMessage(event); - - // Check if it's a chat message (kind 14) or read receipt (kind 15) - if (privateMessage.kind === 14) { - // Chat message - this.handleIncomingChatMessage(privateMessage); - } else if (privateMessage.kind === 15) { - // Read receipt - this.handleIncomingReadReceipt(privateMessage); - } else { - console.log(`Unknown NIP-17 message kind: ${privateMessage.kind}`); - } - } catch (error) { - console.error("Failed to handle gift-wrapped message", error); - } - } - - private handleIncomingChatMessage(privateMessage: PrivateMessage) { - const senderPubkey = privateMessage.senderPubkey; - const rawContent = privateMessage.content; - - // Unwrap message content to extract sender's nametag if present - const { text: content, senderNametag } = this.unwrapMessageContent(rawContent); - - // Check if this message already exists (e.g., after page reload) - const existingMessage = this.chatRepository.getMessage(privateMessage.eventId); - if (existingMessage) { - console.log(`📩 Skipping already saved message ${privateMessage.eventId.slice(0, 8)}`); - return; - } - - console.log(`📩 NIP-17 DM from ${senderNametag || senderPubkey.slice(0, 8)}: ${content.slice(0, 50)}...`); - - // Get or create conversation with sender's nametag if available - const conversation = this.chatRepository.getOrCreateConversation(senderPubkey, senderNametag || undefined); - - // If we received a nametag and the conversation didn't have one, update it - if (senderNametag && !conversation.participantNametag) { - conversation.participantNametag = senderNametag; - this.chatRepository.updateConversationNametag(conversation.id, senderNametag); - } - - // Create and save message (with unwrapped content and sender nametag) - const message = new ChatMessage({ - id: privateMessage.eventId, - conversationId: conversation.id, - content: content, - timestamp: privateMessage.timestamp * 1000, - isFromMe: false, - status: MessageStatus.DELIVERED, - type: MessageType.TEXT, - senderPubkey: senderPubkey, - senderNametag: senderNametag || undefined, - }); - - this.chatRepository.saveMessage(message); - this.chatRepository.incrementUnreadCount(conversation.id); - - // Notify listeners - this.notifyDMListeners(message); - - // Send read receipt - this.sendReadReceipt(senderPubkey, privateMessage.eventId).catch(console.error); - } - - private handleIncomingReadReceipt(privateMessage: PrivateMessage) { - const replyToEventId = privateMessage.replyToEventId; - if (replyToEventId) { - console.log(`📬 Read receipt for message: ${replyToEventId.slice(0, 8)}`); - this.chatRepository.updateMessageStatus(replyToEventId, MessageStatus.READ); - } - } - - async sendReadReceipt(recipientPubkey: string, messageEventId: string): Promise { - if (!this.client) await this.start(); - try { - await this.client?.sendReadReceipt(recipientPubkey, messageEventId); - console.log(`✅ Read receipt sent for ${messageEventId.slice(0, 8)}`); - } catch (error) { - console.error("Failed to send read receipt", error); - } - } - - async sendDirectMessage( - recipientPubkey: string, - content: string, - recipientNametag?: string - ): Promise { - if (!this.client) await this.start(); - - try { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) throw new Error("No identity for sending DM"); - - // Get sender's nametag to include in message - const senderNametag = await this.getMyNametag(); - - // Get or create conversation - const conversation = this.chatRepository.getOrCreateConversation( - recipientPubkey, - recipientNametag - ); - - // Create message with pending status - const message = new ChatMessage({ - conversationId: conversation.id, - content: content, - timestamp: Date.now(), - isFromMe: true, - status: MessageStatus.PENDING, - type: MessageType.TEXT, - senderPubkey: identity.publicKey, - }); - - // Save immediately (optimistic update) - this.chatRepository.saveMessage(message); - - // Wrap content with sender's nametag for recipient to see who sent it - const wrappedContent = this.wrapMessageContent(content, senderNametag); - - // Send via Nostr using NIP-17 private messaging - const eventId = await this.client?.sendPrivateMessage( - recipientPubkey, - wrappedContent - ); - - if (eventId) { - // Update message: replace with new ID (for read receipt tracking) and status - const originalId = message.id; - message.id = eventId; - message.status = MessageStatus.SENT; - // Delete old message and save updated one - this.chatRepository.deleteMessage(originalId); - this.chatRepository.saveMessage(message); - console.log(`📤 NIP-17 DM sent to ${recipientPubkey.slice(0, 8)} from @${senderNametag || 'unknown'}`); - return message; - } else { - // Update status to failed - this.chatRepository.updateMessageStatus(message.id, MessageStatus.FAILED); - return null; - } - } catch (error) { - console.error("Failed to send DM", error); - return null; - } - } - - async sendDirectMessageByNametag( - nametag: string, - content: string - ): Promise { - if (!this.client) await this.start(); - - try { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) throw new Error("No identity for sending DM"); - - // Get sender's nametag to include in message - const senderNametag = await this.getMyNametag(); - - // Resolve nametag to pubkey first for conversation tracking - const pubkey = await this.queryPubkeyByNametag(nametag); - if (!pubkey) { - console.error(`Could not resolve nametag: ${nametag}`); - return null; - } - - // Get or create conversation - const conversation = this.chatRepository.getOrCreateConversation(pubkey, nametag); - - // Create message with pending status - const message = new ChatMessage({ - conversationId: conversation.id, - content: content, - timestamp: Date.now(), - isFromMe: true, - status: MessageStatus.PENDING, - type: MessageType.TEXT, - senderPubkey: identity.publicKey, - }); - - // Save immediately (optimistic update) - this.chatRepository.saveMessage(message); - - // Wrap content with sender's nametag for recipient to see who sent it - const wrappedContent = this.wrapMessageContent(content, senderNametag); - - // Send via Nostr using NIP-17 with nametag (SDK auto-resolves) - const eventId = await this.client?.sendPrivateMessageToNametag( - nametag.replace("@", ""), - wrappedContent - ); - - if (eventId) { - // Update message: replace with new ID (for read receipt tracking) and status - const originalId = message.id; - message.id = eventId; - message.status = MessageStatus.SENT; - // Delete old message and save updated one - this.chatRepository.deleteMessage(originalId); - this.chatRepository.saveMessage(message); - console.log(`📤 NIP-17 DM sent to @${nametag} from @${senderNametag || 'unknown'}`); - return message; - } else { - this.chatRepository.updateMessageStatus(message.id, MessageStatus.FAILED); - return null; - } - } catch (error) { - console.error("Failed to send DM by nametag", error); - return null; - } - } - - addDMListener(listener: (message: ChatMessage) => void): void { - this.dmListeners.push(listener); - } - - removeDMListener(listener: (message: ChatMessage) => void): void { - this.dmListeners = this.dmListeners.filter((l) => l !== listener); - } - - private notifyDMListeners(message: ChatMessage): void { - this.dmListeners.forEach((listener) => listener(message)); - window.dispatchEvent(new CustomEvent("dm-received", { detail: message })); - } - - getMyPublicKey(): string | null { - const keyManager = this.client?.getKeyManager(); - return keyManager?.getPublicKeyHex() || null; - } - - async getMyNametag(): Promise { - const nametagService = NametagService.getInstance(this.identityManager); - return nametagService.getActiveNametag(); - } - - // ========================================== - // App-Specific Data Publishing (NIP-78) - // ========================================== - - /** - * Publish an app-specific data event to Nostr relays. - * Used for IPFS CID pin announcements (kind 30078). - * - * @param kind - Event kind (e.g., 30078 for app-specific data) - * @param tags - Event tags array (e.g., [["d", "ipfs-pin"], ["cid", "Qm..."]]) - * @param content - Event content (can be empty string or JSON) - * @returns Event ID if successful, null otherwise - */ - async publishAppDataEvent( - kind: number, - tags: string[][], - content: string - ): Promise { - if (!this.client) { - await this.start(); - } - - try { - const keyManager = await this.getKeyManager(); - if (!keyManager || !this.client) { - console.error("Cannot publish app data event: no key manager or client"); - return null; - } - - // Create and sign the event - // The SDK's NostrClient createAndPublishEvent expects an UnsignedEventData object - const eventId = await this.client.createAndPublishEvent({ - kind, - tags, - content, - }); - - if (eventId) { - console.log(`📤 Published app data event (kind ${kind}): ${eventId.slice(0, 8)}...`); - } - - return eventId || null; - } catch (error) { - console.error("Failed to publish app data event:", error); - return null; - } - } -} diff --git a/src/components/wallet/L3/services/OutboxRecoveryService.ts b/src/components/wallet/L3/services/OutboxRecoveryService.ts deleted file mode 100644 index 08ce01d8..00000000 --- a/src/components/wallet/L3/services/OutboxRecoveryService.ts +++ /dev/null @@ -1,1082 +0,0 @@ -/** - * OutboxRecoveryService - * - * Handles recovery of incomplete token transfers on startup and periodically. - * Reads outbox entries from localStorage and resumes operations - * based on where they left off. - * - * Recovery by status: - * - PENDING_IPFS_SYNC: Re-sync to IPFS, then continue - * - READY_TO_SUBMIT: Submit to aggregator (idempotent) - * - SUBMITTED: Poll for inclusion proof - * - PROOF_RECEIVED: Retry Nostr delivery - * - NOSTR_SENT: Just mark as completed - * - COMPLETED: Remove from outbox - * - FAILED: Skip (requires manual intervention) - * - * Periodic retry: - * - Runs every 60 seconds while app is open - * - Uses exponential backoff (30s base, 1h max) - * - No age limit - entries remain recoverable indefinitely - * - Only 10 consecutive failures mark entry as FAILED - */ - -import { TransferCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/TransferCommitment"; -import { MintCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment"; -import { MintTransactionData } from "@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData"; -import { waitInclusionProofWithDevBypass } from "../../../../utils/devTools"; -import { Token } from "@unicitylabs/state-transition-sdk/lib/token/Token"; -import { TokenType } from "@unicitylabs/state-transition-sdk/lib/token/TokenType"; -import { TokenState } from "@unicitylabs/state-transition-sdk/lib/token/TokenState"; -import { TokenId } from "@unicitylabs/state-transition-sdk/lib/token/TokenId"; -import { SigningService } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService"; -import { UnmaskedPredicate } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate"; -import { HashAlgorithm } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm"; -import { OutboxRepository } from "../../../../repositories/OutboxRepository"; -import type { NametagData } from "./types/TxfTypes"; -import { getTokensForAddress, setNametagForAddress } from "./InventorySyncService"; -import { ServiceProvider } from "./ServiceProvider"; -import { NostrService } from "./NostrService"; -import { ProxyAddress } from "@unicitylabs/state-transition-sdk/lib/address/ProxyAddress"; -import type { IdentityManager } from "./IdentityManager"; -import type { - OutboxEntry, - MintOutboxEntry, - RecoveryResult, - RecoveryDetail, -} from "./types/OutboxTypes"; -// Note: isMintRecoverable is available but we use getMintEntriesForRecovery() instead -import { IpfsStorageService, SyncPriority } from "./IpfsStorageService"; -import { TokenRecoveryService } from "./TokenRecoveryService"; -import { normalizeSdkTokenToStorage } from "./TxfSerializer"; - -// ========================================== -// Configuration Constants -// ========================================== - -/** Check outbox every 60 seconds */ -const PERIODIC_RETRY_INTERVAL_MS = 60000; - -/** Base delay between retries (30 seconds) */ -const ENTRY_BACKOFF_BASE_MS = 30000; - -/** Maximum delay between retries (1 hour) */ -const ENTRY_MAX_BACKOFF_MS = 3600000; - -/** Maximum consecutive failures before marking as FAILED */ -const MAX_RETRIES_PER_ENTRY = 10; - -/** Cleanup COMPLETED entries after 24 hours */ -const COMPLETED_CLEANUP_AGE_MS = 24 * 60 * 60 * 1000; - -export class OutboxRecoveryService { - private static instance: OutboxRecoveryService; - - private identityManager: IdentityManager | null = null; - private isRecovering = false; - - // Periodic retry state - private periodicRetryInterval: ReturnType | null = null; - private walletAddress: string | null = null; - private nostrServiceRef: NostrService | null = null; - - private constructor() {} - - static getInstance(): OutboxRecoveryService { - if (!OutboxRecoveryService.instance) { - OutboxRecoveryService.instance = new OutboxRecoveryService(); - } - return OutboxRecoveryService.instance; - } - - /** - * Set the identity manager (needed for IPFS sync) - */ - setIdentityManager(manager: IdentityManager): void { - this.identityManager = manager; - } - - /** - * Check if there are any pending entries that need recovery (transfers or mints) - */ - hasPendingRecovery(walletAddress: string): boolean { - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.setCurrentAddress(walletAddress); - const pendingTransfers = outboxRepo.getPendingCount(); - const pendingMints = outboxRepo.getPendingMintEntries().length; - return pendingTransfers > 0 || pendingMints > 0; - } - - /** - * Get count of pending entries (transfers + mints) - */ - getPendingCount(walletAddress: string): number { - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.setCurrentAddress(walletAddress); - const pendingTransfers = outboxRepo.getPendingCount(); - const pendingMints = outboxRepo.getPendingMintEntries().length; - return pendingTransfers + pendingMints; - } - - // ========================================== - // Periodic Retry Methods - // ========================================== - - /** - * Start periodic retry checking - * Call after initial startup recovery completes - * - * Guard: If already running for the same address, skip redundant start - */ - startPeriodicRetry(walletAddress: string, nostrService: NostrService): void { - // Skip if already running for the same address (prevents redundant restarts) - if (this.periodicRetryInterval && this.walletAddress === walletAddress) { - console.log("📤 OutboxRecovery: Periodic retry already running, skipping redundant start"); - return; - } - - this.stopPeriodicRetry(); // Clear any existing interval - - this.walletAddress = walletAddress; - this.nostrServiceRef = nostrService; - - console.log(`📤 OutboxRecovery: Starting periodic retry (every ${PERIODIC_RETRY_INTERVAL_MS / 1000}s)`); - - this.periodicRetryInterval = setInterval(() => { - this.runPeriodicRecovery(); - }, PERIODIC_RETRY_INTERVAL_MS); - } - - /** - * Stop periodic retry checking - * Call on logout or app shutdown - */ - stopPeriodicRetry(): void { - if (this.periodicRetryInterval) { - clearInterval(this.periodicRetryInterval); - this.periodicRetryInterval = null; - console.log("📤 OutboxRecovery: Stopped periodic retry"); - } - this.walletAddress = null; - this.nostrServiceRef = null; - } - - /** - * Run a periodic recovery cycle - * - Skips if already recovering - * - Only processes entries ready for retry (respects backoff) - * - Cleans up old completed entries - */ - private async runPeriodicRecovery(): Promise { - if (!this.walletAddress || !this.nostrServiceRef) return; - if (this.isRecovering) return; // Already running - - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.setCurrentAddress(this.walletAddress); - - // Check both transfer and mint entries - const pendingTransfers = outboxRepo.getPendingEntries(); - const pendingMints = outboxRepo.getMintEntriesForRecovery(); - const totalPending = pendingTransfers.length + pendingMints.length; - - if (totalPending === 0) return; // Nothing to do - - // Get entries that are ready for retry (respect backoff) - const transfersReadyForRetry = pendingTransfers.filter(entry => this.isReadyForRetry(entry)); - const mintsReadyForRetry = pendingMints.filter(entry => this.isMintReadyForRetry(entry)); - const totalReady = transfersReadyForRetry.length + mintsReadyForRetry.length; - - if (totalReady === 0) { - // All entries in backoff, don't log every 60s - return; - } - - console.log(`📤 OutboxRecovery: Periodic check - ${totalReady}/${totalPending} entries ready for retry (transfers: ${transfersReadyForRetry.length}, mints: ${mintsReadyForRetry.length})`); - - // Recover transfers - if (transfersReadyForRetry.length > 0) { - await this.recoverPendingTransfers(this.walletAddress, this.nostrServiceRef); - } - - // Recover mints - if (mintsReadyForRetry.length > 0) { - await this.recoverPendingMints(this.walletAddress); - } - - // Cleanup old COMPLETED entries only (not pending ones - those may complete later) - outboxRepo.cleanupCompleted(COMPLETED_CLEANUP_AGE_MS); - } - - /** - * Check if a mint entry is ready for retry based on exponential backoff - */ - private isMintReadyForRetry(entry: MintOutboxEntry): boolean { - if (entry.status === "FAILED") return false; - if (entry.status === "COMPLETED") return false; - - // Check retry count - entries at or beyond max will be marked FAILED during recovery - if (entry.retryCount >= MAX_RETRIES_PER_ENTRY) { - return true; // Let recoverMintEntry handle marking it as FAILED - } - - // Calculate backoff delay based on retry count - const backoffDelay = Math.min( - ENTRY_BACKOFF_BASE_MS * Math.pow(2, entry.retryCount), - ENTRY_MAX_BACKOFF_MS - ); - - const timeSinceLastUpdate = Date.now() - entry.updatedAt; - return timeSinceLastUpdate >= backoffDelay; - } - - /** - * Check if an entry is ready for retry based on exponential backoff - * NOTE: No age limit - users may close app for days/weeks and return - */ - private isReadyForRetry(entry: OutboxEntry): boolean { - if (entry.status === "FAILED") return false; - if (entry.status === "COMPLETED") return false; - - // Check retry count - entries at or beyond max will be marked FAILED during recovery - if (entry.retryCount >= MAX_RETRIES_PER_ENTRY) { - return true; // Let recoverEntry handle marking it as FAILED - } - - // Calculate backoff delay based on retry count - const backoffDelay = Math.min( - ENTRY_BACKOFF_BASE_MS * Math.pow(2, entry.retryCount), - ENTRY_MAX_BACKOFF_MS - ); - - const timeSinceLastUpdate = Date.now() - entry.updatedAt; - return timeSinceLastUpdate >= backoffDelay; - } - - // ========================================== - // Recovery Methods - // ========================================== - - /** - * Main recovery entry point - called on app startup - * Recovers all pending transfers for the given wallet address - */ - async recoverPendingTransfers( - walletAddress: string, - nostrService: NostrService - ): Promise { - if (this.isRecovering) { - console.log("📤 OutboxRecovery: Recovery already in progress, skipping"); - return { recovered: 0, failed: 0, skipped: 0, details: [] }; - } - - this.isRecovering = true; - const result: RecoveryResult = { - recovered: 0, - failed: 0, - skipped: 0, - details: [], - }; - - try { - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.setCurrentAddress(walletAddress); - - const pendingEntries = outboxRepo.getPendingEntries(); - - if (pendingEntries.length === 0) { - console.log("📤 OutboxRecovery: No pending entries to recover"); - return result; - } - - console.log(`📤 OutboxRecovery: Found ${pendingEntries.length} pending entries`); - - for (const entry of pendingEntries) { - const detail = await this.recoverEntry(entry, outboxRepo, nostrService); - result.details.push(detail); - - switch (detail.status) { - case "recovered": - result.recovered++; - break; - case "failed": - result.failed++; - break; - case "skipped": - result.skipped++; - break; - } - } - - // Final IPFS sync after recovery - if (this.identityManager && (result.recovered > 0 || result.failed > 0)) { - try { - const ipfsService = IpfsStorageService.getInstance(this.identityManager); - await ipfsService.syncNow({ - priority: SyncPriority.MEDIUM, - callerContext: 'outbox-recovery-final', - }); - console.log("📤 OutboxRecovery: Final IPFS sync completed"); - } catch (syncError) { - console.warn("📤 OutboxRecovery: Final IPFS sync failed:", syncError); - } - } - - console.log(`📤 OutboxRecovery: Complete - ${result.recovered} recovered, ${result.failed} failed, ${result.skipped} skipped`); - return result; - } finally { - this.isRecovering = false; - } - } - - /** - * Recover a single outbox entry - */ - private async recoverEntry( - entry: OutboxEntry, - outboxRepo: OutboxRepository, - nostrService: NostrService - ): Promise { - const detail: RecoveryDetail = { - entryId: entry.id, - status: "skipped", - previousStatus: entry.status, - }; - - console.log(`📤 OutboxRecovery: Processing entry ${entry.id.slice(0, 8)}... (status=${entry.status}, type=${entry.type})`); - - try { - switch (entry.status) { - case "PENDING_IPFS_SYNC": - await this.resumeFromPendingIpfs(entry, outboxRepo, nostrService); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "READY_TO_SUBMIT": - await this.resumeFromReadyToSubmit(entry, outboxRepo, nostrService); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "SUBMITTED": - await this.resumeFromSubmitted(entry, outboxRepo, nostrService); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "PROOF_RECEIVED": - // SPLIT_MINT entries with proof are already complete - token is minted and saved - // SPLIT_BURN entries are also complete - the original token was destroyed, not sent - if (entry.type === "SPLIT_MINT") { - await this.finalizeSplitMint(entry, outboxRepo); - } else if (entry.type === "SPLIT_BURN") { - await this.finalizeSplitBurn(entry, outboxRepo); - } else { - await this.resumeFromProofReceived(entry, outboxRepo, nostrService); - } - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "NOSTR_SENT": - // Just mark as completed - Nostr already sent - outboxRepo.updateStatus(entry.id, "COMPLETED"); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "COMPLETED": - // Already done, just clean up - detail.status = "skipped"; - break; - - case "FAILED": - // Check if this FAILED entry can be recovered by verifying token state - // Only attempt if we haven't exceeded max retries - if (entry.retryCount < MAX_RETRIES_PER_ENTRY) { - const publicKey = await this.getOwnerPublicKey(); - if (publicKey) { - const identity = await this.getIdentityFromManager(); - if (identity) { - const tokens = await getTokensForAddress(identity.address); - const sourceToken = tokens.find(t => t.id === entry.sourceTokenId); - if (sourceToken) { - try { - const recoveryService = TokenRecoveryService.getInstance(); - const spentCheck = await recoveryService.checkTokenSpent(sourceToken, publicKey); - - if (!spentCheck.isSpent) { - // Token not spent - revert to committed state and allow retry - console.log(`📤 OutboxRecovery: Token ${entry.sourceTokenId.slice(0, 8)}... not spent, attempting recovery`); - const recovery = await recoveryService.handleTransferFailure( - sourceToken, - entry.lastError || "RECOVERY_ATTEMPT", - publicKey - ); - - if (recovery.tokenRestored) { - // Reset entry for retry - outboxRepo.updateEntry(entry.id, { - status: "READY_TO_SUBMIT", - retryCount: entry.retryCount + 1, - lastError: undefined, - }); - window.dispatchEvent(new Event("wallet-updated")); - detail.status = "recovered"; - detail.newStatus = "READY_TO_SUBMIT"; - break; - } - } else { - // Token is spent - it's permanently failed - console.log(`📤 OutboxRecovery: Token ${entry.sourceTokenId.slice(0, 8)}... is spent, marking permanently failed`); - await recoveryService.handleTransferFailure(sourceToken, "ALREADY_SPENT", publicKey); - window.dispatchEvent(new Event("wallet-updated")); - } - } catch (recoveryErr) { - console.warn(`📤 OutboxRecovery: Failed entry recovery failed:`, recoveryErr); - } - } - } - } - } - // If we get here, entry remains FAILED - console.warn(`📤 OutboxRecovery: Entry ${entry.id.slice(0, 8)}... is FAILED, skipping`); - detail.status = "skipped"; - break; - } - } catch (error) { - console.error(`📤 OutboxRecovery: Failed to recover entry ${entry.id.slice(0, 8)}...`, error); - const newRetryCount = entry.retryCount + 1; - outboxRepo.updateEntry(entry.id, { - lastError: error instanceof Error ? error.message : String(error), - retryCount: newRetryCount, - }); - - // Mark as FAILED after MAX_RETRIES_PER_ENTRY consecutive failures - if (newRetryCount >= MAX_RETRIES_PER_ENTRY) { - outboxRepo.updateStatus(entry.id, "FAILED", `Max retries exceeded (${MAX_RETRIES_PER_ENTRY})`); - } - - detail.status = "failed"; - detail.error = error instanceof Error ? error.message : String(error); - } - - return detail; - } - - /** - * Resume from PENDING_IPFS_SYNC: Sync to IPFS, then continue full flow - */ - private async resumeFromPendingIpfs( - entry: OutboxEntry, - outboxRepo: OutboxRepository, - nostrService: NostrService - ): Promise { - console.log(`📤 OutboxRecovery: Resuming from PENDING_IPFS_SYNC...`); - - // First sync to IPFS - if (this.identityManager) { - const ipfsService = IpfsStorageService.getInstance(this.identityManager); - const syncResult = await ipfsService.syncNow({ - priority: SyncPriority.MEDIUM, - callerContext: 'outbox-recovery-pending-sync', - }); - if (!syncResult.success) { - throw new Error("IPFS sync failed during recovery"); - } - } - - // Update status and continue - outboxRepo.updateStatus(entry.id, "READY_TO_SUBMIT"); - entry.status = "READY_TO_SUBMIT"; - - await this.resumeFromReadyToSubmit(entry, outboxRepo, nostrService); - } - - /** - * Resume from READY_TO_SUBMIT: Submit to aggregator, wait for proof, send via Nostr - */ - private async resumeFromReadyToSubmit( - entry: OutboxEntry, - outboxRepo: OutboxRepository, - nostrService: NostrService - ): Promise { - console.log(`📤 OutboxRecovery: Resuming from READY_TO_SUBMIT...`); - - // Before retrying submission, verify token state is still valid - // This prevents wasting aggregator calls on tokens that were spent elsewhere - const publicKey = await this.getOwnerPublicKey(); - if (publicKey && entry.sourceTokenJson) { - try { - const identity = await this.getIdentityFromManager(); - if (identity) { - const tokens = await getTokensForAddress(identity.address); - const sourceToken = tokens.find(t => t.id === entry.sourceTokenId); - if (sourceToken) { - const recoveryService = TokenRecoveryService.getInstance(); - const spentCheck = await recoveryService.checkTokenSpent(sourceToken, publicKey); - if (spentCheck.isSpent) { - // Token was spent elsewhere - cannot recover this transfer - console.log(`📤 OutboxRecovery: Token ${entry.sourceTokenId.slice(0, 8)}... already spent, removing`); - await recoveryService.handleTransferFailure(sourceToken, "ALREADY_SPENT", publicKey); - outboxRepo.updateEntry(entry.id, { - status: "FAILED", - lastError: "Token state already spent by another transaction" - }); - window.dispatchEvent(new Event("wallet-updated")); - return; // Don't retry - } - } - } - } catch (spentCheckError) { - console.warn(`📤 OutboxRecovery: Failed to check token spent status:`, spentCheckError); - // Continue with submission - let aggregator determine if spent - } - } - - // Reconstruct commitment from stored JSON - const commitment = await this.reconstructCommitment(entry); - - // Submit to aggregator (idempotent - REQUEST_ID_EXISTS is ok) - const client = ServiceProvider.stateTransitionClient; - const response = await client.submitTransferCommitment(commitment); - - if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") { - // Handle failure with recovery - if (publicKey && entry.sourceTokenJson) { - const identity = await this.getIdentityFromManager(); - if (identity) { - const tokens = await getTokensForAddress(identity.address); - const sourceToken = tokens.find(t => t.id === entry.sourceTokenId); - if (sourceToken) { - try { - const recoveryService = TokenRecoveryService.getInstance(); - const recovery = await recoveryService.handleTransferFailure( - sourceToken, - response.status, - publicKey - ); - console.log(`📤 OutboxRecovery: Submission failed (${response.status}), recovery: ${recovery.action}`); - if (recovery.tokenRestored || recovery.tokenRemoved) { - window.dispatchEvent(new Event("wallet-updated")); - } - } catch (recoveryErr) { - console.error(`📤 OutboxRecovery: Token recovery failed:`, recoveryErr); - } - } - } - } - throw new Error(`Aggregator submission failed: ${response.status}`); - } - - outboxRepo.updateStatus(entry.id, "SUBMITTED"); - entry.status = "SUBMITTED"; - - await this.resumeFromSubmitted(entry, outboxRepo, nostrService); - } - - /** - * Get the owner's public key from identity manager - */ - private async getOwnerPublicKey(): Promise { - if (!this.identityManager) return null; - try { - const identity = await this.identityManager.getCurrentIdentity(); - return identity?.publicKey || null; - } catch { - return null; - } - } - - /** - * Get identity context from identity manager - */ - private async getIdentityFromManager(): Promise<{ address: string; publicKey: string; ipnsName: string } | null> { - if (!this.identityManager) return null; - try { - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity || !identity.address || !identity.publicKey || !identity.ipnsName) { - return null; - } - return { - address: identity.address, - publicKey: identity.publicKey, - ipnsName: identity.ipnsName, - }; - } catch { - return null; - } - } - - /** - * Resume from SUBMITTED: Wait for inclusion proof, then send via Nostr - */ - private async resumeFromSubmitted( - entry: OutboxEntry, - outboxRepo: OutboxRepository, - nostrService: NostrService - ): Promise { - console.log(`📤 OutboxRecovery: Resuming from SUBMITTED...`); - - // Reconstruct commitment from stored JSON - const commitment = await this.reconstructCommitment(entry); - - // Wait for inclusion proof (with dev mode bypass if enabled) - const inclusionProof = await waitInclusionProofWithDevBypass(commitment); - - // Create transfer transaction - const transferTx = commitment.toTransaction(inclusionProof); - - // Update entry with proof data - outboxRepo.updateEntry(entry.id, { - status: "PROOF_RECEIVED", - inclusionProofJson: JSON.stringify(inclusionProof.toJSON()), - transferTxJson: JSON.stringify(transferTx.toJSON()), - }); - entry.status = "PROOF_RECEIVED"; - entry.inclusionProofJson = JSON.stringify(inclusionProof.toJSON()); - entry.transferTxJson = JSON.stringify(transferTx.toJSON()); - - await this.resumeFromProofReceived(entry, outboxRepo, nostrService); - } - - /** - * Finalize a SPLIT_MINT entry that has received its proof. - * SPLIT_MINT entries don't need Nostr delivery - the token is already - * minted and saved to the wallet via the onTokenMinted callback. - * We just mark it as completed. - */ - private async finalizeSplitMint( - entry: OutboxEntry, - outboxRepo: OutboxRepository - ): Promise { - console.log(`📤 OutboxRecovery: Finalizing SPLIT_MINT ${entry.id.slice(0, 8)}... (token already minted)`); - - // Token should already be in wallet from onTokenMinted callback - // Just mark the outbox entry as completed - outboxRepo.updateStatus(entry.id, "COMPLETED"); - - console.log(`📤 SPLIT_MINT ${entry.id.slice(0, 8)}... finalized`); - } - - /** - * Finalize a SPLIT_BURN entry. - * The original token was destroyed (burned) as part of the split operation. - * The burned token should NOT be sent via Nostr - it's gone. - * We just mark the entry as completed. - */ - private async finalizeSplitBurn( - entry: OutboxEntry, - outboxRepo: OutboxRepository - ): Promise { - console.log(`📤 OutboxRecovery: Finalizing SPLIT_BURN ${entry.id.slice(0, 8)}... (token destroyed, not sent)`); - - // The original token was burned - it no longer exists and should not be sent anywhere - // Just mark the outbox entry as completed - outboxRepo.updateStatus(entry.id, "COMPLETED"); - - console.log(`📤 SPLIT_BURN ${entry.id.slice(0, 8)}... finalized`); - } - - /** - * Resume from PROOF_RECEIVED: Send via Nostr - */ - private async resumeFromProofReceived( - entry: OutboxEntry, - outboxRepo: OutboxRepository, - nostrService: NostrService - ): Promise { - console.log(`📤 OutboxRecovery: Resuming from PROOF_RECEIVED...`); - - if (!entry.transferTxJson) { - throw new Error("Missing transferTxJson for Nostr delivery"); - } - - // Build Nostr payload - const payload = JSON.stringify({ - sourceToken: entry.sourceTokenJson, - transferTx: entry.transferTxJson, - }); - - // Send via Nostr - await nostrService.sendTokenTransfer(entry.recipientPubkey, payload); - - // Update status - outboxRepo.updateStatus(entry.id, "NOSTR_SENT"); - outboxRepo.updateStatus(entry.id, "COMPLETED"); - - console.log(`📤 OutboxRecovery: Entry ${entry.id.slice(0, 8)}... recovered and completed`); - } - - /** - * Reconstruct a TransferCommitment from stored JSON - * Note: For direct transfers, this recreates from stored data. - * For splits, the commitment is deterministic so can be recreated. - */ - private async reconstructCommitment(entry: OutboxEntry): Promise { - try { - const commitmentData = JSON.parse(entry.commitmentJson); - return await TransferCommitment.fromJSON(commitmentData); - } catch (error) { - throw new Error(`Failed to reconstruct commitment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - // ========================================== - // Mint Recovery Methods - // ========================================== - - /** - * Recover all pending mint entries - * Called on startup and periodically - */ - async recoverPendingMints(walletAddress: string): Promise { - const result: RecoveryResult = { - recovered: 0, - failed: 0, - skipped: 0, - details: [], - }; - - if (!this.identityManager) { - console.warn("📤 OutboxRecovery: Cannot recover mints - no identity manager"); - return result; - } - - const outboxRepo = OutboxRepository.getInstance(); - outboxRepo.setCurrentAddress(walletAddress); - - const mintEntries = outboxRepo.getMintEntriesForRecovery(); - - if (mintEntries.length === 0) { - return result; - } - - console.log(`📤 OutboxRecovery: Found ${mintEntries.length} pending mint entries`); - - for (const entry of mintEntries) { - const detail = await this.recoverMintEntry(entry, outboxRepo); - result.details.push(detail); - - switch (detail.status) { - case "recovered": - result.recovered++; - break; - case "failed": - result.failed++; - break; - case "skipped": - result.skipped++; - break; - } - } - - // Final IPFS sync after mint recovery - if (result.recovered > 0 || result.failed > 0) { - try { - const ipfsService = IpfsStorageService.getInstance(this.identityManager); - await ipfsService.syncNow({ - priority: SyncPriority.MEDIUM, - callerContext: 'outbox-mint-recovery-final', - }); - console.log("📤 OutboxRecovery: Final IPFS sync after mint recovery completed"); - } catch (syncError) { - console.warn("📤 OutboxRecovery: Final IPFS sync after mint recovery failed:", syncError); - } - } - - console.log(`📤 OutboxRecovery: Mint recovery complete - ${result.recovered} recovered, ${result.failed} failed, ${result.skipped} skipped`); - return result; - } - - /** - * Recover a single mint outbox entry - */ - private async recoverMintEntry( - entry: MintOutboxEntry, - outboxRepo: OutboxRepository - ): Promise { - const detail: RecoveryDetail = { - entryId: entry.id, - status: "skipped", - previousStatus: entry.status, - }; - - console.log(`📤 OutboxRecovery: Processing mint entry ${entry.id.slice(0, 8)}... (status=${entry.status}, type=${entry.type})`); - - // Check retry count - if (entry.retryCount >= MAX_RETRIES_PER_ENTRY) { - outboxRepo.updateMintStatus(entry.id, "FAILED", `Max retries exceeded (${MAX_RETRIES_PER_ENTRY})`); - detail.status = "failed"; - detail.error = `Max retries exceeded (${MAX_RETRIES_PER_ENTRY})`; - return detail; - } - - try { - switch (entry.status) { - case "PENDING_IPFS_SYNC": - await this.resumeMintFromPendingIpfs(entry, outboxRepo); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "READY_TO_SUBMIT": - await this.resumeMintFromReadyToSubmit(entry, outboxRepo); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "SUBMITTED": - await this.resumeMintFromSubmitted(entry, outboxRepo); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "PROOF_RECEIVED": - await this.resumeMintFromProofReceived(entry, outboxRepo); - detail.status = "recovered"; - detail.newStatus = "COMPLETED"; - break; - - case "COMPLETED": - detail.status = "skipped"; - break; - - case "FAILED": - console.warn(`📤 OutboxRecovery: Mint entry ${entry.id.slice(0, 8)}... is FAILED, skipping`); - detail.status = "skipped"; - break; - - default: - // NOSTR_SENT is not applicable to mints - detail.status = "skipped"; - break; - } - } catch (error) { - console.error(`📤 OutboxRecovery: Failed to recover mint entry ${entry.id.slice(0, 8)}...`, error); - outboxRepo.updateMintEntry(entry.id, { - lastError: error instanceof Error ? error.message : String(error), - retryCount: entry.retryCount + 1, - }); - detail.status = "failed"; - detail.error = error instanceof Error ? error.message : String(error); - } - - return detail; - } - - /** - * Resume mint from PENDING_IPFS_SYNC: Sync to IPFS, then continue - */ - private async resumeMintFromPendingIpfs( - entry: MintOutboxEntry, - outboxRepo: OutboxRepository - ): Promise { - console.log(`📤 OutboxRecovery: Resuming mint from PENDING_IPFS_SYNC...`); - - if (this.identityManager) { - const ipfsService = IpfsStorageService.getInstance(this.identityManager); - const syncResult = await ipfsService.syncNow({ - priority: SyncPriority.MEDIUM, - callerContext: 'outbox-mint-recovery-pending-sync', - }); - if (!syncResult.success) { - throw new Error("IPFS sync failed during mint recovery"); - } - } - - outboxRepo.updateMintStatus(entry.id, "READY_TO_SUBMIT"); - entry.status = "READY_TO_SUBMIT"; - - await this.resumeMintFromReadyToSubmit(entry, outboxRepo); - } - - /** - * Resume mint from READY_TO_SUBMIT: Submit to aggregator, wait for proof - */ - private async resumeMintFromReadyToSubmit( - entry: MintOutboxEntry, - outboxRepo: OutboxRepository - ): Promise { - console.log(`📤 OutboxRecovery: Resuming mint from READY_TO_SUBMIT...`); - - // Reconstruct MintCommitment from stored MintTransactionData - const mintData = await MintTransactionData.fromJSON(JSON.parse(entry.mintDataJson)); - const commitment = await MintCommitment.create(mintData); - - // Submit to aggregator (idempotent - REQUEST_ID_EXISTS is ok) - const client = ServiceProvider.stateTransitionClient; - const response = await client.submitMintCommitment(commitment); - - if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") { - throw new Error(`Aggregator mint submission failed: ${response.status}`); - } - - outboxRepo.updateMintStatus(entry.id, "SUBMITTED"); - entry.status = "SUBMITTED"; - - await this.resumeMintFromSubmitted(entry, outboxRepo); - } - - /** - * Resume mint from SUBMITTED: Wait for inclusion proof, then create token - */ - private async resumeMintFromSubmitted( - entry: MintOutboxEntry, - outboxRepo: OutboxRepository - ): Promise { - console.log(`📤 OutboxRecovery: Resuming mint from SUBMITTED...`); - - // Reconstruct MintCommitment - const mintData = await MintTransactionData.fromJSON(JSON.parse(entry.mintDataJson)); - const commitment = await MintCommitment.create(mintData); - - // Wait for inclusion proof (with dev mode bypass if enabled) - const inclusionProof = await waitInclusionProofWithDevBypass(commitment); - - // Create genesis transaction - const genesisTransaction = commitment.toTransaction(inclusionProof); - - // Update entry with proof data - outboxRepo.updateMintEntry(entry.id, { - status: "PROOF_RECEIVED", - inclusionProofJson: JSON.stringify(inclusionProof.toJSON()), - mintTransactionJson: JSON.stringify(genesisTransaction.toJSON()), - }); - entry.status = "PROOF_RECEIVED"; - entry.inclusionProofJson = JSON.stringify(inclusionProof.toJSON()); - entry.mintTransactionJson = JSON.stringify(genesisTransaction.toJSON()); - - await this.resumeMintFromProofReceived(entry, outboxRepo); - } - - /** - * Resume mint from PROOF_RECEIVED: Create final token and save to storage - */ - private async resumeMintFromProofReceived( - entry: MintOutboxEntry, - outboxRepo: OutboxRepository - ): Promise { - console.log(`📤 OutboxRecovery: Resuming mint from PROOF_RECEIVED...`); - - if (!entry.mintTransactionJson || !this.identityManager) { - throw new Error("Missing data for mint token creation"); - } - - // Reconstruct data needed for token creation - const mintData = await MintTransactionData.fromJSON(JSON.parse(entry.mintDataJson)); - const genesisTransaction = JSON.parse(entry.mintTransactionJson); - - // Get signing service from identity - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - throw new Error("No identity available for mint recovery"); - } - - const secret = Buffer.from(identity.privateKey, "hex"); - const signingService = await SigningService.createFromSecret(secret); - - // Reconstruct token type and ID - const tokenType = new TokenType(Buffer.from(entry.tokenTypeHex, "hex")); - const salt = Buffer.from(entry.salt, "hex"); - - // For nametag mints, derive token ID from nametag - let tokenId: TokenId; - if (entry.type === "MINT_NAMETAG" && entry.nametag) { - tokenId = await TokenId.fromNameTag(entry.nametag); - } else { - // For generic mints, get token ID from mint data - tokenId = mintData.tokenId; - } - - // Create predicate - const predicate = await UnmaskedPredicate.create( - tokenId, - tokenType, - signingService, - HashAlgorithm.SHA256, - salt - ); - - // Create final token - const tokenState = new TokenState(predicate, null); - const trustBase = ServiceProvider.getRootTrustBase(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let token: Token; - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.warn("⚠️ Creating recovered token WITHOUT verification (dev mode)"); - const tokenJson = { - version: "2.0", - state: tokenState.toJSON(), - genesis: genesisTransaction, - transactions: [], - nametags: [], - }; - token = await Token.fromJSON(tokenJson); - } else { - // Import genesis transaction properly - const { MintTransaction } = await import("@unicitylabs/state-transition-sdk/lib/transaction/MintTransaction"); - const mintTx = await MintTransaction.fromJSON(genesisTransaction); - token = await Token.mint(trustBase, tokenState, mintTx); - } - - // Update outbox with final token - outboxRepo.updateMintEntry(entry.id, { - status: "COMPLETED", - tokenJson: JSON.stringify(normalizeSdkTokenToStorage(token.toJSON())), - }); - - // Save to storage based on mint type - if (entry.type === "MINT_NAMETAG" && entry.nametag) { - const nametagData: NametagData = { - name: entry.nametag, - token: token.toJSON(), - timestamp: Date.now(), - format: "txf", - version: "2.0", - }; - - // Get current identity for address context - const identity = await this.getIdentityFromManager(); - if (!identity) { - console.warn(`📤 OutboxRecovery: Cannot save nametag - no identity available`); - return; - } - - setNametagForAddress(identity.address, nametagData); - console.log(`📤 OutboxRecovery: Recovered nametag "${entry.nametag}" and saved to storage`); - - // CRITICAL: Publish Nostr binding after recovery - // Without this, the nametag won't be found on Nostr and validation will fail - try { - const nostr = NostrService.getInstance(this.identityManager); - await nostr.start(); - - const proxyAddress = await ProxyAddress.fromNameTag(entry.nametag); - console.log(`📤 OutboxRecovery: Publishing Nostr binding: ${entry.nametag} -> ${proxyAddress.address}`); - - const published = await nostr.publishNametagBinding( - entry.nametag, - proxyAddress.address - ); - - if (published) { - console.log(`📤 OutboxRecovery: Nostr binding published successfully for "${entry.nametag}"`); - } else { - console.warn(`📤 OutboxRecovery: Nostr binding publish returned false for "${entry.nametag}"`); - } - } catch (nostrError) { - // Don't fail the entire recovery if Nostr fails - token is already minted - console.warn(`📤 OutboxRecovery: Nostr binding publish failed for "${entry.nametag}":`, nostrError); - } - } - - console.log(`📤 OutboxRecovery: Mint entry ${entry.id.slice(0, 8)}... recovered and completed`); - } -} - -// Export singleton getter for convenience -export function getOutboxRecoveryService(): OutboxRecoveryService { - return OutboxRecoveryService.getInstance(); -} diff --git a/src/components/wallet/L3/services/RegistryService.ts b/src/components/wallet/L3/services/RegistryService.ts deleted file mode 100644 index 46141df4..00000000 --- a/src/components/wallet/L3/services/RegistryService.ts +++ /dev/null @@ -1,141 +0,0 @@ -import axios from 'axios'; -import bundledRegistry from '../../../../assets/unicity-ids.testnet.json'; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; - - -export interface IconEntry { - url: string; -} - -export interface TokenDefinition { - network: string; - assetKind: 'fungible' | 'non-fungible'; - name: string; - symbol?: string; - decimals?: number; - description: string; - icon?: string; // Legacy - icons?: IconEntry[]; // New format - id: string; // Hex CoinID -} - - -const REGISTRY_URL = "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet.json"; -const CACHE_VALIDITY_MS = 24 * 60 * 60 * 1000; // 24 Hours - -export class RegistryService { - private static instance: RegistryService; - - private definitionsById: Map = new Map(); - private isInitialized = false; - private initPromise: Promise | null = null; - - private constructor() { - this.loadFromBundled(); - this.initPromise = this.init(); - } - - static getInstance(): RegistryService { - if (!RegistryService.instance) { - RegistryService.instance = new RegistryService(); - } - return RegistryService.instance; - } - - private async init() { - if (this.isInitialized) return; - - const cachedData = localStorage.getItem(STORAGE_KEYS.UNICITY_IDS_CACHE); - const timestampStr = localStorage.getItem(STORAGE_KEYS.UNICITY_IDS_TIMESTAMP); - const timestamp = timestampStr ? parseInt(timestampStr, 10) : 0; - const isStale = (Date.now() - timestamp) > CACHE_VALIDITY_MS; - - if (cachedData && !isStale) { - console.log("Registry: Loading from local cache"); - try { - const definitions = JSON.parse(cachedData) as TokenDefinition[]; - this.updateMap(definitions); - this.isInitialized = true; - return; - } catch (e) { - console.warn("Registry: Cache corrupted, falling back", e); - } - } - - if (!cachedData || isStale) { - console.log("Registry: Cache stale or missing, fetching from GitHub..."); - await this.fetchAndCacheRegistry(); - } - - this.isInitialized = true; - } - - /** - * Ensure registry is initialized before use - */ - async ensureInitialized(): Promise { - if (this.initPromise) { - await this.initPromise; - } - } - - private loadFromBundled() { - const definitions = bundledRegistry as unknown as TokenDefinition[]; - this.updateMap(definitions); - } - - private updateMap(definitions: TokenDefinition[]) { - this.definitionsById.clear(); - definitions.forEach(def => { - this.definitionsById.set(def.id.toLowerCase(), def); - }); - } - - private async fetchAndCacheRegistry() { - try { - const response = await axios.get(REGISTRY_URL, { timeout: 10000 }); - - if (Array.isArray(response.data)) { - console.log(`Registry: Updated from GitHub (${response.data.length} items)`); - - // Update Memory - this.updateMap(response.data); - - // Update Storage - localStorage.setItem(STORAGE_KEYS.UNICITY_IDS_CACHE, JSON.stringify(response.data)); - localStorage.setItem(STORAGE_KEYS.UNICITY_IDS_TIMESTAMP, Date.now().toString()); - } - } catch (e) { - console.error("Registry: Failed to fetch from GitHub", e); - } - } - - getCoinDefinition(coinIdHex: string): TokenDefinition | undefined { - if (!coinIdHex) return undefined; - - const id = coinIdHex.toLowerCase(); - const def = this.definitionsById.get(id); - - if (!def) { - console.log(`Registry: Coin ${id} not found, trying force refresh...`); - this.fetchAndCacheRegistry(); - } - - return def; - } - - getIconUrl(def: TokenDefinition): string | null { - if (def.icons && def.icons.length > 0) { - const pngIcon = def.icons.find(i => i.url.toLowerCase().includes('.png')); - if (pngIcon) return pngIcon.url; - - return def.icons[0].url; - } - - return def.icon || null; - } - - getAllDefinitions(): TokenDefinition[] { - return Array.from(this.definitionsById.values()); - } -} \ No newline at end of file diff --git a/src/components/wallet/L3/services/ServiceProvider.ts b/src/components/wallet/L3/services/ServiceProvider.ts deleted file mode 100644 index b2e718ee..00000000 --- a/src/components/wallet/L3/services/ServiceProvider.ts +++ /dev/null @@ -1,206 +0,0 @@ -import trustBaseJson from "../../../../assets/trustbase-testnet.json"; -import { StateTransitionClient } from "@unicitylabs/state-transition-sdk/lib/StateTransitionClient"; -import { AggregatorClient } from "@unicitylabs/state-transition-sdk/lib/api/AggregatorClient"; -import { RootTrustBase } from "@unicitylabs/state-transition-sdk/lib/bft/RootTrustBase"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; - -const UNICITY_AGGREGATOR_URL = import.meta.env.VITE_AGGREGATOR_URL || "https://goggregator-test.unicity.network"; -const API_KEY = "sk_06365a9c44654841a366068bcfc68986"; -const TEST_SIG_KEY = - "025f37d20e5b18909361e0ead7ed17c69b417bee70746c9e9c2bcb1394d921d4ae"; - -export class ServiceProvider { - private static _aggregatorClient: AggregatorClient | null = null; - private static _stateTransitionClient: StateTransitionClient | null = null; - private static _rootTrustBase: RootTrustBase | null = null; - private static _runtimeAggregatorUrl: string | null = null; - private static _skipTrustBaseVerification: boolean = false; - private static _initialized: boolean = false; - - /** - * Initialize dev settings from localStorage (called on first access) - */ - private static _initFromStorage(): void { - if (this._initialized) return; - this._initialized = true; - - try { - // Load custom aggregator URL - const storedUrl = localStorage.getItem(STORAGE_KEYS.DEV_AGGREGATOR_URL); - if (storedUrl) { - this._runtimeAggregatorUrl = storedUrl; - console.log(`📦 Loaded dev aggregator URL from storage: ${storedUrl}`); - } - - // Load trust base verification skip flag - const storedSkip = localStorage.getItem(STORAGE_KEYS.DEV_SKIP_TRUST_BASE); - if (storedSkip === "true") { - this._skipTrustBaseVerification = true; - console.warn("⚠️ Trust base verification is DISABLED (loaded from storage)"); - } - } catch (error) { - console.warn("Failed to load dev settings from localStorage:", error); - } - } - - /** - * Get the current aggregator URL (runtime override or default from env) - */ - static getAggregatorUrl(): string { - this._initFromStorage(); - return this._runtimeAggregatorUrl || UNICITY_AGGREGATOR_URL; - } - - /** - * Set a runtime aggregator URL override (dev tools only) - * Pass null to reset to default from environment variable - */ - static setAggregatorUrl(url: string | null): void { - this._initFromStorage(); - this._runtimeAggregatorUrl = url; - - // Persist to localStorage - try { - if (url) { - localStorage.setItem(STORAGE_KEYS.DEV_AGGREGATOR_URL, url); - } else { - localStorage.removeItem(STORAGE_KEYS.DEV_AGGREGATOR_URL); - } - } catch (error) { - console.warn("Failed to save dev aggregator URL to localStorage:", error); - } - - this.reset(); - } - - /** - * Reset all singleton instances (used when aggregator URL changes) - * Note: RootTrustBase is kept as it's aggregator-independent - */ - static reset(): void { - this._aggregatorClient = null; - this._stateTransitionClient = null; - } - - /** - * Check if trust base verification is being skipped (dev mode only) - */ - static isTrustBaseVerificationSkipped(): boolean { - this._initFromStorage(); - return this._skipTrustBaseVerification; - } - - /** - * Enable or disable trust base verification bypass (dev tools only) - * When enabled, SDK verification calls will be skipped to allow - * connecting to aggregators with different trust bases. - */ - static setSkipTrustBaseVerification(skip: boolean): void { - this._initFromStorage(); - this._skipTrustBaseVerification = skip; - - // Persist to localStorage - try { - if (skip) { - localStorage.setItem(STORAGE_KEYS.DEV_SKIP_TRUST_BASE, "true"); - } else { - localStorage.removeItem(STORAGE_KEYS.DEV_SKIP_TRUST_BASE); - } - } catch (error) { - console.warn("Failed to save trust base skip flag to localStorage:", error); - } - - if (skip) { - console.warn("⚠️ Trust base verification is now DISABLED - dev mode only!"); - } else { - console.log("✅ Trust base verification is now ENABLED"); - } - } - - /** - * Check if dev mode is active (any non-default settings) - */ - static isDevModeActive(): boolean { - this._initFromStorage(); - return this._runtimeAggregatorUrl !== null || this._skipTrustBaseVerification; - } - - /** - * Get current dev configuration for banner display - */ - static getDevConfig(): { aggregatorUrl: string | null; skipTrustBase: boolean } { - this._initFromStorage(); - return { - aggregatorUrl: this._runtimeAggregatorUrl, - skipTrustBase: this._skipTrustBaseVerification, - }; - } - - static get aggregatorClient(): AggregatorClient { - if (!this._aggregatorClient) { - const url = this.getAggregatorUrl(); - console.log(`Initializing AggregatorClient with URL: ${url}`); - this._aggregatorClient = new AggregatorClient(url, API_KEY); - } - return this._aggregatorClient; - } - - static get stateTransitionClient(): StateTransitionClient { - if (!this._stateTransitionClient) { - this._stateTransitionClient = new StateTransitionClient( - ServiceProvider.aggregatorClient - ); - } - return this._stateTransitionClient; - } - - static getRootTrustBase(): RootTrustBase { - if (this._rootTrustBase) { - return this._rootTrustBase; - } - - try { - if (trustBaseJson) { - this._rootTrustBase = RootTrustBase.fromJSON(trustBaseJson); - console.log("✅ TrustBase loaded from local assets"); - return this._rootTrustBase; - } - } catch (error) { - console.warn( - "⚠️ Failed to load TrustBase from file, attempting fallback...", - error - ); - } - - try { - console.log("Generating Fallback TrustBase..."); - - const fallbackJson = { - version: "1", - networkId: 0, - epoch: "1", - epochStartRound: "1", - rootNodes: [ - { - nodeId: "TEST_NODE", - sigKey: "0x" + TEST_SIG_KEY, - stake: "1", - }, - ], - quorumThreshold: "1", - stateHash: "", - changeRecordHash: null, - previousEntryHash: null, - signatures: {}, - }; - - this._rootTrustBase = RootTrustBase.fromJSON(fallbackJson); - - console.log("✅ TrustBase created using Fallback mechanism"); - return this._rootTrustBase; - } catch (e) { - console.error("CRITICAL: Failed to initialize TrustBase", e); - throw new Error("Critical: Could not initialize TrustBase"); - } - } -} diff --git a/src/components/wallet/L3/services/SyncCoordinator.ts b/src/components/wallet/L3/services/SyncCoordinator.ts deleted file mode 100644 index cc7e88c6..00000000 --- a/src/components/wallet/L3/services/SyncCoordinator.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * SyncCoordinator - Tab coordination for IPFS sync operations - * - * Uses BroadcastChannel API to coordinate sync operations across browser tabs. - * Implements leader election to ensure only one tab syncs at a time, preventing - * race conditions and duplicate IPNS publishes. - * - * Key features: - * - Leader election among tabs - * - Sync lock acquisition/release - * - Heartbeat for leader liveness detection - * - Graceful handoff on tab close - */ - -interface SyncMessage { - type: - | "leader-request" - | "leader-announce" - | "leader-ack" - | "sync-start" - | "sync-complete" - | "heartbeat" - | "ping" - | "pong"; - from: string; - timestamp: number; - payload?: unknown; -} - -// Singleton instance -let coordinatorInstance: SyncCoordinator | null = null; - -export class SyncCoordinator { - private channel: BroadcastChannel; - private readonly instanceId: string; - - // Leadership state - private isLeader = false; - private leaderId: string | null = null; - private leaderLastSeen: number = 0; - - // Sync state - private isSyncing = false; - private syncQueue: Array<{ - resolve: (acquired: boolean) => void; - timeout: ReturnType; - }> = []; - - // Timers - private heartbeatInterval: ReturnType | null = null; - private leaderCheckInterval: ReturnType | null = null; - - // Constants - private readonly LEADER_TIMEOUT = 10000; // 10s - leader considered dead if no heartbeat - private readonly HEARTBEAT_INTERVAL = 3000; // 3s heartbeat - private readonly LOCK_TIMEOUT = 30000; // 30s max wait for lock - - constructor() { - this.instanceId = crypto.randomUUID(); - - // Initialize BroadcastChannel - this.channel = new BroadcastChannel("ipfs-sync-coordinator"); - this.channel.onmessage = this.handleMessage.bind(this); - - // Start leader check interval - this.leaderCheckInterval = setInterval( - () => this.checkLeaderLiveness(), - this.LEADER_TIMEOUT / 2 - ); - - // Request leadership on startup - this.requestLeadership(); - - // Handle tab close - use pagehide instead of beforeunload - // beforeunload fires BEFORE the confirmation dialog, so cleanup would run - // even if user cancels the reload. pagehide only fires when page actually unloads. - window.addEventListener("pagehide", () => this.cleanup()); - - console.log(`📋 SyncCoordinator initialized: ${this.instanceId.slice(0, 8)}...`); - } - - /** - * Get the singleton instance - */ - static getInstance(): SyncCoordinator { - if (!coordinatorInstance) { - coordinatorInstance = new SyncCoordinator(); - } - return coordinatorInstance; - } - - /** - * Acquire sync lock - waits for leadership or current sync to complete - * Returns true if lock acquired, false if timeout - */ - async acquireLock(timeout: number = this.LOCK_TIMEOUT): Promise { - // If we're already the leader and not syncing, we have the lock - if (this.isLeader && !this.isSyncing) { - this.isSyncing = true; - this.broadcast({ type: "sync-start" }); - return true; - } - - // If another tab is leader and syncing, wait - return new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - // Timeout - remove from queue and return false - this.syncQueue = this.syncQueue.filter((q) => q.resolve !== resolve); - resolve(false); - }, timeout); - - this.syncQueue.push({ resolve, timeout: timeoutHandle }); - - // Ping leader to check if still alive - this.broadcast({ type: "ping" }); - }); - } - - /** - * Release sync lock - */ - releaseLock(): void { - if (!this.isSyncing) return; - - this.isSyncing = false; - this.broadcast({ type: "sync-complete" }); - - // Process waiting queue - this.processQueue(); - } - - /** - * Check if we currently hold the lock - */ - hasLock(): boolean { - return this.isLeader && this.isSyncing; - } - - /** - * Check if this tab is the leader - */ - isCurrentLeader(): boolean { - return this.isLeader; - } - - /** - * Request to become leader - */ - private requestLeadership(): void { - // If no leader or leader is dead, claim leadership - if (!this.leaderId || this.isLeaderDead()) { - this.becomeLeader(); - } else { - // Request leadership from current leader - this.broadcast({ type: "leader-request" }); - } - } - - /** - * Become the leader - */ - private becomeLeader(): void { - this.isLeader = true; - this.leaderId = this.instanceId; - this.leaderLastSeen = Date.now(); - - // Start heartbeat - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - } - this.heartbeatInterval = setInterval(() => { - this.broadcast({ type: "heartbeat" }); - }, this.HEARTBEAT_INTERVAL); - - // Announce leadership - this.broadcast({ type: "leader-announce" }); - - console.log(`📋 Became sync leader: ${this.instanceId.slice(0, 8)}...`); - - // Process any waiting sync requests - this.processQueue(); - } - - /** - * Check if current leader is dead (no heartbeat) - */ - private isLeaderDead(): boolean { - if (!this.leaderId) return true; - if (this.leaderId === this.instanceId) return false; - return Date.now() - this.leaderLastSeen > this.LEADER_TIMEOUT; - } - - /** - * Check leader liveness and take over if dead - */ - private checkLeaderLiveness(): void { - if (this.isLeader) return; - - if (this.isLeaderDead()) { - console.log(`📋 Leader ${this.leaderId?.slice(0, 8)}... appears dead, taking over`); - this.becomeLeader(); - } - } - - /** - * Process queued sync requests - */ - private processQueue(): void { - if (!this.isLeader || this.isSyncing || this.syncQueue.length === 0) { - return; - } - - // Grant lock to first in queue - const next = this.syncQueue.shift(); - if (next) { - clearTimeout(next.timeout); - this.isSyncing = true; - this.broadcast({ type: "sync-start" }); - next.resolve(true); - } - } - - /** - * Handle incoming messages - */ - private handleMessage(event: MessageEvent): void { - const msg = event.data; - - // Ignore our own messages - if (msg.from === this.instanceId) return; - - switch (msg.type) { - case "leader-announce": - // Another tab claimed leadership - if (this.isLeader && msg.from !== this.instanceId) { - // Resolve conflict - higher ID wins - if (msg.from > this.instanceId) { - console.log(`📋 Yielding leadership to ${msg.from.slice(0, 8)}...`); - this.isLeader = false; - this.leaderId = msg.from; - this.leaderLastSeen = Date.now(); - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; - } - } else { - // We have higher ID, re-announce - this.broadcast({ type: "leader-announce" }); - } - } else { - this.leaderId = msg.from; - this.leaderLastSeen = Date.now(); - console.log(`📋 Acknowledged leader: ${msg.from.slice(0, 8)}...`); - } - break; - - case "heartbeat": - if (msg.from === this.leaderId) { - this.leaderLastSeen = Date.now(); - } - break; - - case "leader-request": - // Someone wants leadership - if we're leader, send heartbeat - if (this.isLeader) { - this.broadcast({ type: "heartbeat" }); - } - break; - - case "sync-start": - // Leader started syncing - this.leaderLastSeen = Date.now(); - break; - - case "sync-complete": - // Leader finished syncing - might be our turn - this.leaderLastSeen = Date.now(); - // If we have queued requests and we're the leader, process them - if (this.isLeader) { - this.processQueue(); - } - break; - - case "ping": - // Liveness check - respond if we're leader - if (this.isLeader) { - this.broadcast({ type: "pong" }); - } - break; - - case "pong": - // Leader is alive - if (msg.from === this.leaderId) { - this.leaderLastSeen = Date.now(); - } - break; - } - } - - /** - * Broadcast a message to all tabs - */ - private broadcast(msg: Omit): void { - this.channel.postMessage({ - ...msg, - from: this.instanceId, - timestamp: Date.now(), - } as SyncMessage); - } - - /** - * Cleanup on tab close - */ - private cleanup(): void { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - } - if (this.leaderCheckInterval) { - clearInterval(this.leaderCheckInterval); - } - - // If we're leader and syncing, let others know - if (this.isLeader) { - this.broadcast({ type: "sync-complete" }); - } - - this.channel.close(); - } - - /** - * Shutdown the coordinator - */ - shutdown(): void { - this.cleanup(); - coordinatorInstance = null; - console.log(`📋 SyncCoordinator shutdown: ${this.instanceId.slice(0, 8)}...`); - } -} - -/** - * Get the singleton SyncCoordinator instance - */ -export function getSyncCoordinator(): SyncCoordinator { - return SyncCoordinator.getInstance(); -} diff --git a/src/components/wallet/L3/services/SyncQueue.ts b/src/components/wallet/L3/services/SyncQueue.ts deleted file mode 100644 index 3a4041e1..00000000 --- a/src/components/wallet/L3/services/SyncQueue.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * SyncQueue - Priority-based queue for IPFS sync requests - * - * Solves the "Sync already in progress" error by queuing requests instead of rejecting them. - * Features: - * - Priority-based ordering (HIGH > MEDIUM > LOW) - * - FIFO within same priority level - * - Coalescing for LOW priority requests (auto-sync debouncing) - * - Per-request timeout handling - * - Integration with SyncCoordinator for cross-tab locking - */ - -import type { StorageResult } from "./IpfsStorageService"; - -// ========================================== -// Types -// ========================================== - -/** - * Priority levels for sync requests - */ -export const SyncPriority = { - LOW: 0, // Auto-sync from wallet-updated event (coalesced) - MEDIUM: 1, // Post-transfer sync, outbox recovery - HIGH: 2, // Pre-transfer sync, Nostr incoming, nametag mint - CRITICAL: 3 // Reserved for future use (emergency sync) -} as const; - -export type SyncPriority = (typeof SyncPriority)[keyof typeof SyncPriority]; - -/** Helper to get priority name for logging */ -function priorityName(priority: SyncPriority): string { - switch (priority) { - case SyncPriority.LOW: return 'LOW'; - case SyncPriority.MEDIUM: return 'MEDIUM'; - case SyncPriority.HIGH: return 'HIGH'; - case SyncPriority.CRITICAL: return 'CRITICAL'; - default: return String(priority); - } -} - -/** - * Options for syncNow() calls - */ -export interface SyncOptions { - /** Force IPNS publish even if CID unchanged (for IPNS recovery) */ - forceIpnsPublish?: boolean; - /** Priority level - higher priority requests are processed first */ - priority?: SyncPriority; - /** Maximum time to wait in queue before timing out (ms) */ - timeout?: number; - /** Identifier for debugging/logging */ - callerContext?: string; - /** For LOW priority: coalesce multiple requests into one (default: true) */ - coalesce?: boolean; - /** Internal: true when called from IPNS retry loop (prevents recursive retry) */ - isRetryAttempt?: boolean; -} - -/** - * Internal queue entry - */ -interface SyncQueueEntry { - id: string; - priority: SyncPriority; - options: { forceIpnsPublish?: boolean; isRetryAttempt?: boolean }; - resolve: (result: StorageResult) => void; - reject: (error: Error) => void; - timeoutHandle: ReturnType | null; - createdAt: number; - callerContext?: string; -} - -/** - * Queue status for monitoring/debugging - */ -export interface QueueStatus { - queueLength: number; - isProcessing: boolean; - pendingCoalesce: boolean; - entriesByPriority: Record; -} - -/** - * Executor function type - the actual sync implementation - */ -type SyncExecutor = (options?: { forceIpnsPublish?: boolean; isRetryAttempt?: boolean }) => Promise; - -// ========================================== -// SyncQueue -// ========================================== - -export class SyncQueue { - private queue: SyncQueueEntry[] = []; - private isProcessing = false; - - // Coalescing state for LOW priority requests - private pendingCoalesce: { - entry: SyncQueueEntry; - additionalResolvers: Array<{ - resolve: (result: StorageResult) => void; - reject: (error: Error) => void; - }>; - timer: ReturnType; - } | null = null; - - // Configuration - readonly COALESCE_WINDOW_MS = 5000; // 5 second window for coalescing LOW priority - readonly DEFAULT_TIMEOUT_MS = 60000; // 60 second default timeout - readonly MAX_QUEUE_SIZE = 50; // Prevent unbounded growth - - private executor: SyncExecutor; - private idCounter = 0; - - constructor(executor: SyncExecutor) { - this.executor = executor; - } - - /** - * Enqueue a sync request and return a promise that resolves when sync completes - */ - async enqueue(options: SyncOptions = {}): Promise { - const { - forceIpnsPublish = false, - priority = SyncPriority.MEDIUM, - timeout = this.DEFAULT_TIMEOUT_MS, - callerContext, - coalesce = true, - isRetryAttempt = false, - } = options; - - // Check queue size limit - if (this.queue.length >= this.MAX_QUEUE_SIZE) { - console.warn(`📦 [SyncQueue] Queue full (${this.MAX_QUEUE_SIZE}), rejecting request from ${callerContext || 'unknown'}`); - return { - success: false, - timestamp: Date.now(), - error: "Sync queue is full - too many pending requests", - }; - } - - return new Promise((resolve, reject) => { - const entry: SyncQueueEntry = { - id: `sync-${++this.idCounter}`, - priority, - options: { forceIpnsPublish, isRetryAttempt }, - resolve, - reject, - timeoutHandle: null, - createdAt: Date.now(), - callerContext, - }; - - // Set up timeout - if (timeout > 0) { - entry.timeoutHandle = setTimeout(() => { - this.handleTimeout(entry); - }, timeout); - } - - // Handle LOW priority coalescing - if (priority === SyncPriority.LOW && coalesce) { - this.handleCoalesce(entry); - return; - } - - // Insert into queue by priority (higher priority first, FIFO within same priority) - this.insertByPriority(entry); - console.log(`📦 [SyncQueue] Queued ${entry.id} (priority=${priorityName(priority)}, context=${callerContext || 'none'}, queue=${this.queue.length})`); - - // Start processing if not already - this.processNextIfIdle(); - }); - } - - /** - * Handle LOW priority coalescing - batch multiple auto-syncs into one - */ - private handleCoalesce(entry: SyncQueueEntry): void { - if (this.pendingCoalesce) { - // Add to existing coalesce batch - console.log(`📦 [SyncQueue] Coalescing ${entry.id} into pending batch`); - this.pendingCoalesce.additionalResolvers.push({ - resolve: entry.resolve, - reject: entry.reject, - }); - // Clear timeout since this entry is being coalesced - if (entry.timeoutHandle) { - clearTimeout(entry.timeoutHandle); - } - // Merge forceIpnsPublish (if any request needs it, do it) - if (entry.options.forceIpnsPublish) { - this.pendingCoalesce.entry.options.forceIpnsPublish = true; - } - // Merge isRetryAttempt (if any request is a retry, treat batch as retry) - if (entry.options.isRetryAttempt) { - this.pendingCoalesce.entry.options.isRetryAttempt = true; - } - return; - } - - // Start new coalesce window - console.log(`📦 [SyncQueue] Starting coalesce window for ${entry.id}`); - this.pendingCoalesce = { - entry, - additionalResolvers: [], - timer: setTimeout(() => { - this.flushCoalesce(); - }, this.COALESCE_WINDOW_MS), - }; - } - - /** - * Flush coalesced requests into the queue - */ - private flushCoalesce(): void { - if (!this.pendingCoalesce) return; - - const { entry, additionalResolvers } = this.pendingCoalesce; - this.pendingCoalesce = null; - - // Wrap the original resolver to also resolve all coalesced requests - const originalResolve = entry.resolve; - const originalReject = entry.reject; - - entry.resolve = (result: StorageResult) => { - originalResolve(result); - for (const resolver of additionalResolvers) { - resolver.resolve(result); - } - }; - - entry.reject = (error: Error) => { - originalReject(error); - for (const resolver of additionalResolvers) { - resolver.reject(error); - } - }; - - const totalCoalesced = additionalResolvers.length + 1; - console.log(`📦 [SyncQueue] Flushing coalesced batch: ${totalCoalesced} request(s)`); - - this.insertByPriority(entry); - this.processNextIfIdle(); - } - - /** - * Insert entry into queue maintaining priority order - */ - private insertByPriority(entry: SyncQueueEntry): void { - // Find insertion point: after all entries with >= priority - let insertIndex = this.queue.length; - for (let i = 0; i < this.queue.length; i++) { - if (this.queue[i].priority < entry.priority) { - insertIndex = i; - break; - } - } - this.queue.splice(insertIndex, 0, entry); - } - - /** - * Handle entry timeout - */ - private handleTimeout(entry: SyncQueueEntry): void { - // Remove from queue if still there - const index = this.queue.indexOf(entry); - if (index !== -1) { - this.queue.splice(index, 1); - console.warn(`📦 [SyncQueue] Timeout for ${entry.id} (context=${entry.callerContext || 'none'})`); - entry.resolve({ - success: false, - timestamp: Date.now(), - error: `Sync request timed out after waiting in queue (context: ${entry.callerContext || 'unknown'})`, - }); - } - - // Also check if it's in pending coalesce - if (this.pendingCoalesce?.entry === entry) { - clearTimeout(this.pendingCoalesce.timer); - // Resolve all coalesced requests with timeout error - const result: StorageResult = { - success: false, - timestamp: Date.now(), - error: "Sync request timed out after waiting in queue", - }; - entry.resolve(result); - for (const resolver of this.pendingCoalesce.additionalResolvers) { - resolver.resolve(result); - } - this.pendingCoalesce = null; - } - } - - /** - * Start processing if not already processing - */ - private processNextIfIdle(): void { - if (!this.isProcessing && this.queue.length > 0) { - this.processNext(); - } - } - - /** - * Process the next entry in the queue - */ - private async processNext(): Promise { - if (this.queue.length === 0) { - this.isProcessing = false; - return; - } - - this.isProcessing = true; - const entry = this.queue.shift()!; - - // Clear timeout since we're processing - if (entry.timeoutHandle) { - clearTimeout(entry.timeoutHandle); - entry.timeoutHandle = null; - } - - const waitTime = Date.now() - entry.createdAt; - console.log(`📦 [SyncQueue] Processing ${entry.id} (priority=${priorityName(entry.priority)}, context=${entry.callerContext || 'none'}, waited=${waitTime}ms, remaining=${this.queue.length})`); - - try { - const result = await this.executor(entry.options); - entry.resolve(result); - } catch (error) { - console.error(`📦 [SyncQueue] Error in ${entry.id}:`, error); - entry.resolve({ - success: false, - timestamp: Date.now(), - error: error instanceof Error ? error.message : String(error), - }); - } - - // Process next entry - // Use setImmediate-like behavior to prevent stack overflow on long queues - setTimeout(() => this.processNext(), 0); - } - - /** - * Get current queue status for monitoring - */ - getQueueStatus(): QueueStatus { - const entriesByPriority: Record = { - [SyncPriority.LOW]: 0, - [SyncPriority.MEDIUM]: 0, - [SyncPriority.HIGH]: 0, - [SyncPriority.CRITICAL]: 0, - }; - - for (const entry of this.queue) { - entriesByPriority[entry.priority]++; - } - - return { - queueLength: this.queue.length, - isProcessing: this.isProcessing, - pendingCoalesce: this.pendingCoalesce !== null, - entriesByPriority, - }; - } - - /** - * Shutdown the queue - reject all pending requests - */ - shutdown(): void { - console.log(`📦 [SyncQueue] Shutting down, clearing ${this.queue.length} pending requests`); - - // Clear coalesce timer and reject - if (this.pendingCoalesce) { - clearTimeout(this.pendingCoalesce.timer); - const error = new Error("SyncQueue shutdown"); - this.pendingCoalesce.entry.reject(error); - for (const resolver of this.pendingCoalesce.additionalResolvers) { - resolver.reject(error); - } - this.pendingCoalesce = null; - } - - // Reject all queued entries - for (const entry of this.queue) { - if (entry.timeoutHandle) { - clearTimeout(entry.timeoutHandle); - } - entry.resolve({ - success: false, - timestamp: Date.now(), - error: "SyncQueue shutdown", - }); - } - this.queue = []; - this.isProcessing = false; - } -} diff --git a/src/components/wallet/L3/services/TokenBackupService.ts b/src/components/wallet/L3/services/TokenBackupService.ts deleted file mode 100644 index f42a6bb2..00000000 --- a/src/components/wallet/L3/services/TokenBackupService.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * Token Backup Service - * - * Provides encrypted local backup for tokens since Unicity cannot recover lost tokens. - * - * CRITICAL CONTEXT: - * - Unicity blockchain stores ONLY cryptographic hashes, NOT token data - * - If a token is lost (IPFS failure, device loss, sync error), it is UNRECOVERABLE - * - This service provides a safety net via encrypted local/downloadable backups - * - * Features: - * - AES-256-GCM encryption with PBKDF2 key derivation - * - Compatible with browser Web Crypto API - * - Backup status monitoring (warns when backup is stale) - * - Support for both file download and localStorage backup - */ - -import { Token as LocalToken, TokenStatus } from "../data/model"; -import type { TxfToken } from "./types/TxfTypes"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; - -// ========================================== -// Types -// ========================================== - -export interface BackupMetadata { - version: "1.0"; - timestamp: number; - tokenCount: number; - walletAddress: string; - checksum: string; // SHA-256 of token data for integrity -} - -export interface TokenBackupData { - metadata: BackupMetadata; - tokens: { - id: string; - jsonData: string; - coinId: string; - amount: string; - symbol: string; - type: string; - }[]; -} - -export interface BackupStatus { - needsBackup: boolean; - reason: string; - lastBackupTime: number | null; - daysSinceBackup: number | null; - lastSyncTime: number | null; - daysSinceSync: number | null; -} - -// ========================================== -// TokenBackupService -// ========================================== - -export class TokenBackupService { - private static instance: TokenBackupService | null = null; - - private readonly BACKUP_STALE_DAYS = 7; - private readonly SYNC_WARNING_DAYS = 3; - - // ========================================== - // Singleton - // ========================================== - - static getInstance(): TokenBackupService { - if (!TokenBackupService.instance) { - TokenBackupService.instance = new TokenBackupService(); - } - return TokenBackupService.instance; - } - - // ========================================== - // Public API - // ========================================== - - /** - * Create encrypted backup of all tokens - * Returns a Blob that can be downloaded by the user - */ - async createEncryptedBackup( - tokens: LocalToken[], - password: string, - walletAddress: string - ): Promise<{ blob: Blob; tokenCount: number; checksum: string }> { - // Build backup data structure - const tokensData = tokens - .filter(t => t.jsonData) // Only include tokens with valid data - .map(t => ({ - id: t.id, - jsonData: t.jsonData!, - coinId: t.coinId || "", - amount: t.amount || "0", - symbol: t.symbol || "", - type: t.type, - })); - - // Calculate checksum for integrity verification - const checksum = await this.calculateChecksum(JSON.stringify(tokensData)); - - const backup: TokenBackupData = { - metadata: { - version: "1.0", - timestamp: Date.now(), - tokenCount: tokensData.length, - walletAddress, - checksum, - }, - tokens: tokensData, - }; - - // Encrypt with password - const encrypted = await this.encryptWithPassword( - JSON.stringify(backup), - password - ); - - // Update backup timestamp - this.updateBackupTimestamp(); - - console.log(`📦 Backup created: ${tokensData.length} tokens, checksum: ${checksum.slice(0, 16)}...`); - - return { - blob: new Blob([encrypted], { type: "application/octet-stream" }), - tokenCount: tokensData.length, - checksum, - }; - } - - /** - * Restore tokens from encrypted backup - * Validates checksum to ensure data integrity - */ - async restoreFromBackup( - encryptedData: ArrayBuffer, - password: string - ): Promise<{ - tokens: LocalToken[]; - metadata: BackupMetadata; - warnings: string[]; - }> { - const warnings: string[] = []; - - // Decrypt - let decrypted: string; - try { - decrypted = await this.decryptWithPassword(encryptedData, password); - } catch { - throw new Error("Failed to decrypt backup. Wrong password or corrupted file."); - } - - // Parse backup data - let backup: TokenBackupData; - try { - backup = JSON.parse(decrypted); - } catch { - throw new Error("Invalid backup format. File may be corrupted."); - } - - // Validate structure - if (!backup.metadata || !backup.tokens || !Array.isArray(backup.tokens)) { - throw new Error("Invalid backup structure"); - } - - // Verify checksum - const calculatedChecksum = await this.calculateChecksum(JSON.stringify(backup.tokens)); - if (calculatedChecksum !== backup.metadata.checksum) { - warnings.push("Checksum mismatch - backup may have been tampered with"); - } - - // Check backup age - const backupAge = Date.now() - backup.metadata.timestamp; - const daysSinceBackup = backupAge / (1000 * 60 * 60 * 24); - if (daysSinceBackup > this.BACKUP_STALE_DAYS) { - warnings.push(`Backup is ${Math.floor(daysSinceBackup)} days old. Some tokens may have changed.`); - } - - // Convert to Token objects - const tokens = backup.tokens.map(t => new LocalToken({ - id: t.id, - name: t.type === "NFT" ? "NFT" : "Token", - type: t.type, - timestamp: backup.metadata.timestamp, - jsonData: t.jsonData, - status: TokenStatus.CONFIRMED, - amount: t.amount, - coinId: t.coinId, - symbol: t.symbol, - sizeBytes: t.jsonData.length, - })); - - console.log(`📦 Backup restored: ${tokens.length} tokens from ${new Date(backup.metadata.timestamp).toISOString()}`); - - return { tokens, metadata: backup.metadata, warnings }; - } - - /** - * Create a quick local backup in localStorage (encrypted) - * Useful for automatic periodic backups - */ - async createLocalBackup( - tokens: LocalToken[], - password: string, - walletAddress: string - ): Promise { - const { blob } = await this.createEncryptedBackup(tokens, password, walletAddress); - const arrayBuffer = await blob.arrayBuffer(); - const base64 = this.arrayBufferToBase64(arrayBuffer); - - localStorage.setItem(STORAGE_KEYS.ENCRYPTED_TOKEN_BACKUP, base64); - console.log(`📦 Local backup saved to localStorage`); - } - - /** - * Restore from local backup in localStorage - */ - async restoreFromLocalBackup( - password: string - ): Promise<{ - tokens: LocalToken[]; - metadata: BackupMetadata; - warnings: string[]; - } | null> { - const base64 = localStorage.getItem(STORAGE_KEYS.ENCRYPTED_TOKEN_BACKUP); - if (!base64) { - return null; - } - - const arrayBuffer = this.base64ToArrayBuffer(base64); - return this.restoreFromBackup(arrayBuffer, password); - } - - /** - * Check if backup is recommended (IPFS sync old or failed) - * Call this on app startup to prompt user - */ - checkBackupStatus(): BackupStatus { - const lastBackup = localStorage.getItem(STORAGE_KEYS.TOKEN_BACKUP_TIMESTAMP); - const lastSync = localStorage.getItem(STORAGE_KEYS.LAST_IPFS_SYNC_SUCCESS); - - const now = Date.now(); - const lastBackupTime = lastBackup ? parseInt(lastBackup, 10) : null; - const lastSyncTime = lastSync ? parseInt(lastSync, 10) : null; - - const daysSinceBackup = lastBackupTime - ? (now - lastBackupTime) / (1000 * 60 * 60 * 24) - : null; - - const daysSinceSync = lastSyncTime - ? (now - lastSyncTime) / (1000 * 60 * 60 * 24) - : null; - - // Determine if backup is needed - let needsBackup = false; - let reason = ""; - - if (!lastBackupTime) { - needsBackup = true; - reason = "No backup recorded. Create your first backup to protect your tokens!"; - } else if (daysSinceBackup !== null && daysSinceBackup > this.BACKUP_STALE_DAYS) { - needsBackup = true; - reason = `Last backup was ${Math.floor(daysSinceBackup)} days ago. Create a fresh backup!`; - } else if (!lastSyncTime) { - needsBackup = true; - reason = "No IPFS sync recorded. Backup recommended to protect tokens."; - } else if (daysSinceSync !== null && daysSinceSync > this.SYNC_WARNING_DAYS) { - needsBackup = true; - reason = `No IPFS sync in ${Math.floor(daysSinceSync)} days. Create a backup!`; - } - - return { - needsBackup, - reason, - lastBackupTime, - daysSinceBackup, - lastSyncTime, - daysSinceSync, - }; - } - - /** - * Update the backup timestamp (called after successful backup) - */ - updateBackupTimestamp(): void { - localStorage.setItem(STORAGE_KEYS.TOKEN_BACKUP_TIMESTAMP, Date.now().toString()); - } - - /** - * Update the sync timestamp (call after successful IPFS sync) - */ - updateSyncTimestamp(): void { - localStorage.setItem(STORAGE_KEYS.LAST_IPFS_SYNC_SUCCESS, Date.now().toString()); - } - - /** - * Get a summary of what's in a backup without full decryption - * Useful for showing backup info before restore - */ - async getBackupInfo( - encryptedData: ArrayBuffer, - password: string - ): Promise { - try { - const decrypted = await this.decryptWithPassword(encryptedData, password); - const backup = JSON.parse(decrypted) as TokenBackupData; - return backup.metadata; - } catch { - return null; - } - } - - /** - * Verify backup integrity without full restore - */ - async verifyBackup( - encryptedData: ArrayBuffer, - password: string - ): Promise<{ valid: boolean; error?: string }> { - try { - const decrypted = await this.decryptWithPassword(encryptedData, password); - const backup = JSON.parse(decrypted) as TokenBackupData; - - // Verify checksum - const calculatedChecksum = await this.calculateChecksum(JSON.stringify(backup.tokens)); - if (calculatedChecksum !== backup.metadata.checksum) { - return { valid: false, error: "Checksum mismatch" }; - } - - // Verify each token has valid TXF structure - for (const tokenData of backup.tokens) { - try { - const txf = JSON.parse(tokenData.jsonData) as TxfToken; - if (!txf.genesis || !txf.state) { - return { valid: false, error: `Token ${tokenData.id} has invalid structure` }; - } - } catch { - return { valid: false, error: `Token ${tokenData.id} has invalid JSON` }; - } - } - - return { valid: true }; - } catch (err) { - return { valid: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - /** - * Export backup with human-readable filename - */ - getBackupFilename(walletAddress: string): string { - const date = new Date().toISOString().split("T")[0]; - const shortAddr = walletAddress.slice(0, 8); - return `unicity-tokens-backup-${shortAddr}-${date}.enc`; - } - - // ========================================== - // Encryption/Decryption (AES-256-GCM with PBKDF2) - // ========================================== - - private async encryptWithPassword(data: string, password: string): Promise { - const encoder = new TextEncoder(); - - // Generate random salt and IV - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - - // Derive key from password using PBKDF2 - const keyMaterial = await crypto.subtle.importKey( - "raw", - encoder.encode(password), - "PBKDF2", - false, - ["deriveKey"] - ); - - const key = await crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt, - iterations: 100000, - hash: "SHA-256", - }, - keyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt"] - ); - - // Encrypt the data - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - encoder.encode(data) - ); - - // Combine salt + iv + encrypted data - const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength); - result.set(salt, 0); - result.set(iv, salt.length); - result.set(new Uint8Array(encrypted), salt.length + iv.length); - - return result.buffer; - } - - private async decryptWithPassword(data: ArrayBuffer, password: string): Promise { - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - const dataArray = new Uint8Array(data); - - // Extract salt, IV, and encrypted data - const salt = dataArray.slice(0, 16); - const iv = dataArray.slice(16, 28); - const encrypted = dataArray.slice(28); - - // Derive key from password - const keyMaterial = await crypto.subtle.importKey( - "raw", - encoder.encode(password), - "PBKDF2", - false, - ["deriveKey"] - ); - - const key = await crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt, - iterations: 100000, - hash: "SHA-256", - }, - keyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["decrypt"] - ); - - // Decrypt - const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - encrypted - ); - - return decoder.decode(decrypted); - } - - // ========================================== - // Utility Methods - // ========================================== - - private async calculateChecksum(data: string): Promise { - const encoder = new TextEncoder(); - const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(data)); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); - } - - private arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); - } - - private base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; - } -} - -// ========================================== -// Singleton Export -// ========================================== - -/** - * Get singleton instance of TokenBackupService - */ -export function getTokenBackupService(): TokenBackupService { - return TokenBackupService.getInstance(); -} diff --git a/src/components/wallet/L3/services/TokenRecoveryService.ts b/src/components/wallet/L3/services/TokenRecoveryService.ts deleted file mode 100644 index 55014c49..00000000 --- a/src/components/wallet/L3/services/TokenRecoveryService.ts +++ /dev/null @@ -1,1024 +0,0 @@ -/** - * Token Recovery Service - * - * Emergency recovery for orphaned split tokens. - * When a split operation fails mid-way (browser crash, network error), - * the change token may exist on the blockchain but not in the local wallet. - * - * This service: - * 1. Scans archived tokens for potential orphaned splits (tokens with 0 transactions) - * 2. Reconstructs deterministic change token IDs from the split parameters - * 3. Queries the aggregator to verify change token exists on-chain - * 4. Reconstructs the change token and adds it to the wallet - */ - -import { Token, TokenStatus } from "../data/model"; -import { - getTokensForAddress, - getArchivedTokensForAddress, - addToken, - removeToken -} from "./InventorySyncService"; -import { IdentityManager } from "./IdentityManager"; -import { RegistryService } from "./RegistryService"; -// import { ServiceProvider } from "./ServiceProvider"; // TODO: Uncomment when aggregator token lookup is implemented -import type { TxfToken, TxfTransaction } from "./types/TxfTypes"; -import { getCurrentStateHash, tokenToTxf } from "./TxfSerializer"; -import { getTokenValidationService } from "./TokenValidationService"; -import { Buffer } from "buffer"; - -// ========================================== -// Error Classification Types (Aggregator Failures) -// ========================================== - -/** - * Classification of aggregator errors - * - ALREADY_SPENT: Token state consumed by another transaction - * - AUTHENTICATOR_FAILED: Signature/auth verification failed - * - REQUEST_ID_MISMATCH: Request ID doesn't match expected - * - NETWORK_ERROR: Technical/connectivity issue - skip recovery - * - OTHER_REJECTION: Any other rejection error - */ -export type AggregatorErrorType = - | "ALREADY_SPENT" - | "AUTHENTICATOR_FAILED" - | "REQUEST_ID_MISMATCH" - | "NETWORK_ERROR" - | "OTHER_REJECTION"; - -/** - * Recovery action taken after error classification - * - REMOVE_AND_TOMBSTONE: Token is invalid, deleted permanently - * - REVERT_AND_KEEP: Reverted to committed state, token still valid - * - REVERT_AND_TOMBSTONE: Reverted but sanity check found it spent - * - NO_ACTION: No recovery action needed/possible - */ -export type FailureRecoveryAction = - | "REMOVE_AND_TOMBSTONE" - | "REVERT_AND_KEEP" - | "REVERT_AND_TOMBSTONE" - | "NO_ACTION"; - -/** - * Result of a transfer failure recovery attempt - */ -export interface FailureRecoveryResult { - success: boolean; - action: FailureRecoveryAction; - tokenId: string; - tokenRestored?: boolean; // True if token was reverted and kept - tokenRemoved?: boolean; // True if token was removed - tombstoned?: boolean; // True if tombstone was added - skippedDueToNetworkError?: boolean; // True if skipped due to network error - error?: string; // Error message if recovery failed -} - -/** - * Result of checking if a token is spent - */ -export interface SpentCheckResult { - isSpent: boolean; - stateHash: string; - error?: string; -} - -// ========================================== -// Orphan Recovery Types -// ========================================== - -export interface RecoveredToken { - tokenId: string; - amount: string; - coinId: string; - sourceTokenId: string; - recoveryMethod: "split_change" | "split_recipient"; -} - -export interface RecoveryResult { - recoveredTokens: RecoveredToken[]; - errors: string[]; - scannedArchived: number; -} - -export interface OrphanCandidate { - archivedTokenId: string; - archivedTxf: TxfToken; - possibleSplitAmounts: { splitAmount: string; remainderAmount: string; coinId: string }[]; -} - -// ========================================== -// TokenRecoveryService -// ========================================== - -export class TokenRecoveryService { - private static instance: TokenRecoveryService | null = null; - private identityManager: IdentityManager; - private isRecovering: boolean = false; - - private constructor() { - this.identityManager = IdentityManager.getInstance(); - } - - static getInstance(): TokenRecoveryService { - if (!TokenRecoveryService.instance) { - TokenRecoveryService.instance = new TokenRecoveryService(); - } - return TokenRecoveryService.instance; - } - - // ========================================== - // Public API - // ========================================== - - /** - * Scan archived tokens and attempt to recover any orphaned split change tokens. - * Should be called: - * - On wallet load - * - After IPFS sync completes - * - Manually via wallet settings - */ - async recoverOrphanedSplitTokens(): Promise { - if (this.isRecovering) { - console.log("🔧 Recovery already in progress, skipping..."); - return { recoveredTokens: [], errors: ["Recovery already in progress"], scannedArchived: 0 }; - } - - this.isRecovering = true; - const result: RecoveryResult = { - recoveredTokens: [], - errors: [], - scannedArchived: 0, - }; - - try { - console.log("🔧 Starting orphaned split token recovery scan..."); - - // Get identity context - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - throw new Error("No identity available"); - } - - // 1. Get all archived tokens - const archivedTokens = getArchivedTokensForAddress(identity.address); - result.scannedArchived = archivedTokens.size; - - if (archivedTokens.size === 0) { - console.log("🔧 No archived tokens to scan"); - return result; - } - - // 2. Get current wallet tokens for comparison - const currentTokens = await getTokensForAddress(identity.address); - const currentTokenIds = this.extractCurrentTokenIds(currentTokens); - - // 3. Find orphan candidates (archived tokens that look like they were split) - const orphanCandidates = this.findOrphanCandidates(archivedTokens, currentTokenIds); - - if (orphanCandidates.length === 0) { - console.log("🔧 No orphan candidates found"); - return result; - } - - console.log(`🔧 Found ${orphanCandidates.length} potential orphan candidates`); - - // 4. For each candidate, try to recover change tokens - for (const candidate of orphanCandidates) { - try { - const recovered = await this.attemptRecovery(candidate, currentTokenIds, identity); - if (recovered) { - result.recoveredTokens.push(recovered); - // Update currentTokenIds to prevent re-recovery - currentTokenIds.add(recovered.tokenId); - } - } catch (err) { - const errorMsg = `Failed to recover from ${candidate.archivedTokenId.slice(0, 8)}...: ${err instanceof Error ? err.message : String(err)}`; - console.error(`🔧 ${errorMsg}`); - result.errors.push(errorMsg); - } - } - - console.log(`🔧 Recovery complete: ${result.recoveredTokens.length} tokens recovered, ${result.errors.length} errors`); - - return result; - } finally { - this.isRecovering = false; - } - } - - // ========================================== - // Transfer Failure Recovery API - // ========================================== - - /** - * Detect if an error is a network/technical error that should skip recovery. - * Network errors mean the aggregator never processed the commitment, - * so the token state is unchanged and we should just retry later. - */ - private isNetworkError(errorStatus: string): boolean { - const networkPatterns = [ - // Fetch/connection errors - /fetch failed/i, - /network error/i, - /failed to fetch/i, - /network request failed/i, - /ECONNREFUSED/i, - /ECONNRESET/i, - /ETIMEDOUT/i, - /ENETUNREACH/i, - /socket hang up/i, - // HTTP server errors (5xx) - /^5\d{2}$/, - /502/i, - /503/i, - /504/i, - /service unavailable/i, - /bad gateway/i, - /gateway timeout/i, - // Timeout patterns - /timeout/i, - /timed out/i, - /request timeout/i, - // AbortError - /aborted/i, - /abort/i, - ]; - - return networkPatterns.some(pattern => pattern.test(errorStatus)); - } - - /** - * Classify an aggregator error to determine recovery action - * For ambiguous errors, checks if token state is actually spent - */ - async classifyAggregatorError( - errorStatus: string, - token: Token, - publicKey: string - ): Promise { - // First: Check if this is a network error (skip recovery) - if (this.isNetworkError(errorStatus)) { - console.log(`📦 Recovery: Detected network error: ${errorStatus}`); - return "NETWORK_ERROR"; - } - - // Direct mapping for known error statuses - if (errorStatus === "AUTHENTICATOR_VERIFICATION_FAILED") { - return "AUTHENTICATOR_FAILED"; - } - if (errorStatus === "REQUEST_ID_MISMATCH") { - return "REQUEST_ID_MISMATCH"; - } - - // For CHECK_SPENT flag or unknown errors, verify token state - if (errorStatus === "CHECK_SPENT" || errorStatus === "ALREADY_SPENT") { - const spentCheck = await this.checkTokenSpent(token, publicKey); - if (spentCheck.isSpent) { - return "ALREADY_SPENT"; - } - } - - // For other errors, also check if token is spent - // (could be a race condition where token was spent during submission) - const spentCheck = await this.checkTokenSpent(token, publicKey); - if (spentCheck.isSpent) { - return "ALREADY_SPENT"; - } - - return "OTHER_REJECTION"; - } - - /** - * Check if a token's current state is spent - */ - async checkTokenSpent( - token: Token, - publicKey: string - ): Promise { - const txf = tokenToTxf(token); - if (!txf) { - return { isSpent: false, stateHash: "", error: "Invalid token structure" }; - } - - const stateHash = getCurrentStateHash(txf) ?? ""; - const tokenId = txf.genesis?.data?.tokenId || token.id; - - try { - const validationService = getTokenValidationService(); - const result = await validationService.checkSpentTokens([token], publicKey); - - // Check if this specific token was found as spent - const isSpent = result.spentTokens.some( - s => s.tokenId === tokenId || s.localId === token.id - ); - - return { isSpent, stateHash }; - } catch (err) { - return { - isSpent: false, - stateHash, - error: err instanceof Error ? err.message : String(err), - }; - } - } - - /** - * Revert a token to its last committed state - * Strips uncommitted transactions (those without inclusion proof) - */ - revertToCommittedState(token: Token): Token | null { - const txf = tokenToTxf(token); - if (!txf) { - console.warn(`📦 Recovery: Cannot parse token ${token.id.slice(0, 8)}... for reversion`); - return null; - } - - const transactions = txf.transactions || []; - - // Find the last committed transaction (has inclusionProof) - let lastCommittedIndex = -1; - for (let i = 0; i < transactions.length; i++) { - if (transactions[i].inclusionProof !== null) { - lastCommittedIndex = i; - } - } - - // Check if there are uncommitted transactions to strip - const hasUncommitted = transactions.length > 0 && lastCommittedIndex < transactions.length - 1; - if (!hasUncommitted) { - // All transactions are committed (or no transactions), nothing to revert - console.log(`📦 Recovery: Token ${token.id.slice(0, 8)}... has no uncommitted transactions`); - return token; - } - - // Strip uncommitted transactions - if (lastCommittedIndex === -1) { - // No committed transactions, keep only genesis state - txf.transactions = []; - console.log(`📦 Recovery: Reverted token ${token.id.slice(0, 8)}... to genesis state`); - } else { - // Keep only committed transactions - txf.transactions = transactions.slice(0, lastCommittedIndex + 1); - console.log(`📦 Recovery: Reverted token ${token.id.slice(0, 8)}... to transaction ${lastCommittedIndex}`); - } - - // Create new Token with reverted jsonData - return new Token({ - ...token, - jsonData: JSON.stringify(txf), - status: TokenStatus.CONFIRMED - }); - } - - /** - * Handle a failed transfer by recovering the token - * Main entry point for immediate failure handling - */ - async handleTransferFailure( - token: Token, - errorStatus: string, - publicKey: string - ): Promise { - const tokenId = token.id; - console.log(`📦 Recovery: Handling transfer failure for token ${tokenId.slice(0, 8)}..., error: ${errorStatus}`); - - // Check for network error first - skip recovery, let retry cycle handle it - if (this.isNetworkError(errorStatus)) { - console.log(`📦 Recovery: Network error detected (${errorStatus}), skipping recovery - will retry later`); - return { - success: true, - action: "NO_ACTION", - tokenId, - skippedDueToNetworkError: true, - }; - } - - try { - // Classify the error - const errorType = await this.classifyAggregatorError(errorStatus, token, publicKey); - console.log(`📦 Recovery: Error classified as ${errorType}`); - - if (errorType === "ALREADY_SPENT") { - // Token state is consumed - remove and tombstone - return this.removeAndTombstoneToken(token); - } - - // For other errors, revert to last committed state - const revertedToken = this.revertToCommittedState(token); - if (!revertedToken) { - return { - success: false, - action: "NO_ACTION", - tokenId, - error: "Failed to revert token state", - }; - } - - // Run sanity check on reverted token - const spentCheck = await this.checkTokenSpent(revertedToken, publicKey); - if (spentCheck.isSpent) { - // Even after reversion, token is spent - remove and tombstone - console.log(`📦 Recovery: Reverted token is still spent, removing`); - return this.removeAndTombstoneToken(token); - } - - // Token is valid, save the reverted state - // Get identity context - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - return { - success: false, - action: "NO_ACTION", - tokenId, - error: "No identity available", - }; - } - - // Add the reverted token back to inventory - if (!identity.ipnsName) { - console.warn('No IPNS name available for token recovery'); - return { - success: false, - action: "NO_ACTION", - tokenId, - error: "No IPNS name available", - }; - } - await addToken( - identity.address, - identity.publicKey, - identity.ipnsName, - revertedToken, - { local: true } // skipHistory equivalent - ); - - console.log(`📦 Recovery: Token ${tokenId.slice(0, 8)}... reverted and saved`); - return { - success: true, - action: "REVERT_AND_KEEP", - tokenId, - tokenRestored: true, - }; - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - console.error(`📦 Recovery: Error handling transfer failure:`, err); - return { - success: false, - action: "NO_ACTION", - tokenId, - error: errorMsg, - }; - } - } - - /** - * Handle a failed split burn by restoring the original token - * Special handling for split operation failures - */ - async handleSplitBurnFailure( - originalToken: Token, - errorStatus: string, - publicKey: string - ): Promise { - const tokenId = originalToken.id; - console.log(`📦 Recovery: Handling split burn failure for token ${tokenId.slice(0, 8)}..., error: ${errorStatus}`); - - // Check for network error first - skip recovery, let retry cycle handle it - if (this.isNetworkError(errorStatus)) { - console.log(`📦 Recovery: Network error detected during burn (${errorStatus}), skipping recovery - will retry later`); - return { - success: true, - action: "NO_ACTION", - tokenId, - skippedDueToNetworkError: true, - }; - } - - // For split burns, the token may have been modified in preparation - // Try to revert it to the last committed state - return this.handleTransferFailure(originalToken, errorStatus, publicKey); - } - - /** - * Remove a token and add tombstone for its current state - */ - private async removeAndTombstoneToken(token: Token): Promise { - const tokenId = token.id; - - console.log(`📦 Recovery: Removing spent token ${tokenId.slice(0, 8)}... and adding tombstone`); - - // Get identity context - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - return { - success: false, - action: "NO_ACTION", - tokenId, - error: "No identity available", - }; - } - - // Extract state hash from token - const txf = tokenToTxf(token); - if (!txf) { - return { - success: false, - action: "NO_ACTION", - tokenId, - error: "Cannot convert token to TXF format", - }; - } - - const stateHash = getCurrentStateHash(txf); - if (!stateHash) { - return { - success: false, - action: "NO_ACTION", - tokenId, - error: "Cannot extract state hash from token", - }; - } - - // Remove token - this will archive it AND add tombstone automatically - if (!identity.ipnsName) { - console.warn('No IPNS name available for tombstone'); - return { - success: false, - action: "NO_ACTION", - tokenId, - error: "No IPNS name available", - }; - } - await removeToken( - identity.address, - identity.publicKey, - identity.ipnsName, - tokenId, - stateHash, - { local: true } // skipHistory equivalent - ); - - return { - success: true, - action: "REMOVE_AND_TOMBSTONE", - tokenId, - tokenRemoved: true, - tombstoned: true, - }; - } - - // ========================================== - // Private Methods (Orphan Recovery) - // ========================================== - - /** - * Extract SDK token IDs from current wallet tokens - */ - private extractCurrentTokenIds(tokens: Token[]): Set { - const ids = new Set(); - - for (const token of tokens) { - if (!token.jsonData) continue; - - try { - const txf = JSON.parse(token.jsonData); - const tokenId = txf.genesis?.data?.tokenId; - if (tokenId) { - ids.add(tokenId); - } - } catch { - // Skip invalid JSON - } - } - - return ids; - } - - /** - * Find archived tokens that look like they were split but have missing change tokens. - * - * Criteria for orphan candidates: - * 1. Archived token has transactions.length === 0 (was burned directly, not transferred) - * 2. OR archived token's last transaction is a burn (to a burn predicate address) - * 3. No corresponding change token in current wallet - */ - private findOrphanCandidates( - archivedTokens: Map, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _currentTokenIds: Set - ): OrphanCandidate[] { - const candidates: OrphanCandidate[] = []; - - for (const [tokenId, txf] of archivedTokens) { - // Check if this looks like a potential split source - // For a split, the original token gets burned (transferred to a burn predicate) - - const transactions = txf.transactions || []; - - // Case 1: Token has exactly 0 transactions in archive - // This could mean it was archived before the burn tx was recorded - // OR it was a pristine token that got split - if (transactions.length === 0) { - // This token was archived in genesis state - potentially split - const coinData = txf.genesis?.data?.coinData || []; - const possibleAmounts = this.extractPossibleSplitAmounts(coinData); - - if (possibleAmounts.length > 0) { - candidates.push({ - archivedTokenId: tokenId, - archivedTxf: txf, - possibleSplitAmounts: possibleAmounts, - }); - } - continue; - } - - // Case 2: Check if last transaction is a burn - const lastTx = transactions[transactions.length - 1] as TxfTransaction; - if (this.looksLikeBurnTransaction(lastTx)) { - const coinData = txf.genesis?.data?.coinData || []; - const possibleAmounts = this.extractPossibleSplitAmounts(coinData); - - if (possibleAmounts.length > 0) { - candidates.push({ - archivedTokenId: tokenId, - archivedTxf: txf, - possibleSplitAmounts: possibleAmounts, - }); - } - } - } - - return candidates; - } - - /** - * Check if a transaction looks like a burn (to burn predicate) - */ - private looksLikeBurnTransaction(tx: TxfTransaction): boolean { - // Burn transactions typically have a predicate that encodes the burn destination - // The predicate format for burns starts with a specific prefix - // For now, we can check if the newStateHash has the burn pattern - // This is heuristic - real validation happens when we query the aggregator - - if (!tx.inclusionProof) return false; - - // A burn transaction has been committed (has proof) but the token is archived - // This suggests the token was spent via burn - return true; // Conservative: treat any committed final tx as potential burn - } - - /** - * Extract possible split amounts from coin data. - * Returns combinations that could have been split. - */ - private extractPossibleSplitAmounts( - coinData: [string, string][] - ): { splitAmount: string; remainderAmount: string; coinId: string }[] { - const results: { splitAmount: string; remainderAmount: string; coinId: string }[] = []; - - for (const [coinId, amountStr] of coinData) { - const totalAmount = BigInt(amountStr || "0"); - if (totalAmount <= BigInt(0)) continue; - - // We don't know the exact split ratio, so we'll try common patterns - // For recovery, we primarily care about the _sender (change) token - // The seed string format is: `${tokenIdHex}_${splitAmount}_${remainderAmount}` - - // Strategy: Try to find transactions in history that might reveal split amounts - // For now, we'll generate candidates based on common split patterns - - // Add the full amount as a candidate (covers case where token wasn't actually split) - results.push({ - splitAmount: "0", - remainderAmount: totalAmount.toString(), - coinId, - }); - - // For real recovery, we'd need to query transaction history or use heuristics - // The key insight is that split amounts should match what the aggregator has - } - - return results; - } - - /** - * Attempt to recover a change token from an orphan candidate - */ - private async attemptRecovery( - candidate: OrphanCandidate, - currentTokenIds: Set, - identity: Awaited> - ): Promise { - if (!identity) { - return null; - } - const { archivedTokenId, archivedTxf } = candidate; - - console.log(`🔧 Attempting recovery for archived token ${archivedTokenId.slice(0, 8)}...`); - - // Get coin data from genesis - const coinData = archivedTxf.genesis?.data?.coinData || []; - if (coinData.length === 0) { - console.log(`🔧 No coin data in archived token, skipping`); - return null; - } - - const [coinId, totalAmountStr] = coinData[0]; - const totalAmount = BigInt(totalAmountStr || "0"); - - if (totalAmount <= BigInt(0)) { - console.log(`🔧 Zero amount in archived token, skipping`); - return null; - } - - // Try to find the change token by querying common split patterns - // We need to iterate through possible split/remainder combinations - - // Strategy 1: Check if there's a transaction history entry that hints at split amounts - // Strategy 2: Query aggregator for all possible seed combinations - - // For now, implement a conservative approach: check if any deterministic - // change token ID exists on the aggregator - - // The deterministic ID formula from TokenSplitExecutor: - // const seedString = `${tokenIdHex}_${splitAmount}_${remainderAmount}`; - // const senderTokenId = sha256(seedString + "_sender"); - - // Try common split ratios (this is a heuristic - real implementation would track actual splits) - const splitsToTry = this.generatePossibleSplits(totalAmount); - - for (const split of splitsToTry) { - const seedString = `${archivedTokenId}_${split.splitAmount}_${split.remainderAmount}`; - const changeTokenId = await this.sha256Hex(seedString + "_sender"); - - // Skip if we already have this token - if (currentTokenIds.has(changeTokenId)) { - console.log(`🔧 Change token ${changeTokenId.slice(0, 8)}... already in wallet`); - continue; - } - - // Query aggregator to see if this token exists - const exists = await this.checkTokenExistsOnAggregator(changeTokenId); - - if (exists) { - console.log(`🔧 Found orphaned change token ${changeTokenId.slice(0, 8)}... on aggregator!`); - - // Reconstruct the token - const reconstructed = await this.reconstructChangeToken( - changeTokenId, - archivedTxf, - split.remainderAmount, - coinId, - seedString - ); - - if (reconstructed) { - // Add to wallet - if (!identity.ipnsName) { - console.warn('No IPNS name available for recovered token'); - return null; - } - await addToken( - identity.address, - identity.publicKey, - identity.ipnsName, - reconstructed, - { local: true } // skipHistory equivalent - ); - - return { - tokenId: changeTokenId, - amount: split.remainderAmount, - coinId, - sourceTokenId: archivedTokenId, - recoveryMethod: "split_change", - }; - } - } - } - - console.log(`🔧 No orphaned change token found for ${archivedTokenId.slice(0, 8)}...`); - return null; - } - - /** - * Generate possible split combinations to try - * This is heuristic - in production, we'd track actual split params in outbox - */ - private generatePossibleSplits(totalAmount: bigint): { splitAmount: string; remainderAmount: string }[] { - const splits: { splitAmount: string; remainderAmount: string }[] = []; - - // Common split patterns: - // - Split exact amount (e.g., 4 ETH from 32 ETH -> remainder 28 ETH) - // - Split half - // - Split to common denominations - - const commonAmounts = [ - BigInt("1000000000000000000"), // 1 ETH - BigInt("4000000000000000000"), // 4 ETH - BigInt("10000000000000000000"), // 10 ETH - BigInt("100000000000000000"), // 0.1 ETH - ]; - - for (const splitAmt of commonAmounts) { - if (splitAmt > BigInt(0) && splitAmt < totalAmount) { - const remainder = totalAmount - splitAmt; - splits.push({ - splitAmount: splitAmt.toString(), - remainderAmount: remainder.toString(), - }); - } - } - - // Also try percentages - const halfAmount = totalAmount / BigInt(2); - if (halfAmount > BigInt(0)) { - splits.push({ - splitAmount: halfAmount.toString(), - remainderAmount: (totalAmount - halfAmount).toString(), - }); - } - - return splits; - } - - /** - * Check if a token exists on the aggregator by its state hash - */ - private async checkTokenExistsOnAggregator(tokenId: string): Promise { - try { - // The aggregator client can check if a token ID has any recorded state - // We can use getInclusionProof with a constructed state hash - // For now, return false and log - this needs proper SDK integration - // TODO: Use ServiceProvider.stateTransitionClient.getTokenState(tokenId) when available - - console.log(`🔧 Checking aggregator for token ${tokenId.slice(0, 8)}... (requires SDK integration)`); - - // TODO: Implement proper aggregator query using SDK - // Example: await stateTransitionClient.getTokenState(tokenId); - - return false; // Placeholder - needs SDK method for token lookup - } catch (err) { - console.error(`🔧 Aggregator check failed for ${tokenId.slice(0, 8)}...:`, err); - return false; - } - } - - /** - * Reconstruct a change token from recovered data - */ - private async reconstructChangeToken( - tokenId: string, - sourceTokenTxf: TxfToken, - amount: string, - coinId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _seedString: string - ): Promise { - try { - // Reconstruct TxfToken structure for the change token - const tokenType = sourceTokenTxf.genesis?.data?.tokenType || ""; - const isNft = tokenType === "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"; - - // NOTE: Full reconstruction requires: - // 1. The mint inclusion proof from aggregator - // 2. The predicate structure - // 3. The salt used during minting - - // For now, create a placeholder token that marks this as recovered - // The actual reconstruction would need SDK integration - - // Lookup registry for symbol and icon - let symbol = isNft ? "NFT" : "UCT"; - let iconUrl: string | undefined = undefined; - if (coinId && !isNft) { - const registryService = RegistryService.getInstance(); - const def = registryService.getCoinDefinition(coinId); - if (def) { - symbol = def.symbol || symbol; - iconUrl = registryService.getIconUrl(def) || undefined; - } - } - - const token = new Token({ - id: tokenId, - name: isNft ? "NFT (Recovered)" : `${symbol} (Recovered)`, - type: isNft ? "NFT" : symbol, - timestamp: Date.now(), - status: TokenStatus.CONFIRMED, - amount: amount, - coinId: coinId, - symbol, - iconUrl, - // Note: jsonData would need proper TxfToken reconstruction with proofs - }); - - console.log(`🔧 Reconstructed change token ${tokenId.slice(0, 8)}... (needs proof fetch)`); - - // TODO: Fetch actual proof from aggregator and build full TxfToken - // The token is marked as recovered but may need validation pass to fill in proofs - - return token; - } catch (err) { - console.error(`🔧 Failed to reconstruct token ${tokenId.slice(0, 8)}...:`, err); - return null; - } - } - - /** - * SHA-256 hash helper that returns hex string - */ - private async sha256Hex(input: string): Promise { - const data = new TextEncoder().encode(input); - const hashBuffer = await window.crypto.subtle.digest("SHA-256", data); - return Buffer.from(hashBuffer).toString("hex"); - } - - // ========================================== - // Manual Recovery API - // ========================================== - - /** - * Attempt to recover a specific token by its expected parameters. - * Used when user knows the exact split details. - */ - async recoverSpecificToken( - sourceTokenId: string, - splitAmount: string, - remainderAmount: string - ): Promise { - console.log(`🔧 Attempting specific recovery for split of ${sourceTokenId.slice(0, 8)}...`); - - // Get identity context - const identity = await this.identityManager.getCurrentIdentity(); - if (!identity) { - console.log(`🔧 No identity available`); - return null; - } - - const seedString = `${sourceTokenId}_${splitAmount}_${remainderAmount}`; - const changeTokenId = await this.sha256Hex(seedString + "_sender"); - - // Check current wallet - const currentTokens = await getTokensForAddress(identity.address); - const currentIds = this.extractCurrentTokenIds(currentTokens); - - if (currentIds.has(changeTokenId)) { - console.log(`🔧 Token ${changeTokenId.slice(0, 8)}... already in wallet`); - return null; - } - - // Check aggregator - const exists = await this.checkTokenExistsOnAggregator(changeTokenId); - - if (!exists) { - console.log(`🔧 Token ${changeTokenId.slice(0, 8)}... not found on aggregator`); - return null; - } - - // Get source token info from archive - const archivedTokens = getArchivedTokensForAddress(identity.address); - const sourceTxf = archivedTokens.get(sourceTokenId); - - if (!sourceTxf) { - console.log(`🔧 Source token ${sourceTokenId.slice(0, 8)}... not in archive`); - return null; - } - - const coinData = sourceTxf.genesis?.data?.coinData || []; - const coinId = coinData[0]?.[0] || ""; - - const reconstructed = await this.reconstructChangeToken( - changeTokenId, - sourceTxf, - remainderAmount, - coinId, - seedString - ); - - if (reconstructed) { - if (!identity.ipnsName) { - console.warn('No IPNS name available for recovered token'); - return null; - } - await addToken( - identity.address, - identity.publicKey, - identity.ipnsName, - reconstructed, - { local: true } // skipHistory equivalent - ); - - return { - tokenId: changeTokenId, - amount: remainderAmount, - coinId, - sourceTokenId, - recoveryMethod: "split_change", - }; - } - - return null; - } -} diff --git a/src/components/wallet/L3/services/TokenValidationService.ts b/src/components/wallet/L3/services/TokenValidationService.ts deleted file mode 100644 index c1d13ac2..00000000 --- a/src/components/wallet/L3/services/TokenValidationService.ts +++ /dev/null @@ -1,1469 +0,0 @@ -/** - * Token Validation Service - * Validates tokens before IPFS sync and fetches missing Unicity proofs - */ - -import { Token as LocalToken, TokenStatus } from "../data/model"; -import type { - ValidationResult, - ValidationIssue, - TokenValidationResult, - TxfTransaction, - TxfInclusionProof, - TxfToken, -} from "./types/TxfTypes"; -import { getCurrentStateHash, tokenToTxf } from "./TxfSerializer"; -import { STORAGE_KEYS } from "../../../../config/storageKeys"; - -// ========================================== -// Validation Action Types -// ========================================== - -/** - * Describes what action should be taken based on validation result - * - ACCEPT: Token is valid, can be used - * - RETRY_LATER: Proof not available yet, retry submission later - * - DISCARD_FORK: Transaction can NEVER succeed (source state spent), should be discarded - */ -export type ValidationAction = "ACCEPT" | "RETRY_LATER" | "DISCARD_FORK"; - -/** - * Extended validation result with action guidance - */ -export interface ExtendedValidationResult extends TokenValidationResult { - action?: ValidationAction; -} - -// ========================================== -// Spent Token Detection Types -// ========================================== - -export interface SpentTokenInfo { - tokenId: string; // SDK token ID from genesis - localId: string; // Local Token.id for repository removal - stateHash: string; // Current state hash being checked -} - -export interface SpentTokenResult { - spentTokens: SpentTokenInfo[]; - errors: string[]; -} - -// ========================================== -// Constants -// ========================================== - -const DEFAULT_AGGREGATOR_URL = "https://alpha-aggregator.unicity.network"; - -// ========================================== -// TokenValidationService -// ========================================== - -export class TokenValidationService { - private aggregatorUrl: string; - private trustBaseCache: unknown | null = null; - private trustBaseCacheTime = 0; - private readonly TRUST_BASE_CACHE_TTL = 60 * 60 * 1000; // 1 hour - - // Spent state verification cache - // SPENT results are immutable - cache forever (persisted to localStorage) - // UNSPENT results could change - cache with 5-min TTL (in-memory only) - private spentStateCache = new Map(); - private readonly UNSPENT_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes for UNSPENT - private spentCacheLoadedFromStorage = false; - - // localStorage key for persisting SPENT cache entries - private static readonly SPENT_CACHE_STORAGE_KEY = STORAGE_KEYS.SPENT_TOKEN_CACHE; - - constructor(aggregatorUrl: string = DEFAULT_AGGREGATOR_URL) { - this.aggregatorUrl = aggregatorUrl; - } - - // ========================================== - // Spent State Cache Methods - // ========================================== - - /** - * Generate cache key for spent state verification - * Format: tokenId:stateHash:publicKey - */ - private getSpentStateCacheKey( - tokenId: string, - stateHash: string, - publicKey: string - ): string { - return `${tokenId}:${stateHash}:${publicKey}`; - } - - /** - * Load SPENT cache entries from localStorage (lazy load on first access) - * Only SPENT entries are persisted - UNSPENT entries remain in-memory only. - */ - private loadSpentCacheFromStorage(): void { - if (this.spentCacheLoadedFromStorage) return; - this.spentCacheLoadedFromStorage = true; - - try { - const stored = localStorage.getItem(TokenValidationService.SPENT_CACHE_STORAGE_KEY); - if (!stored) return; - - const entries = JSON.parse(stored) as Array<{ key: string; timestamp: number }>; - let loadedCount = 0; - - for (const entry of entries) { - // Only load SPENT entries (isSpent is always true for persisted entries) - this.spentStateCache.set(entry.key, { - isSpent: true, - timestamp: entry.timestamp, - }); - loadedCount++; - } - - if (loadedCount > 0) { - console.log(`📦 Loaded ${loadedCount} SPENT cache entries from localStorage`); - } - } catch (err) { - console.warn("📦 Failed to load SPENT cache from localStorage:", err); - // Clear corrupted data - try { - localStorage.removeItem(TokenValidationService.SPENT_CACHE_STORAGE_KEY); - } catch { /* ignore */ } - } - } - - /** - * Persist SPENT cache entries to localStorage - * Only SPENT entries are persisted - they are immutable and safe to cache forever. - */ - private persistSpentCacheToStorage(): void { - try { - const entries: Array<{ key: string; timestamp: number }> = []; - - for (const [key, entry] of this.spentStateCache.entries()) { - // Only persist SPENT entries - if (entry.isSpent) { - entries.push({ key, timestamp: entry.timestamp }); - } - } - - if (entries.length === 0) { - localStorage.removeItem(TokenValidationService.SPENT_CACHE_STORAGE_KEY); - } else { - localStorage.setItem( - TokenValidationService.SPENT_CACHE_STORAGE_KEY, - JSON.stringify(entries) - ); - } - } catch (err) { - console.warn("📦 Failed to persist SPENT cache to localStorage:", err); - } - } - - /** - * Check if spent state is cached - * Returns: true (SPENT), false (UNSPENT), or null (not cached/expired) - */ - private getSpentStateFromCache(cacheKey: string): boolean | null { - // Lazy load from localStorage on first access - this.loadSpentCacheFromStorage(); - - const cached = this.spentStateCache.get(cacheKey); - if (!cached) return null; - - // SPENT results never expire (immutable) - if (cached.isSpent) { - return true; - } - - // UNSPENT results expire after TTL (state could have changed) - const isExpired = Date.now() - cached.timestamp > this.UNSPENT_CACHE_TTL_MS; - if (isExpired) { - this.spentStateCache.delete(cacheKey); - return null; - } - - return false; - } - - /** - * Store spent state result in cache - * SPENT entries are persisted to localStorage; UNSPENT entries are in-memory only. - */ - private cacheSpentState(cacheKey: string, isSpent: boolean): void { - this.spentStateCache.set(cacheKey, { - isSpent, - timestamp: Date.now(), - }); - - // Persist SPENT entries to localStorage (they're immutable) - if (isSpent) { - this.persistSpentCacheToStorage(); - } - } - - /** - * Clear spent state cache (call on logout/address change or when refreshing Unicity proofs) - * Also clears localStorage persistence. - */ - clearSpentStateCache(): void { - const size = this.spentStateCache.size; - this.spentStateCache.clear(); - this.spentCacheLoadedFromStorage = false; - - // Also clear localStorage - try { - localStorage.removeItem(TokenValidationService.SPENT_CACHE_STORAGE_KEY); - } catch { /* ignore */ } - - if (size > 0) { - console.log(`📦 Cleared spent state cache (${size} entries) and localStorage`); - } - } - - /** - * Clear only UNSPENT cache entries - * Called after IPFS sync detects inventory changes - * SPENT entries remain cached (immutable) - */ - clearUnspentCacheEntries(): void { - let clearedCount = 0; - for (const [key, entry] of this.spentStateCache.entries()) { - if (!entry.isSpent) { - this.spentStateCache.delete(key); - clearedCount++; - } - } - if (clearedCount > 0) { - console.log(`📦 Cleared ${clearedCount} UNSPENT cache entries after IPFS inventory change`); - } - } - - /** - * Get cache statistics for debugging - */ - getSpentStateCacheStats(): { size: number; spentCount: number; unspentCount: number } { - let spentCount = 0; - let unspentCount = 0; - for (const entry of this.spentStateCache.values()) { - if (entry.isSpent) spentCount++; - else unspentCount++; - } - return { size: this.spentStateCache.size, spentCount, unspentCount }; - } - - // ========================================== - // Public API - // ========================================== - - /** - * Validate all tokens before IPFS sync (parallel with batch limit) - * Returns valid tokens and list of issues - */ - async validateAllTokens( - tokens: LocalToken[], - options?: { batchSize?: number; onProgress?: (completed: number, total: number) => void } - ): Promise { - const validTokens: LocalToken[] = []; - const issues: ValidationIssue[] = []; - - const batchSize = options?.batchSize ?? 5; // Default: 5 concurrent validations - const total = tokens.length; - let completed = 0; - - // Process in batches for controlled parallelism - for (let i = 0; i < tokens.length; i += batchSize) { - const batch = tokens.slice(i, i + batchSize); - - const batchResults = await Promise.allSettled( - batch.map(async (token) => { - try { - const result = await this.validateToken(token); - return { token, result }; - } catch (err) { - return { - token, - result: { - isValid: false, - reason: err instanceof Error ? err.message : String(err), - } as TokenValidationResult, - }; - } - }) - ); - - // Process batch results - for (const settledResult of batchResults) { - completed++; - - if (settledResult.status === "fulfilled") { - const { token, result } = settledResult.value; - if (result.isValid && result.token) { - validTokens.push(result.token); - } else { - issues.push({ - tokenId: token.id, - reason: result.reason || "Unknown validation error", - recoverable: false, - }); - } - } else { - // Promise rejected (shouldn't happen due to try/catch above, but handle anyway) - issues.push({ - tokenId: batch[batchResults.indexOf(settledResult)]?.id || "unknown", - reason: String(settledResult.reason), - recoverable: false, - }); - } - } - - // Report progress - if (options?.onProgress) { - options.onProgress(completed, total); - } - } - - return { validTokens, issues }; - } - - /** - * Validate a single token - */ - async validateToken(token: LocalToken): Promise { - const tokenIdPrefix = token.id.slice(0, 8); - - // Check if token has jsonData - if (!token.jsonData) { - console.log(`📦 Validation FAIL [${tokenIdPrefix}]: no jsonData`); - return { - isValid: false, - reason: "Token has no jsonData field", - }; - } - - let txfToken: unknown; - try { - txfToken = JSON.parse(token.jsonData); - } catch { - console.log(`📦 Validation FAIL [${tokenIdPrefix}]: invalid JSON`); - return { - isValid: false, - reason: "Failed to parse token jsonData as JSON", - }; - } - - // Check basic structure - if (!this.hasValidTxfStructure(txfToken)) { - console.log(`📦 Validation FAIL [${tokenIdPrefix}]: missing TXF structure (genesis/state)`); - return { - isValid: false, - reason: "Token jsonData missing required TXF fields (genesis, state)", - }; - } - - // Check for uncommitted transactions - const uncommitted = this.getUncommittedTransactions(txfToken); - if (uncommitted.length > 0) { - const recovered = await this.fetchMissingProofs(token); - if (recovered) { - return { isValid: true, token: recovered }; - } - - console.warn(`📦 Validation FAIL [${tokenIdPrefix}]: uncommitted transactions, proofs not recoverable`); - return { - isValid: false, - reason: `${uncommitted.length} uncommitted transaction(s), could not fetch proofs from aggregator`, - }; - } - - // Verify token using SDK (if trust base available) - try { - const verificationResult = await this.verifyWithSdk(txfToken); - if (!verificationResult.success) { - console.warn(`📦 Validation FAIL [${tokenIdPrefix}]: SDK verification failed - ${verificationResult.error}`); - return { - isValid: false, - reason: verificationResult.error || "SDK verification failed", - }; - } - } catch (err) { - // SDK verification is optional - log warning but don't fail - console.warn( - `📦 SDK verification skipped for token ${token.id}:`, - err instanceof Error ? err.message : err - ); - } - - return { isValid: true, token }; - } - - /** - * Fetch missing Unicity proofs from aggregator - */ - async fetchMissingProofs(token: LocalToken): Promise { - if (!token.jsonData) return null; - - let txfToken: Record; - try { - txfToken = JSON.parse(token.jsonData); - } catch { - return null; - } - - const transactions = txfToken.transactions as TxfTransaction[] | undefined; - if (!transactions || transactions.length === 0) { - return null; - } - - let modified = false; - - // Try to fetch proofs for each uncommitted transaction - for (let i = 0; i < transactions.length; i++) { - const tx = transactions[i]; - if (tx.inclusionProof === null && tx.newStateHash) { - try { - const proof = await this.fetchProofFromAggregator(tx.newStateHash); - if (proof) { - transactions[i] = { ...tx, inclusionProof: proof as TxfInclusionProof }; - modified = true; - } - } catch (err) { - console.warn( - `📦 Failed to fetch proof for transaction ${i}:`, - err instanceof Error ? err.message : err - ); - } - } - } - - if (!modified) { - return null; - } - - // Return updated token - return new LocalToken({ - ...token, - jsonData: JSON.stringify(txfToken), - status: TokenStatus.CONFIRMED, - }); - } - - // ========================================== - // Pending Transaction Validation - // ========================================== - - /** - * Check if a pending transaction can still be submitted - * Returns false if the source state is already spent (transaction is dead) - * - * CRITICAL: This prevents tokens from being stuck in PENDING state forever - * when another device has already committed a different transaction from the same state - */ - async isPendingTransactionSubmittable( - token: LocalToken, - pendingTxIndex: number - ): Promise<{ submittable: boolean; reason?: string; action?: ValidationAction }> { - const txf = tokenToTxf(token); - if (!txf) { - return { submittable: false, reason: "Invalid token", action: "DISCARD_FORK" }; - } - - const pendingTx = txf.transactions[pendingTxIndex]; - if (!pendingTx) { - return { submittable: false, reason: "Transaction index out of bounds", action: "DISCARD_FORK" }; - } - - // If already committed, it's not pending - if (pendingTx.inclusionProof !== null) { - return { submittable: true, action: "ACCEPT" }; - } - - // Get the state hash BEFORE this pending transaction - let prevStateHash: string; - if (pendingTxIndex === 0) { - // First transaction - source state is genesis state - prevStateHash = txf.genesis.inclusionProof.authenticator.stateHash; - } else { - // Previous transaction's new state - const prevTx = txf.transactions[pendingTxIndex - 1]; - if (!prevTx) { - return { submittable: false, reason: "Previous transaction not found", action: "DISCARD_FORK" }; - } - if (!prevTx.newStateHash) { - // Old token format without newStateHash - can't verify, assume submittable - return { submittable: true, reason: "Cannot verify - missing newStateHash on previous tx", action: "RETRY_LATER" }; - } - prevStateHash = prevTx.newStateHash; - } - - // Check if that state is already spent - const trustBase = await this.getTrustBase(); - if (!trustBase) { - // Can't verify - assume submittable (retry later) - return { submittable: true, reason: "Cannot verify - trust base unavailable", action: "RETRY_LATER" }; - } - - let client: unknown; - try { - const { ServiceProvider } = await import("./ServiceProvider"); - client = ServiceProvider.stateTransitionClient; - } catch { - return { submittable: true, reason: "Cannot verify - client unavailable", action: "RETRY_LATER" }; - } - - if (!client) { - return { submittable: true, reason: "Cannot verify - client is null", action: "RETRY_LATER" }; - } - - try { - // Use SDK to check if source state is spent - const { Token } = await import("@unicitylabs/state-transition-sdk/lib/token/Token"); - const sdkToken = await Token.fromJSON(txf); - - // Get token owner public key from IdentityManager - const { IdentityManager } = await import("./IdentityManager"); - const identity = await IdentityManager.getInstance().getCurrentIdentity(); - if (!identity?.publicKey) { - return { submittable: true, reason: "Cannot verify - no identity", action: "RETRY_LATER" }; - } - - const pubKeyBytes = Buffer.from(identity.publicKey, "hex"); - - // Check if token state is spent - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const isSpent = await (client as any).isTokenStateSpent( - trustBase, - sdkToken, - pubKeyBytes - ); - - if (isSpent) { - return { - submittable: false, - reason: `Source state ${prevStateHash.slice(0, 12)}... already spent - transaction can never be committed`, - action: "DISCARD_FORK" - }; - } - - return { submittable: true, action: "ACCEPT" }; - } catch (err) { - // On error, assume submittable but retry later - console.warn(`📦 isPendingTransactionSubmittable: Error checking state:`, err); - return { - submittable: true, - reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`, - action: "RETRY_LATER" - }; - } - } - - /** - * Check all pending transactions in a token and return their status - * Useful for UI to show which transactions are dead vs just pending - */ - async checkAllPendingTransactions( - token: LocalToken - ): Promise<{ - pendingCount: number; - submittable: number; - dead: number; - deadTransactions: { index: number; reason: string }[]; - }> { - const txf = tokenToTxf(token); - if (!txf) { - return { pendingCount: 0, submittable: 0, dead: 0, deadTransactions: [] }; - } - - let pendingCount = 0; - let submittable = 0; - let dead = 0; - const deadTransactions: { index: number; reason: string }[] = []; - - for (let i = 0; i < txf.transactions.length; i++) { - const tx = txf.transactions[i]; - if (tx.inclusionProof === null) { - pendingCount++; - const result = await this.isPendingTransactionSubmittable(token, i); - if (result.submittable) { - submittable++; - } else { - dead++; - deadTransactions.push({ index: i, reason: result.reason || "Unknown" }); - } - } - } - - return { pendingCount, submittable, dead, deadTransactions }; - } - - // ========================================== - // Split Token Validation - // ========================================== - - /** - * Validate that split tokens are still valid - * - * CRITICAL: Split tokens are only valid if their parent burn transaction was committed. - * If token was split on Device A (burn + mints), but Device B spent the original - * token first, Device A's burn is REJECTED and split tokens can NEVER exist. - * - * This method: - * 1. Identifies split tokens (by checking genesis.data.reason for SPLIT_MINT pattern) - * 2. Verifies the referenced burn transaction was committed on Unicity - * 3. Returns valid/invalid token lists for the caller to handle - */ - async validateSplitTokens( - tokens: LocalToken[] - ): Promise<{ - valid: LocalToken[]; - invalid: LocalToken[]; - errors: { tokenId: string; reason: string }[]; - }> { - const valid: LocalToken[] = []; - const invalid: LocalToken[] = []; - const errors: { tokenId: string; reason: string }[] = []; - - for (const token of tokens) { - const txf = tokenToTxf(token); - if (!txf) { - invalid.push(token); - errors.push({ tokenId: token.id, reason: "Invalid TXF structure" }); - continue; - } - - // Check if this is a split token by examining genesis.data.reason - // Split tokens typically have a reason field referencing the parent burn - const genesisData = txf.genesis?.data; - const reason = genesisData?.reason; - - // If no reason field, not a split token - assume valid - if (!reason) { - valid.push(token); - continue; - } - - // Parse the reason to check if it's a split mint - // Common patterns: "SPLIT_MINT:" or JSON with splitMintReason - let burnTxHash: string | null = null; - - if (typeof reason === "string") { - // Check for SPLIT_MINT prefix - if (reason.startsWith("SPLIT_MINT:")) { - burnTxHash = reason.substring("SPLIT_MINT:".length); - } - // Check for JSON format - else if (reason.startsWith("{")) { - try { - const reasonObj = JSON.parse(reason); - if (reasonObj.splitMintReason?.burnTransactionHash) { - burnTxHash = reasonObj.splitMintReason.burnTransactionHash; - } else if (reasonObj.burnTransactionHash) { - burnTxHash = reasonObj.burnTransactionHash; - } - } catch { - // Not JSON, continue checking other formats - } - } - } - - // If no burn transaction reference found, not a split token - assume valid - if (!burnTxHash) { - valid.push(token); - continue; - } - - // This is a split token - verify the burn was committed - const burnCommitted = await this.checkBurnTransactionCommitted(burnTxHash); - - if (burnCommitted.committed) { - valid.push(token); - } else { - invalid.push(token); - errors.push({ - tokenId: token.id, - reason: burnCommitted.error || "Burn transaction not committed - split token invalid" - }); - console.warn(`⚠️ Split token ${token.id.slice(0, 8)}... is INVALID: ${burnCommitted.error}`); - } - } - return { valid, invalid, errors }; - } - - /** - * Check if a burn transaction was committed on Unicity - * Used to validate split tokens whose existence depends on the burn being committed - */ - private async checkBurnTransactionCommitted( - burnTxHash: string - ): Promise<{ committed: boolean; error?: string }> { - // Get trust base for verification - const trustBase = await this.getTrustBase(); - if (!trustBase) { - // Can't verify - assume committed (safe fallback to avoid false negatives) - return { committed: true, error: "Cannot verify - trust base unavailable" }; - } - - try { - // Try to fetch the inclusion proof for the burn transaction - // If it exists, the burn was committed - const proof = await this.fetchProofFromAggregator(burnTxHash); - - if (proof) { - return { committed: true }; - } - - // No proof found - check if it's just pending or actually rejected - // For safety, we can't definitively say it's rejected without more context - // Return not committed but allow retry - return { - committed: false, - error: "Burn transaction proof not found - may be pending or rejected" - }; - } catch (err) { - return { - committed: false, - error: `Failed to verify burn: ${err instanceof Error ? err.message : String(err)}` - }; - } - } - - /** - * Identify which tokens in a list are split tokens - * Useful for filtering before validation - */ - identifySplitTokens(tokens: LocalToken[]): { - splitTokens: LocalToken[]; - regularTokens: LocalToken[]; - } { - const splitTokens: LocalToken[] = []; - const regularTokens: LocalToken[] = []; - - for (const token of tokens) { - const txf = tokenToTxf(token); - if (!txf) { - regularTokens.push(token); - continue; - } - - const reason = txf.genesis?.data?.reason; - - // Check if reason indicates a split token - const isSplit = reason && ( - (typeof reason === "string" && reason.startsWith("SPLIT_MINT:")) || - (typeof reason === "string" && reason.includes("burnTransactionHash")) - ); - - if (isSplit) { - splitTokens.push(token); - } else { - regularTokens.push(token); - } - } - - return { splitTokens, regularTokens }; - } - - // ========================================== - // Spent Token Detection - // ========================================== - - /** - * Check if a specific token state (tokenId + stateHash) was spent - * Used for tombstone verification (Step 7.5 in InventorySyncService) - * - * This queries the aggregator to determine if a specific state was consumed - * by a subsequent transaction. Returns true if spent, false if unspent. - * - * @param tokenId - The SDK token ID (from genesis.data.tokenId) - * @param stateHash - The state hash to check (with "0000" prefix) - * @param publicKey - The wallet's public key (hex string) - * @returns Promise - true if spent, false if unspent - */ - async isTokenStateSpent( - tokenId: string, - stateHash: string, - publicKey: string - ): Promise { - // Check cache first - const cacheKey = this.getSpentStateCacheKey(tokenId, stateHash, publicKey); - const cachedResult = this.getSpentStateFromCache(cacheKey); - if (cachedResult !== null) { - return cachedResult; - } - - // Get trust base and client - const trustBase = await this.getTrustBase(); - if (!trustBase) { - console.warn(`⚠️ [isTokenStateSpent] Trust base not available - assuming unspent (safe default)`); - return false; - } - - let client: unknown; - try { - const { ServiceProvider } = await import("./ServiceProvider"); - client = ServiceProvider.stateTransitionClient; - } catch { - console.warn(`⚠️ [isTokenStateSpent] StateTransitionClient not available - assuming unspent`); - return false; - } - - if (!client) { - console.warn(`⚠️ [isTokenStateSpent] StateTransitionClient is null - assuming unspent`); - return false; - } - - try { - const { ServiceProvider } = await import("./ServiceProvider"); - const skipVerification = ServiceProvider.isTrustBaseVerificationSkipped(); - - let isSpent: boolean; - - if (skipVerification) { - // DEV MODE: Query aggregator using RequestId derived from publicKey + stateHash - const { RequestId } = await import( - "@unicitylabs/state-transition-sdk/lib/api/RequestId" - ); - const { DataHash } = await import( - "@unicitylabs/state-transition-sdk/lib/hash/DataHash" - ); - - const pubKeyBytes = Buffer.from(publicKey, "hex"); - - // Parse stateHash using SDK's DataHash - const stateHashObj = DataHash.fromJSON(stateHash); - - // Create RequestId exactly as SDK does - const requestId = await RequestId.create(pubKeyBytes, stateHashObj); - - // Query aggregator for inclusion/exclusion proof - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response = await (client as any).getInclusionProof(requestId); - - if (!response.inclusionProof) { - isSpent = false; - } else { - const proof = response.inclusionProof; - - // Verify the hashpath cryptographically corresponds to our RequestId - const pathResult = await proof.merkleTreePath.verify( - requestId.toBitString().toBigInt() - ); - - if (!pathResult.isPathValid) { - // Invalid proof - assume unspent for safety - console.warn(`⚠️ [isTokenStateSpent] Invalid hashpath for ${tokenId.slice(0, 16)}... - assuming unspent`); - isSpent = false; - } else if (pathResult.isPathIncluded && proof.authenticator !== null) { - // Valid INCLUSION proof: state was spent - isSpent = true; - } else if (!pathResult.isPathIncluded && proof.authenticator === null) { - // Valid EXCLUSION proof: state is unspent - isSpent = false; - } else if (pathResult.isPathIncluded && proof.authenticator === null) { - // SECURITY VIOLATION: path included but no authenticator - console.error(`❌ SECURITY VIOLATION: pathIncluded=true but authenticator=null for token ${tokenId.slice(0, 16)}...`); - // Assume unspent for safety (don't trust invalid proof) - isSpent = false; - } else { - // Contradictory: authenticator present but path doesn't lead to RequestId - console.warn(`⚠️ [isTokenStateSpent] Invalid proof state for ${tokenId.slice(0, 16)}... - assuming unspent`); - isSpent = false; - } - } - } else { - // PRODUCTION MODE: We need a full token to use SDK's isTokenStateSpent - // This is a limitation - for now, fall back to dev mode logic - // Future enhancement: Store full token in tombstones for verification - console.warn(`⚠️ [isTokenStateSpent] Production mode requires full token - falling back to dev mode logic`); - - // Use the same dev mode logic above - const { RequestId } = await import( - "@unicitylabs/state-transition-sdk/lib/api/RequestId" - ); - const { DataHash } = await import( - "@unicitylabs/state-transition-sdk/lib/hash/DataHash" - ); - - const pubKeyBytes = Buffer.from(publicKey, "hex"); - const stateHashObj = DataHash.fromJSON(stateHash); - const requestId = await RequestId.create(pubKeyBytes, stateHashObj); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response = await (client as any).getInclusionProof(requestId); - - if (!response.inclusionProof) { - isSpent = false; - } else { - const proof = response.inclusionProof; - const pathResult = await proof.merkleTreePath.verify( - requestId.toBitString().toBigInt() - ); - - if (!pathResult.isPathValid) { - isSpent = false; - } else if (pathResult.isPathIncluded && proof.authenticator !== null) { - isSpent = true; - } else if (!pathResult.isPathIncluded && proof.authenticator === null) { - isSpent = false; - } else { - isSpent = false; - } - } - } - - // Cache the result - this.cacheSpentState(cacheKey, isSpent); - - return isSpent; - } catch (err) { - // On error, assume unspent (safe default - don't recover tokens on errors) - console.warn(`⚠️ [isTokenStateSpent] Error checking state for ${tokenId.slice(0, 16)}...:`, err); - return false; - } - } - - /** - * Check which tokens are NOT spent (unspent) on Unicity - * Used for sanity check when importing remote tombstones/missing tokens - * Requires full TxfToken data for SDK-based verification - * Returns array of tokenIds that are still valid/unspent - * - * NOTE: This now uses checkSingleTokenSpent internally to respect dev mode bypass - * - * @param options.treatErrorsAsUnspent - If true (default), network errors assume token is unspent. - * Use false for tombstone recovery where errors should NOT restore tokens. - * - true: errors → assume unspent → for live tokens, safe (don't delete) - * - false: errors → assume spent → for tombstones, safe (don't restore) - */ - async checkUnspentTokens( - tokens: Map, - publicKey: string, - options?: { treatErrorsAsUnspent?: boolean } - ): Promise { - if (tokens.size === 0) return []; - - // Default to true for backward compatibility (safe for live token sanity checks) - const treatErrorsAsUnspent = options?.treatErrorsAsUnspent ?? true; - - const unspentTokenIds: string[] = []; - - console.log(`📦 Sanity check (checkUnspentTokens): Verifying ${tokens.size} token(s) with aggregator...`); - - // Get trust base and client - const trustBase = await this.getTrustBase(); - if (!trustBase) { - console.warn("📦 Sanity check: Trust base not available, assuming all tokens unspent (safe fallback)"); - return [...tokens.keys()]; - } - - let client: unknown; - try { - const { ServiceProvider } = await import("./ServiceProvider"); - client = ServiceProvider.stateTransitionClient; - } catch { - console.warn("📦 Sanity check: StateTransitionClient not available, assuming all tokens unspent"); - return [...tokens.keys()]; - } - - if (!client) { - console.warn("📦 Sanity check: StateTransitionClient is null, assuming all tokens unspent"); - return [...tokens.keys()]; - } - - // Use checkSingleTokenSpent which has dev mode bypass logic - for (const [tokenId, txfToken] of tokens) { - try { - // Create a LocalToken-like object for checkSingleTokenSpent - const localToken = { - id: tokenId, - jsonData: JSON.stringify(txfToken), - } as LocalToken; - - const result = await this.checkSingleTokenSpent(localToken, publicKey, trustBase, client); - - if (result.error) { - if (treatErrorsAsUnspent) { - // Safe fallback for live tokens: assume unspent → don't delete - unspentTokenIds.push(tokenId); - } - // Safe fallback for tombstones: assume spent → don't restore (no action needed) - } else if (!result.spent) { - unspentTokenIds.push(tokenId); - } - // If spent, no action needed - } catch (err) { - console.warn(`📦 Sanity check: Exception checking token ${tokenId.slice(0, 8)}...:`, err); - if (treatErrorsAsUnspent) { - // Safe fallback for live tokens: assume unspent → don't delete - unspentTokenIds.push(tokenId); - } - // Safe fallback for tombstones: assume spent → don't restore (no action needed) - } - } - return unspentTokenIds; - } - - /** - * Check all tokens for spent state against aggregator - * Returns list of spent tokens that should be removed - */ - async checkSpentTokens( - tokens: LocalToken[], - publicKey: string, - options?: { batchSize?: number; onProgress?: (completed: number, total: number) => void } - ): Promise { - const spentTokens: SpentTokenInfo[] = []; - const errors: string[] = []; - - const batchSize = options?.batchSize ?? 3; // Smaller batch for network calls - const total = tokens.length; - let completed = 0; - - // Get trust base - const trustBase = await this.getTrustBase(); - if (!trustBase) { - console.warn("📦 Sanity check: Trust base not available, skipping"); - return { spentTokens: [], errors: ["Trust base not available"] }; - } - - // Get state transition client - let client: unknown; - try { - const { ServiceProvider } = await import("./ServiceProvider"); - client = ServiceProvider.stateTransitionClient; - } catch { - console.warn("📦 Sanity check: StateTransitionClient not available"); - return { spentTokens: [], errors: ["StateTransitionClient not available"] }; - } - - // Process in batches - for (let i = 0; i < tokens.length; i += batchSize) { - const batch = tokens.slice(i, i + batchSize); - - const batchResults = await Promise.allSettled( - batch.map(async (token) => { - try { - return await this.checkSingleTokenSpent(token, publicKey, trustBase, client); - } catch (err) { - return { - tokenId: token.id, - localId: token.id, - stateHash: "", - spent: false, - error: err instanceof Error ? err.message : String(err), - }; - } - }) - ); - - for (const result of batchResults) { - completed++; - if (result.status === "fulfilled") { - if (result.value.spent) { - spentTokens.push({ - tokenId: result.value.tokenId, - localId: result.value.localId, - stateHash: result.value.stateHash, - }); - } - if (result.value.error) { - errors.push(`Token ${result.value.tokenId}: ${result.value.error}`); - } - } else { - errors.push(String(result.reason)); - } - } - - if (options?.onProgress) { - options.onProgress(completed, total); - } - } - - return { spentTokens, errors }; - } - - /** - * Check if a single token's current state is spent. - * - * Uses SDK's getInclusionProof to query the aggregator: - * - Inclusion proof (authenticator !== null) = SPENT - * - Exclusion proof (authenticator === null) = UNSPENT - * - * When trust base verification is skipped (dev mode), we skip the - * cryptographic verification of the proof but still use the SDK's - * aggregator query logic. - */ - private async checkSingleTokenSpent( - token: LocalToken, - publicKey: string, - trustBase: unknown, - client: unknown - ): Promise<{ - tokenId: string; - localId: string; - stateHash: string; - spent: boolean; - error?: string; - }> { - if (!token.jsonData) { - return { - tokenId: token.id, - localId: token.id, - stateHash: "", - spent: false, - error: "No jsonData", - }; - } - - let txfToken: TxfToken; - try { - txfToken = JSON.parse(token.jsonData); - } catch { - return { - tokenId: token.id, - localId: token.id, - stateHash: "", - spent: false, - error: "Invalid JSON", - }; - } - - // Get SDK token ID and state hash - const tokenId = txfToken.genesis?.data?.tokenId || token.id; - const localStateHash = getCurrentStateHash(txfToken); - - // Try cache lookup if we have a local stateHash - if (localStateHash) { - const cacheKey = this.getSpentStateCacheKey(tokenId, localStateHash, publicKey); - const cachedResult = this.getSpentStateFromCache(cacheKey); - if (cachedResult !== null) { - return { - tokenId, - localId: token.id, - stateHash: localStateHash, - spent: cachedResult, - }; - } - } - - // CACHE MISS - query aggregator - try { - const { ServiceProvider } = await import("./ServiceProvider"); - const skipVerification = ServiceProvider.isTrustBaseVerificationSkipped(); - - let spent: boolean; - - if (skipVerification) { - // DEV MODE: Use SDK Token object directly, skip trust base verification only - // This ensures we create the exact same RequestId as the SDK - const { Token } = await import( - "@unicitylabs/state-transition-sdk/lib/token/Token" - ); - const { RequestId } = await import( - "@unicitylabs/state-transition-sdk/lib/api/RequestId" - ); - - // Parse TXF into SDK Token object - const sdkToken = await Token.fromJSON(txfToken); - const pubKeyBytes = Buffer.from(publicKey, "hex"); - - // Verify ownership - the publicKey must match the token's predicate - const { PredicateEngineService } = await import( - "@unicitylabs/state-transition-sdk/lib/predicate/PredicateEngineService" - ); - const predicate = await PredicateEngineService.createPredicate(sdkToken.state.predicate); - const isOwner = await predicate.isOwner(pubKeyBytes); - if (!isOwner) { - console.warn(`⚠️ [SpentCheck] PublicKey does NOT match token predicate - wrong key being used`); - } - - // Use SDK's method to calculate state hash (matches what TransferCommitment uses) - const calculatedStateHash = await sdkToken.state.calculateHash(); - - // Create RequestId exactly as SDK does internally - const requestId = await RequestId.create(pubKeyBytes, calculatedStateHash); - - const calculatedStateHashStr = calculatedStateHash.toJSON(); - const hashesMatch = !localStateHash || calculatedStateHashStr === localStateHash; - - // CRITICAL FIX: If calculated hash doesn't match expected hash, DON'T query aggregator - // Querying with wrong hash will check wrong SMT slot and may return false "spent" result - // This protects against incorrectly archiving valid tokens - if (!hashesMatch) { - console.warn(`⚠️ [SpentCheck] Hash mismatch for ${tokenId.slice(0, 16)}... - treating as UNSPENT (safe default)`); - - // Return unspent - don't archive tokens when we can't verify their state - return { - tokenId, - localId: token.id, - stateHash: localStateHash || calculatedStateHashStr, - spent: false, - error: "Hash mismatch - treating as unspent for safety", - }; - } - - // Query aggregator for inclusion/exclusion proof - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response = await (client as any).getInclusionProof(requestId); - - if (!response.inclusionProof) { - spent = false; - } else { - const proof = response.inclusionProof; - - // CRITICAL: Verify the hashpath cryptographically corresponds to our RequestId - // This prevents accepting a proof meant for a different RequestId - const pathResult = await proof.merkleTreePath.verify( - requestId.toBitString().toBigInt() - ); - - if (!pathResult.isPathValid) { - // Hashpath doesn't hash to claimed root - invalid proof - throw new Error("Invalid hashpath - does not verify against root"); - } else if (pathResult.isPathIncluded && proof.authenticator !== null) { - // Valid INCLUSION proof: path leads to RequestId AND authenticator present - spent = true; - } else if (!pathResult.isPathIncluded && proof.authenticator === null) { - // Valid EXCLUSION proof: path shows deviation point, no authenticator - spent = false; - } else if (pathResult.isPathIncluded && proof.authenticator === null) { - // SECURITY: Path leads to our RequestId but no authenticator! - // This is INVALID - a rogue aggregator might be hiding the spent status - console.error(`❌ SECURITY VIOLATION: pathIncluded=true but authenticator=null for token ${tokenId.slice(0, 16)}...`); - throw new Error("Invalid proof: path included but missing authenticator"); - } else { - // Contradictory: authenticator present but path doesn't lead to RequestId - throw new Error("Invalid proof: authenticator present but path not included"); - } - } - // Cache using SDK-calculated state hash - const sdkCacheKey = this.getSpentStateCacheKey(tokenId, calculatedStateHashStr, publicKey); - this.cacheSpentState(sdkCacheKey, spent); - - return { - tokenId, - localId: token.id, - stateHash: calculatedStateHashStr, - spent, - }; - } else { - // PRODUCTION MODE: Use SDK's isTokenStateSpent with full verification - const { Token } = await import( - "@unicitylabs/state-transition-sdk/lib/token/Token" - ); - const sdkToken = await Token.fromJSON(txfToken); - const pubKeyBytes = Buffer.from(publicKey, "hex"); - - // Get state hash for caching/return - const prodStateHash = await sdkToken.state.calculateHash(); - const prodStateHashStr = prodStateHash.toJSON(); - - // CRITICAL FIX: Check for hash mismatch in production mode too - const prodHashesMatch = !localStateHash || prodStateHashStr === localStateHash; - if (!prodHashesMatch) { - console.warn(`⚠️ [SpentCheck/Prod] Hash mismatch for ${tokenId.slice(0, 16)}... - treating as UNSPENT (safe default)`); - - return { - tokenId, - localId: token.id, - stateHash: localStateHash || prodStateHashStr, - spent: false, - error: "Hash mismatch - treating as unspent for safety", - }; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const isSpent = await (client as any).isTokenStateSpent( - trustBase, - sdkToken, - pubKeyBytes - ); - - spent = isSpent === true; - - // Cache using SDK-calculated state hash - const prodCacheKey = this.getSpentStateCacheKey(tokenId, prodStateHashStr, publicKey); - this.cacheSpentState(prodCacheKey, spent); - - return { - tokenId, - localId: token.id, - stateHash: prodStateHashStr, - spent, - }; - } - } catch (err) { - // Don't cache errors - allow retry - return { - tokenId, - localId: token.id, - stateHash: localStateHash || "", - spent: false, - error: err instanceof Error ? err.message : String(err), - }; - } - } - - // ========================================== - // Private Helpers - // ========================================== - - /** - * Check if object has valid TXF structure - */ - private hasValidTxfStructure(obj: unknown): boolean { - if (!obj || typeof obj !== "object") return false; - - const txf = obj as Record; - return !!( - txf.genesis && - typeof txf.genesis === "object" && - txf.state && - typeof txf.state === "object" - ); - } - - /** - * Get list of uncommitted transactions - */ - private getUncommittedTransactions(txfToken: unknown): TxfTransaction[] { - const txf = txfToken as Record; - const transactions = txf.transactions as TxfTransaction[] | undefined; - - if (!transactions || !Array.isArray(transactions)) { - return []; - } - - return transactions.filter((tx) => tx.inclusionProof === null); - } - - /** - * Fetch inclusion proof from aggregator - */ - private async fetchProofFromAggregator( - stateHash: string - ): Promise { - try { - // Use the aggregator's getInclusionProof endpoint - const response = await fetch(`${this.aggregatorUrl}/proof`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "getInclusionProof", - params: { stateHash }, - id: Date.now(), - }), - }); - - if (!response.ok) { - return null; - } - - const result = await response.json(); - if (result.error || !result.result) { - return null; - } - - return result.result; - } catch { - return null; - } - } - - /** - * Verify token using state-transition-sdk - */ - private async verifyWithSdk( - txfToken: unknown - ): Promise<{ success: boolean; error?: string }> { - try { - // Skip SDK verification if trust base verification is bypassed (dev mode) - const { ServiceProvider } = await import("./ServiceProvider"); - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - return { success: true }; - } - - // Dynamic import to avoid bundling issues - const { Token } = await import( - "@unicitylabs/state-transition-sdk/lib/token/Token" - ); - - // Parse token from JSON - const sdkToken = await Token.fromJSON(txfToken); - - // Get trust base - const trustBase = await this.getTrustBase(); - if (!trustBase) { - return { success: true }; // Skip verification if no trust base - } - - // Verify (use 'any' cast since SDK types may vary) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await sdkToken.verify(trustBase as any); - - if (!result.isSuccessful) { - console.warn(`📦 SDK verify: FAILED - ${String(result)}`); - return { - success: false, - error: String(result) || "Verification failed", - }; - } - - return { success: true }; - } catch (err) { - // Log the specific error for debugging - console.warn(`📦 SDK verification exception:`, err instanceof Error ? err.message : err); - // Return success if SDK is not available - validation is optional - return { success: true }; - } - } - - /** - * Get trust base from ServiceProvider (local file) - */ - private async getTrustBase(): Promise { - // Check cache - if ( - this.trustBaseCache && - Date.now() - this.trustBaseCacheTime < this.TRUST_BASE_CACHE_TTL - ) { - return this.trustBaseCache; - } - - try { - // Use ServiceProvider which loads from local trustbase-testnet.json - const { ServiceProvider } = await import("./ServiceProvider"); - const trustBase = ServiceProvider.getRootTrustBase(); - - // Cache - this.trustBaseCache = trustBase; - this.trustBaseCacheTime = Date.now(); - - return trustBase; - } catch (err) { - console.warn("📦 Failed to get trust base from ServiceProvider:", err); - return null; - } - } -} - -// ========================================== -// Singleton Instance -// ========================================== - -let validationServiceInstance: TokenValidationService | null = null; - -/** - * Get singleton instance of TokenValidationService - */ -export function getTokenValidationService(): TokenValidationService { - if (!validationServiceInstance) { - validationServiceInstance = new TokenValidationService(); - } - return validationServiceInstance; -} diff --git a/src/components/wallet/L3/services/TxfSerializer.ts b/src/components/wallet/L3/services/TxfSerializer.ts deleted file mode 100644 index 3c049948..00000000 --- a/src/components/wallet/L3/services/TxfSerializer.ts +++ /dev/null @@ -1,979 +0,0 @@ -/** - * TXF Serializer - * Converts between Token model and TXF format for IPFS storage - */ - -import { Token, TokenStatus } from "../data/model"; -import { RegistryService } from "./RegistryService"; -import type { NametagData } from "./types/TxfTypes"; -import { - type TxfStorageData, - type TxfMeta, - type TxfToken, - type TxfGenesis, - type TxfTransaction, - type TombstoneEntry, - type InvalidatedNametagEntry, - isTokenKey, - isArchivedKey, - isForkedKey, - tokenIdFromKey, - tokenIdFromArchivedKey, - parseForkedKey, - keyFromTokenId, - archivedKeyFromTokenId, - forkedKeyFromTokenIdAndState, -} from "./types/TxfTypes"; -import type { OutboxEntry, MintOutboxEntry } from "./types/OutboxTypes"; -import { - safeParseTxfToken, - safeParseTxfMeta, - validateTokenEntry, -} from "./types/TxfSchemas"; -import { validateNametagData } from "../../../../utils/tokenValidation"; - -// ========================================== -// Token → TXF Conversion -// ========================================== - -/** - * Extract TXF token structure from Token.jsonData - * The jsonData field already contains TXF-format JSON string - * Uses Zod validation for type safety - */ -export function tokenToTxf(token: Token): TxfToken | null { - if (!token.jsonData) { - console.warn(`Token ${token.id} has no jsonData, skipping TXF conversion`); - return null; - } - - try { - // Parse and NORMALIZE the data - this ensures all bytes objects are converted - // to hex strings BEFORE Zod validation and BEFORE writing to IPFS. - // This is critical for fixing tokens that were stored with bytes format before - // the normalization fix was deployed. - const txfData = normalizeSdkTokenToStorage(JSON.parse(token.jsonData)); - - // Validate it has the expected TXF structure - if (!txfData.genesis || !txfData.state) { - console.warn(`Token ${token.id} jsonData is not in TXF format`, { - hasGenesis: !!txfData.genesis, - hasState: !!txfData.state, - topLevelKeys: Object.keys(txfData), - genesisKeys: txfData.genesis ? Object.keys(txfData.genesis) : [], - }); - return null; - } - - // Ensure version field is present - if (!txfData.version) { - txfData.version = "2.0"; - } - - // Ensure transactions array exists - if (!txfData.transactions) { - txfData.transactions = []; - } - - // Ensure nametags array exists - if (!txfData.nametags) { - txfData.nametags = []; - } - - // Ensure _integrity exists - if (!txfData._integrity) { - txfData._integrity = { - genesisDataJSONHash: computeGenesisHash(txfData.genesis.data), - }; - } - - // Validate with Zod schema - const validated = safeParseTxfToken(txfData); - if (validated) { - return validated; - } - - // Fallback: return without strict validation (for backwards compatibility) - // Note: safeParseTxfToken already logs validation errors - return txfData as TxfToken; - } catch (err) { - console.error(`Failed to parse token ${token.id} jsonData:`, err); - return null; - } -} - -/** - * Compute hash of genesis data for integrity field - * Returns hex string with "0000" prefix - */ -function computeGenesisHash(genesisData: TxfGenesis["data"]): string { - // For now, return placeholder - proper implementation would use SHA-256 - // The actual hash should be computed when the token is created - void genesisData; // Will be used when proper hashing is implemented - return "0000" + "0".repeat(60); -} - -// ========================================== -// TXF → Token Conversion -// ========================================== - -/** - * Convert TXF token back to Token model - */ -export function txfToToken(tokenId: string, txf: TxfToken): Token { - // Extract coin info from genesis data - const coinData = txf.genesis.data.coinData; - const totalAmount = coinData.reduce((sum, [, amt]) => { - return sum + BigInt(amt || "0"); - }, BigInt(0)); - - // Get coin ID (use first non-zero coin, or first coin if all zero) - let coinId = coinData[0]?.[0] || ""; - for (const [cid, amt] of coinData) { - if (BigInt(amt || "0") > 0) { - coinId = cid; - break; - } - } - - // Determine token status based on transaction proofs - let status: TokenStatus = TokenStatus.CONFIRMED; - if (txf.transactions.length > 0) { - const lastTx = txf.transactions[txf.transactions.length - 1]; - if (lastTx.inclusionProof === null) { - status = TokenStatus.PENDING; - } - } - - // Extract token type for display name - const tokenType = txf.genesis.data.tokenType; - const isNft = tokenType === "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"; - - // Lookup registry for symbol and icon - let symbol = isNft ? "NFT" : "UCT"; - let iconUrl: string | undefined = undefined; - if (coinId && !isNft) { - const registryService = RegistryService.getInstance(); - const def = registryService.getCoinDefinition(coinId); - if (def) { - symbol = def.symbol || symbol; - iconUrl = registryService.getIconUrl(def) || undefined; - } - } - - return new Token({ - id: tokenId, - name: isNft ? "NFT" : symbol, - type: isNft ? "NFT" : symbol, - timestamp: Date.now(), - jsonData: JSON.stringify(txf), - status, - amount: totalAmount.toString(), - coinId, - symbol, - iconUrl, - sizeBytes: JSON.stringify(txf).length, - }); -} - -// ========================================== -// SDK Token Normalization -// ========================================== - -/** - * Convert bytes array/object to hex string - */ -function bytesToHexInternal(bytes: number[] | Uint8Array): string { - const arr = Array.isArray(bytes) ? bytes : Array.from(bytes); - return arr.map(b => b.toString(16).padStart(2, "0")).join(""); -} - -/** - * Normalize a value that may be a hex string, bytes object, or Buffer to hex string - */ -function normalizeToHex(value: unknown): string { - if (typeof value === "string") { - return value; // Already hex string - } - if (value && typeof value === "object") { - const obj = value as Record; - // SDK format: { bytes: [...] } - if ("bytes" in obj && (Array.isArray(obj.bytes) || obj.bytes instanceof Uint8Array)) { - return bytesToHexInternal(obj.bytes as number[] | Uint8Array); - } - // Buffer.toJSON() format: { type: "Buffer", data: [...] } - if (obj.type === "Buffer" && Array.isArray(obj.data)) { - return bytesToHexInternal(obj.data as number[]); - } - } - console.warn("Unknown bytes format, returning as-is:", value); - return String(value); -} - -/** - * Normalize SDK token JSON to canonical TXF storage format. - * Converts all bytes objects to hex strings before storage. - * - * Call this immediately after Token.toJSON() to ensure consistent storage format. - * This prevents storing SDK's internal format (bytes objects) and ensures all - * tokenId, tokenType, salt, publicKey, signature fields are hex strings. - */ -export function normalizeSdkTokenToStorage(sdkTokenJson: unknown): TxfToken { - // Deep copy to avoid mutating the original - const txf = JSON.parse(JSON.stringify(sdkTokenJson)); - - // Normalize genesis.data fields (tokenId, tokenType, salt) - if (txf.genesis?.data) { - const data = txf.genesis.data; - if (data.tokenId !== undefined) { - data.tokenId = normalizeToHex(data.tokenId); - } - if (data.tokenType !== undefined) { - data.tokenType = normalizeToHex(data.tokenType); - } - if (data.salt !== undefined) { - data.salt = normalizeToHex(data.salt); - } - } - - // Normalize authenticator fields in genesis inclusion proof - if (txf.genesis?.inclusionProof?.authenticator) { - const auth = txf.genesis.inclusionProof.authenticator; - if (auth.publicKey !== undefined) { - auth.publicKey = normalizeToHex(auth.publicKey); - } - if (auth.signature !== undefined) { - auth.signature = normalizeToHex(auth.signature); - } - } - - // Normalize transaction authenticators - if (Array.isArray(txf.transactions)) { - for (const tx of txf.transactions) { - if (tx.inclusionProof?.authenticator) { - const auth = tx.inclusionProof.authenticator; - if (auth.publicKey !== undefined) { - auth.publicKey = normalizeToHex(auth.publicKey); - } - if (auth.signature !== undefined) { - auth.signature = normalizeToHex(auth.signature); - } - } - } - } - - return txf as TxfToken; -} - -// ========================================== -// Storage Data Building -// ========================================== - -/** - * Build complete TXF storage data from tokens and metadata - * Now async to support stateHash computation for genesis-only tokens - */ -export async function buildTxfStorageData( - tokens: Token[], - meta: Omit, - nametag?: NametagData, - tombstones?: TombstoneEntry[], - archivedTokens?: Map, - forkedTokens?: Map, - outboxEntries?: OutboxEntry[], - mintOutboxEntries?: MintOutboxEntry[], - invalidatedNametags?: InvalidatedNametagEntry[] -): Promise { - const storageData: TxfStorageData = { - _meta: { - ...meta, - formatVersion: "2.0", - }, - }; - - // Validate nametag before exporting to IPFS - if (nametag) { - const nametagValidation = validateNametagData(nametag, { - requireInclusionProof: false, // May have stripped proofs - context: "IPFS export", - }); - if (nametagValidation.isValid) { - storageData._nametag = nametag; - } else { - // Log error but DO NOT export corrupted nametag data - console.error("❌ Skipping corrupted nametag during IPFS export:", nametagValidation.errors); - } - } - - // Add tombstones for spent token states (prevents zombie token resurrection) - if (tombstones && tombstones.length > 0) { - storageData._tombstones = tombstones; - } - - // Add outbox entries (CRITICAL for transfer recovery) - if (outboxEntries && outboxEntries.length > 0) { - storageData._outbox = outboxEntries; - } - - // Add mint outbox entries (CRITICAL for mint recovery) - if (mintOutboxEntries && mintOutboxEntries.length > 0) { - storageData._mintOutbox = mintOutboxEntries; - } - - // Add invalidated nametags (preserves history across devices) - if (invalidatedNametags && invalidatedNametags.length > 0) { - storageData._invalidatedNametags = invalidatedNametags; - } - - // Add each active token with _ key - for (const token of tokens) { - let txf = tokenToTxf(token); - if (txf) { - // Compute stateHash for genesis-only tokens that don't have it - if (needsStateHashComputation(txf)) { - try { - txf = await computeAndPatchStateHash(txf); - } catch (err) { - console.warn(`Failed to compute stateHash for token ${token.id.slice(0, 8)}...:`, err); - } - } - // Use the token's actual ID from genesis data - const actualTokenId = txf.genesis.data.tokenId; - storageData[keyFromTokenId(actualTokenId)] = txf; - } - } - - // Add archived tokens with _archived_ key - if (archivedTokens && archivedTokens.size > 0) { - for (const [tokenId, txf] of archivedTokens) { - storageData[archivedKeyFromTokenId(tokenId)] = txf; - } - } - - // Add forked tokens with _forked__ key - if (forkedTokens && forkedTokens.size > 0) { - for (const [key, txf] of forkedTokens) { - // Key is already in format tokenId_stateHash - const [tokenId, stateHash] = key.split("_"); - if (tokenId && stateHash) { - storageData[forkedKeyFromTokenIdAndState(tokenId, stateHash)] = txf; - } - } - } - - return storageData; -} - -/** - * Parse TXF storage data from IPFS with Zod validation - */ -export function parseTxfStorageData(data: unknown): { - tokens: Token[]; - meta: TxfMeta | null; - nametag: NametagData | null; - tombstones: TombstoneEntry[]; - archivedTokens: Map; - forkedTokens: Map; - outboxEntries: OutboxEntry[]; - mintOutboxEntries: MintOutboxEntry[]; - invalidatedNametags: InvalidatedNametagEntry[]; - validationErrors: string[]; -} { - const result: { - tokens: Token[]; - meta: TxfMeta | null; - nametag: NametagData | null; - tombstones: TombstoneEntry[]; - archivedTokens: Map; - forkedTokens: Map; - outboxEntries: OutboxEntry[]; - mintOutboxEntries: MintOutboxEntry[]; - invalidatedNametags: InvalidatedNametagEntry[]; - validationErrors: string[]; - } = { - tokens: [], - meta: null, - nametag: null, - tombstones: [], - archivedTokens: new Map(), - forkedTokens: new Map(), - outboxEntries: [], - mintOutboxEntries: [], - invalidatedNametags: [], - validationErrors: [], - }; - - if (!data || typeof data !== "object") { - result.validationErrors.push("Storage data is not an object"); - return result; - } - - const storageData = data as Record; - - // Extract and validate metadata using Zod - if (storageData._meta) { - const validatedMeta = safeParseTxfMeta(storageData._meta); - if (validatedMeta) { - result.meta = validatedMeta; - } else { - result.validationErrors.push("Invalid _meta structure"); - // Still try to use it as fallback - if (typeof storageData._meta === "object") { - result.meta = storageData._meta as TxfMeta; - } - } - } - - // Extract and validate nametag - if (storageData._nametag && typeof storageData._nametag === "object") { - const nametagValidation = validateNametagData(storageData._nametag, { - requireInclusionProof: false, // IPFS data may have stripped proofs - context: "IPFS import", - }); - if (nametagValidation.isValid) { - result.nametag = storageData._nametag as NametagData; - } else { - // Log warning but include validation errors - console.warn("Nametag validation failed during IPFS import:", nametagValidation.errors); - result.validationErrors.push(`Nametag validation: ${nametagValidation.errors.join(", ")}`); - // Do NOT import corrupted nametag - prevents token: {} bug - } - } - - // Extract tombstones (state-hash-aware entries) - if (storageData._tombstones && Array.isArray(storageData._tombstones)) { - for (const entry of storageData._tombstones) { - // Parse TombstoneEntry objects (new format) - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as TombstoneEntry).tokenId === "string" && - typeof (entry as TombstoneEntry).stateHash === "string" && - typeof (entry as TombstoneEntry).timestamp === "number" - ) { - result.tombstones.push(entry as TombstoneEntry); - } - // Legacy string format: discard (no state hash info) - // Per migration strategy: start fresh with state-hash-aware tombstones - } - } - - // Extract outbox entries (CRITICAL for transfer recovery) - if (storageData._outbox && Array.isArray(storageData._outbox)) { - for (const entry of storageData._outbox) { - // Basic validation for OutboxEntry structure - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as OutboxEntry).id === "string" && - typeof (entry as OutboxEntry).status === "string" && - typeof (entry as OutboxEntry).sourceTokenId === "string" && - typeof (entry as OutboxEntry).salt === "string" && - typeof (entry as OutboxEntry).commitmentJson === "string" - ) { - result.outboxEntries.push(entry as OutboxEntry); - } else { - result.validationErrors.push("Invalid outbox entry structure"); - } - } - } - - // Extract mint outbox entries (CRITICAL for mint recovery) - if (storageData._mintOutbox && Array.isArray(storageData._mintOutbox)) { - for (const entry of storageData._mintOutbox) { - // Basic validation for MintOutboxEntry structure - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as MintOutboxEntry).id === "string" && - typeof (entry as MintOutboxEntry).status === "string" && - typeof (entry as MintOutboxEntry).type === "string" && - typeof (entry as MintOutboxEntry).salt === "string" && - typeof (entry as MintOutboxEntry).requestIdHex === "string" && - typeof (entry as MintOutboxEntry).mintDataJson === "string" - ) { - result.mintOutboxEntries.push(entry as MintOutboxEntry); - } else { - result.validationErrors.push("Invalid mint outbox entry structure"); - } - } - } - - // Extract invalidated nametags (preserves history across devices) - if (storageData._invalidatedNametags && Array.isArray(storageData._invalidatedNametags)) { - for (const entry of storageData._invalidatedNametags) { - // Basic validation for InvalidatedNametagEntry structure - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as InvalidatedNametagEntry).name === "string" && - typeof (entry as InvalidatedNametagEntry).invalidatedAt === "number" && - typeof (entry as InvalidatedNametagEntry).invalidationReason === "string" - ) { - result.invalidatedNametags.push(entry as InvalidatedNametagEntry); - } else { - result.validationErrors.push("Invalid invalidated nametag entry structure"); - } - } - } - - // Extract and validate all keys - for (const key of Object.keys(storageData)) { - // Active tokens: _ - if (isTokenKey(key)) { - const tokenId = tokenIdFromKey(key); - const validation = validateTokenEntry(key, storageData[key]); - - if (validation.valid && validation.token) { - try { - const token = txfToToken(tokenId, validation.token); - result.tokens.push(token); - } catch (err) { - result.validationErrors.push(`Token ${tokenId}: conversion failed - ${err}`); - } - } else { - result.validationErrors.push(`Token ${tokenId}: ${validation.error || "validation failed"}`); - // Try fallback without strict validation - try { - const txfToken = storageData[key] as TxfToken; - if (txfToken?.genesis?.data?.tokenId) { - const token = txfToToken(tokenId, txfToken); - result.tokens.push(token); - console.warn(`Token ${tokenId} loaded with fallback (failed Zod validation)`); - } - } catch { - // Skip invalid token - } - } - } - // Archived tokens: _archived_ - else if (isArchivedKey(key)) { - const tokenId = tokenIdFromArchivedKey(key); - try { - const txfToken = storageData[key] as TxfToken; - if (txfToken?.genesis?.data?.tokenId) { - result.archivedTokens.set(tokenId, txfToken); - } - } catch { - result.validationErrors.push(`Archived token ${tokenId}: invalid structure`); - } - } - // Forked tokens: _forked__ - else if (isForkedKey(key)) { - const parsed = parseForkedKey(key); - if (parsed) { - try { - const txfToken = storageData[key] as TxfToken; - if (txfToken?.genesis?.data?.tokenId) { - // Store with key format tokenId_stateHash (matching WalletRepository format) - const mapKey = `${parsed.tokenId}_${parsed.stateHash}`; - result.forkedTokens.set(mapKey, txfToken); - } - } catch { - result.validationErrors.push(`Forked token ${parsed.tokenId}: invalid structure`); - } - } - } - } - - if (result.validationErrors.length > 0) { - console.warn("TXF storage data validation issues:", result.validationErrors); - } - - return result; -} - -// ========================================== -// TXF File Export/Import -// ========================================== - -/** - * Build TXF export file from tokens (for manual export) - * This creates a standard TXF file without metadata envelope - */ -export function buildTxfExportFile(tokens: Token[]): Record { - const txfFile: Record = {}; - - for (const token of tokens) { - const txf = tokenToTxf(token); - if (txf) { - const tokenId = txf.genesis.data.tokenId; - txfFile[keyFromTokenId(tokenId)] = txf; - } - } - - return txfFile; -} - -/** - * Parse TXF file content (for manual import) with Zod validation - */ -export function parseTxfFile(content: unknown): { tokens: Token[]; errors: string[] } { - if (!content || typeof content !== "object") { - return { tokens: [], errors: ["Content is not an object"] }; - } - - const tokens: Token[] = []; - const errors: string[] = []; - const data = content as Record; - - for (const key of Object.keys(data)) { - if (isTokenKey(key)) { - const tokenId = tokenIdFromKey(key); - - // First try with Zod validation - const validatedToken = safeParseTxfToken(data[key]); - - if (validatedToken) { - try { - const token = txfToToken(tokenId, validatedToken); - tokens.push(token); - } catch (err) { - errors.push(`Token ${tokenId}: conversion failed - ${err}`); - } - } else { - // Fallback: try without strict validation - errors.push(`Token ${tokenId}: Zod validation failed, trying fallback`); - try { - const txfToken = data[key] as TxfToken; - if (txfToken?.genesis?.data?.tokenId) { - const token = txfToToken(tokenId, txfToken); - tokens.push(token); - } - } catch (err) { - errors.push(`Token ${tokenId}: fallback also failed - ${err}`); - } - } - } - } - - if (errors.length > 0) { - console.warn("TXF file parsing issues:", errors); - } - - return { tokens, errors }; -} - -// ========================================== -// Utility Functions -// ========================================== - -/** - * Get token ID from Token object - * Prefers the genesis.data.tokenId if available - */ -export function getTokenId(token: Token): string { - if (token.jsonData) { - try { - const txf = JSON.parse(token.jsonData); - if (txf.genesis?.data?.tokenId) { - return txf.genesis.data.tokenId; - } - } catch { - // Fall through to use token.id - } - } - return token.id; -} - -/** - * Get the stored current state hash from a TXF token - * - If has transactions: use newStateHash from last transaction - * - If genesis-only: use _integrity.currentStateHash (if computed and stored) - * - Otherwise: returns undefined (SDK should calculate it) - */ -export function getCurrentStateHash(txf: TxfToken): string | undefined { - // Handle tokens with transactions - use newStateHash from last transaction - if (txf.transactions && txf.transactions.length > 0) { - const lastTx = txf.transactions[txf.transactions.length - 1]; - if (lastTx?.newStateHash) { - return lastTx.newStateHash; - } - // Missing newStateHash is expected for older tokens - SDK will calculate it - return undefined; - } - - // Genesis-only tokens: check _integrity.currentStateHash (computed post-import) - if (txf._integrity?.currentStateHash) { - return txf._integrity.currentStateHash; - } - - // No stored state hash available - SDK must calculate it - return undefined; -} - -/** - * Get current state hash from a Token object (parses jsonData) - */ -export function getCurrentStateHashFromToken(token: Token): string | null { - if (!token.jsonData) return null; - - try { - const txf = JSON.parse(token.jsonData) as TxfToken; - return getCurrentStateHash(txf) ?? null; - } catch { - return null; - } -} - -/** - * Check if token has valid TXF data - */ -export function hasValidTxfData(token: Token): boolean { - if (!token.jsonData) return false; - - try { - const txf = JSON.parse(token.jsonData); - return !!( - txf.genesis && - txf.genesis.data && - txf.genesis.data.tokenId && - txf.state && - txf.genesis.inclusionProof - ); - } catch { - return false; - } -} - -/** - * Count committed transactions in a token - */ -export function countCommittedTransactions(token: Token): number { - if (!token.jsonData) return 0; - - try { - const txf = JSON.parse(token.jsonData); - if (!txf.transactions) return 0; - - return txf.transactions.filter( - (tx: TxfTransaction) => tx.inclusionProof !== null - ).length; - } catch { - return 0; - } -} - -/** - * Check if token has uncommitted transactions - */ -export function hasUncommittedTransactions(token: Token): boolean { - if (!token.jsonData) return false; - - try { - const txf = JSON.parse(token.jsonData); - if (!txf.transactions || txf.transactions.length === 0) return false; - - return txf.transactions.some( - (tx: TxfTransaction) => tx.inclusionProof === null - ); - } catch { - return false; - } -} - -/** - * Check if a TXF token has missing newStateHash on any transaction - * This can happen with tokens sent from older versions of the app - */ -export function hasMissingNewStateHash(txf: TxfToken): boolean { - if (!txf.transactions || txf.transactions.length === 0) { - return false; - } - return txf.transactions.some(tx => !tx.newStateHash); -} - -/** - * Check if a TXF token needs its currentStateHash computed - * Returns true for genesis-only tokens without a stored currentStateHash - */ -export function needsStateHashComputation(txf: TxfToken): boolean { - // Tokens with transactions don't need this - they have newStateHash - if (txf.transactions && txf.transactions.length > 0) { - return false; - } - // Genesis-only tokens need computation if currentStateHash is missing - return !txf._integrity?.currentStateHash; -} - -/** - * Compute and patch the currentStateHash for a genesis-only token. - * - * For genesis-only tokens (no transactions), the current state hash must be - * calculated from the SDK's Token.state.calculateHash(). This function: - * 1. Parses the TXF with the SDK - * 2. Calculates the current state hash - * 3. Stores it in _integrity.currentStateHash - * - * @param txf - The TXF token to patch - * @returns Patched TXF token, or original if no patch needed or computation failed - */ -export async function computeAndPatchStateHash(txf: TxfToken): Promise { - // Only patch genesis-only tokens that don't have currentStateHash - if (!needsStateHashComputation(txf)) { - return txf; - } - - try { - // Dynamic import to avoid bundling issues - const { Token } = await import( - "@unicitylabs/state-transition-sdk/lib/token/Token" - ); - - // Parse with SDK to access state.calculateHash() - const sdkToken = await Token.fromJSON(txf); - - // Calculate the current state hash - const calculatedStateHash = await sdkToken.state.calculateHash(); - const stateHashStr = calculatedStateHash.toJSON(); - - // Deep copy the TXF to avoid mutating the original - const patchedTxf = JSON.parse(JSON.stringify(txf)) as TxfToken; - - // Ensure _integrity exists - if (!patchedTxf._integrity) { - patchedTxf._integrity = { - genesisDataJSONHash: "0000" + "0".repeat(60), - }; - } - - // Store the computed state hash - patchedTxf._integrity.currentStateHash = stateHashStr; - - console.log( - `📦 Computed stateHash for genesis-only token ${txf.genesis.data.tokenId.slice(0, 8)}...: ${stateHashStr.slice(0, 16)}...` - ); - - return patchedTxf; - } catch (err) { - console.error( - `Failed to compute stateHash for token ${txf.genesis?.data?.tokenId?.slice(0, 8)}...:`, - err - ); - return txf; - } -} - -/** - * Compute and patch stateHash for a Token model (updates jsonData) - * @returns New Token with patched jsonData, or original if no patch needed - */ -export async function computeAndPatchTokenStateHash(token: Token): Promise { - if (!token.jsonData) return token; - - try { - const txf = JSON.parse(token.jsonData) as TxfToken; - - // Check if patch is needed - if (!needsStateHashComputation(txf)) { - return token; - } - - // Patch the TXF - const patchedTxf = await computeAndPatchStateHash(txf); - - // If unchanged, return original - if (patchedTxf === txf) { - return token; - } - - // Return new Token with patched jsonData - return new Token({ - ...token, - jsonData: JSON.stringify(patchedTxf), - }); - } catch { - return token; - } -} - -/** - * Repair a TXF token by calculating missing newStateHash values using the SDK. - * - * BACKGROUND: Tokens sent before a bug fix were missing the `newStateHash` field - * on their transfer transactions. The SDK's Token.fromJSON can still parse these - * tokens because it recalculates state hashes internally from the `state` field. - * - * This function: - * 1. Parses the TXF with the SDK (which calculates hashes internally) - * 2. Gets the calculated state hash from the SDK token - * 3. Patches the last transaction with the correct newStateHash - * - * @param txf - The TXF token to repair - * @returns Repaired TXF token, or null if repair failed - */ -export async function repairMissingStateHash(txf: TxfToken): Promise { - if (!txf.transactions || txf.transactions.length === 0) { - // No transactions to repair - return txf; - } - - // Check if repair is needed - const lastTx = txf.transactions[txf.transactions.length - 1]; - if (lastTx.newStateHash) { - // Already has newStateHash, no repair needed - return txf; - } - - try { - // Dynamic import to avoid bundling issues - const { Token } = await import( - "@unicitylabs/state-transition-sdk/lib/token/Token" - ); - - // Parse with SDK - this calculates state hashes internally - const sdkToken = await Token.fromJSON(txf); - - // Get the calculated state hash from the SDK token's current state - const calculatedStateHash = await sdkToken.state.calculateHash(); - const stateHashStr = calculatedStateHash.toJSON(); - - // Deep copy the TXF to avoid mutating the original - const repairedTxf = JSON.parse(JSON.stringify(txf)) as TxfToken; - - // Patch the last transaction with the calculated state hash - const lastTxIndex = repairedTxf.transactions.length - 1; - repairedTxf.transactions[lastTxIndex] = { - ...repairedTxf.transactions[lastTxIndex], - newStateHash: stateHashStr, - }; - - console.log(`🔧 Repaired token ${txf.genesis.data.tokenId.slice(0, 8)}... - added missing newStateHash: ${stateHashStr.slice(0, 12)}...`); - - return repairedTxf; - } catch (err) { - console.error(`Failed to repair token ${txf.genesis?.data?.tokenId?.slice(0, 8)}...:`, err); - return null; - } -} - -/** - * Repair a Token model by calculating missing newStateHash values. - * Returns a new Token with repaired jsonData, or the original if no repair needed/possible. - */ -export async function repairTokenMissingStateHash(token: Token): Promise { - if (!token.jsonData) return token; - - try { - const txf = JSON.parse(token.jsonData) as TxfToken; - - // Check if repair is needed - if (!hasMissingNewStateHash(txf)) { - return token; - } - - // Repair the TXF - const repairedTxf = await repairMissingStateHash(txf); - if (!repairedTxf) { - return token; // Repair failed, return original - } - - // Return new Token with repaired jsonData - return new Token({ - ...token, - jsonData: JSON.stringify(repairedTxf), - }); - } catch { - return token; - } -} diff --git a/src/components/wallet/L3/services/api.ts b/src/components/wallet/L3/services/api.ts deleted file mode 100644 index d24b9097..00000000 --- a/src/components/wallet/L3/services/api.ts +++ /dev/null @@ -1,145 +0,0 @@ -import axios from "axios"; - -const REGISTRY_URL = - "https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet.json"; -// Use Vite proxy to avoid CORS and reduce direct CoinGecko rate-limit hits -const COINGECKO_URL = "/coingecko/simple/price"; - -// In-memory cache to deduplicate concurrent/rapid calls (e.g. SwapModal + useWallet) -let _priceCache: { data: Record; ts: number } | null = null; -const PRICE_CACHE_TTL_MS = 5 * 60_000; // 5 minutes - -const DEFAULT_PRICES: Record = { - bitcoin: { - priceUsd: 98500.0, - priceEur: 91200.0, - change24h: 2.3, - timestamp: Date.now(), - }, - ethereum: { - priceUsd: 3850.0, - priceEur: 3560.0, - change24h: 1.8, - timestamp: Date.now(), - }, - tether: { - priceUsd: 1.0, - priceEur: 0.92, - change24h: 0.01, - timestamp: Date.now(), - }, - solana: { - priceUsd: 220.0, - priceEur: 218.92, - change24h: 0.11, - timestamp: Date.now(), - }, - efranc: { - priceUsd: 0.00169, - priceEur: 0.00152, - change24h: 0.01, - timestamp: Date.now(), - }, - enaira: { - priceUsd: 0.000647, - priceEur: 0.000564, - change24h: 0.02, - timestamp: Date.now(), - }, -}; - -export interface CryptoPriceData { - priceUsd: number; - priceEur: number; - change24h: number; - timestamp: number; -} - -export interface TokenDefinition { - id: string; - network: string; - assetKind: "fungible" | "non-fungible"; - name: string; - symbol: string; - decimals: number; - description: string; - icon?: string; - icons?: Array<{ url: string }>; -} - -interface CoinGeckoResponse { - [key: string]: { - usd: number; - eur: number; - usd_24h_change: number; - }; -} - -export const ApiService = { - fetchPrices: async (): Promise> => { - // Return cached result if still fresh - if (_priceCache && Date.now() - _priceCache.ts < PRICE_CACHE_TTL_MS) { - return _priceCache.data; - } - - try { - const response = await axios.get(COINGECKO_URL, { - params: { - ids: "bitcoin,ethereum,tether,solana", - vs_currencies: "usd,eur", - include_24hr_change: "true", - }, - timeout: 5000, - }); - - const prices: Record = {}; - const data = response.data; - const now = Date.now(); - - Object.keys(data).forEach((key) => { - prices[key] = { - priceUsd: data[key].usd || DEFAULT_PRICES[key]?.priceUsd || 0, - priceEur: data[key].eur || DEFAULT_PRICES[key]?.priceEur || 0, - change24h: - data[key].usd_24h_change || DEFAULT_PRICES[key]?.change24h || 0, - timestamp: now, - }; - }); - - const result = { ...DEFAULT_PRICES, ...prices }; - _priceCache = { data: result, ts: Date.now() }; - return result; - } catch (error) { - console.warn("API: Failed to fetch prices (using defaults)", error); - return _priceCache?.data ?? DEFAULT_PRICES; - } - }, - - - fetchRegistry: async (): Promise => { - try { - const response = await axios.get(REGISTRY_URL, { - timeout: 10000, - }); - - if (Array.isArray(response.data)) { - return response.data; - } - return []; - } catch (error) { - console.error("API: Failed to fetch registry", error); - return []; - } - }, - - getBestIconUrl: (def: TokenDefinition): string | null => { - if (def.icons && def.icons.length > 0) { - const pngIcon = def.icons.find((i) => - i.url.toLowerCase().includes(".png") - ); - if (pngIcon) return pngIcon.url; - return def.icons[0].url; - } - return def.icon || null; - }, -}; diff --git a/src/components/wallet/L3/services/transfer/TokenSplitCalculator.ts b/src/components/wallet/L3/services/transfer/TokenSplitCalculator.ts deleted file mode 100644 index bb2eb7cc..00000000 --- a/src/components/wallet/L3/services/transfer/TokenSplitCalculator.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Token } from "../../data/model"; -import { Token as SdkToken } from "@unicitylabs/state-transition-sdk/lib/token/Token"; - -export interface TokenWithAmount { - sdkToken: SdkToken; - amount: bigint; - uiToken: Token; -} - -export interface SplitPlan { - tokensToTransferDirectly: TokenWithAmount[]; - tokenToSplit: TokenWithAmount | null; - splitAmount: bigint | null; - remainderAmount: bigint | null; - totalTransferAmount: bigint; - coinId: string; - requiresSplit: boolean; -} - -export class TokenSplitCalculator { - async calculateOptimalSplit( - availableTokens: Token[], - targetAmount: bigint, - targetCoinIdHex: string - ): Promise { - console.log( - `🧮 Calculating split for ${targetAmount} of ${targetCoinIdHex}` - ); - - const candidates: TokenWithAmount[] = []; - - for (const t of availableTokens) { - if (t.coinId !== targetCoinIdHex) continue; - if (t.status !== "CONFIRMED") continue; - if (!t.jsonData) continue; - - try { - const parsed = JSON.parse(t.jsonData); - const sdkToken = await SdkToken.fromJSON(parsed); - const realAmount = this.getRealAmountFromSdk(sdkToken); - - if (realAmount <= 0n) { - console.warn(`Token ${t.id} has 0 balance in SDK structure.`); - continue; - } - - console.log(realAmount) - - candidates.push({ - sdkToken: sdkToken, - amount: realAmount, - uiToken: t, - }); - } catch (e) { - console.warn("Failed to parse candidate token", t.id, e); - } - } - - candidates.sort((a, b) => (a.amount < b.amount ? -1 : 1)); - - const totalAvailable = candidates.reduce((sum, t) => sum + t.amount, 0n); - if (totalAvailable < targetAmount) { - console.error( - `Insufficient funds. Available: ${totalAvailable}, Required: ${targetAmount}` - ); - return null; - } - - const exactMatch = candidates.find((t) => t.amount === targetAmount); - if (exactMatch) { - console.log("🎯 Found exact match token"); - return this.createDirectPlan([exactMatch], targetAmount, targetCoinIdHex); - } - - const maxCombinationSize = Math.min(5, candidates.length); - - for (let size = 2; size <= maxCombinationSize; size++) { - const combo = this.findCombinationOfSize(candidates, targetAmount, size); - if (combo) { - console.log(`🎯 Found exact combination of ${size} tokens`); - return this.createDirectPlan(combo, targetAmount, targetCoinIdHex); - } - } - - const toTransfer: TokenWithAmount[] = []; - let currentSum = 0n; - - for (const candidate of candidates) { - const newSum = currentSum + candidate.amount; - - if (newSum === targetAmount) { - toTransfer.push(candidate); - return this.createDirectPlan(toTransfer, targetAmount, targetCoinIdHex); - } else if (newSum < targetAmount) { - toTransfer.push(candidate); - currentSum = newSum; - } else { - const neededFromThisToken = targetAmount - currentSum; - const remainderForMe = candidate.amount - neededFromThisToken; - - console.log(`✂️ Splitting required. Remainder: ${remainderForMe}`); - - return { - tokensToTransferDirectly: toTransfer, - tokenToSplit: candidate, - splitAmount: neededFromThisToken, - remainderAmount: remainderForMe, - totalTransferAmount: targetAmount, - coinId: targetCoinIdHex, - requiresSplit: true, - }; - } - } - - return null; - } - - private getRealAmountFromSdk(sdkToken: SdkToken): bigint { - try { - const coinsOpt = sdkToken.coins; - const coinData = coinsOpt; - - if (coinData && coinData.coins) { - const rawCoins = coinData.coins; - let val: any = null; - - const firstItem = rawCoins[0]; - if (Array.isArray(firstItem) && firstItem.length === 2) { - val = firstItem[1]; - } - - if (Array.isArray(val)) { - return BigInt(val[1]?.toString() || "0"); - } else if (val) { - return BigInt(val.toString()); - } - } - } catch (e) { - console.error("Error extracting amount from SDK token", e); - } - return 0n; - } - - // === PRIVATE HELPERS === - - private createDirectPlan( - tokens: TokenWithAmount[], - total: bigint, - coinId: string - ): SplitPlan { - return { - tokensToTransferDirectly: tokens, - tokenToSplit: null, - splitAmount: null, - remainderAmount: null, - totalTransferAmount: total, - coinId: coinId, - requiresSplit: false, - }; - } - - private findCombinationOfSize( - tokens: TokenWithAmount[], - targetAmount: bigint, - size: Int - ): TokenWithAmount[] | null { - const generator = this.generateCombinations(tokens, size); - - for (const combo of generator) { - const sum = combo.reduce((acc, t) => acc + t.amount, 0n); - if (sum === targetAmount) { - return combo; - } - } - return null; - } - - private *generateCombinations( - tokens: TokenWithAmount[], - k: number, - start: number = 0, - current: TokenWithAmount[] = [] - ): Generator { - if (k === 0) { - yield current; - return; - } - - for (let i = start; i < tokens.length; i++) { - yield* this.generateCombinations(tokens, k - 1, i + 1, [ - ...current, - tokens[i], - ]); - } - } -} - -// TypeScript alias for Integer to match intent -type Int = number; diff --git a/src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts b/src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts deleted file mode 100644 index 8ed8a675..00000000 --- a/src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts +++ /dev/null @@ -1,752 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { IAddress } from "@unicitylabs/state-transition-sdk/lib/address/IAddress"; -import { UnmaskedPredicateReference } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference"; -import { waitInclusionProofWithDevBypass } from "../../../../../utils/devTools"; -import { ServiceProvider } from "../ServiceProvider"; -import type { SplitPlan } from "./TokenSplitCalculator"; -import { Buffer } from "buffer"; -import { Token as SdkToken } from "@unicitylabs/state-transition-sdk/lib/token/Token"; -import { TokenId } from "@unicitylabs/state-transition-sdk/lib/token/TokenId"; -import type { TransferTransaction } from "@unicitylabs/state-transition-sdk/lib/transaction/TransferTransaction"; -import type { SigningService } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService"; -import { CoinId } from "@unicitylabs/state-transition-sdk/lib/token/fungible/CoinId"; -import { TokenSplitBuilder } from "@unicitylabs/state-transition-sdk/lib/transaction/split/TokenSplitBuilder"; -import { HashAlgorithm } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm"; -import { TokenCoinData } from "@unicitylabs/state-transition-sdk/lib/token/fungible/TokenCoinData"; -import { TransferCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/TransferCommitment"; -import { MintCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment"; -import { MintTransactionData } from "@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData"; -import { UnmaskedPredicate } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate"; -import { TokenState } from "@unicitylabs/state-transition-sdk/lib/token/TokenState"; -import { OutboxRepository } from "../../../../../repositories/OutboxRepository"; -import type { OutboxSplitGroup } from "../types/OutboxTypes"; -import { createOutboxEntry } from "../types/OutboxTypes"; -import { TokenRecoveryService } from "../TokenRecoveryService"; -import { getTokensForAddress, dispatchWalletUpdated } from "../InventorySyncService"; - -// === Helper Types === - -interface MintedTokenInfo { - commitment: any; // MintCommitment - inclusionProof: any; // InclusionProof - isForRecipient: boolean; - tokenId: TokenId; - salt: Uint8Array; -} - -interface SplitTokenResult { - tokenForRecipient: SdkToken; - tokenForSender: SdkToken; - recipientTransferTx: TransferTransaction; - /** Outbox entry ID for tracking Nostr delivery (if outbox enabled) */ - outboxEntryId?: string; - /** Split group ID for recovery (if outbox enabled) */ - splitGroupId?: string; -} - -/** - * Callback interface for persisting tokens during split operations. - * This enables the critical "save-before-submit" pattern. - */ -export interface SplitPersistenceCallbacks { - /** - * Called immediately after a minted token proof is received. - * The caller MUST save this token to localStorage before returning. - * @param token The minted SDK token with proof - * @param isChangeToken True if this is the sender's change token - */ - onTokenMinted: (token: SdkToken, isChangeToken: boolean) => Promise; - - /** - * Called before transfer submission to give caller opportunity to sync to IPFS. - * Returns true if sync was successful, false to abort transfer. - */ - onPreTransferSync?: () => Promise; -} - -// === Helper: SHA-256 === -async function sha256(input: string | Uint8Array): Promise { - const data = - typeof input === "string" ? new TextEncoder().encode(input) : input; - const hashBuffer = await window.crypto.subtle.digest("SHA-256", data as BufferSource); - return new Uint8Array(hashBuffer); -} - -/** - * Helper to serialize a MintCommitment to JSON. - * MintCommitment (unlike TransferCommitment) does not have a toJSON() method in the SDK, - * so we manually extract the serializable properties. - */ -function serializeMintCommitment(commitment: any): object { - try { - // Try toJSON first in case SDK is updated - if (typeof commitment.toJSON === "function") { - return commitment.toJSON(); - } - // Manual serialization matching TransferCommitment's structure - return { - requestId: commitment.requestId?.toJSON?.() ?? String(commitment.requestId), - transactionData: commitment.transactionData?.toJSON?.() ?? commitment.transactionData, - authenticator: commitment.authenticator?.toJSON?.() ?? commitment.authenticator, - }; - } catch (err) { - console.warn("Failed to serialize MintCommitment, using fallback:", err); - // Fallback: store raw object - return { _raw: "serialization_failed", type: "MintCommitment" }; - } -} - -export class TokenSplitExecutor { - private get client() { - return ServiceProvider.stateTransitionClient; - } - private get trustBase() { - return ServiceProvider.getRootTrustBase(); - } - - async executeSplitPlan( - plan: SplitPlan, - recipientAddress: IAddress, - signingService: SigningService, - onTokenBurned: (uiId: string) => void, - /** Optional outbox context for tracking. If provided, creates outbox entries for recovery. */ - outboxContext?: { - walletAddress: string; - recipientNametag: string; - recipientPubkey: string; - ownerPublicKey: string; - }, - /** Optional callbacks for immediate token persistence (critical for safety) */ - persistenceCallbacks?: SplitPersistenceCallbacks - ): Promise<{ - tokensForRecipient: SdkToken[]; - tokensKeptBySender: SdkToken[]; - burnedTokens: any[]; - recipientTransferTxs: TransferTransaction[]; - /** Outbox entry IDs for tracking Nostr delivery (one per recipient token) */ - outboxEntryIds: string[]; - /** Split group ID for recovery */ - splitGroupId?: string; - }> { - console.log(`⚙️ Executing split plan using TokenSplitBuilder...`); - - const result = { - tokensForRecipient: [] as SdkToken[], - tokensKeptBySender: [] as SdkToken[], - burnedTokens: [] as any[], - recipientTransferTxs: [] as TransferTransaction[], - outboxEntryIds: [] as string[], - splitGroupId: undefined as string | undefined, - }; - - if ( - plan.requiresSplit && - plan.tokenToSplit && - plan.splitAmount && - plan.remainderAmount - ) { - const coinIdBuffer = Buffer.from(plan.coinId, "hex"); - const coinId = new CoinId(coinIdBuffer); - - const splitResult = await this.executeSingleTokenSplit( - plan.tokenToSplit.sdkToken, - plan.splitAmount, - plan.remainderAmount, - coinId, - recipientAddress, - signingService, - onTokenBurned, - plan.tokenToSplit.uiToken.id, - outboxContext, - persistenceCallbacks - ); - - result.tokensForRecipient.push(splitResult.tokenForRecipient); - result.tokensKeptBySender.push(splitResult.tokenForSender); - result.burnedTokens.push(plan.tokenToSplit.uiToken); - result.recipientTransferTxs.push(splitResult.recipientTransferTx); - - // Track outbox entries for Nostr delivery - if (splitResult.outboxEntryId) { - result.outboxEntryIds.push(splitResult.outboxEntryId); - } - if (splitResult.splitGroupId) { - result.splitGroupId = splitResult.splitGroupId; - } - } - - return result; - } - - private async executeSingleTokenSplit( - tokenToSplit: SdkToken, - splitAmount: bigint, - remainderAmount: bigint, - coinId: CoinId, - recipientAddress: IAddress, - signingService: SigningService, - onTokenBurned: (uiId: string) => void, - uiTokenId: string, - outboxContext?: { - walletAddress: string; - recipientNametag: string; - recipientPubkey: string; - ownerPublicKey: string; - }, - persistenceCallbacks?: SplitPersistenceCallbacks - ): Promise { - const tokenIdHex = Buffer.from(tokenToSplit.id.bytes).toString("hex"); - console.log(`🔪 Splitting token ${tokenIdHex.slice(0, 8)}...`); - - const builder = new TokenSplitBuilder(); - - const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}`; - - // Initialize outbox tracking if context provided - let outboxRepo: OutboxRepository | null = null; - let splitGroupId: string | undefined; - let transferEntryId: string | undefined; - - if (outboxContext) { - outboxRepo = OutboxRepository.getInstance(); - outboxRepo.setCurrentAddress(outboxContext.walletAddress); - - // Create a split group to track this operation - splitGroupId = crypto.randomUUID(); - const splitGroup: OutboxSplitGroup = { - groupId: splitGroupId, - createdAt: Date.now(), - originalTokenId: uiTokenId, - seedString: seedString, - entryIds: [], - }; - outboxRepo.createSplitGroup(splitGroup); - console.log(`📤 Outbox: Created split group ${splitGroupId.slice(0, 8)}...`); - } - - const recipientTokenId = new TokenId(await sha256(seedString)); - const senderTokenId = new TokenId(await sha256(seedString + "_sender")); - - const recipientSalt = await sha256(seedString + "_recipient_salt"); - const senderSalt = await sha256(seedString + "_sender_salt"); - - const senderAddressRef = await UnmaskedPredicateReference.create( - tokenToSplit.type, - signingService.algorithm, - signingService.publicKey, - HashAlgorithm.SHA256 - ); - const senderAddress = await senderAddressRef.toAddress(); - - const coinDataA = TokenCoinData.create([[coinId, splitAmount]]); - - builder.createToken( - recipientTokenId, - tokenToSplit.type, - new Uint8Array(0), // tokenData - coinDataA, - senderAddress, - recipientSalt, - null // dataHash - ); - - const coinDataB = TokenCoinData.create([[coinId, remainderAmount]]); - - builder.createToken( - senderTokenId, - tokenToSplit.type, - new Uint8Array(0), - coinDataB, - senderAddress, - senderSalt, - null - ); - - // 4. Build Split Object - const split = await builder.build(tokenToSplit); - - // === STEP 1: BURN === - const burnSalt = await sha256(seedString + "_burn_salt"); - const burnCommitment = await split.createBurnCommitment(burnSalt, signingService); - - // Log the BURN RequestId (this is what marks the ORIGINAL token as spent) - console.log(`🔥 [SplitBurn] RequestId committed: ${burnCommitment.requestId.toString()}`); - console.log(` - original token stateHash: ${(await tokenToSplit.state.calculateHash()).toJSON()}`); - console.log(` - signingService.publicKey: ${Buffer.from(signingService.publicKey).toString("hex")}`); - - // Create outbox entry for BURN BEFORE submitting to aggregator - // This ensures we can recover if browser crashes after burn is submitted - let burnEntryId: string | undefined; - if (outboxRepo && outboxContext && splitGroupId) { - const coinIdHex = Buffer.from(coinId.bytes).toString("hex"); - const burnEntry = createOutboxEntry( - "SPLIT_BURN", - uiTokenId, - outboxContext.recipientNametag, - outboxContext.recipientPubkey, - JSON.stringify((recipientAddress as any).toJSON ? (recipientAddress as any).toJSON() : recipientAddress), - splitAmount.toString(), - coinIdHex, - Buffer.from(burnSalt).toString("hex"), - JSON.stringify(tokenToSplit.toJSON()), - JSON.stringify(burnCommitment.toJSON()), - splitGroupId, - 0 // Index 0 = burn phase - ); - burnEntry.status = "READY_TO_SUBMIT"; - outboxRepo.addEntry(burnEntry); - outboxRepo.addEntryToSplitGroup(splitGroupId, burnEntry.id); - burnEntryId = burnEntry.id; - console.log(`📤 Outbox: Added SPLIT_BURN entry ${burnEntry.id.slice(0, 8)}...`); - } - - console.log("🔥 Submitting burn commitment..."); - const burnResponse = await this.client.submitTransferCommitment(burnCommitment); - - if (burnResponse.status === "REQUEST_ID_EXISTS") { - console.warn("Token already burned, attempting recovery..."); - } else if (burnResponse.status !== "SUCCESS") { - // Burn failed - original token may still be valid, attempt recovery - if (outboxContext?.ownerPublicKey && outboxContext?.walletAddress) { - const tokens = getTokensForAddress(outboxContext.walletAddress); - const uiToken = tokens.find(t => t.id === uiTokenId); - if (uiToken) { - try { - const recoveryService = TokenRecoveryService.getInstance(); - const recovery = await recoveryService.handleSplitBurnFailure( - uiToken, - burnResponse.status, - outboxContext.ownerPublicKey - ); - console.log(`📤 Burn failed: ${burnResponse.status}, recovery: ${recovery.action}`); - if (recovery.tokenRestored || recovery.tokenRemoved) { - dispatchWalletUpdated(); - } - } catch (recoveryErr) { - console.error(`📤 Token recovery after burn failure failed:`, recoveryErr); - } - } - } - throw new Error(`Burn failed: ${burnResponse.status}`); - } - - onTokenBurned(uiTokenId); - - const burnInclusionProof = await waitInclusionProofWithDevBypass(burnCommitment); - const burnTransaction = burnCommitment.toTransaction(burnInclusionProof); - - // Update burn outbox entry with proof - if (outboxRepo && burnEntryId) { - outboxRepo.updateEntry(burnEntryId, { - status: "PROOF_RECEIVED", - inclusionProofJson: JSON.stringify(burnInclusionProof.toJSON()), - transferTxJson: JSON.stringify(burnTransaction.toJSON()), - }); - } - - // === STEP 2: MINT SPLIT TOKENS === - console.log("✨ Creating split mint commitments..."); - - // Type is intentionally `any[]` because mintCommitments type varies by code path - let mintCommitments: any[]; - - // Dev mode bypass: manually create mint commitments without SDK verification - // The SDK's createSplitMintCommitments() internally verifies the burn transaction - // against the trust base, which fails when using dev aggregators - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.log("⚠️ Dev mode: bypassing SDK verification for split mint commitments"); - - // In dev mode, we create mint commitments without the complex SplitMintReason - // since the dev aggregator doesn't strictly validate the reason - // The SDK's SplitMintReason requires Merkle tree proofs we don't have access to - - // Create mint transaction data for recipient token - const recipientMintData = await MintTransactionData.create( - recipientTokenId, - tokenToSplit.type, - null, // tokenData - coinDataA, // coin data for recipient amount - senderAddress, - Buffer.from(recipientSalt), - null, // recipientDataHash - null // reason - dev mode skip - ); - const recipientMintCommitment = await MintCommitment.create(recipientMintData); - - // Create mint transaction data for sender (change) token - const senderMintData = await MintTransactionData.create( - senderTokenId, - tokenToSplit.type, - null, // tokenData - coinDataB, // coin data for remainder amount - senderAddress, - Buffer.from(senderSalt), - null, // recipientDataHash - null // reason - dev mode skip - ); - const senderMintCommitment = await MintCommitment.create(senderMintData); - - mintCommitments = [senderMintCommitment, recipientMintCommitment]; - console.log("✅ Dev mode: created split mint commitments manually"); - } else { - // Normal mode: use SDK's createSplitMintCommitments with trust base verification - mintCommitments = await split.createSplitMintCommitments( - this.trustBase, - burnTransaction - ); - } - - const mintedTokensInfo: MintedTokenInfo[] = []; - const mintEntryIds: string[] = []; - - // Process each mint commitment with immediate persistence - for (let i = 0; i < mintCommitments.length; i++) { - const commitment = mintCommitments[i]; - const commTokenIdHex = Buffer.from( - commitment.transactionData.tokenId.bytes - ).toString("hex"); - const recipientIdHex = Buffer.from(recipientTokenId.bytes).toString("hex"); - const senderIdHex = Buffer.from(senderTokenId.bytes).toString("hex"); - const isForRecipient = commTokenIdHex === recipientIdHex; - const isSenderToken = commTokenIdHex === senderIdHex; - - // Create outbox entry for MINT BEFORE submitting - let mintEntryId: string | undefined; - if (outboxRepo && outboxContext && splitGroupId) { - const coinIdHex = Buffer.from(coinId.bytes).toString("hex"); - const mintEntry = createOutboxEntry( - "SPLIT_MINT", - uiTokenId, - outboxContext.recipientNametag, - outboxContext.recipientPubkey, - JSON.stringify((recipientAddress as any).toJSON ? (recipientAddress as any).toJSON() : recipientAddress), - isForRecipient ? splitAmount.toString() : remainderAmount.toString(), - coinIdHex, - Buffer.from(commitment.transactionData.salt).toString("hex"), - JSON.stringify(tokenToSplit.toJSON()), // Source token for reference - JSON.stringify(serializeMintCommitment(commitment)), - splitGroupId, - isForRecipient ? 2 : 1 // Index 1 = sender mint, 2 = recipient mint - ); - mintEntry.status = "READY_TO_SUBMIT"; - outboxRepo.addEntry(mintEntry); - outboxRepo.addEntryToSplitGroup(splitGroupId, mintEntry.id); - mintEntryId = mintEntry.id; - mintEntryIds.push(mintEntryId); - console.log(`📤 Outbox: Added SPLIT_MINT entry ${mintEntry.id.slice(0, 8)}... (${isSenderToken ? 'change' : 'recipient'})`); - } - - // Submit mint to aggregator - // Log the MINT RequestId (critical for spent detection debugging) - console.log(`🔑 [SplitMint] RequestId committed for ${isSenderToken ? 'CHANGE' : 'recipient'}: ${commitment.requestId.toString()}`); - console.log(` - tokenId: ${commTokenIdHex.slice(0, 16)}...`); - - const res = await this.client.submitMintCommitment(commitment); - if (res.status !== "SUCCESS" && res.status !== "REQUEST_ID_EXISTS") { - if (outboxRepo && mintEntryId) { - outboxRepo.updateStatus(mintEntryId, "FAILED", `Mint failed: ${res.status}`); - } - // Mint failed after burn succeeded - original token is burned, but split not complete - // Attempt to recover: since burn already went through, original token is gone - // The best we can do is log and let user know - if (outboxContext?.ownerPublicKey && outboxContext?.walletAddress) { - const tokens = getTokensForAddress(outboxContext.walletAddress); - const uiToken = tokens.find(t => t.id === uiTokenId); - if (uiToken) { - try { - const recoveryService = TokenRecoveryService.getInstance(); - // Use handleTransferFailure since original token already burned - // This will check if the burn was spent and act accordingly - const recovery = await recoveryService.handleTransferFailure( - uiToken, - res.status, - outboxContext.ownerPublicKey - ); - console.log(`📤 Mint failed: ${res.status}, recovery: ${recovery.action}`); - if (recovery.tokenRestored || recovery.tokenRemoved) { - dispatchWalletUpdated(); - } - } catch (recoveryErr) { - console.error(`📤 Token recovery after mint failure failed:`, recoveryErr); - } - } - } - throw new Error(`Mint split token failed: ${res.status}`); - } - - // Update outbox: submitted - if (outboxRepo && mintEntryId) { - outboxRepo.updateStatus(mintEntryId, "SUBMITTED"); - } - - // Wait for inclusion proof (use dev bypass when trust base verification is skipped) - const proof = await waitInclusionProofWithDevBypass(commitment); - - // Update outbox: proof received - if (outboxRepo && mintEntryId) { - outboxRepo.updateEntry(mintEntryId, { - status: "PROOF_RECEIVED", - inclusionProofJson: JSON.stringify(proof.toJSON()), - }); - } - - mintedTokensInfo.push({ - commitment: commitment, - inclusionProof: proof, - isForRecipient: isForRecipient, - tokenId: commitment.transactionData.tokenId, - salt: commitment.transactionData.salt, - }); - - // CRITICAL: Save minted token IMMEDIATELY after proof is received - // This is the key fix - we persist BEFORE continuing with more operations - if (persistenceCallbacks?.onTokenMinted) { - try { - // Reconstruct the token so caller can save it - const mintedToken = await this.createAndVerifyToken( - mintedTokensInfo[mintedTokensInfo.length - 1], - signingService, - tokenToSplit.type, - isSenderToken ? "Sender (Change) - Early Persist" : "Recipient (Pre-transfer) - Early Persist" - ); - const isChangeToken = !isForRecipient; - console.log(`💾 Persisting minted ${isChangeToken ? 'change' : 'recipient'} token immediately...`); - await persistenceCallbacks.onTokenMinted(mintedToken, isChangeToken); - } catch (persistError) { - console.error(`⚠️ Failed to persist minted token immediately:`, persistError); - // Don't fail the split - token is on blockchain and can be recovered - } - } - } - - console.log("All split tokens minted on blockchain."); - - // === STEP 3: RECONSTRUCT OBJECTS === - const recipientInfo = mintedTokensInfo.find((t) => t.isForRecipient); - const senderInfo = mintedTokensInfo.find((t) => !t.isForRecipient); - - if (!recipientInfo || !senderInfo) - throw new Error("Failed to identify split tokens"); - - const recipientTokenBeforeTransfer = await this.createAndVerifyToken( - recipientInfo, - signingService, - tokenToSplit.type, - "Recipient (Pre-transfer)" - ); - - const senderToken = await this.createAndVerifyToken( - senderInfo, - signingService, - tokenToSplit.type, - "Sender (Change)" - ); - - // === STEP 4: PRE-TRANSFER SYNC CHECKPOINT === - // CRITICAL: Sync to IPFS BEFORE transfer to ensure all minted tokens are backed up - if (persistenceCallbacks?.onPreTransferSync) { - console.log("📦 Pre-transfer IPFS sync checkpoint..."); - try { - const syncSuccess = await persistenceCallbacks.onPreTransferSync(); - if (!syncSuccess) { - // Sync failed but tokens are on blockchain - warn but continue - // The user's tokens are saved locally and will sync eventually - console.warn("⚠️ Pre-transfer IPFS sync failed - continuing with local tokens saved"); - } else { - console.log("✅ Pre-transfer IPFS sync successful"); - } - } catch (syncError) { - console.error("⚠️ Pre-transfer IPFS sync error:", syncError); - // Continue - tokens are on blockchain and in localStorage - } - } - - // === STEP 5: TRANSFER TO RECIPIENT === - console.log( - `🚀 Transferring split token to ${recipientAddress.address}...` - ); - - const transferSalt = await sha256(seedString + "_transfer_salt"); - - const transferCommitment = await TransferCommitment.create( - recipientTokenBeforeTransfer, - recipientAddress, - transferSalt, - null, - null, - signingService - ); - - // Log the RequestId being committed (for spent detection debugging) - console.log(`🔑 [SplitTransfer] RequestId committed: ${transferCommitment.requestId.toString()}`); - console.log(` - token stateHash: ${(await recipientTokenBeforeTransfer.state.calculateHash()).toJSON()}`); - console.log(` - signingService.publicKey: ${Buffer.from(signingService.publicKey).toString("hex")}`); - - // Create outbox entry for transfer tracking BEFORE submitting - // This is critical for Nostr delivery recovery - if (outboxRepo && outboxContext && splitGroupId) { - const coinIdHex = Buffer.from(coinId.bytes).toString("hex"); - const transferEntry = createOutboxEntry( - "SPLIT_TRANSFER", - uiTokenId, - outboxContext.recipientNametag, - outboxContext.recipientPubkey, - JSON.stringify((recipientAddress as any).toJSON ? (recipientAddress as any).toJSON() : recipientAddress), - splitAmount.toString(), - coinIdHex, - Buffer.from(transferSalt).toString("hex"), - JSON.stringify(recipientTokenBeforeTransfer.toJSON()), - JSON.stringify(transferCommitment.toJSON()), - splitGroupId, - 3 // Index 3 = transfer phase (after burn=0, mint-sender=1, mint-recipient=2) - ); - - // Set status to READY_TO_SUBMIT since IPFS sync should happen at caller level - transferEntry.status = "READY_TO_SUBMIT"; - outboxRepo.addEntry(transferEntry); - outboxRepo.addEntryToSplitGroup(splitGroupId, transferEntry.id); - transferEntryId = transferEntry.id; - console.log(`📤 Outbox: Added split transfer entry ${transferEntry.id.slice(0, 8)}...`); - } - - const transferRes = await this.client.submitTransferCommitment(transferCommitment); - - if ( - transferRes.status !== "SUCCESS" && - transferRes.status !== "REQUEST_ID_EXISTS" - ) { - // Mark outbox entry as failed - if (outboxRepo && transferEntryId) { - outboxRepo.updateStatus(transferEntryId, "FAILED", `Transfer failed: ${transferRes.status}`); - } - // Transfer of split token failed - the minted recipient token may still be valid - // This is different from burn failure: original token is gone, but we have minted tokens - // The recipient token in our wallet can be recovered by reverting to committed state - if (outboxContext?.ownerPublicKey && outboxContext?.walletAddress) { - // Find the minted recipient token that we just persisted - const recipientTokenIdHex = Buffer.from(recipientTokenBeforeTransfer.id.bytes).toString("hex"); - const tokens = getTokensForAddress(outboxContext.walletAddress); - const mintedToken = tokens.find(t => { - // Check if this token's SDK token ID matches the recipient token we're trying to transfer - if (!t.jsonData) return false; - try { - const tokenData = JSON.parse(t.jsonData); - if (tokenData?.id?.bytes) { - const tokenIdHex = Buffer.from(tokenData.id.bytes).toString("hex"); - return tokenIdHex === recipientTokenIdHex; - } - } catch { /* ignore parse errors */ } - return false; - }); - if (mintedToken) { - try { - const recoveryService = TokenRecoveryService.getInstance(); - const recovery = await recoveryService.handleTransferFailure( - mintedToken, - transferRes.status, - outboxContext.ownerPublicKey - ); - console.log(`📤 Split transfer failed: ${transferRes.status}, recovery: ${recovery.action}`); - if (recovery.tokenRestored || recovery.tokenRemoved) { - dispatchWalletUpdated(); - } - } catch (recoveryErr) { - console.error(`📤 Token recovery after split transfer failure failed:`, recoveryErr); - } - } - } - throw new Error(`Transfer failed: ${transferRes.status}`); - } - - // Update outbox: submitted - if (outboxRepo && transferEntryId) { - outboxRepo.updateStatus(transferEntryId, "SUBMITTED"); - } - - const transferProof = await waitInclusionProofWithDevBypass(transferCommitment); - - const transferTx = transferCommitment.toTransaction(transferProof); - - // Update outbox: proof received (ready for Nostr delivery) - if (outboxRepo && transferEntryId) { - outboxRepo.updateEntry(transferEntryId, { - status: "PROOF_RECEIVED", - inclusionProofJson: JSON.stringify(transferProof.toJSON()), - transferTxJson: JSON.stringify(transferTx.toJSON()), - }); - } - - console.log("✅ Split transfer complete!"); - - return { - tokenForRecipient: recipientTokenBeforeTransfer, - tokenForSender: senderToken, - recipientTransferTx: transferTx, - outboxEntryId: transferEntryId, - splitGroupId: splitGroupId, - }; - } - - /** - * Helper to reconstruct and verify token from mint info - */ - private async createAndVerifyToken( - info: MintedTokenInfo, - signingService: SigningService, - tokenType: any, - label: string - ): Promise> { - // 1. Recreate Predicate - const predicate = await UnmaskedPredicate.create( - info.tokenId, - tokenType, - signingService, - HashAlgorithm.SHA256, - info.salt - ); - - // 2. Recreate State - const state = new TokenState(predicate, null); // No data for fungible usually - - // 3. Create Token and Verify - // Dev mode bypass: use fromJSON instead of mint to avoid trust base verification - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.log(`⚠️ Dev mode: creating ${label} token without verification`); - const genesisTransaction = info.commitment.toTransaction(info.inclusionProof); - const tokenJson = { - version: "2.0", - state: state.toJSON(), - genesis: genesisTransaction.toJSON(), - transactions: [], - nametags: [], - }; - const token = await SdkToken.fromJSON(tokenJson); - - // Log the state hash for debugging - this MUST match what spent check calculates - const stateHash = await token.state.calculateHash(); - const tokenIdHex = Buffer.from(info.tokenId.bytes).toString("hex"); - console.log(`🔑 [${label}] Token state hash after creation: ${stateHash.toJSON()}`); - console.log(` - tokenId: ${tokenIdHex.slice(0, 16)}...`); - console.log(` - This hash should match spent check RequestId calculation`); - - return token; - } - - // Normal mode: use SDK's mint with trust base verification - const token = await SdkToken.mint( - this.trustBase, - state, - info.commitment.toTransaction(info.inclusionProof) - ); - - // 4. Verify - const verification = await token.verify(this.trustBase); - - if (!verification.isSuccessful) { - console.error(`Verification failed for ${label}`, verification); - throw new Error(`Token verification failed: ${verification}`); - } - - return token; - } -} diff --git a/src/components/wallet/L3/services/types/IpfsTransport.ts b/src/components/wallet/L3/services/types/IpfsTransport.ts deleted file mode 100644 index a7c19854..00000000 --- a/src/components/wallet/L3/services/types/IpfsTransport.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * IPFS Transport Interface - * - * Defines the contract for low-level IPFS/IPNS operations used by InventorySyncService. - * This interface separates transport concerns from sync orchestration logic. - */ - -import type { TxfStorageData } from './TxfTypes'; - -// ========================================== -// Result Types -// ========================================== - -/** - * Result from resolving an IPNS name to a CID - */ -export interface IpnsResolutionResult { - /** Resolved CID (null if not found) */ - cid: string | null; - /** IPNS sequence number (for conflict detection) */ - sequence: bigint; - /** Fetched content (if available) */ - content?: TxfStorageData; -} - -/** - * Result from uploading content to IPFS - */ -export interface IpfsUploadResult { - /** CID of uploaded content */ - cid: string; - /** Whether upload succeeded */ - success: boolean; - /** Error message (if failed) */ - error?: string; -} - -/** - * Result from publishing to IPNS - */ -export interface IpnsPublishResult { - /** IPNS name that was published */ - ipnsName: string | null; - /** Whether publish succeeded */ - success: boolean; - /** IPNS sequence number (if successful) */ - sequence?: bigint; - /** Whether publish was verified */ - verified: boolean; - /** Error message (if failed) */ - error?: string; -} - -/** - * Gateway health metrics - */ -export interface GatewayHealth { - /** Timestamp of last successful operation */ - lastSuccess: number; - /** Count of consecutive failures */ - failureCount: number; -} - -// ========================================== -// Transport Interface -// ========================================== - -/** - * Pure IPFS/IPNS transport layer interface - * - * This interface defines the contract for low-level IPFS operations - * that InventorySyncService uses. It separates transport concerns - * from sync orchestration logic. - */ -export interface IpfsTransport { - // ========================================== - // Initialization - // ========================================== - - /** - * Ensure IPFS client is initialized and ready - * Returns true if ready, false if unavailable - */ - ensureInitialized(): Promise; - - /** - * Check if WebCrypto API is available (required for IPNS) - * Returns true if available, false otherwise - */ - isWebCryptoAvailable(): boolean; - - // ========================================== - // IPNS Name Management - // ========================================== - - /** - * Get the IPNS name for the current wallet - * Returns null if not available or not initialized - */ - getIpnsName(): string | null; - - // ========================================== - // IPNS Resolution - // ========================================== - - /** - * Resolve IPNS name to CID and fetch content - * Returns resolution result with CID, sequence, and content - */ - resolveIpns(): Promise; - - // ========================================== - // IPFS Content Operations - // ========================================== - - /** - * Fetch content from IPFS by CID - * Returns parsed TxfStorageData or null if not found - */ - fetchContent(cid: string): Promise; - - /** - * Upload content to IPFS - * Returns CID and success status - */ - uploadContent(data: TxfStorageData): Promise; - - // ========================================== - // IPNS Publishing - // ========================================== - - /** - * Publish CID to IPNS - * Returns publish result with verification status - */ - publishIpns(cid: string): Promise; - - // ========================================== - // Version and CID Tracking - // ========================================== - - /** - * Get current version counter (monotonic) - */ - getVersionCounter(): number; - - /** - * Set version counter - */ - setVersionCounter(version: number): void; - - /** - * Get last published CID - */ - getLastCid(): string | null; - - /** - * Set last published CID - */ - setLastCid(cid: string): void; - - // ========================================== - // IPNS Sequence Tracking - // ========================================== - - /** - * Get current IPNS sequence number (for conflict detection) - */ - getIpnsSequence(): bigint; - - /** - * Set IPNS sequence number - */ - setIpnsSequence(seq: bigint): void; - - // ========================================== - // Gateway Health Monitoring - // ========================================== - - /** - * Get gateway health metrics - * Used for gateway selection and circuit breaking - */ - getGatewayHealth(): Map; -} diff --git a/src/components/wallet/L3/services/types/OutboxTypes.ts b/src/components/wallet/L3/services/types/OutboxTypes.ts deleted file mode 100644 index 196575f6..00000000 --- a/src/components/wallet/L3/services/types/OutboxTypes.ts +++ /dev/null @@ -1,518 +0,0 @@ -/** - * Outbox Types - * Data structures for persisting pending token transfers - * - * The Outbox pattern ensures tokens are never lost during the transfer process - * by saving the transfer state (including non-reproducible commitment data) - * to localStorage AND IPFS BEFORE submitting to the Unicity aggregator. - */ - -// ========================================== -// Status Types -// ========================================== - -/** - * Status of an outbox entry through the transfer lifecycle - */ -export type OutboxEntryStatus = - | "PENDING_IPFS_SYNC" // Saved to localStorage, awaiting IPFS confirmation - | "READY_TO_SUBMIT" // IPFS confirmed, safe to submit to aggregator - | "SUBMITTED" // Submitted to aggregator, awaiting inclusion proof - | "PROOF_RECEIVED" // Have inclusion proof, ready for Nostr delivery - | "NOSTR_SENT" // Sent via Nostr, awaiting confirmation - | "COMPLETED" // Fully completed, pending cleanup - | "FAILED"; // Terminal failure (manual intervention needed) - -/** - * Type of transfer operation - */ -export type OutboxEntryType = - | "DIRECT_TRANSFER" // Whole token transfer to recipient - | "SPLIT_BURN" // Burn phase of token split - | "SPLIT_MINT" // Mint phase of token split (sender or recipient portion) - | "SPLIT_TRANSFER"; // Transfer phase of split (recipient token to recipient) - -/** - * Type of mint operation - */ -export type MintOutboxEntryType = - | "MINT_NAMETAG" // Minting a nametag token (Unicity ID) - | "MINT_TOKEN"; // Minting a generic token - -// ========================================== -// Main Outbox Entry -// ========================================== - -/** - * A single outbox entry representing a pending transfer operation - * - * CRITICAL: This structure contains the commitment JSON which includes - * the random salt. Without this data, recovery is IMPOSSIBLE after - * aggregator submission. - */ -export interface OutboxEntry { - /** Unique identifier for this outbox entry */ - id: string; - - /** Timestamp when entry was created */ - createdAt: number; - - /** Timestamp of last status update */ - updatedAt: number; - - /** Current status in the transfer lifecycle */ - status: OutboxEntryStatus; - - /** Type of transfer operation */ - type: OutboxEntryType; - - // ========================================== - // Transfer Metadata - // ========================================== - - /** UI Token ID being spent (from wallet repository) */ - sourceTokenId: string; - - /** Recipient's human-readable nametag (e.g., "@alice") */ - recipientNametag: string; - - /** Recipient's Nostr public key (hex) */ - recipientPubkey: string; - - /** Recipient's Unicity address (serialized ProxyAddress JSON) */ - recipientAddressJson: string; - - /** Amount being transferred (BigInt as string) */ - amount: string; - - /** Coin ID for the token type */ - coinId: string; - - // ========================================== - // CRITICAL: Non-Reproducible Data - // ========================================== - - /** - * Hex-encoded 32-byte random salt used in commitment creation. - * THIS IS THE CRITICAL DATA - without it, the commitment cannot - * be recreated and the requestId cannot be derived. - */ - salt: string; - - /** - * Serialized source token (SdkToken.toJSON() as string) - * Needed for Nostr delivery payload - */ - sourceTokenJson: string; - - /** - * Serialized transfer commitment (TransferCommitment.toJSON() as string) - * Contains: requestId, transactionData (including salt), authenticator - */ - commitmentJson: string; - - // ========================================== - // Post-Submission Data (filled during flow) - // ========================================== - - /** - * Serialized inclusion proof (after aggregator response) - * Set during SUBMITTED → PROOF_RECEIVED transition - */ - inclusionProofJson?: string; - - /** - * Serialized transfer transaction (commitment.toTransaction(proof)) - * Set during SUBMITTED → PROOF_RECEIVED transition - */ - transferTxJson?: string; - - // ========================================== - // Nostr Delivery Tracking - // ========================================== - - /** Nostr event ID after successful send */ - nostrEventId?: string; - - /** Timestamp when Nostr delivery was confirmed */ - nostrConfirmedAt?: number; - - // ========================================== - // Error Tracking - // ========================================== - - /** Last error message (for debugging/retry logic) */ - lastError?: string; - - /** Number of retry attempts */ - retryCount: number; - - // ========================================== - // Split Group Tracking (for split operations) - // ========================================== - - /** - * Group ID linking related split entries (burn + mints + transfers) - * Only set for SPLIT_* types - */ - splitGroupId?: string; - - /** - * Index within split group (e.g., 0=burn, 1=mint-sender, 2=mint-recipient, 3=transfer) - * Only set for SPLIT_* types - */ - splitGroupIndex?: number; -} - -// ========================================== -// Mint Outbox Entry -// ========================================== - -/** - * A single outbox entry representing a pending mint operation - * - * CRITICAL: This structure contains the salt and MintTransactionData which - * are required for recovery. Unlike TransferCommitment, MintCommitment - * does NOT have toJSON()/fromJSON() methods, so we store MintTransactionData - * and requestId separately for reconstruction. - * - * Flow: - * 1. Generate salt, create MintTransactionData, create MintCommitment - * 2. SAVE TO OUTBOX IMMEDIATELY (before any network calls) - * 3. Sync to IPFS and wait for success - * 4. Submit commitment to aggregator - * 5. Wait for inclusion proof - * 6. Create final token with proof - * 7. Save token to storage, mark complete - */ -export interface MintOutboxEntry { - /** Unique identifier for this outbox entry */ - id: string; - - /** Timestamp when entry was created */ - createdAt: number; - - /** Timestamp of last status update */ - updatedAt: number; - - /** Current status in the mint lifecycle */ - status: OutboxEntryStatus; - - /** Type of mint operation */ - type: MintOutboxEntryType; - - // ========================================== - // Mint Metadata - // ========================================== - - /** Nametag being minted (for MINT_NAMETAG type) */ - nametag?: string; - - /** Token type hex string */ - tokenTypeHex: string; - - /** Serialized owner address (DirectAddress.toJSON() as string) */ - ownerAddressJson: string; - - // ========================================== - // CRITICAL: Non-Reproducible Data - // ========================================== - - /** - * Hex-encoded 32-byte random salt used in commitment creation. - * THIS IS THE CRITICAL DATA - without it, the commitment cannot - * be recreated and the token cannot be recovered. - */ - salt: string; - - /** - * Request ID from the commitment (commitment.requestId.toString()) - * Used for polling inclusion proof during recovery. - */ - requestIdHex: string; - - /** - * Serialized MintTransactionData (mintData.toJSON() as string) - * Used to reconstruct MintCommitment during recovery. - */ - mintDataJson: string; - - // ========================================== - // Post-Submission Data (filled during flow) - // ========================================== - - /** - * Serialized inclusion proof (after aggregator response) - * Set during SUBMITTED → PROOF_RECEIVED transition - */ - inclusionProofJson?: string; - - /** - * Serialized mint transaction (commitment.toTransaction(proof)) - * Set during SUBMITTED → PROOF_RECEIVED transition - */ - mintTransactionJson?: string; - - /** - * Serialized final token (Token.toJSON() as string) - * Set when token is fully created with proof - */ - tokenJson?: string; - - // ========================================== - // Error Tracking - // ========================================== - - /** Last error message (for debugging/retry logic) */ - lastError?: string; - - /** Number of retry attempts */ - retryCount: number; -} - -// ========================================== -// Split Group (for tracking multi-step splits) -// ========================================== - -/** - * Groups related split operation entries - * A single token split creates multiple outbox entries (burn + mints + transfer) - * that need to be tracked together for proper recovery. - */ -export interface OutboxSplitGroup { - /** Unique identifier for this split group */ - groupId: string; - - /** Timestamp when split was initiated */ - createdAt: number; - - /** Original token ID being split */ - originalTokenId: string; - - /** Serialized split plan (for recovery) */ - splitPlanJson?: string; - - /** Seed string used for deterministic salt derivation */ - seedString: string; - - /** Entry IDs in this group (in order: burn, mints..., transfer) */ - entryIds: string[]; -} - -// ========================================== -// Recovery Types -// ========================================== - -/** - * Result of recovering pending transfers on startup - */ -export interface RecoveryResult { - /** Number of successfully recovered transfers */ - recovered: number; - - /** Number of failed recovery attempts */ - failed: number; - - /** Number of skipped entries (already completed) */ - skipped: number; - - /** Details of each recovery attempt */ - details: RecoveryDetail[]; -} - -/** - * Detail of a single recovery attempt - */ -export interface RecoveryDetail { - entryId: string; - status: "recovered" | "failed" | "skipped"; - previousStatus: OutboxEntryStatus; - newStatus?: OutboxEntryStatus; - error?: string; -} - -// ========================================== -// Utility Functions -// ========================================== - -/** - * Check if an outbox entry is in a terminal state (completed or failed) - */ -export function isTerminalStatus(status: OutboxEntryStatus): boolean { - return status === "COMPLETED" || status === "FAILED"; -} - -/** - * Check if an outbox entry is pending (needs processing) - */ -export function isPendingStatus(status: OutboxEntryStatus): boolean { - return !isTerminalStatus(status); -} - -/** - * Check if an outbox entry can be safely retried - */ -export function isRetryableStatus(status: OutboxEntryStatus): boolean { - return ( - status === "PENDING_IPFS_SYNC" || - status === "READY_TO_SUBMIT" || - status === "SUBMITTED" || - status === "PROOF_RECEIVED" || - status === "NOSTR_SENT" - ); -} - -/** - * Get the next expected status after current status - */ -export function getNextStatus(current: OutboxEntryStatus): OutboxEntryStatus | null { - const statusOrder: OutboxEntryStatus[] = [ - "PENDING_IPFS_SYNC", - "READY_TO_SUBMIT", - "SUBMITTED", - "PROOF_RECEIVED", - "NOSTR_SENT", - "COMPLETED", - ]; - - const currentIndex = statusOrder.indexOf(current); - if (currentIndex === -1 || currentIndex >= statusOrder.length - 1) { - return null; - } - - return statusOrder[currentIndex + 1]; -} - -/** - * Create a minimal outbox entry with required fields - */ -export function createOutboxEntry( - type: OutboxEntryType, - sourceTokenId: string, - recipientNametag: string, - recipientPubkey: string, - recipientAddressJson: string, - amount: string, - coinId: string, - salt: string, - sourceTokenJson: string, - commitmentJson: string, - splitGroupId?: string, - splitGroupIndex?: number -): OutboxEntry { - const now = Date.now(); - return { - id: crypto.randomUUID(), - createdAt: now, - updatedAt: now, - status: "PENDING_IPFS_SYNC", - type, - sourceTokenId, - recipientNametag, - recipientPubkey, - recipientAddressJson, - amount, - coinId, - salt, - sourceTokenJson, - commitmentJson, - retryCount: 0, - splitGroupId, - splitGroupIndex, - }; -} - -/** - * Validate that an outbox entry has all required fields for its current status - */ -export function validateOutboxEntry(entry: OutboxEntry): { valid: boolean; error?: string } { - // Basic required fields - if (!entry.id || !entry.sourceTokenId || !entry.salt || !entry.commitmentJson) { - return { valid: false, error: "Missing required fields (id, sourceTokenId, salt, or commitmentJson)" }; - } - - // Status-specific validation - switch (entry.status) { - case "PROOF_RECEIVED": - case "NOSTR_SENT": - case "COMPLETED": - if (!entry.inclusionProofJson) { - return { valid: false, error: "Missing inclusionProofJson for status " + entry.status }; - } - break; - } - - return { valid: true }; -} - -// ========================================== -// Mint Outbox Utility Functions -// ========================================== - -/** - * Create a mint outbox entry with required fields - */ -export function createMintOutboxEntry( - type: MintOutboxEntryType, - tokenTypeHex: string, - ownerAddressJson: string, - salt: string, - requestIdHex: string, - mintDataJson: string, - nametag?: string -): MintOutboxEntry { - const now = Date.now(); - return { - id: crypto.randomUUID(), - createdAt: now, - updatedAt: now, - status: "PENDING_IPFS_SYNC", - type, - tokenTypeHex, - ownerAddressJson, - salt, - requestIdHex, - mintDataJson, - nametag, - retryCount: 0, - }; -} - -/** - * Validate that a mint outbox entry has all required fields for its current status - */ -export function validateMintOutboxEntry(entry: MintOutboxEntry): { valid: boolean; error?: string } { - // Basic required fields - if (!entry.id || !entry.salt || !entry.requestIdHex || !entry.mintDataJson) { - return { valid: false, error: "Missing required fields (id, salt, requestIdHex, or mintDataJson)" }; - } - - // Type-specific validation - if (entry.type === "MINT_NAMETAG" && !entry.nametag) { - return { valid: false, error: "Missing nametag for MINT_NAMETAG type" }; - } - - // Status-specific validation - switch (entry.status) { - case "PROOF_RECEIVED": - case "COMPLETED": - if (!entry.inclusionProofJson) { - return { valid: false, error: "Missing inclusionProofJson for status " + entry.status }; - } - break; - } - - return { valid: true }; -} - -/** - * Check if a mint outbox entry is in a state that can be recovered - */ -export function isMintRecoverable(entry: MintOutboxEntry): boolean { - return ( - entry.status === "PENDING_IPFS_SYNC" || - entry.status === "READY_TO_SUBMIT" || - entry.status === "SUBMITTED" || - entry.status === "PROOF_RECEIVED" - ); -} diff --git a/src/components/wallet/L3/services/types/QueueTypes.ts b/src/components/wallet/L3/services/types/QueueTypes.ts deleted file mode 100644 index 594701c3..00000000 --- a/src/components/wallet/L3/services/types/QueueTypes.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Queue Types for Background Loops - * Per TOKEN_INVENTORY_SPEC.md Section 7 - */ - -import type { Token } from '../../data/model'; -import type { SyncResult } from '../../types/SyncTypes'; - -/** - * Single token received from Nostr (waiting for batching) - */ -export interface ReceiveTokenBatchItem { - /** UI token from WalletRepository */ - token: Token; - /** Nostr event ID (for deduplication) */ - eventId: string; - /** When received (epoch ms) */ - timestamp: number; - /** Sender's Nostr pubkey */ - senderPubkey: string; -} - -/** - * Batch of tokens ready for inventory sync - */ -export interface ReceiveTokenBatch { - /** Tokens in this batch */ - items: ReceiveTokenBatchItem[]; - /** UUID for logging/tracking */ - batchId: string; - /** When batch was created (epoch ms) */ - createdAt: number; - /** When 3-second timer fired (epoch ms) */ - finalizedAt?: number; - /** When inventorySync() was called */ - syncStartedAt?: number; - /** When sync completed */ - syncCompletedAt?: number; - /** Result from inventorySync(FAST) */ - syncResult?: SyncResult; -} - -/** - * Entry in Nostr delivery queue - */ -export interface NostrDeliveryQueueEntry { - /** UUID */ - id: string; - /** Links to OutboxEntry for recovery */ - outboxEntryId: string; - /** Recipient's Nostr pubkey (hex) */ - recipientPubkey: string; - /** Recipient's nametag (e.g., "@alice") */ - recipientNametag: string; - /** Serialized token + proof payload */ - payloadJson: string; - /** Amount being sent (for display) */ - amount?: string; - /** Token symbol (for display) */ - symbol?: string; - /** When entry was queued (epoch ms) */ - createdAt: number; - /** First send attempt (epoch ms) */ - attemptedAt?: number; - /** Successful send (epoch ms) */ - completedAt?: number; - /** Nostr event ID from successful send */ - nostrEventId?: string; - /** Exponential backoff tracking */ - retryCount: number; - /** Last error message */ - lastError?: string; - /** Don't retry before this time (epoch ms) */ - backoffUntil?: number; -} - -/** - * Status snapshot of delivery queue - */ -export interface DeliveryQueueStatus { - totalPending: number; - totalCompleted: number; - totalFailed: number; - byRetryCount: Record; - oldestEntryAge: number; - activeDeliveries: number; -} - -/** - * Configuration for loop behavior - * Per TOKEN_INVENTORY_SPEC.md Section 7 - */ -export interface LoopConfig { - // ReceiveTokensToInventoryLoop (Section 7.1) - /** 3000ms - Wait for idle before processing batch */ - receiveTokenBatchWindowMs: number; - /** 100 - Max tokens before forcing sync */ - receiveTokenMaxBatchSize: number; - /** 120000ms - Timeout for batch processing */ - receiveTokenProcessTimeoutMs: number; - - // NostrDeliveryQueue (Section 7.3) - /** 12 - Concurrent Nostr sends */ - deliveryMaxParallel: number; - /** 10 - Per spec Section 9.2 */ - deliveryMaxRetries: number; - /** [1000, 3000, 10000, 30000, 60000] - Per spec max 1 minute */ - deliveryBackoffMs: number[]; - /** 3000ms - Wait for empty before NORMAL sync */ - deliveryEmptyQueueWaitMs: number; - /** 500ms - How often to check queue */ - deliveryCheckIntervalMs: number; -} - -/** - * Default configuration matching spec - */ -export const DEFAULT_LOOP_CONFIG: LoopConfig = { - receiveTokenBatchWindowMs: 3000, - receiveTokenMaxBatchSize: 100, - receiveTokenProcessTimeoutMs: 120000, - deliveryMaxParallel: 12, - deliveryMaxRetries: 10, - deliveryBackoffMs: [1000, 3000, 10000, 30000, 60000], - deliveryEmptyQueueWaitMs: 3000, - deliveryCheckIntervalMs: 500, -}; - -/** - * Completed transfer info for inventorySync(completedList) - * AMENDMENT 2: Must include stateHash for multi-version architecture - */ -export interface CompletedTransfer { - tokenId: string; - /** CRITICAL: Required for multi-version architecture (Spec Section 3.7.4) */ - stateHash: string; - /** Inclusion proof object (matches InventorySyncService.CompletedTransfer) */ - inclusionProof: object; -} diff --git a/src/components/wallet/L3/services/types/TxfSchemas.ts b/src/components/wallet/L3/services/types/TxfSchemas.ts deleted file mode 100644 index a63dd34d..00000000 --- a/src/components/wallet/L3/services/types/TxfSchemas.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * TXF Zod Schemas for Runtime Validation - * Provides safe parsing of external data (IPFS, file imports) - */ - -import { z } from "zod"; - -// ========================================== -// Basic Patterns -// ========================================== - -// Note: hexString and hexString64 replaced by hexStringOrBytesAny and hexStringOrBytes64 -// which accept both hex strings and SDK bytes objects for backward compatibility - -/** - * Helper to convert bytes object/array to hex string - * SDK tokens sometimes serialize IDs as { bytes: [...] } or Uint8Array - */ -function bytesToHex(bytes: number[] | Uint8Array): string { - const arr = Array.isArray(bytes) ? bytes : Array.from(bytes); - return arr.map(b => b.toString(16).padStart(2, "0")).join(""); -} - -/** - * Schema that accepts both hex string and bytes object, normalizing to hex string - * Handles SDK token format where IDs are objects with bytes arrays - */ -const hexStringOrBytes64 = z.union([ - // Direct hex string (preferred format) - z.string().regex(/^[0-9a-fA-F]{64}$/), - // SDK format: object with bytes array - z.object({ - bytes: z.union([ - z.array(z.number()), - z.instanceof(Uint8Array), - ]), - }).transform(obj => bytesToHex(obj.bytes)), - // Buffer-like format from JSON.stringify - z.object({ - type: z.literal("Buffer"), - data: z.array(z.number()), - }).transform(obj => bytesToHex(obj.data)), -]); - -/** - * Schema for variable-length hex strings or bytes objects (no length restriction) - * Used for signatures, public keys, etc. - */ -const hexStringOrBytesAny = z.union([ - // Direct hex string (preferred format) - z.string().regex(/^[0-9a-fA-F]*$/), - // SDK format: object with bytes array - z.object({ - bytes: z.union([ - z.array(z.number()), - z.instanceof(Uint8Array), - ]), - }).transform(obj => bytesToHex(obj.bytes)), - // Buffer-like format from JSON.stringify - z.object({ - type: z.literal("Buffer"), - data: z.array(z.number()), - }).transform(obj => bytesToHex(obj.data)), -]); - -// ========================================== -// Merkle Tree Path -// ========================================== - -export const TxfMerkleStepSchema = z.object({ - data: z.string(), - path: z.string(), -}); - -export const TxfMerkleTreePathSchema = z.object({ - root: z.string(), - steps: z.array(TxfMerkleStepSchema), -}); - -// ========================================== -// Authenticator -// ========================================== - -export const TxfAuthenticatorSchema = z.object({ - algorithm: z.string(), - publicKey: hexStringOrBytesAny, - signature: hexStringOrBytesAny, - stateHash: z.string(), -}); - -// ========================================== -// Inclusion Proof -// ========================================== - -export const TxfInclusionProofSchema = z.object({ - authenticator: TxfAuthenticatorSchema, - merkleTreePath: TxfMerkleTreePathSchema, - transactionHash: z.string(), - unicityCertificate: z.string(), -}); - -// ========================================== -// Token Components -// ========================================== - -export const TxfGenesisDataSchema = z.object({ - // Use hexStringOrBytes64 to handle both string and SDK bytes object formats - tokenId: hexStringOrBytes64, - tokenType: hexStringOrBytes64, - coinData: z.array(z.tuple([z.string(), z.string()])).optional().default([]), - // tokenData can be null/undefined in stored data, coerce to empty string - tokenData: z.string().nullable().optional().transform((v) => v ?? ""), - salt: hexStringOrBytes64, - recipient: z.string(), - recipientDataHash: z.string().nullable(), - reason: z.string().nullable(), -}); - -export const TxfGenesisSchema = z.object({ - data: TxfGenesisDataSchema, - inclusionProof: TxfInclusionProofSchema, -}); - -export const TxfStateSchema = z.object({ - // state.data can be null/undefined in stored data, coerce to empty string - data: z.string().nullable().optional().transform((v) => v ?? ""), - predicate: z.string(), -}); - -export const TxfTransactionSchema = z.object({ - previousStateHash: z.string(), - // newStateHash is optional for backwards compatibility with older tokens - // that were created before this field was added to transfers - newStateHash: z.string().optional(), - predicate: z.string(), - inclusionProof: TxfInclusionProofSchema.nullable(), - data: z.record(z.string(), z.unknown()).optional(), -}); - -export const TxfIntegritySchema = z.object({ - genesisDataJSONHash: z.string(), - currentStateHash: z.string().optional(), -}); - -// ========================================== -// Complete Token -// ========================================== - -export const TxfTokenSchema = z.object({ - version: z.literal("2.0"), - genesis: TxfGenesisSchema, - state: TxfStateSchema, - transactions: z.array(TxfTransactionSchema), - // nametags is optional for backwards compatibility (defaults to empty array) - nametags: z.array(z.string()).optional().default([]), - // _integrity is optional for backwards compatibility with older token formats - _integrity: TxfIntegritySchema.optional(), -}); - -// ========================================== -// Storage Metadata -// ========================================== - -export const TxfMetaSchema = z.object({ - version: z.number().int().nonnegative(), - address: z.string(), - ipnsName: z.string(), - formatVersion: z.literal("2.0"), - lastCid: z.string().optional(), -}); - -// ========================================== -// Nametag Data -// ========================================== - -export const NametagDataSchema = z.object({ - name: z.string(), - tokenId: z.string(), - registeredAt: z.number().optional(), -}).passthrough(); // Allow additional fields - -// ========================================== -// Complete Storage Data -// ========================================== - -export const TxfStorageDataSchema = z.object({ - _meta: TxfMetaSchema, - _nametag: NametagDataSchema.optional(), -}).catchall(z.union([TxfTokenSchema, z.unknown()])); - -// ========================================== -// Validation Functions -// ========================================== - -/** - * Parse and validate TXF token data - */ -export function parseTxfToken(data: unknown): z.infer { - return TxfTokenSchema.parse(data); -} - -/** - * Safely parse TXF token, returning null on failure - * Logs validation errors once (concise format) for debugging - */ -export function safeParseTxfToken(data: unknown): z.infer | null { - const result = TxfTokenSchema.safeParse(data); - if (result.success) { - return result.data; - } - // Log concise error summary (detailed format available via result.error.format()) - const flatErrors = result.error.flatten(); - const fieldKeys = Object.keys(flatErrors.fieldErrors); - if (fieldKeys.length > 0) { - console.debug("TxfToken validation failed, fields:", fieldKeys.join(", ")); - } - return null; -} - -/** - * Parse and validate TXF storage data - */ -export function parseTxfStorageData(data: unknown): z.infer { - return TxfStorageDataSchema.parse(data); -} - -/** - * Safely parse TXF storage data, returning null on failure - */ -export function safeParseTxfStorageData(data: unknown): z.infer | null { - const result = TxfStorageDataSchema.safeParse(data); - if (result.success) { - return result.data; - } - console.warn("TxfStorageData validation failed:", result.error.format()); - return null; -} - -/** - * Parse and validate TXF metadata - */ -export function parseTxfMeta(data: unknown): z.infer { - return TxfMetaSchema.parse(data); -} - -/** - * Safely parse TXF metadata, returning null on failure - */ -export function safeParseTxfMeta(data: unknown): z.infer | null { - const result = TxfMetaSchema.safeParse(data); - if (result.success) { - return result.data; - } - console.warn("TxfMeta validation failed:", result.error.format()); - return null; -} - -/** - * Validate a token key-value pair from storage data - */ -export function validateTokenEntry(key: string, value: unknown): { valid: boolean; token?: z.infer; error?: string } { - if (!key.startsWith("_") || key === "_meta" || key === "_nametag" || key === "_integrity") { - return { valid: false, error: "Invalid token key" }; - } - - const result = TxfTokenSchema.safeParse(value); - if (result.success) { - return { valid: true, token: result.data }; - } - - // Log detailed error path for debugging - const issues = result.error.issues; - if (issues.length > 0) { - const firstIssue = issues[0]; - const path = firstIssue.path.join("."); - console.debug(`[Zod] Token validation failed at path "${path}": ${firstIssue.message} (code: ${firstIssue.code})`); - // Log the actual value at the failing path for debugging - if (firstIssue.path.length > 0 && value && typeof value === "object") { - let current: unknown = value; - for (const segment of firstIssue.path) { - if (current && typeof current === "object" && segment in current) { - current = (current as Record)[segment as string]; - } else { - break; - } - } - console.debug(`[Zod] Value at failing path:`, current); - } - } - - return { valid: false, error: result.error.message }; -} - -// ========================================== -// Type Exports (inferred from schemas) -// ========================================== - -export type ValidatedTxfToken = z.infer; -export type ValidatedTxfMeta = z.infer; -export type ValidatedTxfStorageData = z.infer; -export type ValidatedTxfGenesis = z.infer; -export type ValidatedTxfTransaction = z.infer; -export type ValidatedTxfInclusionProof = z.infer; diff --git a/src/components/wallet/L3/services/types/TxfTypes.ts b/src/components/wallet/L3/services/types/TxfTypes.ts deleted file mode 100644 index 6b0f5a20..00000000 --- a/src/components/wallet/L3/services/types/TxfTypes.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * TXF (Token eXchange Format) Type Definitions - * Based on TXF Format Specification v2.0 - */ - -import type { OutboxEntry, MintOutboxEntry } from "./OutboxTypes"; -import type { InvalidReasonCode } from "../../types/SyncTypes"; - -// ========================================== -// Storage Format (for IPFS) -// ========================================== - -/** - * Nametag data (one per identity) - * Represents a Unicity ID (human-readable address) - */ -export interface NametagData { - name: string; // e.g., "cryptohog" - token: object; // SDK Token JSON - timestamp: number; - format: string; - version: string; -} - -/** - * Tombstone entry for tracking spent token states - * Tracks both tokenId AND stateHash to allow same token to return with new state - */ -export interface TombstoneEntry { - tokenId: string; // 64-char hex token ID - stateHash: string; // State hash that was spent (with "0000" prefix) - timestamp: number; // When tombstoned (epoch ms) -} - -/** - * Entry for invalidated nametags (Unicity IDs) - * Stored when a nametag is found to be owned by a different Nostr pubkey - */ -export interface InvalidatedNametagEntry { - name: string; // The invalidated nametag name - token: object; // Original token data - timestamp: number; // Original creation timestamp - format: string; - version: string; - invalidatedAt: number; // When invalidated (epoch ms) - invalidationReason: string; -} - -/** - * Entry for tokens moved to Sent folder (Section 3.2) - * Stored when a token's latest state is SPENT with inclusion proof - */ -export interface SentTokenEntry { - token: TxfToken; // Complete token data - timestamp: number; // When moved to Sent (epoch ms) - spentAt: number; // When token was spent (epoch ms, from inclusion proof) -} - -/** - * Entry for tokens moved to Invalid folder (Section 3.3) - * Stored when a token fails validation but is kept for investigation - */ -export interface InvalidTokenEntry { - token: TxfToken; // Complete token data (may be partial) - timestamp: number; // When moved to Invalid (epoch ms) - invalidatedAt: number; // When invalidated (epoch ms) - reason: InvalidReasonCode; // Structured reason code - details?: string; // Optional human-readable details -} - -/** - * Complete storage data structure for IPFS - * Contains metadata, nametag, tombstones, outbox, invalidated nametags, and all tokens keyed by their IDs - */ -export interface TxfStorageData { - _meta: TxfMeta; - _nametag?: NametagData; - _tombstones?: TombstoneEntry[]; // State-hash-aware tombstones (spent token states) - _invalidatedNametags?: InvalidatedNametagEntry[]; // Nametags that failed Nostr validation - _outbox?: OutboxEntry[]; // Pending transfers (CRITICAL for recovery) - _mintOutbox?: MintOutboxEntry[]; // Pending mints (CRITICAL for recovery) - _sent?: SentTokenEntry[]; // Sent tokens (SPENT with inclusion proof) - _invalid?: InvalidTokenEntry[]; // Invalid tokens (failed validation, kept for investigation) - // Dynamic keys for tokens: _ - [key: string]: TxfToken | TxfMeta | NametagData | TombstoneEntry[] | InvalidatedNametagEntry[] | OutboxEntry[] | MintOutboxEntry[] | SentTokenEntry[] | InvalidTokenEntry[] | undefined; -} - -/** - * Storage metadata - * Note: timestamp is excluded to ensure CID stability (same content = same CID) - */ -export interface TxfMeta { - version: number; // Monotonic counter (increments each sync) - address: string; // Wallet L3 address - ipnsName: string; // IPNS name for this wallet - formatVersion: "2.0"; // TXF format version - lastCid?: string; // Last successfully stored CID - deviceId?: string; // Unique device identifier for conflict resolution -} - -// ========================================== -// Token Structure (TXF v2.0) -// ========================================== - -/** - * Complete token object in TXF format - */ -export interface TxfToken { - version: "2.0"; - genesis: TxfGenesis; - state: TxfState; - transactions: TxfTransaction[]; - nametags?: string[]; // Optional for backwards compatibility - _integrity?: TxfIntegrity; // Optional for backwards compatibility -} - -/** - * Genesis transaction (initial minting) - */ -export interface TxfGenesis { - data: TxfGenesisData; - inclusionProof: TxfInclusionProof; -} - -/** - * Genesis data payload - */ -export interface TxfGenesisData { - tokenId: string; // 64-char hex - tokenType: string; // 64-char hex - coinData: [string, string][]; // [[coinId, amount], ...] - tokenData: string; // Optional metadata - salt: string; // 64-char hex - recipient: string; // DIRECT://... address - recipientDataHash: string | null; - reason: string | null; -} - -/** - * Current token state - */ -export interface TxfState { - data: string; // State data (can be empty) - predicate: string; // Hex-encoded CBOR predicate -} - -/** - * State transition transaction - */ -export interface TxfTransaction { - previousStateHash: string; - newStateHash?: string; // Optional for backwards compatibility with older tokens - predicate: string; // New owner's predicate - inclusionProof: TxfInclusionProof | null; // null = uncommitted - data?: Record; // Optional transfer metadata -} - -/** - * Sparse Merkle Tree inclusion proof - */ -export interface TxfInclusionProof { - authenticator: TxfAuthenticator; - merkleTreePath: TxfMerkleTreePath; - transactionHash: string; - unicityCertificate: string; // Hex-encoded CBOR -} - -/** - * Proof authenticator - */ -export interface TxfAuthenticator { - algorithm: string; // e.g., "secp256k1" - publicKey: string; // Aggregator's public key (hex) - signature: string; // Signature over state hash (hex) - stateHash: string; // Hash being authenticated (hex with "0000" prefix) -} - -/** - * Merkle tree path for proof verification - */ -export interface TxfMerkleTreePath { - root: string; // Tree root hash (hex with "0000" prefix) - steps: TxfMerkleStep[]; -} - -/** - * Single step in merkle path - */ -export interface TxfMerkleStep { - data: string; // Sibling node hash - path: string; // Path direction as numeric string -} - -/** - * Token integrity metadata - */ -export interface TxfIntegrity { - genesisDataJSONHash: string; // SHA-256 hash with "0000" prefix - currentStateHash?: string; // Current state hash (computed for genesis-only tokens) -} - -// ========================================== -// Validation Types -// ========================================== - -export interface ValidationResult { - validTokens: import("../../data/model").Token[]; - issues: ValidationIssue[]; -} - -export interface ValidationIssue { - tokenId: string; - reason: string; - recoverable?: boolean; -} - -export interface TokenValidationResult { - isValid: boolean; - token?: import("../../data/model").Token; - reason?: string; -} - -// ========================================== -// Conflict Resolution Types -// ========================================== - -export interface TokenConflict { - tokenId: string; - localVersion: TxfToken; - remoteVersion: TxfToken; - resolution: "local" | "remote"; - reason: string; -} - -export interface MergeResult { - merged: TxfStorageData; - conflicts: TokenConflict[]; - newTokens: string[]; // Token IDs added from remote - removedTokens: string[]; // Token IDs only in local (if remote is newer) -} - -// ========================================== -// Utility Types -// ========================================== - -// Key prefixes for special storage types -const ARCHIVED_PREFIX = "_archived_"; -const FORKED_PREFIX = "_forked_"; - -/** - * Check if a key is an archived token key - */ -export function isArchivedKey(key: string): boolean { - return key.startsWith(ARCHIVED_PREFIX); -} - -/** - * Check if a key is a forked token key - */ -export function isForkedKey(key: string): boolean { - return key.startsWith(FORKED_PREFIX); -} - -/** - * Check if a key is an active token key (not archived, forked, or reserved) - */ -export function isActiveTokenKey(key: string): boolean { - return key.startsWith("_") && - !key.startsWith(ARCHIVED_PREFIX) && - !key.startsWith(FORKED_PREFIX) && - key !== "_meta" && - key !== "_nametag" && - key !== "_tombstones" && - key !== "_invalidatedNametags" && - key !== "_outbox" && - key !== "_mintOutbox" && - key !== "_sent" && - key !== "_invalid" && - key !== "_integrity"; -} - -/** - * Check if a key is a token key (starts with _ but not reserved) - * NOTE: This now only returns true for ACTIVE tokens (excludes archived/forked) - */ -export function isTokenKey(key: string): boolean { - return isActiveTokenKey(key); -} - -/** - * Extract token ID from key (remove leading underscore) - */ -export function tokenIdFromKey(key: string): string { - return key.startsWith("_") ? key.substring(1) : key; -} - -/** - * Create token key from ID (add leading underscore) - */ -export function keyFromTokenId(tokenId: string): string { - return `_${tokenId}`; -} - -/** - * Create archived token key from token ID - */ -export function archivedKeyFromTokenId(tokenId: string): string { - return `${ARCHIVED_PREFIX}${tokenId}`; -} - -/** - * Extract token ID from archived key - */ -export function tokenIdFromArchivedKey(key: string): string { - return key.startsWith(ARCHIVED_PREFIX) ? key.substring(ARCHIVED_PREFIX.length) : key; -} - -/** - * Create forked token key from token ID and state hash - */ -export function forkedKeyFromTokenIdAndState(tokenId: string, stateHash: string): string { - return `${FORKED_PREFIX}${tokenId}_${stateHash}`; -} - -/** - * Parse forked key into tokenId and stateHash - * Returns null if key is not a valid forked key - */ -export function parseForkedKey(key: string): { tokenId: string; stateHash: string } | null { - if (!key.startsWith(FORKED_PREFIX)) return null; - const remainder = key.substring(FORKED_PREFIX.length); - // Format: tokenId_stateHash - // tokenId is 64 chars, stateHash starts with "0000" (68+ chars) - // Find underscore after 64-char tokenId - const underscoreIndex = remainder.indexOf("_"); - if (underscoreIndex === -1 || underscoreIndex < 64) return null; - return { - tokenId: remainder.substring(0, underscoreIndex), - stateHash: remainder.substring(underscoreIndex + 1), - }; -} - -/** - * Validate 64-character hex token ID - */ -export function isValidTokenId(tokenId: string): boolean { - return /^[0-9a-fA-F]{64}$/.test(tokenId); -} diff --git a/src/components/wallet/L3/services/utils/SyncModeDetector.ts b/src/components/wallet/L3/services/utils/SyncModeDetector.ts deleted file mode 100644 index b17cc399..00000000 --- a/src/components/wallet/L3/services/utils/SyncModeDetector.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Sync Mode Detection Utility - * - * Implements mode detection logic per TOKEN_INVENTORY_SPEC.md Section 6.1 - * Modes are mutually exclusive with precedence: LOCAL > NAMETAG > FAST > NORMAL - */ - -import type { SyncMode, CircuitBreakerState } from '../../types/SyncTypes'; -import type { OutboxEntry } from '../types/OutboxTypes'; -import type { Token } from '../../data/model'; - -/** - * Parameters for sync mode detection - */ -export interface SyncModeParams { - /** Force LOCAL mode (skip IPFS reads/writes) */ - local?: boolean; - - /** Force NAMETAG mode (fetch nametag token only) */ - nametag?: boolean; - - /** Incoming tokens from Nostr/peer transfer (triggers FAST mode) */ - incomingTokens?: Token[] | null; - - /** Outbox tokens pending send (triggers FAST mode) */ - outboxTokens?: OutboxEntry[] | null; - - /** Circuit breaker state (may auto-activate LOCAL mode) */ - circuitBreaker?: CircuitBreakerState; -} - -/** - * Detects sync mode based on input parameters and circuit breaker state - * - * Precedence Order (Section 6.1): - * 1. LOCAL = true or circuit breaker active → LOCAL mode - * 2. NAMETAG = true → NAMETAG mode - * 3. incomingTokens OR outboxTokens non-empty → FAST mode - * 4. Default → NORMAL mode - * - * @example - * // Force LOCAL mode - * detectSyncMode({ local: true }); // Returns 'LOCAL' - * - * @example - * // FAST mode from incoming tokens - * detectSyncMode({ incomingTokens: [token] }); // Returns 'FAST' - * - * @example - * // Default NORMAL mode - * detectSyncMode({}); // Returns 'NORMAL' - */ -export function detectSyncMode(params: SyncModeParams): SyncMode { - const { - local = false, - nametag = false, - incomingTokens, - outboxTokens, - circuitBreaker - } = params; - - // Precedence 1: Explicit LOCAL flag - if (local === true) { - return 'LOCAL'; - } - - // Precedence 1b: Circuit breaker auto-activates LOCAL mode - if (circuitBreaker?.localModeActive === true) { - return 'LOCAL'; - } - - // Precedence 2: NAMETAG mode - if (nametag === true) { - return 'NAMETAG'; - } - - // Precedence 3: FAST mode (either incoming OR outbox non-empty) - const hasIncoming = Array.isArray(incomingTokens) && incomingTokens.length > 0; - const hasOutbox = Array.isArray(outboxTokens) && outboxTokens.length > 0; - - if (hasIncoming || hasOutbox) { - return 'FAST'; - } - - // Precedence 4: Default to NORMAL - return 'NORMAL'; -} - -/** - * Checks if the current mode should skip IPFS operations - * LOCAL mode skips all IPFS read/write operations - * Also skips if IPFS is disabled via VITE_ENABLE_IPFS=false - */ -export function shouldSkipIpfs(mode: SyncMode): boolean { - // Check if IPFS is disabled via environment variable - const ipfsDisabled = import.meta.env.VITE_ENABLE_IPFS === 'false'; - return mode === 'LOCAL' || ipfsDisabled; -} - -/** - * Checks if the current mode should skip spent detection (Step 7) - * FAST and LOCAL modes skip spent detection for speed - */ -export function shouldSkipSpentDetection(mode: SyncMode): boolean { - return mode === 'FAST' || mode === 'LOCAL'; -} - -/** - * Checks if the current mode is read-only (no IPFS writes) - * NAMETAG mode is read-only - */ -export function isReadOnlyMode(mode: SyncMode): boolean { - return mode === 'NAMETAG'; -} - -/** - * Checks if the current mode should acquire sync lock - * NAMETAG mode does NOT acquire lock (allows parallel reads) - */ -export function shouldAcquireSyncLock(mode: SyncMode): boolean { - return mode !== 'NAMETAG'; -} diff --git a/src/components/wallet/L3/types/SyncTypes.ts b/src/components/wallet/L3/types/SyncTypes.ts deleted file mode 100644 index d5668aac..00000000 --- a/src/components/wallet/L3/types/SyncTypes.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Types for Token Inventory Sync Operations - * - * Spec Reference: /docs/TOKEN_INVENTORY_SPEC.md v3.1 - */ - -import type { NametagData } from '../services/types/TxfTypes'; - -/** - * FOLDER CATEGORIZATION (Section 3.1) - * - * Tokens are organized into logical folders based on their lifecycle state: - * - * - **Nametags**: `_nametag` field + tokens with nametag type pointing to user's address - * - **Active**: Tokens stored with `_` keys (unspent, ready for transactions) - * - **Sent**: Tokens in `_sent` array (latest state SPENT with inclusion proof) - * - **Outbox**: Entries in `_outbox` array (pending send operations) - * - **Invalid**: Entries in `_invalid` array (failed validation, kept for investigation) - * - * Note: Archived tokens (`_archived_`) and forked tokens (`_forked__`) - * are internal categories, not exposed as folders to users. - */ - -/** - * Sync mode determines which steps are executed in inventorySync() - * Modes are mutually exclusive and listed in order of precedence (Section 6.1) - * - * - LOCAL: Skip IPFS entirely, localStorage only (Section 6.2) - * - NAMETAG: Fetch only nametag tokens, read-only (Section 6.3) - * - FAST: Skip Step 7 spent detection, for speed (Section 6.1) - * - NORMAL: Full sync with all validation (Section 6.1) - */ -export type SyncMode = 'LOCAL' | 'NAMETAG' | 'FAST' | 'NORMAL'; - -/** - * Sync operation result status - * - * Note: This is a simplified status model vs spec's original 5-status design. - * PARTIAL_SUCCESS covers spec's PARTIAL_SYNC_FAILED; RETRY handled via error codes. - * NAMETAG_ONLY added for NAMETAG mode completion (spec extension). - * - * - SUCCESS: All operations completed successfully - * - PARTIAL_SUCCESS: localStorage saved but IPFS publish failed (ipnsPublishPending=true) - * - LOCAL_ONLY: Operated in LOCAL mode (no IPFS) - * - NAMETAG_ONLY: NAMETAG mode completed (lightweight return) - * - ERROR: Critical failure, operation aborted - */ -export type SyncStatus = - | 'SUCCESS' - | 'PARTIAL_SUCCESS' - | 'LOCAL_ONLY' - | 'NAMETAG_ONLY' - | 'ERROR'; - -/** - * Standardized error codes aligned with Unicity architecture layers - * - * - IPFS layer: IPFS_UNAVAILABLE, IPNS_PUBLISH_FAILED, IPNS_RESOLUTION_FAILED - * - Aggregator layer: AGGREGATOR_UNREACHABLE, PROOF_FETCH_FAILED - * - Validation layer: VALIDATION_FAILED, INTEGRITY_FAILURE - * - Application layer: CONFLICT_LOOP, PARTIAL_OPERATION, STORAGE_ERROR - */ -export type SyncErrorCode = - | 'IPFS_UNAVAILABLE' // Step 2: IPFS fetch failed (10 consecutive) - | 'IPNS_PUBLISH_FAILED' // Step 10: IPNS publish failed - | 'IPNS_RESOLUTION_FAILED' // Step 2: IPNS resolution failed - | 'AGGREGATOR_UNREACHABLE' // Aggregator timeout/connection failure - | 'PROOF_FETCH_FAILED' // Step 3.2: Inclusion proof fetch failed - | 'VALIDATION_FAILED' // Step 4/5: Token validation failed - | 'INTEGRITY_FAILURE' // Critical: State hash collision detected - | 'CONFLICT_LOOP' // Circuit breaker: max 5 consecutive merge conflicts - | 'PARTIAL_OPERATION' // localStorage saved, IPFS failed - | 'STORAGE_ERROR' // localStorage write failure - | 'UNKNOWN'; - -/** - * Reason codes for invalid tokens (Section 3.3) - */ -export type InvalidReasonCode = - | 'SDK_VALIDATION' // Token failed Unicity SDK validation - | 'INTEGRITY_FAILURE' // State hash collision detected - | 'NAMETAG_MISMATCH' // Nametag token's Nostr pubkey mismatch - | 'MISSING_FIELDS' // Missing required fields (genesis, state, etc.) - | 'OWNERSHIP_MISMATCH' // Token destination doesn't match user's address - | 'PROOF_MISMATCH'; // Inclusion proof doesn't match commitment - -/** - * Circuit breaker state for LOCAL mode auto-recovery (Section 10.7) - * Tracks both IPFS failures and conflict loops - * - * Reset Conditions: - * - consecutiveConflicts: Reset to 0 on successful merge OR when LOCAL mode activated - * - consecutiveIpfsFailures: Reset to 0 on successful IPFS operation - * - localModeActive: Cleared on successful full sync in NORMAL mode - */ -export interface CircuitBreakerState { - /** Currently operating in LOCAL mode due to failures */ - localModeActive: boolean; - - /** Epoch ms when LOCAL mode was activated */ - localModeActivatedAt?: number; - - /** Epoch ms for next auto-recovery attempt (1 hour intervals per Section 10.7) */ - nextRecoveryAttempt?: number; - - /** Consecutive conflicts before switching to LOCAL (max 5) */ - consecutiveConflicts: number; - - /** Timestamp of last conflict */ - lastConflictTimestamp?: number; - - /** Consecutive IPFS failures (max 10 before LOCAL mode per Section 10.2) */ - consecutiveIpfsFailures: number; -} - -/** - * Sync operation statistics - what changed during this sync - */ -export interface SyncOperationStats { - /** New tokens added from IPFS */ - tokensImported: number; - - /** Tokens tombstoned/invalidated */ - tokensRemoved: number; - - /** Existing tokens updated to newer state */ - tokensUpdated: number; - - /** Merge conflicts resolved */ - conflictsResolved: number; - - /** Tokens checked against aggregator (Step 7) */ - tokensValidated: number; - - /** New tombstones created */ - tombstonesAdded: number; - - /** Nametag bindings published to Nostr (Step 8.5) */ - nametagsPublished: number; - - /** Tokens recovered from false tombstones (Step 7.5) */ - tokensRecovered?: number; -} - -/** - * Token inventory statistics - snapshot after sync - * - * Note: Extends spec's tokenStats (Section 6.1) with additional fields: - * - nametagTokens: Count of Unicity ID tokens (Section 3.1 Nametags folder) - * - tombstoneCount: Total tombstone markers (Section 3.6 conflict resolution) - */ -export interface TokenInventoryStats { - /** ACTIVE status tokens (unspent, ready for transactions) */ - activeTokens: number; - - /** SENT status tokens (spent, audit trail) */ - sentTokens: number; - - /** Tokens in OUTBOX (pending send operations) */ - outboxTokens: number; - - /** INVALID status tokens (failed validation) */ - invalidTokens: number; - - /** Nametag tokens (Unicity IDs) */ - nametagTokens: number; - - /** Total tombstone markers */ - tombstoneCount: number; -} - -/** - * Result returned by inventorySync() (Section 6.1) - * - * Supports all sync modes: LOCAL, NAMETAG, FAST, NORMAL - * Tracks multi-stage finality: localStorage -> IPFS -> IPNS - */ -export interface SyncResult { - /** Final status of sync operation */ - status: SyncStatus; - - /** Which sync mode was executed */ - syncMode: SyncMode; - - /** Structured error code (undefined on SUCCESS) */ - errorCode?: SyncErrorCode; - - /** Human-readable error message */ - errorMessage?: string; - - /** What changed during this sync */ - operationStats: SyncOperationStats; - - /** Total inventory state after sync (omit in NAMETAG mode) */ - inventoryStats?: TokenInventoryStats; - - /** IPFS content CID (latest version) */ - lastCid?: string; - - /** IPNS name for this identity */ - ipnsName?: string; - - /** True if IPNS publish succeeded */ - ipnsPublished?: boolean; - - /** True if localStorage saved but IPNS publish failed */ - ipnsPublishPending?: boolean; - - /** Epoch ms when IPNS retry is scheduled */ - ipnsRetryScheduled?: number; - - /** Sync operation duration in milliseconds */ - syncDurationMs: number; - - /** Epoch ms when sync completed */ - timestamp: number; - - /** Storage version after sync */ - version?: number; - - /** Circuit breaker state (for LOCAL mode auto-recovery) */ - circuitBreaker?: CircuitBreakerState; - - /** Nametags (only populated in NAMETAG mode) */ - nametags?: NametagData[]; - - /** Validation warnings that didn't prevent sync */ - validationIssues?: string[]; -} - -/** - * Helper type for creating default CircuitBreakerState - */ -export function createDefaultCircuitBreakerState(): CircuitBreakerState { - return { - localModeActive: false, - consecutiveConflicts: 0, - consecutiveIpfsFailures: 0, - }; -} - -/** - * Helper type for creating default SyncOperationStats - */ -export function createDefaultSyncOperationStats(): SyncOperationStats { - return { - tokensImported: 0, - tokensRemoved: 0, - tokensUpdated: 0, - conflictsResolved: 0, - tokensValidated: 0, - tombstonesAdded: 0, - nametagsPublished: 0, - }; -} - -/** - * Helper type for creating default TokenInventoryStats - */ -export function createDefaultTokenInventoryStats(): TokenInventoryStats { - return { - activeTokens: 0, - sentTokens: 0, - outboxTokens: 0, - invalidTokens: 0, - nametagTokens: 0, - tombstoneCount: 0, - }; -} diff --git a/src/components/wallet/L3/utils/currency.ts b/src/components/wallet/L3/utils/currency.ts index 77b2295a..6eea8d8e 100644 --- a/src/components/wallet/L3/utils/currency.ts +++ b/src/components/wallet/L3/utils/currency.ts @@ -1,4 +1,4 @@ -import { RegistryService } from "../services/RegistryService"; +import { TokenRegistry } from "@unicitylabs/sphere-sdk"; export const CurrencyUtils = { toSmallestUnit: (amount: string, decimals: number): bigint => { @@ -30,8 +30,8 @@ export const AmountFormatUtils = { if(amount === undefined || coinId === undefined) return ""; const amountFloat = parseFloat(amount); - const registryService = RegistryService.getInstance(); - const def = registryService.getCoinDefinition(coinId); + const registry = TokenRegistry.getInstance(); + const def = registry.getDefinition(coinId); const decimals = def?.decimals ?? 6; const divisor = Math.pow(10, decimals); diff --git a/src/components/wallet/L3/views/L3WalletView.tsx b/src/components/wallet/L3/views/L3WalletView.tsx index 6f510b66..e12aa087 100644 --- a/src/components/wallet/L3/views/L3WalletView.tsx +++ b/src/components/wallet/L3/views/L3WalletView.tsx @@ -1,7 +1,6 @@ import { Plus, ArrowUpRight, ArrowDownUp, Sparkles, Loader2, Coins, Layers, CheckCircle, XCircle, Eye, EyeOff, Wifi } from 'lucide-react'; import { AnimatePresence, motion, useMotionValue, useTransform, animate } from 'framer-motion'; import { AssetRow } from '../../shared/components'; -import { Token as LegacyToken } from '../data/model'; import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useIdentity, useAssets, useTokens, useL1Balance } from '../../../../sdk'; @@ -11,39 +10,14 @@ import { TokenRow } from '../../shared/components'; import { SendModal } from '../modals/SendModal'; import { SwapModal } from '../modals/SwapModal'; import { PaymentRequestsModal } from '../modals/PaymentRequestModal'; -import { FaucetService } from '../services/FaucetService'; +import { FaucetService } from '../../../../services/FaucetService'; import { SeedPhraseModal } from '../modals/SeedPhraseModal'; import { TransactionHistoryModal } from '../modals/TransactionHistoryModal'; import { SettingsModal } from '../modals/SettingsModal'; import { BackupWalletModal, LogoutConfirmModal } from '../../shared/modals'; import { SaveWalletModal } from '../../L1/components/modals'; -import type { Token as SdkToken } from '@unicitylabs/sphere-sdk'; - type Tab = 'assets' | 'tokens'; -/** - * Bridge SDK Token to legacy Token class for TokenRow component. - * Maps SDK fields to legacy model expectations. - */ -function sdkTokenToLegacy(token: SdkToken): LegacyToken { - return new LegacyToken({ - id: token.id, - name: token.name, - type: token.symbol, - timestamp: token.createdAt, - amount: token.amount, - coinId: token.coinId, - symbol: token.symbol, - iconUrl: token.iconUrl, - status: token.status === 'confirmed' ? 'CONFIRMED' - : token.status === 'pending' ? 'PENDING' - : token.status === 'submitted' ? 'SUBMITTED' - : token.status === 'spent' ? 'TRANSFERRED' - : 'CONFIRMED', - sizeBytes: token.sdkData?.length ?? 0, - }); -} - // Animated balance display with smooth number transitions function BalanceDisplay({ totalValue, @@ -174,11 +148,7 @@ export function L3WalletView({ const assets = sdkAssets; - // Convert SDK tokens to legacy Token instances for TokenRow component - const tokens = useMemo(() => - sdkTokens.map(sdkTokenToLegacy), - [sdkTokens] - ); + const tokens = sdkTokens; // L1 balance as a number (ALPHA units) const l1Balance = useMemo(() => { @@ -207,7 +177,7 @@ export function L3WalletView({ } const newIds = new Set(); - tokens.filter(t => t.type !== 'Nametag').forEach(token => { + tokens.filter(t => t.coinId !== 'NAMETAG').forEach(token => { if (!prevTokenIdsRef.current.has(token.id)) { newIds.add(token.id); } @@ -235,7 +205,7 @@ export function L3WalletView({ // Update previous snapshots after render (for next comparison) useEffect(() => { - const currentIds = new Set(tokens.filter(t => t.type !== 'Nametag').map(t => t.id)); + const currentIds = new Set(tokens.filter(t => t.coinId !== 'NAMETAG').map(t => t.id)); prevTokenIdsRef.current = currentIds; isFirstLoadRef.current = false; }, [tokens]); @@ -379,8 +349,13 @@ export function L3WalletView({ if (isLoadingIdentity) { return ( -
+
+
); } @@ -560,12 +535,12 @@ export function L3WalletView({ {/* TOKENS VIEW - no container animation, only item animations */} {activeTab === 'tokens' && (
- {tokens.filter(t => t.type !== 'Nametag').length === 0 ? ( + {tokens.filter(t => t.coinId !== 'NAMETAG').length === 0 ? ( ) : ( tokens - .filter(t => t.type !== 'Nametag') - .sort((a, b) => b.timestamp - a.timestamp) + .filter(t => t.coinId !== 'NAMETAG') + .sort((a, b) => b.createdAt - a.createdAt) .map((token, index) => ( void; - setScanCount: (count: number) => void; - setShowScanModal: (show: boolean) => void; - setShowLoadPasswordModal: (show: boolean) => void; - - // Actions - handleFileSelect: (file: File) => Promise; - handleDragOver: (e: React.DragEvent) => void; - handleDragLeave: (e: React.DragEvent) => void; - handleDrop: (e: React.DragEvent) => Promise; - handleConfirmImport: () => void; - handleImportFromFile: (file: File, scanCountParam?: number) => Promise; - onSelectScannedAddress: (scannedAddr: ScannedAddress) => Promise; - onSelectAllScannedAddresses: (scannedAddresses: ScannedAddress[]) => Promise; - onCancelScan: () => void; - onConfirmLoadWithPassword: (password: string) => Promise; -} - -interface UseWalletImportOptions { - getUnifiedKeyManager: () => UnifiedKeyManager; - goToAddressSelection: (skipIpnsCheck?: boolean) => Promise; - setError: (error: string | null) => void; - setIsBusy: (busy: boolean) => void; -} - -export function useWalletImport({ - getUnifiedKeyManager, - goToAddressSelection, - setError, - setIsBusy, -}: UseWalletImportOptions): UseWalletImportReturn { - // File import state - const [selectedFile, setSelectedFile] = useState(null); - const [scanCount, setScanCount] = useState(10); - const [needsScanning, setNeedsScanning] = useState(true); - const [isDragging, setIsDragging] = useState(false); - - // Modal state - const [showScanModal, setShowScanModal] = useState(false); - const [showLoadPasswordModal, setShowLoadPasswordModal] = useState(false); - const [pendingWallet, setPendingWallet] = useState(null); - const [pendingFile, setPendingFile] = useState(null); - const [initialScanCount, setInitialScanCount] = useState(10); - - // Check if file needs blockchain scanning - const checkIfNeedsScanning = useCallback(async (file: File) => { - try { - if (file.name.endsWith(".dat")) { - setNeedsScanning(true); - setScanCount(10); - return; - } - - const content = await file.text(); - setNeedsScanning(needsBlockchainScanning(file.name, content)); - setScanCount(10); - } catch (err) { - console.error("Error checking file type:", err); - setNeedsScanning(true); - } - }, []); - - // Handle file selection - const handleFileSelect = useCallback( - async (file: File) => { - setSelectedFile(file); - await checkIfNeedsScanning(file); - }, - [checkIfNeedsScanning] - ); - - // Drag handlers - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - - const file = e.dataTransfer.files[0]; - if ( - file && - (file.name.endsWith(".txt") || file.name.endsWith(".dat") || file.name.endsWith(".json")) - ) { - await handleFileSelect(file); - } - }, - [handleFileSelect] - ); - - // Confirm import with current file and scan count - const handleConfirmImport = useCallback(() => { - if (!selectedFile) return; - handleImportFromFile(selectedFile, scanCount); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedFile, scanCount]); - - // Main import handler - const handleImportFromFile = useCallback( - async (file: File, scanCountParam?: number) => { - setIsBusy(true); - setError(null); - - // Mark that we're in an active import flow to allow wallet creation - setImportInProgress(); - - try { - // Clear any existing wallet data - const existingKeyManager = getUnifiedKeyManager(); - if (existingKeyManager?.isInitialized()) { - console.log("🔐 Clearing existing wallet before importing from file"); - existingKeyManager.clear(); - UnifiedKeyManager.resetInstance(); - } - - localStorage.removeItem(STORAGE_KEYS.WALLET_MAIN); - - // For .dat files, use direct SDK import and show scan modal - if (file.name.endsWith(".dat")) { - const result = await importWalletFromFile(file); - - // Check if the .dat file is encrypted and needs a password - if (!result.success && result.isEncryptedDat) { - console.log("📦 .dat file is encrypted, showing password modal"); - setPendingFile(file); - setInitialScanCount(scanCountParam || 100); - setShowLoadPasswordModal(true); - setIsBusy(false); - return; - } - - if (!result.success || !result.wallet) { - throw new Error(result.error || "Import failed"); - } - console.log("📦 .dat file imported, showing scan modal"); - setPendingWallet(result.wallet); - setInitialScanCount(scanCountParam || 100); - setShowScanModal(true); - setIsBusy(false); - return; - } - - const content = await file.text(); - - // Handle JSON wallet files - if (file.name.endsWith(".json") || isJSONWalletFormat(content)) { - try { - const json = JSON.parse(content); - - if (json.encrypted) { - setPendingFile(file); - setInitialScanCount(scanCountParam || 10); - setShowLoadPasswordModal(true); - setIsBusy(false); - return; - } - - const result = await importWalletFromJSON(content); - if (!result.success || !result.wallet) { - throw new Error(result.error || "Import failed"); - } - - if (result.mnemonic) { - const keyManager = getUnifiedKeyManager(); - await keyManager.createFromMnemonic(result.mnemonic); - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_INDEX_LEGACY); - await goToAddressSelection(); - return; - } - - const isJsonBIP32 = result.derivationMode === "bip32" || result.wallet.chainCode; - if (isJsonBIP32) { - const keyManager = getUnifiedKeyManager(); - const chainCode = result.wallet.chainCode || result.wallet.masterChainCode || null; - 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 - const keyManager = getUnifiedKeyManager(); - await keyManager.importWithMode(result.wallet.masterPrivateKey, null, "wif_hmac"); - saveWalletToStorage("main", result.wallet); - await goToAddressSelection(); - return; - } catch (e) { - if (file.name.endsWith(".json")) { - throw new Error(`Invalid JSON wallet file: ${e instanceof Error ? e.message : String(e)}`); - } - } - } - - // Check if encrypted TXT file - if (isEncryptedWallet(content)) { - setPendingFile(file); - setInitialScanCount(scanCountParam || 10); - setShowLoadPasswordModal(true); - setIsBusy(false); - return; - } - - // Check if BIP32 wallet - if (isBIP32Wallet(content) && content.includes("MASTER PRIVATE KEY")) { - const result = await importWalletFromFile(file); - if (!result.success || !result.wallet) { - throw new Error(result.error || "Import failed"); - } - setPendingWallet(result.wallet); - setInitialScanCount(scanCountParam || 10); - setShowScanModal(true); - setIsBusy(false); - return; - } - - // Try mnemonic formats - let imported = false; - - try { - const json = JSON.parse(content); - const mnemonic = extractMnemonic(json as Record); - - if (mnemonic) { - const keyManager = getUnifiedKeyManager(); - await keyManager.createFromMnemonic(mnemonic); - imported = true; - } - } catch { - // Not JSON - } - - if (!imported) { - const trimmed = content.trim(); - if (isValidMnemonicFormat(trimmed)) { - const keyManager = getUnifiedKeyManager(); - await keyManager.createFromMnemonic(trimmed); - imported = true; - } - } - - if (!imported && content.includes("MASTER PRIVATE KEY")) { - const result = await importWalletFromFile(file); - if (!result.success || !result.wallet) { - throw new Error(result.error || "Import failed"); - } - - const keyManager = getUnifiedKeyManager(); - await keyManager.importFromFileContent(content); - - if (result.wallet.addresses.length > 0) { - saveWalletToStorage("main", result.wallet); - } - - imported = true; - } - - if (!imported) { - throw new Error("Could not import wallet from file"); - } - - await goToAddressSelection(); - } catch (e) { - // Clear import flag on error - clearImportInProgress(); - const message = e instanceof Error ? e.message : "Failed to import wallet from file"; - setError(message); - setIsBusy(false); - } - }, - [getUnifiedKeyManager, goToAddressSelection, setError, setIsBusy] - ); - - // Handle scanned address selection - const onSelectScannedAddress = useCallback( - async (scannedAddr: ScannedAddress) => { - if (!pendingWallet) return; - - // Mark that we're in an active import flow to allow wallet creation - setImportInProgress(); - - try { - setIsBusy(true); - setError(null); - - const walletWithAddress: L1Wallet = { - ...pendingWallet, - addresses: [ - { - index: scannedAddr.index, - address: scannedAddr.address, - privateKey: scannedAddr.privateKey, - publicKey: scannedAddr.publicKey, - path: scannedAddr.path, - createdAt: new Date().toISOString(), - }, - ], - }; - - saveWalletToStorage("main", walletWithAddress); - - // NOTE: Do NOT save nametag here with empty token data! - // The scan only provides the nametag NAME, not the full token data. - // Saving with `token: {}` corrupts the wallet data. - // Instead, let IPFS sync populate the nametag correctly on first sync. - // See: https://github.com/anthropics/claude-code/issues/XXX - if (scannedAddr.l3Nametag) { - console.log(`📝 Found nametag "${scannedAddr.l3Nametag}" for address - will be populated via IPFS sync`); - } - - const keyManager = getUnifiedKeyManager(); - const basePath = pendingWallet.descriptorPath - ? `m/${pendingWallet.descriptorPath}` - : undefined; - if (pendingWallet.masterPrivateKey && pendingWallet.masterChainCode) { - await keyManager.importWithMode( - pendingWallet.masterPrivateKey, - pendingWallet.masterChainCode, - "bip32", - basePath - ); - } else if (pendingWallet.masterPrivateKey) { - await keyManager.importWithMode(pendingWallet.masterPrivateKey, null, "wif_hmac"); - } - - setShowScanModal(false); - setPendingWallet(null); - await goToAddressSelection(true); - } catch (e) { - // Clear import flag on error - clearImportInProgress(); - const message = e instanceof Error ? e.message : "Failed to import wallet"; - setError(message); - setIsBusy(false); - } - }, - [pendingWallet, getUnifiedKeyManager, goToAddressSelection, setError, setIsBusy] - ); - - // Handle loading all scanned addresses - const onSelectAllScannedAddresses = useCallback( - async (scannedAddresses: ScannedAddress[]) => { - if (!pendingWallet || scannedAddresses.length === 0) return; - - // Mark that we're in an active import flow to allow wallet creation - setImportInProgress(); - - try { - setIsBusy(true); - setError(null); - - const walletWithAddresses: L1Wallet = { - ...pendingWallet, - addresses: scannedAddresses.map((addr) => ({ - index: addr.index, - address: addr.address, - privateKey: addr.privateKey, - publicKey: addr.publicKey, - path: addr.path, - createdAt: new Date().toISOString(), - isChange: addr.isChange, - })), - }; - - saveWalletToStorage("main", walletWithAddresses); - - // NOTE: Do NOT save nametags here with empty token data! - // The scan only provides the nametag NAME, not the full token data. - // Saving with `token: {}` corrupts the wallet data. - // Instead, let IPFS sync populate nametags correctly on first sync. - const addressesWithNametags = scannedAddresses.filter(addr => addr.l3Nametag); - if (addressesWithNametags.length > 0) { - console.log(`📝 Found ${addressesWithNametags.length} addresses with nametags - will be populated via IPFS sync`); - } - - const keyManager = getUnifiedKeyManager(); - const basePath = pendingWallet.descriptorPath - ? `m/${pendingWallet.descriptorPath}` - : undefined; - if (pendingWallet.masterPrivateKey && pendingWallet.masterChainCode) { - await keyManager.importWithMode( - pendingWallet.masterPrivateKey, - pendingWallet.masterChainCode, - "bip32", - basePath - ); - } else if (pendingWallet.masterPrivateKey) { - await keyManager.importWithMode(pendingWallet.masterPrivateKey, null, "wif_hmac"); - } - - setShowScanModal(false); - setPendingWallet(null); - await goToAddressSelection(true); - } catch (e) { - // Clear import flag on error - clearImportInProgress(); - const message = e instanceof Error ? e.message : "Failed to import wallet"; - setError(message); - setIsBusy(false); - } - }, - [pendingWallet, getUnifiedKeyManager, goToAddressSelection, setError, setIsBusy] - ); - - // Cancel scan modal - const onCancelScan = useCallback(() => { - // Clear import flag when user cancels - clearImportInProgress(); - setShowScanModal(false); - setPendingWallet(null); - }, []); - - // Handle password confirmation for encrypted files - const onConfirmLoadWithPassword = useCallback( - async (password: string) => { - if (!pendingFile) return; - - // Mark that we're in an active import flow to allow wallet creation - setImportInProgress(); - - try { - setIsBusy(true); - setError(null); - setShowLoadPasswordModal(false); - - const content = await pendingFile.text(); - - 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 (result.mnemonic) { - const keyManager = getUnifiedKeyManager(); - await keyManager.createFromMnemonic(result.mnemonic); - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_INDEX_LEGACY); - await goToAddressSelection(); - return; - } - - const isBIP32 = result.derivationMode === "bip32" || result.wallet.chainCode; - if (isBIP32) { - const keyManager = getUnifiedKeyManager(); - const chainCode = result.wallet.chainCode || result.wallet.masterChainCode || null; - 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; - } - - 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"); - } - - setPendingFile(null); - - if (result.wallet.masterChainCode || result.wallet.isImportedAlphaWallet) { - setPendingWallet(result.wallet); - setShowScanModal(true); - setIsBusy(false); - } else { - 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", - basePath - ); - } else if (result.wallet.masterPrivateKey) { - await keyManager.importWithMode(result.wallet.masterPrivateKey, null, "wif_hmac"); - } - - if (result.wallet.addresses.length > 0) { - saveWalletToStorage("main", result.wallet); - } - - await goToAddressSelection(); - } - } catch (e) { - // Clear import flag on error - clearImportInProgress(); - const message = e instanceof Error ? e.message : "Failed to decrypt wallet"; - setError(message); - setIsBusy(false); - } - }, - [pendingFile, getUnifiedKeyManager, goToAddressSelection, setError, setIsBusy] - ); - - return { - // Modal state - showScanModal, - showLoadPasswordModal, - pendingWallet, - pendingFile, - initialScanCount, - - // File state - selectedFile, - scanCount, - needsScanning, - isDragging, - - // Setters - setSelectedFile, - setScanCount, - setShowScanModal, - setShowLoadPasswordModal, - - // Actions - handleFileSelect, - handleDragOver, - handleDragLeave, - handleDrop, - handleConfirmImport, - handleImportFromFile, - onSelectScannedAddress, - onSelectAllScannedAddresses, - onCancelScan, - onConfirmLoadWithPassword, - }; -} diff --git a/src/components/wallet/shared/components/AddressSelector.tsx b/src/components/wallet/shared/components/AddressSelector.tsx index 5b7d843a..e3c7e9db 100644 --- a/src/components/wallet/shared/components/AddressSelector.tsx +++ b/src/components/wallet/shared/components/AddressSelector.tsx @@ -83,6 +83,11 @@ export function AddressSelector({ currentNametag, compact = true, addressFormat const displayNametag = currentNametag || nametag; const refreshAfterSwitch = useCallback(() => { + // Remove cached data so stale values from the previous address aren't shown + queryClient.removeQueries({ queryKey: SPHERE_KEYS.identity.all }); + queryClient.removeQueries({ queryKey: SPHERE_KEYS.payments.all }); + queryClient.removeQueries({ queryKey: SPHERE_KEYS.l1.all }); + // Re-fetch fresh data for the new address queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.identity.all }); queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.all }); queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.l1.all }); diff --git a/src/components/wallet/shared/components/TokenRow.tsx b/src/components/wallet/shared/components/TokenRow.tsx index aca4e076..0e80cc84 100644 --- a/src/components/wallet/shared/components/TokenRow.tsx +++ b/src/components/wallet/shared/components/TokenRow.tsx @@ -1,8 +1,8 @@ import { motion, useMotionValue, useTransform, animate } from 'framer-motion'; -import { Token } from '../../L3/data/model'; +import type { Token } from '@unicitylabs/sphere-sdk'; +import { TokenRegistry } from '@unicitylabs/sphere-sdk'; import { Box, Copy, CheckCircle2 } from 'lucide-react'; import { useState, memo, useEffect } from 'react'; -import { RegistryService } from '../../L3/services/RegistryService'; interface TokenRowProps { token: Token; @@ -27,8 +27,8 @@ function parseTokenAmount(amount: string | undefined, coinId: string | undefined try { if (!amount || !coinId) return 0; const amountFloat = parseFloat(amount); - const registryService = RegistryService.getInstance(); - const def = registryService.getCoinDefinition(coinId); + const registry = TokenRegistry.getInstance(); + const def = registry.getDefinition(coinId); const decimals = def?.decimals ?? 6; const divisor = Math.pow(10, decimals); return amountFloat / divisor; @@ -41,8 +41,8 @@ function parseTokenAmount(amount: string | undefined, coinId: string | undefined function formatTokenAmount(value: number, coinId: string | undefined): string { try { if (!coinId) return value.toString(); - const registryService = RegistryService.getInstance(); - const def = registryService.getCoinDefinition(coinId); + const registry = TokenRegistry.getInstance(); + const def = registry.getDefinition(coinId); const decimals = def?.decimals ?? 6; return new Intl.NumberFormat('en-US', { maximumFractionDigits: Math.min(decimals, 6) @@ -127,7 +127,7 @@ export const TokenRow = memo(function TokenRow({ token, delay, isNew = true }: T Token - {new Date(token.timestamp).toLocaleDateString()} + {new Date(token.createdAt).toLocaleDateString()}
diff --git a/src/components/wallet/shared/hooks/index.ts b/src/components/wallet/shared/hooks/index.ts index d325f26f..8acb84fa 100644 --- a/src/components/wallet/shared/hooks/index.ts +++ b/src/components/wallet/shared/hooks/index.ts @@ -5,5 +5,3 @@ export type { UseCreateAddressReturn, } from "./useCreateAddress"; -export { useSwitchAddress } from "./useSwitchAddress"; -export type { UseSwitchAddressReturn } from "./useSwitchAddress"; diff --git a/src/components/wallet/shared/hooks/useCreateAddress.ts b/src/components/wallet/shared/hooks/useCreateAddress.ts index 6c4857ac..a398b24d 100644 --- a/src/components/wallet/shared/hooks/useCreateAddress.ts +++ b/src/components/wallet/shared/hooks/useCreateAddress.ts @@ -1,41 +1,22 @@ /** - * useCreateAddress - Hook for creating new wallet addresses without page reload + * useCreateAddress - Hook for creating new wallet addresses * - * This hook handles: - * 1. Deriving new unified address (L1 + L3) using UnifiedKeyManager - * 2. Minting nametag on blockchain - * 3. Syncing to IPFS/IPNS with verification - * 4. Updating TanStack Query cache - * - * Works for both L1 and L3 wallet views. + * Uses sphere-sdk for: + * 1. Deriving new address via sphere.deriveAddress() + * 2. Checking nametag availability via sphere.isNametagAvailable() + * 3. Atomic address creation with nametag via sphere.switchToAddress() */ import { useState, useCallback, useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { UnifiedKeyManager } from '../services/UnifiedKeyManager'; -import { IdentityManager } from '../../L3/services/IdentityManager'; -import { NametagService } from '../../L3/services/NametagService'; -import { NostrService } from '../../L3/services/NostrService'; -import { IpfsStorageService } from '../../L3/services/IpfsStorageService'; -import { fetchNametagFromIpns } from '../../L3/services/IpnsNametagFetcher'; -import { QUERY_KEYS as KEYS } from '../../../../config/queryKeys'; -import { L1_KEYS } from '../../L1/hooks/useL1Wallet'; -import { STORAGE_KEYS } from '../../../../config/storageKeys'; -import { - saveWalletToStorage, - loadWalletFromStorage, - type Wallet as L1Wallet, -} from '../../L1/sdk'; - -const SESSION_KEY = "user-pin-1234"; +import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; +import { SPHERE_KEYS } from '../../../../sdk/queryKeys'; export type CreateAddressStep = | 'idle' | 'deriving' | 'nametag_input' | 'checking_availability' - | 'minting' - | 'syncing_ipfs' - | 'verifying_ipns' + | 'creating' | 'complete' | 'error'; @@ -44,11 +25,8 @@ export interface CreateAddressState { error: string | null; newAddress: { l1Address: string; - l3Address: string; path: string; index: number; - privateKey: string; - publicKey: string; } | null; progress: string; } @@ -71,67 +49,9 @@ export interface UseCreateAddressReturn { isNametagAvailable: (nametag: string) => Promise; } -/** - * Verify nametag is available via IPNS with retry - */ -async function verifyNametagInIpnsWithRetry( - privateKey: string, - expectedNametag: string, - timeoutMs: number = 60000, - onStatusUpdate?: (status: string) => void -): Promise { - const startTime = Date.now(); - const retryInterval = 3000; - let successCount = 0; - const REQUIRED_SUCCESS_COUNT = 2; - let attemptCount = 0; - - while (Date.now() - startTime < timeoutMs) { - try { - attemptCount++; - const elapsed = Math.floor((Date.now() - startTime) / 1000); - - onStatusUpdate?.(`Verifying IPNS... (${elapsed}s / ${Math.floor(timeoutMs / 1000)}s)`); - - console.log(`🔄 IPNS verification attempt #${attemptCount} for "${expectedNametag}"...`); - - const result = await fetchNametagFromIpns(privateKey); - - if (result.nametag === expectedNametag && result.source === "http" && result.nametagData) { - successCount++; - onStatusUpdate?.(`Verifying IPNS... (${successCount}/${REQUIRED_SUCCESS_COUNT} confirmations)`); - console.log(`✅ IPNS read successful (${successCount}/${REQUIRED_SUCCESS_COUNT})`); - - if (successCount >= REQUIRED_SUCCESS_COUNT) { - console.log(`✅ IPNS verified with ${REQUIRED_SUCCESS_COUNT} consecutive reads`); - return true; - } - - await new Promise((resolve) => setTimeout(resolve, 2000)); - continue; - } - - successCount = 0; - console.log(`🔄 IPNS returned "${result.nametag || "null"}", expected "${expectedNametag}"`); - } catch (error) { - successCount = 0; - console.log("🔄 IPNS verification attempt failed, retrying...", error); - } - - const remainingTime = timeoutMs - (Date.now() - startTime); - if (remainingTime > retryInterval) { - await new Promise((resolve) => setTimeout(resolve, retryInterval)); - } - } - - console.error(`❌ IPNS verification timeout after ${timeoutMs}ms`); - return false; -} - export function useCreateAddress(): UseCreateAddressReturn { const queryClient = useQueryClient(); - const identityManager = IdentityManager.getInstance(SESSION_KEY); - const nametagService = NametagService.getInstance(identityManager); + const { sphere } = useSphereContext(); const [state, setState] = useState({ step: 'idle', @@ -142,13 +62,11 @@ export function useCreateAddress(): UseCreateAddressReturn { // Warn user about closing during critical steps useEffect(() => { - if (!['minting', 'syncing_ipfs', 'verifying_ipns'].includes(state.step)) { - return; - } + if (state.step !== 'creating') return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); - e.returnValue = "Your Unicity ID is being synced. Closing now may prevent recovery on other devices."; + e.returnValue = "Your Unicity ID is being created. Closing now may cause issues."; return e.returnValue; }; @@ -157,78 +75,53 @@ export function useCreateAddress(): UseCreateAddressReturn { }, [state.step]); const reset = useCallback(() => { - setState({ - step: 'idle', - error: null, - newAddress: null, - progress: '', - }); + setState({ step: 'idle', error: null, newAddress: null, progress: '' }); }, []); const setStep = useCallback((step: CreateAddressStep, progress: string = '') => { setState(prev => ({ ...prev, step, progress, error: null })); }, []); - const setProgress = useCallback((progress: string) => { - setState(prev => ({ ...prev, progress })); - }, []); - const setError = useCallback((error: string) => { setState(prev => ({ ...prev, step: 'error', error })); }, []); /** - * Step 1: Derive new address using UnifiedKeyManager + * Step 1: Derive new address using sphere-sdk */ const startCreateAddress = useCallback(async () => { + if (!sphere) { + setError("Wallet not initialized"); + return; + } + try { setStep('deriving', 'Generating new address...'); - const keyManager = UnifiedKeyManager.getInstance(SESSION_KEY); - const basePath = keyManager.getBasePath(); - - if (!keyManager.isInitialized()) { - throw new Error("Wallet not initialized"); - } - - // Load current L1 wallet to find next index - const currentWallet = loadWalletFromStorage("main"); - if (!currentWallet) { - throw new Error("L1 wallet not found"); - } + // Determine next index from sphere's tracked addresses + const tracked = sphere.getActiveAddresses(); + const nextIndex = tracked.length > 0 + ? Math.max(...tracked.map(a => a.index)) + 1 + : 0; - // Find next address index (count existing external addresses) - const nextIndex = currentWallet.addresses.filter(a => !a.isChange).length; - - // Derive unified address - const path = `${basePath}/0/${nextIndex}`; - const derived = keyManager.deriveAddressFromPath(path); - - // Derive L3 identity for this path - const l3Identity = await identityManager.deriveIdentityFromPath(path); - - console.log(`✅ New address derived: L1=${derived.l1Address.slice(0, 12)}... L3=${l3Identity.address.slice(0, 12)}... path=${path}`); + const derived = sphere.deriveAddress(nextIndex); setState(prev => ({ ...prev, step: 'nametag_input', progress: '', newAddress: { - l1Address: derived.l1Address, - l3Address: l3Identity.address, - path: path, - index: nextIndex, - privateKey: l3Identity.privateKey, - publicKey: derived.publicKey, + l1Address: derived.address, + path: derived.path, + index: derived.index, }, })); - } catch (err) { const message = err instanceof Error ? err.message : "Failed to create address"; console.error("createAddress error:", err); setError(message); } - }, [setStep, setError, identityManager]); + }, [sphere, queryClient, setStep, setError]); /** * Set existing address (for addresses without nametag) @@ -241,11 +134,8 @@ export function useCreateAddress(): UseCreateAddressReturn { progress: '', newAddress: { l1Address: address.l1Address, - l3Address: address.l3Address, path: address.path, index: address.index, - privateKey: address.privateKey, - publicKey: address.publicKey, }, }); }, []); @@ -254,208 +144,52 @@ export function useCreateAddress(): UseCreateAddressReturn { * Check if nametag is available */ const isNametagAvailable = useCallback(async (nametag: string): Promise => { - return await nametagService.isNametagAvailable(nametag); - }, [nametagService]); + if (!sphere) return false; + return await sphere.isNametagAvailable(nametag); + }, [sphere]); /** - * Step 2: Submit nametag, mint on blockchain, sync to IPFS + * Step 2: Submit nametag, create address atomically via SDK */ const submitNametag = useCallback(async (nametag: string) => { - if (!state.newAddress) { - setError("No address to create nametag for"); + if (!state.newAddress || !sphere) { + setError("No address or wallet not initialized"); return; } const cleanTag = nametag.trim().replace("@", "").toLowerCase(); - // Store original state for rollback on error - const originalL1Wallet = loadWalletFromStorage("main"); - const originalSelectedPath = localStorage.getItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - let walletModified = false; - - // Rollback function to restore original state on error - const rollback = () => { - if (!walletModified) return; - - console.log("🔄 Rolling back address creation changes..."); - - // Restore original L1 wallet (without the new address) - if (originalL1Wallet) { - saveWalletToStorage("main", originalL1Wallet); - console.log(" ✓ Restored original L1 wallet"); - } - - // Restore original selected path - if (originalSelectedPath) { - localStorage.setItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH, originalSelectedPath); - identityManager.setSelectedAddressPath(originalSelectedPath); - } else { - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - } - console.log(" ✓ Restored original address path"); - }; - try { - // Set flag to prevent auto-sync from interfering - localStorage.setItem(STORAGE_KEYS.ADDRESS_CREATION_IN_PROGRESS, 'true'); - // Check availability setStep('checking_availability', 'Checking if name is available...'); - const available = await nametagService.isNametagAvailable(cleanTag); + const available = await sphere.isNametagAvailable(cleanTag); if (!available) { - localStorage.removeItem(STORAGE_KEYS.ADDRESS_CREATION_IN_PROGRESS); setError(`@${cleanTag} is already taken`); return; } - // Step 1: Mint nametag - setStep('minting', 'Minting Unicity ID on blockchain...'); - - if (!originalL1Wallet) { - throw new Error("L1 wallet not found"); - } - - // Check if address already exists in wallet (for existing address flow) - const addressAlreadyExists = originalL1Wallet.addresses.some( - a => a.address === state.newAddress!.l1Address - ); - - // Only save to L1 wallet if this is a new address - if (!addressAlreadyExists) { - const keyManager = UnifiedKeyManager.getInstance(SESSION_KEY); - const derived = keyManager.deriveAddressFromPath(state.newAddress.path); - - const newWalletAddress = { - index: state.newAddress.index, - address: state.newAddress.l1Address, - privateKey: derived.privateKey, - publicKey: derived.publicKey, - path: state.newAddress.path, - isChange: false, - createdAt: new Date().toISOString(), - }; - - const updatedWallet: L1Wallet = { - ...originalL1Wallet, - addresses: [...originalL1Wallet.addresses, newWalletAddress], - }; - - saveWalletToStorage("main", updatedWallet); - walletModified = true; - console.log(`💾 Saved new address to L1 wallet: ${state.newAddress.l1Address.slice(0, 12)}...`); - } else { - console.log(`📝 Address already exists in wallet, skipping save: ${state.newAddress.l1Address.slice(0, 12)}...`); - } - - // Set selected path for L3 identity - localStorage.setItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH, state.newAddress.path); - identityManager.setSelectedAddressPath(state.newAddress.path); - - // Reset IPFS service for new identity - console.log("🔄 Resetting IpfsStorageService for new identity..."); - await IpfsStorageService.resetInstance(); - - // Reset and restart NostrService with new identity - const nostrService = NostrService.getInstance(identityManager); - console.log("🔄 Resetting NostrService to use new identity..."); - await nostrService.reset(); - await nostrService.start(); - console.log("✅ NostrService reconnected with new identity"); - - // Mint nametag - const mintResult = await nametagService.mintNametagAndPublish(cleanTag); - - if (mintResult.status === 'error') { - // Check if the error is because nametag already exists (interrupted flow recovery) - if (mintResult.message?.includes('Identity already has a nametag')) { - console.log(`ℹ️ Nametag already exists - continuing with sync...`); - // Extract existing nametag name from error message if different from requested - const existingMatch = mintResult.message.match(/nametag: (\S+)/); - const existingNametag = existingMatch?.[1]; - if (existingNametag && existingNametag !== cleanTag) { - console.log(`⚠️ Existing nametag "${existingNametag}" differs from requested "${cleanTag}"`); - } - } else { - throw new Error(mintResult.message); - } - } else { - console.log(`✅ Nametag minted: @${cleanTag}`); - } - - // Wait for localStorage write to flush - setProgress("Preparing to sync..."); - await new Promise(resolve => setTimeout(resolve, 300)); - - // Step 2: Sync to IPFS - setStep('syncing_ipfs', 'Syncing to IPFS storage...'); - - const ipfsService = IpfsStorageService.getInstance(identityManager); - const syncResult = await ipfsService.syncNow(); - - console.log("📦 IPFS sync result:", { - success: syncResult.success, - cid: syncResult.cid, - ipnsPublished: syncResult.ipnsPublished, - error: syncResult.error - }); - - if (!syncResult.success) { - console.error("❌ IPFS sync failed:", syncResult.error); - throw new Error( - `Failed to sync your Unicity ID to decentralized storage. ${syncResult.error || "Unknown error"}.` - ); - } + // Create address with nametag (atomic: derives, mints, syncs) + setStep('creating', 'Creating Unicity ID...'); + await sphere.switchToAddress(state.newAddress.index, { nametag: cleanTag }); - // Step 3: Verify in IPNS - if (syncResult.ipnsPublished) { - setStep('verifying_ipns', 'Verifying IPFS availability...'); - - const verified = await verifyNametagInIpnsWithRetry( - state.newAddress.privateKey, - cleanTag, - 60000, - (status) => setProgress(status) - ); - - if (!verified) { - console.warn("⚠️ IPNS verification failed after 60s - continuing anyway"); - } else { - console.log(`✅ Verified nametag "${cleanTag}" available via IPNS`); - } - } - - // Step 4: Complete + // Complete setStep('complete', 'Address created successfully!'); - // Clear flag - localStorage.removeItem(STORAGE_KEYS.ADDRESS_CREATION_IN_PROGRESS); - - // Dispatch events to trigger UI updates - console.log('📢 Dispatching wallet events...'); - window.dispatchEvent(new Event("wallet-loaded")); + // Dispatch event to trigger UI updates window.dispatchEvent(new Event("wallet-updated")); // Invalidate queries to refresh UI - await queryClient.invalidateQueries({ queryKey: KEYS.IDENTITY }); - await queryClient.invalidateQueries({ queryKey: KEYS.NAMETAG }); - await queryClient.invalidateQueries({ queryKey: KEYS.TOKENS }); - await queryClient.invalidateQueries({ queryKey: L1_KEYS.WALLET }); - - console.log(`🎉 Address creation complete: @${cleanTag}`); + await queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.identity.all }); + await queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.identity.nametag }); + await queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.tokens.all }); + await queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.l1.all }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create nametag"; console.error("submitNametag error:", err); - - // Clear flag on error - localStorage.removeItem(STORAGE_KEYS.ADDRESS_CREATION_IN_PROGRESS); - - // Rollback changes on error - rollback(); - setError(message); } - }, [state.newAddress, identityManager, nametagService, queryClient, setStep, setProgress, setError]); + }, [state.newAddress, sphere, queryClient, setStep, setError]); return { state, diff --git a/src/components/wallet/shared/hooks/useSwitchAddress.ts b/src/components/wallet/shared/hooks/useSwitchAddress.ts deleted file mode 100644 index 65f39b25..00000000 --- a/src/components/wallet/shared/hooks/useSwitchAddress.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * useSwitchAddress - Hook for switching between wallet addresses without page reload - * - * This hook handles: - * 1. Updating localStorage with new selected path - * 2. Updating IdentityManager with new path - * 3. Resetting WalletRepository in-memory state - * 4. Resetting IpfsStorageService for new identity - * 5. Dispatching wallet-loaded/wallet-updated events - * 6. Invalidating TanStack Query cache - */ -import { useCallback, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { WalletRepository } from '../../../../repositories/WalletRepository'; -import { IdentityManager } from '../../L3/services/IdentityManager'; -import { IpfsStorageService } from '../../L3/services/IpfsStorageService'; -import { QUERY_KEYS as KEYS } from '../../../../config/queryKeys'; -import { L1_KEYS } from '../../L1/hooks/useL1Wallet'; -import { STORAGE_KEYS } from '../../../../config/storageKeys'; - -const SESSION_KEY = "user-pin-1234"; - -export interface UseSwitchAddressReturn { - switchToAddress: (l1Address: string, path: string | null) => Promise; - isSwitching: boolean; -} - -export function useSwitchAddress(): UseSwitchAddressReturn { - const queryClient = useQueryClient(); - const [isSwitching, setIsSwitching] = useState(false); - - const switchToAddress = useCallback(async (l1Address: string, path: string | null) => { - if (isSwitching) return; - - setIsSwitching(true); - - try { - console.log(`🔄 Switching to address: ${l1Address.slice(0, 12)}... path=${path}`); - - // 1. Update localStorage with new selected path - if (path) { - localStorage.setItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH, path); - } else { - localStorage.removeItem(STORAGE_KEYS.L3_SELECTED_ADDRESS_PATH); - } - - // 2. Update IdentityManager with new path - const identityManager = IdentityManager.getInstance(SESSION_KEY); - identityManager.setSelectedAddressPath(path || "m/84'/1'/0'/0/0"); - - // 3. Reset WalletRepository in-memory state - const walletRepo = WalletRepository.getInstance(); - walletRepo.resetInMemoryState(); - - // 4. Reset IpfsStorageService for new identity - // This ensures IPFS sync uses the new identity's IPNS keys - await IpfsStorageService.resetInstance(); - - // 5. Get new identity and pre-load wallet BEFORE invalidating queries - // This prevents race conditions in useWallet's queryFn - const newIdentity = await identityManager.getCurrentIdentity(); - if (newIdentity) { - console.log(`📦 Pre-loading wallet for new identity: ${newIdentity.address.slice(0, 20)}...`); - walletRepo.loadWalletForAddress(newIdentity.address); - } - - // 6. Dispatch wallet-loaded event for services (NostrService reset) - console.log('📢 Dispatching wallet-loaded event...'); - window.dispatchEvent(new Event("wallet-loaded")); - - // 7. Invalidate queries to refresh UI - // Identity first, then others - the pre-loaded wallet will be used - await queryClient.invalidateQueries({ queryKey: KEYS.IDENTITY }); - await queryClient.invalidateQueries({ queryKey: KEYS.NAMETAG }); - await queryClient.invalidateQueries({ queryKey: KEYS.TOKENS }); - await queryClient.invalidateQueries({ queryKey: KEYS.AGGREGATED }); - await queryClient.invalidateQueries({ queryKey: L1_KEYS.WALLET }); - - console.log(`✅ Switched to address: ${l1Address.slice(0, 12)}...`); - - } catch (err) { - console.error('Failed to switch address:', err); - throw err; - } finally { - setIsSwitching(false); - } - }, [isSwitching, queryClient]); - - return { - switchToAddress, - isSwitching, - }; -} diff --git a/src/components/wallet/shared/modals/CreateAddressModal.tsx b/src/components/wallet/shared/modals/CreateAddressModal.tsx index cfe81253..dc1507b6 100644 --- a/src/components/wallet/shared/modals/CreateAddressModal.tsx +++ b/src/components/wallet/shared/modals/CreateAddressModal.tsx @@ -1,13 +1,10 @@ /** * CreateAddressModal - Modal for creating new wallet addresses * - * Multi-step modal that allows users to create additional addresses - * without leaving the main app (no page reload). - * * Steps: - * 1. Deriving - Generate new L1/L3 address + * 1. Deriving - Generate new address via SDK * 2. Nametag Input - User enters desired nametag - * 3. Processing - Mint nametag, sync to IPFS + * 3. Creating - Mint nametag on blockchain (atomic via SDK) * 4. Complete - Show success */ import { useState, useEffect } from 'react'; @@ -97,14 +94,14 @@ export function CreateAddressModal({ isOpen, onClose, existingAddress }: CreateA const handleClose = () => { // Don't allow closing during critical steps - if (['minting', 'syncing_ipfs'].includes(state.step)) { + if (state.step === 'creating') { return; } onClose(); }; - const isProcessing = ['deriving', 'checking_availability', 'minting', 'syncing_ipfs', 'verifying_ipns'].includes(state.step); - const canClose = !['minting', 'syncing_ipfs', 'verifying_ipns'].includes(state.step); + const isProcessing = ['deriving', 'checking_availability', 'creating'].includes(state.step); + const canClose = state.step !== 'creating'; return ( @@ -249,7 +246,7 @@ function StepNametagInput({ onKeyDown, onSubmit, }: { - newAddress: { l1Address: string; l3Address: string; path: string; index: number }; + newAddress: { l1Address: string; path: string; index: number }; nametagInput: string; isCheckingAvailability: boolean; availabilityError: string | null; @@ -328,7 +325,7 @@ function StepNametagInput({ whileTap={{ scale: 0.98 }} onClick={onSubmit} disabled={!nametagInput || isCheckingAvailability} - className="w-full py-3 px-6 rounded-xl bg-gradient-to-r from-orange-500 to-orange-600 text-white font-bold shadow-lg shadow-orange-500/30 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" + className="w-full py-3 px-6 rounded-xl bg-linear-to-r from-orange-500 to-orange-600 text-white font-bold shadow-lg shadow-orange-500/30 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" > {isCheckingAvailability ? ( <> @@ -352,22 +349,15 @@ function StepProcessing({ step, progress }: { step: CreateAddressStep; progress: switch (step) { case 'checking_availability': return { title: 'Checking Availability', subtitle: 'Verifying name is unique...' }; - case 'minting': - return { title: 'Minting Unicity ID', subtitle: progress || 'Creating on blockchain...' }; - case 'syncing_ipfs': - return { title: 'Syncing to IPFS', subtitle: progress || 'Backing up to decentralized storage...' }; - case 'verifying_ipns': - return { title: 'Verifying IPNS', subtitle: progress || 'Confirming availability...' }; + case 'creating': + return { title: 'Creating Unicity ID', subtitle: progress || 'Minting on blockchain...' }; default: return { title: 'Processing', subtitle: progress || 'Please wait...' }; } }; const info = getStepInfo(); - const isCritical = ['minting', 'syncing_ipfs', 'verifying_ipns'].includes(step); - const isMinting = step === 'minting'; - const isSyncing = step === 'syncing_ipfs'; - const isVerifying = step === 'verifying_ipns'; + const isCritical = step === 'creating'; return ( {info.subtitle} - {/* Step indicators */} -
-
-
-
-
- {/* Warning for critical steps */} {isCritical && ( Don't close this window

- {isVerifying && ( -

- Verifying IPFS storage (up to 60 seconds)... -

- )}
)} @@ -484,7 +462,7 @@ function StepComplete({ nametag, onClose }: { nametag: string; onClose: () => vo whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={onClose} - className="w-full py-3 px-6 rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-bold shadow-lg shadow-emerald-500/30" + className="w-full py-3 px-6 rounded-xl bg-linear-to-r from-emerald-500 to-emerald-600 text-white font-bold shadow-lg shadow-emerald-500/30" > Done diff --git a/src/components/wallet/shared/services/UnifiedKeyManager.ts b/src/components/wallet/shared/services/UnifiedKeyManager.ts deleted file mode 100644 index ea6004ee..00000000 --- a/src/components/wallet/shared/services/UnifiedKeyManager.ts +++ /dev/null @@ -1,800 +0,0 @@ -/** - * UnifiedKeyManager - Single source of truth for L1 and L3 key management - * - * Supports TWO derivation modes for webwallet compatibility: - * 1. Standard BIP32 - When chain code is available (full HD wallet) - * 2. WIF HMAC - When only master key is available (simple wallet) - * - * Same private keys are used for: - * - L1 Alpha addresses (P2WPKH bech32) - * - L3 Unicity identities (secp256k1) - * - Nostr keypairs (secp256k1/schnorr) - * - IPFS keys (HKDF-derived Ed25519) - */ - -import * as bip39 from "bip39"; -import CryptoJS from "crypto-js"; -import elliptic from "elliptic"; -import { - deriveKeyAtPath, - generateMasterKeyFromSeed, - generateHDAddressBIP32, - generateAddressFromMasterKey, - generateHDAddress, -} from "../../L1/sdk/address"; -import { - exportWalletToJSON, - downloadWalletJSON, - importWalletFromJSON, - type WalletJSON, - type WalletJSONExportOptions, -} from "../../L1/sdk/import-export"; -import { STORAGE_KEYS, clearAllSphereData } from "../../../../config/storageKeys"; - -const ec = new elliptic.ec("secp256k1"); - -// Default base path for BIP32 derivation -const DEFAULT_BASE_PATH = "m/44'/0'/0'"; - -export type WalletSource = "mnemonic" | "file" | "unknown"; - -/** - * Derivation mode determines how child keys are derived: - * - "bip32": Standard BIP32 with chain code (IL + parentKey) mod n - * - "legacy_hmac": Legacy Sphere HMAC derivation with chain code (HMAC-SHA512(chainCode, masterKey || index)) - * - "wif_hmac": Simple HMAC derivation without chain code (HMAC-SHA512(pathString, masterKey)) - */ -export type DerivationMode = "bip32" | "legacy_hmac" | "wif_hmac"; - -export interface DerivedAddress { - privateKey: string; - publicKey: string; - l1Address: string; - index: number; - path: string; - isChange?: boolean; -} - -export interface WalletInfo { - source: WalletSource; - hasMnemonic: boolean; - hasChainCode: boolean; - derivationMode: DerivationMode; - address0: string | null; -} - -/** - * UnifiedKeyManager provides a single interface for key management - * across L1 and L3 wallets. - * - * Supports both BIP32 (with chain code) and WIF HMAC (without chain code) derivation. - */ -export class UnifiedKeyManager { - private static instance: UnifiedKeyManager | null = null; - - private mnemonic: string | null = null; - private masterKey: string | null = null; - private chainCode: string | null = null; - private derivationMode: DerivationMode = "bip32"; - private basePath: string = DEFAULT_BASE_PATH; - private source: WalletSource = "unknown"; - private sessionKey: string; - - // Initialization guards - private isInitializing: boolean = false; - private hasInitialized: boolean = false; - private initializePromise: Promise | null = null; - - private constructor(sessionKey: string) { - this.sessionKey = sessionKey; - } - - static getInstance(sessionKey: string): UnifiedKeyManager { - if (!UnifiedKeyManager.instance) { - UnifiedKeyManager.instance = new UnifiedKeyManager(sessionKey); - } else if (UnifiedKeyManager.instance.sessionKey !== sessionKey) { - // Session key mismatch! This is a critical error that would cause - // decryption to fail. Log the issue and update the session key. - console.error( - "WARNING: UnifiedKeyManager session key mismatch detected!", - "This can cause data loss. Updating session key to maintain consistency." - ); - UnifiedKeyManager.instance.sessionKey = sessionKey; - } - return UnifiedKeyManager.instance; - } - - /** - * Initialize wallet from stored data (if available) - */ - async initialize(): Promise { - // Return cached result if already initialized - if (this.hasInitialized) { - return this.masterKey !== null; - } - - // Return existing promise if initialization in progress - if (this.isInitializing && this.initializePromise) { - return this.initializePromise; - } - - // Start initialization - this.isInitializing = true; - this.initializePromise = this.doInitialize(); - - try { - const result = await this.initializePromise; - this.hasInitialized = true; - return result; - } finally { - this.isInitializing = false; - } - } - - private async doInitialize(): Promise { - try { - // Try to load from storage - const encryptedMnemonic = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_MNEMONIC); - const encryptedMaster = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_MASTER); - const chainCode = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_CHAINCODE); - const source = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_SOURCE) as WalletSource; - const derivationMode = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_DERIVATION_MODE) as DerivationMode; - const storedBasePath = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_BASE_PATH); - - console.log("🔐 UnifiedKeyManager initializing...", { - hasMnemonic: !!encryptedMnemonic, - hasMaster: !!encryptedMaster, - hasChainCode: !!chainCode, - source, - derivationMode, - }); - - if (encryptedMnemonic) { - // Wallet was created from mnemonic - const mnemonic = this.decrypt(encryptedMnemonic); - if (mnemonic) { - await this.createFromMnemonic(mnemonic, false); // Don't save again - console.log("✅ Wallet initialized from mnemonic"); - return true; - } else { - console.error("❌ Failed to decrypt mnemonic - session key mismatch?"); - } - } else if (encryptedMaster) { - // Wallet was imported from file - const masterKey = this.decrypt(encryptedMaster); - if (masterKey) { - this.masterKey = masterKey; - this.chainCode = chainCode || null; // May be null for WIF HMAC mode - this.source = source || "file"; - this.derivationMode = derivationMode || (chainCode ? "bip32" : "wif_hmac"); - this.basePath = storedBasePath || DEFAULT_BASE_PATH; - console.log(`✅ Wallet initialized from file import (basePath: ${this.basePath})`); - return true; - } else { - console.error("❌ Failed to decrypt master key - session key mismatch?"); - } - } - - console.log("ℹ️ No wallet data found in storage"); - return false; - } catch (error) { - console.error("Failed to initialize UnifiedKeyManager:", error); - return false; - } - } - - /** - * Create a new wallet from a BIP39 mnemonic - */ - async createFromMnemonic(mnemonic: string, save: boolean = true): Promise { - // Validate mnemonic - if (!bip39.validateMnemonic(mnemonic)) { - throw new Error("Invalid mnemonic phrase"); - } - - // Convert mnemonic to seed (64 bytes) - const seed = await bip39.mnemonicToSeed(mnemonic); - const seedHex = Buffer.from(seed).toString("hex"); - - // Derive master key and chain code using BIP32 standard - const { masterPrivateKey, masterChainCode } = generateMasterKeyFromSeed(seedHex); - - this.mnemonic = mnemonic; - this.masterKey = masterPrivateKey; - this.chainCode = masterChainCode; - this.source = "mnemonic"; - - if (save) { - this.saveToStorage(); - } - - console.log("🔐 Unified wallet created from mnemonic"); - } - - /** - * Generate a new wallet with a fresh mnemonic - */ - async generateNew(wordCount: 12 | 24 = 12): Promise { - const strength = wordCount === 24 ? 256 : 128; - const mnemonic = bip39.generateMnemonic(strength); - await this.createFromMnemonic(mnemonic); - return mnemonic; - } - - /** - * Import wallet from webwallet txt file content - * Supports two formats: - * 1. With Chain Code (BIP32 mode): Uses standard HD derivation - * 2. Without Chain Code (WIF HMAC mode): Uses simple HMAC derivation - */ - async importFromFileContent(content: string): Promise { - const lines = content.split("\n").map((l) => l.trim()); - - let masterKey: string | null = null; - let chainCode: string | null = null; - let expectMasterKey = false; - let expectChainCode = false; - - for (const line of lines) { - // Check if this line is a label for master key (value on next line) - // Handles formats like: "MASTER PRIVATE KEY (keep secret!):" or "MASTER PRIVATE KEY:" - if (/MASTER\s*PRIVATE\s*KEY/i.test(line) && !/[a-fA-F0-9]{64}/.test(line)) { - expectMasterKey = true; - continue; - } - - // Check if this line is a label for chain code (value on next line) - // Handles formats like: "MASTER CHAIN CODE (for BIP32...):" or "MASTER CHAIN CODE:" - if (/MASTER\s*CHAIN\s*CODE/i.test(line) && !/[a-fA-F0-9]{64}/.test(line)) { - expectChainCode = true; - continue; - } - - // If we're expecting a master key and this line is a 64-char hex string - if (expectMasterKey && /^[a-fA-F0-9]{64}$/.test(line)) { - masterKey = line.toLowerCase(); - expectMasterKey = false; - continue; - } - - // If we're expecting a chain code and this line is a 64-char hex string - if (expectChainCode && /^[a-fA-F0-9]{64}$/.test(line)) { - chainCode = line.toLowerCase(); - expectChainCode = false; - continue; - } - - // Also try same-line format: "Master Private Key: " - const masterMatch = line.match(/(?:Master\s*(?:Private\s*)?Key|masterPriv)[:\s]+([a-fA-F0-9]{64})/i); - const chainMatch = line.match(/(?:Chain\s*Code|chainCode)[:\s]+([a-fA-F0-9]{64})/i); - - if (masterMatch) { - masterKey = masterMatch[1].toLowerCase(); - } - if (chainMatch) { - chainCode = chainMatch[1].toLowerCase(); - } - - // Reset expectations if we hit a non-hex line - if (!/^[a-fA-F0-9]{64}$/.test(line)) { - expectMasterKey = false; - expectChainCode = false; - } - } - - if (!masterKey) { - throw new Error("Could not find master private key in file"); - } - - // Validate key by trying to create a keypair - try { - ec.keyFromPrivate(masterKey, "hex"); - } catch { - throw new Error("Invalid master private key format"); - } - - // Clear any existing mnemonic storage (so file import takes precedence) - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_MNEMONIC); - - this.mnemonic = null; // No mnemonic when importing from file - this.masterKey = masterKey; - this.chainCode = chainCode; // May be null for WIF HMAC mode - this.source = "file"; - - // Determine derivation mode based on chain code presence - if (chainCode) { - this.derivationMode = "bip32"; - console.log("🔐 Unified wallet imported with BIP32 mode (chain code present)"); - } else { - this.derivationMode = "wif_hmac"; - console.log("🔐 Unified wallet imported with WIF HMAC mode (no chain code)"); - } - - // Mark as initialized since we have valid data - this.hasInitialized = true; - - this.saveToStorage(); - } - - /** - * Import wallet from a File object - */ - async importFromFile(file: File): Promise { - const content = await file.text(); - return this.importFromFileContent(content); - } - - /** - * Import wallet with explicit derivation mode - * Use this when you know the derivation mode the wallet was created with - * @param basePath - The BIP32 base path (e.g., "m/84'/1'/0'" from wallet.dat descriptor) - */ - async importWithMode( - masterKey: string, - chainCode: string | null, - mode: DerivationMode, - basePath?: string - ): Promise { - // Validate key - try { - ec.keyFromPrivate(masterKey, "hex"); - } catch { - throw new Error("Invalid master private key format"); - } - - this.mnemonic = null; - this.masterKey = masterKey; - this.chainCode = chainCode; - this.derivationMode = mode; - this.basePath = basePath || DEFAULT_BASE_PATH; - this.source = "file"; - - // Mark as initialized since we have valid data - this.hasInitialized = true; - - this.saveToStorage(); - - console.log(`🔐 Unified wallet imported with ${mode} mode (basePath: ${this.basePath})`); - } - - /** - * Set derivation mode (useful for switching modes after import) - */ - setDerivationMode(mode: DerivationMode): void { - if (mode === "bip32" || mode === "legacy_hmac") { - if (!this.chainCode) { - throw new Error(`${mode} mode requires chain code`); - } - } - this.derivationMode = mode; - this.saveToStorage(); - console.log(`🔐 Derivation mode changed to ${mode}`); - } - - /** - * Derive address from a full BIP32 path string - * This is the ONLY method for address derivation - PATH is the single identifier - * @param path - Full path like "m/84'/1'/0'/0/5" or "m/44'/0'/0'/1/3" or "m/44'/0'/0'" (HMAC style) - */ - deriveAddressFromPath(path: string): DerivedAddress { - if (!this.masterKey) { - throw new Error("Wallet not initialized"); - } - - let index: number; - let isChange: boolean; - - // Parse path to extract chain and index - // Try 5-level BIP32 first: m/84'/1'/0'/0/5 or m/44'/0'/0'/1/3 - const bip32Match = path.match(/m\/(\d+)'\/(\d+)'\/(\d+)'\/(\d+)\/(\d+)/); - if (bip32Match) { - const chain = parseInt(bip32Match[4], 10); // 0=external, 1=change - index = parseInt(bip32Match[5], 10); - isChange = chain === 1; - } else { - // Try 3-level HMAC path: m/44'/0'/0' (Standard wallet format) - const hmacMatch = path.match(/m\/(\d+)'\/(\d+)'\/(\d+)'/); - if (hmacMatch) { - // In HMAC paths, the last hardened component is the index - index = parseInt(hmacMatch[3], 10); - isChange = false; // HMAC wallets don't have change addresses - } else { - throw new Error(`Invalid BIP32 path: ${path}`); - } - } - - if (this.derivationMode === "bip32" && this.chainCode) { - // Standard BIP32 derivation using wallet's base path (e.g., m/84'/1'/0'/0/{index}) - const result = generateHDAddressBIP32( - this.masterKey, - this.chainCode, - index, - this.basePath, // Use wallet's stored base path instead of hardcoded default - isChange // Pass isChange to use correct chain (0=external, 1=change) - ); - - return { - privateKey: result.privateKey, - publicKey: result.publicKey, - l1Address: result.address, - index: result.index, - path: result.path, - isChange, - }; - } else if (this.derivationMode === "legacy_hmac" && this.chainCode) { - // Legacy Sphere HMAC: HMAC-SHA512(chainCode, masterKey || index) - // Note: Legacy mode doesn't support change addresses, but we track the flag anyway - const result = generateHDAddress( - this.masterKey, - this.chainCode, - index - ); - - return { - privateKey: result.privateKey, - publicKey: result.publicKey, - l1Address: result.address, - index: result.index, - path: result.path, - isChange, - }; - } else { - // WIF HMAC derivation: HMAC-SHA512(masterKey, "m/44'/0'/{index}'") - // Note: WIF mode doesn't support change addresses, but we track the flag anyway - const result = generateAddressFromMasterKey(this.masterKey, index); - - return { - privateKey: result.privateKey, - publicKey: result.publicKey, - l1Address: result.address, - index: result.index, - path: result.path, - isChange, - }; - } - } - - /** - * Get the default address path (first external address) - * Returns path like "m/44'/0'/0'/0/0" based on wallet's base path - * - * Use this instead of hardcoding paths or using addresses[0] - */ - getDefaultAddressPath(): string { - return `${this.basePath}/0/0`; - } - - /** - * Derive private key at a custom path - */ - deriveKeyAtPath(path: string): { privateKey: string; chainCode: string } { - if (!this.masterKey || !this.chainCode) { - throw new Error("Wallet not initialized"); - } - - return deriveKeyAtPath(this.masterKey, this.chainCode, path); - } - - /** - * Get the mnemonic phrase (for backup purposes) - * Returns null if wallet was imported from file - */ - getMnemonic(): string | null { - return this.mnemonic; - } - - /** - * Get the master private key in hex format - * Used by L1 wallet for signing transactions - */ - getMasterKeyHex(): string | null { - return this.masterKey; - } - - /** - * Get the chain code in hex format - * Used by L1 wallet for BIP32 derivation - */ - getChainCodeHex(): string | null { - return this.chainCode; - } - - /** - * Get the base derivation path (e.g., "m/84'/1'/0'" from wallet.dat descriptor) - * Used for BIP32 address derivation - */ - getBasePath(): string { - return this.basePath; - } - - /** - * Get wallet info - */ - getWalletInfo(): WalletInfo { - let address0: string | null = null; - try { - if (this.masterKey) { - address0 = this.deriveAddressFromPath(this.getDefaultAddressPath()).l1Address; - } - } catch { - // Ignore errors - } - - return { - source: this.source, - hasMnemonic: this.mnemonic !== null, - hasChainCode: this.chainCode !== null, - derivationMode: this.derivationMode, - address0, - }; - } - - /** - * Check if wallet is initialized - */ - isInitialized(): boolean { - // For BIP32 and legacy_hmac modes, we need both master key and chain code - // For WIF HMAC mode, we only need master key - if (this.derivationMode === "bip32" || this.derivationMode === "legacy_hmac") { - return this.masterKey !== null && this.chainCode !== null; - } - return this.masterKey !== null; - } - - /** - * Get current derivation mode - */ - getDerivationMode(): DerivationMode { - return this.derivationMode; - } - - /** - * Export wallet to txt format (compatible with webwallet) - */ - exportToTxt(): string { - if (!this.masterKey || !this.chainCode) { - throw new Error("Wallet not initialized"); - } - - const address0 = this.deriveAddressFromPath(this.getDefaultAddressPath()); - - let output = `# Alpha Wallet Export\n`; - output += `# Generated: ${new Date().toISOString()}\n`; - output += `#\n`; - output += `# WARNING: Keep this file secure! Anyone with this data can access your funds.\n`; - output += `#\n\n`; - output += `Master Private Key: ${this.masterKey}\n`; - output += `Chain Code: ${this.chainCode}\n`; - output += `\n`; - output += `# First address (${this.getDefaultAddressPath()}):\n`; - output += `Address: ${address0.l1Address}\n`; - output += `Public Key: ${address0.publicKey}\n`; - - if (this.mnemonic) { - output += `\n# Recovery Phrase (12 words):\n`; - output += `Mnemonic: ${this.mnemonic}\n`; - } - - return output; - } - - /** - * Export wallet to JSON format (new standard) - * - * This is the recommended export format as it: - * - Preserves mnemonic phrase if available - * - Supports encryption with password - * - Includes verification address - * - Maintains source and derivation mode information - */ - exportToJSON(options: WalletJSONExportOptions = {}): WalletJSON { - if (!this.masterKey) { - throw new Error("Wallet not initialized"); - } - - // Build addresses array for export using path-based derivation - const address0 = this.deriveAddressFromPath(this.getDefaultAddressPath()); - const addresses = [{ - address: address0.l1Address, - publicKey: address0.publicKey, - path: address0.path, - index: address0.index, - }]; - - // Add more addresses if requested - const addressCount = options.addressCount || 1; - for (let i = 1; i < addressCount; i++) { - const path = `${this.basePath}/0/${i}`; // External addresses only for export - const addr = this.deriveAddressFromPath(path); - addresses.push({ - address: addr.l1Address, - publicKey: addr.publicKey, - path: addr.path, - index: addr.index, - }); - } - - // Build wallet object for export - const wallet = { - masterPrivateKey: this.masterKey, - chainCode: this.chainCode || undefined, - masterChainCode: this.chainCode || undefined, - addresses, - isBIP32: this.derivationMode === "bip32", - isImportedAlphaWallet: this.source === "file", - descriptorPath: this.derivationMode === "bip32" ? this.basePath.replace(/^m\//, '') : null, - }; - - return exportWalletToJSON({ - wallet, - mnemonic: this.mnemonic || undefined, - importSource: this.source === "file" ? "file" : undefined, - options, - }); - } - - /** - * Download wallet as JSON file - */ - downloadJSON(filename?: string, options: WalletJSONExportOptions = {}): void { - const json = this.exportToJSON(options); - const defaultFilename = this.mnemonic - ? "alpha_wallet_mnemonic_backup.json" - : "alpha_wallet_backup.json"; - downloadWalletJSON(json, filename || defaultFilename); - } - - /** - * Import wallet from JSON content - * Returns the mnemonic if present in the JSON (for recovery purposes) - */ - async importFromJSON( - jsonContent: string, - password?: string - ): Promise<{ success: boolean; mnemonic?: string; error?: string }> { - const result = await importWalletFromJSON(jsonContent, password); - - if (!result.success || !result.wallet) { - return { success: false, error: result.error }; - } - - // If mnemonic is available (either plaintext or decrypted), use createFromMnemonic - if (result.mnemonic) { - try { - await this.createFromMnemonic(result.mnemonic); - console.log("🔐 Wallet restored from JSON with mnemonic"); - return { success: true, mnemonic: result.mnemonic }; - } catch (e) { - return { - success: false, - error: `Failed to restore from mnemonic: ${e instanceof Error ? e.message : String(e)}`, - }; - } - } - - // Otherwise, import as file-based wallet (no mnemonic available) - const chainCode = result.wallet.chainCode || result.wallet.masterChainCode || null; - const mode = result.derivationMode || (chainCode ? "bip32" : "wif_hmac"); - // Get base path from wallet.dat descriptor (e.g., "84'/1'/0'" -> "m/84'/1'/0'") - const basePath = result.wallet.descriptorPath - ? `m/${result.wallet.descriptorPath}` - : undefined; - - await this.importWithMode(result.wallet.masterPrivateKey, chainCode, mode, basePath); - console.log(`🔐 Wallet restored from JSON (source: ${result.source}, mode: ${mode}, basePath: ${basePath || DEFAULT_BASE_PATH})`); - - return { success: true }; - } - - /** - * Clear wallet data - */ - clear(): void { - this.mnemonic = null; - this.masterKey = null; - this.chainCode = null; - this.derivationMode = "bip32"; - this.basePath = DEFAULT_BASE_PATH; - this.source = "unknown"; - - // Reset initialization state - this.hasInitialized = false; - this.isInitializing = false; - this.initializePromise = null; - - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_MNEMONIC); - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_MASTER); - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_CHAINCODE); - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_SOURCE); - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_DERIVATION_MODE); - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_BASE_PATH); - - console.log("🔐 Unified wallet cleared"); - } - - /** - * Reset the singleton instance - * Call this after clear() to ensure fresh state on next getInstance() - */ - static resetInstance(): void { - UnifiedKeyManager.instance = null; - console.log("🔐 UnifiedKeyManager instance reset"); - } - - /** - * Clear ALL wallet data from localStorage and reset singleton - * Use this before creating/importing a new wallet to ensure clean slate - * This is a static method that can be called without an instance - * - * @param fullCleanup - If true (default), deletes ALL data (use for logout). - * If false, preserves onboarding flags (use during onboarding). - */ - static clearAll(fullCleanup: boolean = true): void { - console.log("🔐 Clearing all wallet data..."); - - // Clear ALL sphere_* keys from localStorage in one go - clearAllSphereData(fullCleanup); - - // Reset singleton instance (in-memory state) - if (UnifiedKeyManager.instance) { - UnifiedKeyManager.instance.mnemonic = null; - UnifiedKeyManager.instance.masterKey = null; - UnifiedKeyManager.instance.chainCode = null; - UnifiedKeyManager.instance.derivationMode = "bip32"; - UnifiedKeyManager.instance.basePath = DEFAULT_BASE_PATH; - UnifiedKeyManager.instance.source = "unknown"; - UnifiedKeyManager.instance.hasInitialized = false; - UnifiedKeyManager.instance.isInitializing = false; - UnifiedKeyManager.instance.initializePromise = null; - } - UnifiedKeyManager.instance = null; - - console.log("🔐 All wallet data cleared"); - } - - // ========================================== - // Private methods - // ========================================== - - private saveToStorage(): void { - if (this.mnemonic) { - const encryptedMnemonic = this.encrypt(this.mnemonic); - localStorage.setItem(STORAGE_KEYS.UNIFIED_WALLET_MNEMONIC, encryptedMnemonic); - } else if (this.masterKey) { - const encryptedMaster = this.encrypt(this.masterKey); - localStorage.setItem(STORAGE_KEYS.UNIFIED_WALLET_MASTER, encryptedMaster); - } - - if (this.chainCode) { - // Chain code is not secret (derived from public data in BIP32) - // but we store it for convenience - localStorage.setItem(STORAGE_KEYS.UNIFIED_WALLET_CHAINCODE, this.chainCode); - } else { - // Remove chain code if not present (WIF HMAC mode) - localStorage.removeItem(STORAGE_KEYS.UNIFIED_WALLET_CHAINCODE); - } - - localStorage.setItem(STORAGE_KEYS.UNIFIED_WALLET_SOURCE, this.source); - localStorage.setItem(STORAGE_KEYS.UNIFIED_WALLET_DERIVATION_MODE, this.derivationMode); - localStorage.setItem(STORAGE_KEYS.UNIFIED_WALLET_BASE_PATH, this.basePath); - } - - private encrypt(data: string): string { - return CryptoJS.AES.encrypt(data, this.sessionKey).toString(); - } - - private decrypt(encrypted: string): string | null { - try { - const bytes = CryptoJS.AES.decrypt(encrypted, this.sessionKey); - const decrypted = bytes.toString(CryptoJS.enc.Utf8); - if (!decrypted) { - console.error("Decryption failed: empty result. Possible session key mismatch."); - return null; - } - return decrypted; - } catch (error) { - console.error("Decryption error:", error); - return null; - } - } -} diff --git a/src/components/wallet/shared/utils/cryptoUtils.ts b/src/components/wallet/shared/utils/cryptoUtils.ts deleted file mode 100644 index a2005584..00000000 --- a/src/components/wallet/shared/utils/cryptoUtils.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Shared cryptographic utilities for wallet operations - * Consolidates duplicate HASH160 and address generation logic - */ - -import CryptoJS from "crypto-js"; -import elliptic from "elliptic"; -import { createBech32 } from "../../L1/sdk/bech32"; - -const ec = new elliptic.ec("secp256k1"); - -/** - * Compute HASH160 (SHA256 -> RIPEMD160) of a public key - * @param publicKey - Compressed public key as hex string - * @returns HASH160 as hex string - */ -export function computeHash160(publicKey: string): string { - const sha = CryptoJS.SHA256(CryptoJS.enc.Hex.parse(publicKey)).toString(); - const hash160 = CryptoJS.RIPEMD160(CryptoJS.enc.Hex.parse(sha)).toString(); - return hash160; -} - -/** - * Convert HASH160 hex string to Uint8Array (witness program bytes) - * @param hash160 - HASH160 as hex string - * @returns 20-byte Uint8Array - */ -export function hash160ToBytes(hash160: string): Uint8Array { - return Uint8Array.from(hash160.match(/../g)!.map((x) => parseInt(x, 16))); -} - -/** - * Generate bech32 address from public key - * @param publicKey - Compressed public key as hex string - * @param prefix - Address prefix (default: "alpha") - * @param witnessVersion - Witness version (default: 0) - * @returns Bech32 encoded address - */ -export function publicKeyToAddress( - publicKey: string, - prefix: string = "alpha", - witnessVersion: number = 0 -): string { - const hash160 = computeHash160(publicKey); - const programBytes = hash160ToBytes(hash160); - return createBech32(prefix, witnessVersion, programBytes); -} - -/** - * Generate address info from a private key - * @param privateKey - Private key as hex string - * @returns Object with address, publicKey - */ -export function privateKeyToAddressInfo(privateKey: string): { - address: string; - publicKey: string; -} { - const keyPair = ec.keyFromPrivate(privateKey); - const publicKey = keyPair.getPublic(true, "hex"); - const address = publicKeyToAddress(publicKey); - return { address, publicKey }; -} - -/** - * Generate full address info from private key with index and path - * @param privateKey - Private key as hex string - * @param index - Address index - * @param path - Derivation path - * @returns Full address info object - */ -export function generateAddressInfo( - privateKey: string, - index: number, - path: string -): { - address: string; - privateKey: string; - publicKey: string; - index: number; - path: string; -} { - const { address, publicKey } = privateKeyToAddressInfo(privateKey); - return { - address, - privateKey, - publicKey, - index, - path, - }; -} - -// Re-export elliptic instance for use in other modules -export { ec }; diff --git a/src/components/wallet/shared/utils/walletFileParser.ts b/src/components/wallet/shared/utils/walletFileParser.ts index 0208c962..5c8e8e13 100644 --- a/src/components/wallet/shared/utils/walletFileParser.ts +++ b/src/components/wallet/shared/utils/walletFileParser.ts @@ -3,10 +3,17 @@ * Provides helpers for parsing and validating wallet file formats */ -import { isJSONWalletFormat } from "../../L1/sdk"; - export type WalletFileType = "dat" | "json" | "txt" | "mnemonic" | "unknown"; +function isJSONWalletFormat(content: string): boolean { + try { + const json = JSON.parse(content); + return json.version === "1.0" && (json.masterPrivateKey || json.encrypted); + } catch { + return false; + } +} + export interface ParsedWalletInfo { fileType: WalletFileType; isEncrypted: boolean; diff --git a/src/config/ipfs.config.ts b/src/config/ipfs.config.ts deleted file mode 100644 index 81d66b7b..00000000 --- a/src/config/ipfs.config.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * IPFS/Helia Configuration - * Custom bootstrap peers with SSL/non-SSL support - * - * Browser clients connect via WebSocket (ws:// or wss://) - * Your IPFS nodes need WebSocket enabled on port 4002 - * - * Server setup required on each IPFS host: - * docker exec ipfs-kubo ipfs config --json Swarm.Transports.Network.Websocket true - * docker exec ipfs-kubo ipfs config --json Addresses.Swarm '["/ip4/0.0.0.0/tcp/4001","/ip4/0.0.0.0/tcp/4002/ws"]' - * docker restart ipfs-kubo - */ - -interface IpfsPeer { - host: string; // DNS hostname (e.g., unicity-ipfs1.dyndns.org) - peerId: string; // Get from: docker exec ipfs-kubo ipfs id -f='' - wsPort: number; // WebSocket port (4002) - wssPort?: number; // Secure WebSocket port (4003, via nginx) -} - -/** - * Your IPFS nodes - * UPDATE peer IDs after running `docker exec ipfs-kubo ipfs id -f=''` on each host - */ -export const CUSTOM_PEERS: IpfsPeer[] = [ - { host: "unicity-ipfs1.dyndns.org", peerId: "12D3KooWDKJqEMAhH4nsSSiKtK1VLcas5coUqSPZAfbWbZpxtL4u", wsPort: 4002, wssPort: 4003 }, - // TEMPORARILY DISABLED: ipfs2-5 for debugging IPNS propagation issues - // { host: "unicity-ipfs2.dyndns.org", peerId: "12D3KooWLNi5NDPPHbrfJakAQqwBqymYTTwMQXQKEWuCrJNDdmfh", wsPort: 4002, wssPort: 4003 }, - // { host: "unicity-ipfs3.dyndns.org", peerId: "12D3KooWQ4aujVE4ShLjdusNZBdffq3TbzrwT2DuWZY9H1Gxhwn6", wsPort: 4002, wssPort: 4003 }, - // { host: "unicity-ipfs4.dyndns.org", peerId: "12D3KooWJ1ByPfUzUrpYvgxKU8NZrR8i6PU1tUgMEbQX9Hh2DEn1", wsPort: 4002, wssPort: 4003 }, - // { host: "unicity-ipfs5.dyndns.org", peerId: "12D3KooWB1MdZZGHN5B8TvWXntbycfe7Cjcz7n6eZ9eykZadvmDv", wsPort: 4002, wssPort: 4003 }, -]; - -/** - * Default public IPFS bootstrap peers (fallback) - */ -export const DEFAULT_BOOTSTRAP_PEERS = [ - "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", - "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", - "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", - "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8", - "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", -]; - -/** - * Check if a peer ID is configured (not a placeholder) - */ -function isPeerConfigured(peerId: string): boolean { - return Boolean( - peerId && - !peerId.startsWith("<") && - peerId.length > 10 && - (peerId.startsWith("12D3KooW") || peerId.startsWith("Qm")) - ); -} - -/** - * Generate multiaddrs based on current page protocol - * - HTTPS page -> use wss:// (secure WebSocket) - * - HTTP/file page -> use ws:// (plain WebSocket) - */ -export function getBootstrapPeers(): string[] { - const isSecure = - typeof window !== "undefined" && window.location.protocol === "https:"; - - const customPeers = CUSTOM_PEERS.filter((p) => - isPeerConfigured(p.peerId) - ).map((peer) => { - if (isSecure && peer.wssPort) { - // Secure WebSocket for HTTPS pages - return `/dns4/${peer.host}/tcp/${peer.wssPort}/wss/p2p/${peer.peerId}`; - } else { - // Plain WebSocket for HTTP/file pages - return `/dns4/${peer.host}/tcp/${peer.wsPort}/ws/p2p/${peer.peerId}`; - } - }); - - // Custom peers first (prioritized), then 1 emergency fallback - // We limit fallback to reduce traffic - full list was causing excessive connections - const fallbackPeer = DEFAULT_BOOTSTRAP_PEERS[0]; // Just one fallback - return [...customPeers, fallbackPeer]; -} - -/** - * Get only custom configured peers (for diagnostics) - */ -export function getConfiguredCustomPeers(): IpfsPeer[] { - return CUSTOM_PEERS.filter((p) => isPeerConfigured(p.peerId)); -} - -/** - * IPFS configuration options - */ -export const IPFS_CONFIG = { - connectionTimeout: 10000, // 10s timeout per peer - maxConnections: 10, // Reduced from 50 - we only connect to Unicity peers + 1 fallback - enableAutoSync: true, - syncIntervalMs: 5 * 60 * 1000, // 5 minutes -}; - -/** - * IPNS resolution configuration - * Controls progressive multi-peer IPNS record collection - * - * Two resolution methods are used in parallel (racing): - * 1. Gateway path (/ipns/{name}?format=dag-json) - Fast (~30ms), returns content directly - * 2. Routing API (/api/v0/routing/get) - Slower (~5s), returns IPNS record with sequence number - * - * The gateway path is preferred for speed, while the routing API provides - * authoritative sequence numbers for version tracking. - */ -export const IPNS_RESOLUTION_CONFIG = { - /** Wait this long for initial responses before selecting best record */ - initialTimeoutMs: 3000, // Reduced from 10s - dead nodes fail fast (~100ms) - /** Maximum wait for all gateway responses (late arrivals handled separately) */ - maxWaitMs: 15000, // Reduced from 30s - faster overall resolution - /** Minimum polling interval for background IPNS re-fetch (active tab) */ - pollingIntervalMinMs: 45000, - /** Maximum polling interval (jitter applied between min and max, active tab) */ - pollingIntervalMaxMs: 75000, - /** Minimum polling interval when tab is inactive/hidden (4 minutes) */ - inactivePollingIntervalMinMs: 240000, - /** Maximum polling interval when tab is inactive/hidden (4.5 minutes with jitter) */ - inactivePollingIntervalMaxMs: 270000, - /** Per-gateway request timeout (for routing API) */ - perGatewayTimeoutMs: 5000, // Reduced from 25s - dead nodes timeout quickly - /** Gateway path resolution timeout (fast path) */ - gatewayPathTimeoutMs: 3000, // Reduced from 5s - faster path timeout -}; - -/** - * TODO: IPNS Archiving Service Enhancement - * Location: /home/vrogojin/ipfs-storage (kubo docker image) - * - * Implement an IPNS archiving service that: - * 1. Archives N previous IPNS record versions (configurable, default 10) - * 2. API endpoint: GET /api/v0/ipns/archive/{name} - * Returns: { records: [{ cid, sequence, timestamp, signature }] } - * 3. Enables recovery of tokens lost due to race conditions where empty - * inventory overwrites populated one - * 4. Store in MongoDB alongside current IPNS implementation - * - * Recovery scenario: - * - Device A: tokens, publishes seq=11 - * - Device B: empty wallet, IPNS resolution times out - * - Device B: publishes seq=1, overwrites Device A data - * - Archive service: allows recovery of seq=11 record - */ - -/** - * Get the backend gateway URL for API calls - * Uses HTTPS on secure pages, HTTP otherwise - */ -export function getBackendGatewayUrl(): string | null { - const configured = CUSTOM_PEERS.find((p) => isPeerConfigured(p.peerId)); - if (!configured) return null; - - const isSecure = - typeof window !== "undefined" && window.location.protocol === "https:"; - - // Use HTTPS gateway (port 443) for secure pages - return isSecure - ? `https://${configured.host}` - : `http://${configured.host}:9080`; -} - -/** - * Get all configured backend gateway URLs for multi-node upload - * Returns URLs for all IPFS nodes that have valid peer IDs configured - */ -export function getAllBackendGatewayUrls(): string[] { - const isSecure = - typeof window !== "undefined" && window.location.protocol === "https:"; - - return CUSTOM_PEERS.filter((p) => isPeerConfigured(p.peerId)).map((peer) => - isSecure ? `https://${peer.host}` : `http://${peer.host}:9080` - ); -} - -/** - * Get the primary backend peer ID for direct connection maintenance - */ -export function getBackendPeerId(): string | null { - const configured = CUSTOM_PEERS.find((p) => isPeerConfigured(p.peerId)); - return configured?.peerId || null; -} diff --git a/src/config/nostr.config.ts b/src/config/nostr.config.ts deleted file mode 100644 index dbc01079..00000000 --- a/src/config/nostr.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Nostr Configuration - * - * Configuration for Nostr relay connections used for DMs and token transfers. - */ - -/** - * Parse a comma-separated list of relay URLs from an environment variable. - * Filters out empty strings and trims whitespace. - */ -function parseRelayUrls(envValue: string | undefined, defaults: string[]): string[] { - if (!envValue) { - return defaults; - } - - const parsed = envValue - .split(',') - .map((url) => url.trim()) - .filter((url) => url.length > 0); - - return parsed.length > 0 ? parsed : defaults; -} - -/** - * Default relay URLs for DMs and token transfers - */ -const DEFAULT_NOSTR_RELAYS = ['wss://nostr-relay.testnet.unicity.network']; - -export const NOSTR_CONFIG = { - /** - * WebSocket URLs for Nostr relays (DMs, token transfers, payment requests) - * Override with VITE_NOSTR_RELAYS environment variable (comma-separated) - * - * Example: VITE_NOSTR_RELAYS=wss://relay1.example.com,wss://relay2.example.com - */ - RELAYS: parseRelayUrls(import.meta.env.VITE_NOSTR_RELAYS, DEFAULT_NOSTR_RELAYS), - - /** - * Maximum number of processed event IDs to store (for deduplication) - */ - MAX_PROCESSED_EVENTS: 100, -} as const; diff --git a/src/config/nostrPin.config.ts b/src/config/nostrPin.config.ts deleted file mode 100644 index 210e06ee..00000000 --- a/src/config/nostrPin.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Nostr IPFS Pin Publisher Configuration - * - * When enabled, publishes CID announcements to Nostr relays - * after successful IPFS storage. Pin services subscribed to - * these relays will automatically pin the announced content. - */ - -export const NOSTR_PIN_CONFIG = { - /** Enable/disable automatic CID publishing to Nostr */ - enabled: true, - - /** NIP-78 app-specific data event kind */ - eventKind: 30078, - - /** Distinguisher tag for IPFS pin requests */ - dTag: "ipfs-pin", - - /** Log publishing activity to console */ - debug: true, -}; diff --git a/src/config/queryKeys.ts b/src/config/queryKeys.ts deleted file mode 100644 index 5aeaf7b8..00000000 --- a/src/config/queryKeys.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * TanStack Query Keys Configuration - * - * Centralized query keys for all TanStack Query operations. - * Use these keys when defining queries or invalidating them from services. - */ - -export const QUERY_KEYS = { - // Wallet identity and authentication - IDENTITY: ['wallet', 'identity'], - NAMETAG: ['wallet', 'nametag'], - - // Token data - TOKENS: ['wallet', 'tokens'], - AGGREGATED: ['wallet', 'aggregated'], - TRANSACTION_HISTORY: ['wallet', 'transaction-history'], - - // Market data - PRICES: ['market', 'prices'], - REGISTRY: ['market', 'registry'], - - // L1 wallet - L1_WALLET: ['l1', 'wallet'], - L1_BALANCE: ['l1', 'balance'], - L1_VESTING: ['l1', 'vesting'], -} as const; diff --git a/src/config/storageKeys.ts b/src/config/storageKeys.ts index fafc0601..fb8a8e79 100644 --- a/src/config/storageKeys.ts +++ b/src/config/storageKeys.ts @@ -1,7 +1,7 @@ /** * Storage Keys Configuration * - * Centralized configuration for all localStorage and sessionStorage keys + * Centralized configuration for all localStorage keys * used throughout the Sphere application. * * All keys use the `sphere_` prefix for: @@ -10,209 +10,41 @@ * - Avoiding conflicts with other apps */ -// ============================================================================ -// STATIC STORAGE KEYS -// ============================================================================ - export const STORAGE_KEYS = { - // ============================================================================ - // THEME & UI STATE - // ============================================================================ - // Theme preference (light/dark) THEME: 'sphere_theme', - // Active wallet layer (L1/L3) - WALLET_ACTIVE_LAYER: 'sphere_wallet_active_layer', - // Welcome screen acceptance flag WELCOME_ACCEPTED: 'sphere_welcome_accepted', - // ============================================================================ - // ONBOARDING & AUTHENTICATION - // ============================================================================ - - // Flag indicating user has completed onboarding and is authenticated - AUTHENTICATED: 'sphere_authenticated', - - // Flag indicating onboarding is currently in progress (prevents auto-sync) - ONBOARDING_IN_PROGRESS: 'sphere_onboarding_in_progress', - - // Flag indicating onboarding steps are complete (before final auth) - ONBOARDING_COMPLETE: 'sphere_onboarding_complete', - - // Flag indicating address creation is in progress via modal (prevents auto-sync) - ADDRESS_CREATION_IN_PROGRESS: 'sphere_address_creation_in_progress', - - // ============================================================================ - // UNIFIED KEY MANAGER (Core Wallet Keys - Encrypted) - // ============================================================================ - - // AES-256 encrypted BIP39 mnemonic (12 words) - UNIFIED_WALLET_MNEMONIC: 'sphere_wallet_mnemonic', - - // AES-256 encrypted master private key (hex) - UNIFIED_WALLET_MASTER: 'sphere_wallet_master', - - // Chain code for BIP32 derivation - UNIFIED_WALLET_CHAINCODE: 'sphere_wallet_chaincode', - - // Source type: "mnemonic" | "file" | "unknown" - UNIFIED_WALLET_SOURCE: 'sphere_wallet_source', - - // Derivation mode: "bip32" | "legacy_hmac" | "wif_hmac" - UNIFIED_WALLET_DERIVATION_MODE: 'sphere_wallet_derivation_mode', - - // Base BIP32 path (default "m/44'/0'/0'") - UNIFIED_WALLET_BASE_PATH: 'sphere_wallet_base_path', - - // ============================================================================ - // ADDRESS SELECTION - // ============================================================================ - - // BIP32 derivation path for selected L3 address - L3_SELECTED_ADDRESS_PATH: 'sphere_l3_selected_address_path', - - // LEGACY: L3 selected address index (migrated to path-based) - L3_SELECTED_ADDRESS_INDEX_LEGACY: 'sphere_l3_selected_address_index', - - // Legacy encrypted seed storage (for mnemonic) - ENCRYPTED_SEED: 'sphere_encrypted_seed', - - // ============================================================================ - // WALLET DATA - // ============================================================================ - // Transaction history TRANSACTION_HISTORY: 'sphere_transaction_history', - // LEGACY: Old single-wallet format (being migrated) - WALLET_DATA_LEGACY: 'sphere_wallet_data', - - // Main L1 wallet - WALLET_MAIN: 'sphere_wallet_main', - - // ============================================================================ - // TOKEN OPERATIONS (Outbox) - // ============================================================================ - - // Pending token transfers - OUTBOX: 'sphere_outbox', - - // Token split groups for pending transfers - OUTBOX_SPLIT_GROUPS: 'sphere_outbox_split_groups', - - // ============================================================================ - // CHAT (User-to-User DMs) - // ============================================================================ - - // Chat conversations list + // Chat (User-to-User DMs) CHAT_CONVERSATIONS: 'sphere_chat_conversations', - - // Chat messages CHAT_MESSAGES: 'sphere_chat_messages', - // ============================================================================ - // GROUP CHAT (NIP-29) - // ============================================================================ - - // Joined groups list + // Group Chat (NIP-29) GROUP_CHAT_GROUPS: 'sphere_group_chat_groups', - - // Group messages GROUP_CHAT_MESSAGES: 'sphere_group_chat_messages', - - // Group members cache GROUP_CHAT_MEMBERS: 'sphere_group_chat_members', - - // Group chat relay URL GROUP_CHAT_RELAY_URL: 'sphere_group_chat_relay_url', - - // Processed group event IDs (for deduplication) GROUP_CHAT_PROCESSED_EVENTS: 'sphere_group_chat_processed_events', - // ============================================================================ - // CHAT UI STATE (Persistence across navigation) - // ============================================================================ - - // Chat mode (global/dm) + // Chat UI State CHAT_MODE: 'sphere_chat_mode', - - // Selected group ID (when in global mode) CHAT_SELECTED_GROUP: 'sphere_chat_selected_group', - - // Selected DM conversation pubkey (when in dm mode) CHAT_SELECTED_DM: 'sphere_chat_selected_dm', - // ============================================================================ - // AGENT CHAT SESSIONS - // ============================================================================ - - // Agent chat sessions metadata + // Agent Chat Sessions AGENT_CHAT_SESSIONS: 'sphere_agent_chat_sessions', - // Agent chat tombstones (deleted sessions tracking) - AGENT_CHAT_TOMBSTONES: 'sphere_agent_chat_tombstones', - - // ============================================================================ - // BACKUP & SYNC - // ============================================================================ - - // Token backup timestamp - TOKEN_BACKUP_TIMESTAMP: 'sphere_token_backup_timestamp', - - // Spent token state cache (persisted SPENT results) - SPENT_TOKEN_CACHE: 'sphere_spent_token_cache', - - // Last successful IPFS sync timestamp - LAST_IPFS_SYNC_SUCCESS: 'sphere_last_ipfs_sync_success', - - // Encrypted token backup (AES-256-GCM, Base64) - ENCRYPTED_TOKEN_BACKUP: 'sphere_encrypted_token_backup', - - // ============================================================================ - // REGISTRY CACHE - // ============================================================================ - - // Unicity IDs cache (from GitHub) - UNICITY_IDS_CACHE: 'sphere_unicity_ids_cache', - - // Unicity IDs cache timestamp - UNICITY_IDS_TIMESTAMP: 'sphere_unicity_ids_timestamp', - - // ============================================================================ - // NOSTR SERVICE - // ============================================================================ - - // Last Nostr sync timestamp - NOSTR_LAST_SYNC: 'sphere_nostr_last_sync', - - // Processed Nostr event IDs - NOSTR_PROCESSED_EVENTS: 'sphere_nostr_processed_events', - - // ============================================================================ - // DEV SETTINGS - // ============================================================================ - - // Custom aggregator URL (dev mode) + // Dev Settings DEV_AGGREGATOR_URL: 'sphere_dev_aggregator_url', - - // Trust base verification skip flag (dev mode) DEV_SKIP_TRUST_BASE: 'sphere_dev_skip_trust_base', } as const; -// ============================================================================ -// DYNAMIC KEY GENERATORS -// For keys that include dynamic parts (like addresses, session IDs, etc.) -// ============================================================================ - export const STORAGE_KEY_GENERATORS = { - // Per-address wallet data: `sphere_wallet_${address}` - walletByAddress: (address: string) => `sphere_wallet_${address}` as const, - - // L1 wallet by key: `sphere_l1_wallet_${key}` - l1WalletByKey: (key: string) => `sphere_l1_wallet_${key}` as const, - // Agent memory: `sphere_agent_memory:${userId}:${activityId}` agentMemory: (userId: string, activityId: string) => `sphere_agent_memory:${userId}:${activityId}` as const, @@ -220,115 +52,25 @@ export const STORAGE_KEY_GENERATORS = { // Agent chat messages per session: `sphere_agent_chat_messages:${sessionId}` agentChatMessages: (sessionId: string) => `sphere_agent_chat_messages:${sessionId}` as const, - - // IPFS version tracking: `sphere_ipfs_version_${ipnsName}` - ipfsVersion: (ipnsName: string) => `sphere_ipfs_version_${ipnsName}` as const, - - // IPFS last CID: `sphere_ipfs_last_cid_${ipnsName}` - ipfsLastCid: (ipnsName: string) => `sphere_ipfs_last_cid_${ipnsName}` as const, - - // IPFS pending IPNS: `sphere_ipfs_pending_ipns_${ipnsName}` - ipfsPendingIpns: (ipnsName: string) => `sphere_ipfs_pending_ipns_${ipnsName}` as const, - - // IPFS last sequence: `sphere_ipfs_last_seq_${ipnsName}` - ipfsLastSeq: (ipnsName: string) => `sphere_ipfs_last_seq_${ipnsName}` as const, - - // IPFS chat version: `sphere_ipfs_chat_version_${ipnsName}` - ipfsChatVersion: (ipnsName: string) => `sphere_ipfs_chat_version_${ipnsName}` as const, - - // IPFS chat CID: `sphere_ipfs_chat_cid_${ipnsName}` - ipfsChatCid: (ipnsName: string) => `sphere_ipfs_chat_cid_${ipnsName}` as const, - - // IPFS chat sequence: `sphere_ipfs_chat_seq_${ipnsName}` - ipfsChatSeq: (ipnsName: string) => `sphere_ipfs_chat_seq_${ipnsName}` as const, - - // Token list hash for spent check optimization: `sphere_token_hash_${address}` - tokenListHash: (address: string) => `sphere_token_hash_${address}` as const, -} as const; - -// ============================================================================ -// KEY PREFIXES -// For identifying groups of keys (useful for cleanup/migration) -// ============================================================================ - -export const STORAGE_KEY_PREFIXES = { - // Main app prefix - ALL sphere keys start with this - APP: 'sphere_', - - // Per-address wallet data prefix - WALLET_ADDRESS: 'sphere_wallet_', - - // L1 wallet prefix - L1_WALLET: 'sphere_l1_wallet_', - - // Agent memory prefix - AGENT_MEMORY: 'sphere_agent_memory:', - - // Agent chat messages prefix - AGENT_CHAT_MESSAGES: 'sphere_agent_chat_messages:', - - // IPFS version prefix - IPFS_VERSION: 'sphere_ipfs_version_', - - // IPFS CID prefix - IPFS_LAST_CID: 'sphere_ipfs_last_cid_', - - // IPFS pending IPNS prefix - IPFS_PENDING_IPNS: 'sphere_ipfs_pending_ipns_', - - // IPFS sequence prefix - IPFS_LAST_SEQ: 'sphere_ipfs_last_seq_', - - // IPNS sequence number prefix - IPNS_SEQ: 'sphere_ipns_seq_', - - // IPFS chat version prefix - IPFS_CHAT_VERSION: 'sphere_ipfs_chat_version_', - - // IPFS chat CID prefix - IPFS_CHAT_CID: 'sphere_ipfs_chat_cid_', - - // IPFS chat sequence prefix - IPFS_CHAT_SEQ: 'sphere_ipfs_chat_seq_', } as const; -// ============================================================================ -// CLEANUP UTILITY -// ============================================================================ +const STORAGE_PREFIX = 'sphere_'; /** * Clear all Sphere app data from localStorage. - * - * @param fullCleanup - If true (default), deletes ALL sphere_* keys (use for logout). - * If false, preserves onboarding flags (use during wallet create/import in onboarding). */ -export function clearAllSphereData(fullCleanup: boolean = true): void { +export function clearAllSphereData(): void { const keysToRemove: string[] = []; - const preserveKeys: Set = fullCleanup - ? new Set() - : new Set([ - STORAGE_KEYS.AUTHENTICATED, - STORAGE_KEYS.ONBOARDING_IN_PROGRESS, - STORAGE_KEYS.ONBOARDING_COMPLETE, - STORAGE_KEYS.WELCOME_ACCEPTED, - ]); - for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && key.startsWith(STORAGE_KEY_PREFIXES.APP) && !preserveKeys.has(key)) { + if (key && key.startsWith(STORAGE_PREFIX)) { keysToRemove.push(key); } } keysToRemove.forEach(key => localStorage.removeItem(key)); - - console.log(`🧹 Cleared ${keysToRemove.length} sphere_* keys from localStorage${fullCleanup ? '' : ' (preserved onboarding flags)'}`); + console.log(`Cleared ${keysToRemove.length} sphere_* keys from localStorage`); } -// ============================================================================ -// TYPE EXPORTS -// ============================================================================ - export type StorageKey = typeof STORAGE_KEYS[keyof typeof STORAGE_KEYS]; -export type StorageKeyPrefix = typeof STORAGE_KEY_PREFIXES[keyof typeof STORAGE_KEY_PREFIXES]; diff --git a/src/hooks/useGlobalSyncStatus.ts b/src/hooks/useGlobalSyncStatus.ts index f4940322..30867d6d 100644 --- a/src/hooks/useGlobalSyncStatus.ts +++ b/src/hooks/useGlobalSyncStatus.ts @@ -1,208 +1,19 @@ /** - * useGlobalSyncStatus - Aggregates IPFS sync status from all services + * useGlobalSyncStatus - Provides sync status for UI components * - * Monitors sync status from: - * - ChatHistoryIpfsService (chat history) - * - IpfsStorageService (tokens) - * - * Used to prevent wallet deletion while sync is in progress. + * Previously aggregated IPFS sync status from multiple services. + * Now simplified since IPFS sync has been removed. + * Kept for API compatibility with LogoutConfirmModal and DeleteConfirmationModal. */ -import { useState, useEffect, useCallback } from 'react'; -import { getChatHistoryIpfsService, type SyncStep } from '../components/agents/shared/ChatHistoryIpfsService'; -import { IpfsStorageService } from '../components/wallet/L3/services/IpfsStorageService'; -import { IdentityManager } from '../components/wallet/L3/services/IdentityManager'; - -// Session key (same as useWallet.ts) -const SESSION_KEY = 'user-pin-1234'; - export interface GlobalSyncStatus { - // Individual service states - chatSyncing: boolean; - chatStep: SyncStep; - tokenSyncing: boolean; - inventorySyncing: boolean; - - // Combined state isAnySyncing: boolean; - - // Human-readable status statusMessage: string; } -// Event name for inventory sync state changes (same as useInventorySync.ts) -const INVENTORY_SYNC_STATE_EVENT = 'inventory-sync-state'; - export function useGlobalSyncStatus(): GlobalSyncStatus { - const [chatSyncing, setChatSyncing] = useState(false); - const [chatStep, setChatStep] = useState('idle'); - const [tokenSyncing, setTokenSyncing] = useState(false); - const [inventorySyncing, setInventorySyncing] = useState(false); - - // Subscribe to chat history sync status - useEffect(() => { - const chatService = getChatHistoryIpfsService(); - - // Get initial status - const updateChatStatus = () => { - const status = chatService.getStatus(); - // Consider syncing if: actively syncing, has pending sync (debounce period), or in a sync step - const isSyncing = status.isSyncing || - status.hasPendingSync || - (status.currentStep !== 'idle' && status.currentStep !== 'complete' && status.currentStep !== 'error'); - setChatSyncing(isSyncing); - setChatStep(status.currentStep); - }; - - updateChatStatus(); - - // Subscribe to changes - const unsubscribe = chatService.onStatusChange(updateChatStatus); - - return unsubscribe; - }, []); - - // Subscribe to token storage sync status (IpfsStorageService) - useEffect(() => { - const identityManager = IdentityManager.getInstance(SESSION_KEY); - const tokenService = IpfsStorageService.getInstance(identityManager); - - // Get initial status - setTokenSyncing(tokenService.isCurrentlySyncing()); - - // Listen for sync state changes via custom event - const handleSyncEvent = (e: CustomEvent) => { - if (e.detail?.type === 'sync:state-changed' && e.detail.data?.isSyncing !== undefined) { - setTokenSyncing(e.detail.data.isSyncing); - } - }; - - window.addEventListener('ipfs-storage-event', handleSyncEvent as EventListener); - - // Poll for token sync status as backup (events may be missed during initialization) - const pollInterval = setInterval(() => { - const currentSyncing = tokenService.isCurrentlySyncing(); - setTokenSyncing(currentSyncing); - }, 500); - - return () => { - window.removeEventListener('ipfs-storage-event', handleSyncEvent as EventListener); - clearInterval(pollInterval); - }; - }, []); - - // Subscribe to inventory sync status (InventorySyncService) - useEffect(() => { - const handleInventorySyncEvent = (e: CustomEvent<{ isSyncing: boolean }>) => { - setInventorySyncing(e.detail.isSyncing); - }; - - window.addEventListener(INVENTORY_SYNC_STATE_EVENT, handleInventorySyncEvent as EventListener); - - return () => { - window.removeEventListener(INVENTORY_SYNC_STATE_EVENT, handleInventorySyncEvent as EventListener); - }; - }, []); - - const isAnySyncing = chatSyncing || tokenSyncing || inventorySyncing; - - // Generate human-readable status message - const getStatusMessage = useCallback((): string => { - const parts: string[] = []; - - if (chatSyncing) { - switch (chatStep) { - case 'initializing': - parts.push('Initializing...'); - break; - case 'resolving-ipns': - parts.push('Resolving chat history...'); - break; - case 'fetching-content': - parts.push('Fetching chat history...'); - break; - case 'importing-data': - parts.push('Importing chat data...'); - break; - case 'building-data': - parts.push('Preparing chat data...'); - break; - case 'uploading': - parts.push('Uploading chat history...'); - break; - case 'publishing-ipns': - parts.push('Publishing chat to network...'); - break; - case 'idle': - case 'complete': - case 'error': - // If chatSyncing is true but step is idle/complete/error, - // it means we have a pending sync (debounce period) - parts.push('Preparing to sync chat...'); - break; - default: - parts.push('Syncing chat history...'); - } - } - - if (tokenSyncing) { - parts.push('Syncing tokens...'); - } - - if (inventorySyncing) { - parts.push('Syncing wallet data...'); - } - - if (parts.length === 0) { - return 'All data synced'; - } - - return parts.join(' '); - }, [chatSyncing, chatStep, tokenSyncing, inventorySyncing]); - return { - chatSyncing, - chatStep, - tokenSyncing, - inventorySyncing, - isAnySyncing, - statusMessage: getStatusMessage(), + isAnySyncing: false, + statusMessage: 'All data synced', }; } - -/** - * Utility function to wait for all syncs to complete - * Returns a promise that resolves when no sync is in progress - */ -export async function waitForAllSyncsToComplete(timeoutMs: number = 60000): Promise { - const chatService = getChatHistoryIpfsService(); - const identityManager = IdentityManager.getInstance(SESSION_KEY); - const tokenService = IpfsStorageService.getInstance(identityManager); - - const startTime = Date.now(); - - return new Promise((resolve) => { - const checkSync = () => { - const chatStatus = chatService.getStatus(); - const tokenSyncing = tokenService.isCurrentlySyncing(); - - // Check both active sync and pending sync (debounce period) - const chatBusy = chatStatus.isSyncing || chatStatus.hasPendingSync; - - if (!chatBusy && !tokenSyncing) { - resolve(true); - return; - } - - if (Date.now() - startTime > timeoutMs) { - resolve(false); // Timeout - return; - } - - // Check again in 500ms - setTimeout(checkSync, 500); - }; - - checkSync(); - }); -} diff --git a/src/main.tsx b/src/main.tsx index 052fbf6c..a528b1df 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -49,12 +49,6 @@ mixpanel.init('19d06212425213a4eeb34337016d0186', { api_host: 'https://api-eu.mixpanel.com', }) -// Register dev tools in development mode only -if (import.meta.env.DEV) { - import('./utils/devTools').then(({ registerDevTools }) => { - registerDevTools(); - }); -} createRoot(document.getElementById('root')!).render( diff --git a/src/repositories/OutboxRepository.ts b/src/repositories/OutboxRepository.ts deleted file mode 100644 index ea9ac4d7..00000000 --- a/src/repositories/OutboxRepository.ts +++ /dev/null @@ -1,749 +0,0 @@ -/** - * OutboxRepository - * - * Persists pending token transfers to localStorage. - * This is critical for preventing token loss - the outbox stores - * the complete transfer state (including the random salt) BEFORE - * submitting to the Unicity aggregator. - * - * The flow: - * 1. Create commitment (with random salt) - * 2. Save to outbox (localStorage) - * 3. Sync outbox to IPFS (wait for success) - * 4. Submit to aggregator (now safe - can recover from IPFS) - * 5. Get proof, send via Nostr - * 6. Mark complete, remove from outbox - */ - -import type { - OutboxEntry, - OutboxEntryStatus, - OutboxSplitGroup, - MintOutboxEntry, -} from "../components/wallet/L3/services/types/OutboxTypes"; -import { - isTerminalStatus, - isPendingStatus, - validateOutboxEntry, - validateMintOutboxEntry, - isMintRecoverable, -} from "../components/wallet/L3/services/types/OutboxTypes"; -import { STORAGE_KEYS } from "../config/storageKeys"; - -export class OutboxRepository { - private static instance: OutboxRepository; - - /** In-memory cache of outbox entries (transfers) */ - private _entries: Map = new Map(); - - /** In-memory cache of mint outbox entries */ - private _mintEntries: Map = new Map(); - - /** In-memory cache of split groups */ - private _splitGroups: Map = new Map(); - - /** Current wallet address (for namespacing if needed) */ - private _currentAddress: string | null = null; - - private constructor() { - this.loadFromStorage(); - } - - static getInstance(): OutboxRepository { - if (!OutboxRepository.instance) { - OutboxRepository.instance = new OutboxRepository(); - } - return OutboxRepository.instance; - } - - // ========================================== - // Address Management - // ========================================== - - /** - * Set the current wallet address - * Call this when wallet is loaded/changed - */ - setCurrentAddress(address: string): void { - if (this._currentAddress !== address) { - this._currentAddress = address; - this.loadFromStorage(); - } - } - - getCurrentAddress(): string | null { - return this._currentAddress; - } - - // ========================================== - // CRUD Operations - // ========================================== - - /** - * Add a new outbox entry - * @throws Error if entry with same ID already exists - */ - addEntry(entry: OutboxEntry): void { - const validation = validateOutboxEntry(entry); - if (!validation.valid) { - throw new Error(`Invalid outbox entry: ${validation.error}`); - } - - if (this._entries.has(entry.id)) { - throw new Error(`Outbox entry ${entry.id} already exists`); - } - - this._entries.set(entry.id, { ...entry }); - this.saveToStorage(); - - console.log(`📤 Outbox: Added entry ${entry.id.slice(0, 8)}... (${entry.type}, status=${entry.status})`); - } - - /** - * Update an existing outbox entry - * @returns The updated entry - */ - updateEntry(id: string, updates: Partial): OutboxEntry { - const existing = this._entries.get(id); - if (!existing) { - throw new Error(`Outbox entry ${id} not found`); - } - - const updated: OutboxEntry = { - ...existing, - ...updates, - updatedAt: Date.now(), - }; - - const validation = validateOutboxEntry(updated); - if (!validation.valid) { - throw new Error(`Invalid outbox entry after update: ${validation.error}`); - } - - this._entries.set(id, updated); - this.saveToStorage(); - - console.log(`📤 Outbox: Updated entry ${id.slice(0, 8)}... (status=${updated.status})`); - - return updated; - } - - /** - * Update just the status of an entry (convenience method) - */ - updateStatus(id: string, status: OutboxEntryStatus, error?: string): OutboxEntry { - const updates: Partial = { status }; - if (error) { - updates.lastError = error; - } - return this.updateEntry(id, updates); - } - - /** - * Remove an outbox entry - */ - removeEntry(id: string): void { - const existed = this._entries.delete(id); - if (existed) { - this.saveToStorage(); - console.log(`📤 Outbox: Removed entry ${id.slice(0, 8)}...`); - } - } - - /** - * Get an outbox entry by ID - */ - getEntry(id: string): OutboxEntry | null { - const entry = this._entries.get(id); - return entry ? { ...entry } : null; - } - - /** - * Check if an entry exists - */ - hasEntry(id: string): boolean { - return this._entries.has(id); - } - - // ========================================== - // Query Methods - // ========================================== - - /** - * Get all entries - */ - getAllEntries(): OutboxEntry[] { - return Array.from(this._entries.values()).map((e) => ({ ...e })); - } - - /** - * Get all pending (non-terminal) entries - */ - getPendingEntries(): OutboxEntry[] { - return Array.from(this._entries.values()) - .filter((e) => isPendingStatus(e.status)) - .map((e) => ({ ...e })); - } - - /** - * Get entries by status - */ - getEntriesByStatus(status: OutboxEntryStatus): OutboxEntry[] { - return Array.from(this._entries.values()) - .filter((e) => e.status === status) - .map((e) => ({ ...e })); - } - - /** - * Get entries for a specific source token - */ - getEntriesForToken(sourceTokenId: string): OutboxEntry[] { - return Array.from(this._entries.values()) - .filter((e) => e.sourceTokenId === sourceTokenId) - .map((e) => ({ ...e })); - } - - /** - * Check if a token has any pending outbox entries - * Use this to prevent double-spend - */ - isTokenInOutbox(sourceTokenId: string): boolean { - for (const entry of this._entries.values()) { - if (entry.sourceTokenId === sourceTokenId && isPendingStatus(entry.status)) { - return true; - } - } - return false; - } - - /** - * Get the count of pending entries - */ - getPendingCount(): number { - let count = 0; - for (const entry of this._entries.values()) { - if (isPendingStatus(entry.status)) { - count++; - } - } - return count; - } - - // ========================================== - // Split Group Management - // ========================================== - - /** - * Create a new split group - */ - createSplitGroup(group: OutboxSplitGroup): void { - if (this._splitGroups.has(group.groupId)) { - throw new Error(`Split group ${group.groupId} already exists`); - } - - this._splitGroups.set(group.groupId, { ...group }); - this.saveSplitGroupsToStorage(); - - console.log(`📤 Outbox: Created split group ${group.groupId.slice(0, 8)}...`); - } - - /** - * Get a split group by ID - */ - getSplitGroup(groupId: string): OutboxSplitGroup | null { - const group = this._splitGroups.get(groupId); - return group ? { ...group } : null; - } - - /** - * Add an entry ID to a split group - */ - addEntryToSplitGroup(groupId: string, entryId: string): void { - const group = this._splitGroups.get(groupId); - if (!group) { - throw new Error(`Split group ${groupId} not found`); - } - - if (!group.entryIds.includes(entryId)) { - group.entryIds.push(entryId); - this.saveSplitGroupsToStorage(); - } - } - - /** - * Remove a split group - */ - removeSplitGroup(groupId: string): void { - const existed = this._splitGroups.delete(groupId); - if (existed) { - this.saveSplitGroupsToStorage(); - console.log(`📤 Outbox: Removed split group ${groupId.slice(0, 8)}...`); - } - } - - /** - * Get all split groups - */ - getAllSplitGroups(): OutboxSplitGroup[] { - return Array.from(this._splitGroups.values()).map((g) => ({ ...g })); - } - - // ========================================== - // IPFS Integration - // ========================================== - - /** - * Get all entries for IPFS sync - * Returns entries that should be included in the TXF storage - */ - getAllForSync(): OutboxEntry[] { - // Include all non-completed entries - // Completed entries can be cleaned up after sync - return Array.from(this._entries.values()) - .filter((e) => e.status !== "COMPLETED") - .map((e) => ({ ...e })); - } - - /** - * Import entries from remote IPFS storage - * Used during bidirectional sync - */ - importFromRemote(remoteEntries: OutboxEntry[]): void { - let imported = 0; - - for (const remote of remoteEntries) { - const local = this._entries.get(remote.id); - - if (!local) { - // New entry from remote - add it - this._entries.set(remote.id, { ...remote }); - imported++; - } else if (remote.updatedAt > local.updatedAt) { - // Remote is newer - update local - this._entries.set(remote.id, { ...remote }); - imported++; - } - // If local is newer or same, keep local - } - - if (imported > 0) { - this.saveToStorage(); - console.log(`📤 Outbox: Imported ${imported} entries from remote`); - } - } - - // ========================================== - // Mint Outbox CRUD Operations - // ========================================== - - /** - * Add a new mint outbox entry - * @throws Error if entry with same ID already exists - */ - addMintEntry(entry: MintOutboxEntry): void { - const validation = validateMintOutboxEntry(entry); - if (!validation.valid) { - throw new Error(`Invalid mint outbox entry: ${validation.error}`); - } - - if (this._mintEntries.has(entry.id)) { - throw new Error(`Mint outbox entry ${entry.id} already exists`); - } - - this._mintEntries.set(entry.id, { ...entry }); - this.saveMintEntriesToStorage(); - - console.log(`📤 Outbox: Added mint entry ${entry.id.slice(0, 8)}... (${entry.type}, status=${entry.status})`); - } - - /** - * Get a mint outbox entry by ID - */ - getMintEntry(id: string): MintOutboxEntry | null { - const entry = this._mintEntries.get(id); - return entry ? { ...entry } : null; - } - - /** - * Update an existing mint outbox entry - * @returns The updated entry - */ - updateMintEntry(id: string, updates: Partial): MintOutboxEntry { - const existing = this._mintEntries.get(id); - if (!existing) { - throw new Error(`Mint outbox entry ${id} not found`); - } - - const updated: MintOutboxEntry = { - ...existing, - ...updates, - updatedAt: Date.now(), - }; - - const validation = validateMintOutboxEntry(updated); - if (!validation.valid) { - throw new Error(`Invalid mint outbox entry after update: ${validation.error}`); - } - - this._mintEntries.set(id, updated); - this.saveMintEntriesToStorage(); - - console.log(`📤 Outbox: Updated mint entry ${id.slice(0, 8)}... (status=${updated.status})`); - - return updated; - } - - /** - * Update just the status of a mint entry (convenience method) - */ - updateMintStatus(id: string, status: OutboxEntryStatus, error?: string): MintOutboxEntry { - const updates: Partial = { status }; - if (error) { - updates.lastError = error; - } - return this.updateMintEntry(id, updates); - } - - /** - * Remove a mint outbox entry - */ - removeMintEntry(id: string): void { - const existed = this._mintEntries.delete(id); - if (existed) { - this.saveMintEntriesToStorage(); - console.log(`📤 Outbox: Removed mint entry ${id.slice(0, 8)}...`); - } - } - - /** - * Check if a mint entry exists - */ - hasMintEntry(id: string): boolean { - return this._mintEntries.has(id); - } - - // ========================================== - // Mint Outbox Query Methods - // ========================================== - - /** - * Get all mint entries - */ - getAllMintEntries(): MintOutboxEntry[] { - return Array.from(this._mintEntries.values()).map((e) => ({ ...e })); - } - - /** - * Get all pending (non-terminal) mint entries - */ - getPendingMintEntries(): MintOutboxEntry[] { - return Array.from(this._mintEntries.values()) - .filter((e) => isPendingStatus(e.status)) - .map((e) => ({ ...e })); - } - - /** - * Get mint entries that can be recovered - */ - getMintEntriesForRecovery(): MintOutboxEntry[] { - return Array.from(this._mintEntries.values()) - .filter((e) => isMintRecoverable(e)) - .map((e) => ({ ...e })); - } - - /** - * Get mint entries by status - */ - getMintEntriesByStatus(status: OutboxEntryStatus): MintOutboxEntry[] { - return Array.from(this._mintEntries.values()) - .filter((e) => e.status === status) - .map((e) => ({ ...e })); - } - - /** - * Check if there's a pending mint for a specific nametag - * Use this to prevent duplicate mints - */ - isNametagMintInProgress(nametag: string): boolean { - for (const entry of this._mintEntries.values()) { - if (entry.nametag === nametag && isPendingStatus(entry.status)) { - return true; - } - } - return false; - } - - /** - * Get all mint entries for IPFS sync - */ - getAllMintEntriesForSync(): MintOutboxEntry[] { - // Include all non-completed entries - return Array.from(this._mintEntries.values()) - .filter((e) => e.status !== "COMPLETED") - .map((e) => ({ ...e })); - } - - /** - * Import mint entries from remote IPFS storage - */ - importMintEntriesFromRemote(remoteEntries: MintOutboxEntry[]): void { - let imported = 0; - - for (const remote of remoteEntries) { - const local = this._mintEntries.get(remote.id); - - if (!local) { - // New entry from remote - add it - this._mintEntries.set(remote.id, { ...remote }); - imported++; - } else if (remote.updatedAt > local.updatedAt) { - // Remote is newer - update local - this._mintEntries.set(remote.id, { ...remote }); - imported++; - } - // If local is newer or same, keep local - } - - if (imported > 0) { - this.saveMintEntriesToStorage(); - console.log(`📤 Outbox: Imported ${imported} mint entries from remote`); - } - } - - // ========================================== - // Cleanup - // ========================================== - - /** - * Remove completed entries older than maxAge milliseconds - * @param maxAge Maximum age in milliseconds (default: 24 hours) - * @returns Number of entries removed (transfers + mints) - */ - cleanupCompleted(maxAge: number = 24 * 60 * 60 * 1000): number { - const cutoff = Date.now() - maxAge; - let removed = 0; - - // Cleanup transfer entries - for (const [id, entry] of this._entries) { - if (isTerminalStatus(entry.status) && entry.updatedAt < cutoff) { - this._entries.delete(id); - removed++; - } - } - - if (removed > 0) { - this.saveToStorage(); - } - - // Cleanup mint entries - let mintRemoved = 0; - for (const [id, entry] of this._mintEntries) { - if (isTerminalStatus(entry.status) && entry.updatedAt < cutoff) { - this._mintEntries.delete(id); - mintRemoved++; - } - } - - if (mintRemoved > 0) { - this.saveMintEntriesToStorage(); - } - - const totalRemoved = removed + mintRemoved; - if (totalRemoved > 0) { - console.log(`📤 Outbox: Cleaned up ${removed} transfer entries and ${mintRemoved} mint entries`); - } - - return totalRemoved; - } - - /** - * Clear all entries (use with caution!) - */ - clearAll(): void { - this._entries.clear(); - this._mintEntries.clear(); - this._splitGroups.clear(); - this.saveToStorage(); - this.saveMintEntriesToStorage(); - this.saveSplitGroupsToStorage(); - console.log(`📤 Outbox: Cleared all entries`); - } - - // ========================================== - // Storage Operations - // ========================================== - - private getStorageKey(): string { - // If we have a current address, namespace the storage - if (this._currentAddress) { - return `${STORAGE_KEYS.OUTBOX}_${this._currentAddress}`; - } - return STORAGE_KEYS.OUTBOX; - } - - private getSplitGroupsStorageKey(): string { - if (this._currentAddress) { - return `${STORAGE_KEYS.OUTBOX_SPLIT_GROUPS}_${this._currentAddress}`; - } - return STORAGE_KEYS.OUTBOX_SPLIT_GROUPS; - } - - private getMintEntriesStorageKey(): string { - if (this._currentAddress) { - return `${STORAGE_KEYS.OUTBOX}_mint_${this._currentAddress}`; - } - return `${STORAGE_KEYS.OUTBOX}_mint`; - } - - private loadFromStorage(): void { - try { - // Load transfer entries - const json = localStorage.getItem(this.getStorageKey()); - if (json) { - const entries = JSON.parse(json) as OutboxEntry[]; - this._entries.clear(); - for (const entry of entries) { - this._entries.set(entry.id, entry); - } - console.log(`📤 Outbox: Loaded ${this._entries.size} transfer entries from storage`); - } else { - this._entries.clear(); - } - - // Load mint entries - const mintJson = localStorage.getItem(this.getMintEntriesStorageKey()); - if (mintJson) { - const mintEntries = JSON.parse(mintJson) as MintOutboxEntry[]; - this._mintEntries.clear(); - for (const entry of mintEntries) { - this._mintEntries.set(entry.id, entry); - } - if (this._mintEntries.size > 0) { - console.log(`📤 Outbox: Loaded ${this._mintEntries.size} mint entries from storage`); - } - } else { - this._mintEntries.clear(); - } - - // Load split groups - const groupsJson = localStorage.getItem(this.getSplitGroupsStorageKey()); - if (groupsJson) { - const groups = JSON.parse(groupsJson) as OutboxSplitGroup[]; - this._splitGroups.clear(); - for (const group of groups) { - this._splitGroups.set(group.groupId, group); - } - } else { - this._splitGroups.clear(); - } - } catch (error) { - console.error("📤 Outbox: Failed to load from storage:", error); - this._entries.clear(); - this._mintEntries.clear(); - this._splitGroups.clear(); - } - } - - private saveToStorage(): void { - try { - const entries = Array.from(this._entries.values()); - localStorage.setItem(this.getStorageKey(), JSON.stringify(entries)); - } catch (error) { - console.error("📤 Outbox: Failed to save to storage:", error); - } - } - - private saveMintEntriesToStorage(): void { - try { - const entries = Array.from(this._mintEntries.values()); - localStorage.setItem(this.getMintEntriesStorageKey(), JSON.stringify(entries)); - } catch (error) { - console.error("📤 Outbox: Failed to save mint entries to storage:", error); - } - } - - private saveSplitGroupsToStorage(): void { - try { - const groups = Array.from(this._splitGroups.values()); - localStorage.setItem(this.getSplitGroupsStorageKey(), JSON.stringify(groups)); - } catch (error) { - console.error("📤 Outbox: Failed to save split groups to storage:", error); - } - } - - // ========================================== - // Debug / Stats - // ========================================== - - /** - * Get statistics about the outbox (transfers and mints) - */ - getStats(): { - total: number; - pending: number; - completed: number; - failed: number; - byStatus: Record; - mints: { - total: number; - pending: number; - completed: number; - failed: number; - byStatus: Record; - }; - } { - // Transfer stats - const byStatus: Record = { - PENDING_IPFS_SYNC: 0, - READY_TO_SUBMIT: 0, - SUBMITTED: 0, - PROOF_RECEIVED: 0, - NOSTR_SENT: 0, - COMPLETED: 0, - FAILED: 0, - }; - - for (const entry of this._entries.values()) { - byStatus[entry.status]++; - } - - // Mint stats - const mintByStatus: Record = { - PENDING_IPFS_SYNC: 0, - READY_TO_SUBMIT: 0, - SUBMITTED: 0, - PROOF_RECEIVED: 0, - NOSTR_SENT: 0, - COMPLETED: 0, - FAILED: 0, - }; - - let mintPending = 0; - for (const entry of this._mintEntries.values()) { - mintByStatus[entry.status]++; - if (isPendingStatus(entry.status)) { - mintPending++; - } - } - - return { - total: this._entries.size, - pending: this.getPendingCount(), - completed: byStatus.COMPLETED, - failed: byStatus.FAILED, - byStatus, - mints: { - total: this._mintEntries.size, - pending: mintPending, - completed: mintByStatus.COMPLETED, - failed: mintByStatus.FAILED, - byStatus: mintByStatus, - }, - }; - } -} - -// Export singleton getter for convenience -export function getOutboxRepository(): OutboxRepository { - return OutboxRepository.getInstance(); -} diff --git a/src/repositories/WalletRepository.ts b/src/repositories/WalletRepository.ts deleted file mode 100644 index 10e948e6..00000000 --- a/src/repositories/WalletRepository.ts +++ /dev/null @@ -1,2082 +0,0 @@ -import { Token, Wallet, TokenStatus } from "../components/wallet/L3/data/model"; -import type { TombstoneEntry, TxfToken, TxfTransaction, InvalidatedNametagEntry, NametagData } from "../components/wallet/L3/services/types/TxfTypes"; -import { v4 as uuidv4 } from "uuid"; -import { STORAGE_KEYS, STORAGE_KEY_GENERATORS, STORAGE_KEY_PREFIXES } from "../config/storageKeys"; -import { assertValidNametagData, sanitizeNametagForLogging, validateTokenJson } from "../utils/tokenValidation"; -import { RegistryService } from "../components/wallet/L3/services/RegistryService"; - -// Re-export NametagData for backwards compatibility -export type { NametagData } from "../components/wallet/L3/services/types/TxfTypes"; - -// Session flag to indicate active import flow -// This allows wallet creation during import even when credentials exist -// (because during import, credentials are set BEFORE wallet data is created) -const IMPORT_SESSION_FLAG = "sphere_active_import"; - -// DEBUG: Log wallet state on module load (BEFORE any code runs) -// This helps diagnose if corruption happens before or during app initialization -(function debugModuleLoad() { - try { - console.log("🔍 [MODULE LOAD] WalletRepository module initializing..."); - const walletKeys: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith("sphere_wallet_DIRECT://")) { - walletKeys.push(key); - } - } - if (walletKeys.length === 0) { - console.log("🔍 [MODULE LOAD] No wallet data found in localStorage"); - } else { - for (const key of walletKeys) { - const value = localStorage.getItem(key); - if (value) { - try { - const parsed = JSON.parse(value); - console.log(`🔍 [MODULE LOAD] Found wallet: key=${key.slice(0, 60)}..., size=${value.length} bytes`); - console.log(`🔍 [MODULE LOAD] id=${parsed.id?.slice(0, 8)}..., tokens=${parsed.tokens?.length || 0}, nametag=${parsed.nametag?.name || 'none'}`); - } catch { - console.log(`🔍 [MODULE LOAD] Found wallet: key=${key.slice(0, 60)}..., size=${value.length} bytes (parse error)`); - } - } - } - } - } catch (e) { - console.error("🔍 [MODULE LOAD] Error checking localStorage:", e); - } -})(); - -/** - * Interface for transaction history entries - */ -export interface TransactionHistoryEntry { - id: string; - type: 'SENT' | 'RECEIVED'; - amount: string; - coinId: string; - symbol: string; - iconUrl?: string; - timestamp: number; - recipientNametag?: string; - senderPubkey?: string; -} - -/** - * Interface for stored wallet data (for type safety when parsing JSON) - */ -interface StoredWallet { - id: string; - name: string; - address: string; - tokens: Partial[]; - nametag?: NametagData; // One nametag per wallet/identity - tombstones?: TombstoneEntry[] | string[]; // TombstoneEntry[] (new) or string[] (legacy, discarded on load) - archivedTokens?: Record; // Archived spent tokens (keyed by tokenId) - forkedTokens?: Record; // Forked tokens (keyed by tokenId_stateHash) - invalidatedNametags?: InvalidatedNametagEntry[]; // Nametags invalidated due to Nostr pubkey mismatch -} - -export class WalletRepository { - private static instance: WalletRepository; - - // Sync lock: prevents direct writes during InventorySyncService execution - // Per TOKEN_INVENTORY_SPEC.md Section 6.1: "Only inventorySync should be allowed to access the inventory in localStorage!" - private static _syncInProgress: boolean = false; - private static _pendingTokens: Token[] = []; // Tokens queued during sync for next sync cycle - - private _wallet: Wallet | null = null; - private _currentAddress: string | null = null; - private _migrationComplete: boolean = false; - private _nametag: NametagData | null = null; - private _tombstones: TombstoneEntry[] = []; // State-hash-aware tombstones for IPFS sync - private _transactionHistory: TransactionHistoryEntry[] = []; - private _archivedTokens: Map = new Map(); // Archived spent tokens (keyed by tokenId) - private _forkedTokens: Map = new Map(); // Forked tokens (keyed by tokenId_stateHash) - private _invalidatedNametags: InvalidatedNametagEntry[] = []; // Nametags invalidated due to Nostr pubkey mismatch - - // Debounce timer for wallet refresh events - private _refreshDebounceTimer: ReturnType | null = null; - - private constructor() { - // Don't auto-load wallet in constructor - wait for address - this.loadTransactionHistory(); - } - - static getInstance(): WalletRepository { - if (!WalletRepository.instance) { - WalletRepository.instance = new WalletRepository(); - } - return WalletRepository.instance; - } - - /** - * Check if an address has a nametag without loading the full wallet - * Static method for use during onboarding address selection - */ - static checkNametagForAddress(address: string): NametagData | null { - if (!address) return null; - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - try { - const json = localStorage.getItem(storageKey); - if (json) { - const parsed = JSON.parse(json) as StoredWallet; - return parsed.nametag || null; - } - } catch (error) { - console.error("Error checking nametag for address:", error); - } - return null; - } - - /** - * Check if an address has tokens without loading the full wallet - * Static method for use during onboarding address selection - */ - static checkTokensForAddress(address: string): boolean { - if (!address) return false; - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - try { - const json = localStorage.getItem(storageKey); - if (json) { - const parsed = JSON.parse(json) as StoredWallet; - return Array.isArray(parsed.tokens) && parsed.tokens.length > 0; - } - } catch (error) { - console.error("Error checking tokens for address:", error); - } - return false; - } - - /** - * Mark that we're in an active import flow. - * During import, credentials are saved BEFORE wallet data, so the safeguard - * that prevents wallet creation when credentials exist needs to be bypassed. - * This flag is stored in sessionStorage so it's cleared on browser close. - */ - static setImportInProgress(): void { - console.log("📦 [IMPORT] Setting import-in-progress flag"); - sessionStorage.setItem(IMPORT_SESSION_FLAG, "true"); - } - - /** - * Clear the import-in-progress flag. - * Should be called when import completes (success or failure). - */ - static clearImportInProgress(): void { - console.log("📦 [IMPORT] Clearing import-in-progress flag"); - sessionStorage.removeItem(IMPORT_SESSION_FLAG); - } - - /** - * Check if we're in an active import flow. - */ - static isImportInProgress(): boolean { - return sessionStorage.getItem(IMPORT_SESSION_FLAG) === "true"; - } - - // ========================================== - // Sync Lock Methods (InventorySyncService integration) - // ========================================== - - /** - * Set the sync-in-progress flag. - * Called by InventorySyncService at the start of inventorySync(). - * While set, direct addToken/removeToken calls will queue tokens for next sync. - */ - static setSyncInProgress(value: boolean): void { - console.log(`🔒 [SYNC LOCK] setSyncInProgress(${value})`); - WalletRepository._syncInProgress = value; - } - - /** - * Check if sync is currently in progress. - */ - static isSyncInProgress(): boolean { - return WalletRepository._syncInProgress; - } - - /** - * Get tokens that were queued during sync. - * Called by InventorySyncService to include pending tokens in next sync. - */ - static getPendingTokens(): Token[] { - const tokens = [...WalletRepository._pendingTokens]; - WalletRepository._pendingTokens = []; // Clear after retrieval - return tokens; - } - - /** - * Queue a token for the next sync cycle. - * Called internally when addToken is blocked by sync lock. - */ - private static queuePendingToken(token: Token): void { - console.log(`📥 [SYNC LOCK] Queuing token ${token.id.slice(0, 8)}... for next sync`); - WalletRepository._pendingTokens.push(token); - } - - /** - * Save nametag for an address without loading the full wallet - * Used during onboarding when we fetch nametag from IPNS - * Creates minimal wallet structure if needed - * - * CRITICAL: Validates nametag data before saving to prevent corruption. - * Will throw if nametag.token is empty or invalid. - */ - static saveNametagForAddress(address: string, nametag: NametagData): void { - if (!address || !nametag) return; - - console.log(`📦 [saveNametagForAddress] Called for address=${address.slice(0, 20)}..., nametag="${nametag.name}"`); - - // CRITICAL VALIDATION: Prevent saving corrupted nametag data - // This check prevents the bug where `token: {}` was saved - try { - assertValidNametagData(nametag, "saveNametagForAddress"); - } catch (validationError) { - console.error("❌ BLOCKED: Attempted to save invalid nametag data:", { - address: address.slice(0, 20) + "...", - nametagInfo: sanitizeNametagForLogging(nametag), - error: validationError instanceof Error ? validationError.message : String(validationError), - }); - // Do NOT save corrupted data - throw to alert caller - throw validationError; - } - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(address); - try { - // Load existing wallet data or create minimal structure - let walletData: StoredWallet; - const existingJson = localStorage.getItem(storageKey); - - console.log(`📦 [saveNametagForAddress] Existing data: ${existingJson ? `${existingJson.length} bytes` : 'null'}`); - - if (existingJson) { - walletData = JSON.parse(existingJson) as StoredWallet; - console.log(`📦 [saveNametagForAddress] Updating existing wallet id=${walletData.id?.slice(0, 8)}..., tokens=${walletData.tokens?.length || 0}`); - walletData.nametag = nametag; - } else { - // CRITICAL SAFEGUARD: If wallet credentials exist but wallet data doesn't, - // something is very wrong. DO NOT create a minimal wallet - this would - // overwrite the user's tokens when they restart. This case can happen if - // localStorage was corrupted or cleared while credentials remain intact. - // - // EXCEPTION: During active import flow, credentials are saved BEFORE wallet - // data, so we check for the import flag to allow creation in that case. - const hasMasterKey = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_MASTER); - const hasMnemonic = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_MNEMONIC); - const isImporting = WalletRepository.isImportInProgress(); - if ((hasMasterKey || hasMnemonic) && !isImporting) { - console.error(`🚨 CRITICAL: saveNametagForAddress called with null existingJson but wallet credentials exist!`); - console.error(` This would create a minimal wallet and overwrite user's tokens on restart!`); - console.error(` Address: ${address.slice(0, 30)}...`); - console.error(` Nametag: ${nametag.name}`); - console.error(` hasMasterKey: ${!!hasMasterKey}, hasMnemonic: ${!!hasMnemonic}`); - console.trace(`🚨 Call stack:`); - // DO NOT create a new wallet - throw to prevent data loss - throw new Error(`Cannot save nametag: wallet data missing but credentials exist. This would cause data loss.`); - } - - if (isImporting) { - console.log(`📦 [saveNametagForAddress] Import in progress - allowing wallet creation despite credentials`); - } - - // Create minimal wallet structure with just the nametag - // This should only happen during FRESH onboarding (no credentials) OR during import - const newId = crypto.randomUUID ? crypto.randomUUID() : `wallet-${Date.now()}`; - console.warn(`⚠️ [saveNametagForAddress] Creating NEW minimal wallet! id=${newId.slice(0, 8)}... - this should only happen during onboarding`); - console.trace(`📦 [saveNametagForAddress] Call stack for new wallet creation:`); - walletData = { - id: newId, - name: "Wallet", - address: address, - tokens: [], - nametag: nametag, - }; - } - - localStorage.setItem(storageKey, JSON.stringify(walletData)); - console.log(`💾 Saved IPNS-fetched nametag "${nametag.name}" for address ${address.slice(0, 20)}...`); - } catch (error) { - console.error("Error saving nametag for address:", error); - throw error; // Re-throw to alert caller of storage failure - } - } - - /** - * Validate address format - * Returns true if the address is valid, false otherwise - */ - private validateAddress(address: string | null | undefined): address is string { - if (!address || typeof address !== "string") { - return false; - } - - const trimmed = address.trim(); - - // Check minimum length (L3 addresses are typically long) - if (trimmed.length < 20) { - return false; - } - - // L3 addresses can be in format: DIRECT://... or PROXY://... - // Allow alphanumeric, colon, slash (for scheme), and underscore - // Block dangerous characters: <, >, ", ', \, and path traversal (..) - if (/[<>"'\\]|\.\./.test(trimmed)) { - return false; - } - - return true; - } - - /** - * Generate storage key for a specific address - */ - private getStorageKey(address: string): string { - return STORAGE_KEY_GENERATORS.walletByAddress(address); - } - - /** - * Migrate legacy wallet data to address-based storage - * Only runs once per session - */ - private migrateLegacyWallet(): void { - if (this._migrationComplete) { - return; - } - - try { - const legacyJson = localStorage.getItem(STORAGE_KEYS.WALLET_DATA_LEGACY); - if (!legacyJson) { - this._migrationComplete = true; - return; - } - - console.log("Migrating legacy wallet data to address-based storage..."); - const parsed = JSON.parse(legacyJson) as StoredWallet; - - if (!parsed.address) { - console.warn("Legacy wallet has no address, cannot migrate"); - this._migrationComplete = true; - return; - } - - if (!this.validateAddress(parsed.address)) { - console.error(`Legacy wallet has invalid address: ${parsed.address}`); - this._migrationComplete = true; - return; - } - - const newKey = this.getStorageKey(parsed.address); - - // Check if already migrated - if (localStorage.getItem(newKey)) { - console.log("Wallet already migrated, removing legacy key"); - localStorage.removeItem(STORAGE_KEYS.WALLET_DATA_LEGACY); - this._migrationComplete = true; - return; - } - - localStorage.setItem(newKey, legacyJson); - localStorage.removeItem(STORAGE_KEYS.WALLET_DATA_LEGACY); - console.log(`Successfully migrated wallet for ${parsed.address}`); - this._migrationComplete = true; - } catch (error) { - console.error("Failed to migrate legacy wallet", error); - this._migrationComplete = true; // Don't retry on error - } - } - - /** - * Load wallet for a specific address - */ - loadWalletForAddress(address: string): Wallet | null { - console.log(`📦 [LOAD] loadWalletForAddress called for ${address?.slice(0, 30)}...`); - - // Validate address format - if (!this.validateAddress(address)) { - console.error(`📦 [LOAD] FAILED: Invalid address format: ${address}`); - return null; - } - - try { - // First check if migration is needed (only runs once per session) - this.migrateLegacyWallet(); - - const storageKey = this.getStorageKey(address); - const json = localStorage.getItem(storageKey); - - console.log(`📦 [LOAD] Storage key: ${storageKey}`); - console.log(`📦 [LOAD] localStorage has data: ${!!json}, length: ${json?.length || 0}`); - - if (json) { - const parsed = JSON.parse(json) as StoredWallet; - - console.log(`📦 [LOAD] Parsed wallet: id=${parsed.id?.slice(0, 8)}..., tokens=${parsed.tokens?.length || 0}, archived=${Object.keys(parsed.archivedTokens || {}).length}`); - - // CRITICAL FIX: Detect TxfStorageData format (used by InventorySyncService) - // TxfStorageData has _meta object instead of id/address/tokens - // DO NOT DELETE - InventorySyncService owns this data per spec Section 6.1 - const parsedAny = parsed as unknown as Record; - if (parsedAny._meta && typeof parsedAny._meta === 'object') { - console.log(`📦 [LOAD] Detected TxfStorageData format (version=${(parsedAny._meta as {version?: number}).version}) - skipping StoredWallet load`); - console.log(`📦 [LOAD] This is expected behavior - InventorySyncService is the authoritative storage owner`); - // Return null (no StoredWallet loaded) but DO NOT DELETE the TxfStorageData - return null; - } - - // Validate stored data structure (legacy StoredWallet format) - if (!parsed.id || !parsed.address || !Array.isArray(parsed.tokens)) { - console.error(`📦 [LOAD] FAILED: Invalid wallet structure - id=${!!parsed.id}, address=${!!parsed.address}, tokens=${Array.isArray(parsed.tokens)}`); - // Only delete if it's neither TxfStorageData nor valid StoredWallet - // This handles truly corrupted data - localStorage.removeItem(storageKey); - return null; - } - - // Verify address match - critical security check - if (parsed.address !== address) { - console.error( - `📦 [LOAD] FAILED: Address mismatch: requested ${address}, stored ${parsed.address}. Removing corrupted data.` - ); - localStorage.removeItem(storageKey); - return null; - } - - const tokens = parsed.tokens.map((t: Partial) => new Token(t)); - const wallet = new Wallet( - parsed.id, - parsed.name, - parsed.address, - tokens - ); - - this._wallet = wallet; - this._currentAddress = address; - this._nametag = parsed.nametag || null; - - // Parse tombstones - handle legacy format (string[]) by discarding it - this._tombstones = []; - if (parsed.tombstones && Array.isArray(parsed.tombstones)) { - for (const entry of parsed.tombstones) { - // New format: TombstoneEntry objects - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as TombstoneEntry).tokenId === "string" && - typeof (entry as TombstoneEntry).stateHash === "string" && - typeof (entry as TombstoneEntry).timestamp === "number" - ) { - this._tombstones.push(entry as TombstoneEntry); - } - // Legacy string format: discard (no state hash info) - } - } - - // Load archived tokens - this._archivedTokens = new Map(); - if (parsed.archivedTokens && typeof parsed.archivedTokens === "object") { - for (const [tokenId, txfToken] of Object.entries(parsed.archivedTokens)) { - if (txfToken && typeof txfToken === "object" && (txfToken as TxfToken).genesis) { - this._archivedTokens.set(tokenId, txfToken as TxfToken); - } - } - } - - // Load forked tokens - this._forkedTokens = new Map(); - if (parsed.forkedTokens && typeof parsed.forkedTokens === "object") { - for (const [key, txfToken] of Object.entries(parsed.forkedTokens)) { - if (txfToken && typeof txfToken === "object" && (txfToken as TxfToken).genesis) { - this._forkedTokens.set(key, txfToken as TxfToken); - } - } - } - - // Load invalidated nametags - this._invalidatedNametags = []; - if (parsed.invalidatedNametags && Array.isArray(parsed.invalidatedNametags)) { - for (const entry of parsed.invalidatedNametags) { - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as InvalidatedNametagEntry).name === "string" && - typeof (entry as InvalidatedNametagEntry).invalidatedAt === "number" - ) { - this._invalidatedNametags.push(entry as InvalidatedNametagEntry); - } - } - } - - // Don't call refreshWallet() here - loading is a read operation, not a write - // refreshWallet() should only be called when data actually changes - - const archiveInfo = this._archivedTokens.size > 0 ? `, ${this._archivedTokens.size} archived` : ""; - const forkedInfo = this._forkedTokens.size > 0 ? `, ${this._forkedTokens.size} forked` : ""; - console.log(`📦 [LOAD] SUCCESS: wallet id=${parsed.id.slice(0, 8)}..., ${tokens.length} tokens${this._nametag ? `, nametag: ${this._nametag.name}` : ""}${this._tombstones.length > 0 ? `, ${this._tombstones.length} tombstones` : ""}${archiveInfo}${forkedInfo}`); - return wallet; - } - - console.log(`📦 [LOAD] No wallet found in localStorage for key ${storageKey}`); - return null; - } catch (error) { - console.error(`Failed to load wallet for address ${address}`, error); - return null; - } - } - - /** - * Switch to a different address - */ - switchToAddress(address: string): Wallet | null { - // Validate address format - if (!this.validateAddress(address)) { - console.error(`Cannot switch to invalid address: ${address}`); - return null; - } - - // Optimization: skip if already on the correct address - if (this._currentAddress === address && this._wallet?.address === address) { - return this._wallet; - } - - console.log(`Switching from ${this._currentAddress || "none"} to ${address}`); - return this.loadWalletForAddress(address); - } - - // Transaction History Methods - private loadTransactionHistory() { - try { - const json = localStorage.getItem(STORAGE_KEYS.TRANSACTION_HISTORY); - if (json) { - this._transactionHistory = JSON.parse(json); - } - } catch (error) { - console.error("Failed to load transaction history", error); - this._transactionHistory = []; - } - } - - private saveTransactionHistory() { - try { - localStorage.setItem(STORAGE_KEYS.TRANSACTION_HISTORY, JSON.stringify(this._transactionHistory)); - } catch (error) { - console.error("Failed to save transaction history", error); - } - } - - addTransactionToHistory(entry: Omit): void { - const historyEntry: TransactionHistoryEntry = { - id: uuidv4(), - ...entry, - }; - this._transactionHistory.push(historyEntry); - this.saveTransactionHistory(); - this.refreshWallet(); // Trigger UI update - } - - getTransactionHistory(): TransactionHistoryEntry[] { - return [...this._transactionHistory].sort((a, b) => b.timestamp - a.timestamp); - } - - addSentTransaction(amount: string, coinId: string, symbol: string, iconUrl: string | undefined, recipientNametag: string): void { - this.addTransactionToHistory({ - type: 'SENT', - amount: amount, - coinId: coinId, - symbol: symbol, - iconUrl: iconUrl, - timestamp: Date.now(), - recipientNametag: recipientNametag, - }); - } - - createWallet(address: string, name: string = "My Wallet"): Wallet { - console.log(`📦 [CREATE] createWallet called for ${address?.slice(0, 30)}...`); - - // Validate address format - if (!this.validateAddress(address)) { - throw new Error(`Cannot create wallet with invalid address: ${address}`); - } - - // Check if wallet already exists for this address - const existing = this.loadWalletForAddress(address); - if (existing) { - console.log(`📦 [CREATE] Wallet already exists (id=${existing.id.slice(0, 8)}...), using existing`); - return existing; - } - - // RECOVERY SCENARIO DETECTION: If wallet credentials exist but wallet data doesn't, - // this is likely a "cache cleared" scenario. The user's tokens can be recovered from IPFS - // because the mnemonic can derive the IPNS key for fetching remote data. - // - // Previously this threw an error, but that caused an infinite loop in React Query. - // Now we log a warning and allow wallet creation - the IPFS sync flow will recover tokens. - // - // EXCEPTION: During active import flow, credentials are saved BEFORE wallet - // data, so we check for the import flag to allow creation in that case. - const hasMasterKey = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_MASTER); - const hasMnemonic = localStorage.getItem(STORAGE_KEYS.UNIFIED_WALLET_MNEMONIC); - const isImporting = WalletRepository.isImportInProgress(); - const isRecoveryScenario = (hasMasterKey || hasMnemonic) && !isImporting; - - if (isRecoveryScenario) { - console.warn(`⚠️ [RECOVERY] Wallet credentials exist but wallet data is missing.`); - console.warn(`⚠️ [RECOVERY] Address: ${address}`); - console.warn(`⚠️ [RECOVERY] Has master key: ${!!hasMasterKey}, Has mnemonic: ${!!hasMnemonic}`); - console.warn(`⚠️ [RECOVERY] Creating empty wallet - tokens will be recovered from IPFS sync.`); - } - - if (isImporting) { - console.log(`📦 [CREATE] Import in progress - allowing wallet creation despite credentials`); - } - - const newId = uuidv4(); - console.log(`📦 [CREATE] Creating NEW wallet with id=${newId.slice(0, 8)}...`); - console.trace(`📦 [CREATE] Call stack for new wallet creation:`); - - const newWallet = new Wallet(newId, name, address, []); - this._currentAddress = address; - this.saveWallet(newWallet); - console.log(`📦 [CREATE] Saved new wallet for address ${address}`); - this.refreshWallet(); // Trigger wallet-updated for UI updates - window.dispatchEvent(new Event("wallet-loaded")); // Signal wallet creation for Nostr initialization - return newWallet; - } - - private saveWallet(wallet: Wallet) { - console.log(`📦 [SAVE] saveWallet called: id=${wallet.id.slice(0, 8)}..., tokens=${wallet.tokens.length}, address=${wallet.address.slice(0, 30)}...`); - - const storageKey = this.getStorageKey(wallet.address); - - // CRITICAL SAFETY CHECK: Merge wallet data on ID mismatch - // This is the last line of defense against data corruption - try { - const existingJson = localStorage.getItem(storageKey); - if (existingJson) { - const existing = JSON.parse(existingJson) as StoredWallet; - - // DETECT: Wallet ID mismatch - merge instead of overwrite - if (existing.id && existing.id !== wallet.id) { - const isImporting = WalletRepository.isImportInProgress(); - if (!isImporting) { - console.error(`🚨 CRITICAL: Wallet ID mismatch detected!`); - console.error(`🚨 Existing ID: ${existing.id}`); - console.error(`🚨 Incoming ID: ${wallet.id}`); - console.error(`🚨 Existing tokens: ${existing.tokens?.length || 0}`); - console.error(`🚨 Incoming tokens: ${wallet.tokens.length}`); - console.trace(`🚨 Call stack for wallet ID mismatch:`); - - // MERGE wallet data instead of just preserving ID - wallet = this.mergeWalletData(existing, wallet); - - // Also merge other StoredWallet fields into memory - this.mergeStoredWalletFields(existing); - } else { - console.log(`📦 [SAVE] Allowing wallet ID change during import (old: ${existing.id.slice(0, 8)}..., new: ${wallet.id.slice(0, 8)}...)`); - } - } - - // WARN: Token count decrease (but allow it - could be valid due to transfers) - if (existing.tokens && existing.tokens.length > wallet.tokens.length) { - console.warn(`⚠️ [SAVE] TOKEN COUNT DECREASE! Old: ${existing.tokens.length}, New: ${wallet.tokens.length}`); - console.trace(`📦 [SAVE] Call stack for token decrease:`); - } - } - } catch (e) { - console.error(`📦 [SAVE] Error in safety check:`, e); - // Proceed with save on error - don't block legitimate saves - } - - this._wallet = wallet; - this._currentAddress = wallet.address; - - // Include nametag, tombstones, archived/forked tokens, and invalidated nametags in stored data - const storedData: StoredWallet = { - id: wallet.id, - name: wallet.name, - address: wallet.address, - tokens: wallet.tokens, - nametag: this._nametag || undefined, - tombstones: this._tombstones.length > 0 ? this._tombstones : undefined, - archivedTokens: this._archivedTokens.size > 0 ? Object.fromEntries(this._archivedTokens) : undefined, - forkedTokens: this._forkedTokens.size > 0 ? Object.fromEntries(this._forkedTokens) : undefined, - invalidatedNametags: this._invalidatedNametags.length > 0 ? this._invalidatedNametags : undefined, - }; - - localStorage.setItem(storageKey, JSON.stringify(storedData)); - } - - getWallet(): Wallet | null { - return this._wallet; - } - - getTokens(): Token[] { - return this._wallet?.tokens || []; - } - - private isSameToken(t1: Token, t2: Token): boolean { - if (t1.id === t2.id) return true; - - try { - const p1 = JSON.parse(t1.jsonData || "{}"); - const p2 = JSON.parse(t2.jsonData || "{}"); - - const id1 = p1.genesis?.data?.tokenId; - const id2 = p2.genesis?.data?.tokenId; - - if (id1 && id2 && id1 === id2) return true; - } catch { - return false; - } - - return false; - } - - // ========================================== - // Wallet Data Merge Methods (ID mismatch handling) - // ========================================== - - /** - * Merge two wallet data sets when ID mismatch is detected. - * Preserves existing wallet ID and merges tokens from both sources. - * - * @param existing - The wallet data currently in localStorage - * @param incoming - The wallet data being saved - * @returns Merged wallet with existing.id preserved - */ - private mergeWalletData(existing: StoredWallet, incoming: Wallet): Wallet { - console.log(`🔀 [MERGE] Merging wallet data due to ID mismatch`); - console.log(`🔀 [MERGE] Existing: id=${existing.id.slice(0, 8)}..., tokens=${existing.tokens?.length || 0}`); - console.log(`🔀 [MERGE] Incoming: id=${incoming.id.slice(0, 8)}..., tokens=${incoming.tokens.length}`); - - // 1. Merge tokens by SDK tokenId - const mergedTokens = this.mergeTokenArrays( - existing.tokens || [], - incoming.tokens - ); - - // 2. Create merged wallet with EXISTING ID preserved - const mergedWallet = new Wallet( - existing.id, // PRESERVE existing ID - incoming.name, // Use incoming name - incoming.address, // Address must match - mergedTokens // Merged tokens - ); - - console.log(`🔀 [MERGE] Result: id=${mergedWallet.id.slice(0, 8)}..., tokens=${mergedWallet.tokens.length}`); - return mergedWallet; - } - - /** - * Merge other StoredWallet fields (tombstones, archives, etc.) into memory. - * Called after mergeWalletData when ID mismatch is detected. - */ - private mergeStoredWalletFields(existing: StoredWallet): void { - // Merge tombstones (union by tokenId:stateHash) - if (existing.tombstones && Array.isArray(existing.tombstones)) { - for (const entry of existing.tombstones) { - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as TombstoneEntry).tokenId === "string" && - typeof (entry as TombstoneEntry).stateHash === "string" - ) { - const typedEntry = entry as TombstoneEntry; - const alreadyExists = this._tombstones.some( - t => t.tokenId === typedEntry.tokenId && t.stateHash === typedEntry.stateHash - ); - if (!alreadyExists) { - this._tombstones.push(typedEntry); - } - } - } - } - - // Merge archived tokens (prefer more complete version) - if (existing.archivedTokens && typeof existing.archivedTokens === "object") { - for (const [tokenId, txfToken] of Object.entries(existing.archivedTokens)) { - if (txfToken && typeof txfToken === "object" && (txfToken as TxfToken).genesis) { - const typedTxf = txfToken as TxfToken; - const existingArchive = this._archivedTokens.get(tokenId); - if (!existingArchive) { - this._archivedTokens.set(tokenId, typedTxf); - } else if (this.isIncrementalUpdate(existingArchive, typedTxf)) { - // Existing localStorage version is more advanced - this._archivedTokens.set(tokenId, typedTxf); - } - } - } - } - - // Merge forked tokens (union by tokenId_stateHash) - if (existing.forkedTokens && typeof existing.forkedTokens === "object") { - for (const [key, txfToken] of Object.entries(existing.forkedTokens)) { - if (txfToken && typeof txfToken === "object" && (txfToken as TxfToken).genesis) { - if (!this._forkedTokens.has(key)) { - this._forkedTokens.set(key, txfToken as TxfToken); - } - } - } - } - - // Merge invalidated nametags (union by name) - if (existing.invalidatedNametags && Array.isArray(existing.invalidatedNametags)) { - for (const entry of existing.invalidatedNametags) { - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as InvalidatedNametagEntry).name === "string" - ) { - const typedEntry = entry as InvalidatedNametagEntry; - const alreadyExists = this._invalidatedNametags.some(e => e.name === typedEntry.name); - if (!alreadyExists) { - this._invalidatedNametags.push(typedEntry); - } - } - } - } - - // Merge nametag (prefer valid over corrupted, prefer existing if both valid) - if (existing.nametag && !this._nametag) { - this._nametag = existing.nametag; - } - - console.log(`🔀 [MERGE] Merged fields: ${this._tombstones.length} tombstones, ${this._archivedTokens.size} archived, ${this._forkedTokens.size} forked, ${this._invalidatedNametags.length} invalidated nametags`); - } - - /** - * Merge two token arrays by SDK tokenId. - * For duplicates, use conflict resolution (longer chain > more proofs). - */ - private mergeTokenArrays( - existingTokens: Partial[], - incomingTokens: Token[] - ): Token[] { - const tokenMap = new Map(); - - // Helper to get SDK token ID from a token - const getSdkTokenId = (t: Partial): string | null => { - try { - if (t.jsonData) { - const parsed = JSON.parse(t.jsonData); - return parsed.genesis?.data?.tokenId || null; - } - } catch { /* ignore */ } - return null; - }; - - // Add existing tokens to map - for (const t of existingTokens) { - const sdkId = getSdkTokenId(t); - const key = sdkId || t.id || crypto.randomUUID(); - if (!tokenMap.has(key)) { - // Convert Partial to Token - tokenMap.set(key, new Token(t)); - } - } - - // Merge incoming tokens - for (const t of incomingTokens) { - const sdkId = getSdkTokenId(t); - const key = sdkId || t.id || crypto.randomUUID(); - - if (tokenMap.has(key)) { - // Conflict: use resolution logic - const existing = tokenMap.get(key)!; - const winner = this.resolveTokenConflict(existing, t); - tokenMap.set(key, winner); - } else { - // New token: add it - tokenMap.set(key, t); - } - } - - const result = Array.from(tokenMap.values()); - console.log(`🔀 [MERGE] Tokens: existing=${existingTokens.length}, incoming=${incomingTokens.length}, merged=${result.length}`); - return result; - } - - /** - * Resolve conflict between two tokens with same SDK ID. - * Priority: longer transaction chain > more proofs > existing wins tie - */ - private resolveTokenConflict(existing: Token, incoming: Token): Token { - try { - const existingData = existing.jsonData ? JSON.parse(existing.jsonData) : null; - const incomingData = incoming.jsonData ? JSON.parse(incoming.jsonData) : null; - - // Compare transaction chain length - const existingTxCount = existingData?.transactions?.length || 0; - const incomingTxCount = incomingData?.transactions?.length || 0; - - if (incomingTxCount > existingTxCount) { - console.log(`🔀 [CONFLICT] Incoming wins (more tx: ${incomingTxCount} > ${existingTxCount})`); - return incoming; - } - if (existingTxCount > incomingTxCount) { - console.log(`🔀 [CONFLICT] Existing wins (more tx: ${existingTxCount} > ${incomingTxCount})`); - return existing; - } - - // Compare proofs (inclusionProofs array) - const existingProofs = existingData?.inclusionProofs?.length || 0; - const incomingProofs = incomingData?.inclusionProofs?.length || 0; - - if (incomingProofs > existingProofs) { - console.log(`🔀 [CONFLICT] Incoming wins (more proofs: ${incomingProofs} > ${existingProofs})`); - return incoming; - } - if (existingProofs > incomingProofs) { - console.log(`🔀 [CONFLICT] Existing wins (more proofs: ${existingProofs} > ${incomingProofs})`); - return existing; - } - - // Tie: prefer existing (already in storage) - console.log(`🔀 [CONFLICT] Tie - keeping existing`); - return existing; - } catch (e) { - console.warn(`🔀 [CONFLICT] Error comparing tokens, keeping existing:`, e); - return existing; - } - } - - addToken(token: Token, skipHistory: boolean = false): void { - console.log("💾 Repository: Adding token...", token.id); - - // SYNC LOCK: If InventorySyncService is running, queue token for next sync - // This prevents race conditions where direct writes overwrite sync results - if (WalletRepository._syncInProgress) { - console.warn(`⚠️ [SYNC LOCK] Sync in progress, queuing token ${token.id.slice(0, 8)}...`); - WalletRepository.queuePendingToken(token); - return; - } - - if (!this._wallet) { - console.error("💾 Repository: Wallet not initialized!"); - return; - } - - // CRITICAL: Validate token data before storing - if (token.jsonData) { - try { - const tokenJson = JSON.parse(token.jsonData); - const validation = validateTokenJson(tokenJson, { - context: `addToken(${token.id})`, - requireInclusionProof: false, // Proofs may be stripped in some flows - }); - if (!validation.isValid) { - console.error(`❌ BLOCKED: Attempted to add token with invalid data:`, { - tokenId: token.id, - errors: validation.errors, - }); - throw new Error(`Invalid token data: ${validation.errors[0]}`); - } - } catch (parseError) { - if (parseError instanceof SyntaxError) { - console.error(`❌ BLOCKED: Token jsonData is not valid JSON:`, token.id); - throw new Error(`Token jsonData is not valid JSON`); - } - throw parseError; - } - } - - const currentTokens = this._wallet.tokens; - - const isDuplicate = currentTokens.some((existing) => - this.isSameToken(existing, token) - ); - - if (isDuplicate) { - console.warn( - `⛔ Duplicate token detected (CoinID: ${token.coinId}). Skipping add.` - ); - return; - } - - if (currentTokens.some((t) => t.id === token.id)) { - console.warn(`Token ${token.id} already exists`); - return; - } - - const updatedTokens = [token, ...currentTokens]; - - const updatedWallet = new Wallet( - this._wallet.id, - this._wallet.name, - this._wallet.address, - updatedTokens - ); - - this.saveWallet(updatedWallet); - - // Archive the token (ensures every token is preserved for sanity check restoration) - this.archiveToken(token); - - // Add to transaction history (RECEIVED) - skip for change tokens from split - if (!skipHistory && token.coinId && token.amount) { - this.addTransactionToHistory({ - type: 'RECEIVED', - amount: token.amount, - coinId: token.coinId, - symbol: token.symbol || 'UNK', - iconUrl: token.iconUrl, - timestamp: token.timestamp, - senderPubkey: token.senderPubkey, - }); - } - - console.log(`💾 Repository: Saved! Total tokens: ${updatedTokens.length}`); - this.refreshWallet(); - } - - /** - * Update an existing token with a new version - * Used when remote has a better version (more transactions/proofs) - */ - updateToken(token: Token): void { - console.log("💾 Repository: Updating token...", token.id); - if (!this._wallet) { - console.error("💾 Repository: Wallet not initialized!"); - return; - } - - // CRITICAL: Validate token data before storing - if (token.jsonData) { - try { - const tokenJson = JSON.parse(token.jsonData); - const validation = validateTokenJson(tokenJson, { - context: `updateToken(${token.id})`, - requireInclusionProof: false, // Proofs may be stripped in some flows - }); - if (!validation.isValid) { - console.error(`❌ BLOCKED: Attempted to update token with invalid data:`, { - tokenId: token.id, - errors: validation.errors, - }); - throw new Error(`Invalid token data: ${validation.errors[0]}`); - } - } catch (parseError) { - if (parseError instanceof SyntaxError) { - console.error(`❌ BLOCKED: Token jsonData is not valid JSON:`, token.id); - throw new Error(`Token jsonData is not valid JSON`); - } - throw parseError; - } - } - - // Find the existing token by genesis tokenId - let existingIndex = -1; - let existingToken: Token | null = null; - - for (let i = 0; i < this._wallet.tokens.length; i++) { - const existing = this._wallet.tokens[i]; - // Compare by token ID from jsonData (genesis.data.tokenId) - if (existing.jsonData && token.jsonData) { - try { - const existingTxf = JSON.parse(existing.jsonData); - const incomingTxf = JSON.parse(token.jsonData); - if (existingTxf?.genesis?.data?.tokenId === incomingTxf?.genesis?.data?.tokenId) { - existingIndex = i; - existingToken = existing; - break; - } - } catch { - // Continue checking - } - } - // Fallback: compare by token.id - if (existing.id === token.id) { - existingIndex = i; - existingToken = existing; - break; - } - } - - if (existingIndex === -1 || !existingToken) { - console.warn(`💾 Repository: Token ${token.id} not found for update, adding instead`); - this.addToken(token, true); // skipHistory since it's an update - return; - } - - // Replace the token at the same position - const updatedTokens = [...this._wallet.tokens]; - updatedTokens[existingIndex] = token; - - const updatedWallet = new Wallet( - this._wallet.id, - this._wallet.name, - this._wallet.address, - updatedTokens - ); - - this.saveWallet(updatedWallet); - - // Archive the updated token - this.archiveToken(token); - - console.log(`💾 Repository: Updated token ${token.id.slice(0, 8)}...`); - this.refreshWallet(); - } - - removeToken(tokenId: string, recipientNametag?: string, skipHistory: boolean = false): void { - if (!this._wallet) return; - - // Find the token before removing to add to history - const tokenToRemove = this._wallet.tokens.find((t) => t.id === tokenId); - - // Archive the token before removing (preserves spent token history) - if (tokenToRemove?.jsonData) { - this.archiveToken(tokenToRemove); - } - - const updatedTokens = this._wallet.tokens.filter((t) => t.id !== tokenId); - const updatedWallet = new Wallet( - this._wallet.id, - this._wallet.name, - this._wallet.address, - updatedTokens - ); - - // Add to tombstones with state hash (prevents zombie token resurrection during IPFS sync) - // Extract current state hash from the token to tombstone the specific spent state - let stateHash = ""; - if (tokenToRemove?.jsonData) { - try { - const txf = JSON.parse(tokenToRemove.jsonData); - if (txf.transactions && txf.transactions.length > 0) { - // Use newStateHash from the last transaction - stateHash = txf.transactions[txf.transactions.length - 1].newStateHash || ""; - } else if (txf.genesis?.inclusionProof?.authenticator?.stateHash) { - // No transactions - use genesis state hash - stateHash = txf.genesis.inclusionProof.authenticator.stateHash; - } - } catch { - console.warn(`💀 Could not extract state hash for token ${tokenId.slice(0, 8)}...`); - } - } - - // Get the actual SDK token ID from genesis data - let actualTokenId = tokenId; - if (tokenToRemove?.jsonData) { - try { - const txf = JSON.parse(tokenToRemove.jsonData); - if (txf.genesis?.data?.tokenId) { - actualTokenId = txf.genesis.data.tokenId; - } - } catch { - // Use the provided tokenId as fallback - } - } - - // Only add if not already in tombstones (check by tokenId + stateHash) - const alreadyTombstoned = this._tombstones.some( - t => t.tokenId === actualTokenId && t.stateHash === stateHash - ); - - if (!alreadyTombstoned) { - const tombstone: TombstoneEntry = { - tokenId: actualTokenId, - stateHash, - timestamp: Date.now(), - }; - this._tombstones.push(tombstone); - console.log(`💀 Token ${actualTokenId.slice(0, 8)}... state ${stateHash.slice(0, 12)}... added to tombstones`); - } - - this.saveWallet(updatedWallet); - - // Add to transaction history (SENT) - skip for split operations - if (!skipHistory && tokenToRemove && tokenToRemove.coinId && tokenToRemove.amount) { - this.addTransactionToHistory({ - type: 'SENT', - amount: tokenToRemove.amount, - coinId: tokenToRemove.coinId, - symbol: tokenToRemove.symbol || 'UNK', - iconUrl: tokenToRemove.iconUrl, - timestamp: Date.now(), - recipientNametag: recipientNametag, - }); - } - - this.refreshWallet(); - } - - clearWallet(): void { - if (this._currentAddress) { - const storageKey = this.getStorageKey(this._currentAddress); - localStorage.removeItem(storageKey); - } - // Also remove legacy key if it exists - localStorage.removeItem(STORAGE_KEYS.WALLET_DATA_LEGACY); - this._wallet = null; - this._currentAddress = null; - this._nametag = null; - this._tombstones = []; - this._archivedTokens = new Map(); - this._forkedTokens = new Map(); - this._invalidatedNametags = []; - this.refreshWallet(); - } - - /** - * Reset in-memory state without touching localStorage - * Used when switching wallets - preserves per-identity token/nametag storage - */ - resetInMemoryState(): void { - this._wallet = null; - this._currentAddress = null; - this._nametag = null; - this._tombstones = []; - this._archivedTokens = new Map(); - this._forkedTokens = new Map(); - this._invalidatedNametags = []; - this.refreshWallet(); - } - - /** - * Clear ALL wallet data from localStorage - * This removes all per-address wallet data (tokens, nametags) - * Used when deleting wallet completely - */ - static clearAllWalletStorage(): void { - console.log("🗑️ Clearing all wallet storage from localStorage..."); - - // Find and remove all keys that start with wallet address prefix - const keysToRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith(STORAGE_KEY_PREFIXES.WALLET_ADDRESS)) { - keysToRemove.push(key); - } - } - - // Also remove legacy key and transaction history - keysToRemove.push(STORAGE_KEYS.WALLET_DATA_LEGACY); - keysToRemove.push(STORAGE_KEYS.TRANSACTION_HISTORY); - - // Remove all found keys - for (const key of keysToRemove) { - localStorage.removeItem(key); - console.log(` Removed: ${key}`); - } - - console.log(`🗑️ Cleared ${keysToRemove.length} wallet storage keys`); - } - - /** - * Get the current active address - */ - getCurrentAddress(): string | null { - return this._currentAddress; - } - - // ========================================== - // Nametag Methods (One per identity) - // ========================================== - - /** - * Set the nametag for the current wallet/identity - * Only one nametag is allowed per identity - * - * CRITICAL: Validates nametag data before saving to prevent corruption. - * Will throw if nametag.token is empty or invalid. - */ - setNametag(nametag: NametagData): void { - if (!this._wallet) { - console.error("Cannot set nametag: wallet not initialized"); - return; - } - - // CRITICAL VALIDATION: Prevent saving corrupted nametag data - try { - assertValidNametagData(nametag, "setNametag"); - } catch (validationError) { - console.error("❌ BLOCKED: Attempted to set invalid nametag data:", { - address: this._wallet.address.slice(0, 20) + "...", - nametagInfo: sanitizeNametagForLogging(nametag), - error: validationError instanceof Error ? validationError.message : String(validationError), - }); - throw validationError; - } - - this._nametag = nametag; - - // Re-save wallet to persist nametag - this.saveWallet(this._wallet); - - console.log(`💾 Nametag set for ${this._wallet.address}: ${nametag.name}`); - this.refreshWallet(); - } - - /** - * Get the nametag for the current wallet/identity - */ - getNametag(): NametagData | null { - return this._nametag; - } - - /** - * Clear the nametag for the current wallet/identity - */ - clearNametag(): void { - if (!this._wallet) return; - - this._nametag = null; - - // Re-save wallet without nametag - this.saveWallet(this._wallet); - - console.log(`💾 Nametag cleared for ${this._wallet.address}`); - this.refreshWallet(); - } - - /** - * Check if current identity already has a nametag - */ - hasNametag(): boolean { - return this._nametag !== null; - } - - // ========================================== - // Invalidated Nametag Methods - // ========================================== - - /** - * Add a nametag to the invalidated list - * Called when Nostr pubkey mismatch is detected - */ - addInvalidatedNametag(entry: InvalidatedNametagEntry): void { - // Avoid duplicates by name - const exists = this._invalidatedNametags.some(e => e.name === entry.name); - if (!exists) { - this._invalidatedNametags.push(entry); - if (this._wallet) { - this.saveWallet(this._wallet); - } - console.log(`💀 Nametag "${entry.name}" added to invalidated list: ${entry.invalidationReason}`); - } - } - - /** - * Get all invalidated nametags for this identity - */ - getInvalidatedNametags(): InvalidatedNametagEntry[] { - return [...this._invalidatedNametags]; - } - - /** - * Merge invalidated nametags from remote (IPFS sync) - * Returns number of nametags added - */ - mergeInvalidatedNametags(remoteEntries: InvalidatedNametagEntry[]): number { - let mergedCount = 0; - for (const entry of remoteEntries) { - const exists = this._invalidatedNametags.some(e => e.name === entry.name); - if (!exists) { - this._invalidatedNametags.push(entry); - mergedCount++; - } - } - if (mergedCount > 0 && this._wallet) { - this.saveWallet(this._wallet); - } - return mergedCount; - } - - /** - * Remove an invalidated nametag by name (for recovery from false positives) - * Returns the removed entry or null if not found - */ - removeInvalidatedNametag(nametagName: string): InvalidatedNametagEntry | null { - const index = this._invalidatedNametags.findIndex(e => e.name === nametagName); - if (index === -1) { - return null; - } - const [removed] = this._invalidatedNametags.splice(index, 1); - if (this._wallet) { - this.saveWallet(this._wallet); - } - return removed; - } - - /** - * Restore an invalidated nametag back to active status - * This removes it from invalidatedNametags and sets it as the current nametag - * Returns true if restored successfully, false if not found - */ - restoreInvalidatedNametag(nametagName: string): boolean { - const entry = this.removeInvalidatedNametag(nametagName); - if (!entry) { - console.warn(`Cannot restore nametag "${nametagName}" - not found in invalidated list`); - return false; - } - - // Restore as current nametag (without the invalidation metadata) - this._nametag = { - name: entry.name, - token: entry.token, - timestamp: entry.timestamp, - format: entry.format, - version: entry.version, - }; - - if (this._wallet) { - this.saveWallet(this._wallet); - } - - console.log(`✅ Restored nametag "${nametagName}" from invalidated list`); - this.refreshWallet(); - return true; - } - - refreshWallet(): void { - // Debounce at the source - coalesce rapid updates into one event - if (this._refreshDebounceTimer) { - clearTimeout(this._refreshDebounceTimer); - } - - this._refreshDebounceTimer = setTimeout(() => { - this._refreshDebounceTimer = null; - window.dispatchEvent(new Event("wallet-updated")); - }, 100); // 100ms debounce at source - } - - /** - * Force immediate cache refresh, bypassing the 100ms debounce. - * - * CRITICAL: Use this when a token MUST be visible to IPFS sync immediately. - * The normal refreshWallet() debounces by 100ms which can cause race conditions - * when IPFS sync is triggered immediately after saving a token. - * - * This method: - * 1. Cancels any pending debounced refresh - * 2. Dispatches wallet-updated event immediately - * - * Use case: After saving change token during split, before triggering IPFS sync. - */ - forceRefreshCache(): void { - // Cancel any pending debounced refresh - if (this._refreshDebounceTimer) { - clearTimeout(this._refreshDebounceTimer); - this._refreshDebounceTimer = null; - } - // Note: this._wallet is already updated by saveWallet() which is called before this - // We just need to dispatch the event immediately - window.dispatchEvent(new Event("wallet-updated")); - } - - // ========================================== - // Tombstone Methods (IPFS sync) - // ========================================== - - /** - * Get all tombstones (state-hash-aware entries) - * Used during IPFS sync to prevent zombie token resurrection - */ - getTombstones(): TombstoneEntry[] { - return [...this._tombstones]; - } - - /** - * Check if a specific token state is tombstoned - * Returns true if both tokenId AND stateHash match a tombstone - */ - isStateTombstoned(tokenId: string, stateHash: string): boolean { - return this._tombstones.some( - t => t.tokenId === tokenId && t.stateHash === stateHash - ); - } - - /** - * Merge remote tombstones into local - * Also removes any local tokens whose state matches a remote tombstone - */ - mergeTombstones(remoteTombstones: TombstoneEntry[]): number { - if (!this._wallet) return 0; - - let removedCount = 0; - - // Build a set of tombstoned states for quick lookup - const tombstoneKeys = new Set( - remoteTombstones.map(t => `${t.tokenId}:${t.stateHash}`) - ); - - // Find and remove any local tokens whose state matches a remote tombstone - const tokensToRemove: Token[] = []; - for (const token of this._wallet.tokens) { - // Extract tokenId and stateHash from the token's jsonData - if (token.jsonData) { - try { - const txf = JSON.parse(token.jsonData); - const sdkTokenId = txf.genesis?.data?.tokenId; - let currentStateHash = ""; - - if (txf.transactions && txf.transactions.length > 0) { - currentStateHash = txf.transactions[txf.transactions.length - 1].newStateHash || ""; - } - // NOTE: For genesis-only tokens (no transactions), we leave currentStateHash empty. - // The genesis.inclusionProof.authenticator.stateHash is the MINT COMMITMENT hash, - // NOT the state hash. Genesis-only tokens have never been transferred, so they - // shouldn't match any tombstones anyway (tombstones are created on transfer). - - const key = `${sdkTokenId}:${currentStateHash}`; - if (tombstoneKeys.has(key)) { - tokensToRemove.push(token); - } - } catch { - // Skip tokens with invalid jsonData - } - } - } - - for (const token of tokensToRemove) { - if (!this._wallet) break; // Type guard - // Remove from wallet without adding to history (it's a sync operation) - const currentTokens: Token[] = this._wallet.tokens; - const updatedTokens: Token[] = currentTokens.filter((t: Token) => t.id !== token.id); - this._wallet = new Wallet( - this._wallet.id, - this._wallet.name, - this._wallet.address, - updatedTokens - ); - console.log(`💀 Removed tombstoned token ${token.id.slice(0, 8)}... from local (state matched)`); - removedCount++; - } - - // Merge tombstones (union of local and remote by tokenId+stateHash) - for (const remoteTombstone of remoteTombstones) { - const alreadyExists = this._tombstones.some( - t => t.tokenId === remoteTombstone.tokenId && t.stateHash === remoteTombstone.stateHash - ); - if (!alreadyExists) { - this._tombstones.push(remoteTombstone); - } - } - - if (removedCount > 0) { - this.saveWallet(this._wallet); - this.refreshWallet(); - } - - return removedCount; - } - - /** - * Clear old tombstones (cleanup to prevent unlimited growth) - * Uses timestamp-based pruning - removes tombstones older than maxAge - */ - pruneTombstones(maxAge: number = 30 * 24 * 60 * 60 * 1000): void { - const now = Date.now(); - const originalCount = this._tombstones.length; - - // Filter by age (keep tombstones newer than maxAge) - this._tombstones = this._tombstones.filter(t => (now - t.timestamp) < maxAge); - - // Also limit to most recent 100 if still too many - if (this._tombstones.length > 100) { - // Sort by timestamp descending and keep newest 100 - this._tombstones.sort((a, b) => b.timestamp - a.timestamp); - this._tombstones = this._tombstones.slice(0, 100); - } - - if (this._tombstones.length < originalCount) { - if (this._wallet) { - this.saveWallet(this._wallet); - } - console.log(`💀 Pruned tombstones from ${originalCount} to ${this._tombstones.length}`); - } - } - - /** - * Remove a specific tombstone entry - * Used for recovery when tombstone is detected as invalid (token not actually spent) - */ - removeTombstone(tokenId: string, stateHash: string): boolean { - const initialLength = this._tombstones.length; - this._tombstones = this._tombstones.filter( - t => !(t.tokenId === tokenId && t.stateHash === stateHash) - ); - - if (this._tombstones.length < initialLength && this._wallet) { - this.saveWallet(this._wallet); - return true; - } - return false; - } - - /** - * Remove ALL tombstones for a given tokenId (regardless of stateHash) - * Used when archive recovery detects that a token is not actually spent - * and all tombstones for that token are invalid - */ - removeTombstonesForToken(tokenId: string): number { - const initialLength = this._tombstones.length; - this._tombstones = this._tombstones.filter(t => t.tokenId !== tokenId); - - const removedCount = initialLength - this._tombstones.length; - if (removedCount > 0 && this._wallet) { - this.saveWallet(this._wallet); - } - return removedCount; - } - - /** - * Revert a token to its last committed state - * Replaces the token in wallet with a reverted version - * Used for recovery when a transfer fails but token is still valid - */ - revertTokenToCommittedState(localId: string, revertedToken: Token): boolean { - if (!this._wallet) { - console.warn(`📦 Cannot revert token: no wallet loaded`); - return false; - } - - // Find the token index by localId - const tokenIndex = this._wallet.tokens.findIndex(t => t.id === localId); - - if (tokenIndex === -1) { - console.warn(`📦 Cannot revert token ${localId.slice(0, 8)}...: not found in wallet`); - return false; - } - - // Replace the token with the reverted version - const updatedTokens = [...this._wallet.tokens]; - updatedTokens[tokenIndex] = revertedToken; - - this._wallet = new Wallet( - this._wallet.id, - this._wallet.name, - this._wallet.address, - updatedTokens - ); - - this.saveWallet(this._wallet); - console.log(`📦 Reverted token ${localId.slice(0, 8)}... to committed state`); - - return true; - } - - // ========================================== - // Archived Token Methods (spent token history) - // ========================================== - - /** - * Archive a token before removal - * Only updates archive if incoming token is an incremental (non-forking) update - */ - archiveToken(token: Token): void { - if (!token.jsonData) return; - - let txfToken: TxfToken; - try { - txfToken = JSON.parse(token.jsonData); - } catch { - console.warn(`📦 Cannot archive token ${token.id.slice(0, 8)}...: invalid JSON`); - return; - } - - // Get the actual SDK token ID from genesis - const tokenId = txfToken.genesis?.data?.tokenId; - if (!tokenId) { - console.warn(`📦 Cannot archive token ${token.id.slice(0, 8)}...: missing genesis tokenId`); - return; - } - - // Check if we already have this token archived - const existingArchive = this._archivedTokens.get(tokenId); - - if (existingArchive) { - // Check if this is an incremental (non-forking) update - if (this.isIncrementalUpdate(existingArchive, txfToken)) { - this._archivedTokens.set(tokenId, txfToken); - console.log(`📦 Updated archived token ${tokenId.slice(0, 8)}... (incremental update: ${existingArchive.transactions.length} → ${txfToken.transactions.length} txns)`); - } else { - // This is a forking update - store as forked token instead - const stateHash = this.getCurrentStateHash(txfToken); - this.storeForkedToken(tokenId, stateHash, txfToken); - console.log(`📦 Archived token ${tokenId.slice(0, 8)}... is a fork, stored as forked`); - } - } else { - // First time archiving this token - this._archivedTokens.set(tokenId, txfToken); - console.log(`📦 Archived token ${tokenId.slice(0, 8)}... (${txfToken.transactions.length} txns)`); - } - - // Save to persist changes - if (this._wallet) { - this.saveWallet(this._wallet); - } - } - - /** - * Check if an incoming token is an incremental (non-forking) update to an existing archived token - * - * Incremental update criteria: - * 1. Same genesis (tokenId matches) - * 2. Incoming has >= transactions than existing - * 3. All existing transactions match incoming (same state hashes in order) - * 4. New transactions have inclusion proofs (committed) - */ - isIncrementalUpdate(existing: TxfToken, incoming: TxfToken): boolean { - // 1. Same genesis (tokenId must match) - if (existing.genesis?.data?.tokenId !== incoming.genesis?.data?.tokenId) { - return false; - } - - const existingTxns = existing.transactions || []; - const incomingTxns = incoming.transactions || []; - - // 2. Incoming must have >= transactions - if (incomingTxns.length < existingTxns.length) { - return false; - } - - // 3. All existing transactions must match incoming (same state hashes in order) - for (let i = 0; i < existingTxns.length; i++) { - const existingTx = existingTxns[i]; - const incomingTx = incomingTxns[i]; - - if (existingTx.previousStateHash !== incomingTx.previousStateHash || - existingTx.newStateHash !== incomingTx.newStateHash) { - return false; - } - } - - // 4. New transactions (if any) must have inclusion proofs (committed) - for (let i = existingTxns.length; i < incomingTxns.length; i++) { - const newTx = incomingTxns[i] as TxfTransaction; - if (newTx.inclusionProof === null) { - return false; - } - } - - return true; - } - - /** - * Get current state hash from a TxfToken - */ - private getCurrentStateHash(txf: TxfToken): string { - if (txf.transactions && txf.transactions.length > 0) { - return txf.transactions[txf.transactions.length - 1].newStateHash || ""; - } - return txf.genesis?.inclusionProof?.authenticator?.stateHash || ""; - } - - /** - * Store a forked token (alternative unconfirmed transaction history) - */ - storeForkedToken(tokenId: string, stateHash: string, txfToken: TxfToken): void { - const key = `${tokenId}_${stateHash}`; - - // Don't store if we already have this exact fork - if (this._forkedTokens.has(key)) { - return; - } - - this._forkedTokens.set(key, txfToken); - console.log(`📦 Stored forked token ${tokenId.slice(0, 8)}... state ${stateHash.slice(0, 12)}...`); - - // Save to persist changes - if (this._wallet) { - this.saveWallet(this._wallet); - } - } - - /** - * Get all archived tokens (spent token history) - */ - getArchivedTokens(): Map { - return new Map(this._archivedTokens); - } - - /** - * Get a specific archived token by tokenId - * Returns null if not found - */ - getArchivedToken(_address: string, tokenId: string): TxfToken | null { - // Note: _address parameter is for API consistency but not used since - // WalletRepository is already scoped to current wallet - return this._archivedTokens.get(tokenId) || null; - } - - /** - * Get the best archived version of a token (most committed transactions) - * Checks both _archivedTokens and _forkedTokens - * Used for sanity check restoration when tombstones are invalid - */ - getBestArchivedVersion(tokenId: string): TxfToken | null { - const candidates: TxfToken[] = []; - - // Check main archive - const archived = this._archivedTokens.get(tokenId); - if (archived) candidates.push(archived); - - // Check forked versions - for (const [key, forked] of this._forkedTokens) { - if (key.startsWith(tokenId + "_")) { - candidates.push(forked); - } - } - - if (candidates.length === 0) return null; - - // Sort by number of committed transactions (desc) - candidates.sort((a, b) => { - const aCommitted = (a.transactions || []).filter((tx: TxfTransaction) => tx.inclusionProof !== null).length; - const bCommitted = (b.transactions || []).filter((tx: TxfTransaction) => tx.inclusionProof !== null).length; - return bCommitted - aCommitted; - }); - - return candidates[0]; - } - - /** - * Restore a token from archive back to active tokens - * Used when sanity check detects invalid tombstone/missing token - * Returns true if restoration succeeded - */ - restoreTokenFromArchive(tokenId: string, txfToken: TxfToken): boolean { - if (!this._wallet) { - console.error("Cannot restore token: wallet not initialized"); - return false; - } - - try { - // Create Token from TxfToken - const coinData = txfToken.genesis?.data?.coinData || []; - const totalAmount = coinData.reduce((sum: bigint, [, amt]: [string, string]) => { - return sum + BigInt(amt || "0"); - }, BigInt(0)); - - // Get coin ID - let coinId = coinData[0]?.[0] || ""; - for (const [cid, amt] of coinData) { - if (BigInt(amt || "0") > 0) { - coinId = cid; - break; - } - } - - const tokenType = txfToken.genesis?.data?.tokenType || ""; - const isNft = tokenType === "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"; - - // Lookup registry for symbol and icon - let symbol = isNft ? "NFT" : "UCT"; - let iconUrl: string | undefined = undefined; - if (coinId && !isNft) { - const registryService = RegistryService.getInstance(); - const def = registryService.getCoinDefinition(coinId); - if (def) { - symbol = def.symbol || symbol; - iconUrl = registryService.getIconUrl(def) || undefined; - } - } - - const token = new Token({ - id: tokenId, - name: isNft ? "NFT" : symbol, - type: isNft ? "NFT" : symbol, - timestamp: Date.now(), - jsonData: JSON.stringify(txfToken), - status: TokenStatus.CONFIRMED, - amount: totalAmount.toString(), - coinId, - symbol, - iconUrl, - sizeBytes: JSON.stringify(txfToken).length, - }); - - // Check if token already exists - const existingIdx = this._wallet.tokens.findIndex(t => { - try { - const parsed = JSON.parse(t.jsonData || "{}"); - return parsed.genesis?.data?.tokenId === tokenId; - } catch { - return t.id === tokenId; - } - }); - - if (existingIdx !== -1) { - // Update existing token - const updatedTokens = [...this._wallet.tokens]; - updatedTokens[existingIdx] = token; - this._wallet = new Wallet( - this._wallet.id, - this._wallet.name, - this._wallet.address, - updatedTokens - ); - } else { - // Add new token - const updatedTokens = [token, ...this._wallet.tokens]; - this._wallet = new Wallet( - this._wallet.id, - this._wallet.name, - this._wallet.address, - updatedTokens - ); - } - - this.saveWallet(this._wallet); - console.log(`📦 Restored token ${tokenId.slice(0, 8)}... from archive`); - return true; - } catch (err) { - console.error(`Failed to restore token ${tokenId}:`, err); - return false; - } - } - - /** - * Get all forked tokens (alternative transaction histories) - */ - getForkedTokens(): Map { - return new Map(this._forkedTokens); - } - - /** - * Get a specific forked token by tokenId and stateHash - * Returns null if not found - */ - getForkedToken(_address: string, tokenId: string, stateHash: string): TxfToken | null { - // Note: _address parameter is for API consistency but not used since - // WalletRepository is already scoped to current wallet - const key = `${tokenId}_${stateHash}`; - return this._forkedTokens.get(key) || null; - } - - /** - * Import an archived token from remote (IPFS sync) - * Only updates if incoming is incremental or archive doesn't exist - */ - importArchivedToken(tokenId: string, txfToken: TxfToken): void { - const existingArchive = this._archivedTokens.get(tokenId); - - if (existingArchive) { - // Check if remote is an incremental update - if (this.isIncrementalUpdate(existingArchive, txfToken)) { - this._archivedTokens.set(tokenId, txfToken); - console.log(`📦 Imported remote archived token ${tokenId.slice(0, 8)}... (incremental update)`); - } else if (this.isIncrementalUpdate(txfToken, existingArchive)) { - // Local is more advanced - keep local - console.log(`📦 Kept local archived token ${tokenId.slice(0, 8)}... (local is more advanced)`); - } else { - // True fork - store remote as forked - const stateHash = this.getCurrentStateHash(txfToken); - this.storeForkedToken(tokenId, stateHash, txfToken); - console.log(`📦 Remote archived token ${tokenId.slice(0, 8)}... is a fork, stored as forked`); - } - } else { - // No local archive - accept remote - this._archivedTokens.set(tokenId, txfToken); - console.log(`📦 Imported remote archived token ${tokenId.slice(0, 8)}...`); - } - } - - /** - * Import a forked token from remote (IPFS sync) - */ - importForkedToken(key: string, txfToken: TxfToken): void { - if (!this._forkedTokens.has(key)) { - this._forkedTokens.set(key, txfToken); - console.log(`📦 Imported remote forked token ${key.slice(0, 20)}...`); - } - } - - /** - * Merge remote archived tokens into local - * Returns number of tokens updated/added - */ - mergeArchivedTokens(remoteArchived: Map): number { - let mergedCount = 0; - - for (const [tokenId, remoteTxf] of remoteArchived) { - const existingArchive = this._archivedTokens.get(tokenId); - - if (!existingArchive) { - // New token - add to archive - this._archivedTokens.set(tokenId, remoteTxf); - mergedCount++; - } else if (this.isIncrementalUpdate(existingArchive, remoteTxf)) { - // Remote is incremental update - accept - this._archivedTokens.set(tokenId, remoteTxf); - mergedCount++; - } else if (!this.isIncrementalUpdate(remoteTxf, existingArchive)) { - // True fork - store remote as forked - const stateHash = this.getCurrentStateHash(remoteTxf); - this.storeForkedToken(tokenId, stateHash, remoteTxf); - } - // Otherwise local is more advanced - keep local - } - - if (mergedCount > 0 && this._wallet) { - this.saveWallet(this._wallet); - } - - return mergedCount; - } - - /** - * Merge remote forked tokens into local (union merge) - * Returns number of tokens added - */ - mergeForkedTokens(remoteForked: Map): number { - let addedCount = 0; - - for (const [key, remoteTxf] of remoteForked) { - if (!this._forkedTokens.has(key)) { - this._forkedTokens.set(key, remoteTxf); - addedCount++; - } - } - - if (addedCount > 0 && this._wallet) { - this.saveWallet(this._wallet); - } - - return addedCount; - } - - /** - * Prune archived tokens to prevent unlimited growth - * Keeps most recently archived tokens up to maxCount - */ - pruneArchivedTokens(maxCount: number = 100): void { - if (this._archivedTokens.size <= maxCount) return; - - // Convert to array for sorting - we don't have timestamp on TxfToken - // so just keep arbitrary subset (could be improved by adding archive timestamp) - const entries = [...this._archivedTokens.entries()]; - const toRemove = entries.slice(0, entries.length - maxCount); - - for (const [tokenId] of toRemove) { - this._archivedTokens.delete(tokenId); - } - - if (this._wallet) { - this.saveWallet(this._wallet); - } - console.log(`📦 Pruned archived tokens to ${this._archivedTokens.size}`); - } - - /** - * Prune forked tokens to prevent unlimited growth - */ - pruneForkedTokens(maxCount: number = 50): void { - if (this._forkedTokens.size <= maxCount) return; - - const entries = [...this._forkedTokens.entries()]; - const toRemove = entries.slice(0, entries.length - maxCount); - - for (const [key] of toRemove) { - this._forkedTokens.delete(key); - } - - if (this._wallet) { - this.saveWallet(this._wallet); - } - console.log(`📦 Pruned forked tokens to ${this._forkedTokens.size}`); - } -} diff --git a/src/sdk/SphereProvider.tsx b/src/sdk/SphereProvider.tsx index fe85b438..68dcd8e5 100644 --- a/src/sdk/SphereProvider.tsx +++ b/src/sdk/SphereProvider.tsx @@ -239,7 +239,7 @@ export function SphereProvider({ if (sphere) { await sphere.destroy(); } - clearAllSphereData(true); + clearAllSphereData(); queryClient.clear(); setSphere(null); setWalletExists(false); diff --git a/src/components/wallet/L3/services/FaucetService.ts b/src/services/FaucetService.ts similarity index 100% rename from src/components/wallet/L3/services/FaucetService.ts rename to src/services/FaucetService.ts diff --git a/src/sphere-sdk-browser.d.ts b/src/sphere-sdk-browser.d.ts new file mode 100644 index 00000000..cffda3ca --- /dev/null +++ b/src/sphere-sdk-browser.d.ts @@ -0,0 +1,44 @@ +/** + * Type declarations for @unicitylabs/sphere-sdk/impl/browser + * + * The SDK's tsup config has dts: false for the browser entry point, + * so no .d.ts files are emitted. This module declaration provides + * the types needed by our adapter layer. + * + * TODO: Remove once sphere-sdk enables dts for impl/browser. + */ +declare module '@unicitylabs/sphere-sdk/impl/browser' { + import type { + NetworkType, + StorageProvider, + TransportProvider, + OracleProvider, + TokenStorageProvider, + TxfStorageDataBase, + PriceProvider, + } from '@unicitylabs/sphere-sdk'; + + export interface BrowserProvidersConfig { + network?: NetworkType; + storage?: Record; + transport?: Record; + oracle?: Record; + l1?: Record; + tokenSync?: Record; + price?: Record; + } + + export interface BrowserProviders { + storage: StorageProvider; + transport: TransportProvider; + oracle: OracleProvider; + tokenStorage: TokenStorageProvider; + l1?: Record; + price?: PriceProvider; + tokenSyncConfig?: Record; + } + + export function createBrowserProviders( + config?: BrowserProvidersConfig, + ): BrowserProviders; +} diff --git a/src/utils/devTools.ts b/src/utils/devTools.ts deleted file mode 100644 index 368bf668..00000000 --- a/src/utils/devTools.ts +++ /dev/null @@ -1,2300 +0,0 @@ -/** - * Developer Tools for AgentSphere - * - * This module provides utilities callable from the browser console for debugging - * and development purposes. Only loaded in development mode. - */ - -import { Token, TokenStatus } from "../components/wallet/L3/data/model"; -import { ServiceProvider } from "../components/wallet/L3/services/ServiceProvider"; -import { - getTokensForAddress, - getNametagForAddress, - setNametagForAddress, - getArchivedTokensForAddress, - getInvalidatedNametagsForAddress, - addToken as inventoryAddToken, - dispatchWalletUpdated, -} from "../components/wallet/L3/services/InventorySyncService"; -import { OutboxRepository } from "../repositories/OutboxRepository"; -import { TransferCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/TransferCommitment"; -import { MintCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment"; -import { MintTransactionData } from "@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData"; -import type { IMintTransactionReason } from "@unicitylabs/state-transition-sdk/lib/transaction/IMintTransactionReason"; -import { waitInclusionProof } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils"; -import { RequestId } from "@unicitylabs/state-transition-sdk/lib/api/RequestId"; -import { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/transaction/InclusionProof"; -import type { TxfToken, TxfInclusionProof, TxfTransaction, TxfGenesis } from "../components/wallet/L3/services/types/TxfTypes"; -import type { OutboxEntry } from "../components/wallet/L3/services/types/OutboxTypes"; - -// Imports for devTopup (fungible token minting) -import { TokenType } from "@unicitylabs/state-transition-sdk/lib/token/TokenType"; -import { TokenId } from "@unicitylabs/state-transition-sdk/lib/token/TokenId"; -import { Token as SdkToken } from "@unicitylabs/state-transition-sdk/lib/token/Token"; -import { TokenState } from "@unicitylabs/state-transition-sdk/lib/token/TokenState"; -import { TokenCoinData } from "@unicitylabs/state-transition-sdk/lib/token/fungible/TokenCoinData"; -import { CoinId } from "@unicitylabs/state-transition-sdk/lib/token/fungible/CoinId"; -import { UnmaskedPredicate } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate"; -import { SigningService } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService"; -import { HashAlgorithm } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm"; -import { IdentityManager } from "../components/wallet/L3/services/IdentityManager"; -import { IpfsStorageService } from "../components/wallet/L3/services/IpfsStorageService"; -import { getIpfsHttpResolver } from "../components/wallet/L3/services/IpfsHttpResolver"; -import { isActiveTokenKey, tokenIdFromKey, type TxfStorageData, type InvalidTokenEntry } from "../components/wallet/L3/services/types/TxfTypes"; -import { unicityIdValidator, type UnicityIdValidationResult } from "./unicityIdValidator"; - -// Type declarations for window extension -declare global { - interface Window { - devHelp: () => void; - devDumpLocalStorage: (filter?: string) => void; - devRefreshProofs: () => Promise; - devSetAggregatorUrl: (url: string | null) => void; - devGetAggregatorUrl: () => string; - devSkipTrustBaseVerification: () => void; - devEnableTrustBaseVerification: () => void; - devIsTrustBaseVerificationSkipped: () => boolean; - devTopup: (coins?: string[]) => Promise; - devReset: () => void; - devRecoverCorruptedTokens: () => Promise; - devDumpArchivedTokens: () => void; - devIpfsSync: () => Promise<{ success: boolean; cid?: string; error?: string }>; - devValidateUnicityId: () => Promise; - devRepairUnicityId: () => Promise; - devCheckNametag: (nametag: string) => Promise; - devRestoreNametag: (nametagName: string) => Promise; - devDumpNametagToken: () => Promise; - devInspectIpfs: () => Promise; - } -} - -/** - * Result of the devRecoverCorruptedTokens operation - */ -export interface RecoverCorruptedTokensResult { - success: boolean; - recovered: number; - failed: number; - details: Array<{ tokenId: string; status: string; error?: string }>; -} - -/** - * Result of the topup operation - */ -export interface TopupResult { - success: boolean; - mintedTokens: Array<{ coin: string; amount: string; tokenId: string }>; - errors: Array<{ coin: string; error: string }>; - duration: number; -} - -/** - * Coin configuration for dev topup - */ -const DEV_COIN_CONFIG = { - bitcoin: { - coinId: "86bc190fcf7b2d07c6078de93db803578760148b16d4431aa2f42a3241ff0daa", - amount: BigInt("100000000"), // 1 BTC (8 decimals) - symbol: "BTC", - }, - solana: { - coinId: "dee5f8ce778562eec90e9c38a91296a023210ccc76ff4c29d527ac3eb64ade93", - amount: BigInt("1000000000000"), // 1000 SOL (9 decimals) - symbol: "SOL", - }, - ethereum: { - coinId: "3c2450f2fd867e7bb60c6a69d7ad0e53ce967078c201a3ecaa6074ed4c0deafb", - amount: BigInt("42000000000000000000"), // 42 ETH (18 decimals) - symbol: "ETH", - }, -} as const; - -const UNICITY_TOKEN_TYPE_HEX = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"; - -/** - * Result of the proof refresh operation - */ -export interface RefreshProofsResult { - totalTokens: number; - refreshed: number; // Actually got new proofs from aggregator - kept: number; // Couldn't refresh but original proofs were valid - failed: number; // Couldn't refresh AND no valid original - errors: Array<{ tokenId: string; error: string }>; - duration: number; -} - -/** - * Internal type for tracking proof fetch requests - * - * For genesis proofs: tokenId is used to derive the requestId - * For transaction proofs: requestId cannot be derived from stateHash (they're different!) - * Must use OutboxEntry recovery - */ -interface ProofRequest { - type: "genesis" | "transaction"; - index?: number; - tokenId?: string; // For genesis: used to derive requestId - // Note: For transactions, we cannot derive requestId from any available data - // We must use OutboxEntry recovery which has the full commitment -} - -/** - * Submit a commitment to the aggregator - * Returns: "SUCCESS" | "REQUEST_ID_EXISTS" | error message - */ -async function submitCommitmentToAggregator( - commitment: TransferCommitment -): Promise<{ success: boolean; status: string }> { - try { - const client = ServiceProvider.stateTransitionClient; - const response = await client.submitTransferCommitment(commitment); - - if (response.status === "SUCCESS" || response.status === "REQUEST_ID_EXISTS") { - return { success: true, status: response.status }; - } - return { success: false, status: response.status }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - return { success: false, status: msg }; - } -} - -/** - * Submit a mint commitment to the aggregator - * Returns: "SUCCESS" | "REQUEST_ID_EXISTS" | error message - */ -async function submitMintCommitmentToAggregator( - commitment: MintCommitment -): Promise<{ success: boolean; status: string }> { - try { - const client = ServiceProvider.stateTransitionClient; - const response = await client.submitMintCommitment(commitment); - - if (response.status === "SUCCESS" || response.status === "REQUEST_ID_EXISTS") { - return { success: true, status: response.status }; - } - return { success: false, status: response.status }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - return { success: false, status: msg }; - } -} - -/** - * Reconstruct a MintCommitment from TxfGenesisData - * This allows refreshing mint proofs after aggregator tree reset. - * - * Uses MintTransactionData.fromJSON() to parse the genesis data and - * then creates a MintCommitment from it. - */ -async function reconstructMintCommitment( - genesis: TxfGenesis -): Promise<{ commitment: MintCommitment | null; error?: string }> { - try { - const data = genesis.data; - - // Debug: log what we're working with - console.log(` Debug genesis keys: ${genesis ? Object.keys(genesis).join(", ") : "(null)"}`); - console.log(` Debug data keys: ${data ? Object.keys(data).join(", ") : "(null)"}`); - if (data) { - console.log(` Debug data.tokenId: ${data.tokenId?.slice(0, 16)}...`); - console.log(` Debug data.coinData: ${data.coinData ? `array[${data.coinData.length}]` : "(null)"}`); - console.log(` Debug data.salt: ${data.salt ? data.salt.slice(0, 16) + "..." : "(null)"}`); - } - - // Convert TxfGenesisData to IMintTransactionDataJson format - // TxfGenesisData.coinData is [string, string][] which matches TokenCoinDataJson - // So we just pass it through directly - const mintDataJson = { - tokenId: data.tokenId, - tokenType: data.tokenType, - tokenData: data.tokenData || null, - coinData: data.coinData && data.coinData.length > 0 ? data.coinData : null, - recipient: data.recipient, - salt: data.salt, - recipientDataHash: data.recipientDataHash, - reason: data.reason ? JSON.parse(data.reason) : null, - }; - - console.log(` Debug mintDataJson created successfully`); - - // Use SDK's fromJSON to properly reconstruct the MintTransactionData - const mintTransactionData = await MintTransactionData.fromJSON(mintDataJson); - - // Create commitment from transaction data - const commitment = await MintCommitment.create(mintTransactionData); - return { commitment }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - console.error(` Debug reconstruction error: ${msg}`); - return { commitment: null, error: msg }; - } -} - -// Note: To derive requestId from genesis data, we use reconstructMintCommitment() -// which properly creates the MintCommitment with correct requestId. -// The requestId depends on both the tokenId and the MintTransactionData.sourceState. - -/** - * Check if a proof JSON is an INCLUSION proof (has authenticator) vs EXCLUSION proof (no authenticator). - * Uses SDK's InclusionProof class for proper type-safe checking. - * - * Per SDK: InclusionProof.authenticator is `Authenticator | null` - * - If authenticator is present: inclusion proof (commitment exists in tree) - * - If authenticator is null: exclusion proof (commitment NOT in tree, e.g., after tree reset) - */ -function isInclusionProofNotExclusion(proofJson: TxfInclusionProof | null): boolean { - if (!proofJson) return false; - - try { - // Use SDK's InclusionProof to properly parse and check - const sdkProof = InclusionProof.fromJSON(proofJson); - // SDK defines: authenticator: Authenticator | null - // null = exclusion proof, non-null = inclusion proof - return sdkProof.authenticator !== null; - } catch { - // If parsing fails, check raw JSON as fallback - return proofJson.authenticator !== null && proofJson.authenticator !== undefined; - } -} - -/** - * Wait for mint inclusion proof using the SDK - */ -async function waitForMintProofWithSDK( - commitment: MintCommitment, - timeoutMs: number = 30000 -): Promise { - try { - const trustBase = ServiceProvider.getRootTrustBase(); - const client = ServiceProvider.stateTransitionClient; - - // If trust base verification is skipped, use direct polling - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - // Use toJSON() to get hex format, not toString() which gives human-readable format - return await pollForProofNoVerify(commitment.requestId.toJSON(), timeoutMs); - } - - const signal = AbortSignal.timeout(timeoutMs); - const inclusionProof = await waitInclusionProof(trustBase, client, commitment, signal); - return inclusionProof.toJSON() as TxfInclusionProof; - } catch (error) { - console.warn("Failed to wait for mint proof via SDK:", error); - return null; - } -} - -/** - * Wait for inclusion proof using the SDK (handles longer waits for block inclusion) - */ -async function waitForProofWithSDK( - commitment: TransferCommitment, - timeoutMs: number = 30000 -): Promise { - try { - const trustBase = ServiceProvider.getRootTrustBase(); - const client = ServiceProvider.stateTransitionClient; - - // If trust base verification is skipped, use direct polling - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - // Use toJSON() to get hex format, not toString() which gives human-readable format - return await pollForProofNoVerify(commitment.requestId.toJSON(), timeoutMs); - } - - const signal = AbortSignal.timeout(timeoutMs); - const inclusionProof = await waitInclusionProof(trustBase, client, commitment, signal); - return inclusionProof.toJSON() as TxfInclusionProof; - } catch (error) { - console.warn("Failed to wait for proof via SDK:", error); - return null; - } -} - -/** - * Poll for proof without verification (dev mode) - * Uses the SDK's StateTransitionClient.getInclusionProof() method - * - * IMPORTANT: This function waits for an actual INCLUSION proof (with authenticator), - * not just any proof. An exclusion proof (no authenticator) means the commitment - * hasn't been included in the tree yet, so we keep polling. - */ -async function pollForProofNoVerify( - requestIdStr: string, - timeoutMs: number = 30000, - intervalMs: number = 1000 -): Promise { - const client = ServiceProvider.stateTransitionClient; - const requestId = RequestId.fromJSON(requestIdStr); - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - try { - const response = await client.getInclusionProof(requestId); - - if (response.inclusionProof) { - const proofJson = response.inclusionProof.toJSON() as TxfInclusionProof; - - // Check if this is an actual INCLUSION proof (has authenticator) - // An exclusion proof (no authenticator) means the commitment hasn't been - // included in the aggregator tree yet - keep polling - if (isInclusionProofNotExclusion(proofJson)) { - console.warn("⚠️ Returning inclusion proof WITHOUT verification (dev mode)"); - return proofJson; - } else { - // Got exclusion proof - commitment not yet in tree, keep polling - console.log(" Polling: got exclusion proof (no authenticator), waiting for inclusion..."); - await new Promise(resolve => setTimeout(resolve, intervalMs)); - continue; - } - } - - // No proof at all, keep polling - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } catch (error: unknown) { - // 404 means proof not ready yet, keep polling - const err = error as { status?: number }; - if (err?.status === 404) { - await new Promise(resolve => setTimeout(resolve, intervalMs)); - continue; - } - // Other errors - retry - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } - } - - return null; -} - -/** - * Wait for inclusion proof with dev mode bypass. - * - * When trust base verification is skipped (dev mode), this function polls - * for the proof without SDK verification. Otherwise, it uses the normal - * SDK waitInclusionProof with trust base verification. - * - * This is the PUBLIC API for use by transfer flows (useWallet.ts, etc.) - * - * @param commitment - TransferCommitment or MintCommitment - * @param timeoutMs - Timeout in milliseconds (default 60s) - * @returns SDK InclusionProof object - * @throws Error if proof cannot be obtained - */ -export async function waitInclusionProofWithDevBypass( - commitment: TransferCommitment | MintCommitment, - timeoutMs: number = 60000 -): Promise { - const trustBase = ServiceProvider.getRootTrustBase(); - const client = ServiceProvider.stateTransitionClient; - - // If trust base verification is skipped, use direct polling - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.log("⚠️ Dev mode: bypassing trust base verification for proof"); - const proofJson = await pollForProofNoVerify(commitment.requestId.toJSON(), timeoutMs); - if (!proofJson) { - throw new Error("Failed to get inclusion proof (dev mode)"); - } - return InclusionProof.fromJSON(proofJson); - } - - // Normal mode: use SDK's waitInclusionProof with trust base verification - const signal = AbortSignal.timeout(timeoutMs); - return await waitInclusionProof(trustBase, client, commitment, signal); -} - -/** - * Fetch a proof from the aggregator using requestId with retry logic - * Uses the SDK's StateTransitionClient.getInclusionProof() method - */ -async function fetchProofByRequestId( - requestIdStr: string, - maxRetries: number = 3 -): Promise { - const client = ServiceProvider.stateTransitionClient; - const requestId = RequestId.fromJSON(requestIdStr); - let lastError: Error | null = null; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - const response = await client.getInclusionProof(requestId); - - if (response.inclusionProof) { - const proofJson = response.inclusionProof.toJSON(); - return proofJson as TxfInclusionProof; - } - - // No proof available - return null; - } catch (error: unknown) { - // 404 means proof doesn't exist - const err = error as { status?: number }; - if (err?.status === 404) { - return null; - } - - lastError = error instanceof Error ? error : new Error(String(error)); - if (attempt < maxRetries) { - // Exponential backoff: 500ms, 1s, 2s... - await new Promise(resolve => setTimeout(resolve, 500 * Math.pow(2, attempt))); - } - } - } - - console.warn(`Failed to fetch proof for requestId ${requestIdStr.slice(0, 16)}... after ${maxRetries + 1} attempts:`, lastError); - return null; -} - -/** - * Try to recover a token using OutboxEntry if available - * This handles the case where commitment was never submitted or proof never received. - * - * @param tokenId - The token ID to recover - * @param forceResubmit - If true, accept any entry with commitmentJson (for tree reset scenario) - * If false, only accept uncommitted entries (READY_TO_SUBMIT, SUBMITTED) - */ -async function tryRecoverFromOutbox( - tokenId: string, - forceResubmit: boolean = false -): Promise<{ recovered: boolean; proof?: TxfInclusionProof; message: string }> { - try { - const outboxRepo = OutboxRepository.getInstance(); - const entries = outboxRepo.getAllEntries(); - - // Find matching outbox entry by tokenId - const entry = entries.find((e: OutboxEntry) => { - if (e.sourceTokenId !== tokenId) return false; - if (forceResubmit) { - // Tree reset: accept any entry with commitment data, including PROOF_RECEIVED - return !!e.commitmentJson; - } - // Normal: only uncommitted entries - return e.status === "READY_TO_SUBMIT" || e.status === "SUBMITTED"; - }); - - if (!entry) { - return { recovered: false, message: "No matching outbox entry found" }; - } - - if (!entry.commitmentJson) { - return { recovered: false, message: "Outbox entry missing commitment data" }; - } - - console.log(`📤 Found outbox entry for token ${tokenId.slice(0, 12)}... (status: ${entry.status})`); - - // Reconstruct commitment - const commitmentData = JSON.parse(entry.commitmentJson); - const commitment = await TransferCommitment.fromJSON(commitmentData); - - // Submit commitment (idempotent - REQUEST_ID_EXISTS is OK) - const submitResult = await submitCommitmentToAggregator(commitment); - console.log(` Submission result: ${submitResult.status}`); - - if (!submitResult.success) { - return { recovered: false, message: `Submission failed: ${submitResult.status}` }; - } - - // Wait for inclusion proof - console.log(` Waiting for inclusion proof...`); - const proof = await waitForProofWithSDK(commitment, 60000); // 60 second timeout - - if (!proof) { - return { recovered: false, message: "Timeout waiting for inclusion proof" }; - } - - // Update outbox entry status - outboxRepo.updateStatus(entry.id, "PROOF_RECEIVED"); - - return { recovered: true, proof, message: "Recovered via outbox" }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - return { recovered: false, message: `Recovery error: ${msg}` }; - } -} - -/** - * Collect proof requests from a TxfToken WITHOUT modifying it. - * Returns the list of proof requests needed for refresh. - */ -function collectProofRequests(txf: TxfToken): { - requests: ProofRequest[]; - hasTransactions: boolean; -} { - const requests: ProofRequest[] = []; - let hasTransactions = false; - - // Handle genesis proof - // For genesis, we can derive the requestId from the tokenId - if (txf.genesis) { - // Extract tokenId from genesis data for requestId derivation - const tokenId = txf.genesis.data?.tokenId; - requests.push({ - type: "genesis", - tokenId: tokenId, // Will be used to derive requestId - }); - } - - // Handle transaction proofs - // For transactions, we CANNOT derive requestId from stateHash or any other field - // We must use OutboxEntry recovery which has the full commitment data - if (txf.transactions && txf.transactions.length > 0) { - hasTransactions = true; - for (let i = 0; i < txf.transactions.length; i++) { - requests.push({ - type: "transaction", - index: i, - // No tokenId or requestId - must use OutboxEntry recovery - }); - } - } - - return { requests, hasTransactions }; -} - -/** - * Re-fetch all unicity proofs for all tokens in the wallet - * - * This function: - * 1. Scans all loaded L3 tokens - * 2. Tries to fetch fresh proofs from the aggregator - * 3. Only updates tokens if ALL proofs were successfully fetched - * 4. Preserves original valid proofs if refresh fails - * - * Results: - * - refreshed: Actually got new proofs from aggregator - * - kept: Couldn't refresh but original proofs were valid (no changes made) - * - failed: Couldn't refresh AND no valid original proofs - * - * Usage from browser console: await window.devRefreshProofs() - */ -export async function devRefreshProofs(): Promise { - const startTime = Date.now(); - const errors: Array<{ tokenId: string; error: string }> = []; - let refreshed = 0; - let kept = 0; - let failed = 0; - - console.group("🔄 Dev: Refreshing Unicity Proofs"); - console.log(`📡 Aggregator: ${ServiceProvider.getAggregatorUrl()}`); - console.log(`🔐 Trust base verification: ${ServiceProvider.isTrustBaseVerificationSkipped() ? "SKIPPED" : "enabled"}`); - - // Clear the spent state cache since we're regenerating proofs - // This ensures tokens will be re-verified against the aggregator - try { - const { getTokenValidationService } = await import("../components/wallet/L3/services/TokenValidationService"); - const validationService = getTokenValidationService(); - validationService.clearSpentStateCache(); - console.log(`📦 Cleared spent state cache for proof refresh`); - } catch (err) { - console.warn("⚠️ Could not clear spent state cache:", err); - } - - // Get current wallet address - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - if (!identity) { - console.error("❌ No wallet identity available"); - console.groupEnd(); - return { - totalTokens: 0, - refreshed: 0, - kept: 0, - failed: 0, - errors: [{ tokenId: "all", error: "No identity" }], - duration: Date.now() - startTime, - }; - } - - const tokens = getTokensForAddress(identity.address); - const nametag = getNametagForAddress(identity.address); - - // Include nametag token if present - const hasNametag = !!(nametag?.token); - - const totalToProcess = tokens.length + (hasNametag ? 1 : 0); - console.log(`📦 Found ${tokens.length} tokens${hasNametag ? ` + 1 nametag ("${nametag?.name}")` : ""} to process`); - - if (totalToProcess === 0) { - console.log("No tokens found in wallet"); - console.groupEnd(); - return { - totalTokens: 0, - refreshed: 0, - kept: 0, - failed: 0, - errors: [], - duration: Date.now() - startTime, - }; - } - - // Process nametag first if present - if (hasNametag && nametag?.token) { - console.group(`🏷️ Nametag "${nametag.name}"`); - - // The token is already an object from storage (NametagData.token is typed as object) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nametagTxf = nametag.token as any; - const keys = Object.keys(nametagTxf); - - if (keys.length === 0) { - console.error(" ❌ Nametag token is empty - may need to re-mint"); - errors.push({ tokenId: `nametag:${nametag.name}`, error: "Token data is empty" }); - failed++; - console.groupEnd(); - } else if (!nametagTxf.genesis) { - console.error(" ❌ Nametag token has no 'genesis' property"); - errors.push({ tokenId: `nametag:${nametag.name}`, error: "Invalid token structure - no genesis" }); - failed++; - console.groupEnd(); - } else { - // Check if original proof is valid (for fallback decision) - const originalProofValid = isInclusionProofNotExclusion(nametagTxf.genesis?.inclusionProof as TxfInclusionProof | null); - - // Try to fetch new proof - let newProof: TxfInclusionProof | null = null; - const genesisData = nametagTxf.genesis.data; - - if (genesisData && genesisData.salt) { - try { - const txfGenesis = { - data: genesisData, - inclusionProof: nametagTxf.genesis.inclusionProof, - } as TxfGenesis; - - console.log(` genesis: Reconstructing mint commitment...`); - const result = await reconstructMintCommitment(txfGenesis); - if (result.commitment) { - const correctRequestId = result.commitment.requestId.toJSON(); - console.log(` genesis: Fetching proof by requestId...`); - newProof = await fetchProofByRequestId(correctRequestId); - - if (isInclusionProofNotExclusion(newProof)) { - console.log(` ✅ genesis: Inclusion proof fetched successfully`); - } else { - // Try resubmission - if (newProof) { - console.log(` ⚠️ genesis: Got EXCLUSION proof - tree was reset, resubmitting...`); - } else { - console.log(` genesis: No proof found, resubmitting...`); - } - const submitResult = await submitMintCommitmentToAggregator(result.commitment); - console.log(` genesis: Submission result: ${submitResult.status}`); - if (submitResult.success) { - newProof = await waitForMintProofWithSDK(result.commitment, 60000); - if (isInclusionProofNotExclusion(newProof)) { - console.log(` ✅ genesis: New inclusion proof obtained after resubmission`); - } else { - newProof = null; - console.warn(` ⚠️ genesis: Failed to get valid proof after resubmission`); - } - } else { - newProof = null; - } - } - } else { - console.warn(` ❌ genesis: Cannot reconstruct mint: ${result.error}`); - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(` ❌ genesis: Reconstruction error: ${msg}`); - } - } else { - console.warn(` ❌ genesis: Missing genesis.data or salt for reconstruction`); - } - - // Decide: refreshed, kept, or failed - if (newProof) { - // Got new proof - update and save - nametagTxf.genesis.inclusionProof = newProof; - setNametagForAddress(identity.address, { ...nametag, token: nametagTxf }); - refreshed++; - console.log(`✅ Nametag proof refreshed`); - } else if (originalProofValid) { - // Couldn't refresh but original is valid - keep it (don't save anything) - kept++; - console.log(`ℹ️ Keeping original valid proof (couldn't refresh from aggregator)`); - } else { - // Couldn't refresh AND original invalid - failed++; - errors.push({ tokenId: `nametag:${nametag.name}`, error: "Couldn't refresh and original proof invalid" }); - console.warn(`❌ Failed - no valid proofs available`); - } - console.groupEnd(); - } - } - - // Process tokens sequentially for clear progress tracking - for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { - const token = tokens[tokenIndex]; - const tokenIdShort = token.id.slice(0, 12); - - console.group(`📦 Token ${tokenIndex + 1}/${tokens.length}: ${tokenIdShort}...`); - - if (!token.jsonData) { - console.warn("⚠️ Token has no jsonData, skipping"); - errors.push({ tokenId: token.id, error: "No jsonData" }); - failed++; - console.groupEnd(); - continue; - } - - let txf: TxfToken; - try { - txf = JSON.parse(token.jsonData) as TxfToken; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error("❌ Failed to parse jsonData:", msg); - errors.push({ tokenId: token.id, error: `Parse error: ${msg}` }); - failed++; - console.groupEnd(); - continue; - } - - // Collect proof requests WITHOUT stripping - const { requests, hasTransactions } = collectProofRequests(txf); - - if (requests.length === 0) { - console.log("ℹ️ No proofs to refresh (token has no genesis/transactions)"); - kept++; - console.groupEnd(); - continue; - } - - console.log(`🔍 Processing ${requests.length} proof request(s) individually...`); - if (hasTransactions) { - console.log(`⚠️ Token has transactions - will attempt outbox recovery`); - } - - // Process each proof INDIVIDUALLY - update token proof-by-proof - // This supports different aggregators for different transactions - let anyProofRefreshed = false; - let anyProofFailed = false; - let tokenModified = false; - - for (const req of requests) { - const { type, index } = req; - const label = `${type}${index !== undefined ? ` #${index}` : ""}`; - - // Get the current/original proof for this request - let originalProof: TxfInclusionProof | null = null; - if (type === "genesis") { - originalProof = txf.genesis?.inclusionProof as TxfInclusionProof | null; - } else if (type === "transaction" && index !== undefined) { - originalProof = txf.transactions?.[index]?.inclusionProof as TxfInclusionProof | null; - } - const originalProofValid = isInclusionProofNotExclusion(originalProof); - - let newProof: TxfInclusionProof | null = null; - - // Strategy for genesis proofs - if (type === "genesis") { - const result = await reconstructMintCommitment(txf.genesis); - if (result.commitment) { - const derivedRequestId = result.commitment.requestId.toJSON(); - console.log(` ${label}: Fetching proof...`); - - newProof = await fetchProofByRequestId(derivedRequestId); - - if (isInclusionProofNotExclusion(newProof)) { - console.log(` ✅ ${label}: Inclusion proof fetched`); - } else { - // Try resubmission - if (newProof) { - console.log(` ⚠️ ${label}: Got EXCLUSION proof, resubmitting...`); - } else { - console.log(` ${label}: No proof found, resubmitting...`); - } - const submitResult = await submitMintCommitmentToAggregator(result.commitment); - console.log(` ${label}: Submission result: ${submitResult.status}`); - if (submitResult.success) { - newProof = await waitForMintProofWithSDK(result.commitment, 60000); - if (isInclusionProofNotExclusion(newProof)) { - console.log(` ✅ ${label}: New proof obtained after resubmission`); - } else { - newProof = null; - console.warn(` ⚠️ ${label}: Failed to get valid proof`); - } - } else { - newProof = null; - } - } - } else { - console.warn(` ❌ ${label}: Cannot reconstruct: ${result.error}`); - } - } - - // Strategy for transaction proofs - use OutboxEntry recovery - if (type === "transaction") { - console.log(` ${label}: Attempting outbox recovery...`); - const recovery = await tryRecoverFromOutbox(token.id, true); - - if (recovery.recovered && recovery.proof && isInclusionProofNotExclusion(recovery.proof)) { - newProof = recovery.proof; - console.log(` ✅ ${label}: ${recovery.message}`); - } else { - console.warn(` ⚠️ ${label}: ${recovery.message}`); - } - } - - // Decide what to do with this individual proof - if (newProof) { - // Got new proof - update this specific proof in the token - if (type === "genesis") { - (txf.genesis as { inclusionProof: TxfInclusionProof | null }).inclusionProof = newProof; - } else if (type === "transaction" && index !== undefined) { - txf.transactions[index] = { - ...txf.transactions[index], - inclusionProof: newProof, - } as TxfTransaction; - } - anyProofRefreshed = true; - tokenModified = true; - console.log(` 🔄 ${label}: Updated with new proof`); - } else if (originalProofValid) { - // Couldn't get new proof but original is valid - keep it - console.log(` ℹ️ ${label}: Keeping original valid proof`); - } else { - // Couldn't get new proof AND original was invalid - this is a failure - anyProofFailed = true; - console.warn(` ❌ ${label}: No valid proof available`); - if (type === "transaction") { - console.log(` 💡 If this is a received token, ask the sender to re-transfer`); - } - } - } - - // Save token if any proof was modified - if (tokenModified) { - const updatedToken = new Token({ - ...token, - jsonData: JSON.stringify(txf), - status: anyProofFailed ? token.status : TokenStatus.CONFIRMED, - }); - try { - await inventoryAddToken( - identity.address, - identity.publicKey, - identity.ipnsName ?? '', - updatedToken, - { local: true } - ); - dispatchWalletUpdated(); - console.log(`💾 Token saved with updated proofs`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`❌ Failed to save token:`, msg); - errors.push({ tokenId: token.id, error: `Save error: ${msg}` }); - failed++; - console.groupEnd(); - continue; - } - } - - // Categorize the token result - if (anyProofRefreshed && !anyProofFailed) { - refreshed++; - console.log(`✅ Token: all proofs valid (some refreshed)`); - } else if (anyProofRefreshed && anyProofFailed) { - // Partial success - some refreshed, some failed - refreshed++; - errors.push({ tokenId: token.id, error: "Some proofs couldn't be refreshed" }); - console.warn(`⚠️ Token: partial success (some proofs refreshed, some failed)`); - } else if (!anyProofFailed) { - // No refreshes but all originals were valid - kept++; - console.log(`ℹ️ Token: keeping all original valid proofs`); - } else { - // No refreshes and some proofs are invalid - failed++; - errors.push({ tokenId: token.id, error: "Some proofs invalid and couldn't be refreshed" }); - console.warn(`❌ Token: has invalid proofs that couldn't be refreshed`); - } - - console.groupEnd(); - } - - // Trigger UI refresh - window.dispatchEvent(new Event("wallet-updated")); - - const duration = Date.now() - startTime; - console.log(`\n✅ Complete: ${refreshed} refreshed, ${kept} kept, ${failed} failed (${duration}ms)`); - if (failed > 0) { - console.log(`\n💡 Tips for failed tokens:`); - console.log(` - If commitment wasn't submitted: check if OutboxEntry exists`); - console.log(` - If using different aggregator: commitment may not exist there`); - console.log(` - For uncommitted transfers: use OutboxRecoveryService`); - } - console.groupEnd(); - - return { - totalTokens: totalToProcess, - refreshed, - kept, - failed, - errors, - duration, - }; -} - -/** - * Mint a single fungible token with the specified coin configuration. - * Internal helper for devTopup(). - */ -async function mintFungibleToken( - _coinName: string, - coinConfig: { coinId: string; amount: bigint; symbol: string }, - identityManager: IdentityManager, - secret: Buffer -): Promise<{ success: boolean; token?: Token; tokenId?: string; error?: string }> { - try { - // 1. Generate random tokenId and salt - const tokenIdBytes = new Uint8Array(32); - window.crypto.getRandomValues(tokenIdBytes); - const tokenId = new TokenId(tokenIdBytes); - - const salt = new Uint8Array(32); - window.crypto.getRandomValues(salt); - - // 2. Create token type - const tokenType = new TokenType(Buffer.from(UNICITY_TOKEN_TYPE_HEX, "hex")); - - // 3. Get recipient address - const ownerAddress = await identityManager.getWalletAddress(); - if (!ownerAddress) throw new Error("No wallet address"); - - // 4. Create coin data - const coinIdBuffer = Buffer.from(coinConfig.coinId, "hex"); - const coinId = new CoinId(coinIdBuffer); - const coinData = TokenCoinData.create([[coinId, coinConfig.amount]]); - - // 5. Create mint transaction data - const mintData = await MintTransactionData.create( - tokenId, - tokenType, - null, // tokenData - coinData, - ownerAddress, - Buffer.from(salt), - null, // recipientDataHash - null // reason - ); - - // 6. Create commitment - const commitment = await MintCommitment.create(mintData); - - // 7. Submit to aggregator - const client = ServiceProvider.stateTransitionClient; - const response = await client.submitMintCommitment(commitment); - - if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") { - return { success: false, error: `Submission failed: ${response.status}` }; - } - - // 8. Wait for inclusion proof - let proof: TxfInclusionProof | null; - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - proof = await pollForProofNoVerify(commitment.requestId.toJSON(), 60000); - } else { - const sdkProof = await waitInclusionProof( - ServiceProvider.getRootTrustBase(), - client, - commitment - ); - proof = sdkProof.toJSON() as TxfInclusionProof; - } - - if (!proof || !isInclusionProofNotExclusion(proof)) { - return { success: false, error: "Failed to get inclusion proof" }; - } - - // 9. Create token with predicate - const signingService = await SigningService.createFromSecret(secret); - const predicate = await UnmaskedPredicate.create( - tokenId, - tokenType, - signingService, - HashAlgorithm.SHA256, - Buffer.from(salt) - ); - - // 10. Create SDK token - const genesisTransaction = commitment.toTransaction( - InclusionProof.fromJSON(proof) - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let sdkToken: SdkToken; - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - // Dev mode: create without verification - const tokenJson = { - version: "2.0", - state: new TokenState(predicate, null).toJSON(), - genesis: genesisTransaction.toJSON(), - transactions: [], - nametags: [], - }; - sdkToken = await SdkToken.fromJSON(tokenJson); - } else { - sdkToken = await SdkToken.mint( - ServiceProvider.getRootTrustBase(), - new TokenState(predicate, null), - genesisTransaction - ); - } - - // 11. Create app Token and save to wallet - const appToken = new Token({ - id: crypto.randomUUID(), - name: coinConfig.symbol, - type: "fungible", - jsonData: JSON.stringify(sdkToken.toJSON()), - status: TokenStatus.CONFIRMED, - symbol: coinConfig.symbol, - amount: coinConfig.amount.toString(), - coinId: coinConfig.coinId, - timestamp: Date.now(), - }); - - const ownerIdentity = await identityManager.getCurrentIdentity(); - if (!ownerIdentity) { - return { success: false, error: "No identity available" }; - } - - await inventoryAddToken( - ownerIdentity.address, - ownerIdentity.publicKey, - ownerIdentity.ipnsName ?? '', - appToken, - { local: true } - ); - dispatchWalletUpdated(); - - return { - success: true, - token: appToken, - tokenId: Buffer.from(tokenIdBytes).toString("hex"), - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { success: false, error: msg }; - } -} - -/** - * Mint fungible tokens directly to the current wallet (dev/testing only). - * - * This function mints tokens locally using the SDK and aggregator, without - * relying on the external faucet service. Tokens are saved to local storage - * and synced to IPFS. - * - * Usage from browser console: - * await devTopup() // Mint BTC, SOL, ETH - * await devTopup(['bitcoin']) // Mint only BTC - * await devTopup(['solana', 'ethereum']) // Mint SOL and ETH - * - * Note: Requires devSkipTrustBaseVerification() when using dev aggregators. - */ -export async function devTopup( - coins: string[] = ["bitcoin", "solana", "ethereum"] -): Promise { - const startTime = Date.now(); - const mintedTokens: Array<{ coin: string; amount: string; tokenId: string }> = []; - const errors: Array<{ coin: string; error: string }> = []; - - console.group("💰 Dev: Topup Tokens"); - console.log(`📡 Aggregator: ${ServiceProvider.getAggregatorUrl()}`); - console.log(`🔐 Trust base verification: ${ServiceProvider.isTrustBaseVerificationSkipped() ? "SKIPPED" : "enabled"}`); - console.log(`🪙 Coins to mint: ${coins.join(", ")}`); - - // Get identity - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - if (!identity) { - console.error("❌ No wallet identity found"); - console.groupEnd(); - return { success: false, mintedTokens: [], errors: [{ coin: "all", error: "No identity" }], duration: Date.now() - startTime }; - } - - const secret = Buffer.from(identity.privateKey, "hex"); - console.log(`👛 Wallet: ${identity.address.slice(0, 20)}...`); - - // Mint each requested coin - for (const coinName of coins) { - const config = DEV_COIN_CONFIG[coinName as keyof typeof DEV_COIN_CONFIG]; - if (!config) { - console.warn(`⚠️ Unknown coin: ${coinName}`); - errors.push({ coin: coinName, error: "Unknown coin" }); - continue; - } - - console.log(`\n🪙 Minting ${config.symbol}...`); - - const result = await mintFungibleToken(coinName, config, identityManager, secret); - - if (result.success && result.tokenId) { - console.log(` ✅ Minted ${config.amount.toString()} ${config.symbol}`); - console.log(` 📦 TokenID: ${result.tokenId.slice(0, 16)}...`); - mintedTokens.push({ - coin: coinName, - amount: config.amount.toString(), - tokenId: result.tokenId, - }); - } else { - console.error(` ❌ Failed: ${result.error}`); - errors.push({ coin: coinName, error: result.error || "Unknown error" }); - } - } - - // Sync to IPFS if any tokens were minted - if (mintedTokens.length > 0) { - console.log(`\n☁️ Syncing ${mintedTokens.length} new tokens to IPFS...`); - try { - const ipfsService = IpfsStorageService.getInstance(identityManager); - - // Wait for any existing sync to complete (with timeout) - const MAX_WAIT_MS = 60000; // 60 seconds max wait - const POLL_INTERVAL_MS = 500; - const startWait = Date.now(); - - while (ipfsService.isCurrentlySyncing() && Date.now() - startWait < MAX_WAIT_MS) { - console.log(` ⏳ Waiting for existing sync to complete...`); - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - - if (ipfsService.isCurrentlySyncing()) { - console.warn(` ⚠️ Existing sync did not complete within ${MAX_WAIT_MS / 1000}s, attempting sync anyway`); - } - - // Now sync with the new tokens - const result = await ipfsService.syncNow({ forceIpnsPublish: true }); - - if (result.success) { - console.log(` ✅ IPFS sync complete (CID: ${result.cid?.slice(0, 16)}...)`); - // Log IPNS publish status explicitly for diagnostics - if (result.ipnsPublished) { - console.log(` ✅ IPNS record published (v${result.version})`); - } else if (result.ipnsPublishPending) { - console.warn(` ⚠️ IPNS publish pending (will retry) - tokens may not persist in incognito!`); - } else { - console.log(` ℹ️ IPNS unchanged (CID same as before)`); - } - } else if (result.error === "Sync already in progress") { - // Retry once after waiting - console.log(` ⏳ Sync still in progress, waiting and retrying...`); - await new Promise((resolve) => setTimeout(resolve, 5000)); - const retryResult = await ipfsService.syncNow({ forceIpnsPublish: true }); - if (retryResult.success) { - console.log(` ✅ IPFS sync complete on retry (CID: ${retryResult.cid?.slice(0, 16)}...)`); - } else { - console.error(` ⚠️ IPFS sync failed after retry: ${retryResult.error}`); - errors.push({ coin: "ipfs", error: retryResult.error || "Sync failed" }); - } - } else { - console.error(` ⚠️ IPFS sync failed: ${result.error}`); - errors.push({ coin: "ipfs", error: result.error || "Sync failed" }); - } - } catch (ipfsError) { - const msg = ipfsError instanceof Error ? ipfsError.message : String(ipfsError); - console.error(` ⚠️ IPFS sync failed: ${msg}`); - errors.push({ coin: "ipfs", error: msg }); - } - } - - // Trigger UI refresh - window.dispatchEvent(new Event("wallet-updated")); - - const duration = Date.now() - startTime; - const success = mintedTokens.length > 0; - - console.log(`\n${success ? "✅" : "❌"} Complete: ${mintedTokens.length} minted, ${errors.length} failed (${duration}ms)`); - console.groupEnd(); - - return { success, mintedTokens, errors, duration }; -} - -/** - * Recover corrupted tokens from archive that have undefined newStateHash - * - * This fixes tokens that were received via PROXY or DIRECT address transfers - * but were saved incorrectly (without proper finalizeTransaction call). - * - * The recovery process uses the SDK's finalizeTransaction method (same as NostrService): - * 1. Load archived tokens - * 2. For each token with undefined newStateHash in last transaction: - * - Get the transfer salt from transaction data - * - Create recipient predicate using identity + salt - * - Determine if PROXY or DIRECT address - * - Get nametag token for PROXY addresses - * - Call finalizeTransaction() to properly update the token - * - Remove the tombstone and save the token - * 3. Sync to IPFS - */ -export async function devRecoverCorruptedTokens(): Promise { - console.group("🔧 Recovering corrupted tokens using SDK finalizeTransaction..."); - const details: Array<{ tokenId: string; status: string; error?: string }> = []; - let recovered = 0; - let failed = 0; - - try { - // Get identity for predicate reconstruction - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - - if (!identity) { - console.error("❌ No wallet identity found"); - console.groupEnd(); - return { - success: false, - recovered: 0, - failed: 0, - details: [{ tokenId: "all", status: "No wallet identity" }], - }; - } - - const archivedTokens = getArchivedTokensForAddress(identity.address); - - console.log(`📦 Found ${archivedTokens.size} archived token(s)`); - - // Get my nametag token (needed for PROXY address verification) - const nametagData = getNametagForAddress(identity.address); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let myNametagToken: SdkToken | null = null; - - if (nametagData?.token) { - try { - myNametagToken = await SdkToken.fromJSON(nametagData.token); - console.log(`✅ Loaded nametag token: @${nametagData.name}`); - } catch (err) { - console.warn(`⚠️ Could not load nametag token:`, err); - } - } - - const client = ServiceProvider.stateTransitionClient; - const rootTrustBase = ServiceProvider.getRootTrustBase(); - const secret = Buffer.from(identity.privateKey, "hex"); - const signingService = await SigningService.createFromSecret(secret); - - for (const [tokenId, txf] of archivedTokens) { - try { - const lastTx = txf.transactions?.[txf.transactions.length - 1]; - - // Check if this token needs recovery - if (!lastTx) { - console.log(` ℹ️ Token ${tokenId.slice(0, 16)}... has no transactions, skipping`); - details.push({ tokenId, status: "No transactions - skipped" }); - continue; - } - - if (lastTx.newStateHash) { - console.log(` ℹ️ Token ${tokenId.slice(0, 16)}... already has newStateHash, skipping`); - details.push({ tokenId, status: "Already has newStateHash - skipped" }); - continue; - } - - console.log(` 🔧 Recovering token ${tokenId.slice(0, 16)}...`); - - // Get the transfer salt from the transaction data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const txData = lastTx.data as any; - const transferSalt = txData?.salt; - const recipient = txData?.recipient; - - console.log(` - Transfer salt: ${transferSalt ? transferSalt.slice(0, 16) + '...' : '❌ MISSING'}`); - console.log(` - Recipient: ${recipient || '❌ MISSING'}`); - - if (!transferSalt) { - console.warn(` ⚠️ No transfer salt in transaction data, cannot recover`); - details.push({ tokenId, status: "No transfer salt", error: "Missing salt in transaction data" }); - failed++; - continue; - } - - // Determine if this is a PROXY or DIRECT address - const isProxyAddress = recipient?.startsWith("PROXY://"); - console.log(` - Address type: ${isProxyAddress ? 'PROXY' : 'DIRECT'}`); - - // For PROXY addresses, we need the nametag token - if (isProxyAddress && !myNametagToken) { - console.warn(` ⚠️ PROXY address but no nametag token available, cannot recover`); - details.push({ tokenId, status: "No nametag token for PROXY", error: "Missing nametag token" }); - failed++; - continue; - } - - // Load the source token from archived TXF (without the bad state) - // We need to create a "source" token that represents the state BEFORE the transfer - // This means we use the previousStateHash transaction data - console.log(` - Loading source token from TXF...`); - - // The archived TXF has the transfer transaction but with wrong state - // We need to reconstruct what the source token looked like - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let sourceToken: SdkToken; - try { - // Create a version of the TXF that represents the SOURCE token (before transfer) - // This means excluding the last transaction that has the transfer - const sourceTxf = { - ...txf, - // The state should be the SENDER's state (from lastTx.data.sourceState) - state: txData.sourceState || txf.state, - // Include the transfer transaction - SDK needs it for finalization - transactions: txf.transactions, - }; - sourceToken = await SdkToken.fromJSON(sourceTxf); - console.log(` ✅ Source token loaded`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(` ⚠️ Failed to load source token: ${msg}`); - details.push({ tokenId, status: "Failed to load source token", error: msg }); - failed++; - continue; - } - - // Get genesis data for token type and ID - const genesisData = txf.genesis?.data; - if (!genesisData) { - console.warn(` ⚠️ No genesis data, cannot recover`); - details.push({ tokenId, status: "No genesis data", error: "Missing genesis data" }); - failed++; - continue; - } - - const tokenType = TokenType.fromJSON(genesisData.tokenType); - const tokenIdObj = TokenId.fromJSON(genesisData.tokenId); - - // Create the recipient predicate (same way NostrService does it) - console.log(` - Creating recipient predicate...`); - const recipientPredicate = await UnmaskedPredicate.create( - tokenIdObj, - tokenType, - signingService, - HashAlgorithm.SHA256, - Buffer.from(transferSalt, "hex") - ); - - const recipientState = new TokenState(recipientPredicate, null); - console.log(` ✅ Recipient predicate created`); - - // Get the transfer transaction from SDK - // The lastTx is the JSON, we need to reconstruct the SDK TransferTransaction - const { TransferTransaction } = await import("@unicitylabs/state-transition-sdk/lib/transaction/TransferTransaction"); - const transferTx = await TransferTransaction.fromJSON(lastTx); - console.log(` ✅ Transfer transaction loaded`); - - // Call finalizeTransaction - this is the key SDK method that handles everything - console.log(` - Calling finalizeTransaction (${isProxyAddress ? 'with nametag' : 'no nametag'})...`); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let finalizedToken: SdkToken; - try { - // Dev mode: skip trust base verification in finalizeTransaction - if (ServiceProvider.isTrustBaseVerificationSkipped()) { - console.log(` ⚠️ Dev mode: attempting finalization without full verification`); - // In dev mode, we can't use finalizeTransaction because it requires valid trust base - // Instead, manually create the finalized token - const finalizedTxf = { - ...txf, - state: recipientState.toJSON(), - transactions: txf.transactions.map((tx, idx) => { - if (idx === txf.transactions.length - 1) { - return { - ...tx, - // SDK sets newStateHash when applying the transaction - // For dev mode recovery, we calculate it ourselves - newStateHash: undefined, // Will be set below - }; - } - return tx; - }), - }; - - // Calculate the new state hash - const newStateHash = await recipientState.calculateHash(); - const newStateHashStr = newStateHash.toJSON(); - console.log(` - Calculated state hash: ${newStateHashStr.slice(0, 16)}...`); - - // Update the last transaction with the new state hash - const lastTxIndex = finalizedTxf.transactions.length - 1; - finalizedTxf.transactions[lastTxIndex] = { - ...finalizedTxf.transactions[lastTxIndex], - newStateHash: newStateHashStr, - }; - - finalizedToken = await SdkToken.fromJSON(finalizedTxf); - } else { - // Normal mode: use SDK's finalizeTransaction - const nametagTokens = isProxyAddress && myNametagToken ? [myNametagToken] : []; - finalizedToken = await client.finalizeTransaction( - rootTrustBase, - sourceToken, - recipientState, - transferTx, - nametagTokens - ); - } - console.log(` ✅ Token finalized successfully`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(` ❌ finalizeTransaction failed: ${msg}`); - details.push({ tokenId, status: "finalizeTransaction failed", error: msg }); - failed++; - continue; - } - - // Note: Tombstones are managed by InventorySyncService automatically - // When we add the recovered token, the next sync will clean up any tombstones - - // Get coin info from the token - let amount = "0"; - let coinIdHex = tokenId; - const symbol = "UNK"; - - try { - if (finalizedToken.coins?.coins) { - const coinsMap = finalizedToken.coins.coins; - if (coinsMap instanceof Map) { - const firstEntry = coinsMap.entries().next().value; - if (firstEntry) { - const [key, val] = firstEntry; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const keyObj = key as any; - if (keyObj && keyObj.data) { - coinIdHex = Buffer.from(keyObj.data).toString("hex"); - } else if (Buffer.isBuffer(key)) { - coinIdHex = key.toString("hex"); - } - amount = val?.toString() || "0"; - } - } - } - } catch { - // Keep default values - } - - // Serialize the finalized token - const finalizedTxfJson = finalizedToken.toJSON(); - - // Create a new Token object with the fixed data - const { v4: uuidv4 } = await import("uuid"); - const fixedToken = new Token({ - id: uuidv4(), - name: `Recovered ${symbol}`, - type: finalizedToken.type?.toString() || tokenId, - symbol: symbol, - jsonData: JSON.stringify(finalizedTxfJson), - status: TokenStatus.CONFIRMED, - amount: amount, - coinId: coinIdHex, - timestamp: Date.now(), - }); - - // Add the recovered token to the wallet - await inventoryAddToken( - identity.address, - identity.publicKey, - identity.ipnsName ?? '', - fixedToken, - { local: true } - ); - dispatchWalletUpdated(); - - console.log(` ✅ Token recovered successfully`); - details.push({ tokenId, status: "Recovered" }); - recovered++; - - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(` ❌ Failed to recover token ${tokenId.slice(0, 16)}...: ${msg}`); - details.push({ tokenId, status: "Failed", error: msg }); - failed++; - } - } - - // Sync to IPFS if any tokens were recovered - if (recovered > 0) { - console.log(`\n📤 Syncing recovered tokens to IPFS...`); - try { - const ipfsService = IpfsStorageService.getInstance(identityManager); - if (ipfsService) { - await ipfsService.syncNow({ forceIpnsPublish: true }); - console.log(` ✅ IPFS sync complete`); - } - } catch (err) { - console.error(` ⚠️ IPFS sync failed:`, err); - } - - // Trigger UI refresh - window.dispatchEvent(new Event("wallet-updated")); - } - - console.log(`\n✅ Recovery complete: ${recovered} recovered, ${failed} failed`); - console.groupEnd(); - - return { - success: recovered > 0, - recovered, - failed, - details, - }; - - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`❌ Recovery failed: ${msg}`); - console.groupEnd(); - return { - success: false, - recovered, - failed, - details: [{ tokenId: "global", status: "Failed", error: msg }], - }; - } -} - -/** - * Set the aggregator URL at runtime (dev tools only) - * Pass null to reset to the default from environment variable - * - * Usage from browser console: - * window.devSetAggregatorUrl("https://new-aggregator.example.com") - * window.devSetAggregatorUrl(null) // Reset to default - */ -export function devSetAggregatorUrl(url: string | null): void { - const oldUrl = ServiceProvider.getAggregatorUrl(); - ServiceProvider.setAggregatorUrl(url); - const newUrl = ServiceProvider.getAggregatorUrl(); - - console.log("🔄 Aggregator URL changed:"); - console.log(` Old: ${oldUrl}`); - console.log(` New: ${newUrl}`); - console.log(` 📦 Setting persisted to localStorage`); - - // Dispatch events to notify UI components - window.dispatchEvent(new Event("wallet-updated")); - window.dispatchEvent(new Event("dev-config-changed")); -} - -/** - * Get the current aggregator URL - * - * Usage from browser console: - * window.devGetAggregatorUrl() - */ -export function devGetAggregatorUrl(): string { - return ServiceProvider.getAggregatorUrl(); -} - -/** - * Skip trust base verification (dev mode only) - * Use when connecting to aggregators with different trust bases - * - * Usage from browser console: - * window.devSkipTrustBaseVerification() - */ -export function devSkipTrustBaseVerification(): void { - ServiceProvider.setSkipTrustBaseVerification(true); - console.log(` 📦 Setting persisted to localStorage`); - window.dispatchEvent(new Event("dev-config-changed")); -} - -/** - * Re-enable trust base verification - * - * Usage from browser console: - * window.devEnableTrustBaseVerification() - */ -export function devEnableTrustBaseVerification(): void { - ServiceProvider.setSkipTrustBaseVerification(false); - console.log(` 📦 Setting persisted to localStorage`); - window.dispatchEvent(new Event("dev-config-changed")); -} - -/** - * Check if trust base verification is currently skipped - * - * Usage from browser console: - * window.devIsTrustBaseVerificationSkipped() - */ -export function devIsTrustBaseVerificationSkipped(): boolean { - return ServiceProvider.isTrustBaseVerificationSkipped(); -} - -/** - * Reset all dev settings to production defaults - * - Resets aggregator URL to default from environment variable - * - Enables trust base verification - * - Removes DEV banner from header - * - * Usage from browser console: - * window.devReset() - */ -export function devReset(): void { - ServiceProvider.setAggregatorUrl(null); - ServiceProvider.setSkipTrustBaseVerification(false); - console.log("🔄 Dev settings reset to production defaults"); - window.dispatchEvent(new Event("dev-config-changed")); -} - -/** - * Display help for all available dev commands - * - * Usage from browser console: - * window.devHelp() - */ -export function devHelp(): void { - console.log(""); - console.log("🛠️ AgentSphere Developer Tools"); - console.log("═══════════════════════════════════════════════════════════════"); - console.log(""); - console.log(" devHelp()"); - console.log(" Show this help message"); - console.log(""); - console.log(" devDumpLocalStorage(filter?)"); - console.log(" Dump all localStorage data with detailed wallet analysis"); - console.log(" Shows tokens, tombstones, archived tokens, and state hashes"); - console.log(" Example: devDumpLocalStorage() // All keys"); - console.log(" Example: devDumpLocalStorage('wallet') // Filter by 'wallet'"); - console.log(""); - console.log(" devGetAggregatorUrl()"); - console.log(" Get the current Unicity aggregator URL"); - console.log(""); - console.log(" devSetAggregatorUrl(url)"); - console.log(" Change the aggregator URL at runtime (persists across page reloads)"); - console.log(" Pass null to reset to default from environment variable"); - console.log(" Example: devSetAggregatorUrl('/dev-rpc') // Uses Vite proxy"); - console.log(" Proxied routes: /rpc (testnet), /dev-rpc (dev aggregator)"); - console.log(""); - console.log(" devSkipTrustBaseVerification()"); - console.log(" Disable trust base verification (persists across page reloads)"); - console.log(" Use when connecting to aggregators with different trust bases"); - console.log(""); - console.log(" devEnableTrustBaseVerification()"); - console.log(" Re-enable trust base verification (persists across page reloads)"); - console.log(""); - console.log(" devIsTrustBaseVerificationSkipped()"); - console.log(" Check if trust base verification is currently disabled"); - console.log(""); - console.log(" devReset()"); - console.log(" Reset all dev settings to production defaults"); - console.log(" Resets aggregator URL and enables trust base verification"); - console.log(""); - console.log(" devRefreshProofs()"); - console.log(" Re-fetch all Unicity proofs for tokens in the wallet"); - console.log(" Strips existing proofs and requests fresh ones from aggregator"); - console.log(" Returns: { totalTokens, succeeded, failed, errors, duration }"); - console.log(""); - console.log(" devTopup(coins?)"); - console.log(" Mint fungible tokens to current wallet and sync to IPFS"); - console.log(" Default coins: ['bitcoin', 'solana', 'ethereum']"); - console.log(" Amounts: BTC=1, SOL=1000, ETH=42"); - console.log(" Example: devTopup() or devTopup(['bitcoin'])"); - console.log(" Note: Requires devSkipTrustBaseVerification() for dev aggregators"); - console.log(""); - console.log(" devRecoverCorruptedTokens()"); - console.log(" Recover tokens from archive that have corrupted state data"); - console.log(" Fixes tokens received via DIRECT address transfer before bug fix"); - console.log(" Updates newStateHash and removes tombstones for recovered tokens"); - console.log(" Returns: { success, recovered, failed, details }"); - console.log(""); - console.log(" devValidateUnicityId()"); - console.log(" Validate your Unicity ID (nametag) configuration"); - console.log(" Checks: identity exists, nametag token valid, Nostr binding correct"); - console.log(" Returns: { isValid, identity, nametag, nostrBinding, errors, warnings }"); - console.log(""); - console.log(" devRepairUnicityId()"); - console.log(" Attempt to repair a broken Unicity ID by re-publishing to Nostr"); - console.log(" Only works if nametag is not already owned by someone else"); - console.log(" Returns: true if successful, false otherwise"); - console.log(""); - console.log(" devCheckNametag(name)"); - console.log(" Check who owns a nametag on Nostr relay"); - console.log(" Returns: pubkey if owned, null if available"); - console.log(" Example: devCheckNametag('eric')"); - console.log(""); - console.log("═══════════════════════════════════════════════════════════════"); - console.log(""); -} - -/** - * Search chat messages for token transfer data containing the salt - * The transfer payload might still be in message history! - * - * Usage from browser console: - * window.devFindTransferSalt(tokenId) - */ -export function devFindTransferSalt(tokenId: string): { found: boolean; salt?: string; fullPayload?: unknown } { - console.group(`🔍 Searching for transfer salt for token ${tokenId.slice(0, 16)}...`); - - try { - // Get all chat messages from localStorage - const messagesJson = localStorage.getItem("unicity_chat_messages"); - if (!messagesJson) { - console.log("No chat messages found in localStorage"); - console.groupEnd(); - return { found: false }; - } - - const messages = JSON.parse(messagesJson) as Array<{ - id: string; - content: string; - type: string; - metadata?: Record; - }>; - - console.log(`Found ${messages.length} messages in history`); - - // Search for TOKEN_TRANSFER messages or any message containing the tokenId - for (const msg of messages) { - // Check if content contains the tokenId - if (msg.content?.includes(tokenId) || msg.content?.includes(tokenId.slice(0, 32))) { - console.log(`Found message potentially containing token: ${msg.id}`); - - try { - // Try to parse the content as JSON payload - const payload = JSON.parse(msg.content); - - // Check for transferTx with salt - if (payload.transferTx) { - const transferTx = typeof payload.transferTx === 'string' - ? JSON.parse(payload.transferTx) - : payload.transferTx; - - if (transferTx.data?.salt) { - console.log(`✅ Found transfer salt in message ${msg.id}!`); - console.log(` Salt: ${transferTx.data.salt}`); - console.groupEnd(); - return { - found: true, - salt: transferTx.data.salt, - fullPayload: payload, - }; - } - } - - // Check sourceToken for matching token ID - if (payload.sourceToken) { - const sourceToken = typeof payload.sourceToken === 'string' - ? JSON.parse(payload.sourceToken) - : payload.sourceToken; - - if (sourceToken.genesis?.data?.tokenId === tokenId) { - console.log(`Found matching sourceToken in message ${msg.id}`); - // Look for transferTx in the same payload - if (payload.transferTx) { - const transferTx = typeof payload.transferTx === 'string' - ? JSON.parse(payload.transferTx) - : payload.transferTx; - if (transferTx.data?.salt) { - console.log(`✅ Found transfer salt!`); - console.log(` Salt: ${transferTx.data.salt}`); - console.groupEnd(); - return { - found: true, - salt: transferTx.data.salt, - fullPayload: payload, - }; - } - } - } - } - } catch { - // Not JSON, skip - } - } - - // Also check TOKEN_TRANSFER type messages - if (msg.type === 'TOKEN_TRANSFER' && msg.metadata) { - console.log(`Found TOKEN_TRANSFER message: ${msg.id}`); - console.log(`Metadata:`, msg.metadata); - // Check if metadata has transfer info - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const meta = msg.metadata as any; - if (meta?.salt) { - console.log(`✅ Found salt in message metadata!`); - console.groupEnd(); - return { found: true, salt: meta.salt, fullPayload: msg }; - } - } - } - - console.log(`❌ No transfer salt found for token ${tokenId.slice(0, 16)}...`); - console.groupEnd(); - return { found: false }; - } catch (err) { - console.error("Error searching messages:", err); - console.groupEnd(); - return { found: false }; - } -} - -// Add to window interface -declare global { - interface Window { - devFindTransferSalt: (tokenId: string) => { found: boolean; salt?: string; fullPayload?: unknown }; - } -} - -/** - * Dump all archived tokens to console for analysis - * Use this to understand the structure of corrupted tokens - * - * Usage from browser console: - * window.devDumpArchivedTokens() - */ -export async function devDumpArchivedTokens(): Promise { - console.group("📦 Archived Tokens Dump"); - - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - if (!identity) { - console.error("❌ No wallet identity available"); - console.groupEnd(); - return; - } - - const archivedTokens = getArchivedTokensForAddress(identity.address); - - console.log(`Found ${archivedTokens.size} archived token(s)`); - - for (const [tokenId, txf] of archivedTokens) { - console.group(`\n🔸 Token: ${tokenId.slice(0, 16)}...`); - - // Basic structure - console.log("Top-level keys:", Object.keys(txf)); - console.log("Version:", txf.version); - - // Genesis - console.group("📜 Genesis:"); - console.log("Keys:", Object.keys(txf.genesis || {})); - if (txf.genesis?.data) { - console.log("genesis.data.tokenId:", txf.genesis.data.tokenId?.slice(0, 16) + '...'); - console.log("genesis.data.tokenType:", txf.genesis.data.tokenType?.slice(0, 16) + '...'); - console.log("genesis.data.salt:", txf.genesis.data.salt); - console.log("genesis.data.recipient:", txf.genesis.data.recipient); - console.log("genesis.data.coinData:", txf.genesis.data.coinData); - } - if (txf.genesis?.inclusionProof) { - console.log("genesis.inclusionProof.authenticator.stateHash:", txf.genesis.inclusionProof.authenticator?.stateHash); - } - console.groupEnd(); - - // State - console.group("🔐 State:"); - console.log("state.data:", txf.state?.data); - console.log("state.predicate:", txf.state?.predicate?.slice(0, 64) + '...'); - console.groupEnd(); - - // Transactions - console.group(`📝 Transactions (${txf.transactions?.length || 0}):`); - txf.transactions?.forEach((tx, idx) => { - console.group(`Transaction[${idx}]:`); - console.log("Keys:", Object.keys(tx)); - console.log("previousStateHash:", tx.previousStateHash?.slice(0, 32) + '...'); - console.log("newStateHash:", tx.newStateHash || '❌ MISSING'); - console.log("predicate:", tx.predicate?.slice(0, 32) || '❌ MISSING'); - console.log("data:", tx.data); - console.log("inclusionProof:", tx.inclusionProof ? '✅ present' : '❌ missing'); - if (tx.inclusionProof) { - console.log(" authenticator.stateHash:", tx.inclusionProof.authenticator?.stateHash); - console.log(" transactionHash:", tx.inclusionProof.transactionHash?.slice(0, 32) + '...'); - } - console.groupEnd(); - }); - console.groupEnd(); - - // Raw JSON for deep inspection - console.log("📋 Full TXF JSON:", JSON.stringify(txf, null, 2)); - - console.groupEnd(); - } - - console.groupEnd(); -} - -/** - * Dump all localStorage data for debugging - * Parses JSON values and displays them in a structured format - * - * Usage from browser console: - * devDumpLocalStorage() // Dump all keys - * devDumpLocalStorage('wallet') // Filter keys containing 'wallet' - * devDumpLocalStorage('unicity') // Filter keys containing 'unicity' - */ -export function devDumpLocalStorage(filter?: string): void { - console.group("📦 LocalStorage Dump" + (filter ? ` (filter: "${filter}")` : "")); - - const keys = Object.keys(localStorage).sort(); - const filteredKeys = filter - ? keys.filter(k => k.toLowerCase().includes(filter.toLowerCase())) - : keys; - - console.log(`Total keys: ${keys.length}, Showing: ${filteredKeys.length}`); - console.log(""); - - let totalSize = 0; - - for (const key of filteredKeys) { - const value = localStorage.getItem(key); - if (!value) continue; - - const sizeBytes = new Blob([value]).size; - totalSize += sizeBytes; - const sizeStr = sizeBytes > 1024 - ? `${(sizeBytes / 1024).toFixed(1)} KB` - : `${sizeBytes} B`; - - console.group(`🔑 ${key} (${sizeStr})`); - - // Try to parse as JSON - try { - const parsed = JSON.parse(value); - - // Special handling for wallet data - if (key.startsWith("unicity_wallet_")) { - const wallet = parsed; - console.log("📋 Wallet Summary:"); - console.log(` Address: ${wallet.address || "(none)"}`); - console.log(` Tokens: ${wallet.tokens?.length || 0}`); - console.log(` Tombstones: ${wallet.tombstones?.length || 0}`); - console.log(` Archived: ${wallet.archivedTokens ? Object.keys(wallet.archivedTokens).length : 0}`); - console.log(` Forked: ${wallet.forkedTokens ? Object.keys(wallet.forkedTokens).length : 0}`); - console.log(` Invalidated Nametags: ${wallet.invalidatedNametags?.length || 0}`); - - if (wallet.nametag) { - console.log(` Nametag: @${wallet.nametag.name}`); - } - - // Token details - if (wallet.tokens?.length > 0) { - console.group(" 📦 Tokens:"); - for (const token of wallet.tokens) { - let tokenId = token.id; - let stateInfo = ""; - try { - const txf = JSON.parse(token.jsonData || "{}"); - tokenId = txf.genesis?.data?.tokenId || token.id; - const txCount = txf.transactions?.length || 0; - const lastTx = txf.transactions?.[txCount - 1]; - stateInfo = lastTx?.newStateHash - ? `state=${lastTx.newStateHash.slice(0, 12)}...` - : txf._integrity?.currentStateHash - ? `genesis-state=${txf._integrity.currentStateHash.slice(0, 12)}...` - : "(no state hash)"; - } catch { /* ignore */ } - console.log(` - ${tokenId.slice(0, 12)}... ${token.symbol || ""} ${token.amount || ""} ${stateInfo}`); - } - console.groupEnd(); - } - - // Tombstone details - if (wallet.tombstones?.length > 0) { - console.group(" 💀 Tombstones:"); - for (const t of wallet.tombstones) { - console.log(` - ${t.tokenId.slice(0, 12)}... state=${t.stateHash.slice(0, 12)}... (${new Date(t.timestamp).toISOString()})`); - } - console.groupEnd(); - } - - // Archived token details - if (wallet.archivedTokens && Object.keys(wallet.archivedTokens).length > 0) { - console.group(" 📁 Archived Tokens:"); - for (const [id, txf] of Object.entries(wallet.archivedTokens)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const t = txf as any; - const txCount = t.transactions?.length || 0; - console.log(` - ${id.slice(0, 12)}... (${txCount} transactions)`); - } - console.groupEnd(); - } - - // Full raw data - console.log("📋 Raw data:", parsed); - } - // Special handling for outbox - else if (key === "transfer_outbox" || key === "mint_outbox") { - const entries = Array.isArray(parsed) ? parsed : []; - console.log(`Entries: ${entries.length}`); - for (const entry of entries) { - console.log(` - ${entry.id?.slice(0, 8) || "?"} status=${entry.status} token=${entry.sourceTokenId?.slice(0, 12) || entry.tokenId?.slice(0, 12) || "?"}...`); - } - console.log("📋 Raw data:", parsed); - } - // Generic JSON - else { - // For large objects, show summary - if (typeof parsed === "object" && parsed !== null) { - const keys = Object.keys(parsed); - if (keys.length > 10) { - console.log(`Object with ${keys.length} keys:`, keys.slice(0, 10).join(", ") + "..."); - } - if (Array.isArray(parsed)) { - console.log(`Array with ${parsed.length} elements`); - } - } - console.log("📋 Value:", parsed); - } - } catch { - // Not JSON, show as string (truncated if long) - if (value.length > 200) { - console.log(`📋 Value (truncated): ${value.slice(0, 200)}...`); - } else { - console.log(`📋 Value: ${value}`); - } - } - - console.groupEnd(); - } - - console.log(""); - const totalSizeStr = totalSize > 1024 * 1024 - ? `${(totalSize / (1024 * 1024)).toFixed(2)} MB` - : totalSize > 1024 - ? `${(totalSize / 1024).toFixed(1)} KB` - : `${totalSize} B`; - console.log(`📊 Total size: ${totalSizeStr}`); - console.groupEnd(); -} - -/** - * Inspect remote IPFS storage data - * Fetches the current IPNS-resolved content and displays its structure - * - * Usage from browser console: - * devInspectIpfs() - */ -export async function devInspectIpfs(): Promise { - console.group("📦 IPFS Remote Data Inspection"); - - try { - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - - if (!identity) { - console.error("❌ No wallet identity available"); - console.groupEnd(); - return { error: "No identity" }; - } - - console.log(`📋 Identity: ${identity.address.slice(0, 30)}...`); - - // Get IPNS name from IpfsStorageService (it computes from identity keys) - const ipfsService = IpfsStorageService.getInstance(identityManager); - const ipnsName = ipfsService.getIpnsName(); - - if (!ipnsName) { - console.error("❌ No IPNS name available - IPFS service may not be initialized"); - console.groupEnd(); - return { error: "No IPNS name" }; - } - - console.log(`📋 IPNS Name: ${ipnsName}`); - - // Resolve IPNS and fetch content - const httpResolver = getIpfsHttpResolver(); - console.log("🔍 Resolving IPNS..."); - - const ipnsResult = await httpResolver.resolveIpnsName(ipnsName); - if (!ipnsResult.cid) { - console.warn("⚠️ No CID found for IPNS name - wallet may not have been synced to IPFS yet"); - console.groupEnd(); - return { error: "No IPNS record", ipnsName: identity.ipnsName }; - } - - console.log(`✅ IPNS resolved: CID=${ipnsResult.cid.slice(0, 20)}..., seq=${ipnsResult.sequence}`); - - // Fetch content with CID verification - console.log("📥 Fetching content from IPFS..."); - const content = await httpResolver.fetchContentByCid(ipnsResult.cid) as TxfStorageData | null; - - if (!content) { - console.error("❌ Failed to fetch content from IPFS (CID verification failed)"); - console.groupEnd(); - return { error: "Fetch failed - CID mismatch", cid: ipnsResult.cid }; - } - - // Analyze content - console.log("\n═══════════════════════════════════════════════════════════════"); - console.log("📊 IPFS STORAGE CONTENT SUMMARY"); - console.log("═══════════════════════════════════════════════════════════════\n"); - - // Meta - if (content._meta) { - console.log("📋 _meta:"); - console.log(` Version: ${content._meta.version}`); - console.log(` Address: ${content._meta.address?.slice(0, 30)}...`); - console.log(` IPNS Name: ${content._meta.ipnsName}`); - console.log(` Format: ${content._meta.formatVersion}`); - console.log(` Last CID: ${content._meta.lastCid?.slice(0, 20) || "(none)"}...`); - } - - // Nametag - if (content._nametag) { - console.log(`\n📛 _nametag: "${content._nametag.name}"`); - } else { - console.log("\n📛 _nametag: (none)"); - } - - // Active tokens - const tokenKeys = Object.keys(content).filter(isActiveTokenKey); - console.log(`\n🪙 Active tokens: ${tokenKeys.length}`); - for (const key of tokenKeys) { - const tokenId = tokenIdFromKey(key); - const token = content[key] as TxfToken; - console.log(` - ${tokenId.slice(0, 16)}... (tx=${token.transactions?.length || 0})`); - } - - // Invalid tokens - const invalidTokens = content._invalid as InvalidTokenEntry[] | undefined; - console.log(`\n❌ _invalid tokens: ${invalidTokens?.length || 0}`); - if (invalidTokens && invalidTokens.length > 0) { - for (const entry of invalidTokens) { - const tokenId = entry.token?.genesis?.data?.tokenId || "unknown"; - console.log(` - ${tokenId.slice(0, 16)}...`); - console.log(` Reason: ${entry.reason}`); - console.log(` Details: ${entry.details || "(none)"}`); - console.log(` Invalidated: ${new Date(entry.invalidatedAt).toISOString()}`); - } - } - - // Tombstones - const tombstones = content._tombstones; - console.log(`\n⚰️ _tombstones: ${tombstones?.length || 0}`); - if (tombstones && tombstones.length > 0) { - for (const ts of tombstones) { - console.log(` - ${ts.tokenId.slice(0, 16)}... (state: ${ts.stateHash.slice(0, 12)}...)`); - } - } - - // Sent tokens - const sentTokens = content._sent; - console.log(`\n📤 _sent tokens: ${sentTokens?.length || 0}`); - - // Outbox - const outbox = content._outbox; - console.log(`\n📮 _outbox entries: ${outbox?.length || 0}`); - - // Mint outbox - const mintOutbox = content._mintOutbox; - console.log(`\n🏭 _mintOutbox entries: ${mintOutbox?.length || 0}`); - - // Invalidated nametags - const invalidatedNametags = content._invalidatedNametags; - console.log(`\n🚫 _invalidatedNametags: ${invalidatedNametags?.length || 0}`); - - console.log("\n═══════════════════════════════════════════════════════════════"); - console.groupEnd(); - - return { - cid: ipnsResult.cid, - sequence: ipnsResult.sequence, - meta: content._meta, - activeTokens: tokenKeys.length, - invalidTokens: invalidTokens?.length || 0, - tombstones: tombstones?.length || 0, - sentTokens: sentTokens?.length || 0, - outboxEntries: outbox?.length || 0, - rawContent: content - }; - - } catch (error) { - console.error("❌ Error inspecting IPFS:", error); - console.groupEnd(); - return { error: error instanceof Error ? error.message : String(error) }; - } -} - -/** - * Register developer tools on the window object - * Call this during app initialization in development mode - */ -export function registerDevTools(): void { - window.devHelp = devHelp; - window.devDumpLocalStorage = devDumpLocalStorage; - window.devRefreshProofs = devRefreshProofs; - window.devSetAggregatorUrl = devSetAggregatorUrl; - window.devGetAggregatorUrl = devGetAggregatorUrl; - window.devSkipTrustBaseVerification = devSkipTrustBaseVerification; - window.devEnableTrustBaseVerification = devEnableTrustBaseVerification; - window.devIsTrustBaseVerificationSkipped = devIsTrustBaseVerificationSkipped; - window.devReset = devReset; - window.devTopup = devTopup; - window.devRecoverCorruptedTokens = devRecoverCorruptedTokens; - window.devDumpArchivedTokens = devDumpArchivedTokens; - window.devFindTransferSalt = devFindTransferSalt; - window.devIpfsSync = async () => { - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - if (!identity) { - console.error("❌ No wallet identity available"); - return { success: false, error: "No identity" }; - } - const ipfsService = IpfsStorageService.getInstance(identityManager); - console.log("☁️ Triggering IPFS sync..."); - const result = await ipfsService.syncNow({ forceIpnsPublish: false, callerContext: "devIpfsSync" }); - if (result.success) { - console.log(`✅ IPFS sync complete (CID: ${result.cid?.slice(0, 16)}...)`); - } else { - console.error(`❌ IPFS sync failed: ${result.error}`); - } - return result; - }; - window.devValidateUnicityId = unicityIdValidator.validate; - window.devRepairUnicityId = unicityIdValidator.repair; - window.devCheckNametag = unicityIdValidator.getNametagOwner; - window.devRestoreNametag = async (nametagName: string) => { - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - if (!identity) { - console.error("❌ No wallet identity available"); - return false; - } - const invalidated = getInvalidatedNametagsForAddress(identity.address); - console.log(`📋 Invalidated nametags: ${invalidated.map(e => e.name).join(", ") || "(none)"}`); - if (nametagName) { - // NOTE: restoreInvalidatedNametag requires direct WalletRepository access (dev tool only) - // This function would need to be implemented in InventorySyncService for full migration - console.error("❌ restoreInvalidatedNametag not yet migrated - requires WalletRepository"); - return false; - } - return false; - }; - window.devDumpNametagToken = async () => { - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - if (!identity) { - console.error("❌ No wallet identity available"); - return null; - } - const nametagData = getNametagForAddress(identity.address); - if (!nametagData) { - console.log("❌ No nametag found"); - return null; - } - console.log("📋 Nametag data:", { - name: nametagData.name, - timestamp: nametagData.timestamp, - format: nametagData.format, - version: nametagData.version, - }); - console.log("📋 Raw token:", nametagData.token); - - try { - const { Token } = await import("@unicitylabs/state-transition-sdk/lib/token/Token"); - const token = await Token.fromJSON(nametagData.token); - console.log("📋 Parsed token:"); - console.log(" ID:", token.id); - console.log(" Type:", token.type?.toString?.() || "unknown"); - console.log(" State:", token.state); - console.log(" Genesis:", token.genesis); - console.log(" Transactions:", token.transactions?.length || 0); - if (token.transactions?.length > 0) { - token.transactions.forEach((tx: unknown, i: number) => { - console.log(` TX[${i}]:`, tx); - }); - } - return { nametagData, token }; - } catch (err) { - console.error("❌ Failed to parse token:", err); - return { nametagData, parseError: err }; - } - }; - window.devInspectIpfs = devInspectIpfs; - console.log("🛠️ Dev tools registered. Type devHelp() for available commands."); -} diff --git a/src/utils/tokenValidation.ts b/src/utils/tokenValidation.ts deleted file mode 100644 index 37376ab5..00000000 --- a/src/utils/tokenValidation.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * Token Validation Utilities - * - * CRITICAL: These validation functions MUST be used at ALL token import/export boundaries - * to prevent corrupted data from entering or leaving the system. - * - * Validation is required at: - * - WalletRepository.saveNametagForAddress() - * - WalletRepository.setNametag() - * - WalletRepository.addToken() - * - WalletRepository.updateToken() - * - IpfsStorageService (import and export) - * - NostrService (receive tokens) - * - Any other entry/exit point for token data - */ - -import type { NametagData } from "../components/wallet/L3/services/types/TxfTypes"; - -/** - * Result of token validation - */ -export interface TokenValidationResult { - isValid: boolean; - errors: string[]; -} - -/** - * Validate that a token JSON object has the required SDK structure. - * - * A valid SDK Token JSON must have: - * - version: string - * - state: object with predicate - * - genesis: object with data and inclusionProof - * - transactions: array - * - nametags: array (can be empty) - * - * @param token - The token object to validate - * @param options - Validation options - * @returns Validation result with isValid flag and error messages - */ -export function validateTokenJson( - token: unknown, - options: { - requireInclusionProof?: boolean; - context?: string; // For error messages - } = {} -): TokenValidationResult { - const errors: string[] = []; - const context = options.context ? `[${options.context}] ` : ""; - const requireProof = options.requireInclusionProof ?? true; - - // Check basic structure - if (!token || typeof token !== "object") { - errors.push(`${context}Token must be a non-null object`); - return { isValid: false, errors }; - } - - const t = token as Record; - - // Check for empty object (the bug we're preventing) - if (Object.keys(t).length === 0) { - errors.push(`${context}Token is an empty object - this indicates data corruption`); - return { isValid: false, errors }; - } - - // Version check - if (!t.version || typeof t.version !== "string") { - errors.push(`${context}Token missing required 'version' field`); - } - - // State check - if (!t.state || typeof t.state !== "object") { - errors.push(`${context}Token missing required 'state' object`); - } else { - const state = t.state as Record; - // Predicate is hex-encoded CBOR string, not an object - if (!state.predicate || typeof state.predicate !== "string") { - errors.push(`${context}Token state missing required 'predicate' string`); - } - } - - // Genesis check (required for all tokens) - if (!t.genesis || typeof t.genesis !== "object") { - errors.push(`${context}Token missing required 'genesis' object`); - } else { - const genesis = t.genesis as Record; - - // Genesis must have data - if (!genesis.data || typeof genesis.data !== "object") { - errors.push(`${context}Token genesis missing required 'data' object`); - } else { - const data = genesis.data as Record; - - // Genesis data must have tokenId - if (!data.tokenId || typeof data.tokenId !== "string") { - errors.push(`${context}Token genesis.data missing required 'tokenId' string`); - } - - // Genesis data must have salt (for reconstruction) - if (!data.salt || typeof data.salt !== "string") { - errors.push(`${context}Token genesis.data missing required 'salt' string`); - } - - // Genesis data must have recipient - if (!data.recipient || typeof data.recipient !== "string") { - errors.push(`${context}Token genesis.data missing required 'recipient' string`); - } - } - - // Genesis must have inclusionProof (unless explicitly skipped) - if (requireProof) { - if (!genesis.inclusionProof || typeof genesis.inclusionProof !== "object") { - errors.push(`${context}Token genesis missing required 'inclusionProof' object`); - } else { - const proof = genesis.inclusionProof as Record; - if (!proof.authenticator || typeof proof.authenticator !== "object") { - errors.push(`${context}Token genesis.inclusionProof missing 'authenticator'`); - } - } - } - } - - // Transactions check (must be array, can be empty) - if (!Array.isArray(t.transactions)) { - errors.push(`${context}Token missing required 'transactions' array`); - } - - // Nametags check (must be array, can be empty) - if (!Array.isArray(t.nametags)) { - // Note: Some older tokens might not have this field, so just warn - // errors.push(`${context}Token missing 'nametags' array`); - } - - return { - isValid: errors.length === 0, - errors, - }; -} - -/** - * Validate a NametagData object before storing. - * - * A valid NametagData must have: - * - name: non-empty string - * - token: valid SDK Token JSON (NOT an empty object) - * - timestamp: number - * - format: string - * - version: string - * - * @param nametag - The NametagData to validate - * @param options - Validation options - * @returns Validation result with isValid flag and error messages - */ -export function validateNametagData( - nametag: unknown, - options: { - requireInclusionProof?: boolean; - context?: string; - } = {} -): TokenValidationResult { - const errors: string[] = []; - const context = options.context ? `[${options.context}] ` : ""; - - // Check basic structure - if (!nametag || typeof nametag !== "object") { - errors.push(`${context}NametagData must be a non-null object`); - return { isValid: false, errors }; - } - - const n = nametag as Record; - - // Name check - if (!n.name || typeof n.name !== "string" || n.name.trim().length === 0) { - errors.push(`${context}NametagData missing required 'name' string`); - } - - // Token check - THIS IS THE CRITICAL VALIDATION - if (!n.token) { - errors.push(`${context}NametagData missing required 'token' object`); - } else { - const tokenValidation = validateTokenJson(n.token, { - requireInclusionProof: options.requireInclusionProof, - context: `${context}NametagData.token`, - }); - if (!tokenValidation.isValid) { - errors.push(...tokenValidation.errors); - } - } - - // Timestamp check - if (typeof n.timestamp !== "number" || n.timestamp <= 0) { - errors.push(`${context}NametagData missing valid 'timestamp' number`); - } - - // Format check - if (!n.format || typeof n.format !== "string") { - errors.push(`${context}NametagData missing required 'format' string`); - } - - // Version check - if (!n.version || typeof n.version !== "string") { - errors.push(`${context}NametagData missing required 'version' string`); - } - - return { - isValid: errors.length === 0, - errors, - }; -} - -/** - * Validate a NametagData and throw if invalid. - * Use this at storage boundaries to prevent corrupted data from being saved. - * - * @param nametag - The NametagData to validate - * @param context - Context for error messages (e.g., "IPFS import", "wallet import") - * @throws Error if validation fails - */ -export function assertValidNametagData( - nametag: NametagData | unknown, - context: string = "storage" -): asserts nametag is NametagData { - const result = validateNametagData(nametag, { context }); - if (!result.isValid) { - const errorMsg = `Invalid NametagData at ${context}:\n${result.errors.join("\n")}`; - console.error(errorMsg); - throw new Error(errorMsg); - } -} - -/** - * Validate a token JSON and throw if invalid. - * Use this at storage boundaries to prevent corrupted data from being saved. - * - * @param token - The token JSON to validate - * @param context - Context for error messages - * @throws Error if validation fails - */ -export function assertValidTokenJson( - token: unknown, - context: string = "storage" -): void { - const result = validateTokenJson(token, { context }); - if (!result.isValid) { - const errorMsg = `Invalid Token JSON at ${context}:\n${result.errors.join("\n")}`; - console.error(errorMsg); - throw new Error(errorMsg); - } -} - -/** - * Check if a nametag token is corrupted (empty or missing critical fields). - * This is a quick check for the common corruption case of `token: {}`. - * - * @param nametag - The NametagData to check - * @returns true if the nametag appears corrupted - */ -export function isNametagCorrupted(nametag: NametagData | null | undefined): boolean { - if (!nametag) return false; // No nametag is not corruption - - // Check for empty token object (the main corruption case) - if (!nametag.token || typeof nametag.token !== "object") { - return true; - } - - if (Object.keys(nametag.token).length === 0) { - return true; - } - - // Check for missing critical fields - const token = nametag.token as Record; - if (!token.genesis || !token.state) { - return true; - } - - return false; -} - -/** - * Sanitize a NametagData for logging (hide sensitive data). - */ -export function sanitizeNametagForLogging(nametag: NametagData | null | undefined): object { - if (!nametag) return { exists: false }; - - const tokenKeys = nametag.token ? Object.keys(nametag.token) : []; - - return { - name: nametag.name, - tokenKeyCount: tokenKeys.length, - tokenKeys: tokenKeys.slice(0, 10), // First 10 keys only - hasGenesis: tokenKeys.includes("genesis"), - hasState: tokenKeys.includes("state"), - format: nametag.format, - version: nametag.version, - timestamp: nametag.timestamp, - }; -} diff --git a/src/utils/unicityIdValidator.ts b/src/utils/unicityIdValidator.ts deleted file mode 100644 index fdda4d87..00000000 --- a/src/utils/unicityIdValidator.ts +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Unicity ID Validator - * - * Validates that a user's Unicity ID (nametag) is properly configured: - * 1. Nametag token exists and is valid - * 2. Nametag is published to Nostr relay - * 3. The Nostr pubkey for the nametag matches the wallet's identity - * - * IMPORTANT - Key relationship: - * - L3 pubkey (33 bytes compressed ECDSA): 02/03 prefix + 32 bytes x-coordinate - * - Nostr pubkey (32 bytes x-only Schnorr): derived using BIP-340 Schnorr - * - * WARNING: You CANNOT simply strip the prefix from L3 pubkey to get Nostr pubkey! - * BIP-340 Schnorr negates the private key if y-coordinate is odd, which produces - * a DIFFERENT x-coordinate. The correct approach is to derive the Nostr pubkey - * from the private key using the Nostr SDK's Schnorr implementation. - */ - -import { IdentityManager, type UserIdentity } from "../components/wallet/L3/services/IdentityManager"; -import { NostrService } from "../components/wallet/L3/services/NostrService"; -import { NostrKeyManager } from "@unicitylabs/nostr-js-sdk"; -import { IpfsStorageService, SyncPriority } from "../components/wallet/L3/services/IpfsStorageService"; -import { getNametagForAddress } from "../components/wallet/L3/services/InventorySyncService"; -import { STORAGE_KEY_GENERATORS } from "../config/storageKeys"; -import type { NametagData } from "../components/wallet/L3/services/types/TxfTypes"; -import type { InvalidatedNametagEntry } from "../components/wallet/L3/services/types/TxfTypes"; - -export interface UnicityIdValidationResult { - isValid: boolean; - identity: { - l3Pubkey: string; - expectedNostrPubkey: string; - directAddress: string; - } | null; - nametag: { - name: string; - hasToken: boolean; - tokenRecipient: string | null; - } | null; - nostrBinding: { - resolvedPubkey: string | null; - matchesIdentity: boolean; - } | null; - errors: string[]; - warnings: string[]; -} - -/** - * Derive Nostr pubkey from L3 identity's private key - * - * IMPORTANT: This uses the Nostr SDK's Schnorr implementation which follows BIP-340. - * BIP-340 may negate the private key if the y-coordinate is odd, producing a different - * x-coordinate than the ECDSA public key. This is why we MUST derive from the private - * key rather than trying to convert the L3 public key. - * - * @param identity - The L3 identity containing the private key - * @returns The 32-byte Nostr pubkey as hex string (64 chars) - */ -export function deriveNostrPubkeyFromIdentity(identity: UserIdentity): string { - const secretKey = Buffer.from(identity.privateKey, "hex"); - const keyManager = NostrKeyManager.fromPrivateKey(secretKey); - return keyManager.getPublicKeyHex(); -} - -/** - * @deprecated Use deriveNostrPubkeyFromIdentity instead. - * This function is BROKEN because it assumes stripping the ECDSA prefix gives - * the Schnorr pubkey, which is only true ~50% of the time due to BIP-340 key negation. - */ -export function l3PubkeyToNostrPubkey(l3Pubkey: string): string { - console.warn("DEPRECATED: l3PubkeyToNostrPubkey is broken. Use deriveNostrPubkeyFromIdentity instead."); - // L3 pubkey should be 66 hex chars (33 bytes) - if (l3Pubkey.length !== 66) { - throw new Error(`Invalid L3 pubkey length: ${l3Pubkey.length}, expected 66`); - } - - // First byte (02 or 03) indicates y-coordinate parity - const prefix = l3Pubkey.substring(0, 2); - if (prefix !== "02" && prefix !== "03") { - throw new Error(`Invalid L3 pubkey prefix: ${prefix}, expected 02 or 03`); - } - - // Return the x-coordinate (32 bytes = 64 hex chars) - // WARNING: This is NOT correct for Nostr! BIP-340 may negate the key! - return l3Pubkey.substring(2); -} - -/** - * Validate a user's Unicity ID (nametag) configuration - * - * Checks: - * 1. Identity exists and can be loaded - * 2. Nametag token exists locally - * 3. Nametag is published to Nostr relay - * 4. The Nostr pubkey matches the wallet's expected pubkey - */ -export async function validateUnicityId(): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - const result: UnicityIdValidationResult = { - isValid: false, - identity: null, - nametag: null, - nostrBinding: null, - errors, - warnings, - }; - - // Step 1: Get identity - let identity: UserIdentity | null = null; - let expectedNostrPubkey: string = ""; - - try { - const identityManager = IdentityManager.getInstance(); - identity = await identityManager.getCurrentIdentity(); - - if (!identity) { - errors.push("No identity found - wallet not initialized"); - return result; - } - - // Derive Nostr pubkey using Schnorr (BIP-340) - must use private key, not convert from L3 pubkey - expectedNostrPubkey = deriveNostrPubkeyFromIdentity(identity); - - result.identity = { - l3Pubkey: identity.publicKey, - expectedNostrPubkey, - directAddress: identity.address, - }; - - console.log("✅ Identity loaded"); - console.log(` L3 pubkey: ${identity.publicKey}...`); - console.log(` Nostr pubkey (Schnorr): ${expectedNostrPubkey}...`); - console.log(` Address: ${identity.address}...`); - } catch (err) { - errors.push(`Failed to load identity: ${err instanceof Error ? err.message : String(err)}`); - return result; - } - - // Step 2: Check local nametag token - let nametagData: NametagData | null = null; - let nametagName: string | null = null; - - try { - nametagData = getNametagForAddress(identity.address); - - if (!nametagData) { - errors.push("No nametag registered locally"); - result.nametag = { name: "", hasToken: false, tokenRecipient: null }; - } else { - nametagName = nametagData.name; - const hasToken = !!nametagData.token; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const token = nametagData.token as any; - const tokenRecipient = token?.genesis?.data?.recipient || null; - - result.nametag = { - name: nametagName, - hasToken, - tokenRecipient, - }; - - console.log(`✅ Local nametag: "${nametagName}"`); - console.log(` Has token: ${hasToken}`); - if (tokenRecipient) { - console.log(` Token recipient: ${tokenRecipient.substring(0, 40)}...`); - } - - // Verify token recipient matches our address - if (tokenRecipient && tokenRecipient !== identity.address) { - warnings.push( - `Nametag token recipient (${tokenRecipient.substring(0, 30)}...) ` + - `doesn't match current address (${identity.address.substring(0, 30)}...)` - ); - } - } - } catch (err) { - errors.push(`Failed to load nametag: ${err instanceof Error ? err.message : String(err)}`); - } - - // Step 3: Check Nostr binding - if (nametagName) { - try { - const nostrService = NostrService.getInstance(); - const resolvedPubkey = await nostrService.queryPubkeyByNametag(nametagName); - - result.nostrBinding = { - resolvedPubkey, - matchesIdentity: resolvedPubkey === expectedNostrPubkey, - }; - - if (!resolvedPubkey) { - errors.push(`Nametag "${nametagName}" is not published to Nostr relay`); - console.log(`❌ Nametag "${nametagName}" not found on Nostr relay`); - } else if (resolvedPubkey === expectedNostrPubkey) { - console.log(`✅ Nostr binding verified: "${nametagName}" -> ${resolvedPubkey.substring(0, 16)}...`); - } else { - errors.push( - `Nametag "${nametagName}" is owned by different pubkey!\n` + - ` Expected: ${expectedNostrPubkey.substring(0, 20)}...\n` + - ` Actual: ${resolvedPubkey.substring(0, 20)}...` - ); - console.log(`❌ Nostr pubkey mismatch for "${nametagName}":`); - console.log(` Expected: ${expectedNostrPubkey}`); - console.log(` Actual: ${resolvedPubkey}`); - } - } catch (err) { - warnings.push(`Failed to query Nostr relay: ${err instanceof Error ? err.message : String(err)}`); - console.log(`⚠️ Could not verify Nostr binding: ${err}`); - } - } - - // Determine overall validity - result.isValid = errors.length === 0; - - // Summary - console.log("\n=== Unicity ID Validation Summary ==="); - console.log(`Valid: ${result.isValid ? "✅ YES" : "❌ NO"}`); - if (errors.length > 0) { - console.log("\nErrors:"); - errors.forEach((e) => console.log(` ❌ ${e}`)); - } - if (warnings.length > 0) { - console.log("\nWarnings:"); - warnings.forEach((w) => console.log(` ⚠️ ${w}`)); - } - - return result; -} - -/** - * Fix a broken Unicity ID by re-publishing the nametag binding to Nostr - * - * This will only work if: - * 1. The nametag is not already owned by someone else - * 2. We have a valid nametag token - * - * @returns true if successful, false otherwise - */ -export async function repairUnicityId(): Promise { - console.log("\n=== Attempting to Repair Unicity ID ===\n"); - - const validation = await validateUnicityId(); - - if (validation.isValid) { - console.log("✅ Unicity ID is already valid, no repair needed"); - return true; - } - - if (!validation.identity) { - console.log("❌ Cannot repair: No identity found"); - return false; - } - - if (!validation.nametag?.name) { - console.log("❌ Cannot repair: No nametag registered"); - return false; - } - - const nametagName = validation.nametag.name; - - // Check if nametag is owned by someone else - if ( - validation.nostrBinding?.resolvedPubkey && - validation.nostrBinding.resolvedPubkey !== validation.identity.expectedNostrPubkey - ) { - console.log(`❌ Cannot repair: Nametag "${nametagName}" is owned by another pubkey`); - console.log(` Owner: ${validation.nostrBinding.resolvedPubkey}`); - console.log(` You need to choose a different nametag`); - return false; - } - - // Try to publish/re-publish the nametag - try { - const nostrService = NostrService.getInstance(); - const success = await nostrService.publishNametagBinding(nametagName, validation.identity.directAddress); - - if (success) { - // Wait for propagation - console.log("Waiting for Nostr propagation..."); - await new Promise((r) => setTimeout(r, 2000)); - - // Verify - const verifyResult = await validateUnicityId(); - if (verifyResult.isValid) { - console.log(`✅ Repair successful! Nametag "${nametagName}" is now valid`); - return true; - } else { - console.log("⚠️ Publish succeeded but verification failed"); - console.log(" The nametag may be owned by someone else"); - return false; - } - } else { - console.log("❌ Failed to publish nametag binding"); - return false; - } - } catch (err) { - console.log(`❌ Repair failed: ${err instanceof Error ? err.message : String(err)}`); - return false; - } -} - -/** - * Invalidate the current Unicity ID by: - * 1. Moving the nametag to invalidatedNametags array (preserves history) - * 2. Clearing the current nametag - * - * This triggers the wallet to show CreateWalletFlow for new nametag registration. - * Invalidation is one-way - once invalidated, user must create a new nametag. - * - * @param reason - Explanation of why the nametag was invalidated - * @returns true if invalidation was successful, false if no nametag to invalidate - */ -export async function invalidateUnicityId(reason: string): Promise { - // Get current identity for address-scoped operations - const identityManager = IdentityManager.getInstance(); - const identity = await identityManager.getCurrentIdentity(); - - if (!identity) { - console.log("No identity available - cannot invalidate nametag"); - return false; - } - - const currentNametag = getNametagForAddress(identity.address); - - if (!currentNametag) { - console.log("No nametag to invalidate"); - return false; - } - - const nametagName = currentNametag.name; - - // Create invalidated entry with full history - const invalidatedEntry: InvalidatedNametagEntry = { - name: currentNametag.name, - token: currentNametag.token || {}, - timestamp: currentNametag.timestamp || Date.now(), - format: currentNametag.format || "unknown", - version: currentNametag.version || "unknown", - invalidatedAt: Date.now(), - invalidationReason: reason, - }; - - // Add to invalidated list and clear current nametag in localStorage - // NOTE: This directly manipulates TxfStorageData format per spec - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(identity.address); - const json = localStorage.getItem(storageKey); - - if (json) { - try { - const data = JSON.parse(json); - if (!data._invalidatedNametags) { - data._invalidatedNametags = []; - } - data._invalidatedNametags.push(invalidatedEntry); - - // Clear current nametag (this triggers CreateWalletFlow) - delete data._nametag; - - localStorage.setItem(storageKey, JSON.stringify(data)); - console.log(`Invalidated Unicity ID "${nametagName}": ${reason}`); - } catch (err) { - console.error("Failed to invalidate nametag:", err); - return false; - } - } - - // Trigger wallet refresh so UI updates - window.dispatchEvent(new Event("wallet-updated")); - - // CRITICAL: Sync to IPFS immediately to push the invalidation to remote - // This prevents subsequent syncs from restoring the old nametag - try { - const identityManager = IdentityManager.getInstance(); - const ipfsService = IpfsStorageService.getInstance(identityManager); - // Use HIGH priority so it runs immediately - // Don't await - let it run in background to not block UI - ipfsService.syncNow({ - forceIpnsPublish: true, - priority: SyncPriority.HIGH, - callerContext: 'unicity-id-invalidation', - }).then(() => { - console.log(`✅ IPFS sync completed after invalidating "${nametagName}"`); - }).catch((err) => { - console.warn(`⚠️ IPFS sync failed after invalidating "${nametagName}":`, err); - }); - } catch (err) { - console.warn(`⚠️ Could not trigger IPFS sync after invalidation:`, err); - } - - return true; -} - -/** - * Check if a nametag is available (not owned by anyone) - */ -export async function isNametagAvailable(nametag: string): Promise { - try { - const nostrService = NostrService.getInstance(); - const pubkey = await nostrService.queryPubkeyByNametag(nametag); - return pubkey === null; - } catch { - // If query fails, assume unavailable to be safe - return false; - } -} - -/** - * Get the owner of a nametag - */ -export async function getNametagOwner(nametag: string): Promise { - try { - const nostrService = NostrService.getInstance(); - return await nostrService.queryPubkeyByNametag(nametag); - } catch { - return null; - } -} - -// Export for dev tools -export const unicityIdValidator = { - validate: validateUnicityId, - repair: repairUnicityId, - invalidate: invalidateUnicityId, - isNametagAvailable, - getNametagOwner, - deriveNostrPubkeyFromIdentity, - l3PubkeyToNostrPubkey, // deprecated - kept for backwards compatibility -}; diff --git a/tests/integration/wallet/L3/InventorySync.test.ts b/tests/integration/wallet/L3/InventorySync.test.ts deleted file mode 100644 index 410fbce4..00000000 --- a/tests/integration/wallet/L3/InventorySync.test.ts +++ /dev/null @@ -1,961 +0,0 @@ -/** - * Integration Tests for Token Inventory Sync - * - * These tests verify end-to-end sync scenarios across multiple modes - * and components, as specified in TOKEN_INVENTORY_SPEC.md. - * - * Spec Reference: /docs/TOKEN_INVENTORY_SPEC.md - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import type { Token } from "../../../../src/components/wallet/L3/data/model"; -import type { TxfToken, TxfStorageData, SentTokenEntry, InvalidTokenEntry } from "../../../../src/components/wallet/L3/services/types/TxfTypes"; -import type { OutboxEntry } from "../../../../src/components/wallet/L3/services/types/OutboxTypes"; - -// ========================================== -// Configurable Mock Setup -// ========================================== - -// These variables allow per-test configuration of mock behavior -let mockValidationResult: { valid: boolean; issues: Array<{ tokenId: string; reason: string }> } = { valid: true, issues: [] }; -let mockSpentTokens: Array<{ tokenId: string; stateHash: string; localId: string }> = []; -let mockIpfsAvailable = true; -let mockRemoteData: TxfStorageData | null = null; - -// Mock IpfsHttpResolver with configurable response -vi.mock("../../../../src/components/wallet/L3/services/IpfsHttpResolver", () => ({ - getIpfsHttpResolver: vi.fn(() => ({ - resolveIpnsName: vi.fn().mockImplementation(async () => { - if (!mockIpfsAvailable) { - return { success: false, error: "IPFS unavailable" }; - } - if (!mockRemoteData) { - return { success: false, error: "No remote data" }; - } - return { - success: true, - cid: "QmTestCid", - content: mockRemoteData, - }; - }), - })), - computeCidFromContent: vi.fn().mockResolvedValue("QmTestCid123"), -})); - -// Mock TokenValidationService with configurable per-test results -vi.mock("../../../../src/components/wallet/L3/services/TokenValidationService", () => ({ - getTokenValidationService: vi.fn(() => ({ - validateAllTokens: vi.fn().mockImplementation(async (tokens: Token[]) => { - return { - valid: mockValidationResult.valid, - validTokens: mockValidationResult.valid ? tokens : tokens.filter(t => - !mockValidationResult.issues.some(i => t.id === i.tokenId || t.id.includes(i.tokenId)) - ), - issues: mockValidationResult.issues.map(i => ({ - tokenId: i.tokenId, - reason: i.reason, - })), - }; - }), - checkSpentTokens: vi.fn().mockImplementation(async () => { - return { - spentTokens: mockSpentTokens, - errors: [], - }; - }), - // Mock for Step 7.5: isTokenStateSpent - // Returns true if tokenId+stateHash is in mockSpentTokens - isTokenStateSpent: vi.fn().mockImplementation(async (tokenId: string, stateHash: string) => { - return mockSpentTokens.some(s => s.tokenId === tokenId && s.stateHash === stateHash); - }), - })), -})); - -// Mock NostrService -vi.mock("../../../../src/components/wallet/L3/services/NostrService", () => ({ - NostrService: { - getInstance: vi.fn(() => ({ - queryPubkeyByNametag: vi.fn().mockResolvedValue(null), - publishNametagBinding: vi.fn().mockResolvedValue(true), - })), - }, -})); - -// Mock IdentityManager -vi.mock("../../../../src/components/wallet/L3/services/IdentityManager", () => ({ - IdentityManager: { - getInstance: vi.fn(() => ({ - getCurrentIdentity: vi.fn().mockResolvedValue({ - address: "test-address", - publicKey: "0".repeat(64), - ipnsName: "test-ipns-name", - }), - })), - }, -})); - -// Mock IPFS config -vi.mock("../../../../src/config/ipfs.config", () => ({ - getAllBackendGatewayUrls: vi.fn(() => ["https://test-gateway.example.com"]), -})); - -// Mock IpfsStorageService - getIpfsTransport throws to force fallback to HTTP resolver -// This ensures the existing IpfsHttpResolver mock is used for tests -vi.mock("../../../../src/components/wallet/L3/services/IpfsStorageService", () => ({ - getIpfsTransport: vi.fn(() => { - throw new Error("Transport not available in test - using HTTP resolver fallback"); - }), -})); - -// Mock PredicateEngineService -vi.mock("@unicitylabs/state-transition-sdk/lib/predicate/PredicateEngineService", () => ({ - PredicateEngineService: { - createPredicate: vi.fn().mockResolvedValue({ - isOwner: vi.fn().mockResolvedValue(true), - }), - }, -})); - -// Mock ProxyAddress -vi.mock("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress", () => ({ - ProxyAddress: { - fromNameTag: vi.fn().mockResolvedValue({ - address: "proxy-address-123", - }), - }, -})); - -// Import after mocks -import { inventorySync, type SyncParams } from "../../../../src/components/wallet/L3/services/InventorySyncService"; -import { STORAGE_KEY_GENERATORS } from "../../../../src/config/storageKeys"; - -// ========================================== -// Test Fixtures -// ========================================== - -const TEST_ADDRESS = "0x" + "a".repeat(40); -const TEST_PUBLIC_KEY = "0".repeat(64); -const TEST_IPNS_NAME = "k51test123"; - -// Default stateHash used for genesis-only tokens -const DEFAULT_STATE_HASH = "0000" + "a".repeat(60); - -const createMockTxfToken = (tokenId: string, amount = "1000", txCount = 0): TxfToken => { - const transactions = []; - - // Build proper state hash chain for transactions - // First tx links to genesis, subsequent txs link to previous - let prevStateHash = DEFAULT_STATE_HASH; // Genesis stateHash - - for (let i = 0; i < txCount; i++) { - const newStateHash = "0000" + (i + 1).toString().padStart(4, "0").padEnd(60, "0"); - transactions.push({ - data: { recipient: "recipient" + i }, - previousStateHash: prevStateHash, - newStateHash: newStateHash, - inclusionProof: { - authenticator: { stateHash: newStateHash }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "d".repeat(60), - }, - }); - prevStateHash = newStateHash; - } - - // Current stateHash is the last tx's newStateHash, or genesis if no txs - const currentStateHash = txCount > 0 ? prevStateHash : DEFAULT_STATE_HASH; - - return { - version: "2.0", - genesis: { - data: { - tokenId: tokenId.padEnd(64, "0"), - coinId: "ALPHA", - coinData: [["ALPHA", amount]], - }, - inclusionProof: { - authenticator: { stateHash: DEFAULT_STATE_HASH }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "c".repeat(60), - }, - }, - state: { data: "", predicate: new Uint8Array([1, 2, 3]) }, - transactions, - // Add _integrity with currentStateHash - _integrity: { - currentStateHash: currentStateHash, - genesisDataJSONHash: "0000" + "e".repeat(60), - }, - } as TxfToken; -}; - -const createMockToken = (id: string, amount = "1000"): Token => ({ - id, - name: "Test Token", - type: "UCT", - timestamp: Date.now(), - jsonData: JSON.stringify({ - version: "2.0", - genesis: { - data: { - tokenId: id.padEnd(64, "0"), - coinId: "ALPHA", - coinData: [["ALPHA", amount]], - }, - inclusionProof: { - authenticator: { stateHash: "0000" + "a".repeat(60) }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "c".repeat(60), - }, - }, - state: { data: "", predicate: [1, 2, 3] }, - transactions: [], - }), - status: 0, - amount, - coinId: "ALPHA", - symbol: "ALPHA", - sizeBytes: 100, -} as Token); - -const createMockStorageData = (tokens: Record = {}): TxfStorageData => ({ - _meta: { - version: 1, - address: TEST_ADDRESS, - ipnsName: TEST_IPNS_NAME, - formatVersion: '2.0', - }, - _sent: [], - _invalid: [], - _outbox: [], - _tombstones: [], - _nametag: null, - ...Object.fromEntries( - Object.entries(tokens).map(([tokenId, token]) => [`_${tokenId}`, token]) - ), -}); - -const createBaseSyncParams = (): SyncParams => ({ - address: TEST_ADDRESS, - publicKey: TEST_PUBLIC_KEY, - ipnsName: TEST_IPNS_NAME, -}); - -// ========================================== -// Test Helpers -// ========================================== - -const setLocalStorage = (data: TxfStorageData) => { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - localStorage.setItem(storageKey, JSON.stringify(data)); -}; - -const getLocalStorage = (): TxfStorageData | null => { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const json = localStorage.getItem(storageKey); - return json ? JSON.parse(json) : null; -}; - -const clearLocalStorage = () => { - localStorage.clear(); -}; - -const resetMocks = () => { - mockValidationResult = { valid: true, issues: [] }; - mockSpentTokens = []; - mockIpfsAvailable = true; - mockRemoteData = null; -}; - -// Count token keys in storage (excluding special folders) -const countTokensInStorage = (storage: TxfStorageData | null): number => { - if (!storage) return 0; - return Object.keys(storage).filter(k => - k.startsWith("_") && - !k.startsWith("_meta") && - !k.startsWith("_sent") && - !k.startsWith("_invalid") && - !k.startsWith("_outbox") && - !k.startsWith("_tombstones") && - !k.startsWith("_nametag") - ).length; -}; - -// ========================================== -// Integration Tests -// ========================================== - -describe("Token Inventory Sync Integration", () => { - beforeEach(() => { - vi.clearAllMocks(); - clearLocalStorage(); - resetMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ------------------------------------------ - // Edge Case 13.1: Empty Inventory Sync - // ------------------------------------------ - describe("Edge Case 13.1: Empty Inventory Sync", () => { - it("should handle empty local and remote inventory", async () => { - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS)$/); - expect(result.inventoryStats?.activeTokens).toBe(0); - expect(result.inventoryStats?.sentTokens).toBe(0); - expect(result.inventoryStats?.invalidTokens).toBe(0); - - // Verify localStorage was created with empty inventory - const stored = getLocalStorage(); - expect(stored).not.toBeNull(); - expect(stored?._meta).toBeDefined(); - expect(countTokensInStorage(stored)).toBe(0); - }); - - it("should import tokens from remote when local is empty", async () => { - // Set up remote data with one token - mockIpfsAvailable = true; - mockRemoteData = createMockStorageData({ - "abc123": createMockTxfToken("abc123", "5000"), - }); - - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS)$/); - expect(result.inventoryStats?.activeTokens).toBe(1); - - // VERIFY: Token was actually written to localStorage - const stored = getLocalStorage(); - expect(stored).not.toBeNull(); - const tokenKey = Object.keys(stored || {}).find(k => k.includes("abc123")); - expect(tokenKey).toBeDefined(); - - // VERIFY: Token data matches remote - const token = stored?.[tokenKey!] as TxfToken; - expect(token.genesis?.data?.coinData[0][1]).toBe("5000"); - expect(token.genesis?.data?.tokenId).toBe("abc123".padEnd(64, "0")); - }); - }); - - // ------------------------------------------ - // Edge Case 13.2: IPFS Unavailable - // ------------------------------------------ - describe("Edge Case 13.2: IPFS Unavailable", () => { - it("should fall back to local data when IPFS unavailable", async () => { - mockIpfsAvailable = false; - - setLocalStorage(createMockStorageData({ - "local1": createMockTxfToken("local1", "1000"), - "local2": createMockTxfToken("local2", "2000"), - })); - - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - // Should succeed with local data only - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS)$/); - expect(result.inventoryStats?.activeTokens).toBe(2); - - // VERIFY: Both tokens preserved in localStorage - const stored = getLocalStorage(); - expect(countTokensInStorage(stored)).toBe(2); - }); - - it("should succeed with local=true when IPFS unavailable", async () => { - mockIpfsAvailable = false; - - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS)$/); - expect(result.syncMode).toBe("LOCAL"); - expect(result.inventoryStats?.activeTokens).toBe(1); - - // VERIFY: Token still in localStorage - const stored = getLocalStorage(); - expect(countTokensInStorage(stored)).toBe(1); - }); - - it("should set ipnsPublished=false when IPFS upload fails", async () => { - mockIpfsAvailable = false; - - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - expect(result.ipnsPublished).toBe(false); - }); - }); - - // ------------------------------------------ - // Edge Case 13.3: Incoming Token Processing - // ------------------------------------------ - describe("Edge Case 13.3: Incoming Token Processing", () => { - it("should process incoming tokens in FAST mode", async () => { - const incomingTokens = [ - createMockToken("incoming1", "1000"), - createMockToken("incoming2", "2000"), - ]; - - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("FAST"); - expect(result.operationStats.tokensImported).toBe(2); - expect(result.inventoryStats?.activeTokens).toBe(2); - - // VERIFY: Tokens actually in localStorage - const stored = getLocalStorage(); - expect(countTokensInStorage(stored)).toBe(2); - }); - - it("should merge incoming tokens with existing inventory", async () => { - setLocalStorage(createMockStorageData({ - "existing1": createMockTxfToken("existing1", "500"), - })); - - const incomingTokens = [ - createMockToken("incoming1", "1000"), - ]; - - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens, - }; - - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(2); - - // VERIFY: Both tokens in localStorage - const stored = getLocalStorage(); - expect(countTokensInStorage(stored)).toBe(2); - - // VERIFY: Both token IDs present - const keys = Object.keys(stored || {}); - expect(keys.some(k => k.includes("existing1"))).toBe(true); - expect(keys.some(k => k.includes("incoming1"))).toBe(true); - }); - }); - - // ------------------------------------------ - // Edge Case 13.4: Outbox Processing - // ------------------------------------------ - describe("Edge Case 13.4: Outbox Processing", () => { - it("should process outbox entries", async () => { - const outboxTokens: OutboxEntry[] = [{ - id: "outbox1", - tokenId: "token1".padEnd(64, "0"), - status: "PENDING_IPFS_SYNC", - createdAt: Date.now(), - updatedAt: Date.now(), - retryCount: 0, - recipientAddress: "DIRECT://test", - }]; - - const params: SyncParams = { - ...createBaseSyncParams(), - outboxTokens, - }; - - const result = await inventorySync(params); - - expect(result.inventoryStats?.outboxTokens).toBe(1); - expect(result.syncMode).toBe("FAST"); - }); - }); - - // ------------------------------------------ - // Edge Case 13.5: Sent Token Tracking - // ------------------------------------------ - describe("Edge Case 13.5: Sent Token Tracking", () => { - it("should preserve sent tokens across sync", async () => { - const storageData = createMockStorageData({ - "active1": createMockTxfToken("active1"), - }); - storageData._sent = [{ - token: createMockTxfToken("sent1"), - timestamp: Date.now() - 10000, - spentAt: Date.now() - 10000, - }]; - setLocalStorage(storageData); - - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - expect(result.inventoryStats?.sentTokens).toBe(1); - expect(result.inventoryStats?.activeTokens).toBe(1); - - // VERIFY: Sent token preserved in localStorage - const stored = getLocalStorage(); - expect(stored?._sent?.length).toBe(1); - expect((stored?._sent?.[0] as SentTokenEntry)?.token?.genesis?.data?.tokenId).toContain("sent1"); - }); - - it("should preserve sent token metadata (timestamp, spentAt)", async () => { - const originalTimestamp = Date.now() - 10000; - const originalSpentAt = Date.now() - 9000; - - const storageData = createMockStorageData(); - storageData._sent = [{ - token: createMockTxfToken("sent1"), - timestamp: originalTimestamp, - spentAt: originalSpentAt, - }]; - setLocalStorage(storageData); - - const params = createBaseSyncParams(); - - // First sync - await inventorySync(params); - - // Second sync - should preserve metadata - await inventorySync(params); - - const stored = getLocalStorage(); - const sentEntry = stored?._sent?.[0] as SentTokenEntry; - expect(sentEntry.timestamp).toBe(originalTimestamp); - expect(sentEntry.spentAt).toBe(originalSpentAt); - }); - }); - - // ------------------------------------------ - // Edge Case 13.6: Invalid Token Handling - // ------------------------------------------ - describe("Edge Case 13.6: Invalid Token Handling", () => { - it("should preserve invalid tokens in Invalid folder", async () => { - const storageData = createMockStorageData(); - storageData._invalid = [{ - token: createMockTxfToken("invalid1"), - timestamp: Date.now(), - invalidatedAt: Date.now(), - reason: "SDK_VALIDATION", - }]; - setLocalStorage(storageData); - - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - expect(result.inventoryStats?.invalidTokens).toBe(1); - - // VERIFY: Invalid token preserved in localStorage - const stored = getLocalStorage(); - expect(stored?._invalid?.length).toBe(1); - expect((stored?._invalid?.[0] as InvalidTokenEntry)?.reason).toBe("SDK_VALIDATION"); - }); - - it("should move newly invalid tokens to Invalid folder", async () => { - // Use unpadded tokenId to match storage key format - // ctx.tokens uses tokenIdFromKey(key) which strips underscore but keeps unpadded ID - const invalidTokenId = "willbeinvalid"; - - // Configure mock to report this token as invalid - mockValidationResult = { - valid: false, - issues: [{ tokenId: invalidTokenId, reason: "Signature verification failed" }], - }; - - setLocalStorage(createMockStorageData({ - "willbeinvalid": createMockTxfToken("willbeinvalid"), - "stillvalid": createMockTxfToken("stillvalid"), - })); - - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - // One valid, one invalid - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.inventoryStats?.invalidTokens).toBe(1); - - // VERIFY: Invalid folder has the correct token with details - const stored = getLocalStorage(); - expect(stored?._invalid?.length).toBe(1); - const invalidEntry = stored?._invalid?.[0] as InvalidTokenEntry; - expect(invalidEntry.reason).toBe("SDK_VALIDATION"); - expect(invalidEntry.details).toBe("Signature verification failed"); - }); - }); - - // ------------------------------------------ - // Cross-Mode Scenarios - // ------------------------------------------ - describe("Cross-Mode Scenarios", () => { - it("should transition from FAST to NORMAL mode cleanly", async () => { - // First FAST sync with incoming tokens - const fastParams: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("fast1")], - }; - - const fastResult = await inventorySync(fastParams); - expect(fastResult.syncMode).toBe("FAST"); - expect(fastResult.inventoryStats?.activeTokens).toBe(1); - - // Then NORMAL sync - const normalParams = createBaseSyncParams(); - const normalResult = await inventorySync(normalParams); - - expect(normalResult.syncMode).toBe("NORMAL"); - expect(normalResult.inventoryStats?.activeTokens).toBe(1); - - // VERIFY: Token persisted across mode change - const stored = getLocalStorage(); - expect(countTokensInStorage(stored)).toBe(1); - }); - - it("should handle sequential LOCAL mode syncs", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - // First sync - const result1 = await inventorySync(params); - expect(result1.inventoryStats?.activeTokens).toBe(1); - - // Second sync (should see same data) - const result2 = await inventorySync(params); - expect(result2.inventoryStats?.activeTokens).toBe(1); - - // VERIFY: Data consistent - const stored = getLocalStorage(); - expect(countTokensInStorage(stored)).toBe(1); - }); - }); - - // ------------------------------------------ - // Statistics Accuracy - // ------------------------------------------ - describe("Statistics Accuracy", () => { - it("should provide accurate operation stats", async () => { - const incomingTokens = [ - createMockToken("new1"), - createMockToken("new2"), - createMockToken("new3"), - ]; - - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens, - }; - - const result = await inventorySync(params); - - expect(result.operationStats.tokensImported).toBe(3); - expect(result.operationStats.tokensRemoved).toBe(0); - - // VERIFY: All 3 tokens in storage - const stored = getLocalStorage(); - expect(countTokensInStorage(stored)).toBe(3); - }); - - it("should track sync duration accurately", async () => { - const params = createBaseSyncParams(); - const startTime = Date.now(); - - const result = await inventorySync(params); - - const endTime = Date.now(); - - expect(typeof result.syncDurationMs).toBe("number"); - expect(result.syncDurationMs).toBeGreaterThanOrEqual(0); - expect(result.syncDurationMs).toBeLessThanOrEqual(endTime - startTime + 100); - - expect(typeof result.timestamp).toBe("number"); - expect(result.timestamp).toBeGreaterThanOrEqual(startTime); - expect(result.timestamp).toBeLessThanOrEqual(endTime + 100); - }); - }); - - // ------------------------------------------ - // Data Persistence - // ------------------------------------------ - describe("Data Persistence", () => { - it("should persist changes to localStorage", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("persist1", "5000")], - }; - - await inventorySync(params); - - const stored = getLocalStorage(); - expect(stored).not.toBeNull(); - expect(stored?._meta).toBeDefined(); - expect(typeof stored?._meta?.version).toBe("number"); - - // VERIFY: Token with correct amount - amount is in genesis.data.coinData - const tokenKey = Object.keys(stored || {}).find(k => k.includes("persist1")); - expect(tokenKey).toBeDefined(); - const token = stored?.[tokenKey!] as TxfToken; - expect(token.genesis?.data?.coinData[0][1]).toBe("5000"); - }); - - it("should NOT increment version when content unchanged", async () => { - setLocalStorage(createMockStorageData()); - const initialVersion = getLocalStorage()?._meta?.version || 0; - - const params = createBaseSyncParams(); - - // First sync - version stays same (content matches existing localStorage) - await inventorySync(params); - const v1 = getLocalStorage()?._meta?.version || 0; - expect(v1).toBe(initialVersion); - - // Subsequent syncs - version still unchanged (no content changes) - await inventorySync(params); - const v2 = getLocalStorage()?._meta?.version || 0; - expect(v2).toBe(v1); - - await inventorySync(params); - const v3 = getLocalStorage()?._meta?.version || 0; - expect(v3).toBe(v2); - }); - - it("should increment version when content changes", async () => { - setLocalStorage(createMockStorageData()); - const initialVersion = getLocalStorage()?._meta?.version || 0; - - const params = createBaseSyncParams(); - - // Add token - version should increment - await inventorySync({ - ...params, - incomingTokens: [createMockToken("token1")], - }); - const v1 = getLocalStorage()?._meta?.version || 0; - expect(v1).toBe(initialVersion + 1); - - // Add another token - version should increment again - await inventorySync({ - ...params, - incomingTokens: [createMockToken("token2")], - }); - const v2 = getLocalStorage()?._meta?.version || 0; - expect(v2).toBe(v1 + 1); - }); - }); - - // ------------------------------------------ - // NAMETAG Mode - // ------------------------------------------ - describe("NAMETAG Mode", () => { - it("should return lightweight result in NAMETAG mode", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - nametag: true, - }; - - const result = await inventorySync(params); - - expect(result.status).toBe("NAMETAG_ONLY"); - expect(result.syncMode).toBe("NAMETAG"); - expect(result.nametags).toBeDefined(); - expect(Array.isArray(result.nametags)).toBe(true); - expect(result.inventoryStats).toBeUndefined(); - }); - - it("should skip validation in NAMETAG mode", async () => { - // Even with invalid tokens configured, NAMETAG mode should skip validation - mockValidationResult = { - valid: false, - issues: [{ tokenId: "some-token", reason: "Should be skipped" }], - }; - - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - nametag: true, - }; - - const result = await inventorySync(params); - - // Should succeed (NAMETAG mode doesn't run validation) - expect(result.status).toBe("NAMETAG_ONLY"); - expect(result.inventoryStats).toBeUndefined(); - }); - }); - - // ------------------------------------------ - // Spent Detection in NORMAL mode - // ------------------------------------------ - describe("Spent Detection (NORMAL mode)", () => { - it("should move spent tokens to Sent folder", async () => { - const spentTokenId = "spent1".padEnd(64, "0"); - const spentStateHash = "0000" + "a".repeat(60); - - mockSpentTokens = [{ - tokenId: spentTokenId, - stateHash: spentStateHash, - localId: "spent1", - }]; - - setLocalStorage(createMockStorageData({ - "spent1": createMockTxfToken("spent1"), - "active1": createMockTxfToken("active1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.sentTokens).toBe(1); - expect(result.inventoryStats?.activeTokens).toBe(1); - - // VERIFY: Correct tokens in correct folders - const stored = getLocalStorage(); - expect(stored?._sent?.length).toBe(1); - expect(countTokensInStorage(stored)).toBe(1); // Only active token - - // VERIFY: Sent token has correct tokenId - const sentEntry = stored?._sent?.[0] as SentTokenEntry; - expect(sentEntry.token?.genesis?.data?.tokenId).toBe(spentTokenId); - }); - - it("should add tombstone for spent token", async () => { - const spentTokenId = "tomb1".padEnd(64, "0"); - const spentStateHash = "0000" + "a".repeat(60); - - mockSpentTokens = [{ - tokenId: spentTokenId, - stateHash: spentStateHash, - localId: "tomb1", - }]; - - setLocalStorage(createMockStorageData({ - "tomb1": createMockTxfToken("tomb1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.operationStats.tombstonesAdded).toBeGreaterThanOrEqual(1); - - // VERIFY: Tombstone in localStorage - const stored = getLocalStorage(); - expect(stored?._tombstones?.length).toBeGreaterThanOrEqual(1); - - // VERIFY: Tombstone has correct tokenId and stateHash - const tombstone = stored?._tombstones?.find(t => - t.tokenId === spentTokenId || t.tokenId?.includes("tomb1") - ); - expect(tombstone).toBeDefined(); - }); - }); -}); - -// ========================================== -// Concurrent Sync Scenarios -// ========================================== - -describe("Concurrent Sync Behavior", () => { - beforeEach(() => { - vi.clearAllMocks(); - clearLocalStorage(); - resetMocks(); - }); - - it("should handle sequential syncs without data loss", async () => { - const params = createBaseSyncParams(); - - // Run 5 syncs sequentially (not truly concurrent due to JS single-thread) - const results: Awaited>[] = []; - for (let i = 0; i < 5; i++) { - results.push(await inventorySync(params)); - } - - // All should complete successfully - expect(results.every(r => r.status === "SUCCESS" || r.status === "PARTIAL_SUCCESS")).toBe(true); - - // VERIFY: Version is maintained (doesn't increment when content unchanged) - const stored = getLocalStorage(); - // With no content changes, version should stay the same after initial sync - // This is correct behavior - prevents unnecessary IPFS uploads on reload - expect(stored?._meta?.version).toBeGreaterThanOrEqual(1); - }); - - it("should maintain data integrity across multiple syncs", async () => { - // Start with existing token - setLocalStorage(createMockStorageData({ - "existing": createMockTxfToken("existing", "1000"), - })); - - const params = createBaseSyncParams(); - - // Run multiple syncs - await inventorySync(params); - await inventorySync(params); - await inventorySync(params); - - const stored = getLocalStorage(); - expect(stored).not.toBeNull(); - - // VERIFY: Existing token not lost - const tokenKey = Object.keys(stored || {}).find(k => k.includes("existing")); - expect(tokenKey).toBeDefined(); - - // VERIFY: Token amount preserved - amount is in genesis.data.coinData - const token = stored?.[tokenKey!] as TxfToken; - expect(token.genesis?.data?.coinData[0][1]).toBe("1000"); - - // VERIFY: No duplicate tokens - expect(countTokensInStorage(stored)).toBe(1); - }); - - it("should correctly merge data added between syncs", async () => { - const params = createBaseSyncParams(); - - // First sync - empty - await inventorySync(params); - - // Add token via incoming - const params2: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("added1", "2000")], - }; - await inventorySync(params2); - - // Third sync (normal) should still have the token - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - - // VERIFY: Token persisted - const stored = getLocalStorage(); - const tokenKey = Object.keys(stored || {}).find(k => k.includes("added1")); - expect(tokenKey).toBeDefined(); - }); -}); diff --git a/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts b/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts deleted file mode 100644 index a006ee91..00000000 --- a/tests/unit/components/agents/shared/ChatHistoryIpfsService.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { STORAGE_KEYS } from "../../../../../src/config/storageKeys"; - -// ========================================== -// Mock Setup -// ========================================== - -// Mock IdentityManager -vi.mock("../../../../../src/components/wallet/L3/services/IdentityManager", () => ({ - IdentityManager: { - getInstance: vi.fn(() => ({ - getMasterSeed: vi.fn().mockReturnValue(new Uint8Array(32).fill(1)), - getWalletAddress: vi.fn().mockReturnValue("0x123"), - })), - }, -})); - -// Mock IPFS config -vi.mock("../../../../../src/config/ipfs.config", () => ({ - getBootstrapPeers: vi.fn(() => []), - getAllBackendGatewayUrls: vi.fn(() => []), - IPNS_RESOLUTION_CONFIG: { timeout: 1000 }, - IPFS_CONFIG: { timeout: 1000 }, -})); - -// Mock Helia and related -vi.mock("helia", () => ({ - createHelia: vi.fn(), -})); - -vi.mock("@helia/json", () => ({ - json: vi.fn(), -})); - -vi.mock("@libp2p/bootstrap", () => ({ - bootstrap: vi.fn(), -})); - -vi.mock("@libp2p/crypto/keys", () => ({ - generateKeyPairFromSeed: vi.fn(), -})); - -vi.mock("@libp2p/peer-id", () => ({ - peerIdFromPrivateKey: vi.fn(), -})); - -vi.mock("ipns", () => ({ - createIPNSRecord: vi.fn(), - marshalIPNSRecord: vi.fn(), - unmarshalIPNSRecord: vi.fn(), -})); - -// Mock ChatHistoryRepository -vi.mock("../../../../../src/components/agents/shared/ChatHistoryRepository", () => ({ - chatHistoryRepository: { - getAllSessions: vi.fn(() => []), - getMessages: vi.fn(() => []), - }, -})); - -// Import after mocking -import { - ChatHistoryIpfsService, - getChatHistoryIpfsService, -} from "../../../../../src/components/agents/shared/ChatHistoryIpfsService"; - -// ========================================== -// ChatHistoryIpfsService Tests -// ========================================== - -describe("ChatHistoryIpfsService", () => { - let localStorageMock: Record; - - beforeEach(() => { - localStorageMock = {}; - - // Mock localStorage - vi.stubGlobal("localStorage", { - getItem: vi.fn((key: string) => localStorageMock[key] || null), - setItem: vi.fn((key: string, value: string) => { - localStorageMock[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete localStorageMock[key]; - }), - clear: vi.fn(() => { - localStorageMock = {}; - }), - key: vi.fn((index: number) => Object.keys(localStorageMock)[index] || null), - get length() { - return Object.keys(localStorageMock).length; - }, - }); - - // Mock window - vi.stubGlobal("window", { - ...globalThis.window, - dispatchEvent: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - // ========================================== - // Singleton Tests - // ========================================== - - describe("getInstance", () => { - it("should return singleton instance", () => { - const instance1 = ChatHistoryIpfsService.getInstance(); - const instance2 = ChatHistoryIpfsService.getInstance(); - - expect(instance1).toBe(instance2); - }); - }); - - describe("getChatHistoryIpfsService", () => { - it("should return singleton via accessor function", () => { - const instance1 = getChatHistoryIpfsService(); - const instance2 = getChatHistoryIpfsService(); - - expect(instance1).toBe(instance2); - expect(instance1).toBe(ChatHistoryIpfsService.getInstance()); - }); - }); - - // ========================================== - // Tombstone Management Tests - // ========================================== - - describe("recordSessionDeletion", () => { - it("should record tombstone for deleted session", () => { - const service = getChatHistoryIpfsService(); - - service.recordSessionDeletion("session-1"); - - const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(tombstones["session-1"]).toBeDefined(); - expect(tombstones["session-1"].sessionId).toBe("session-1"); - expect(tombstones["session-1"].reason).toBe("user-deleted"); - }); - - it("should preserve existing tombstones", () => { - localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ - "existing-session": { - sessionId: "existing-session", - deletedAt: Date.now() - 1000, - reason: "user-deleted", - }, - }); - - const service = getChatHistoryIpfsService(); - service.recordSessionDeletion("new-session"); - - const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(tombstones["existing-session"]).toBeDefined(); - expect(tombstones["new-session"]).toBeDefined(); - }); - }); - - describe("recordBulkDeletion", () => { - it("should record tombstones for multiple sessions", () => { - const service = getChatHistoryIpfsService(); - - service.recordBulkDeletion(["session-1", "session-2", "session-3"]); - - const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(Object.keys(tombstones)).toHaveLength(3); - expect(tombstones["session-1"].reason).toBe("clear-all"); - expect(tombstones["session-2"].reason).toBe("clear-all"); - expect(tombstones["session-3"].reason).toBe("clear-all"); - }); - - it("should use same timestamp for all tombstones in bulk", () => { - const service = getChatHistoryIpfsService(); - - service.recordBulkDeletion(["session-1", "session-2"]); - - const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(tombstones["session-1"].deletedAt).toBe(tombstones["session-2"].deletedAt); - }); - }); - - describe("cleanupOldTombstones", () => { - it("should return 0 when no tombstones exist", () => { - const service = getChatHistoryIpfsService(); - - const removed = service.cleanupOldTombstones(); - - expect(removed).toBe(0); - }); - - it("should remove tombstones older than 30 days", () => { - const now = Date.now(); - const thirtyOneDaysAgo = now - 31 * 24 * 60 * 60 * 1000; - const twentyDaysAgo = now - 20 * 24 * 60 * 60 * 1000; - - localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ - "old-session": { - sessionId: "old-session", - deletedAt: thirtyOneDaysAgo, - reason: "user-deleted", - }, - "recent-session": { - sessionId: "recent-session", - deletedAt: twentyDaysAgo, - reason: "user-deleted", - }, - }); - - const service = getChatHistoryIpfsService(); - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - const removed = service.cleanupOldTombstones(); - - expect(removed).toBe(1); - - const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(tombstones["old-session"]).toBeUndefined(); - expect(tombstones["recent-session"]).toBeDefined(); - - consoleSpy.mockRestore(); - }); - - it("should keep tombstones under 30 days old", () => { - const now = Date.now(); - // 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: justUnderThirtyDaysAgo, - reason: "user-deleted", - }, - }); - - const service = getChatHistoryIpfsService(); - - const removed = service.cleanupOldTombstones(); - - expect(removed).toBe(0); - - const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(tombstones["boundary-session"]).toBeDefined(); - }); - - it("should remove all tombstones when all are old", () => { - const thirtyOneDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000; - - localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ - "session-1": { - sessionId: "session-1", - deletedAt: thirtyOneDaysAgo - 1000, - reason: "user-deleted", - }, - "session-2": { - sessionId: "session-2", - deletedAt: thirtyOneDaysAgo - 2000, - reason: "clear-all", - }, - }); - - const service = getChatHistoryIpfsService(); - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - const removed = service.cleanupOldTombstones(); - - expect(removed).toBe(2); - - const tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(Object.keys(tombstones)).toHaveLength(0); - - consoleSpy.mockRestore(); - }); - - it("should not modify storage when no tombstones removed", () => { - const recentTime = Date.now() - 1000; - - localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ - "recent-session": { - sessionId: "recent-session", - deletedAt: recentTime, - reason: "user-deleted", - }, - }); - - const service = getChatHistoryIpfsService(); - const setItemSpy = vi.spyOn(localStorage, "setItem"); - - const removed = service.cleanupOldTombstones(); - - expect(removed).toBe(0); - // setItem should not be called when no tombstones removed - expect(setItemSpy).not.toHaveBeenCalledWith( - STORAGE_KEYS.AGENT_CHAT_TOMBSTONES, - expect.any(String) - ); - }); - }); - - // ========================================== - // Status Tests - // ========================================== - - describe("getStatus", () => { - it("should return initial status", () => { - const service = getChatHistoryIpfsService(); - - const status = service.getStatus(); - - expect(status.initialized).toBe(false); - expect(status.isSyncing).toBe(false); - expect(status.hasPendingSync).toBe(false); - expect(status.currentStep).toBe("idle"); - }); - }); - - describe("onStatusChange", () => { - it("should register and call status listener", () => { - const service = getChatHistoryIpfsService(); - const listener = vi.fn(); - - service.onStatusChange(listener); - - // Recording a tombstone triggers status update - service.recordSessionDeletion("test-session"); - - // Listener should be added (will be called on next status change) - expect(listener).not.toHaveBeenCalled(); // Not called immediately - }); - - it("should return unsubscribe function", () => { - const service = getChatHistoryIpfsService(); - const listener = vi.fn(); - - const unsubscribe = service.onStatusChange(listener); - - expect(typeof unsubscribe).toBe("function"); - - // Unsubscribe should work without error - expect(() => unsubscribe()).not.toThrow(); - }); - }); - - // ========================================== - // hasPendingSync Tests - // ========================================== - - describe("hasPendingSync tracking", () => { - it("should include hasPendingSync in status", () => { - const service = getChatHistoryIpfsService(); - - const status = service.getStatus(); - - expect(status).toHaveProperty("hasPendingSync"); - expect(typeof status.hasPendingSync).toBe("boolean"); - }); - }); - - // ========================================== - // clearLocalStateOnly Tests - // ========================================== - - describe("clearLocalStateOnly", () => { - it("should clear tombstones without IPFS sync", () => { - localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({ - "session-1": { sessionId: "session-1", deletedAt: Date.now(), reason: "user-deleted" }, - }); - - const service = getChatHistoryIpfsService(); - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - service.clearLocalStateOnly(); - - expect(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]).toBeUndefined(); - - consoleSpy.mockRestore(); - }); - }); -}); - -// ========================================== -// Integration-like Tests (using mock) -// ========================================== - -describe("ChatHistoryIpfsService tombstone lifecycle", () => { - let localStorageMock: Record; - - beforeEach(() => { - localStorageMock = {}; - - vi.stubGlobal("localStorage", { - getItem: vi.fn((key: string) => localStorageMock[key] || null), - setItem: vi.fn((key: string, value: string) => { - localStorageMock[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete localStorageMock[key]; - }), - clear: vi.fn(() => { - localStorageMock = {}; - }), - key: vi.fn((index: number) => Object.keys(localStorageMock)[index] || null), - get length() { - return Object.keys(localStorageMock).length; - }, - }); - - vi.stubGlobal("window", { - ...globalThis.window, - dispatchEvent: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - it("should handle full tombstone lifecycle: create, age, cleanup", () => { - const service = getChatHistoryIpfsService(); - - // Step 1: Record deletions - service.recordSessionDeletion("session-1"); - service.recordBulkDeletion(["session-2", "session-3"]); - - let tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(Object.keys(tombstones)).toHaveLength(3); - - // Step 2: Simulate aging (modify timestamps) - const oldTime = Date.now() - 35 * 24 * 60 * 60 * 1000; // 35 days ago - tombstones["session-1"].deletedAt = oldTime; - tombstones["session-2"].deletedAt = oldTime; - // session-3 stays recent - localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify(tombstones); - - // Step 3: Cleanup - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const removed = service.cleanupOldTombstones(); - - expect(removed).toBe(2); - - tombstones = JSON.parse(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]); - expect(Object.keys(tombstones)).toHaveLength(1); - expect(tombstones["session-3"]).toBeDefined(); - - consoleSpy.mockRestore(); - }); -}); diff --git a/tests/unit/components/agents/shared/ChatHistoryRepository.test.ts b/tests/unit/components/agents/shared/ChatHistoryRepository.test.ts index 19cee733..30d5b4e0 100644 --- a/tests/unit/components/agents/shared/ChatHistoryRepository.test.ts +++ b/tests/unit/components/agents/shared/ChatHistoryRepository.test.ts @@ -5,17 +5,7 @@ import { STORAGE_KEYS, STORAGE_KEY_GENERATORS } from "../../../../../src/config/ // Mock Setup // ========================================== -// Mock ChatHistoryIpfsService before importing ChatHistoryRepository -vi.mock("../../../../../src/components/agents/shared/ChatHistoryIpfsService", () => ({ - getChatHistoryIpfsService: vi.fn(() => ({ - syncImmediately: vi.fn().mockResolvedValue({ success: true }), - scheduleSync: vi.fn(), - recordSessionDeletion: vi.fn(), - recordBulkDeletion: vi.fn(), - })), -})); - -// Import after mocking +// Import import { ChatHistoryRepository, chatHistoryRepository, @@ -361,15 +351,15 @@ describe("ChatHistoryRepository", () => { }); describe("clearAllLocalHistoryOnly", () => { - it("should clear sessions without IPFS sync", () => { + it("should clear sessions and messages", () => { const sessions: ChatSession[] = [createMockSession("session-1")]; localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS] = JSON.stringify(sessions); - localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES] = JSON.stringify({}); + localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")] = JSON.stringify([]); repository.clearAllLocalHistoryOnly(); expect(localStorageMock[STORAGE_KEYS.AGENT_CHAT_SESSIONS]).toBeUndefined(); - expect(localStorageMock[STORAGE_KEYS.AGENT_CHAT_TOMBSTONES]).toBeUndefined(); + expect(localStorageMock[STORAGE_KEY_GENERATORS.agentChatMessages("session-1")]).toBeUndefined(); }); }); diff --git a/tests/unit/components/wallet/L3/services/ConflictResolutionService.test.ts b/tests/unit/components/wallet/L3/services/ConflictResolutionService.test.ts deleted file mode 100644 index 6288d554..00000000 --- a/tests/unit/components/wallet/L3/services/ConflictResolutionService.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { - ConflictResolutionService, - getConflictResolutionService, -} from "../../../../../../src/components/wallet/L3/services/ConflictResolutionService"; -import type { TxfStorageData, TxfToken, TxfMeta } from "../../../../../../src/components/wallet/L3/services/types/TxfTypes"; - -// ========================================== -// Test Fixtures -// ========================================== - -const createMockMeta = (overrides: Partial = {}): TxfMeta => ({ - version: 1, - timestamp: Date.now(), - address: "0x123", - ipnsName: "ipns-test", - formatVersion: "2.0", - ...overrides, -}); - -const createMockTxfToken = ( - tokenId: string = "a".repeat(64), - transactionCount: number = 0, - proofCount: number = 0 -): TxfToken => { - const inclusionProof = { - authenticator: { - algorithm: "secp256k1", - publicKey: "d".repeat(64), - signature: "e".repeat(128), - stateHash: "0000" + "f".repeat(60), - }, - merkleTreePath: { - root: "0000" + "1".repeat(60), - steps: [{ data: "2".repeat(64), path: "1" }], - }, - transactionHash: "3".repeat(64), - unicityCertificate: "4".repeat(100), - }; - - const transactions = []; - for (let i = 0; i < transactionCount; i++) { - transactions.push({ - previousStateHash: i === 0 ? tokenId : `hash${i - 1}`, - newStateHash: `hash${i}`, - predicate: "pred" + i, - inclusionProof: i < proofCount ? inclusionProof : null, - }); - } - - return { - version: "2.0", - genesis: { - data: { - tokenId, - tokenType: "b".repeat(64), - coinData: [["ALPHA", "1000"]], - tokenData: "", - salt: "c".repeat(64), - recipient: "DIRECT://abc123", - recipientDataHash: null, - reason: null, - }, - inclusionProof, - }, - state: { - data: "", - predicate: "5".repeat(64), - }, - transactions, - nametags: [], - _integrity: { - genesisDataJSONHash: "0000" + tokenId.slice(0, 60), - }, - }; -}; - -const createMockStorageData = ( - tokens: Record, - meta: Partial = {} -): TxfStorageData => { - const storageData: TxfStorageData = { - _meta: createMockMeta(meta), - }; - - for (const [tokenId, token] of Object.entries(tokens)) { - storageData[`_${tokenId}`] = token; - } - - return storageData; -}; - -// ========================================== -// ConflictResolutionService Tests -// ========================================== - -describe("ConflictResolutionService", () => { - let service: ConflictResolutionService; - - beforeEach(() => { - service = new ConflictResolutionService(); - }); - - // ========================================== - // resolveConflict Tests - // ========================================== - - describe("resolveConflict", () => { - it("should use remote as base when remote version is higher", () => { - const tokenId = "a".repeat(64); - const local = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 1 } - ); - const remote = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 2 } - ); - - const result = service.resolveConflict(local, remote); - - expect(result.merged._meta.version).toBe(3); // remote version + 1 - }); - - it("should use local as base when local version is higher", () => { - const tokenId = "a".repeat(64); - const local = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 3 } - ); - const remote = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 1 } - ); - - const result = service.resolveConflict(local, remote); - - expect(result.merged._meta.version).toBe(4); // local version + 1 - }); - - it("should use timestamp for tiebreaker when versions are equal", () => { - const tokenId = "a".repeat(64); - const now = Date.now(); - - const local = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 1, timestamp: now - 1000 } - ); - const remote = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 1, timestamp: now } - ); - - const result = service.resolveConflict(local, remote); - - expect(result.merged._meta.version).toBe(2); - }); - - it("should merge tokens from both sources", () => { - const localTokenId = "a".repeat(64); - const remoteTokenId = "b".repeat(64); - - const local = createMockStorageData( - { [localTokenId]: createMockTxfToken(localTokenId) }, - { version: 1 } - ); - const remote = createMockStorageData( - { [remoteTokenId]: createMockTxfToken(remoteTokenId) }, - { version: 2 } - ); - - const result = service.resolveConflict(local, remote); - - expect(result.merged[`_${localTokenId}`]).toBeDefined(); - expect(result.merged[`_${remoteTokenId}`]).toBeDefined(); - expect(result.newTokens).toContain(localTokenId); - }); - - it("should report conflicts when tokens exist in both sources", () => { - const tokenId = "a".repeat(64); - - // Local has longer chain - const localToken = createMockTxfToken(tokenId, 2, 2); - // Remote has shorter chain - const remoteToken = createMockTxfToken(tokenId, 1, 1); - - const local = createMockStorageData( - { [tokenId]: localToken }, - { version: 1 } - ); - const remote = createMockStorageData( - { [tokenId]: remoteToken }, - { version: 2 } - ); - - const result = service.resolveConflict(local, remote); - - // The winner should be the longer chain (local) - const mergedToken = result.merged[`_${tokenId}`] as TxfToken; - expect(mergedToken.transactions.length).toBe(2); - }); - }); - - // ========================================== - // Token Conflict Resolution Tests - // ========================================== - - describe("token conflict resolution", () => { - it("should prefer longer chain", () => { - const tokenId = "a".repeat(64); - - const localToken = createMockTxfToken(tokenId, 3, 3); // 3 transactions - const remoteToken = createMockTxfToken(tokenId, 1, 1); // 1 transaction - - const local = createMockStorageData( - { [tokenId]: localToken }, - { version: 1, timestamp: Date.now() - 1000 } - ); - const remote = createMockStorageData( - { [tokenId]: remoteToken }, - { version: 1, timestamp: Date.now() } - ); - - const result = service.resolveConflict(local, remote); - - // Should prefer local (longer chain) even though remote has newer timestamp - const mergedToken = result.merged[`_${tokenId}`] as TxfToken; - expect(mergedToken.transactions.length).toBe(3); - }); - - it("should prefer more proofs when chains are equal length", () => { - const tokenId = "a".repeat(64); - - const localToken = createMockTxfToken(tokenId, 2, 2); // 2 txs, 2 proofs - const remoteToken = createMockTxfToken(tokenId, 2, 1); // 2 txs, 1 proof - - const local = createMockStorageData( - { [tokenId]: localToken }, - { version: 1 } - ); - const remote = createMockStorageData( - { [tokenId]: remoteToken }, - { version: 2 } - ); - - const result = service.resolveConflict(local, remote); - - // Remote is base (higher version), but local has more proofs - // Check that conflicts array contains this resolution - const conflict = result.conflicts.find((c) => c.tokenId === tokenId); - if (conflict) { - expect(conflict.reason).toContain("proofs"); - } - }); - }); - - // ========================================== - // hasConflict Tests - // ========================================== - - describe("hasConflict", () => { - it("should return true when versions differ", () => { - const local = createMockStorageData({}, { version: 1 }); - const remote = createMockStorageData({}, { version: 2 }); - - expect(service.hasConflict(local, remote)).toBe(true); - }); - - it("should return false when versions are the same", () => { - const local = createMockStorageData({}, { version: 1 }); - const remote = createMockStorageData({}, { version: 1 }); - - expect(service.hasConflict(local, remote)).toBe(false); - }); - }); - - // ========================================== - // isRemoteNewer Tests - // ========================================== - - describe("isRemoteNewer", () => { - it("should return true when remote version is higher and contains all local tokens", () => { - const tokenId = "a".repeat(64); - const local = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 1 } - ); - const remote = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 2 } - ); - - expect(service.isRemoteNewer(local, remote)).toBe(true); - }); - - it("should return false when local has tokens not in remote", () => { - const localTokenId = "a".repeat(64); - const remoteTokenId = "b".repeat(64); - - const local = createMockStorageData( - { [localTokenId]: createMockTxfToken(localTokenId) }, - { version: 1 } - ); - const remote = createMockStorageData( - { [remoteTokenId]: createMockTxfToken(remoteTokenId) }, - { version: 2 } - ); - - expect(service.isRemoteNewer(local, remote)).toBe(false); - }); - - it("should return false when local version is higher or equal", () => { - const local = createMockStorageData({}, { version: 2 }); - const remote = createMockStorageData({}, { version: 2 }); - - expect(service.isRemoteNewer(local, remote)).toBe(false); - }); - }); - - // ========================================== - // isLocalNewer Tests - // ========================================== - - describe("isLocalNewer", () => { - it("should return true when local version is higher and contains all remote tokens", () => { - const tokenId = "a".repeat(64); - const local = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 2 } - ); - const remote = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 1 } - ); - - expect(service.isLocalNewer(local, remote)).toBe(true); - }); - - it("should return false when remote has tokens not in local", () => { - const remoteTokenId = "b".repeat(64); - - const local = createMockStorageData({}, { version: 2 }); - const remote = createMockStorageData( - { [remoteTokenId]: createMockTxfToken(remoteTokenId) }, - { version: 1 } - ); - - expect(service.isLocalNewer(local, remote)).toBe(false); - }); - }); - - // ========================================== - // Nametag Merging Tests - // ========================================== - - describe("nametag merging", () => { - it("should prefer local nametag when both exist", () => { - const tokenId = "a".repeat(64); - - const local: TxfStorageData = { - ...createMockStorageData({ [tokenId]: createMockTxfToken(tokenId) }, { version: 1 }), - _nametag: { name: "local-user", token: {}, timestamp: Date.now(), format: "1.0", version: "1.0" }, - }; - const remote: TxfStorageData = { - ...createMockStorageData({ [tokenId]: createMockTxfToken(tokenId) }, { version: 2 }), - _nametag: { name: "remote-user", token: {}, timestamp: Date.now(), format: "1.0", version: "1.0" }, - }; - - const result = service.resolveConflict(local, remote); - - expect(result.merged._nametag?.name).toBe("local-user"); - }); - - it("should use remote nametag when local has none", () => { - const tokenId = "a".repeat(64); - - const local = createMockStorageData( - { [tokenId]: createMockTxfToken(tokenId) }, - { version: 1 } - ); - const remote: TxfStorageData = { - ...createMockStorageData({ [tokenId]: createMockTxfToken(tokenId) }, { version: 2 }), - _nametag: { name: "remote-user", token: {}, timestamp: Date.now(), format: "1.0", version: "1.0" }, - }; - - const result = service.resolveConflict(local, remote); - - expect(result.merged._nametag?.name).toBe("remote-user"); - }); - }); -}); - -// ========================================== -// Singleton Tests -// ========================================== - -describe("getConflictResolutionService", () => { - it("should return the same instance on multiple calls", () => { - const instance1 = getConflictResolutionService(); - const instance2 = getConflictResolutionService(); - - expect(instance1).toBe(instance2); - }); -}); diff --git a/tests/unit/components/wallet/L3/services/InventoryBackgroundLoops.test.ts b/tests/unit/components/wallet/L3/services/InventoryBackgroundLoops.test.ts deleted file mode 100644 index 4a0686c8..00000000 --- a/tests/unit/components/wallet/L3/services/InventoryBackgroundLoops.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import type { Token } from "../../../../../../src/components/wallet/L3/data/model"; -import type { IdentityManager } from "../../../../../../src/components/wallet/L3/services/IdentityManager"; -import type { NostrDeliveryQueueEntry } from "../../../../../../src/components/wallet/L3/services/types/QueueTypes"; -import { DEFAULT_LOOP_CONFIG } from "../../../../../../src/components/wallet/L3/services/types/QueueTypes"; - -// ========================================== -// Mock Fixtures -// ========================================== - -const createMockToken = (id: string): Token => ({ - id, - name: "Test Token", - type: "UCT", - timestamp: Date.now(), - jsonData: JSON.stringify({ - version: "2.0", - genesis: { data: { tokenId: id.padEnd(64, "0") } }, - state: { data: "", predicate: "test" }, - transactions: [], - }), - status: 0, - amount: "1000", - coinId: "ALPHA", - symbol: "ALPHA", - sizeBytes: 100, -} as Token); - -const createMockIdentityManager = (): IdentityManager => ({ - getCurrentIdentity: vi.fn().mockResolvedValue({ - address: "test-address", - publicKey: "test-public-key", - ipnsName: "test-ipns-name", - }), -} as unknown as IdentityManager); - -const createMockDeliveryEntry = (id: string): NostrDeliveryQueueEntry => ({ - id, - outboxEntryId: `outbox-${id}`, - recipientPubkey: "recipient-pubkey", - recipientNametag: "@test", - payloadJson: JSON.stringify({ - tokenId: "test-token-id".padEnd(64, "0"), - stateHash: "0000" + "a".repeat(60), - inclusionProof: {}, - }), - retryCount: 0, - createdAt: Date.now(), -}); - -// ========================================== -// DEFAULT_LOOP_CONFIG Tests -// ========================================== - -describe("DEFAULT_LOOP_CONFIG", () => { - it("should have correct batch window (3 seconds)", () => { - expect(DEFAULT_LOOP_CONFIG.receiveTokenBatchWindowMs).toBe(3000); - }); - - it("should have correct max batch size (100 tokens)", () => { - expect(DEFAULT_LOOP_CONFIG.receiveTokenMaxBatchSize).toBe(100); - }); - - it("should have correct delivery parallelism (12)", () => { - expect(DEFAULT_LOOP_CONFIG.deliveryMaxParallel).toBe(12); - }); - - it("should have correct max retries (10)", () => { - expect(DEFAULT_LOOP_CONFIG.deliveryMaxRetries).toBe(10); - }); - - it("should have correct backoff schedule per spec (1s, 3s, 10s, 30s, 60s)", () => { - expect(DEFAULT_LOOP_CONFIG.deliveryBackoffMs).toEqual([1000, 3000, 10000, 30000, 60000]); - }); - - it("should have correct empty queue wait (3 seconds)", () => { - expect(DEFAULT_LOOP_CONFIG.deliveryEmptyQueueWaitMs).toBe(3000); - }); - - it("should have correct check interval (500ms)", () => { - expect(DEFAULT_LOOP_CONFIG.deliveryCheckIntervalMs).toBe(500); - }); -}); - -// ========================================== -// ReceiveTokensToInventoryLoop Tests -// ========================================== - -describe("ReceiveTokensToInventoryLoop", () => { - let mockIdentityManager: IdentityManager; - - beforeEach(() => { - vi.useFakeTimers(); - mockIdentityManager = createMockIdentityManager(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - describe("Batching Behavior", () => { - it("should batch tokens within 3-second window", async () => { - // Dynamic import to allow mocking - const { ReceiveTokensToInventoryLoop } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const loop = new ReceiveTokensToInventoryLoop(mockIdentityManager); - - // Queue multiple tokens quickly - await loop.queueIncomingToken(createMockToken("1"), "event-1", "sender-1"); - await loop.queueIncomingToken(createMockToken("2"), "event-2", "sender-2"); - - const status = loop.getBatchStatus(); - expect(status.pending).toBe(2); - expect(status.batchId).not.toBeNull(); - - loop.destroy(); - }); - - it("should create unique batch ID for each batch", async () => { - const { ReceiveTokensToInventoryLoop } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const loop = new ReceiveTokensToInventoryLoop(mockIdentityManager); - - await loop.queueIncomingToken(createMockToken("1"), "event-1", "sender-1"); - const firstBatchId = loop.getBatchStatus().batchId; - - expect(firstBatchId).toBeTruthy(); - expect(typeof firstBatchId).toBe("string"); - - loop.destroy(); - }); - - it("should track event to token mapping", async () => { - const { ReceiveTokensToInventoryLoop } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const loop = new ReceiveTokensToInventoryLoop(mockIdentityManager); - - await loop.queueIncomingToken(createMockToken("token-1"), "event-1", "sender-1"); - - const status = loop.getBatchStatus(); - expect(status.pending).toBe(1); - - loop.destroy(); - }); - }); - - describe("Event Processed Callback", () => { - it("should accept and store callback", async () => { - const { ReceiveTokensToInventoryLoop } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const loop = new ReceiveTokensToInventoryLoop(mockIdentityManager); - const callback = vi.fn(); - - loop.setEventProcessedCallback(callback); - - // Callback should be stored (no immediate call) - expect(callback).not.toHaveBeenCalled(); - - loop.destroy(); - }); - }); - - describe("Cleanup", () => { - it("should clear buffer on destroy", async () => { - const { ReceiveTokensToInventoryLoop } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const loop = new ReceiveTokensToInventoryLoop(mockIdentityManager); - - await loop.queueIncomingToken(createMockToken("1"), "event-1", "sender-1"); - expect(loop.getBatchStatus().pending).toBe(1); - - loop.destroy(); - expect(loop.getBatchStatus().pending).toBe(0); - }); - }); -}); - -// ========================================== -// NostrDeliveryQueue Tests -// ========================================== - -describe("NostrDeliveryQueue", () => { - let mockIdentityManager: IdentityManager; - - beforeEach(() => { - vi.useFakeTimers(); - mockIdentityManager = createMockIdentityManager(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - describe("Queue Management", () => { - it("should add entry to queue", async () => { - const { NostrDeliveryQueue } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const queue = new NostrDeliveryQueue(mockIdentityManager); - const entry = createMockDeliveryEntry("1"); - - await queue.queueForDelivery(entry); - - const status = queue.getQueueStatus(); - expect(status.totalPending).toBe(1); - - queue.destroy(); - }); - - it("should track multiple entries", async () => { - const { NostrDeliveryQueue } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const queue = new NostrDeliveryQueue(mockIdentityManager); - - await queue.queueForDelivery(createMockDeliveryEntry("1")); - await queue.queueForDelivery(createMockDeliveryEntry("2")); - await queue.queueForDelivery(createMockDeliveryEntry("3")); - - const status = queue.getQueueStatus(); - expect(status.totalPending).toBe(3); - - queue.destroy(); - }); - - it("should report correct retry count distribution", async () => { - const { NostrDeliveryQueue } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const queue = new NostrDeliveryQueue(mockIdentityManager); - - const entry1 = createMockDeliveryEntry("1"); - entry1.retryCount = 0; - const entry2 = createMockDeliveryEntry("2"); - entry2.retryCount = 2; - const entry3 = createMockDeliveryEntry("3"); - entry3.retryCount = 2; - - await queue.queueForDelivery(entry1); - await queue.queueForDelivery(entry2); - await queue.queueForDelivery(entry3); - - const status = queue.getQueueStatus(); - expect(status.byRetryCount[0]).toBe(1); - expect(status.byRetryCount[2]).toBe(2); - - queue.destroy(); - }); - }); - - describe("NostrService Integration", () => { - it("should accept NostrService reference", async () => { - const { NostrDeliveryQueue } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const queue = new NostrDeliveryQueue(mockIdentityManager); - const mockNostrService = { - sendTokenToRecipient: vi.fn().mockResolvedValue("event-id"), - }; - - // Should not throw - // eslint-disable-next-line @typescript-eslint/no-explicit-any - queue.setNostrService(mockNostrService as any); - - queue.destroy(); - }); - }); - - describe("Cleanup", () => { - it("should clear queue on destroy", async () => { - const { NostrDeliveryQueue } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const queue = new NostrDeliveryQueue(mockIdentityManager); - - await queue.queueForDelivery(createMockDeliveryEntry("1")); - expect(queue.getQueueStatus().totalPending).toBe(1); - - queue.destroy(); - expect(queue.getQueueStatus().totalPending).toBe(0); - }); - }); -}); - -// ========================================== -// InventoryBackgroundLoopsManager Tests -// ========================================== - -describe("InventoryBackgroundLoopsManager", () => { - let mockIdentityManager: IdentityManager; - - beforeEach(() => { - mockIdentityManager = createMockIdentityManager(); - // Reset singleton between tests - }); - - afterEach(async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - InventoryBackgroundLoopsManager.resetInstance(); - vi.clearAllMocks(); - }); - - describe("Singleton Pattern", () => { - it("should create singleton instance", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const instance1 = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - const instance2 = InventoryBackgroundLoopsManager.getInstance(); - - expect(instance1).toBe(instance2); - }); - - it("should throw if no IdentityManager on first call", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - expect(() => InventoryBackgroundLoopsManager.getInstance()).toThrow( - "IdentityManager required for first getInstance() call" - ); - }); - - it("should reset instance correctly", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - // Create first instance (needed to test reset behavior) - InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - InventoryBackgroundLoopsManager.resetInstance(); - - // Should throw because instance was reset - expect(() => InventoryBackgroundLoopsManager.getInstance()).toThrow(); - }); - }); - - describe("Initialization", () => { - it("should not be ready before initialize()", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - expect(manager.isReady()).toBe(false); - }); - - it("should be ready after initialize()", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - await manager.initialize(); - - expect(manager.isReady()).toBe(true); - }); - - it("should handle multiple initialize() calls gracefully", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - await manager.initialize(); - await manager.initialize(); // Should not throw - - expect(manager.isReady()).toBe(true); - }); - }); - - describe("Loop Access", () => { - it("should throw when accessing receive loop before initialize", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - - expect(() => manager.getReceiveLoop()).toThrow( - "ReceiveLoop not initialized - call initialize() first" - ); - }); - - it("should throw when accessing delivery queue before initialize", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - - expect(() => manager.getDeliveryQueue()).toThrow( - "DeliveryQueue not initialized - call initialize() first" - ); - }); - - it("should return loops after initialize", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - await manager.initialize(); - - expect(manager.getReceiveLoop()).toBeDefined(); - expect(manager.getDeliveryQueue()).toBeDefined(); - }); - }); - - describe("Status Reporting", () => { - it("should report combined status", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - await manager.initialize(); - - const status = manager.getStatus(); - - expect(status).toHaveProperty("receive"); - expect(status).toHaveProperty("delivery"); - expect(status).toHaveProperty("isInitialized"); - expect(status.isInitialized).toBe(true); - }); - - it("should report default status before initialize", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - - const status = manager.getStatus(); - - expect(status.isInitialized).toBe(false); - expect(status.receive.pending).toBe(0); - expect(status.delivery.totalPending).toBe(0); - }); - }); - - describe("Shutdown", () => { - it("should not be ready after shutdown", async () => { - const { InventoryBackgroundLoopsManager } = await import( - "../../../../../../src/components/wallet/L3/services/InventoryBackgroundLoops" - ); - - const manager = InventoryBackgroundLoopsManager.getInstance(mockIdentityManager); - await manager.initialize(); - manager.shutdown(); - - expect(manager.isReady()).toBe(false); - }); - }); -}); diff --git a/tests/unit/components/wallet/L3/services/InventorySyncService.test.ts b/tests/unit/components/wallet/L3/services/InventorySyncService.test.ts deleted file mode 100644 index b3697185..00000000 --- a/tests/unit/components/wallet/L3/services/InventorySyncService.test.ts +++ /dev/null @@ -1,2821 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import type { Token } from "../../../../../../src/components/wallet/L3/data/model"; -import type { OutboxEntry } from "../../../../../../src/components/wallet/L3/services/types/OutboxTypes"; -import type { TxfToken, TxfStorageData, SentTokenEntry, InvalidTokenEntry } from "../../../../../../src/components/wallet/L3/services/types/TxfTypes"; - -// ========================================== -// Configurable Mock Setup -// ========================================== - -// These variables allow per-test configuration of mock behavior -let mockValidationResult: { valid: boolean; issues: Array<{ tokenId: string; reason: string }> } = { valid: true, issues: [] }; -let mockSpentTokens: Array<{ tokenId: string; stateHash: string; localId: string }> = []; -let mockIpfsAvailable = false; -let mockRemoteData: TxfStorageData | null = null; - -// Spy functions for verifying calls -const mockCheckSpentTokensSpy = vi.fn(); -const mockValidateAllTokensSpy = vi.fn(); - -// Spy functions for Step 8.5 Nostr binding verification -const mockQueryPubkeyByNametagSpy = vi.fn(); -const mockPublishNametagBindingSpy = vi.fn(); - -// Spy function for IPFS upload verification -const mockIpfsUploadSpy = vi.fn(); - -// Mock IpfsHttpResolver with configurable response -vi.mock("../../../../../../src/components/wallet/L3/services/IpfsHttpResolver", () => ({ - getIpfsHttpResolver: vi.fn(() => ({ - resolveIpnsName: vi.fn().mockImplementation(async () => { - if (!mockIpfsAvailable) { - return { success: false, error: "IPFS disabled in test" }; - } - if (!mockRemoteData) { - return { success: false, error: "No remote data" }; - } - return { - success: true, - cid: "QmTestCid", - content: mockRemoteData, - }; - }), - })), - computeCidFromContent: vi.fn().mockResolvedValue("QmTestCid123"), -})); - -// Mock TokenValidationService with configurable per-test results -vi.mock("../../../../../../src/components/wallet/L3/services/TokenValidationService", () => ({ - getTokenValidationService: vi.fn(() => ({ - validateAllTokens: vi.fn().mockImplementation(async (tokens: Token[]) => { - mockValidateAllTokensSpy(tokens); - // Return the configurable mock result - return { - valid: mockValidationResult.valid, - validTokens: mockValidationResult.valid ? tokens : tokens.filter(t => - !mockValidationResult.issues.some(i => t.id === i.tokenId || t.id.includes(i.tokenId)) - ), - issues: mockValidationResult.issues.map(i => ({ - tokenId: i.tokenId, - reason: i.reason, - })), - }; - }), - checkSpentTokens: vi.fn().mockImplementation(async () => { - mockCheckSpentTokensSpy(); - return { - spentTokens: mockSpentTokens, - errors: [], - }; - }), - // Mock for Step 7.5: isTokenStateSpent - // Returns true if tokenId+stateHash is in mockSpentTokens - isTokenStateSpent: vi.fn().mockImplementation(async (tokenId: string, stateHash: string) => { - return mockSpentTokens.some(s => s.tokenId === tokenId && s.stateHash === stateHash); - }), - })), -})); - -// Mock NostrService with spy functions -vi.mock("../../../../../../src/components/wallet/L3/services/NostrService", () => ({ - NostrService: { - getInstance: vi.fn(() => ({ - queryPubkeyByNametag: vi.fn().mockImplementation(async (nametag: string) => { - mockQueryPubkeyByNametagSpy(nametag); - return null; // Default: no existing binding - }), - publishNametagBinding: vi.fn().mockImplementation(async (nametag: string, address: string) => { - mockPublishNametagBindingSpy(nametag, address); - return true; // Default: success - }), - })), - }, -})); - -// Mock IdentityManager -vi.mock("../../../../../../src/components/wallet/L3/services/IdentityManager", () => ({ - IdentityManager: { - getInstance: vi.fn(() => ({ - getCurrentIdentity: vi.fn().mockResolvedValue({ - address: "test-address", - publicKey: "0".repeat(64), - ipnsName: "test-ipns-name", - }), - })), - }, -})); - -// Storage for mock WalletRepository state -let mockWalletRepoTokens: Token[] = []; -let mockWalletRepoNametag: NametagData | null = null; -let mockWalletRepoTombstones: TombstoneEntry[] = []; -let mockWalletRepoAddress: string = ""; - -// Import Token and TombstoneEntry types for the mock -import type { Token as MockToken } from "../../../../../../src/components/wallet/L3/data/model"; -import type { TombstoneEntry } from "../../../../../../src/components/wallet/L3/services/types/TxfTypes"; -import type { NametagData } from "../../../../../../src/repositories/WalletRepository"; - -// Helper to convert TxfToken to Token (simplified for tests) -const txfToMockToken = (tokenId: string, txf: TxfToken): MockToken => ({ - id: tokenId, - name: "Test Token", - type: "UCT", - timestamp: Date.now(), - jsonData: JSON.stringify(txf), - status: 0, - amount: txf.genesis?.data?.coinData?.[0]?.[1] || "0", - coinId: txf.genesis?.data?.coinId || "ALPHA", - symbol: txf.genesis?.data?.coinId || "ALPHA", - sizeBytes: 100, -} as MockToken); - -// Reset mock wallet state -const resetMockWalletRepo = () => { - mockWalletRepoTokens = []; - mockWalletRepoNametag = null; - mockWalletRepoTombstones = []; - mockWalletRepoAddress = ""; -}; - -// Sync mock wallet repo state FROM localStorage TxfStorageData -const syncMockWalletFromStorage = (address: string) => { - try { - const storageKey = `sphere_wallet_${address}`; - const json = localStorage.getItem(storageKey); - if (!json) return; - - const data = JSON.parse(json); - - // If it's TxfStorageData format (has _meta or _ keys) - mockWalletRepoTokens = []; - mockWalletRepoNametag = data._nametag || null; - mockWalletRepoTombstones = data._tombstones || []; - mockWalletRepoAddress = address; - - // Extract tokens from _ keys - for (const key of Object.keys(data)) { - if (key.startsWith("_") && !key.startsWith("_meta") && !key.startsWith("_nametag") && - !key.startsWith("_tombstones") && !key.startsWith("_sent") && !key.startsWith("_invalid") && - !key.startsWith("_outbox") && !key.startsWith("_archived") && !key.startsWith("_forked") && - !key.startsWith("_mintOutbox") && !key.startsWith("_invalidatedNametags")) { - const txf = data[key] as TxfToken; - if (txf && txf.genesis?.data?.tokenId) { - // Use the actual tokenId from genesis data (important for proper ID matching) - const actualTokenId = txf.genesis.data.tokenId; - mockWalletRepoTokens.push(txfToMockToken(actualTokenId, txf)); - } - } - } - } catch { - // Ignore parse errors - } -}; - -// Mock WalletRepository -vi.mock("../../../../../../src/repositories/WalletRepository", () => ({ - WalletRepository: { - // Static methods for sync lock (Phase 0 of WalletRepository elimination) - setSyncInProgress: vi.fn(), - isSyncInProgress: vi.fn(() => false), - getPendingTokens: vi.fn(() => []), - getInstance: vi.fn(() => ({ - getWallet: vi.fn(() => { - // Return wallet if address is set (either from loadWalletForAddress or directly) - if (!mockWalletRepoAddress) return null; - return { - id: "test-wallet-id", - name: "Test Wallet", - address: mockWalletRepoAddress, - tokens: mockWalletRepoTokens, - nametag: mockWalletRepoNametag, - tombstones: mockWalletRepoTombstones, - }; - }), - loadWalletForAddress: vi.fn((address: string) => { - // Only sync from localStorage if address changes or wallet hasn't been loaded yet. - // Once loaded, mockWalletRepoTokens becomes the authoritative store. - if (mockWalletRepoAddress !== address) { - syncMockWalletFromStorage(address); - } - // If still no address set after sync attempt, set it now (new wallet case) - if (!mockWalletRepoAddress) { - mockWalletRepoAddress = address; - } - return { - id: "test-wallet-id", - name: "Test Wallet", - address: mockWalletRepoAddress, - tokens: mockWalletRepoTokens, - }; - }), - getTokens: vi.fn(() => mockWalletRepoTokens), - getNametag: vi.fn(() => mockWalletRepoNametag), - getTombstones: vi.fn(() => mockWalletRepoTombstones), - setNametag: vi.fn((nametag: NametagData) => { - mockWalletRepoNametag = nametag; - }), - addToken: vi.fn((token: MockToken) => { - // Check for duplicates - const existingIndex = mockWalletRepoTokens.findIndex(t => { - try { - const existing = JSON.parse(t.jsonData || "{}"); - const incoming = JSON.parse(token.jsonData || "{}"); - return existing.genesis?.data?.tokenId === incoming.genesis?.data?.tokenId; - } catch { return false; } - }); - if (existingIndex === -1) { - mockWalletRepoTokens.push(token); - } - }), - updateToken: vi.fn((token: MockToken) => { - const index = mockWalletRepoTokens.findIndex(t => { - try { - const existing = JSON.parse(t.jsonData || "{}"); - const incoming = JSON.parse(token.jsonData || "{}"); - return existing.genesis?.data?.tokenId === incoming.genesis?.data?.tokenId; - } catch { return false; } - }); - if (index >= 0) { - mockWalletRepoTokens[index] = token; - } - }), - removeToken: vi.fn((tokenId: string) => { - mockWalletRepoTokens = mockWalletRepoTokens.filter(t => t.id !== tokenId); - }), - mergeTombstones: vi.fn((tombstones: TombstoneEntry[]) => { - for (const t of tombstones) { - if (!mockWalletRepoTombstones.some(existing => - existing.tokenId === t.tokenId && existing.stateHash === t.stateHash - )) { - mockWalletRepoTombstones.push(t); - } - } - return 0; // Return removed count (simplified) - }), - // Methods used by attemptTokenRecovery in Step 7.5 - getArchivedToken: vi.fn(() => null), // No archived tokens in tests - getForkedToken: vi.fn(() => null), // No forked tokens in tests - })), - }, -})); - -// Mock IPFS config -vi.mock("../../../../../../src/config/ipfs.config", () => ({ - getAllBackendGatewayUrls: vi.fn(() => ["https://test-gateway.example.com"]), -})); - -// Mock IpfsStorageService - getIpfsTransport throws to force fallback to HTTP resolver -// This ensures the existing IpfsHttpResolver mock is used for tests -vi.mock("../../../../../../src/components/wallet/L3/services/IpfsStorageService", () => ({ - getIpfsTransport: vi.fn(() => { - throw new Error("Transport not available in test - using HTTP resolver fallback"); - }), -})); - -// Mock PredicateEngineService for Step 8.4 -vi.mock("@unicitylabs/state-transition-sdk/lib/predicate/PredicateEngineService", () => ({ - PredicateEngineService: { - createPredicate: vi.fn().mockResolvedValue({ - isOwner: vi.fn().mockResolvedValue(true), - }), - }, -})); - -// Mock ProxyAddress for Step 8.5 -vi.mock("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress", () => ({ - ProxyAddress: { - fromNameTag: vi.fn().mockResolvedValue({ - address: "proxy-address-123", - }), - }, -})); - -// Now import the module under test -import { inventorySync, type SyncParams, type CompletedTransfer } from "../../../../../../src/components/wallet/L3/services/InventorySyncService"; -import { STORAGE_KEY_GENERATORS } from "../../../../../../src/config/storageKeys"; - -// ========================================== -// Test Fixtures -// ========================================== - -const TEST_ADDRESS = "0x" + "a".repeat(40); -const TEST_PUBLIC_KEY = "0".repeat(64); -const TEST_IPNS_NAME = "k51test123"; - -const createMockToken = (id: string, amount = "1000"): Token => ({ - id, - name: "Test Token", - type: "UCT", - timestamp: Date.now(), - jsonData: JSON.stringify({ - version: "2.0", - genesis: { - data: { tokenId: id.padEnd(64, "0"), coinId: "ALPHA", coinData: [["ALPHA", amount]] }, - inclusionProof: { - authenticator: { stateHash: "0000" + "a".repeat(60) }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "c".repeat(60), - }, - }, - state: { data: "", predicate: [1, 2, 3] }, - transactions: [], - }), - status: 0, - amount, - coinId: "ALPHA", - symbol: "ALPHA", - sizeBytes: 100, -} as Token); - -// Default stateHash used for genesis-only tokens -const DEFAULT_STATE_HASH = "0000" + "a".repeat(60); - -const createMockTxfToken = (tokenId: string, amount = "1000", txCount = 0): TxfToken => { - const transactions = []; - - // Build proper state hash chain for transactions - // First tx links to genesis, subsequent txs link to previous - let prevStateHash = DEFAULT_STATE_HASH; // Genesis stateHash - - for (let i = 0; i < txCount; i++) { - const newStateHash = "0000" + (i + 1).toString().padStart(4, "0").padEnd(60, "0"); - transactions.push({ - data: { recipient: "recipient" + i }, - previousStateHash: prevStateHash, - newStateHash: newStateHash, - inclusionProof: { - authenticator: { stateHash: newStateHash }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "d".repeat(60), - }, - }); - prevStateHash = newStateHash; - } - - // Current stateHash is the last tx's newStateHash, or genesis if no txs - const currentStateHash = txCount > 0 ? prevStateHash : DEFAULT_STATE_HASH; - - return { - version: "2.0", - genesis: { - data: { - tokenId: tokenId.padEnd(64, "0"), - coinId: "ALPHA", - coinData: [["ALPHA", amount]], - }, - inclusionProof: { - authenticator: { stateHash: DEFAULT_STATE_HASH }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "c".repeat(60), - }, - }, - state: { data: "", predicate: new Uint8Array([1, 2, 3]) }, - transactions, - // Add _integrity with currentStateHash - // This is required for completedList processing to match stateHash - _integrity: { - currentStateHash: currentStateHash, - genesisDataJSONHash: "0000" + "e".repeat(60), - }, - } as TxfToken; -}; - -// Create a token with unnormalized proof (missing "0000" prefix) -const createUnnormalizedTxfToken = (tokenId: string, amount = "1000"): TxfToken => ({ - version: "2.0", - genesis: { - data: { - tokenId: tokenId.padEnd(64, "0"), - coinId: "ALPHA", - coinData: [["ALPHA", amount]], - }, - inclusionProof: { - authenticator: { stateHash: "a".repeat(64) }, // Missing 0000 prefix - merkleTreePath: { root: "b".repeat(64), path: [] }, // Missing 0000 prefix - transactionHash: "0000" + "c".repeat(60), - }, - }, - state: { data: "", predicate: new Uint8Array([1, 2, 3]) }, - transactions: [], - _meta: { amount, symbol: "ALPHA" }, -}); - -const createMockOutboxEntry = (id: string): OutboxEntry => ({ - id, - tokenId: "test-token-id".padEnd(64, "0"), - status: "PENDING_IPFS_SYNC", - createdAt: Date.now(), - updatedAt: Date.now(), - retryCount: 0, - recipientAddress: "DIRECT://test", -} as OutboxEntry); - -const createBaseSyncParams = (): SyncParams => ({ - address: TEST_ADDRESS, - publicKey: TEST_PUBLIC_KEY, - ipnsName: TEST_IPNS_NAME, -}); - -const createMockStorageData = (tokens: Record = {}): TxfStorageData => ({ - _meta: { - version: 1, - address: TEST_ADDRESS, - ipnsName: TEST_IPNS_NAME, - formatVersion: '2.0', - }, - _sent: [], - _invalid: [], - _outbox: [], - _tombstones: [], - _nametag: null, - ...Object.fromEntries( - Object.entries(tokens).map(([tokenId, token]) => [`_${tokenId}`, token]) - ), -}); - -// ========================================== -// Test Helpers -// ========================================== - -const setLocalStorage = (data: TxfStorageData) => { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - localStorage.setItem(storageKey, JSON.stringify(data)); -}; - -const clearLocalStorage = () => { - localStorage.clear(); -}; - -const getLocalStorage = (): TxfStorageData | null => { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const json = localStorage.getItem(storageKey); - - if (!json) { - return null; - } - - // InventorySyncService now writes TxfStorageData directly to localStorage, - // including tokens with _ keys. Just parse and return. - return JSON.parse(json) as TxfStorageData; -}; - -// Reset all mock configurations -const resetMocks = () => { - mockValidationResult = { valid: true, issues: [] }; - mockSpentTokens = []; - mockIpfsAvailable = false; - mockRemoteData = null; - // Reset spy call counts - mockCheckSpentTokensSpy.mockClear(); - mockValidateAllTokensSpy.mockClear(); - mockQueryPubkeyByNametagSpy.mockClear(); - mockPublishNametagBindingSpy.mockClear(); - mockIpfsUploadSpy.mockClear(); - // Reset mock WalletRepository state - resetMockWalletRepo(); -}; - -// ========================================== -// inventorySync Tests -// ========================================== - -describe("inventorySync", () => { - beforeEach(() => { - vi.clearAllMocks(); - clearLocalStorage(); - resetMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ------------------------------------------ - // Mode Detection Tests - // ------------------------------------------ - - describe("Mode Detection", () => { - it("should detect LOCAL mode when local=true", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("LOCAL"); - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS)$/); - }); - - it("should detect NAMETAG mode when nametag=true", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - nametag: true, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("NAMETAG"); - expect(result.status).toBe("NAMETAG_ONLY"); - }); - - it("should detect FAST mode when incomingTokens provided", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("token1")], - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("FAST"); - }); - - it("should detect FAST mode when outboxTokens provided", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - outboxTokens: [createMockOutboxEntry("outbox1")], - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("FAST"); - }); - - it("should detect NORMAL mode by default", async () => { - const params = createBaseSyncParams(); - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("NORMAL"); - }); - - it("should respect mode precedence: LOCAL > NAMETAG > FAST > NORMAL", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - nametag: true, - incomingTokens: [createMockToken("token1")], - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("LOCAL"); - }); - }); - - // ------------------------------------------ - // Step 3: Proof Normalization Tests - // ------------------------------------------ - - describe("Step 3: Proof Normalization", () => { - it("should normalize proofs missing 0000 prefix", async () => { - // Set up token with unnormalized proof - setLocalStorage(createMockStorageData({ - "unnorm1": createUnnormalizedTxfToken("unnorm1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - // Token should still be processed after normalization - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify localStorage was updated with normalized proof - const stored = getLocalStorage(); - expect(stored).not.toBeNull(); - // The key is prefixed with underscore - const tokenKey = Object.keys(stored || {}).find(k => k.startsWith("_unnorm1")); - expect(tokenKey).toBeDefined(); - }); - - it("should not modify already normalized proofs", async () => { - const normalizedToken = createMockTxfToken("norm1"); - setLocalStorage(createMockStorageData({ - "norm1": normalizedToken, - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - }); - }); - - // ------------------------------------------ - // Step 5: Token Validation Tests (REAL VALIDATION) - // ------------------------------------------ - - describe("Step 5: Token Validation", () => { - it("should move invalid tokens to Invalid folder when validation fails", async () => { - // The Token.id used in InventorySyncService matches the storage key, not the padded genesis tokenId - const invalidTokenId = "invalid1"; - - // Configure mock to report this token as invalid - mockValidationResult = { - valid: false, - issues: [{ tokenId: invalidTokenId, reason: "Invalid signature" }], - }; - - setLocalStorage(createMockStorageData({ - "invalid1": createMockTxfToken("invalid1"), - "valid1": createMockTxfToken("valid1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // One token should be invalid, one active - expect(result.inventoryStats?.invalidTokens).toBe(1); - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.operationStats.tokensRemoved).toBeGreaterThanOrEqual(1); - - // Verify localStorage has token in Invalid folder - const stored = getLocalStorage(); - expect(stored?._invalid).toBeDefined(); - expect(stored?._invalid?.length).toBe(1); - expect((stored?._invalid?.[0] as InvalidTokenEntry)?.reason).toBe("SDK_VALIDATION"); - }); - - it("should keep all tokens active when validation passes", async () => { - mockValidationResult = { valid: true, issues: [] }; - - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - "token2": createMockTxfToken("token2"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(2); - expect(result.inventoryStats?.invalidTokens).toBe(0); - }); - - it("should record validation details in invalid entry", async () => { - // The Token.id used in InventorySyncService matches the storage key, not the padded genesis tokenId - const invalidTokenId = "badtoken"; - const errorReason = "Merkle proof verification failed"; - - mockValidationResult = { - valid: false, - issues: [{ tokenId: invalidTokenId, reason: errorReason }], - }; - - setLocalStorage(createMockStorageData({ - "badtoken": createMockTxfToken("badtoken"), - })); - - const params = createBaseSyncParams(); - await inventorySync(params); - - const stored = getLocalStorage(); - expect(stored?._invalid?.length).toBe(1); - const invalidEntry = stored?._invalid?.[0] as InvalidTokenEntry; - expect(invalidEntry.reason).toBe("SDK_VALIDATION"); - expect(invalidEntry.details).toBe(errorReason); - expect(invalidEntry.invalidatedAt).toBeGreaterThan(0); - }); - }); - - // ------------------------------------------ - // Step 6: Deduplication Tests - // ------------------------------------------ - - describe("Step 6: Token Deduplication", () => { - it("should prefer remote token with more transactions", async () => { - // Local token has 0 transactions - setLocalStorage(createMockStorageData({ - "dup1": createMockTxfToken("dup1", "1000", 0), - })); - - // Remote token has 2 transactions (more advanced) - mockIpfsAvailable = true; - mockRemoteData = createMockStorageData({ - "dup1": createMockTxfToken("dup1", "1000", 2), - }); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should have 1 token (deduplicated) - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify the token in storage has 2 transactions (remote version) - const stored = getLocalStorage(); - const tokenKey = Object.keys(stored || {}).find(k => k.startsWith("_dup1")); - expect(tokenKey).toBeDefined(); - const token = stored?.[tokenKey!] as TxfToken; - expect(token.transactions?.length).toBe(2); - }); - - it("should keep local token when it has more transactions", async () => { - // Local token has 3 transactions (more advanced) - setLocalStorage(createMockStorageData({ - "dup2": createMockTxfToken("dup2", "1000", 3), - })); - - // Remote token has 1 transaction - mockIpfsAvailable = true; - mockRemoteData = createMockStorageData({ - "dup2": createMockTxfToken("dup2", "1000", 1), - }); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify the token kept has 3 transactions (local version) - const stored = getLocalStorage(); - const tokenKey = Object.keys(stored || {}).find(k => k.startsWith("_dup2")); - const token = stored?.[tokenKey!] as TxfToken; - expect(token.transactions?.length).toBe(3); - }); - }); - - // ------------------------------------------ - // Step 7: Spent Token Detection Tests (REAL DETECTION) - // ------------------------------------------ - - describe("Step 7: Spent Token Detection", () => { - it("should move spent tokens to Sent folder in NORMAL mode", async () => { - const spentTokenId = "spent1".padEnd(64, "0"); - const spentStateHash = "0000" + "a".repeat(60); - - // Configure mock to report this token as spent - mockSpentTokens = [{ - tokenId: spentTokenId, - stateHash: spentStateHash, - localId: "spent1", - }]; - - setLocalStorage(createMockStorageData({ - "spent1": createMockTxfToken("spent1"), - "active1": createMockTxfToken("active1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // One token spent, one active - expect(result.inventoryStats?.sentTokens).toBe(1); - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify localStorage - const stored = getLocalStorage(); - expect(stored?._sent?.length).toBe(1); - expect((stored?._sent?.[0] as SentTokenEntry)?.spentAt).toBeGreaterThan(0); - }); - - it("should call checkSpentTokens in NORMAL mode", async () => { - mockSpentTokens = []; // No spent tokens, but we want to verify the function is called - - setLocalStorage(createMockStorageData({ - "normal1": createMockTxfToken("normal1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.syncMode).toBe("NORMAL"); - // CRITICAL: Verify checkSpentTokens WAS called in NORMAL mode - expect(mockCheckSpentTokensSpy).toHaveBeenCalled(); - }); - - it("should add tombstone when token is detected as spent", async () => { - const spentTokenId = "tomb1".padEnd(64, "0"); - const spentStateHash = "0000" + "a".repeat(60); - - mockSpentTokens = [{ - tokenId: spentTokenId, - stateHash: spentStateHash, - localId: "tomb1", - }]; - - setLocalStorage(createMockStorageData({ - "tomb1": createMockTxfToken("tomb1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.operationStats.tombstonesAdded).toBeGreaterThanOrEqual(1); - - // Verify tombstone in localStorage - const stored = getLocalStorage(); - expect(stored?._tombstones?.length).toBeGreaterThanOrEqual(1); - }); - - it("should skip spent detection in FAST mode", async () => { - // Configure mock to report spent token - mockSpentTokens = [{ - tokenId: "fast1".padEnd(64, "0"), - stateHash: "0000" + "a".repeat(60), - localId: "fast1", - }]; - - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("incoming1")], - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("FAST"); - // Even though mock would report spent, FAST mode skips Step 7 - // So sentTokens should be 0 (unless there were pre-existing sent tokens) - expect(result.inventoryStats?.sentTokens).toBe(0); - // CRITICAL: Verify checkSpentTokens was NOT called in FAST mode - expect(mockCheckSpentTokensSpy).not.toHaveBeenCalled(); - }); - - it("should skip spent detection in LOCAL mode", async () => { - mockSpentTokens = [{ - tokenId: "local1".padEnd(64, "0"), - stateHash: "0000" + "a".repeat(60), - localId: "local1", - }]; - - setLocalStorage(createMockStorageData({ - "local1": createMockTxfToken("local1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("LOCAL"); - // LOCAL mode skips Step 7, token should remain active - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.inventoryStats?.sentTokens).toBe(0); - // CRITICAL: Verify checkSpentTokens was NOT called in LOCAL mode - expect(mockCheckSpentTokensSpy).not.toHaveBeenCalled(); - }); - - it("should skip spent detection in NAMETAG mode", async () => { - mockSpentTokens = [{ - tokenId: "nametag1".padEnd(64, "0"), - stateHash: "0000" + "a".repeat(60), - localId: "nametag1", - }]; - - const params: SyncParams = { - ...createBaseSyncParams(), - nametag: true, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("NAMETAG"); - // NAMETAG mode only fetches nametags, skips Step 7 - // CRITICAL: Verify checkSpentTokens was NOT called in NAMETAG mode - expect(mockCheckSpentTokensSpy).not.toHaveBeenCalled(); - }); - }); - - // ------------------------------------------ - // IPFS Merge Tests - // ------------------------------------------ - - describe("IPFS Merge (Step 2)", () => { - it("should merge tokens from IPFS when available", async () => { - // Local has token1 - setLocalStorage(createMockStorageData({ - "local1": createMockTxfToken("local1", "1000"), - })); - - // Remote has token2 - mockIpfsAvailable = true; - mockRemoteData = createMockStorageData({ - "remote1": createMockTxfToken("remote1", "2000"), - }); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should have both tokens - expect(result.inventoryStats?.activeTokens).toBe(2); - }); - - it("should fallback to local-only when IPFS unavailable", async () => { - mockIpfsAvailable = false; - - setLocalStorage(createMockStorageData({ - "local1": createMockTxfToken("local1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - }); - - it("should merge sent folder from remote using tokenId:stateHash key", async () => { - // Local has one sent token - const localStorageData = createMockStorageData(); - localStorageData._sent = [{ - token: createMockTxfToken("sent1"), - timestamp: Date.now() - 10000, - spentAt: Date.now() - 10000, - }]; - setLocalStorage(localStorageData); - - // Remote has different sent token (different tokenId) - mockIpfsAvailable = true; - const remoteData = createMockStorageData(); - remoteData._sent = [{ - token: createMockTxfToken("sent2"), - timestamp: Date.now() - 5000, - spentAt: Date.now() - 5000, - }]; - mockRemoteData = remoteData; - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should have both sent tokens (union merge) - expect(result.inventoryStats?.sentTokens).toBe(2); - }); - }); - - // ------------------------------------------ - // Persistence Verification Tests - // ------------------------------------------ - - describe("Data Persistence", () => { - it("should persist tokens to localStorage after sync", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("persist1", "5000")], - }; - - await inventorySync(params); - - const stored = getLocalStorage(); - expect(stored).not.toBeNull(); - - // Verify token is actually in storage - const tokenKey = Object.keys(stored || {}).find(k => k.includes("persist1")); - expect(tokenKey).toBeDefined(); - - // Verify token data - amount is in genesis.data.coinData - const token = stored?.[tokenKey!] as TxfToken; - expect(token.genesis?.data?.coinData).toBeDefined(); - expect(token.genesis.data.coinData[0][1]).toBe("5000"); - }); - - it("should preserve data across multiple syncs", async () => { - // First sync adds a token - const params1: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("multi1", "1000")], - }; - await inventorySync(params1); - - // Second sync adds another token - const params2: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("multi2", "2000")], - }; - await inventorySync(params2); - - // Third sync (normal, no new tokens) - const params3 = createBaseSyncParams(); - const result = await inventorySync(params3); - - // Should have both tokens from previous syncs - expect(result.inventoryStats?.activeTokens).toBe(2); - - // Verify both tokens in localStorage - const stored = getLocalStorage(); - const keys = Object.keys(stored || {}).filter(k => k.startsWith("_") && !k.startsWith("_meta") && !k.startsWith("_sent") && !k.startsWith("_invalid") && !k.startsWith("_outbox") && !k.startsWith("_tombstones") && !k.startsWith("_nametag")); - expect(keys.length).toBe(2); - }); - - it("should NOT increment version when content unchanged", async () => { - setLocalStorage(createMockStorageData()); - - const params = createBaseSyncParams(); - - await inventorySync(params); - const v1 = getLocalStorage()?._meta?.version || 0; - - await inventorySync(params); - const v2 = getLocalStorage()?._meta?.version || 0; - - await inventorySync(params); - const v3 = getLocalStorage()?._meta?.version || 0; - - // Version should stay the same when content hasn't changed - // This prevents unnecessary IPFS uploads on reload - expect(v2).toBe(v1); - expect(v3).toBe(v1); - }); - - it("should increment version when content changes", async () => { - setLocalStorage(createMockStorageData()); - - const params = createBaseSyncParams(); - - await inventorySync(params); - const v1 = getLocalStorage()?._meta?.version || 0; - - // Add a new token - this changes content - await inventorySync({ - ...params, - incomingTokens: [createMockToken("newtoken1")], - }); - const v2 = getLocalStorage()?._meta?.version || 0; - - // Add another token - this changes content again - await inventorySync({ - ...params, - incomingTokens: [createMockToken("newtoken2")], - }); - const v3 = getLocalStorage()?._meta?.version || 0; - - // Version should increment when content changes - expect(v2).toBeGreaterThan(v1); - expect(v3).toBeGreaterThan(v2); - }); - - it("should preserve sent folder across syncs", async () => { - // First sync creates a sent token - mockSpentTokens = [{ - tokenId: "spent1".padEnd(64, "0"), - stateHash: "0000" + "a".repeat(60), - localId: "spent1", - }]; - - setLocalStorage(createMockStorageData({ - "spent1": createMockTxfToken("spent1"), - })); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // Clear spent mock for second sync - mockSpentTokens = []; - - // Second sync should still show sent token - const result = await inventorySync(params); - expect(result.inventoryStats?.sentTokens).toBe(1); - - // Verify sent folder persisted - const stored = getLocalStorage(); - expect(stored?._sent?.length).toBe(1); - }); - }); - - // ------------------------------------------ - // Error Handling Tests (ACTUAL VERIFICATION) - // ------------------------------------------ - - describe("Error Handling", () => { - it("should handle malformed JSON in localStorage gracefully", async () => { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - localStorage.setItem(storageKey, "invalid json{{{"); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should not throw, should return a valid result - expect(result).toBeDefined(); - expect(result.status).toBeDefined(); - // Empty inventory after failed parse - expect(result.inventoryStats?.activeTokens).toBe(0); - }); - - it("should continue sync when validation service throws", async () => { - // This would test error handling in Step 5 - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should complete (validation errors are non-fatal) - expect(result.status).not.toBe("ERROR"); - }); - }); - - // ------------------------------------------ - // SyncResult Structure Tests (STRICT ASSERTIONS) - // ------------------------------------------ - - describe("SyncResult Structure", () => { - it("should include all required fields with correct types", async () => { - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Status must be a valid enum value - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS|ERROR|LOCAL_ONLY|NAMETAG_ONLY)$/); - - // SyncMode must be valid - expect(result.syncMode).toMatch(/^(LOCAL|NAMETAG|FAST|NORMAL)$/); - - // Duration must be a non-negative number - expect(typeof result.syncDurationMs).toBe("number"); - expect(result.syncDurationMs).toBeGreaterThanOrEqual(0); - - // Timestamp must be a recent time - expect(typeof result.timestamp).toBe("number"); - expect(result.timestamp).toBeGreaterThan(Date.now() - 10000); - expect(result.timestamp).toBeLessThanOrEqual(Date.now() + 1000); - }); - - it("should include operationStats with numeric counters", async () => { - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(typeof result.operationStats.tokensImported).toBe("number"); - expect(typeof result.operationStats.tokensRemoved).toBe("number"); - expect(typeof result.operationStats.tokensUpdated).toBe("number"); - expect(typeof result.operationStats.conflictsResolved).toBe("number"); - expect(typeof result.operationStats.tokensValidated).toBe("number"); - expect(typeof result.operationStats.tombstonesAdded).toBe("number"); - - // All counters should be non-negative - expect(result.operationStats.tokensImported).toBeGreaterThanOrEqual(0); - expect(result.operationStats.tokensRemoved).toBeGreaterThanOrEqual(0); - }); - - it("should include inventoryStats with folder counts (except NAMETAG mode)", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats).toBeDefined(); - expect(typeof result.inventoryStats!.activeTokens).toBe("number"); - expect(typeof result.inventoryStats!.sentTokens).toBe("number"); - expect(typeof result.inventoryStats!.invalidTokens).toBe("number"); - expect(typeof result.inventoryStats!.outboxTokens).toBe("number"); - }); - - it("should NOT include inventoryStats in NAMETAG mode", async () => { - const params: SyncParams = { - ...createBaseSyncParams(), - nametag: true, - }; - - const result = await inventorySync(params); - - expect(result.inventoryStats).toBeUndefined(); - expect(result.nametags).toBeDefined(); - expect(Array.isArray(result.nametags)).toBe(true); - }); - }); - - // ------------------------------------------ - // CompletedList Processing Tests - // ------------------------------------------ - - describe("CompletedList Processing", () => { - it("should move completed tokens to Sent folder", async () => { - // Use storage key format (unpadded) since ctx.tokens uses storage keys - const tokenId = "completed1"; - // Use DEFAULT_STATE_HASH to match the mock token's _integrity.currentStateHash - const stateHash = DEFAULT_STATE_HASH; - - const completedList: CompletedTransfer[] = [{ - tokenId, - stateHash, - inclusionProof: {}, - }]; - - setLocalStorage(createMockStorageData({ - "completed1": createMockTxfToken("completed1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - completedList, - }; - - const result = await inventorySync(params); - - // Token should be in sent, not active - expect(result.inventoryStats?.sentTokens).toBe(1); - expect(result.inventoryStats?.activeTokens).toBe(0); - }); - - it("should add tombstone for completed transfer", async () => { - // Use storage key format (unpadded) since ctx.tokens uses storage keys - const tokenId = "completed2"; - // Use DEFAULT_STATE_HASH to match the mock token's _integrity.currentStateHash - const stateHash = DEFAULT_STATE_HASH; - - const completedList: CompletedTransfer[] = [{ - tokenId, - stateHash, - inclusionProof: {}, - }]; - - setLocalStorage(createMockStorageData({ - "completed2": createMockTxfToken("completed2"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - completedList, - }; - - const result = await inventorySync(params); - - expect(result.operationStats.tombstonesAdded).toBeGreaterThanOrEqual(1); - }); - }); - - // ------------------------------------------ - // Edge Cases - // ------------------------------------------ - - describe("Edge Cases", () => { - it("should handle empty inventory gracefully", async () => { - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS)$/); - expect(result.inventoryStats?.activeTokens).toBe(0); - }); - - it("should handle large token collections", async () => { - const tokens: Record = {}; - for (let i = 0; i < 100; i++) { - const hexId = i.toString(16).padStart(8, "0"); - tokens[hexId] = createMockTxfToken(hexId); - } - setLocalStorage(createMockStorageData(tokens)); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(100); - }); - - it("should handle null/undefined optional params", async () => { - const params: SyncParams = { - address: TEST_ADDRESS, - publicKey: TEST_PUBLIC_KEY, - ipnsName: TEST_IPNS_NAME, - incomingTokens: null, - outboxTokens: undefined, - completedList: undefined, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("NORMAL"); - expect(result.status).not.toBe("ERROR"); - }); - - it("should not create duplicate tokens when same token received twice", async () => { - // First sync with token - const params1: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("dup1", "1000")], - }; - await inventorySync(params1); - - // Second sync with same token - const params2: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("dup1", "1000")], - }; - const result = await inventorySync(params2); - - // Should still have only 1 token (deduplicated) - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify only one token key in localStorage - const stored = getLocalStorage(); - const tokenKeys = Object.keys(stored || {}).filter(k => - k.startsWith("_") && - !k.startsWith("_meta") && - !k.startsWith("_sent") && - !k.startsWith("_invalid") && - !k.startsWith("_outbox") && - !k.startsWith("_tombstones") && - !k.startsWith("_nametag") - ); - expect(tokenKeys.length).toBe(1); - }); - }); - - // ------------------------------------------ - // Step 4: State Hash Chain Validation Tests - // ------------------------------------------ - - describe("Step 4: State Hash Chain Validation", () => { - // Helper to create token with broken chain - const createBrokenChainToken = (tokenId: string, breakType: "wrong_previous" | "missing_previous"): TxfToken => { - const genesisStateHash = DEFAULT_STATE_HASH; - const wrongPreviousHash = "0000" + "f".repeat(60); // Doesn't match genesis - - const token: TxfToken = { - version: "2.0", - genesis: { - data: { - tokenId: tokenId.padEnd(64, "0"), - coinId: "ALPHA", - coinData: [["ALPHA", "1000"]], - }, - inclusionProof: { - authenticator: { stateHash: genesisStateHash }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "c".repeat(60), - }, - }, - state: { data: "", predicate: new Uint8Array([1, 2, 3]) }, - transactions: [{ - data: { recipient: "recipient0" }, - // Break the chain based on breakType - ...(breakType === "wrong_previous" ? { previousStateHash: wrongPreviousHash } : {}), - // missing_previous: no previousStateHash at all - newStateHash: "0000" + "1".repeat(60), - inclusionProof: { - authenticator: { stateHash: "0000" + "1".repeat(60) }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - transactionHash: "0000" + "d".repeat(60), - }, - }], - _integrity: { - currentStateHash: "0000" + "1".repeat(60), - genesisDataJSONHash: "0000" + "e".repeat(60), - }, - } as TxfToken; - - return token; - }; - - it("should reject token with wrong previousStateHash (chain break)", async () => { - setLocalStorage(createMockStorageData({ - "broken1": createBrokenChainToken("broken1", "wrong_previous"), - "valid1": createMockTxfToken("valid1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - // Broken token should be moved to Invalid, valid token stays active - expect(result.inventoryStats?.invalidTokens).toBe(1); - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.operationStats.tokensRemoved).toBeGreaterThanOrEqual(1); - - // Verify invalid entry has correct reason - const stored = getLocalStorage(); - expect(stored?._invalid?.length).toBe(1); - const invalidEntry = stored?._invalid?.[0] as InvalidTokenEntry; - expect(invalidEntry.reason).toBe("PROOF_MISMATCH"); - expect(invalidEntry.details).toContain("Chain break"); - }); - - it("should ALLOW token with missing previousStateHash on first transaction", async () => { - // Missing previousStateHash on the first transaction is allowed because: - // 1. We know it should be the genesis stateHash - // 2. Full SDK validation in Step 5 will verify the cryptographic proof - // 3. This matches faucet token behavior where the SDK doesn't populate this field - setLocalStorage(createMockStorageData({ - "missing_prev": createBrokenChainToken("missing_prev", "missing_previous"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - // Token should remain active (missing previousStateHash on first tx is OK) - expect(result.inventoryStats?.invalidTokens).toBe(0); - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify no invalid entries - const stored = getLocalStorage(); - expect(stored?._invalid?.length ?? 0).toBe(0); - }); - }); - - // ------------------------------------------ - // CompletedList Edge Cases - // ------------------------------------------ - - describe("CompletedList Edge Cases", () => { - it("should NOT move token to Sent when stateHash doesn't match", async () => { - const tokenId = "mismatch1".padEnd(64, "0"); - const wrongStateHash = "0000" + "f".repeat(60); // Doesn't match token's stateHash - - const completedList: CompletedTransfer[] = [{ - tokenId, - stateHash: wrongStateHash, // Wrong hash! - inclusionProof: {}, - }]; - - setLocalStorage(createMockStorageData({ - "mismatch1": createMockTxfToken("mismatch1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - completedList, - }; - - const result = await inventorySync(params); - - // Token should remain active, not moved to sent - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.inventoryStats?.sentTokens).toBe(0); - expect(result.operationStats.tombstonesAdded).toBe(0); - }); - - it("should handle completedList for token not in inventory", async () => { - const completedList: CompletedTransfer[] = [{ - tokenId: "nonexistent".padEnd(64, "0"), - stateHash: DEFAULT_STATE_HASH, - inclusionProof: {}, - }]; - - setLocalStorage(createMockStorageData({ - "existing1": createMockTxfToken("existing1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - completedList, - }; - - const result = await inventorySync(params); - - // Existing token should remain, no errors - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.inventoryStats?.sentTokens).toBe(0); - }); - }); - - // ------------------------------------------ - // Invalid Folder Merge Tests - // ------------------------------------------ - - describe("Invalid Folder Merge (Step 2)", () => { - it("should merge invalid folder from remote using tokenId:stateHash key", async () => { - // Local has one invalid token - const localStorageData = createMockStorageData(); - localStorageData._invalid = [{ - token: createMockTxfToken("invalid1"), - timestamp: Date.now() - 10000, - invalidatedAt: Date.now() - 10000, - reason: "SDK_VALIDATION" as const, - details: "Local validation error", - }]; - setLocalStorage(localStorageData); - - // Remote has different invalid token - mockIpfsAvailable = true; - const remoteData = createMockStorageData(); - remoteData._invalid = [{ - token: createMockTxfToken("invalid2"), - timestamp: Date.now() - 5000, - invalidatedAt: Date.now() - 5000, - reason: "PROOF_MISMATCH" as const, - details: "Remote validation error", - }]; - mockRemoteData = remoteData; - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should have both invalid tokens (union merge) - expect(result.inventoryStats?.invalidTokens).toBe(2); - - // Verify both are in localStorage - const stored = getLocalStorage(); - expect(stored?._invalid?.length).toBe(2); - }); - - it("should not duplicate invalid entries with same tokenId:stateHash", async () => { - const sameToken = createMockTxfToken("dup_invalid"); - - // Local has invalid token - const localStorageData = createMockStorageData(); - localStorageData._invalid = [{ - token: sameToken, - timestamp: Date.now() - 10000, - invalidatedAt: Date.now() - 10000, - reason: "SDK_VALIDATION" as const, - details: "Original error", - }]; - setLocalStorage(localStorageData); - - // Remote has same invalid token (same tokenId and stateHash) - mockIpfsAvailable = true; - const remoteData = createMockStorageData(); - remoteData._invalid = [{ - token: sameToken, // Same token! - timestamp: Date.now() - 5000, - invalidatedAt: Date.now() - 5000, - reason: "SDK_VALIDATION" as const, - details: "Duplicate error", - }]; - mockRemoteData = remoteData; - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should have only 1 invalid token (deduplicated by tokenId:stateHash) - expect(result.inventoryStats?.invalidTokens).toBe(1); - }); - }); - - // ------------------------------------------ - // Boomerang Token Detection Tests (Step 8.2) - // ------------------------------------------ - - describe("Boomerang Token Detection (Step 8.2)", () => { - it("should detect and remove outbox entry when token returns at different state", async () => { - // Use storage key format (unpadded) since ctx.tokens uses storage keys - const tokenId = "boomerang1"; - const originalStateHash = DEFAULT_STATE_HASH; - - // Create token that "returned" with different state (has 1 transaction, so state changed) - const returnedToken = createMockTxfToken("boomerang1", "1000", 1); - - // Local storage with outbox entry pointing to this token's original state - // The boomerang detection reads previousStateHash from commitmentJson - const localStorageData = createMockStorageData({ - "boomerang1": returnedToken, - }); - localStorageData._outbox = [{ - id: "outbox-1", - sourceTokenId: tokenId, - tokenId: tokenId, - status: "PENDING_NOSTR" as const, - createdAt: Date.now() - 60000, - updatedAt: Date.now() - 60000, - retryCount: 0, - recipientAddress: "DIRECT://recipient", - // Boomerang detection reads from commitmentJson.transactionData.previousStateHash - commitmentJson: JSON.stringify({ - transactionData: { - previousStateHash: originalStateHash, // Original state when we sent it - }, - }), - }] as OutboxEntry[]; - setLocalStorage(localStorageData); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Token should still be active (it's ours now) - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Outbox entry should be removed (boomerang detected) - // Check inventoryStats which reports actual outbox count - expect(result.inventoryStats?.outboxTokens).toBe(0); - - // Also verify localStorage - _outbox may be undefined or empty array - const stored = getLocalStorage(); - expect(stored?._outbox?.length ?? 0).toBe(0); - }); - - it("should keep outbox entry when token state matches (send still pending)", async () => { - // Use storage key format (unpadded) since ctx.tokens uses storage keys - const tokenId = "pending1"; - - // Token still at original state (send didn't happen yet) - const localStorageData = createMockStorageData({ - "pending1": createMockTxfToken("pending1"), - }); - localStorageData._outbox = [{ - id: "outbox-2", - sourceTokenId: tokenId, - tokenId: tokenId, - status: "PENDING_NOSTR" as const, - createdAt: Date.now() - 60000, - updatedAt: Date.now() - 60000, - retryCount: 0, - recipientAddress: "DIRECT://recipient", - // commitmentJson with same previousStateHash as current state - commitmentJson: JSON.stringify({ - transactionData: { - previousStateHash: DEFAULT_STATE_HASH, // Same as current state - }, - }), - }] as OutboxEntry[]; - setLocalStorage(localStorageData); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Token still active - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Outbox entry should remain (not a boomerang - send pending) - // Check via inventoryStats - expect(result.inventoryStats?.outboxTokens).toBe(1); - - // Also verify localStorage - const stored = getLocalStorage(); - expect(stored?._outbox?.length ?? 0).toBe(1); - }); - }); - - // ------------------------------------------ - // Strengthened Deduplication Tests - // ------------------------------------------ - - describe("Deduplication Verification", () => { - it("should keep remote token with correct genesis hash after deduplication", async () => { - const localToken = createMockTxfToken("dedup1", "1000", 0); - const remoteToken = createMockTxfToken("dedup1", "1000", 2); - - // Mark remote token with distinguishing feature - const remoteGenesisHash = "0000" + "remote".padEnd(58, "0"); - remoteToken._integrity = { - currentStateHash: remoteToken._integrity?.currentStateHash || DEFAULT_STATE_HASH, - genesisDataJSONHash: remoteGenesisHash, // Unique marker - }; - - setLocalStorage(createMockStorageData({ "dedup1": localToken })); - - mockIpfsAvailable = true; - mockRemoteData = createMockStorageData({ "dedup1": remoteToken }); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify the REMOTE token was kept (has 2 transactions and unique genesisDataJSONHash) - const stored = getLocalStorage(); - const tokenKey = Object.keys(stored || {}).find(k => k.includes("dedup1")); - const keptToken = stored?.[tokenKey!] as TxfToken; - - expect(keptToken.transactions?.length).toBe(2); - expect(keptToken._integrity?.genesisDataJSONHash).toBe(remoteGenesisHash); - }); - - it("should keep local token with correct data when it has more transactions", async () => { - const localToken = createMockTxfToken("dedup2", "1000", 3); - const remoteToken = createMockTxfToken("dedup2", "1000", 1); - - // Mark local token with distinguishing feature - const localGenesisHash = "0000" + "local0".padEnd(58, "0"); - localToken._integrity = { - currentStateHash: localToken._integrity?.currentStateHash || DEFAULT_STATE_HASH, - genesisDataJSONHash: localGenesisHash, // Unique marker - }; - - setLocalStorage(createMockStorageData({ "dedup2": localToken })); - - mockIpfsAvailable = true; - mockRemoteData = createMockStorageData({ "dedup2": remoteToken }); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - - // Verify the LOCAL token was kept (has 3 transactions and unique genesisDataJSONHash) - const stored = getLocalStorage(); - const tokenKey = Object.keys(stored || {}).find(k => k.includes("dedup2")); - const keptToken = stored?.[tokenKey!] as TxfToken; - - expect(keptToken.transactions?.length).toBe(3); - expect(keptToken._integrity?.genesisDataJSONHash).toBe(localGenesisHash); - }); - }); - - // ------------------------------------------ - // Step 8.5: Nametag-Nostr Binding Tests (CRITICAL) - // ------------------------------------------ - - describe("Step 8.5: Nametag-Nostr Binding", () => { - // Nametags are loaded from _nametag field in storage data - const createStorageDataWithNametag = (name: string) => { - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = createMockStorageData({ - "token1": createMockTxfToken("token1"), - }); - // Add _nametag field (this is how nametags are stored) - (data as TxfStorageData & { _nametag?: unknown })._nametag = { - name, - token: createMockTxfToken("nametag1"), - timestamp: Date.now(), - format: "1.0", - version: "1.0", - }; - localStorage.setItem(storageKey, JSON.stringify(data)); - }; - - it("should query Nostr for existing binding when nametag present", async () => { - createStorageDataWithNametag("alice"); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // Step 8.5 should query Nostr for existing binding - expect(mockQueryPubkeyByNametagSpy).toHaveBeenCalled(); - }); - - it("should publish binding when no existing binding found", async () => { - createStorageDataWithNametag("bob"); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // When queryPubkeyByNametag returns null, publishNametagBinding should be called - expect(mockPublishNametagBindingSpy).toHaveBeenCalled(); - }); - - it("should skip Nostr binding in NAMETAG mode (read-only)", async () => { - createStorageDataWithNametag("charlie"); - - const params: SyncParams = { - ...createBaseSyncParams(), - nametag: true, - }; - - await inventorySync(params); - - // NAMETAG mode is read-only, should NOT publish bindings - expect(mockPublishNametagBindingSpy).not.toHaveBeenCalled(); - }); - - it("should still publish Nostr binding in LOCAL mode (LOCAL only skips IPFS)", async () => { - createStorageDataWithNametag("dave"); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - await inventorySync(params); - - // LOCAL mode only skips IPFS operations, Nostr bindings are still published - // Per spec: Step 8.5 only skips in NAMETAG mode - expect(mockPublishNametagBindingSpy).toHaveBeenCalled(); - }); - - it("should track nametagsPublished in stats", async () => { - createStorageDataWithNametag("eve"); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Stats should track published nametags - expect(result.operationStats.nametagsPublished).toBeDefined(); - }); - }); - - // ------------------------------------------ - // IPFS Upload Pipeline Tests (Steps 9-10) - // ------------------------------------------ - - describe("IPFS Upload Pipeline (Steps 9-10)", () => { - it("should increment version when content changes", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - // First sync - const params = createBaseSyncParams(); - const result1 = await inventorySync(params); - const version1 = result1.version; - - // Second sync WITH new token (content changes) - const result2 = await inventorySync({ - ...params, - incomingTokens: [createMockToken("newtoken")], - }); - const version2 = result2.version; - - // Version should increment when content changes - expect(version2).toBe((version1 || 0) + 1); - }); - - it("should NOT increment version when content unchanged", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result1 = await inventorySync(params); - const version1 = result1.version; - - // Second sync with same content - const result2 = await inventorySync(params); - const version2 = result2.version; - - // Version should stay the same when content unchanged - expect(version2).toBe(version1); - }); - - it("should set uploadNeeded when tokens change", async () => { - // Start with one token - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - // Add an incoming token - const params: SyncParams = { - ...createBaseSyncParams(), - incomingTokens: [createMockToken("incoming1")], - }; - - const result = await inventorySync(params); - - // With changes, IPFS upload should be triggered (in non-LOCAL mode) - // We verify via the result having ipnsPublishPending flag - expect(result.ipnsPublishPending).toBeDefined(); - }); - - it("should skip IPFS upload in LOCAL mode", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - incomingTokens: [createMockToken("incoming1")], - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("LOCAL"); - // LOCAL mode should not attempt IPFS publish - expect(result.ipnsPublished).toBeFalsy(); - }); - - it("should persist _meta with correct structure", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - await inventorySync(params); - - const stored = getLocalStorage(); - expect(stored?._meta).toBeDefined(); - expect(stored?._meta?.formatVersion).toBe("2.0"); - expect(stored?._meta?.address).toBe(TEST_ADDRESS); - // Version should be a number - expect(typeof stored?._meta?.version).toBe("number"); - }); - - it("should track token counts correctly after upload prep", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - "token2": createMockTxfToken("token2"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // inventoryStats should have correct token counts - expect(result.inventoryStats?.activeTokens).toBe(2); - expect(result.inventoryStats?.sentTokens).toBe(0); - expect(result.inventoryStats?.invalidTokens).toBe(0); - }); - }); - - // ------------------------------------------ - // Circuit Breaker State Tests (Section 10.2, 10.6) - // ------------------------------------------ - - describe("Circuit Breaker State", () => { - it("should include circuitBreaker in result", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.circuitBreaker).toBeDefined(); - expect(result.circuitBreaker?.localModeActive).toBe(false); - expect(result.circuitBreaker?.consecutiveConflicts).toBe(0); - }); - - it("should have localModeActive=false in NORMAL mode", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.syncMode).toBe("NORMAL"); - expect(result.circuitBreaker?.localModeActive).toBe(false); - }); - - it("should preserve circuitBreaker state across syncs", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result1 = await inventorySync(params); - const result2 = await inventorySync(params); - - // Circuit breaker should be consistent - expect(result2.circuitBreaker?.localModeActive).toBe( - result1.circuitBreaker?.localModeActive - ); - }); - }); - - // ------------------------------------------ - // Proof Validation Tests (Steps 3-4) - Chain Integrity - // ------------------------------------------ - - describe("Proof Validation - Chain Integrity (Steps 3-4)", () => { - it("should normalize transaction hash missing 0000 prefix", async () => { - // Create token with transaction hash missing "0000" prefix - const tokenWithUnnormalizedHash = createMockTxfToken("unnorm1"); - // Modify the proof to have unnormalized hash - if (tokenWithUnnormalizedHash.genesis.inclusionProof) { - tokenWithUnnormalizedHash.genesis.inclusionProof.transactionHash = "a".repeat(64); - } - - setLocalStorage(createMockStorageData({ - "unnorm1": tokenWithUnnormalizedHash, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Token should still be valid after normalization - expect(result.inventoryStats?.activeTokens).toBeGreaterThanOrEqual(0); - }); - - it("should normalize stateHash missing 0000 prefix", async () => { - const tokenWithUnnormalizedState = createMockTxfToken("unnorm2"); - // Modify the authenticator stateHash to be unnormalized - if (tokenWithUnnormalizedState.genesis.inclusionProof?.authenticator) { - tokenWithUnnormalizedState.genesis.inclusionProof.authenticator.stateHash = "b".repeat(64); - } - - setLocalStorage(createMockStorageData({ - "unnorm2": tokenWithUnnormalizedState, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Normalization should fix the hash - expect(result.status).not.toBe("ERROR"); - }); - - it("should validate genesis-to-first-transaction chain", async () => { - // Token with proper chain: genesis stateHash matches tx[0].previousStateHash - const tokenWithValidChain = createMockTxfToken("valid1", "1000", 1); - - setLocalStorage(createMockStorageData({ - "valid1": tokenWithValidChain, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Valid chain should keep token active - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.inventoryStats?.invalidTokens).toBe(0); - }); - - it("should reject token with broken state hash chain", async () => { - // Create token where tx[0].previousStateHash doesn't match genesis - const tokenWithBrokenChain: TxfToken = { - ...createMockTxfToken("broken1"), - transactions: [{ - data: { recipient: "someone" }, - previousStateHash: "0000" + "wrong".padEnd(60, "0"), // Wrong - doesn't match genesis - newStateHash: "0000" + "new".padEnd(60, "0"), - inclusionProof: { - authenticator: { stateHash: "0000" + "new".padEnd(60, "0") }, - merkleTreePath: { root: "0000" + "root".padEnd(60, "0"), path: [] }, - transactionHash: "0000" + "txhash".padEnd(60, "0"), - }, - }], - _integrity: { - currentStateHash: "0000" + "new".padEnd(60, "0"), - genesisDataJSONHash: "0000" + "genesis".padEnd(60, "0"), - }, - }; - - setLocalStorage(createMockStorageData({ - "broken1": tokenWithBrokenChain, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Broken chain should move token to Invalid folder - expect(result.inventoryStats?.invalidTokens).toBeGreaterThanOrEqual(1); - }); - - it("should validate multi-transaction chain integrity", async () => { - // Token with 3 transactions - each links to previous - const tokenWithLongChain = createMockTxfToken("longchain", "1000", 3); - - setLocalStorage(createMockStorageData({ - "longchain": tokenWithLongChain, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Valid long chain should pass - expect(result.inventoryStats?.activeTokens).toBe(1); - }); - }); - - // ------------------------------------------ - // Outbox Processing Tests (Split Operations) - // ------------------------------------------ - - describe("Outbox Processing - Split Operations", () => { - it("should track outbox entries with splitGroupId", async () => { - const splitGroupId = "split-group-123"; - const outboxEntries: OutboxEntry[] = [ - { - id: "burn-entry", - type: "SPLIT_BURN", - status: "COMPLETED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId, - splitIndex: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - { - id: "mint-entry-1", - type: "SPLIT_MINT", - status: "READY_TO_SUBMIT", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId, - splitIndex: 1, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - ]; - - setLocalStorage(createMockStorageData({ - "source1": createMockTxfToken("source1"), - })); - - // Set outbox in localStorage - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = outboxEntries; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Outbox entries should be tracked - expect(result.inventoryStats?.outboxTokens).toBe(2); - }); - - it("should mark mint as FAILED after status indicates failure", async () => { - const failedMintEntry: OutboxEntry = { - id: "failed-mint", - type: "SPLIT_MINT", - status: "FAILED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: "split-fail-123", - splitIndex: 1, - retryCount: 10, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry; - - setLocalStorage(createMockStorageData({ - "source1": createMockTxfToken("source1"), - })); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = [failedMintEntry]; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // Failed entry should still be in outbox for manual intervention - const stored = getLocalStorage(); - const failedEntries = stored?._outbox?.filter( - (e: OutboxEntry) => e.status === "FAILED" - ); - expect(failedEntries?.length).toBeGreaterThanOrEqual(0); - }); - - it("should preserve ABANDONED status for unrecoverable entries", async () => { - const abandonedEntry: OutboxEntry = { - id: "abandoned-mint", - type: "SPLIT_MINT", - status: "ABANDONED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: "split-abandoned-123", - splitIndex: 1, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry; - - setLocalStorage(createMockStorageData({ - "source1": createMockTxfToken("source1"), - })); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = [abandonedEntry]; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - const stored = getLocalStorage(); - const abandonedEntries = stored?._outbox?.filter( - (e: OutboxEntry) => e.status === "ABANDONED" - ); - // ABANDONED entries should be preserved - expect(abandonedEntries?.length).toBe(1); - }); - }); - - // ------------------------------------------ - // Circuit Breaker Activation Tests (Section 10.2, 10.6, 10.7) - CRITICAL - // ------------------------------------------ - - describe("Circuit Breaker Activation Logic", () => { - it("should track consecutiveIpfsFailures in circuitBreaker state", async () => { - // Simulate IPFS failure scenario - mockIpfsAvailable = false; - - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Circuit breaker should be present in result - expect(result.circuitBreaker).toBeDefined(); - // Even if IPFS fails, we don't activate LOCAL mode automatically on first failure - expect(result.circuitBreaker?.localModeActive).toBe(false); - }); - - it("should include all required circuitBreaker fields", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.circuitBreaker).toBeDefined(); - expect(typeof result.circuitBreaker?.localModeActive).toBe("boolean"); - expect(typeof result.circuitBreaker?.consecutiveConflicts).toBe("number"); - expect(typeof result.circuitBreaker?.consecutiveIpfsFailures).toBe("number"); - }); - - it("should preserve circuitBreaker.consecutiveConflicts across syncs", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - // First sync - baseline - const params = createBaseSyncParams(); - const result1 = await inventorySync(params); - expect(result1.circuitBreaker?.consecutiveConflicts).toBe(0); - - // Second sync should maintain state - const result2 = await inventorySync(params); - expect(result2.circuitBreaker?.consecutiveConflicts).toBe(0); - }); - - it("should set localModeActive=false when syncing successfully", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.circuitBreaker?.localModeActive).toBe(false); - }); - }); - - // ------------------------------------------ - // Auto LOCAL Mode Detection Tests (Section 10.2) - // ------------------------------------------ - - describe("Auto LOCAL Mode Detection", () => { - it("should detect LOCAL mode when local=true is explicitly set", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("LOCAL"); - expect(result.status).toMatch(/^(SUCCESS|PARTIAL_SUCCESS|LOCAL_ONLY)$/); - }); - - it("should continue in NORMAL mode when IPFS resolution fails (graceful degradation)", async () => { - // IPFS is unavailable but sync should continue - mockIpfsAvailable = false; - - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should complete with NORMAL mode (IPFS failure is non-fatal) - expect(result.syncMode).toBe("NORMAL"); - expect(result.status).not.toBe("ERROR"); - }); - - it("should skip IPFS Step 2 load in LOCAL mode", async () => { - // Set up remote data that would merge if IPFS was checked - mockIpfsAvailable = true; - mockRemoteData = createMockStorageData({ - "remote1": createMockTxfToken("remote1"), - }); - - setLocalStorage(createMockStorageData({ - "local1": createMockTxfToken("local1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - // LOCAL mode should only have local token (skipped IPFS merge) - expect(result.inventoryStats?.activeTokens).toBe(1); - }); - - it("should skip IPFS Step 10 upload in LOCAL mode", async () => { - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params: SyncParams = { - ...createBaseSyncParams(), - local: true, - }; - - const result = await inventorySync(params); - - expect(result.syncMode).toBe("LOCAL"); - // No IPNS publish in LOCAL mode - expect(result.ipnsPublished).toBeFalsy(); - expect(result.ipnsPublishPending).toBeFalsy(); - }); - }); - - // ------------------------------------------ - // Auto-Recovery Procedure Tests (Section 10.7) - // ------------------------------------------ - - describe("Auto-Recovery Procedures", () => { - it("should include nextRecoveryAttempt field when localModeActive", async () => { - // This tests the structure even if we don't have auto-activation yet - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Circuit breaker should be defined - expect(result.circuitBreaker).toBeDefined(); - // When not in LOCAL mode, nextRecoveryAttempt is undefined - if (!result.circuitBreaker?.localModeActive) { - expect(result.circuitBreaker?.nextRecoveryAttempt).toBeUndefined(); - } - }); - - it("should complete sync successfully after validation errors (non-fatal)", async () => { - // Configure validation to fail for one token - // Token.id matches storage key, not the padded genesis tokenId - mockValidationResult = { - valid: false, - issues: [{ tokenId: "invalid1", reason: "Test validation error" }], - }; - - setLocalStorage(createMockStorageData({ - "invalid1": createMockTxfToken("invalid1"), - "valid1": createMockTxfToken("valid1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Sync should complete (validation errors are non-fatal) - expect(result.status).not.toBe("ERROR"); - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.inventoryStats?.invalidTokens).toBe(1); - }); - - it("should complete sync with errors recorded in validationIssues", async () => { - // Force IPFS to fail in a way that records an error - mockIpfsAvailable = false; - - setLocalStorage(createMockStorageData({ - "token1": createMockTxfToken("token1"), - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Sync should still complete - expect(result.status).not.toBe("ERROR"); - // Errors might be recorded in validationIssues - // (depends on implementation - if IPFS failure is logged) - }); - - it("should preserve all tokens when recovering from SDK validation error", async () => { - // One token fails, one passes - // Token.id matches storage key, not the padded genesis tokenId - mockValidationResult = { - valid: false, - issues: [{ tokenId: "fail1", reason: "SDK error" }], - }; - - setLocalStorage(createMockStorageData({ - "fail1": createMockTxfToken("fail1"), - "pass1": createMockTxfToken("pass1"), - })); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // Verify storage state - const stored = getLocalStorage(); - expect(stored?._invalid?.length).toBe(1); - - // Good token should still be in active storage - const tokenKeys = Object.keys(stored || {}).filter(k => - k.startsWith("_") && - !k.startsWith("_meta") && - !k.startsWith("_sent") && - !k.startsWith("_invalid") && - !k.startsWith("_outbox") && - !k.startsWith("_tombstones") && - !k.startsWith("_nametag") - ); - expect(tokenKeys.length).toBe(1); - }); - }); - - // ------------------------------------------ - // Transaction Hash Verification Tests (Step 4 - CRITICAL) - // ------------------------------------------ - - describe("Transaction Hash Verification (Step 4)", () => { - it("should validate transactionHash has proper hex format", async () => { - // Create token with valid hex transactionHash - const validToken = createMockTxfToken("valid1"); - expect(validToken.genesis.inclusionProof?.transactionHash).toMatch(/^[0-9a-fA-F]+$/); - - setLocalStorage(createMockStorageData({ - "valid1": validToken, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - expect(result.inventoryStats?.invalidTokens).toBe(0); - }); - - it("should invalidate token with malformed transactionHash", async () => { - // Create token with invalid transactionHash (non-hex characters) - const tokenWithBadHash: TxfToken = { - ...createMockTxfToken("bad1"), - genesis: { - ...createMockTxfToken("bad1").genesis, - inclusionProof: { - ...createMockTxfToken("bad1").genesis.inclusionProof!, - transactionHash: "INVALID_NOT_HEX_STRING!!!", - }, - }, - }; - - setLocalStorage(createMockStorageData({ - "bad1": tokenWithBadHash, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Token should be moved to Invalid folder - expect(result.inventoryStats?.invalidTokens).toBe(1); - expect(result.inventoryStats?.activeTokens).toBe(0); - }); - - it("should validate stateHash format (64+ hex chars with optional 0000 prefix)", async () => { - // Create token with properly formatted stateHash - const validToken = createMockTxfToken("valid2"); - expect(validToken.genesis.inclusionProof?.authenticator?.stateHash).toMatch(/^0000[0-9a-fA-F]{60,}$/); - - setLocalStorage(createMockStorageData({ - "valid2": validToken, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - expect(result.inventoryStats?.activeTokens).toBe(1); - }); - - it("should accept token with missing transactionHash (optional field)", async () => { - // Create token without transactionHash - // This is valid because: - // 1. Some SDK versions/faucet tokens don't populate this field - // 2. Full cryptographic validation happens in Step 5 via SDK's token.verify() - const tokenWithoutTxHash: TxfToken = { - ...createMockTxfToken("notxhash"), - genesis: { - ...createMockTxfToken("notxhash").genesis, - inclusionProof: { - authenticator: { stateHash: "0000" + "a".repeat(60) }, - merkleTreePath: { root: "0000" + "b".repeat(60), path: [] }, - // Missing transactionHash - this is OK, it's optional - } as TxfToken["genesis"]["inclusionProof"], - }, - }; - - setLocalStorage(createMockStorageData({ - "notxhash": tokenWithoutTxHash, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should remain in Active folder (transactionHash is optional) - expect(result.inventoryStats?.invalidTokens).toBe(0); - expect(result.inventoryStats?.activeTokens).toBe(1); - }); - - it("should validate merkle root format", async () => { - // Create token with invalid merkle root - const tokenWithBadRoot: TxfToken = { - ...createMockTxfToken("badroot"), - genesis: { - ...createMockTxfToken("badroot").genesis, - inclusionProof: { - ...createMockTxfToken("badroot").genesis.inclusionProof!, - merkleTreePath: { root: "NOT_HEX!!!", path: [] }, - }, - }, - }; - - setLocalStorage(createMockStorageData({ - "badroot": tokenWithBadRoot, - })); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should be moved to Invalid folder - expect(result.inventoryStats?.invalidTokens).toBe(1); - }); - }); - - // ------------------------------------------ - // Split Burn Recovery Tests (Section 13.25) - CRITICAL - // ------------------------------------------ - - describe("Split Burn Recovery (Section 13.25) - Value Loss Prevention", () => { - it("should preserve split group when burn succeeds but mints pending", async () => { - const splitGroupId = "recovery-group-123"; - - // Burn completed, mint still pending - const outboxEntries: OutboxEntry[] = [ - { - id: "burn-completed", - type: "SPLIT_BURN", - status: "COMPLETED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId, - splitIndex: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - { - id: "mint-pending", - type: "SPLIT_MINT", - status: "READY_TO_SUBMIT", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId, - splitIndex: 1, - retryCount: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - ]; - - setLocalStorage(createMockStorageData({})); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = outboxEntries; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // Split group should be preserved for recovery - const stored = getLocalStorage(); - const groupEntries = stored?._outbox?.filter( - (e: OutboxEntry) => e.splitGroupId === splitGroupId - ); - expect(groupEntries?.length).toBe(2); - }); - - it("should track retry count for mint operations", async () => { - const mintWithRetries: OutboxEntry = { - id: "mint-retrying", - type: "SPLIT_MINT", - status: "READY_TO_SUBMIT", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: "retry-group-123", - splitIndex: 1, - retryCount: 5, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry; - - setLocalStorage(createMockStorageData({})); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = [mintWithRetries]; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // Retry count should be preserved - const stored = getLocalStorage(); - const entry = stored?._outbox?.find((e: OutboxEntry) => e.id === "mint-retrying"); - expect(entry?.retryCount).toBe(5); - }); - - it("should maintain splitGroupId linkage for burn-mint pairs", async () => { - const splitGroupId = "linked-group-456"; - - const outboxEntries: OutboxEntry[] = [ - { - id: "burn-1", - type: "SPLIT_BURN", - status: "COMPLETED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId, - splitIndex: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - { - id: "mint-sender", - type: "SPLIT_MINT", - status: "COMPLETED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId, - splitIndex: 1, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - { - id: "mint-recipient", - type: "SPLIT_MINT", - status: "FAILED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId, - splitIndex: 2, - retryCount: 10, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - ]; - - setLocalStorage(createMockStorageData({})); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = outboxEntries; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // Verify all entries in split group are preserved - const stored = getLocalStorage(); - const burnEntry = stored?._outbox?.find((e: OutboxEntry) => e.type === "SPLIT_BURN"); - const mintEntries = stored?._outbox?.filter((e: OutboxEntry) => e.type === "SPLIT_MINT"); - - expect(burnEntry?.splitGroupId).toBe(splitGroupId); - expect(mintEntries?.every((e: OutboxEntry) => e.splitGroupId === splitGroupId)).toBe(true); - }); - - it("should not remove FAILED mints (require manual intervention)", async () => { - const failedMint: OutboxEntry = { - id: "failed-mint-critical", - type: "SPLIT_MINT", - status: "FAILED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: "failed-group-789", - splitIndex: 1, - retryCount: 10, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry; - - setLocalStorage(createMockStorageData({})); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = [failedMint]; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - // FAILED entries should NOT be automatically removed - const stored = getLocalStorage(); - const failedEntries = stored?._outbox?.filter( - (e: OutboxEntry) => e.status === "FAILED" - ); - expect(failedEntries?.length).toBe(1); - }); - - it("should track multiple split groups independently", async () => { - const group1 = "split-group-1"; - const group2 = "split-group-2"; - - const outboxEntries: OutboxEntry[] = [ - // Group 1: Both completed - { - id: "g1-burn", - type: "SPLIT_BURN", - status: "COMPLETED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: group1, - splitIndex: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - { - id: "g1-mint", - type: "SPLIT_MINT", - status: "COMPLETED", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: group1, - splitIndex: 1, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - // Group 2: Burn completed, mint pending - { - id: "g2-burn", - type: "SPLIT_BURN", - status: "COMPLETED", - sourceTokenId: "source2".padEnd(64, "0"), - splitGroupId: group2, - splitIndex: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - { - id: "g2-mint", - type: "SPLIT_MINT", - status: "READY_TO_SUBMIT", - sourceTokenId: "source2".padEnd(64, "0"), - splitGroupId: group2, - splitIndex: 1, - retryCount: 3, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry, - ]; - - setLocalStorage(createMockStorageData({})); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = outboxEntries; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - const stored = getLocalStorage(); - const g1Entries = stored?._outbox?.filter((e: OutboxEntry) => e.splitGroupId === group1); - const g2Entries = stored?._outbox?.filter((e: OutboxEntry) => e.splitGroupId === group2); - - // Both groups should be preserved independently - expect(g1Entries?.length).toBe(2); - expect(g2Entries?.length).toBe(2); - }); - - it("should preserve outbox timestamps for audit trail", async () => { - const originalTimestamp = Date.now() - 86400000; // 24 hours ago - - const outboxEntry: OutboxEntry = { - id: "audit-entry", - type: "SPLIT_MINT", - status: "READY_TO_SUBMIT", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: "audit-group", - splitIndex: 1, - retryCount: 2, - createdAt: originalTimestamp, - updatedAt: originalTimestamp + 3600000, // 1 hour after creation - } as OutboxEntry; - - setLocalStorage(createMockStorageData({})); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = [outboxEntry]; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - await inventorySync(params); - - const stored = getLocalStorage(); - const entry = stored?._outbox?.find((e: OutboxEntry) => e.id === "audit-entry"); - - // Timestamps should be preserved for audit - expect(entry?.createdAt).toBe(originalTimestamp); - expect(entry?.updatedAt).toBe(originalTimestamp + 3600000); - }); - - it("should handle orphan mint (no corresponding burn) gracefully", async () => { - // Mint without a burn in the same split group - const orphanMint: OutboxEntry = { - id: "orphan-mint", - type: "SPLIT_MINT", - status: "READY_TO_SUBMIT", - sourceTokenId: "source1".padEnd(64, "0"), - splitGroupId: "orphan-group", - splitIndex: 1, - retryCount: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - } as OutboxEntry; - - setLocalStorage(createMockStorageData({})); - - const storageKey = STORAGE_KEY_GENERATORS.walletByAddress(TEST_ADDRESS); - const data = JSON.parse(localStorage.getItem(storageKey) || "{}"); - data._outbox = [orphanMint]; - localStorage.setItem(storageKey, JSON.stringify(data)); - - const params = createBaseSyncParams(); - const result = await inventorySync(params); - - // Should not error - orphan entries are preserved - expect(result.status).not.toBe("ERROR"); - - const stored = getLocalStorage(); - expect(stored?._outbox?.length).toBe(1); - }); - }); -}); diff --git a/tests/unit/components/wallet/L3/services/SyncCoordinator.test.ts b/tests/unit/components/wallet/L3/services/SyncCoordinator.test.ts deleted file mode 100644 index 75d27ff0..00000000 --- a/tests/unit/components/wallet/L3/services/SyncCoordinator.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -// ========================================== -// Mock BroadcastChannel for testing -// ========================================== - -interface MockMessageHandler { - (event: { data: SyncMessage }): void; -} - -interface SyncMessage { - type: string; - from: string; - timestamp: number; - payload?: unknown; -} - -// Global message bus to simulate cross-tab communication -let globalMessageBus: Array<{ channel: MockBroadcastChannel; handler: MockMessageHandler }> = []; -let messageQueue: Array<{ target: MockBroadcastChannel; message: SyncMessage }> = []; - -class MockBroadcastChannel { - name: string; - onmessage: MockMessageHandler | null = null; - private _closed = false; - - constructor(name: string) { - this.name = name; - // Register this channel in the global bus - globalMessageBus.push({ - channel: this, - handler: (event) => { - if (!this._closed && this.onmessage) { - this.onmessage(event); - } - }, - }); - } - - postMessage(message: SyncMessage): void { - if (this._closed) return; - // Queue messages for delivery (synchronous for testing) - globalMessageBus.forEach(({ channel }) => { - if (channel !== this && channel.name === this.name && !channel._closed) { - messageQueue.push({ target: channel, message }); - } - }); - } - - close(): void { - this._closed = true; - // Remove from global bus - globalMessageBus = globalMessageBus.filter(({ channel }) => channel !== this); - } -} - -// Process all pending messages synchronously -function deliverMessages(): void { - const pending = messageQueue.splice(0, messageQueue.length); - pending.forEach(({ target, message }) => { - if (target.onmessage) { - target.onmessage({ data: message }); - } - }); -} - -// Replace global BroadcastChannel with mock -vi.stubGlobal("BroadcastChannel", MockBroadcastChannel); - -// Mock crypto.randomUUID for deterministic instance IDs -let uuidCounter = 0; -vi.stubGlobal("crypto", { - ...globalThis.crypto, - randomUUID: vi.fn(() => `test-uuid-${++uuidCounter}`), -}); - -// Now import the module under test -import { SyncCoordinator, getSyncCoordinator } from "../../../../../../src/components/wallet/L3/services/SyncCoordinator"; - -// ========================================== -// Test Suite -// ========================================== - -describe("SyncCoordinator", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset the global message bus and queue - globalMessageBus = []; - messageQueue = []; - // Reset UUID counter for deterministic tests - uuidCounter = 0; - // Reset singleton - (SyncCoordinator as unknown as { prototype: { shutdown: () => void } }).prototype.shutdown?.call?.( - getSyncCoordinator?.() as SyncCoordinator - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - // Clean up any remaining channels - globalMessageBus.forEach(({ channel }) => channel.close()); - globalMessageBus = []; - messageQueue = []; - }); - - // ------------------------------------------ - // Initialization Tests - // ------------------------------------------ - - describe("Initialization", () => { - it("should create a unique instance ID", () => { - const coordinator = new SyncCoordinator(); - expect(coordinator).toBeDefined(); - coordinator.shutdown(); - }); - - it("should become leader immediately when no other tabs exist", () => { - const coordinator = new SyncCoordinator(); - // No messages to deliver, should auto-become leader - expect(coordinator.isCurrentLeader()).toBe(true); - coordinator.shutdown(); - }); - - it("should create BroadcastChannel with correct name", () => { - const coordinator = new SyncCoordinator(); - - // Verify channel was created (via globalMessageBus) - expect(globalMessageBus.length).toBe(1); - expect(globalMessageBus[0].channel.name).toBe("ipfs-sync-coordinator"); - - coordinator.shutdown(); - }); - }); - - // ------------------------------------------ - // Leadership Election Tests - // ------------------------------------------ - - describe("Leadership Election", () => { - it("should resolve leadership conflict using higher ID wins", () => { - // Create first coordinator (will claim leadership) - const coordinator1 = new SyncCoordinator(); - deliverMessages(); - - expect(coordinator1.isCurrentLeader()).toBe(true); - - // Create second coordinator (will compete for leadership) - const coordinator2 = new SyncCoordinator(); - deliverMessages(); - - // Process leader-announce messages - deliverMessages(); - - // Higher UUID wins - coordinator2 has higher ID - expect(coordinator2.isCurrentLeader()).toBe(true); - expect(coordinator1.isCurrentLeader()).toBe(false); - - coordinator1.shutdown(); - coordinator2.shutdown(); - }); - - it("should start as leader when alone", () => { - const coordinator = new SyncCoordinator(); - expect(coordinator.isCurrentLeader()).toBe(true); - coordinator.shutdown(); - }); - }); - - // ------------------------------------------ - // Lock Acquisition Tests - // ------------------------------------------ - - describe("Lock Acquisition", () => { - it("should acquire lock immediately when leader and not syncing", async () => { - const coordinator = new SyncCoordinator(); - deliverMessages(); - - const acquired = await coordinator.acquireLock(100); - - expect(acquired).toBe(true); - expect(coordinator.hasLock()).toBe(true); - - coordinator.releaseLock(); - coordinator.shutdown(); - }); - - it("should release lock correctly", async () => { - const coordinator = new SyncCoordinator(); - deliverMessages(); - - await coordinator.acquireLock(100); - expect(coordinator.hasLock()).toBe(true); - - coordinator.releaseLock(); - deliverMessages(); - expect(coordinator.hasLock()).toBe(false); - - coordinator.shutdown(); - }); - - it("should broadcast sync-start when acquiring lock", async () => { - const coordinator1 = new SyncCoordinator(); - deliverMessages(); - - // Create second coordinator to receive messages - const coordinator2 = new SyncCoordinator(); - deliverMessages(); - deliverMessages(); // process leader announcements - - // Track messages - const receivedMessages: SyncMessage[] = []; - const nonLeader = coordinator1.isCurrentLeader() ? coordinator2 : coordinator1; - const originalOnMessage = nonLeader["channel"].onmessage; - nonLeader["channel"].onmessage = (event) => { - receivedMessages.push(event.data); - originalOnMessage?.(event); - }; - - // Leader acquires lock - const leader = coordinator1.isCurrentLeader() ? coordinator1 : coordinator2; - await leader.acquireLock(100); - deliverMessages(); - - // Should have broadcast sync-start - const syncStarts = receivedMessages.filter((m) => m.type === "sync-start"); - expect(syncStarts.length).toBeGreaterThanOrEqual(1); - - leader.releaseLock(); - coordinator1.shutdown(); - coordinator2.shutdown(); - }); - - it("should broadcast sync-complete when releasing lock", async () => { - const coordinator1 = new SyncCoordinator(); - deliverMessages(); - - const coordinator2 = new SyncCoordinator(); - deliverMessages(); - deliverMessages(); - - // Track messages on non-leader - const receivedMessages: SyncMessage[] = []; - const nonLeader = coordinator1.isCurrentLeader() ? coordinator2 : coordinator1; - const originalOnMessage = nonLeader["channel"].onmessage; - nonLeader["channel"].onmessage = (event) => { - receivedMessages.push(event.data); - originalOnMessage?.(event); - }; - - // Leader syncs - const leader = coordinator1.isCurrentLeader() ? coordinator1 : coordinator2; - await leader.acquireLock(100); - deliverMessages(); - - // Clear and release - receivedMessages.length = 0; - leader.releaseLock(); - deliverMessages(); - - // Should receive sync-complete - const syncCompletes = receivedMessages.filter((m) => m.type === "sync-complete"); - expect(syncCompletes.length).toBeGreaterThanOrEqual(1); - - coordinator1.shutdown(); - coordinator2.shutdown(); - }); - }); - - // ------------------------------------------ - // Multi-Instance Coordination Tests - // ------------------------------------------ - - describe("Multi-Instance Coordination", () => { - it("should coordinate between two tabs (only one is leader)", () => { - const coordinator1 = new SyncCoordinator(); - deliverMessages(); - - const coordinator2 = new SyncCoordinator(); - deliverMessages(); - deliverMessages(); // Process all announcements - - // Only one should be leader - const leaders = [coordinator1.isCurrentLeader(), coordinator2.isCurrentLeader()]; - expect(leaders.filter(Boolean).length).toBe(1); - - coordinator1.shutdown(); - coordinator2.shutdown(); - }); - - it("should allow leader to acquire lock", async () => { - const coordinator1 = new SyncCoordinator(); - deliverMessages(); - - const coordinator2 = new SyncCoordinator(); - deliverMessages(); - deliverMessages(); - - // Leader should be able to acquire lock - const leader = coordinator1.isCurrentLeader() ? coordinator1 : coordinator2; - const acquired = await leader.acquireLock(100); - expect(acquired).toBe(true); - - leader.releaseLock(); - coordinator1.shutdown(); - coordinator2.shutdown(); - }); - - it("should track hasLock status correctly", async () => { - const coordinator = new SyncCoordinator(); - deliverMessages(); - - expect(coordinator.hasLock()).toBe(false); - - await coordinator.acquireLock(100); - expect(coordinator.hasLock()).toBe(true); - - coordinator.releaseLock(); - expect(coordinator.hasLock()).toBe(false); - - coordinator.shutdown(); - }); - }); - - // ------------------------------------------ - // Cleanup Tests - // ------------------------------------------ - - describe("Cleanup", () => { - it("should close BroadcastChannel on shutdown", () => { - const coordinator = new SyncCoordinator(); - expect(globalMessageBus.length).toBe(1); - - coordinator.shutdown(); - expect(globalMessageBus.length).toBe(0); - }); - - it("should broadcast sync-complete on shutdown if syncing", async () => { - const coordinator1 = new SyncCoordinator(); - deliverMessages(); - - const coordinator2 = new SyncCoordinator(); - deliverMessages(); - deliverMessages(); - - // Track messages on non-leader - const receivedMessages: SyncMessage[] = []; - const nonLeader = coordinator1.isCurrentLeader() ? coordinator2 : coordinator1; - const originalOnMessage = nonLeader["channel"].onmessage; - nonLeader["channel"].onmessage = (event) => { - receivedMessages.push(event.data); - originalOnMessage?.(event); - }; - - // Leader starts syncing - const leader = coordinator1.isCurrentLeader() ? coordinator1 : coordinator2; - await leader.acquireLock(100); - deliverMessages(); - - // Clear and shutdown while syncing - receivedMessages.length = 0; - leader.shutdown(); - deliverMessages(); - - // Should have announced completion - const syncCompletes = receivedMessages.filter((m) => m.type === "sync-complete"); - expect(syncCompletes.length).toBeGreaterThanOrEqual(1); - - nonLeader.shutdown(); - }); - }); - - // ------------------------------------------ - // Singleton Pattern Tests - // ------------------------------------------ - - describe("Singleton Pattern", () => { - it("getSyncCoordinator should return SyncCoordinator instance", () => { - const instance = getSyncCoordinator(); - expect(instance).toBeDefined(); - expect(instance).toBeInstanceOf(SyncCoordinator); - instance.shutdown(); - }); - }); -}); diff --git a/tests/unit/components/wallet/L3/services/TokenValidationService.test.ts b/tests/unit/components/wallet/L3/services/TokenValidationService.test.ts deleted file mode 100644 index 727d7468..00000000 --- a/tests/unit/components/wallet/L3/services/TokenValidationService.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - TokenValidationService, - getTokenValidationService, -} from "../../../../../../src/components/wallet/L3/services/TokenValidationService"; -import { Token, TokenStatus } from "../../../../../../src/components/wallet/L3/data/model"; -import type { TxfToken } from "../../../../../../src/components/wallet/L3/services/types/TxfTypes"; - -// ========================================== -// Mock fetch globally -// ========================================== - -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -// ========================================== -// Test Fixtures -// ========================================== - -const validTxfToken: TxfToken = { - version: "2.0", - genesis: { - data: { - tokenId: "a".repeat(64), - tokenType: "b".repeat(64), - coinData: [["ALPHA", "1000000000"]], - tokenData: "", - salt: "c".repeat(64), - recipient: "DIRECT://abc123", - recipientDataHash: null, - reason: null, - }, - inclusionProof: { - authenticator: { - algorithm: "secp256k1", - publicKey: "d".repeat(64), - signature: "e".repeat(128), - stateHash: "0000" + "f".repeat(60), - }, - merkleTreePath: { - root: "0000" + "1".repeat(60), - steps: [{ data: "2".repeat(64), path: "1" }], - }, - transactionHash: "3".repeat(64), - unicityCertificate: "4".repeat(100), - }, - }, - state: { - data: "", - predicate: "5".repeat(64), - }, - transactions: [], - nametags: [], - _integrity: { - genesisDataJSONHash: "0000" + "6".repeat(60), - }, -}; - -const createMockToken = (overrides: Partial = {}): Token => { - return new Token({ - id: "test-token-id", - name: "Test Token", - type: "UCT", - timestamp: Date.now(), - jsonData: JSON.stringify(validTxfToken), - status: TokenStatus.CONFIRMED, - amount: "1000000000", - coinId: "ALPHA", - symbol: "ALPHA", - sizeBytes: 1000, - ...overrides, - }); -}; - -// ========================================== -// TokenValidationService Tests -// ========================================== - -describe("TokenValidationService", () => { - let service: TokenValidationService; - - beforeEach(() => { - service = new TokenValidationService("https://test-aggregator.example.com"); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ========================================== - // validateToken Tests - // ========================================== - - describe("validateToken", () => { - it("should return invalid for token without jsonData", async () => { - const token = createMockToken({ jsonData: undefined }); - - const result = await service.validateToken(token); - - expect(result.isValid).toBe(false); - expect(result.reason).toContain("no jsonData"); - }); - - it("should return invalid for token with unparseable JSON", async () => { - const token = createMockToken({ jsonData: "not valid json" }); - - const result = await service.validateToken(token); - - expect(result.isValid).toBe(false); - expect(result.reason).toContain("parse"); - }); - - it("should return invalid for token without TXF structure", async () => { - const token = createMockToken({ - jsonData: JSON.stringify({ foo: "bar" }), - }); - - const result = await service.validateToken(token); - - expect(result.isValid).toBe(false); - expect(result.reason).toContain("TXF fields"); - }); - - it("should return valid for token with proper TXF structure", async () => { - const token = createMockToken(); - - // Mock SDK verification to throw (optional verification) - mockFetch.mockRejectedValueOnce(new Error("Network error")); - - const result = await service.validateToken(token); - - expect(result.isValid).toBe(true); - expect(result.token).toBeDefined(); - }); - - it("should attempt to fetch proofs for uncommitted transactions", async () => { - const txfWithUncommitted = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: null, // Uncommitted - }, - ], - }; - - const token = createMockToken({ - jsonData: JSON.stringify(txfWithUncommitted), - }); - - // Mock aggregator returning a proof - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - result: validTxfToken.genesis.inclusionProof, - }), - }); - - await service.validateToken(token); - - expect(mockFetch).toHaveBeenCalled(); - // Should have tried to fetch proof - const fetchCall = mockFetch.mock.calls[0]; - expect(fetchCall[0]).toContain("/proof"); - }); - }); - - // ========================================== - // validateAllTokens Tests - // ========================================== - - describe("validateAllTokens", () => { - it("should validate all tokens and return results", async () => { - const tokens = [ - createMockToken({ id: "token1" }), - createMockToken({ id: "token2" }), - createMockToken({ id: "token3", jsonData: undefined }), - ]; - - const result = await service.validateAllTokens(tokens); - - expect(result.validTokens.length).toBe(2); - expect(result.issues.length).toBe(1); - expect(result.issues[0].tokenId).toBe("token3"); - }); - - it("should process tokens in batches", async () => { - const tokens = Array.from({ length: 10 }, (_, i) => - createMockToken({ id: `token${i}` }) - ); - - const progressUpdates: { completed: number; total: number }[] = []; - - await service.validateAllTokens(tokens, { - batchSize: 3, - onProgress: (completed, total) => { - progressUpdates.push({ completed, total }); - }, - }); - - // Should have multiple progress updates - expect(progressUpdates.length).toBeGreaterThan(0); - // Last update should have completed all - expect(progressUpdates[progressUpdates.length - 1].completed).toBe(10); - }); - - it("should use custom batch size", async () => { - const tokens = Array.from({ length: 6 }, (_, i) => - createMockToken({ id: `token${i}` }) - ); - - const progressUpdates: number[] = []; - await service.validateAllTokens(tokens, { - batchSize: 2, - onProgress: (completed) => { - progressUpdates.push(completed); - }, - }); - - // With batch size 2 and 6 tokens, progress is reported once per batch - // So we should get 3 progress calls (one per batch) with cumulative completed counts - expect(progressUpdates.length).toBe(3); // 3 batches - expect(progressUpdates).toEqual([2, 4, 6]); // After each batch: 2, 4, 6 tokens complete - }); - - it("should handle validation errors gracefully", async () => { - const tokens = [ - createMockToken({ id: "valid-token" }), - createMockToken({ id: "invalid-token", jsonData: "invalid" }), - ]; - - const result = await service.validateAllTokens(tokens); - - // Should still return valid tokens - expect(result.validTokens.length).toBe(1); - // Should report issues for invalid tokens - expect(result.issues.length).toBe(1); - }); - }); - - // ========================================== - // fetchMissingProofs Tests - // ========================================== - - describe("fetchMissingProofs", () => { - it("should return null for token without jsonData", async () => { - const token = createMockToken({ jsonData: undefined }); - - const result = await service.fetchMissingProofs(token); - - expect(result).toBeNull(); - }); - - it("should return null for token without transactions", async () => { - const token = createMockToken(); - - const result = await service.fetchMissingProofs(token); - - expect(result).toBeNull(); - }); - - it("should fetch and apply proofs for uncommitted transactions", async () => { - const txfWithUncommitted = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: null, - }, - ], - }; - - const token = createMockToken({ - jsonData: JSON.stringify(txfWithUncommitted), - }); - - // Mock successful proof fetch - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - result: validTxfToken.genesis.inclusionProof, - }), - }); - - const result = await service.fetchMissingProofs(token); - - expect(result).not.toBeNull(); - expect(result?.status).toBe(TokenStatus.CONFIRMED); - - // Verify proof was applied - const updatedTxf = JSON.parse(result!.jsonData!); - expect(updatedTxf.transactions[0].inclusionProof).not.toBeNull(); - }); - - it("should return null when proof fetch fails", async () => { - // Reset mock to ensure clean state - mockFetch.mockReset(); - - const txfWithUncommitted = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: null, - }, - ], - }; - - const token = createMockToken({ - jsonData: JSON.stringify(txfWithUncommitted), - }); - - // Mock failed proof fetch - return error response - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - }); - - const result = await service.fetchMissingProofs(token); - - expect(result).toBeNull(); - }); - - it("should skip transactions that already have proofs", async () => { - const txfWithMixedTransactions = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: validTxfToken.genesis.inclusionProof, // Already has proof - }, - { - previousStateHash: "b".repeat(64), - newStateHash: "c".repeat(64), - predicate: "d".repeat(64), - inclusionProof: null, // Needs proof - }, - ], - }; - - const token = createMockToken({ - jsonData: JSON.stringify(txfWithMixedTransactions), - }); - - // Only one fetch call should be made (for the uncommitted tx) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - result: validTxfToken.genesis.inclusionProof, - }), - }); - - await service.fetchMissingProofs(token); - - // Only one fetch call for the uncommitted transaction - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - }); -}); - -// ========================================== -// Singleton Tests -// ========================================== - -describe("getTokenValidationService", () => { - it("should return the same instance on multiple calls", () => { - const instance1 = getTokenValidationService(); - const instance2 = getTokenValidationService(); - - expect(instance1).toBe(instance2); - }); -}); diff --git a/tests/unit/components/wallet/L3/services/TxfSerializer.test.ts b/tests/unit/components/wallet/L3/services/TxfSerializer.test.ts deleted file mode 100644 index be52b5df..00000000 --- a/tests/unit/components/wallet/L3/services/TxfSerializer.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - tokenToTxf, - txfToToken, - buildTxfStorageData, - parseTxfStorageData, - parseTxfFile, - getTokenId, - hasValidTxfData, - countCommittedTransactions, - hasUncommittedTransactions, -} from "../../../../../../src/components/wallet/L3/services/TxfSerializer"; -import { Token, TokenStatus } from "../../../../../../src/components/wallet/L3/data/model"; -import type { TxfToken, TxfStorageData } from "../../../../../../src/components/wallet/L3/services/types/TxfTypes"; - -// ========================================== -// Test Fixtures -// ========================================== - -const validTxfToken: TxfToken = { - version: "2.0", - genesis: { - data: { - tokenId: "a".repeat(64), - tokenType: "b".repeat(64), - coinData: [["ALPHA", "1000000000"]], - tokenData: "", - salt: "c".repeat(64), - recipient: "DIRECT://abc123", - recipientDataHash: null, - reason: null, - }, - inclusionProof: { - authenticator: { - algorithm: "secp256k1", - publicKey: "d".repeat(64), - signature: "e".repeat(128), - stateHash: "0000" + "f".repeat(60), - }, - merkleTreePath: { - root: "0000" + "1".repeat(60), - steps: [{ data: "2".repeat(64), path: "1" }], - }, - transactionHash: "3".repeat(64), - unicityCertificate: "4".repeat(100), - }, - }, - state: { - data: "", - predicate: "5".repeat(64), - }, - transactions: [], - nametags: [], - _integrity: { - genesisDataJSONHash: "0000" + "6".repeat(60), - }, -}; - -const createMockToken = (overrides: Partial = {}): Token => { - return new Token({ - id: "test-token-id", - name: "Test Token", - type: "UCT", - timestamp: Date.now(), - jsonData: JSON.stringify(validTxfToken), - status: TokenStatus.CONFIRMED, - amount: "1000000000", - coinId: "ALPHA", - symbol: "ALPHA", - sizeBytes: 1000, - ...overrides, - }); -}; - -// ========================================== -// tokenToTxf Tests -// ========================================== - -describe("tokenToTxf", () => { - it("should convert token with valid jsonData to TxfToken", () => { - const token = createMockToken(); - const result = tokenToTxf(token); - - expect(result).not.toBeNull(); - expect(result?.version).toBe("2.0"); - expect(result?.genesis.data.tokenId).toBe("a".repeat(64)); - }); - - it("should return null for token without jsonData", () => { - const token = createMockToken({ jsonData: undefined }); - const result = tokenToTxf(token); - - expect(result).toBeNull(); - }); - - it("should return null for token with invalid JSON", () => { - const token = createMockToken({ jsonData: "not valid json" }); - const result = tokenToTxf(token); - - expect(result).toBeNull(); - }); - - it("should return null for token with non-TXF structure", () => { - const token = createMockToken({ jsonData: JSON.stringify({ foo: "bar" }) }); - const result = tokenToTxf(token); - - expect(result).toBeNull(); - }); - - it("should add default version if missing", () => { - const tokenWithoutVersion = { ...validTxfToken }; - // @ts-expect-error - Testing missing version - delete tokenWithoutVersion.version; - - const token = createMockToken({ - jsonData: JSON.stringify(tokenWithoutVersion), - }); - const result = tokenToTxf(token); - - expect(result).not.toBeNull(); - expect(result?.version).toBe("2.0"); - }); - - it("should add default transactions array if missing", () => { - const tokenWithoutTxs = { ...validTxfToken }; - // @ts-expect-error - Testing missing transactions - delete tokenWithoutTxs.transactions; - - const token = createMockToken({ - jsonData: JSON.stringify(tokenWithoutTxs), - }); - const result = tokenToTxf(token); - - expect(result).not.toBeNull(); - expect(result?.transactions).toEqual([]); - }); -}); - -// ========================================== -// txfToToken Tests -// ========================================== - -describe("txfToToken", () => { - it("should convert TxfToken to Token model", () => { - const tokenId = "a".repeat(64); - const result = txfToToken(tokenId, validTxfToken); - - expect(result).toBeInstanceOf(Token); - expect(result.id).toBe(tokenId); - expect(result.status).toBe(TokenStatus.CONFIRMED); - expect(result.amount).toBe("1000000000"); - expect(result.coinId).toBe("ALPHA"); - }); - - it("should set status to PENDING when last transaction has no proof", () => { - const tokenWithPendingTx: TxfToken = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: null, - }, - ], - }; - - const result = txfToToken("test-id", tokenWithPendingTx); - expect(result.status).toBe(TokenStatus.PENDING); - }); - - it("should calculate total amount from coinData", () => { - const tokenWithMultipleCoins: TxfToken = { - ...validTxfToken, - genesis: { - ...validTxfToken.genesis, - data: { - ...validTxfToken.genesis.data, - coinData: [ - ["COIN1", "1000"], - ["COIN2", "2000"], - ], - }, - }, - }; - - const result = txfToToken("test-id", tokenWithMultipleCoins); - expect(result.amount).toBe("3000"); - }); -}); - -// ========================================== -// buildTxfStorageData Tests -// ========================================== - -describe("buildTxfStorageData", () => { - it("should build storage data with tokens and metadata", async () => { - const tokens = [createMockToken()]; - const meta = { - version: 1, - timestamp: Date.now(), - address: "0x123", - ipnsName: "ipns-test", - }; - - const result = await buildTxfStorageData(tokens, meta); - - expect(result._meta).toBeDefined(); - expect(result._meta.formatVersion).toBe("2.0"); - expect(result._meta.version).toBe(1); - expect(Object.keys(result).length).toBeGreaterThan(1); - }); - - it("should include nametag if provided", async () => { - const tokens = [createMockToken()]; - const meta = { - version: 1, - timestamp: Date.now(), - address: "0x123", - ipnsName: "ipns-test", - }; - const nametag = { - name: "testuser", - token: validTxfToken, // Must be a valid token, not empty object - timestamp: Date.now(), - format: "1.0", - version: "1.0", - }; - - const result = await buildTxfStorageData(tokens, meta, nametag); - - expect(result._nametag).toBeDefined(); - expect(result._nametag?.name).toBe("testuser"); - }); - - it("should skip tokens without valid TXF data", async () => { - const invalidToken = createMockToken({ jsonData: undefined }); - const meta = { - version: 1, - timestamp: Date.now(), - address: "0x123", - ipnsName: "ipns-test", - }; - - const result = await buildTxfStorageData([invalidToken], meta); - - // Should only have _meta key - const tokenKeys = Object.keys(result).filter((k) => k.startsWith("_") && k !== "_meta"); - expect(tokenKeys.length).toBe(0); - }); -}); - -// ========================================== -// parseTxfStorageData Tests -// ========================================== - -describe("parseTxfStorageData", () => { - it("should parse valid storage data", () => { - const storageData: TxfStorageData = { - _meta: { - version: 1, - timestamp: Date.now(), - address: "0x123", - ipnsName: "ipns-test", - formatVersion: "2.0", - }, - ["_" + "a".repeat(64)]: validTxfToken, - }; - - const result = parseTxfStorageData(storageData); - - expect(result.tokens.length).toBe(1); - expect(result.meta).toBeDefined(); - expect(result.validationErrors.length).toBe(0); - }); - - it("should return errors for non-object data", () => { - const result = parseTxfStorageData("not an object"); - - expect(result.tokens.length).toBe(0); - expect(result.validationErrors.length).toBeGreaterThan(0); - }); - - it("should extract nametag if present", () => { - const storageData = { - _meta: { - version: 1, - timestamp: Date.now(), - address: "0x123", - ipnsName: "ipns-test", - formatVersion: "2.0", - }, - _nametag: { - name: "testuser", - token: validTxfToken, // Must be a valid token, not empty object - timestamp: Date.now(), - format: "1.0", - version: "1.0", - }, - }; - - const result = parseTxfStorageData(storageData); - - expect(result.nametag).toBeDefined(); - expect(result.nametag?.name).toBe("testuser"); - }); - - it("should report validation errors for invalid tokens", () => { - const storageData = { - _meta: { - version: 1, - timestamp: Date.now(), - address: "0x123", - ipnsName: "ipns-test", - formatVersion: "2.0", - }, - _invalidToken: { notATxfToken: true }, - }; - - const result = parseTxfStorageData(storageData); - - expect(result.validationErrors.length).toBeGreaterThan(0); - }); -}); - -// ========================================== -// parseTxfFile Tests -// ========================================== - -describe("parseTxfFile", () => { - it("should parse valid TXF file content", () => { - const content = { - ["_" + "a".repeat(64)]: validTxfToken, - }; - - const result = parseTxfFile(content); - - expect(result.tokens.length).toBe(1); - expect(result.errors.length).toBe(0); - }); - - it("should return empty for non-object content", () => { - const result = parseTxfFile("not an object"); - - expect(result.tokens.length).toBe(0); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it("should skip non-token keys", () => { - const content = { - _meta: { version: 1 }, - regularKey: { foo: "bar" }, - }; - - const result = parseTxfFile(content); - - expect(result.tokens.length).toBe(0); - }); -}); - -// ========================================== -// Utility Function Tests -// ========================================== - -describe("getTokenId", () => { - it("should extract tokenId from jsonData genesis", () => { - const token = createMockToken(); - const result = getTokenId(token); - - expect(result).toBe("a".repeat(64)); - }); - - it("should fall back to token.id when no jsonData", () => { - const token = createMockToken({ jsonData: undefined, id: "fallback-id" }); - const result = getTokenId(token); - - expect(result).toBe("fallback-id"); - }); -}); - -describe("hasValidTxfData", () => { - it("should return true for valid TXF token", () => { - const token = createMockToken(); - expect(hasValidTxfData(token)).toBe(true); - }); - - it("should return false for token without jsonData", () => { - const token = createMockToken({ jsonData: undefined }); - expect(hasValidTxfData(token)).toBe(false); - }); - - it("should return false for incomplete TXF structure", () => { - const incompleteToken = { genesis: { data: {} } }; - const token = createMockToken({ - jsonData: JSON.stringify(incompleteToken), - }); - expect(hasValidTxfData(token)).toBe(false); - }); -}); - -describe("countCommittedTransactions", () => { - it("should return 0 for token with no transactions", () => { - const token = createMockToken(); - expect(countCommittedTransactions(token)).toBe(0); - }); - - it("should count transactions with proofs", () => { - const tokenWithTxs: TxfToken = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: validTxfToken.genesis.inclusionProof, - }, - { - previousStateHash: "b".repeat(64), - newStateHash: "c".repeat(64), - predicate: "d".repeat(64), - inclusionProof: null, - }, - ], - }; - - const token = createMockToken({ - jsonData: JSON.stringify(tokenWithTxs), - }); - - expect(countCommittedTransactions(token)).toBe(1); - }); -}); - -describe("hasUncommittedTransactions", () => { - it("should return false for token with no transactions", () => { - const token = createMockToken(); - expect(hasUncommittedTransactions(token)).toBe(false); - }); - - it("should return true for token with uncommitted transaction", () => { - const tokenWithUncommitted: TxfToken = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: null, - }, - ], - }; - - const token = createMockToken({ - jsonData: JSON.stringify(tokenWithUncommitted), - }); - - expect(hasUncommittedTransactions(token)).toBe(true); - }); - - it("should return false when all transactions are committed", () => { - const tokenWithCommitted: TxfToken = { - ...validTxfToken, - transactions: [ - { - previousStateHash: "a".repeat(64), - newStateHash: "b".repeat(64), - predicate: "c".repeat(64), - inclusionProof: validTxfToken.genesis.inclusionProof, - }, - ], - }; - - const token = createMockToken({ - jsonData: JSON.stringify(tokenWithCommitted), - }); - - expect(hasUncommittedTransactions(token)).toBe(false); - }); -}); diff --git a/tests/unit/components/wallet/L3/services/utils/SyncModeDetector.test.ts b/tests/unit/components/wallet/L3/services/utils/SyncModeDetector.test.ts deleted file mode 100644 index aeb75256..00000000 --- a/tests/unit/components/wallet/L3/services/utils/SyncModeDetector.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - detectSyncMode, - shouldSkipIpfs, - shouldSkipSpentDetection, - isReadOnlyMode, - shouldAcquireSyncLock, - type SyncModeParams, -} from "../../../../../../../src/components/wallet/L3/services/utils/SyncModeDetector"; -import type { Token } from "../../../../../../../src/components/wallet/L3/data/model"; -import type { OutboxEntry } from "../../../../../../../src/components/wallet/L3/services/types/OutboxTypes"; - -// ========================================== -// Test Fixtures -// ========================================== - -const createMockToken = (id: string): Token => ({ - id, - name: "Test Token", - type: "UCT", - timestamp: Date.now(), - jsonData: "{}", - status: 0, - amount: "1000", - coinId: "ALPHA", - symbol: "ALPHA", - sizeBytes: 100, -} as Token); - -const createMockOutboxEntry = (id: string): OutboxEntry => ({ - id, - tokenId: "test-token-id", - status: "PENDING_IPFS_SYNC", - createdAt: Date.now(), - updatedAt: Date.now(), - retryCount: 0, - recipientAddress: "DIRECT://test", -} as OutboxEntry); - -// ========================================== -// detectSyncMode Tests -// ========================================== - -describe("detectSyncMode", () => { - describe("Precedence Order (Section 6.1)", () => { - it("should return LOCAL when local=true (highest precedence)", () => { - const params: SyncModeParams = { - local: true, - nametag: true, - incomingTokens: [createMockToken("1")], - outboxTokens: [createMockOutboxEntry("1")], - }; - expect(detectSyncMode(params)).toBe("LOCAL"); - }); - - it("should return LOCAL when circuit breaker is active", () => { - const params: SyncModeParams = { - nametag: true, - incomingTokens: [createMockToken("1")], - circuitBreaker: { - localModeActive: true, - consecutiveConflicts: 5, - consecutiveIpfsFailures: 0, - }, - }; - expect(detectSyncMode(params)).toBe("LOCAL"); - }); - - it("should return NAMETAG when nametag=true and not LOCAL", () => { - const params: SyncModeParams = { - nametag: true, - incomingTokens: [createMockToken("1")], - }; - expect(detectSyncMode(params)).toBe("NAMETAG"); - }); - - it("should return FAST when incomingTokens non-empty and not LOCAL/NAMETAG", () => { - const params: SyncModeParams = { - incomingTokens: [createMockToken("1")], - }; - expect(detectSyncMode(params)).toBe("FAST"); - }); - - it("should return FAST when outboxTokens non-empty and not LOCAL/NAMETAG", () => { - const params: SyncModeParams = { - outboxTokens: [createMockOutboxEntry("1")], - }; - expect(detectSyncMode(params)).toBe("FAST"); - }); - - it("should return FAST when both incomingTokens AND outboxTokens non-empty", () => { - const params: SyncModeParams = { - incomingTokens: [createMockToken("1")], - outboxTokens: [createMockOutboxEntry("1")], - }; - expect(detectSyncMode(params)).toBe("FAST"); - }); - - it("should return NORMAL when no special conditions (default)", () => { - const params: SyncModeParams = {}; - expect(detectSyncMode(params)).toBe("NORMAL"); - }); - }); - - describe("Edge Cases", () => { - it("should return NORMAL when incomingTokens is empty array", () => { - const params: SyncModeParams = { - incomingTokens: [], - }; - expect(detectSyncMode(params)).toBe("NORMAL"); - }); - - it("should return NORMAL when outboxTokens is empty array", () => { - const params: SyncModeParams = { - outboxTokens: [], - }; - expect(detectSyncMode(params)).toBe("NORMAL"); - }); - - it("should return NORMAL when incomingTokens is null", () => { - const params: SyncModeParams = { - incomingTokens: null, - }; - expect(detectSyncMode(params)).toBe("NORMAL"); - }); - - it("should return NORMAL when circuitBreaker.localModeActive is false", () => { - const params: SyncModeParams = { - circuitBreaker: { - localModeActive: false, - consecutiveConflicts: 0, - consecutiveIpfsFailures: 0, - }, - }; - expect(detectSyncMode(params)).toBe("NORMAL"); - }); - - it("should handle local=false explicitly", () => { - const params: SyncModeParams = { - local: false, - }; - expect(detectSyncMode(params)).toBe("NORMAL"); - }); - - it("should handle nametag=false explicitly", () => { - const params: SyncModeParams = { - nametag: false, - }; - expect(detectSyncMode(params)).toBe("NORMAL"); - }); - }); -}); - -// ========================================== -// shouldSkipIpfs Tests -// ========================================== - -describe("shouldSkipIpfs", () => { - it("should return true for LOCAL mode", () => { - expect(shouldSkipIpfs("LOCAL")).toBe(true); - }); - - it("should return false for NAMETAG mode", () => { - expect(shouldSkipIpfs("NAMETAG")).toBe(false); - }); - - it("should return false for FAST mode", () => { - expect(shouldSkipIpfs("FAST")).toBe(false); - }); - - it("should return false for NORMAL mode", () => { - expect(shouldSkipIpfs("NORMAL")).toBe(false); - }); -}); - -// ========================================== -// shouldSkipSpentDetection Tests -// ========================================== - -describe("shouldSkipSpentDetection", () => { - it("should return true for LOCAL mode", () => { - expect(shouldSkipSpentDetection("LOCAL")).toBe(true); - }); - - it("should return true for FAST mode", () => { - expect(shouldSkipSpentDetection("FAST")).toBe(true); - }); - - it("should return false for NAMETAG mode", () => { - expect(shouldSkipSpentDetection("NAMETAG")).toBe(false); - }); - - it("should return false for NORMAL mode", () => { - expect(shouldSkipSpentDetection("NORMAL")).toBe(false); - }); -}); - -// ========================================== -// isReadOnlyMode Tests -// ========================================== - -describe("isReadOnlyMode", () => { - it("should return true for NAMETAG mode", () => { - expect(isReadOnlyMode("NAMETAG")).toBe(true); - }); - - it("should return false for LOCAL mode", () => { - expect(isReadOnlyMode("LOCAL")).toBe(false); - }); - - it("should return false for FAST mode", () => { - expect(isReadOnlyMode("FAST")).toBe(false); - }); - - it("should return false for NORMAL mode", () => { - expect(isReadOnlyMode("NORMAL")).toBe(false); - }); -}); - -// ========================================== -// shouldAcquireSyncLock Tests -// ========================================== - -describe("shouldAcquireSyncLock", () => { - it("should return false for NAMETAG mode (no lock needed for read-only)", () => { - expect(shouldAcquireSyncLock("NAMETAG")).toBe(false); - }); - - it("should return true for LOCAL mode", () => { - expect(shouldAcquireSyncLock("LOCAL")).toBe(true); - }); - - it("should return true for FAST mode", () => { - expect(shouldAcquireSyncLock("FAST")).toBe(true); - }); - - it("should return true for NORMAL mode", () => { - expect(shouldAcquireSyncLock("NORMAL")).toBe(true); - }); -}); diff --git a/tests/unit/components/wallet/L3/types/SyncTypes.test.ts b/tests/unit/components/wallet/L3/types/SyncTypes.test.ts deleted file mode 100644 index f884401e..00000000 --- a/tests/unit/components/wallet/L3/types/SyncTypes.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - createDefaultCircuitBreakerState, - createDefaultSyncOperationStats, - createDefaultTokenInventoryStats, - type CircuitBreakerState, - type SyncOperationStats, - type TokenInventoryStats, - type SyncMode, - type SyncStatus, - type SyncErrorCode, - type InvalidReasonCode, -} from "../../../../../../src/components/wallet/L3/types/SyncTypes"; - -// ========================================== -// createDefaultCircuitBreakerState Tests -// ========================================== - -describe("createDefaultCircuitBreakerState", () => { - it("should create state with localModeActive=false", () => { - const state = createDefaultCircuitBreakerState(); - expect(state.localModeActive).toBe(false); - }); - - it("should create state with zero consecutive conflicts", () => { - const state = createDefaultCircuitBreakerState(); - expect(state.consecutiveConflicts).toBe(0); - }); - - it("should create state with zero consecutive IPFS failures", () => { - const state = createDefaultCircuitBreakerState(); - expect(state.consecutiveIpfsFailures).toBe(0); - }); - - it("should not include optional timestamp fields by default", () => { - const state = createDefaultCircuitBreakerState(); - expect(state.localModeActivatedAt).toBeUndefined(); - expect(state.nextRecoveryAttempt).toBeUndefined(); - expect(state.lastConflictTimestamp).toBeUndefined(); - }); - - it("should return a new object each time", () => { - const state1 = createDefaultCircuitBreakerState(); - const state2 = createDefaultCircuitBreakerState(); - expect(state1).not.toBe(state2); - expect(state1).toEqual(state2); - }); -}); - -// ========================================== -// createDefaultSyncOperationStats Tests -// ========================================== - -describe("createDefaultSyncOperationStats", () => { - it("should create stats with all counters at zero", () => { - const stats = createDefaultSyncOperationStats(); - expect(stats.tokensImported).toBe(0); - expect(stats.tokensRemoved).toBe(0); - expect(stats.tokensUpdated).toBe(0); - expect(stats.conflictsResolved).toBe(0); - expect(stats.tokensValidated).toBe(0); - expect(stats.tombstonesAdded).toBe(0); - }); - - it("should return a new object each time", () => { - const stats1 = createDefaultSyncOperationStats(); - const stats2 = createDefaultSyncOperationStats(); - expect(stats1).not.toBe(stats2); - expect(stats1).toEqual(stats2); - }); - - it("should have all required fields", () => { - const stats = createDefaultSyncOperationStats(); - const requiredFields: (keyof SyncOperationStats)[] = [ - "tokensImported", - "tokensRemoved", - "tokensUpdated", - "conflictsResolved", - "tokensValidated", - "tombstonesAdded", - ]; - for (const field of requiredFields) { - expect(stats).toHaveProperty(field); - } - }); -}); - -// ========================================== -// createDefaultTokenInventoryStats Tests -// ========================================== - -describe("createDefaultTokenInventoryStats", () => { - it("should create stats with all folder counts at zero", () => { - const stats = createDefaultTokenInventoryStats(); - expect(stats.activeTokens).toBe(0); - expect(stats.sentTokens).toBe(0); - expect(stats.outboxTokens).toBe(0); - expect(stats.invalidTokens).toBe(0); - expect(stats.nametagTokens).toBe(0); - expect(stats.tombstoneCount).toBe(0); - }); - - it("should return a new object each time", () => { - const stats1 = createDefaultTokenInventoryStats(); - const stats2 = createDefaultTokenInventoryStats(); - expect(stats1).not.toBe(stats2); - expect(stats1).toEqual(stats2); - }); - - it("should have all required fields per spec Section 3.1", () => { - const stats = createDefaultTokenInventoryStats(); - const requiredFields: (keyof TokenInventoryStats)[] = [ - "activeTokens", - "sentTokens", - "outboxTokens", - "invalidTokens", - "nametagTokens", - "tombstoneCount", - ]; - for (const field of requiredFields) { - expect(stats).toHaveProperty(field); - } - }); -}); - -// ========================================== -// Type Guard Tests (TypeScript compilation validation) -// ========================================== - -describe("SyncMode type", () => { - it("should accept valid sync modes", () => { - const modes: SyncMode[] = ["LOCAL", "NAMETAG", "FAST", "NORMAL"]; - expect(modes).toHaveLength(4); - }); -}); - -describe("SyncStatus type", () => { - it("should accept valid sync statuses", () => { - const statuses: SyncStatus[] = [ - "SUCCESS", - "PARTIAL_SUCCESS", - "LOCAL_ONLY", - "NAMETAG_ONLY", - "ERROR", - ]; - expect(statuses).toHaveLength(5); - }); -}); - -describe("SyncErrorCode type", () => { - it("should accept valid error codes", () => { - const codes: SyncErrorCode[] = [ - "IPFS_UNAVAILABLE", - "IPNS_PUBLISH_FAILED", - "IPNS_RESOLUTION_FAILED", - "AGGREGATOR_UNREACHABLE", - "PROOF_FETCH_FAILED", - "VALIDATION_FAILED", - "INTEGRITY_FAILURE", - "CONFLICT_LOOP", - "PARTIAL_OPERATION", - "STORAGE_ERROR", - "UNKNOWN", - ]; - expect(codes).toHaveLength(11); - }); -}); - -describe("InvalidReasonCode type", () => { - it("should accept valid reason codes per spec Section 3.3", () => { - const codes: InvalidReasonCode[] = [ - "SDK_VALIDATION", - "INTEGRITY_FAILURE", - "NAMETAG_MISMATCH", - "MISSING_FIELDS", - "OWNERSHIP_MISMATCH", - "PROOF_MISMATCH", - ]; - expect(codes).toHaveLength(6); - }); -}); - -// ========================================== -// CircuitBreakerState Structure Tests -// ========================================== - -describe("CircuitBreakerState structure", () => { - it("should support LOCAL mode activation", () => { - const state: CircuitBreakerState = { - localModeActive: true, - localModeActivatedAt: Date.now(), - nextRecoveryAttempt: Date.now() + 3600000, // 1 hour - consecutiveConflicts: 5, - consecutiveIpfsFailures: 10, - }; - - expect(state.localModeActive).toBe(true); - expect(state.localModeActivatedAt).toBeGreaterThan(0); - expect(state.nextRecoveryAttempt).toBeGreaterThan(state.localModeActivatedAt!); - }); - - it("should support conflict tracking", () => { - const state: CircuitBreakerState = { - localModeActive: false, - consecutiveConflicts: 3, - lastConflictTimestamp: Date.now(), - consecutiveIpfsFailures: 0, - }; - - expect(state.consecutiveConflicts).toBe(3); - expect(state.lastConflictTimestamp).toBeGreaterThan(0); - }); -}); diff --git a/tests/unit/config/storageKeys.test.ts b/tests/unit/config/storageKeys.test.ts index eaa69991..1e6b5138 100644 --- a/tests/unit/config/storageKeys.test.ts +++ b/tests/unit/config/storageKeys.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { STORAGE_KEYS, STORAGE_KEY_GENERATORS, - STORAGE_KEY_PREFIXES, clearAllSphereData, } from "../../../src/config/storageKeys"; @@ -26,21 +25,14 @@ describe("STORAGE_KEYS", () => { expect(uniqueValues.size).toBe(values.length); }); - it("should contain expected wallet keys", () => { - expect(STORAGE_KEYS.UNIFIED_WALLET_MNEMONIC).toBe("sphere_wallet_mnemonic"); - expect(STORAGE_KEYS.UNIFIED_WALLET_MASTER).toBe("sphere_wallet_master"); - expect(STORAGE_KEYS.UNIFIED_WALLET_CHAINCODE).toBe("sphere_wallet_chaincode"); - }); - it("should contain expected UI keys", () => { expect(STORAGE_KEYS.THEME).toBe("sphere_theme"); - expect(STORAGE_KEYS.WALLET_ACTIVE_LAYER).toBe("sphere_wallet_active_layer"); expect(STORAGE_KEYS.WELCOME_ACCEPTED).toBe("sphere_welcome_accepted"); }); - it("should contain expected Nostr keys", () => { - expect(STORAGE_KEYS.NOSTR_LAST_SYNC).toBe("sphere_nostr_last_sync"); - expect(STORAGE_KEYS.NOSTR_PROCESSED_EVENTS).toBe("sphere_nostr_processed_events"); + it("should contain expected dev keys", () => { + expect(STORAGE_KEYS.DEV_AGGREGATOR_URL).toBe("sphere_dev_aggregator_url"); + expect(STORAGE_KEYS.DEV_SKIP_TRUST_BASE).toBe("sphere_dev_skip_trust_base"); }); }); @@ -49,30 +41,6 @@ describe("STORAGE_KEYS", () => { // ========================================== describe("STORAGE_KEY_GENERATORS", () => { - describe("walletByAddress", () => { - it("should generate correct key format", () => { - const address = "abc123"; - const key = STORAGE_KEY_GENERATORS.walletByAddress(address); - - expect(key).toBe("sphere_wallet_abc123"); - }); - - it("should handle empty address", () => { - const key = STORAGE_KEY_GENERATORS.walletByAddress(""); - - expect(key).toBe("sphere_wallet_"); - }); - }); - - describe("l1WalletByKey", () => { - it("should generate correct key format", () => { - const walletKey = "main"; - const key = STORAGE_KEY_GENERATORS.l1WalletByKey(walletKey); - - expect(key).toBe("sphere_l1_wallet_main"); - }); - }); - describe("agentMemory", () => { it("should generate correct key format", () => { const userId = "user1"; @@ -91,94 +59,6 @@ describe("STORAGE_KEY_GENERATORS", () => { expect(key).toBe("sphere_agent_chat_messages:session123"); }); }); - - describe("ipfsVersion", () => { - it("should generate correct key format", () => { - const ipnsName = "k51qzi5uqu5d..."; - const key = STORAGE_KEY_GENERATORS.ipfsVersion(ipnsName); - - expect(key).toBe("sphere_ipfs_version_k51qzi5uqu5d..."); - }); - }); - - describe("ipfsLastCid", () => { - it("should generate correct key format", () => { - const ipnsName = "k51qzi5uqu5d..."; - const key = STORAGE_KEY_GENERATORS.ipfsLastCid(ipnsName); - - expect(key).toBe("sphere_ipfs_last_cid_k51qzi5uqu5d..."); - }); - }); - - describe("ipfsPendingIpns", () => { - it("should generate correct key format", () => { - const ipnsName = "k51test"; - const key = STORAGE_KEY_GENERATORS.ipfsPendingIpns(ipnsName); - - expect(key).toBe("sphere_ipfs_pending_ipns_k51test"); - }); - }); - - describe("ipfsLastSeq", () => { - it("should generate correct key format", () => { - const ipnsName = "k51test"; - const key = STORAGE_KEY_GENERATORS.ipfsLastSeq(ipnsName); - - expect(key).toBe("sphere_ipfs_last_seq_k51test"); - }); - }); - - describe("ipfsChatVersion", () => { - it("should generate correct key format", () => { - const ipnsName = "k51chat"; - const key = STORAGE_KEY_GENERATORS.ipfsChatVersion(ipnsName); - - expect(key).toBe("sphere_ipfs_chat_version_k51chat"); - }); - }); - - describe("ipfsChatCid", () => { - it("should generate correct key format", () => { - const ipnsName = "k51chat"; - const key = STORAGE_KEY_GENERATORS.ipfsChatCid(ipnsName); - - expect(key).toBe("sphere_ipfs_chat_cid_k51chat"); - }); - }); - - describe("ipfsChatSeq", () => { - it("should generate correct key format", () => { - const ipnsName = "k51chat"; - const key = STORAGE_KEY_GENERATORS.ipfsChatSeq(ipnsName); - - expect(key).toBe("sphere_ipfs_chat_seq_k51chat"); - }); - }); -}); - -// ========================================== -// Test: STORAGE_KEY_PREFIXES -// ========================================== - -describe("STORAGE_KEY_PREFIXES", () => { - it("should have APP prefix as sphere_", () => { - expect(STORAGE_KEY_PREFIXES.APP).toBe("sphere_"); - }); - - it("should have all prefixes start with sphere_", () => { - const prefixes = Object.values(STORAGE_KEY_PREFIXES); - - for (const prefix of prefixes) { - expect(prefix).toMatch(/^sphere_/); - } - }); - - it("should have expected prefix values", () => { - expect(STORAGE_KEY_PREFIXES.WALLET_ADDRESS).toBe("sphere_wallet_"); - expect(STORAGE_KEY_PREFIXES.L1_WALLET).toBe("sphere_l1_wallet_"); - expect(STORAGE_KEY_PREFIXES.AGENT_MEMORY).toBe("sphere_agent_memory:"); - expect(STORAGE_KEY_PREFIXES.AGENT_CHAT_MESSAGES).toBe("sphere_agent_chat_messages:"); - }); }); // ========================================== @@ -218,8 +98,8 @@ describe("clearAllSphereData", () => { it("should remove all sphere_* keys", () => { // Setup: add some sphere keys localStorageMock["sphere_theme"] = "dark"; - localStorageMock["sphere_wallet_mnemonic"] = "encrypted_data"; - localStorageMock["sphere_nostr_last_sync"] = "1234567890"; + localStorageMock["sphere_chat_messages"] = "data"; + localStorageMock["sphere_agent_chat_sessions"] = "sessions"; // Setup: add non-sphere key (should NOT be removed) localStorageMock["other_app_key"] = "some_value"; @@ -228,8 +108,8 @@ describe("clearAllSphereData", () => { // Verify sphere keys are removed expect(localStorageMock["sphere_theme"]).toBeUndefined(); - expect(localStorageMock["sphere_wallet_mnemonic"]).toBeUndefined(); - expect(localStorageMock["sphere_nostr_last_sync"]).toBeUndefined(); + expect(localStorageMock["sphere_chat_messages"]).toBeUndefined(); + expect(localStorageMock["sphere_agent_chat_sessions"]).toBeUndefined(); // Verify non-sphere key is preserved expect(localStorageMock["other_app_key"]).toBe("some_value"); @@ -241,15 +121,13 @@ describe("clearAllSphereData", () => { it("should remove dynamically generated keys", () => { // Setup: add dynamic keys - localStorageMock["sphere_wallet_abc123"] = "wallet_data"; + localStorageMock["sphere_agent_memory:user1:activity1"] = "memory_data"; localStorageMock["sphere_agent_chat_messages:session1"] = "messages"; - localStorageMock["sphere_ipfs_version_k51..."] = "5"; clearAllSphereData(); - expect(localStorageMock["sphere_wallet_abc123"]).toBeUndefined(); + expect(localStorageMock["sphere_agent_memory:user1:activity1"]).toBeUndefined(); expect(localStorageMock["sphere_agent_chat_messages:session1"]).toBeUndefined(); - expect(localStorageMock["sphere_ipfs_version_k51..."]).toBeUndefined(); }); it("should log the number of cleared keys", () => { @@ -268,66 +146,3 @@ describe("clearAllSphereData", () => { consoleSpy.mockRestore(); }); }); - -// ========================================== -// Test: Key consistency -// ========================================== - -describe("Key consistency", () => { - it("should have matching static keys and prefixes", () => { - // WALLET_ADDRESS prefix should match walletByAddress generator - const generatedKey = STORAGE_KEY_GENERATORS.walletByAddress("test"); - expect(generatedKey.startsWith(STORAGE_KEY_PREFIXES.WALLET_ADDRESS)).toBe(true); - }); - - it("should have matching L1_WALLET prefix and generator", () => { - const generatedKey = STORAGE_KEY_GENERATORS.l1WalletByKey("main"); - expect(generatedKey.startsWith(STORAGE_KEY_PREFIXES.L1_WALLET)).toBe(true); - }); - - it("should have matching AGENT_CHAT_MESSAGES prefix and generator", () => { - const generatedKey = STORAGE_KEY_GENERATORS.agentChatMessages("session1"); - expect(generatedKey.startsWith(STORAGE_KEY_PREFIXES.AGENT_CHAT_MESSAGES)).toBe(true); - }); - - it("should have matching IPFS prefixes and generators", () => { - const ipnsName = "k51test"; - - expect( - STORAGE_KEY_GENERATORS.ipfsVersion(ipnsName).startsWith(STORAGE_KEY_PREFIXES.IPFS_VERSION) - ).toBe(true); - - expect( - STORAGE_KEY_GENERATORS.ipfsLastCid(ipnsName).startsWith(STORAGE_KEY_PREFIXES.IPFS_LAST_CID) - ).toBe(true); - - expect( - STORAGE_KEY_GENERATORS.ipfsPendingIpns(ipnsName).startsWith(STORAGE_KEY_PREFIXES.IPFS_PENDING_IPNS) - ).toBe(true); - - expect( - STORAGE_KEY_GENERATORS.ipfsLastSeq(ipnsName).startsWith(STORAGE_KEY_PREFIXES.IPFS_LAST_SEQ) - ).toBe(true); - }); - - it("should have matching IPFS chat prefixes and generators", () => { - const ipnsName = "k51chat"; - - expect( - STORAGE_KEY_GENERATORS.ipfsChatVersion(ipnsName).startsWith(STORAGE_KEY_PREFIXES.IPFS_CHAT_VERSION) - ).toBe(true); - - expect( - STORAGE_KEY_GENERATORS.ipfsChatCid(ipnsName).startsWith(STORAGE_KEY_PREFIXES.IPFS_CHAT_CID) - ).toBe(true); - - expect( - STORAGE_KEY_GENERATORS.ipfsChatSeq(ipnsName).startsWith(STORAGE_KEY_PREFIXES.IPFS_CHAT_SEQ) - ).toBe(true); - }); - - it("should have matching AGENT_MEMORY prefix and generator", () => { - const generatedKey = STORAGE_KEY_GENERATORS.agentMemory("user1", "activity1"); - expect(generatedKey.startsWith(STORAGE_KEY_PREFIXES.AGENT_MEMORY)).toBe(true); - }); -}); diff --git a/tests/unit/hooks/useGlobalSyncStatus.test.ts b/tests/unit/hooks/useGlobalSyncStatus.test.ts index 9ece5cea..d2834d2c 100644 --- a/tests/unit/hooks/useGlobalSyncStatus.test.ts +++ b/tests/unit/hooks/useGlobalSyncStatus.test.ts @@ -1,453 +1,17 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { renderHook } from "@testing-library/react"; -import type { SyncStep } from "../../../src/components/agents/shared/ChatHistoryIpfsService"; - -// ========================================== -// Mock Setup -// ========================================== - -// Mock ChatHistoryIpfsService -const mockGetStatus = vi.fn(() => ({ - initialized: false, - isSyncing: false, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, -})); - -const mockOnStatusChange = vi.fn(() => { - // Return unsubscribe function - return () => {}; -}); - -vi.mock("../../../src/components/agents/shared/ChatHistoryIpfsService", () => ({ - getChatHistoryIpfsService: vi.fn(() => ({ - getStatus: mockGetStatus, - onStatusChange: mockOnStatusChange, - })), -})); - -// Mock IpfsStorageService -const mockIsCurrentlySyncing = vi.fn(() => false); - -vi.mock("../../../src/components/wallet/L3/services/IpfsStorageService", () => ({ - IpfsStorageService: { - getInstance: vi.fn(() => ({ - isCurrentlySyncing: mockIsCurrentlySyncing, - })), - }, -})); - -// Mock IdentityManager -vi.mock("../../../src/components/wallet/L3/services/IdentityManager", () => ({ - IdentityManager: { - getInstance: vi.fn(() => ({})), - }, -})); - -// Import after mocking -import { - useGlobalSyncStatus, - waitForAllSyncsToComplete, -} from "../../../src/hooks/useGlobalSyncStatus"; - -// ========================================== -// useGlobalSyncStatus Tests -// ========================================== +import { useGlobalSyncStatus } from "../../../src/hooks/useGlobalSyncStatus"; describe("useGlobalSyncStatus", () => { - beforeEach(() => { - vi.clearAllMocks(); - - vi.stubGlobal("window", { - ...globalThis.window, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - - // Reset mock return values - mockGetStatus.mockReturnValue({ - initialized: false, - isSyncing: false, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - mockIsCurrentlySyncing.mockReturnValue(false); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - // ========================================== - // Basic State Tests - // ========================================== - - describe("initial state", () => { - it("should return initial sync status", () => { - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.chatSyncing).toBe(false); - expect(result.current.tokenSyncing).toBe(false); - expect(result.current.isAnySyncing).toBe(false); - }); - - it("should return idle chat step", () => { - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.chatStep).toBe("idle"); - }); - - it("should return 'All data synced' message when not syncing", () => { - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.statusMessage).toBe("All data synced"); - }); - }); - - // ========================================== - // Chat Syncing Tests - // ========================================== - - describe("chat syncing", () => { - it("should detect active chat sync", () => { - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: true, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "uploading" as SyncStep, - }); - - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.chatSyncing).toBe(true); - expect(result.current.isAnySyncing).toBe(true); - }); - - it("should detect pending chat sync (debounce period)", () => { - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: false, - hasPendingSync: true, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.chatSyncing).toBe(true); - expect(result.current.isAnySyncing).toBe(true); - }); - - it("should show correct status message for each sync step", () => { - const steps: Array<{ step: string; expected: string }> = [ - { step: "initializing", expected: "Initializing..." }, - { step: "resolving-ipns", expected: "Resolving chat history..." }, - { step: "fetching-content", expected: "Fetching chat history..." }, - { step: "importing-data", expected: "Importing chat data..." }, - { step: "building-data", expected: "Preparing chat data..." }, - { step: "uploading", expected: "Uploading chat history..." }, - { step: "publishing-ipns", expected: "Publishing chat to network..." }, - ]; - - for (const { step, expected } of steps) { - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: true, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: step as SyncStep, - }); - - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.statusMessage).toBe(expected); - } - }); - - it("should show 'Preparing to sync chat...' for pending sync in idle state", () => { - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: false, - hasPendingSync: true, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.statusMessage).toBe("Preparing to sync chat..."); - }); - }); - - // ========================================== - // Token Syncing Tests - // ========================================== - - describe("token syncing", () => { - it("should detect token sync from service", () => { - mockIsCurrentlySyncing.mockReturnValue(true); - - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.tokenSyncing).toBe(true); - expect(result.current.isAnySyncing).toBe(true); - }); - - it("should show token syncing in status message", () => { - mockIsCurrentlySyncing.mockReturnValue(true); - - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.statusMessage).toContain("Syncing tokens..."); - }); - }); - - // ========================================== - // Combined Sync Status Tests - // ========================================== - - describe("combined sync status", () => { - it("should show both services syncing", () => { - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: true, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "uploading" as SyncStep, - }); - mockIsCurrentlySyncing.mockReturnValue(true); + it("should return not syncing", () => { + const { result } = renderHook(() => useGlobalSyncStatus()); - const { result } = renderHook(() => useGlobalSyncStatus()); - - expect(result.current.chatSyncing).toBe(true); - expect(result.current.tokenSyncing).toBe(true); - expect(result.current.isAnySyncing).toBe(true); - expect(result.current.statusMessage).toContain("Uploading chat history..."); - expect(result.current.statusMessage).toContain("Syncing tokens..."); - }); - - it("should detect syncing when only one service is active", () => { - // Only chat syncing - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: true, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "uploading" as SyncStep, - }); - mockIsCurrentlySyncing.mockReturnValue(false); - - const { result: result1 } = renderHook(() => useGlobalSyncStatus()); - expect(result1.current.isAnySyncing).toBe(true); - - // Only token syncing - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: false, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - mockIsCurrentlySyncing.mockReturnValue(true); - - const { result: result2 } = renderHook(() => useGlobalSyncStatus()); - expect(result2.current.isAnySyncing).toBe(true); - }); - }); - - // ========================================== - // Event Subscription Tests - // ========================================== - - describe("event subscriptions", () => { - it("should subscribe to chat status changes", () => { - renderHook(() => useGlobalSyncStatus()); - - expect(mockOnStatusChange).toHaveBeenCalled(); - }); - - it("should subscribe to token sync events", () => { - renderHook(() => useGlobalSyncStatus()); - - expect(window.addEventListener).toHaveBeenCalledWith( - "ipfs-storage-event", - expect.any(Function) - ); - }); - - it("should cleanup subscriptions on unmount", () => { - const { unmount } = renderHook(() => useGlobalSyncStatus()); - - unmount(); - - expect(window.removeEventListener).toHaveBeenCalledWith( - "ipfs-storage-event", - expect.any(Function) - ); - }); - }); -}); - -// ========================================== -// waitForAllSyncsToComplete Tests -// ========================================== - -describe("waitForAllSyncsToComplete", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - - mockGetStatus.mockReturnValue({ - initialized: false, - isSyncing: false, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - mockIsCurrentlySyncing.mockReturnValue(false); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("should resolve immediately when no sync in progress", async () => { - const resultPromise = waitForAllSyncsToComplete(); - - // Advance timer to trigger first check - await vi.advanceTimersByTimeAsync(0); - - const result = await resultPromise; - expect(result).toBe(true); - }); - - it("should wait for chat sync to complete", async () => { - // Start with syncing - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: true, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "uploading" as SyncStep, - }); - - const resultPromise = waitForAllSyncsToComplete(); - - // First check - still syncing - await vi.advanceTimersByTimeAsync(500); - - // Complete sync - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: false, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - - // Second check - should resolve - await vi.advanceTimersByTimeAsync(500); - - const result = await resultPromise; - expect(result).toBe(true); - }); - - it("should wait for pending sync (debounce period)", async () => { - // Start with pending sync - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: false, - hasPendingSync: true, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - - const resultPromise = waitForAllSyncsToComplete(); - - // First check - still has pending - await vi.advanceTimersByTimeAsync(500); - - // Clear pending - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: false, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - - // Second check - should resolve - await vi.advanceTimersByTimeAsync(500); - - const result = await resultPromise; - expect(result).toBe(true); - }); - - it("should timeout after specified duration", async () => { - // Sync never completes - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: true, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "uploading" as SyncStep, - }); - - const resultPromise = waitForAllSyncsToComplete(2000); // 2 second timeout - - // Advance past timeout - await vi.advanceTimersByTimeAsync(2500); - - const result = await resultPromise; - expect(result).toBe(false); + expect(result.current.isAnySyncing).toBe(false); }); - it("should use default timeout of 60 seconds", async () => { - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: true, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "uploading" as SyncStep, - }); - - const resultPromise = waitForAllSyncsToComplete(); - - // Advance to just before default timeout - await vi.advanceTimersByTimeAsync(59000); - - // Sync completes - mockGetStatus.mockReturnValue({ - initialized: true, - isSyncing: false, - hasPendingSync: false, - lastSync: null, - ipnsName: null, - currentStep: "idle" as SyncStep, - }); - - await vi.advanceTimersByTimeAsync(500); + it("should return 'All data synced' message", () => { + const { result } = renderHook(() => useGlobalSyncStatus()); - const result = await resultPromise; - expect(result).toBe(true); + expect(result.current.statusMessage).toBe("All data synced"); }); }); diff --git a/vite.config.ts b/vite.config.ts index 1ea29ae8..a120d6f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -68,12 +68,9 @@ export default defineConfig(({ mode }) => { } }, // Pre-bundle heavy CJS dependencies to speed up dev server cold start - // Note: ESM packages like @unicitylabs/* and helia/* don't need pre-bundling optimizeDeps: { include: [ - 'buffer', 'elliptic', - 'bip39', 'crypto-js', 'framer-motion', 'react',