Changeset View
Changeset View
Standalone View
Standalone View
apps/marlin-wallet/web/src/index.ts
| // Copyright (c) 2025 The Bitcoin developers | // Copyright (c) 2025 The Bitcoin developers | ||||
| // Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||
| // file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||
| // ============================================================================ | // ============================================================================ | ||||
| // IMPORTS | // IMPORTS | ||||
| // ============================================================================ | // ============================================================================ | ||||
| import { Wallet } from 'ecash-wallet'; | import { Wallet } from 'ecash-wallet'; | ||||
| import { ChronikClient, ConnectionStrategy } from 'chronik-client'; | import { ChronikClient, ConnectionStrategy } from 'chronik-client'; | ||||
| import { | import { | ||||
| CoinGeckoProvider, | CoinGeckoProvider, | ||||
| CryptoTicker, | |||||
| Fiat, | Fiat, | ||||
| PriceFetcher, | PriceFetcher, | ||||
| ProviderStrategy, | ProviderStrategy, | ||||
| } from 'ecash-price'; | } from 'ecash-price'; | ||||
| import { TransactionHistoryManager } from './transaction-history'; | import { TransactionHistoryManager } from './transaction-history'; | ||||
| import { | import { | ||||
| PostConsensusFinalizationResult, | PostConsensusFinalizationResult, | ||||
| TransactionManager, | TransactionManager, | ||||
| } from './transaction-manager'; | } from './transaction-manager'; | ||||
| import { | import { | ||||
| sendMessageToBackend, | sendMessageToBackend, | ||||
| webViewLog, | webViewLog, | ||||
| webViewError, | webViewError, | ||||
| isReactNativeWebView, | isReactNativeWebView, | ||||
| } from './common'; | } from './common'; | ||||
| import { satsToXec } from './amount'; | import { atomsToUnit } from './amount'; | ||||
| import { | |||||
| activeCryptoTicker, | |||||
| activeAssetTicker, | |||||
| activeAssetDecimals, | |||||
| activeQuoteCurrency, | |||||
| activeTokenId, | |||||
| allowFiatForActiveAsset, | |||||
| } from './active-asset'; | |||||
| import { getAddress, WalletData } from './wallet'; | import { getAddress, WalletData } from './wallet'; | ||||
| import { storeMnemonic, loadMnemonic, generateMnemonic } from './mnemonic'; | import { storeMnemonic, loadMnemonic, generateMnemonic } from './mnemonic'; | ||||
| import { config } from './config'; | import { config } from './config'; | ||||
| import { parseBip21Uri } from './bip21'; | import { parseBip21Uri } from './bip21'; | ||||
| import { AppSettings, loadSettings } from './settings'; | import { AppSettings, loadSettings } from './settings'; | ||||
| import { Navigation, Screen } from './navigation'; | import { Navigation, Screen } from './navigation'; | ||||
| import { SettingsScreen } from './screen/settings'; | import { SettingsScreen } from './screen/settings'; | ||||
| import { HistoryScreen } from './screen/history'; | import { HistoryScreen } from './screen/history'; | ||||
| import { SendScreen } from './screen/send'; | import { SendScreen } from './screen/send'; | ||||
| import { MainScreen } from './screen/main'; | import { MainScreen } from './screen/main'; | ||||
| import { paybuttonDeepLinkToBip21Uri } from './paybutton'; | import { paybuttonDeepLinkToBip21Uri } from './paybutton'; | ||||
| import { changeLocale, initI18n, t } from './i18n'; | import { changeLocale, initI18n, t } from './i18n'; | ||||
| import { MarlinPriceFetcher } from './price'; | import { MarlinPriceFetcher } from './price'; | ||||
| import { SUPPORTED_ASSETS } from './supported-assets'; | |||||
| // Styles | // Styles | ||||
| import './main.css'; | import './main.css'; | ||||
| // Icons | // Icons | ||||
| import backArrowIcon from './assets/back-arrow.svg'; | import backArrowIcon from './assets/back-arrow.svg'; | ||||
| import marlin from './assets/marlin.svg'; | import marlin from './assets/marlin.svg'; | ||||
| import editIcon from './assets/edit.svg'; | import editIcon from './assets/edit.svg'; | ||||
| ▲ Show 20 Lines • Show All 279 Lines • ▼ Show 20 Lines | try { | ||||
| ); | ); | ||||
| if (!tx) { | if (!tx) { | ||||
| webViewError( | webViewError( | ||||
| `Failed to add pending mempool transaction: ${txid}`, | `Failed to add pending mempool transaction: ${txid}`, | ||||
| ); | ); | ||||
| break; | break; | ||||
| } | } | ||||
| webViewLog( | webViewLog( | ||||
| `Added pending transaction: ${satsToXec( | `Added pending transaction: ${atomsToUnit( | ||||
| tx.amountSats, | tx.amountAtoms, | ||||
| )} ${config.ticker} for tx ${txid}`, | activeAssetDecimals(), | ||||
| )} ${activeAssetTicker()} for tx ${txid}`, | |||||
| ); | ); | ||||
| break; | break; | ||||
| } | } | ||||
| case 'TX_CONFIRMED': | case 'TX_CONFIRMED': | ||||
| if ( | if ( | ||||
| !transactionManager.isPendingTransaction(txid) | !transactionManager.isPendingTransaction(txid) | ||||
| ) { | ) { | ||||
| // If the pending transaction doesn't exist, we | // If the pending transaction doesn't exist, we | ||||
| Show All 13 Lines | try { | ||||
| ); | ); | ||||
| if (!tx) { | if (!tx) { | ||||
| webViewError( | webViewError( | ||||
| `Failed to add pending confirmed transaction: ${txid}`, | `Failed to add pending confirmed transaction: ${txid}`, | ||||
| ); | ); | ||||
| break; | break; | ||||
| } | } | ||||
| webViewLog( | webViewLog( | ||||
| `Added pending confirmed transaction: ${satsToXec( | `Added pending confirmed transaction: ${atomsToUnit( | ||||
| tx.amountSats, | tx.amountAtoms, | ||||
| )} ${config.ticker} for tx ${txid}`, | activeAssetDecimals(), | ||||
| )} ${activeAssetTicker()} for tx ${txid}`, | |||||
| ); | ); | ||||
| } | } | ||||
| } | } | ||||
| break; | break; | ||||
| case 'TX_FINALIZED': | case 'TX_FINALIZED': | ||||
| switch (msg.finalizationReasonType) { | switch (msg.finalizationReasonType) { | ||||
| case 'TX_FINALIZATION_REASON_PRE_CONSENSUS': | case 'TX_FINALIZATION_REASON_PRE_CONSENSUS': | ||||
| await transactionManager.finalizePreConsensus( | await transactionManager.finalizePreConsensus( | ||||
| ▲ Show 20 Lines • Show All 104 Lines • ▼ Show 20 Lines | try { | ||||
| await Promise.race([syncPromise, timeoutPromise]); | await Promise.race([syncPromise, timeoutPromise]); | ||||
| if (transactionManager) { | if (transactionManager) { | ||||
| transactionManager.sync(); | transactionManager.sync(); | ||||
| } | } | ||||
| // Update the display | // Update the display | ||||
| const pricePerXec = await priceFetcher?.current({ | const pricePerXec = allowFiatForActiveAsset() | ||||
| source: CryptoTicker.XEC, | ? await priceFetcher?.current({ | ||||
| source: activeQuoteCurrency(), | |||||
| quote: appSettings.fiatCurrency, | quote: appSettings.fiatCurrency, | ||||
| }); | }) | ||||
| : null; | |||||
| if (mainScreen) { | if (mainScreen) { | ||||
| mainScreen.updateTransitionalBalance( | mainScreen.updateTransitionalBalance( | ||||
| transactionManager.getTransitionalBalanceSats(), | transactionManager.getTransitionalBalanceSats(), | ||||
| pricePerXec, | pricePerXec, | ||||
| ); | ); | ||||
| mainScreen.updateAvailableBalanceDisplay( | mainScreen.updateAvailableBalanceDisplay( | ||||
| 0, | 0, | ||||
| satsToXec(transactionManager.getAvailableBalanceSats()), | atomsToUnit( | ||||
| transactionManager.getAvailableBalanceSats(), | |||||
| activeAssetDecimals(), | |||||
| ), | |||||
| pricePerXec, | pricePerXec, | ||||
| false, | false, | ||||
| ); | ); | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| webViewError('Failed to sync wallet:', error); | webViewError('Failed to sync wallet:', error); | ||||
| // Show user-friendly error message | // Show user-friendly error message | ||||
| Show All 26 Lines | try { | ||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| // ============================================================================ | // ============================================================================ | ||||
| // INITIALIZATION FUNCTIONS | // INITIALIZATION FUNCTIONS | ||||
| // ============================================================================ | // ============================================================================ | ||||
| function refreshStaticTickerLabels(): void { | |||||
| const ticker = activeAssetTicker(); | |||||
| const ids = [ | |||||
| 'ticker-balance', | |||||
| 'ticker-label', | |||||
| 'ticker-slider-min', | |||||
| 'ticker-slider-max', | |||||
| ]; | |||||
| for (const elementId of ids) { | |||||
| const el = document.getElementById(elementId); | |||||
| if (el) { | |||||
| el.textContent = ticker; | |||||
| } | |||||
| } | |||||
| } | |||||
| // Initialize the app when DOM is ready | // Initialize the app when DOM is ready | ||||
| async function initializeApp() { | async function initializeApp() { | ||||
| webViewLog('Initializing app...'); | webViewLog('Initializing app...'); | ||||
| // Load saved settings first to get locale preference | // Load saved settings first to get locale preference | ||||
| appSettings = loadSettings(); | appSettings = loadSettings(); | ||||
| // Initialize i18n with locale from settings | // Initialize i18n with locale from settings | ||||
| await initI18n(appSettings.locale); | await initI18n(appSettings.locale); | ||||
| // Detect if running in standalone web browser (not in mobile WebView) | // Detect if running in standalone web browser (not in mobile WebView) | ||||
| // In mobile app, the WebView has transparent background and shows React Native gradient | // In mobile app, the WebView has transparent background and shows React Native gradient | ||||
| // In standalone web, we need to apply a CSS gradient background | // In standalone web, we need to apply a CSS gradient background | ||||
| if (!isReactNativeWebView()) { | if (!isReactNativeWebView()) { | ||||
| document.body.classList.add('standalone-web'); | document.body.classList.add('standalone-web'); | ||||
| } | } | ||||
| // Initialize navigation | // Initialize navigation | ||||
| navigation = new Navigation(); | navigation = new Navigation(); | ||||
| // Initialize ticker symbols in HTML | refreshStaticTickerLabels(); | ||||
| const tickerElements = [ | |||||
| 'ticker-balance', | |||||
| 'ticker-label', | |||||
| 'ticker-slider-min', | |||||
| 'ticker-slider-max', | |||||
| ]; | |||||
| for (const elementId of tickerElements) { | |||||
| const el = document.getElementById(elementId); | |||||
| if (el) { | |||||
| el.textContent = config.ticker; | |||||
| } | |||||
| } | |||||
| // Set the back arrow icons | // Set the back arrow icons | ||||
| for (const iconEl of document.querySelectorAll('.back-arrow-icon')) { | for (const iconEl of document.querySelectorAll('.back-arrow-icon')) { | ||||
| (iconEl as HTMLImageElement).src = backArrowIcon; | (iconEl as HTMLImageElement).src = backArrowIcon; | ||||
| } | } | ||||
| // Set the PayButton logo source | // Set the PayButton logo source | ||||
| const paybuttonLogoEl = document.getElementById( | const paybuttonLogoEl = document.getElementById( | ||||
| ▲ Show 20 Lines • Show All 58 Lines • ▼ Show 20 Lines | async function initializeApp() { | ||||
| // Initialize price API with CoinGecko provider | // Initialize price API with CoinGecko provider | ||||
| // Cache prices for 60 seconds to reduce API calls | // Cache prices for 60 seconds to reduce API calls | ||||
| priceFetcher = new MarlinPriceFetcher( | priceFetcher = new MarlinPriceFetcher( | ||||
| new PriceFetcher( | new PriceFetcher( | ||||
| [new CoinGeckoProvider()], | [new CoinGeckoProvider()], | ||||
| ProviderStrategy.FALLBACK, | ProviderStrategy.FALLBACK, | ||||
| 60 * 1000, | 60 * 1000, | ||||
| ), | ), | ||||
| [CryptoTicker.XEC], | SUPPORTED_ASSETS.map(asset => asset.quoteCurrency), | ||||
| ); | ); | ||||
| try { | try { | ||||
| await loadWallet(); | await loadWallet(); | ||||
| } catch (error) { | } catch (error) { | ||||
| webViewError('Failed to load the wallet:', error); | webViewError('Failed to load the wallet:', error); | ||||
| sendMessageToBackend('CLOSE_APP', undefined); | sendMessageToBackend('CLOSE_APP', undefined); | ||||
| return; | return; | ||||
| } | } | ||||
| // At this point the wallet is loaded | // At this point the wallet is loaded | ||||
| mainScreen = new MainScreen({ | mainScreen = new MainScreen({ | ||||
| ecashWallet, | ecashWallet, | ||||
| navigation, | navigation, | ||||
| appSettings, | appSettings, | ||||
| priceFetcher, | priceFetcher, | ||||
| primaryBalanceTicker: activeCryptoTicker(), | |||||
| primaryBalanceDecimals: activeAssetDecimals(), | |||||
| onQRScanResult: async result => { | onQRScanResult: async result => { | ||||
| if (sendScreen) { | if (sendScreen) { | ||||
| await sendScreen.show(result); | await sendScreen.show(result); | ||||
| } | } | ||||
| }, | }, | ||||
| onAssetSwitched: async () => { | |||||
| if (!transactionManager || !ecashWallet || !mainScreen) { | |||||
| return; | |||||
| } | |||||
| transactionManager.setTokenId(activeTokenId()); | |||||
| await syncWallet(); | |||||
| await mainScreen.updateWallet( | |||||
| ecashWallet, | |||||
| transactionManager.getAvailableBalanceSats(), | |||||
| transactionManager.getTransitionalBalanceSats(), | |||||
| ); | |||||
| }, | |||||
| refreshStaticTickerLabels, | |||||
| }); | }); | ||||
| // Initialize TransactionManager | // Initialize TransactionManager | ||||
| transactionManager = new TransactionManager({ | transactionManager = new TransactionManager({ | ||||
| ecashWallet, | ecashWallet, | ||||
| chronik, | chronik, | ||||
| tokenId: activeTokenId(), | |||||
| onBalanceChange: async ( | onBalanceChange: async ( | ||||
| fromAvailableBalanceSats: number, | fromAvailableBalanceAtoms: number, | ||||
| toAvailableBalanceSats: number, | toAvailableBalanceAtoms: number, | ||||
| transitionalBalanceSats: number, | transitionalBalanceAtoms: number, | ||||
| ) => { | ) => { | ||||
| if (!mainScreen) { | if (!mainScreen) { | ||||
| return; | return; | ||||
| } | } | ||||
| const pricePerXec = await priceFetcher?.current({ | const pricePerXec = allowFiatForActiveAsset() | ||||
| source: CryptoTicker.XEC, | ? await priceFetcher?.current({ | ||||
| source: activeQuoteCurrency(), | |||||
| quote: appSettings.fiatCurrency, | quote: appSettings.fiatCurrency, | ||||
| }); | }) | ||||
| : null; | |||||
| mainScreen.updateTransitionalBalance( | mainScreen.updateTransitionalBalance( | ||||
| transitionalBalanceSats, | transitionalBalanceAtoms, | ||||
| pricePerXec, | pricePerXec, | ||||
| ); | ); | ||||
| const fromXec = satsToXec(fromAvailableBalanceSats); | const fromUnits = atomsToUnit( | ||||
| const toXec = satsToXec(toAvailableBalanceSats); | fromAvailableBalanceAtoms, | ||||
| activeAssetDecimals(), | |||||
| ); | |||||
| const toUnits = atomsToUnit( | |||||
| toAvailableBalanceAtoms, | |||||
| activeAssetDecimals(), | |||||
| ); | |||||
| mainScreen.updateAvailableBalanceDisplay( | mainScreen.updateAvailableBalanceDisplay( | ||||
| fromXec, | fromUnits, | ||||
| toXec, | toUnits, | ||||
| pricePerXec, | pricePerXec, | ||||
| fromXec !== toXec, | fromUnits !== toUnits, | ||||
| ); | ); | ||||
| }, | }, | ||||
| }); | }); | ||||
| historyScreen = new HistoryScreen({ | historyScreen = new HistoryScreen({ | ||||
| transactionHistory, | transactionHistory, | ||||
| navigation, | navigation, | ||||
| }); | }); | ||||
| settingsScreen = new SettingsScreen({ | settingsScreen = new SettingsScreen({ | ||||
| appSettings, | appSettings, | ||||
| wallet, | wallet, | ||||
| navigation, | navigation, | ||||
| }); | }); | ||||
| // Register callbacks | // Register callbacks | ||||
| settingsScreen.onPrimaryBalanceChange(async () => { | settingsScreen.onPrimaryBalanceChange(async () => { | ||||
| // Refresh balance display with new primary/secondary order | |||||
| if (ecashWallet && mainScreen && transactionManager) { | if (ecashWallet && mainScreen && transactionManager) { | ||||
| const currentXec = satsToXec( | const currentPrimary = atomsToUnit( | ||||
| transactionManager.getAvailableBalanceSats(), | transactionManager.getAvailableBalanceSats(), | ||||
| activeAssetDecimals(), | |||||
| ); | ); | ||||
| mainScreen.updateAvailableBalanceDisplay( | const pricePerXec = | ||||
| currentXec, | allowFiatForActiveAsset() && priceFetcher | ||||
| currentXec, | ? await priceFetcher.current({ | ||||
| await priceFetcher?.current({ | source: activeQuoteCurrency(), | ||||
| source: CryptoTicker.XEC, | |||||
| quote: appSettings.fiatCurrency, | quote: appSettings.fiatCurrency, | ||||
| }), | }) | ||||
| : null; | |||||
| mainScreen.updateAvailableBalanceDisplay( | |||||
| currentPrimary, | |||||
| currentPrimary, | |||||
| pricePerXec, | |||||
| false, | false, | ||||
| ); | ); | ||||
| } | } | ||||
| }); | }); | ||||
| settingsScreen.onFiatCurrencyChange(async () => { | settingsScreen.onFiatCurrencyChange(async () => { | ||||
| // Refresh balance display with new fiat currency | |||||
| if (ecashWallet && mainScreen && transactionManager) { | if (ecashWallet && mainScreen && transactionManager) { | ||||
| const currentXec = satsToXec( | const currentPrimary = atomsToUnit( | ||||
| transactionManager.getAvailableBalanceSats(), | transactionManager.getAvailableBalanceSats(), | ||||
| activeAssetDecimals(), | |||||
| ); | ); | ||||
| mainScreen.updateAvailableBalanceDisplay( | const pricePerXec = | ||||
| currentXec, | allowFiatForActiveAsset() && priceFetcher | ||||
| currentXec, | ? await priceFetcher.current({ | ||||
| await priceFetcher?.current({ | source: activeQuoteCurrency(), | ||||
| source: CryptoTicker.XEC, | |||||
| quote: appSettings.fiatCurrency, | quote: appSettings.fiatCurrency, | ||||
| }), | }) | ||||
| : null; | |||||
| mainScreen.updateAvailableBalanceDisplay( | |||||
| currentPrimary, | |||||
| currentPrimary, | |||||
| pricePerXec, | |||||
| false, | false, | ||||
| ); | ); | ||||
| } | } | ||||
| }); | }); | ||||
| settingsScreen.onLocaleChange(async (newLocale: string) => { | settingsScreen.onLocaleChange(async (newLocale: string) => { | ||||
| // Change locale and reload translations | // Change locale and reload translations | ||||
| changeLocale(newLocale); | changeLocale(newLocale); | ||||
| // Refresh the address display to override the "generating..." message | // Refresh the address display to override the "generating..." message | ||||
| if (mainScreen) { | if (mainScreen) { | ||||
| mainScreen.updateAddressDisplay(); | mainScreen.updateAddressDisplay(); | ||||
| if (ecashWallet && transactionManager) { | if (ecashWallet && transactionManager) { | ||||
| const currentXec = satsToXec( | const currentPrimary = atomsToUnit( | ||||
| transactionManager.getAvailableBalanceSats(), | transactionManager.getAvailableBalanceSats(), | ||||
| activeAssetDecimals(), | |||||
| ); | ); | ||||
| mainScreen.updateAvailableBalanceDisplay( | const pricePerXec = | ||||
| currentXec, | allowFiatForActiveAsset() && priceFetcher | ||||
| currentXec, | ? await priceFetcher.current({ | ||||
| await priceFetcher?.current({ | source: activeQuoteCurrency(), | ||||
| source: CryptoTicker.XEC, | |||||
| quote: appSettings.fiatCurrency, | quote: appSettings.fiatCurrency, | ||||
| }), | }) | ||||
| : null; | |||||
| mainScreen.updateAvailableBalanceDisplay( | |||||
| currentPrimary, | |||||
| currentPrimary, | |||||
| pricePerXec, | |||||
| false, | false, | ||||
| ); | ); | ||||
| } | } | ||||
| } | } | ||||
| webViewLog(`Locale changed to ${newLocale}`); | webViewLog(`Locale changed to ${newLocale}`); | ||||
| }); | }); | ||||
| settingsScreen.onMnemonicSaved(async (mnemonic: string) => { | settingsScreen.onMnemonicSaved(async (mnemonic: string) => { | ||||
| Show All 23 Lines | async function initializeApp() { | ||||
| const backBtn = document.getElementById('back-btn'); | const backBtn = document.getElementById('back-btn'); | ||||
| if (backBtn) { | if (backBtn) { | ||||
| backBtn.addEventListener('click', () => { | backBtn.addEventListener('click', () => { | ||||
| navigation.showScreen(Screen.Main); | navigation.showScreen(Screen.Main); | ||||
| }); | }); | ||||
| } | } | ||||
| mainScreen.initAssetPicker(); | |||||
| // Hide loading screen on success | // Hide loading screen on success | ||||
| hideLoadingScreen(); | hideLoadingScreen(); | ||||
| } | } | ||||
| // Listen for payment requests from React Native | // Listen for payment requests from React Native | ||||
| async function handlePaymentRequest(event: any) { | async function handlePaymentRequest(event: any) { | ||||
| try { | try { | ||||
| const message = JSON.parse(event.data); | const message = JSON.parse(event.data); | ||||
| ▲ Show 20 Lines • Show All 58 Lines • Show Last 20 Lines | |||||