Changeset View
Changeset View
Standalone View
Standalone View
apps/marlin-wallet/web/src/screen/send.ts
| // Copyright (c) 2026 The Bitcoin developers | // Copyright (c) 2026 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. | ||||
| import { Navigation, Screen } from '../navigation'; | import { Navigation, Screen } from '../navigation'; | ||||
| import { AppSettings } from '../settings'; | import { AppSettings } from '../settings'; | ||||
| import { DEFAULT_DUST_SATS } from 'ecash-lib'; | import { DEFAULT_DUST_SATS } from 'ecash-lib'; | ||||
| import { ChronikClient } from 'chronik-client'; | import { ChronikClient } from 'chronik-client'; | ||||
| import { Wallet } from 'ecash-wallet'; | import { BuiltAction, Wallet } from 'ecash-wallet'; | ||||
| import { CryptoTicker, formatPrice } from 'ecash-price'; | import { CryptoTicker, formatPrice } from 'ecash-price'; | ||||
| import type { MarlinPriceFetcher } from '../price'; | import type { MarlinPriceFetcher } from '../price'; | ||||
| import { | import { | ||||
| atomsToUnit, | |||||
| calculateMaxSpendableAmount, | calculateMaxSpendableAmount, | ||||
| calculateMaxSpendableTokenDisplay, | |||||
| estimateTransactionFee, | estimateTransactionFee, | ||||
| satsToXec, | estimateTokenSendFee, | ||||
| unitToAtoms, | |||||
| } from '../amount'; | } from '../amount'; | ||||
| import { buildAction } from '../wallet'; | import { buildAction, buildTokenSendAction } from '../wallet'; | ||||
| import { | |||||
| activeAssetDefinition, | |||||
| activeAssetTicker, | |||||
| activeCryptoTicker, | |||||
| activeAssetDecimals, | |||||
| activeTokenId, | |||||
| allowFiatForActiveAsset, | |||||
| activeQuoteCurrency, | |||||
| } from '../active-asset'; | |||||
| import { isValidECashAddress } from '../address'; | import { isValidECashAddress } from '../address'; | ||||
| import { parseBip21Uri, Bip21ParseResult } from '../bip21'; | import { parseBip21Uri, Bip21ParseResult } from '../bip21'; | ||||
| import { isPayButtonTransaction } from '../paybutton'; | import { isPayButtonTransaction } from '../paybutton'; | ||||
| import { config } from '../config'; | import { config } from '../config'; | ||||
| import { XEC_ASSET } from '../supported-assets'; | |||||
| import { sendMessageToBackend, webViewLog, webViewError } from '../common'; | import { sendMessageToBackend, webViewLog, webViewError } from '../common'; | ||||
| import { t } from '../i18n'; | import { t } from '../i18n'; | ||||
| const MIN_AMOUNT_XEC = satsToXec(Number(DEFAULT_DUST_SATS)); | const MIN_AMOUNT_XEC = atomsToUnit( | ||||
| Number(DEFAULT_DUST_SATS), | |||||
| XEC_ASSET.decimals, | |||||
| ); | |||||
| export interface SendScreenParams { | export interface SendScreenParams { | ||||
| ecashWallet: Wallet; | ecashWallet: Wallet; | ||||
| navigation: Navigation; | navigation: Navigation; | ||||
| appSettings: AppSettings; | appSettings: AppSettings; | ||||
| priceFetcher: MarlinPriceFetcher | null; | priceFetcher: MarlinPriceFetcher | null; | ||||
| syncWallet: () => Promise<void>; | syncWallet: () => Promise<void>; | ||||
| } | } | ||||
| export class SendScreen { | export class SendScreen { | ||||
| private params: SendScreenParams; | private params: SendScreenParams; | ||||
| private returnToBrowser: boolean = false; | private returnToBrowser: boolean = false; | ||||
| private sendOpReturnRaw: string | undefined = undefined; | private sendOpReturnRaw: string | undefined = undefined; | ||||
| private currentPricePerXec: number | null = null; | private currentActiveAssetPrice: number | null = null; | ||||
| private useXecPrimary: boolean = true; | private currentXecPrice: number | null = null; | ||||
| private useActiveAssetPrimary: boolean = true; | |||||
| private amountDigits: number = 2; | private amountDigits: number = 2; | ||||
| private minSpendablePrimary: number = MIN_AMOUNT_XEC; | private minSpendablePrimary: number = MIN_AMOUNT_XEC; | ||||
| private maxSpendablePrimary: number = MIN_AMOUNT_XEC; | private maxSpendablePrimary: number = MIN_AMOUNT_XEC; | ||||
| private ui: { | private ui: { | ||||
| recipientInput: HTMLInputElement; | recipientInput: HTMLInputElement; | ||||
| sendAmountInput: HTMLInputElement; | sendAmountInput: HTMLInputElement; | ||||
| amountSlider: HTMLInputElement; | amountSlider: HTMLInputElement; | ||||
| feeDisplay: HTMLElement; | feeDisplay: HTMLElement; | ||||
| Show All 23 Lines | export class SendScreen { | ||||
| ): Promise<void> { | ): Promise<void> { | ||||
| webViewLog('Showing send screen'); | webViewLog('Showing send screen'); | ||||
| this.returnToBrowser = returnToBrowser; | this.returnToBrowser = returnToBrowser; | ||||
| // Always refresh the available utxos before showing the send screen | // Always refresh the available utxos before showing the send screen | ||||
| await this.params.syncWallet(); | await this.params.syncWallet(); | ||||
| const decimals = activeAssetDecimals(); | |||||
| const tokenId = activeTokenId(); | |||||
| if (tokenId !== null) { | |||||
| // Not supported for now | |||||
| this.sendOpReturnRaw = undefined; | |||||
| } | |||||
| // Fetch current price once upon screen opening | // Fetch current price once upon screen opening | ||||
| this.currentPricePerXec = | this.currentActiveAssetPrice = allowFiatForActiveAsset() | ||||
| (await this.params.priceFetcher?.current({ | ? await this.params.priceFetcher?.current({ | ||||
| source: activeQuoteCurrency(), | |||||
| quote: this.params.appSettings.fiatCurrency, | |||||
| }) | |||||
| : null; | |||||
| this.currentXecPrice = await this.params.priceFetcher?.current({ | |||||
| source: CryptoTicker.XEC, | source: CryptoTicker.XEC, | ||||
| quote: this.params.appSettings.fiatCurrency, | quote: this.params.appSettings.fiatCurrency, | ||||
| })) ?? null; | }); | ||||
| // Since the price and the settings won't change during the lifetime of | // Since the price and the settings won't change during the lifetime of | ||||
| // the screen, we can cache some parameters for simplicity. | // the screen, we can cache some parameters for simplicity. | ||||
| this.useXecPrimary = | this.useActiveAssetPrimary = | ||||
| this.params.appSettings.primaryBalanceType === 'XEC' || | this.params.appSettings.primaryBalanceType === 'XEC' || | ||||
| this.currentPricePerXec === null; | this.currentActiveAssetPrice === null; | ||||
| this.ui.sendAmountInput.step = this.useXecPrimary | this.ui.sendAmountInput.step = this.useActiveAssetPrimary | ||||
| ? '0.01' | ? decimals > 0 | ||||
| ? `0.${'0'.repeat(Math.max(0, decimals - 1))}1` | |||||
| : '1' | |||||
| : '0.00000001'; | : '0.00000001'; | ||||
| this.ui.amountSlider.step = this.ui.sendAmountInput.step; | this.ui.amountSlider.step = this.ui.sendAmountInput.step; | ||||
| this.amountDigits = this.useXecPrimary ? 2 : 8; | this.amountDigits = this.useActiveAssetPrimary ? decimals : 8; | ||||
| this.ui.tickerLabel.textContent = this.useXecPrimary | this.ui.tickerLabel.textContent = this.useActiveAssetPrimary | ||||
| ? config.ticker | ? activeAssetTicker() | ||||
| : this.params.appSettings.fiatCurrency.toString().toUpperCase(); | : this.params.appSettings.fiatCurrency.toString().toUpperCase(); | ||||
| // Compute the min and max spendable amounts, update the ui accordingly | |||||
| this.minSpendablePrimary = | this.minSpendablePrimary = | ||||
| Math.ceil( | Math.ceil( | ||||
| this.xecToPrimary(MIN_AMOUNT_XEC) * | this.xecToPrimary( | ||||
| Math.pow(10, this.amountDigits), | !tokenId ? MIN_AMOUNT_XEC : 1 / Math.pow(10, decimals), | ||||
| ) * Math.pow(10, this.amountDigits), | |||||
| ) / Math.pow(10, this.amountDigits); | ) / Math.pow(10, this.amountDigits); | ||||
| this.maxSpendablePrimary = | this.maxSpendablePrimary = | ||||
| Math.floor( | Math.floor( | ||||
| this.xecToPrimary( | this.xecToPrimary( | ||||
| calculateMaxSpendableAmount(this.params.ecashWallet), | tokenId | ||||
| ? calculateMaxSpendableTokenDisplay( | |||||
| this.params.ecashWallet, | |||||
| tokenId, | |||||
| ) | |||||
| : calculateMaxSpendableAmount(this.params.ecashWallet), | |||||
| ) * Math.pow(10, this.amountDigits), | ) * Math.pow(10, this.amountDigits), | ||||
| ) / Math.pow(10, this.amountDigits); | ) / Math.pow(10, this.amountDigits); | ||||
| // Update amount and slider input min and max attributes (in primary currency) | // Update amount and slider input min and max attributes (in primary currency) | ||||
| this.ui.sendAmountInput.min = this.minSpendablePrimary.toFixed( | this.ui.sendAmountInput.min = this.minSpendablePrimary.toFixed( | ||||
| this.amountDigits, | this.amountDigits, | ||||
| ); | ); | ||||
| this.ui.sendAmountInput.max = this.maxSpendablePrimary.toFixed( | this.ui.sendAmountInput.max = this.maxSpendablePrimary.toFixed( | ||||
| Show All 20 Lines | ): Promise<void> { | ||||
| this.ui.recipientInput.classList.remove('invalid'); | this.ui.recipientInput.classList.remove('invalid'); | ||||
| this.ui.recipientInput.classList.add('valid'); // Mark as valid (already validated) | this.ui.recipientInput.classList.add('valid'); // Mark as valid (already validated) | ||||
| } else { | } else { | ||||
| this.ui.recipientInput.value = ''; | this.ui.recipientInput.value = ''; | ||||
| this.ui.recipientInput.classList.remove('valid', 'invalid'); | this.ui.recipientInput.classList.remove('valid', 'invalid'); | ||||
| this.ui.recipientInput.removeAttribute('readonly'); // Allow editing for manual entry | this.ui.recipientInput.removeAttribute('readonly'); // Allow editing for manual entry | ||||
| } | } | ||||
| // Initialize amount field | if ( | ||||
| if (prefillOptions?.sats !== undefined && prefillOptions.sats > 0) { | !tokenId && | ||||
| prefillOptions?.sats !== undefined && | |||||
| prefillOptions.sats > 0 | |||||
| ) { | |||||
| const amountPrimary = this.xecToPrimary( | const amountPrimary = this.xecToPrimary( | ||||
| satsToXec(prefillOptions.sats), | atomsToUnit(prefillOptions.sats, XEC_ASSET.decimals), | ||||
| ); | ); | ||||
| this.ui.sendAmountInput.value = amountPrimary.toFixed( | this.ui.sendAmountInput.value = amountPrimary.toFixed( | ||||
| this.amountDigits, | this.amountDigits, | ||||
| ); | ); | ||||
| this.ui.amountSlider.value = amountPrimary.toFixed( | this.ui.amountSlider.value = amountPrimary.toFixed( | ||||
| this.amountDigits, | this.amountDigits, | ||||
| ); | ); | ||||
| this.ui.sendAmountInput.setAttribute('readonly', 'readonly'); | this.ui.sendAmountInput.setAttribute('readonly', 'readonly'); | ||||
| this.ui.amountSlider.disabled = true; | this.ui.amountSlider.disabled = true; | ||||
| } else { | } else { | ||||
| this.ui.sendAmountInput.value = this.minSpendablePrimary.toFixed( | this.ui.sendAmountInput.value = this.minSpendablePrimary.toFixed( | ||||
| this.amountDigits, | this.amountDigits, | ||||
| ); | ); | ||||
| this.ui.amountSlider.value = this.minSpendablePrimary.toFixed( | this.ui.amountSlider.value = this.minSpendablePrimary.toFixed( | ||||
| this.amountDigits, | this.amountDigits, | ||||
| ); | ); | ||||
| this.ui.sendAmountInput.removeAttribute('readonly'); | this.ui.sendAmountInput.removeAttribute('readonly'); | ||||
| this.ui.amountSlider.disabled = false; | this.ui.amountSlider.disabled = false; | ||||
| } | } | ||||
| if (!tokenId) { | |||||
| // Store opReturnRaw for use when sending transaction, only for paybutton transactions | // Store opReturnRaw for use when sending transaction, only for paybutton transactions | ||||
| this.sendOpReturnRaw = | this.sendOpReturnRaw = | ||||
| prefillOptions?.opReturnRaw && | prefillOptions?.opReturnRaw && | ||||
| isPayButtonTransaction(prefillOptions.opReturnRaw) | isPayButtonTransaction(prefillOptions.opReturnRaw) | ||||
| ? prefillOptions.opReturnRaw | ? prefillOptions.opReturnRaw | ||||
| : undefined; | : undefined; | ||||
| } | |||||
| // Setup with current behavior | // Setup with current behavior | ||||
| this.setupHoldToSend(); | this.setupHoldToSend(); | ||||
| // Update the UI elements | // Update the UI elements | ||||
| this.updatePayButtonLogoVisibility(); | this.updatePayButtonLogoVisibility(); | ||||
| this.validateAmountField(); | this.validateAmountField(); | ||||
| this.updateFeeDisplay(); | this.updateFeeDisplay(); | ||||
| ▲ Show 20 Lines • Show All 150 Lines • ▼ Show 20 Lines | ): void { | ||||
| : undefined; | : undefined; | ||||
| this.updatePayButtonLogoVisibility(); | this.updatePayButtonLogoVisibility(); | ||||
| // Set amount if provided | // Set amount if provided | ||||
| if ( | if ( | ||||
| bip21Result.sats !== undefined && | bip21Result.sats !== undefined && | ||||
| bip21Result.sats >= DEFAULT_DUST_SATS | bip21Result.sats >= DEFAULT_DUST_SATS | ||||
| ) { | ) { | ||||
| const amountXec = satsToXec(bip21Result.sats); | const amountXec = atomsToUnit(bip21Result.sats, XEC_ASSET.decimals); | ||||
| const amountPrimary = this.xecToPrimary(amountXec); | const amountPrimary = this.xecToPrimary(amountXec); | ||||
| this.ui.sendAmountInput.value = amountPrimary.toFixed( | this.ui.sendAmountInput.value = amountPrimary.toFixed( | ||||
| this.amountDigits, | this.amountDigits, | ||||
| ); | ); | ||||
| this.ui.sendAmountInput.setAttribute('readonly', 'readonly'); | this.ui.sendAmountInput.setAttribute('readonly', 'readonly'); | ||||
| this.validateAmountField(); | this.validateAmountField(); | ||||
| this.ui.amountSlider.value = amountPrimary.toFixed( | this.ui.amountSlider.value = amountPrimary.toFixed( | ||||
| this.amountDigits, | this.amountDigits, | ||||
| ); | ); | ||||
| this.ui.amountSlider.disabled = true; | this.ui.amountSlider.disabled = true; | ||||
| } | } | ||||
| // Trigger fee calculation | // Trigger fee calculation | ||||
| this.updateFeeDisplay(); | this.updateFeeDisplay(); | ||||
| } | } | ||||
| // Helper methods for primary/secondary balance conversion | // Helper methods for primary/secondary balance conversion | ||||
| private xecToPrimary(xec: number): number { | private xecToPrimary(xec: number): number { | ||||
| return this.useXecPrimary ? xec : xec * this.currentPricePerXec; | return this.useActiveAssetPrimary | ||||
| ? xec | |||||
| : xec * this.currentActiveAssetPrice; | |||||
| } | } | ||||
| private primaryToXec(primary: number): number { | private primaryToXec(primary: number): number { | ||||
| return this.useXecPrimary ? primary : primary / this.currentPricePerXec; | return this.useActiveAssetPrimary | ||||
| ? primary | |||||
| : primary / this.currentActiveAssetPrice; | |||||
| } | } | ||||
| private formatPrimary(primary: number): string { | private formatPrimary(primary: number): string { | ||||
| return this.useXecPrimary | const cryptoDecimals = activeAssetDecimals(); | ||||
| ? formatPrice(primary, CryptoTicker.XEC, { | return this.useActiveAssetPrimary | ||||
| ? formatPrice(primary, activeCryptoTicker(), { | |||||
| locale: this.params.appSettings.locale, | locale: this.params.appSettings.locale, | ||||
| decimals: 2, | decimals: cryptoDecimals, | ||||
| }) | }) | ||||
| : formatPrice(primary, this.params.appSettings.fiatCurrency, { | : formatPrice(primary, this.params.appSettings.fiatCurrency, { | ||||
| locale: this.params.appSettings.locale, | locale: this.params.appSettings.locale, | ||||
| }); | }); | ||||
| } | } | ||||
| private formatSecondary(xec: number): string | null { | private formatSecondary(cryptoAmount: number): string | null { | ||||
| if (this.currentPricePerXec === null) { | if (this.currentActiveAssetPrice === null) { | ||||
| return null; | return null; | ||||
| } | } | ||||
| return this.useXecPrimary | const cryptoDecimals = activeAssetDecimals(); | ||||
| return this.useActiveAssetPrimary | |||||
| ? formatPrice( | ? formatPrice( | ||||
| xec * this.currentPricePerXec, | cryptoAmount * this.currentActiveAssetPrice, | ||||
| this.params.appSettings.fiatCurrency, | this.params.appSettings.fiatCurrency, | ||||
| { locale: this.params.appSettings.locale }, | { locale: this.params.appSettings.locale }, | ||||
| ) | ) | ||||
| : formatPrice(xec, CryptoTicker.XEC, { | : formatPrice(cryptoAmount, activeCryptoTicker(), { | ||||
| locale: this.params.appSettings.locale, | locale: this.params.appSettings.locale, | ||||
| decimals: 2, | decimals: cryptoDecimals, | ||||
| }); | }); | ||||
| } | } | ||||
| private formatSecondaryXec(xecAmount: number): string | null { | |||||
| if (this.currentXecPrice === null) { | |||||
| return null; | |||||
| } | |||||
| return this.useActiveAssetPrimary | |||||
| ? formatPrice( | |||||
| xecAmount * this.currentXecPrice, | |||||
| this.params.appSettings.fiatCurrency, | |||||
| { locale: this.params.appSettings.locale }, | |||||
| ) | |||||
| : formatPrice(xecAmount, CryptoTicker.XEC, { | |||||
| locale: this.params.appSettings.locale, | |||||
| decimals: XEC_ASSET.decimals, | |||||
| }); | |||||
| } | |||||
| private formatPrimaryXec(xecAmount: number): string { | |||||
| if (this.useActiveAssetPrimary) { | |||||
| return formatPrice(xecAmount, CryptoTicker.XEC, { | |||||
| locale: this.params.appSettings.locale, | |||||
| decimals: XEC_ASSET.decimals, | |||||
| }); | |||||
| } | |||||
| if (this.currentXecPrice === null) { | |||||
| return formatPrice(xecAmount, CryptoTicker.XEC, { | |||||
| locale: this.params.appSettings.locale, | |||||
| decimals: XEC_ASSET.decimals, | |||||
| }); | |||||
| } | |||||
| return formatPrice( | |||||
| xecAmount * this.currentXecPrice, | |||||
| this.params.appSettings.fiatCurrency, | |||||
| { locale: this.params.appSettings.locale }, | |||||
| ); | |||||
| } | |||||
| // Update fee display | // Update fee display | ||||
| private updatePayButtonLogoVisibility(): void { | private updatePayButtonLogoVisibility(): void { | ||||
| if ( | if ( | ||||
| this.sendOpReturnRaw && | this.sendOpReturnRaw && | ||||
| isPayButtonTransaction(this.sendOpReturnRaw) | isPayButtonTransaction(this.sendOpReturnRaw) | ||||
| ) { | ) { | ||||
| this.ui.logoContainer.style.display = 'flex'; | this.ui.logoContainer.style.display = 'flex'; | ||||
| } else { | } else { | ||||
| Show All 10 Lines | private updateFeeDisplay(): void { | ||||
| !isValidECashAddress(recipientAddress) || | !isValidECashAddress(recipientAddress) || | ||||
| isNaN(amountPrimary) || | isNaN(amountPrimary) || | ||||
| amountPrimary <= 0 | amountPrimary <= 0 | ||||
| ) { | ) { | ||||
| this.ui.feeDisplay.style.display = 'none'; | this.ui.feeDisplay.style.display = 'none'; | ||||
| return; | return; | ||||
| } | } | ||||
| // Convert from primary currency to XEC for fee estimation | |||||
| let amountXec = this.primaryToXec(amountPrimary); | |||||
| let errorMessage: string | null = null; | let errorMessage: string | null = null; | ||||
| if (activeTokenId() !== null) { | |||||
| this.updateFeeDisplayToken(recipientAddress, amountPrimary); | |||||
| return; | |||||
| } | |||||
| let amountXec = this.primaryToXec(amountPrimary); | |||||
| // Check for dust threshold | // Check for dust threshold | ||||
| if (amountXec < MIN_AMOUNT_XEC) { | if (amountXec < MIN_AMOUNT_XEC) { | ||||
| errorMessage = t('errors.amountTooSmall'); | errorMessage = t('errors.amountTooSmall'); | ||||
| } | } | ||||
| // Try to estimate fee for the requested amount (include OP_RETURN if present) | // Try to estimate fee for the requested amount (include OP_RETURN if present) | ||||
| let feeEstimateXec = estimateTransactionFee( | let feeEstimateXec = estimateTransactionFee( | ||||
| this.params.ecashWallet, | this.params.ecashWallet, | ||||
| ▲ Show 20 Lines • Show All 79 Lines • ▼ Show 20 Lines | private updateFeeDisplay(): void { | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| `; | `; | ||||
| this.ui.feeDisplay.innerHTML = html; | this.ui.feeDisplay.innerHTML = html; | ||||
| this.ui.feeDisplay.style.display = 'block'; | this.ui.feeDisplay.style.display = 'block'; | ||||
| } | } | ||||
| private updateFeeDisplayToken( | |||||
| recipientAddress: string, | |||||
| amountPrimary: number, | |||||
| ): void { | |||||
| const tokenId = activeTokenId(); | |||||
| if (!tokenId) { | |||||
| this.ui.feeDisplay.style.display = 'none'; | |||||
| return; | |||||
| } | |||||
| let errorMessage: string | null = null; | |||||
| let displayAmountPrimary = amountPrimary; | |||||
| if (displayAmountPrimary < this.minSpendablePrimary) { | |||||
| errorMessage = t('errors.amountTooSmall'); | |||||
| } | |||||
| if (displayAmountPrimary > this.maxSpendablePrimary) { | |||||
| displayAmountPrimary = this.maxSpendablePrimary; | |||||
| errorMessage = t('errors.insufficientFunds'); | |||||
| } | |||||
| const feeEstimate = estimateTokenSendFee( | |||||
| this.params.ecashWallet, | |||||
| recipientAddress, | |||||
| displayAmountPrimary, | |||||
| activeAssetDefinition(), | |||||
| ); | |||||
| if (!feeEstimate && !errorMessage) { | |||||
| errorMessage = t('errors.cannotCoverFee'); | |||||
| } | |||||
| let feeBlockHeading = t('send.transactionDetails'); | |||||
| let feeBlockHeadingClasses = 'title'; | |||||
| if (errorMessage) { | |||||
| this.ui.feeDisplay.classList.add('error'); | |||||
| feeBlockHeading = errorMessage; | |||||
| feeBlockHeadingClasses += ' error'; | |||||
| } else { | |||||
| this.ui.feeDisplay.classList.remove('error'); | |||||
| } | |||||
| const amountPrimaryFormatted = this.formatPrimary(displayAmountPrimary); | |||||
| const amountSecondaryFormatted = this.formatSecondary( | |||||
| this.primaryToXec(displayAmountPrimary), | |||||
| ); | |||||
| const feePrimaryFormatted = | |||||
| feeEstimate !== null | |||||
| ? this.formatPrimaryXec(feeEstimate.feeXEC) | |||||
| : '—'; | |||||
| const feeSecondaryFormatted = | |||||
| feeEstimate !== null | |||||
| ? this.formatSecondaryXec(feeEstimate.feeXEC) | |||||
| : null; | |||||
| const html = `<div class="fee-info"> | |||||
| <div class="fee-item ${feeBlockHeadingClasses}"> | |||||
| ${feeBlockHeading} | |||||
| </div> | |||||
| <div class="fee-item"> | |||||
| <span class="fee-label">${t('send.amount')}:</span> | |||||
| <div class="fee-value"> | |||||
| <span class="fee-value-primary">${amountPrimaryFormatted}</span>${ | |||||
| amountSecondaryFormatted | |||||
| ? `<span class="fee-value-secondary">${amountSecondaryFormatted}</span>` | |||||
| : '' | |||||
| } | |||||
| </div> | |||||
| </div> | |||||
| <div class="fee-item"> | |||||
| <span class="fee-label">${t('send.networkFee')} (${config.ticker}):</span> | |||||
| <div class="fee-value"> | |||||
| <span class="fee-value-primary">${feePrimaryFormatted}</span>${ | |||||
| feeSecondaryFormatted | |||||
| ? `<span class="fee-value-secondary">${feeSecondaryFormatted}</span>` | |||||
| : '' | |||||
| } | |||||
| </div> | |||||
| </div> | |||||
| </div>`; | |||||
| this.ui.feeDisplay.innerHTML = html; | |||||
| this.ui.feeDisplay.style.display = 'block'; | |||||
| } | |||||
| // Amount input handling to prevent more than 2 decimals | // Amount input handling to prevent more than 2 decimals | ||||
| private handleAmountInput(event: Event): void { | private handleAmountInput(event: Event): void { | ||||
| const input = event.target as HTMLInputElement; | const input = event.target as HTMLInputElement; | ||||
| let sanitizedValue = input.value; | let sanitizedValue = input.value; | ||||
| // Allow only numbers and one decimal point | // Allow only numbers and one decimal point | ||||
| sanitizedValue = sanitizedValue.replace(/[^0-9.]/g, ''); | sanitizedValue = sanitizedValue.replace(/[^0-9.]/g, ''); | ||||
| ▲ Show 20 Lines • Show All 245 Lines • ▼ Show 20 Lines | private async validateAndSend(): Promise<void> { | ||||
| // Validate amount | // Validate amount | ||||
| this.validateAmountField(); | this.validateAmountField(); | ||||
| if (this.ui.confirmSendBtn.disabled) { | if (this.ui.confirmSendBtn.disabled) { | ||||
| return; // Amount validation failed | return; // Amount validation failed | ||||
| } | } | ||||
| // All validations passed, proceed with sending | // All validations passed, proceed with sending | ||||
| try { | try { | ||||
| // Input is in primary currency, convert to XEC for sending | |||||
| const amountPrimary = parseFloat(this.ui.sendAmountInput.value); | const amountPrimary = parseFloat(this.ui.sendAmountInput.value); | ||||
| let builtAction: BuiltAction; | |||||
| let sendMessage: string; | |||||
| const tokenId = activeTokenId(); | |||||
| if (tokenId !== null) { | |||||
| let atoms: bigint; | |||||
| try { | |||||
| atoms = BigInt( | |||||
| unitToAtoms(amountPrimary, activeAssetDecimals()), | |||||
| ); | |||||
| } catch { | |||||
| return; | |||||
| } | |||||
| if (atoms <= 0n) { | |||||
| return; | |||||
| } | |||||
| builtAction = buildTokenSendAction( | |||||
| this.params.ecashWallet, | |||||
| address, | |||||
| atoms, | |||||
| activeAssetDefinition(), | |||||
| ).build(); | |||||
| sendMessage = `Sent ${amountPrimary} ${activeAssetTicker()} to ${address}`; | |||||
| } else { | |||||
| const amountXec = this.primaryToXec(amountPrimary); | const amountXec = this.primaryToXec(amountPrimary); | ||||
| // Convert XEC to satoshis (1 XEC = 100 satoshis) | const sats = unitToAtoms(amountXec, activeAssetDecimals()); | ||||
| const sats = Math.round(amountXec * 100); | |||||
| const action = buildAction( | const action = buildAction( | ||||
| this.params.ecashWallet, | this.params.ecashWallet, | ||||
| address, | address, | ||||
| sats, | sats, | ||||
| this.sendOpReturnRaw, | this.sendOpReturnRaw, | ||||
| ); | ); | ||||
| const builtAction = action.build(); | builtAction = action.build(); | ||||
| sendMessage = `Sent ${amountXec} ${config.ticker} to ${address}`; | |||||
| } | |||||
| if ( | if ( | ||||
| this.sendOpReturnRaw && | this.sendOpReturnRaw && | ||||
| isPayButtonTransaction(this.sendOpReturnRaw) | isPayButtonTransaction(this.sendOpReturnRaw) | ||||
| ) { | ) { | ||||
| // For PayButton transactions, we broadcast to the PayButton node first | |||||
| // to reduce the latency. Then we attempt to broadcast to the main node | |||||
| // as well which may fail because the tx might have been relayed already. | |||||
| try { | try { | ||||
| const paybuttonChronik = new ChronikClient([ | const paybuttonChronik = new ChronikClient([ | ||||
| 'https://xec.paybutton.io', | 'https://xec.paybutton.io', | ||||
| ]); | ]); | ||||
| const txsToBroadcast = builtAction.txs.map(tx => | const txsToBroadcast = builtAction.txs.map(tx => | ||||
| tx.toHex(), | tx.toHex(), | ||||
| ); | ); | ||||
| await paybuttonChronik.broadcastTxs(txsToBroadcast); | await paybuttonChronik.broadcastTxs(txsToBroadcast); | ||||
| webViewLog( | webViewLog(`${sendMessage} via PayButton`); | ||||
| `Sent ${amountXec} ${config.ticker} to ${address} via PayButton`, | |||||
| ); | |||||
| } catch (error) { | } catch (error) { | ||||
| webViewError('PayButton broadcast failed,:', error); | webViewError('PayButton broadcast failed,:', error); | ||||
| } | } | ||||
| } | } | ||||
| await builtAction.broadcast(); | await builtAction.broadcast(); | ||||
| webViewLog(`Sent ${amountXec} ${config.ticker} to ${address}`); | webViewLog(sendMessage); | ||||
| } catch (error) { | } catch (error) { | ||||
| webViewError('Failed to send transaction:', error); | webViewError('Failed to send transaction:', error); | ||||
| } finally { | } finally { | ||||
| this.params.navigation.showScreen(Screen.Main); | this.params.navigation.showScreen(Screen.Main); | ||||
| if (this.returnToBrowser) { | if (this.returnToBrowser) { | ||||
| // Send message to native app to return to the previous app (browser) | // Send message to native app to return to the previous app (browser) | ||||
| sendMessageToBackend('RETURN_TO_PREVIOUS_APP', null); | sendMessageToBackend('RETURN_TO_PREVIOUS_APP', null); | ||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| } | } | ||||