From 3973d4649e8a388a88d49bbebb0713b97b4ea129 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:25:23 -0700 Subject: [PATCH 1/8] Fix lint warnings in SwapDetailsCard --- eslint.config.mjs | 4 +-- src/actions/CategoriesActions.ts | 5 ++-- src/components/cards/SwapDetailsCard.tsx | 12 ++++---- src/components/rows/SwapProviderRow.tsx | 8 ++++- .../scenes/SwapConfirmationScene.tsx | 18 +++++++++-- src/components/scenes/SwapCreateScene.tsx | 10 +++++-- src/components/scenes/SwapProcessingScene.tsx | 30 +++++++++++++------ .../scenes/TransactionDetailsScene.tsx | 4 ++- .../themed/ExchangeQuoteComponent.tsx | 8 ++++- 9 files changed, 72 insertions(+), 27 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7dc53841b8d..f92b3884e78 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -160,7 +160,7 @@ export default [ 'src/components/cards/StakingOptionCard.tsx', 'src/components/cards/StakingReturnsCard.tsx', 'src/components/cards/SupportCard.tsx', - 'src/components/cards/SwapDetailsCard.tsx', + 'src/components/cards/TappableAccountCard.tsx', 'src/components/cards/TappableCard.tsx', 'src/components/cards/UnderlinedNumInputCard.tsx', @@ -258,7 +258,7 @@ export default [ 'src/components/rows/EdgeRow.tsx', 'src/components/rows/PaymentMethodRow.tsx', - 'src/components/rows/SwapProviderRow.tsx', + 'src/components/rows/TxCryptoAmountRow.tsx', 'src/components/scenes/ChangeMiningFeeScene.tsx', diff --git a/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index 494d875de17..a3e192b6cbe 100644 --- a/src/actions/CategoriesActions.ts +++ b/src/actions/CategoriesActions.ts @@ -382,8 +382,9 @@ export const getTxActionDisplayInfo = ( ? lstrings.transaction_details_swap_to_subcat_1s : lstrings.transaction_details_swap_from_subcat_1s const walletName = - account.currencyWallets[action.payoutWalletId]?.name ?? - displayName + (action.payoutWalletId != null + ? account.currencyWallets[action.payoutWalletId]?.name + : undefined) ?? displayName edgeCategory = { category: 'transfer', subcategory: sprintf(toFromStr, walletName) diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index a0df0c33f4a..7b93f971f7c 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -58,7 +58,7 @@ const upgradeSwapData = ( return swapData } -export function SwapDetailsCard(props: Props) { +export const SwapDetailsCard: React.FC = props => { const { swapData, transaction, wallet } = props const theme = useTheme() const styles = getStyles(theme) @@ -124,12 +124,12 @@ export function SwapDetailsCard(props: Props) { return } - if (error) showError(error) + if (error != null) showError(error) } ) }) - const handleLink = async () => { + const handleLink = async (): Promise => { if (formattedOrderUri == null) return // Replace {{TXID}} with actual transaction ID if present @@ -140,9 +140,9 @@ export function SwapDetailsCard(props: Props) { if (available) await SafariView.show({ url: formattedOrderUri }) else await Linking.openURL(formattedOrderUri) }) - .catch(error => { + .catch((error: unknown) => { showError(error) - Linking.openURL(formattedOrderUri).catch(err => { + Linking.openURL(formattedOrderUri).catch((err: unknown) => { showError(err) }) }) @@ -192,7 +192,7 @@ export function SwapDetailsCard(props: Props) { ? walletDefaultDenom.symbol : transaction.currencyCode - const createExchangeDataString = (newline: string = '\n') => { + const createExchangeDataString = (newline: string = '\n'): string => { const uniqueIdentifier = memos .map( (memo, index) => diff --git a/src/components/rows/SwapProviderRow.tsx b/src/components/rows/SwapProviderRow.tsx index e94a212fc60..c7521d3ae7c 100644 --- a/src/components/rows/SwapProviderRow.tsx +++ b/src/components/rows/SwapProviderRow.tsx @@ -18,7 +18,13 @@ export const SwapProviderRow: React.FC = (props: Props) => { const { quote } = props const { request, toNativeAmount, fromNativeAmount } = quote const { quoteFor } = request - const { fromWallet, fromTokenId, toWallet, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + // A wallet-to-wallet swap quote always carries a destination wallet; only a + // swap-to-address request (its own flow) omits it. + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } const theme = useTheme() const styles = getStyles(theme) diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 5aba519cc4b..4eb0fe30126 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -127,7 +127,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const { quoteFor } = request const priceImpact = React.useMemo(() => { - const { fromWallet, fromTokenId, toWallet, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } const fromExchangeDenom = getExchangeDenom( fromWallet.currencyConfig, @@ -282,7 +286,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { request } = selectedQuote // Both fromCurrencyCode and toCurrencyCode will exist, since we set them: - const { toWallet, toTokenId, fromWallet, fromTokenId } = request + const { toTokenId, fromWallet, fromTokenId } = request + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } try { dispatch(logEvent('Exchange_Shift_Start')) @@ -556,7 +564,11 @@ const getSwapInfo = ( // Currency conversion tools: // Both fromCurrencyCode and toCurrencyCode will exist, since we set them: const { request } = quote - const { fromWallet, toWallet, fromTokenId, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } // Format from amount: const fromDisplayDenomination = selectDisplayDenom( diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index f42ec8d90b1..2245a6a0116 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -228,6 +228,12 @@ export const SwapCreateScene: React.FC = props => { } const getQuote = (swapRequest: EdgeSwapRequest): void => { + // This scene only builds wallet-to-wallet swap requests, which always carry + // a destination wallet (swap-to-address has its own flow). + const toWallet = swapRequest.toWallet + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } if (exchangeInfo != null) { const disableSrc = checkDisableAsset( exchangeInfo.swap.disableAssets.source, @@ -247,7 +253,7 @@ export const SwapCreateScene: React.FC = props => { const disableDest = checkDisableAsset( exchangeInfo.swap.disableAssets.destination, - swapRequest.toWallet.id, + toWallet.id, toTokenId ) if (disableDest) { @@ -255,7 +261,7 @@ export const SwapCreateScene: React.FC = props => { sprintf( lstrings.swap_token_no_enabled_exchanges_2s, toCurrencyCode, - swapRequest.toWallet.currencyInfo.displayName + toWallet.currencyInfo.displayName ) ) return diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 3305f8a2458..bafcf4eba7e 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -49,10 +49,19 @@ export const SwapProcessingScene: React.FC = (props: Props) => { swapRequest.fromTokenId ) const toDenomination = useDisplayDenom( - swapRequest.toWallet.currencyConfig, + // Wallet-to-wallet swaps always have a destination wallet here; fall back to + // the source config only so this hook stays unconditional. + (swapRequest.toWallet ?? swapRequest.fromWallet).currencyConfig, swapRequest.toTokenId ) + // This scene only processes wallet-to-wallet swap requests, which always + // carry a destination wallet (swap-to-address has its own flow). + const toWallet = swapRequest.toWallet + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } + const doWork = async (isCancelled: () => boolean): Promise => { const quotes = await account.fetchSwapQuotes( swapRequest, @@ -70,7 +79,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { const fromWallet = swapRequest.fromWallet const fromAddresses = await fromWallet.getAddresses({ tokenId: null }) const fromAddress = fromAddresses[0]?.publicAddress - const targetPluginId = swapRequest.toWallet.currencyInfo.pluginId + const targetPluginId = toWallet.currencyInfo.pluginId let matchingWalletId: string | undefined for (const walletId of Object.keys(account.currencyWallets)) { @@ -89,8 +98,8 @@ export const SwapProcessingScene: React.FC = (props: Props) => { } } - let finalToWalletId: string = swapRequest.toWallet.id - let finalToWallet = swapRequest.toWallet + let finalToWalletId: string + let finalToWallet: typeof toWallet let isWalletCreated = false if (matchingWalletId == null) { // If not found, split from the source chain wallet to the destination @@ -165,7 +174,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId } }) @@ -189,7 +198,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId, errorDisplayInfo } @@ -313,7 +322,9 @@ function processSwapQuoteError({ swapRequest.fromTokenId ) const toCurrencyCode = getCurrencyCode( - swapRequest.toWallet, + // Wallet-to-wallet swaps always have a destination wallet here; the + // fallback only keeps the type honest for swap-to-address requests. + swapRequest.toWallet ?? swapRequest.fromWallet, swapRequest.toTokenId ) @@ -362,10 +373,11 @@ function trackSwapError(error: unknown, swapRequest: EdgeSwapRequest): void { swapRequest.fromTokenId ), swapToCurrency: getCurrencyCode( - swapRequest.toWallet, + swapRequest.toWallet ?? swapRequest.fromWallet, swapRequest.toTokenId ), - swapToWalletKind: swapRequest.toWallet.currencyInfo.pluginId, + swapToWalletKind: (swapRequest.toWallet ?? swapRequest.fromWallet) + .currencyInfo.pluginId, swapDirectionType: swapRequest.quoteFor }) // Unsearchable context data: diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 1d17b7a482c..4f5cea8a262 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -771,7 +771,9 @@ const convertActionToSwapData = ( payoutCurrencyCode, payoutTokenId: toAsset.tokenId, payoutNativeAmount: action.toAsset.nativeAmount ?? '0', - payoutWalletId, + // A swap-to-address (private send) has no payout wallet; EdgeTxSwap still + // types this as required, so fall back to an empty id. + payoutWalletId: payoutWalletId ?? '', refundAddress } return out diff --git a/src/components/themed/ExchangeQuoteComponent.tsx b/src/components/themed/ExchangeQuoteComponent.tsx index 70b110a5db6..d287b602362 100644 --- a/src/components/themed/ExchangeQuoteComponent.tsx +++ b/src/components/themed/ExchangeQuoteComponent.tsx @@ -27,7 +27,13 @@ interface Props { export const ExchangeQuote: React.FC = props => { const { fromTo, priceImpact, quote, showFeeWarning } = props const { request, fromNativeAmount, toNativeAmount, networkFee } = quote - const { fromWallet, fromTokenId, toWallet, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + // A wallet-to-wallet swap quote always carries a destination wallet; only a + // swap-to-address request (its own flow) omits it. + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } const theme = useTheme() const styles = getStyles(theme) From 25ae577c372a323b1b2c531df5693cc104e67f63 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:26:41 -0700 Subject: [PATCH 2/8] Handle optional swap destination wallet and payout wallet id edge-core-js swap-to-address makes EdgeSwapRequest.toWallet optional (an address-only toAddressInfo destination replaces it) and makes EdgeTxActionSwap.payoutWalletId and EdgeTxSwap.payoutWalletId optional. Sweep the consumers: wallet-to-wallet swap scenes throw if their destination wallet is missing, savedAction and swap-details readers tolerate a missing payout wallet. --- src/components/cards/SwapDetailsCard.tsx | 10 +++++++--- src/components/scenes/TransactionDetailsScene.tsx | 5 ++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index 7b93f971f7c..6234887f097 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -72,10 +72,14 @@ export const SwapDetailsCard: React.FC = props => { : selectDisplayDenom(state, wallet.currencyConfig, tokenId) ) - // The wallet may have been deleted: + // A swap-to-address payout has no wallet, and the wallet may also have + // been deleted: const account = useSelector(state => state.core.account) const currencyWallets = useWatch(account, 'currencyWallets') - const destinationWallet = currencyWallets[swapData.payoutWalletId] + const destinationWallet = + swapData.payoutWalletId == null + ? undefined + : currencyWallets[swapData.payoutWalletId] const destinationWalletName = destinationWallet == null ? '' : getWalletName(destinationWallet) @@ -180,7 +184,7 @@ export const SwapDetailsCard: React.FC = props => { destinationDenomination.multiplier )(swapData.payoutNativeAmount) const destinationAssetName = - payoutTokenId == null + payoutTokenId == null || destinationWallet == null ? payoutCurrencyCode : `${payoutCurrencyCode} (${ getExchangeDenom(destinationWallet.currencyConfig, null).name diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 4f5cea8a262..2e50d9ee3d1 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -771,9 +771,8 @@ const convertActionToSwapData = ( payoutCurrencyCode, payoutTokenId: toAsset.tokenId, payoutNativeAmount: action.toAsset.nativeAmount ?? '0', - // A swap-to-address (private send) has no payout wallet; EdgeTxSwap still - // types this as required, so fall back to an empty id. - payoutWalletId: payoutWalletId ?? '', + // A swap-to-address (private send) has no payout wallet: + payoutWalletId, refundAddress } return out From 1b385953ce9a290f8e2c1c919dc9d308847cb8c3 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:31:27 -0700 Subject: [PATCH 3/8] Share the swap price-impact calculation and display Extract the swap confirmation scene's price-impact computation and the quote card's colored percentage text into PriceImpactText, so the send scene's quote row can reuse the same delta UI. --- .../scenes/SwapConfirmationScene.tsx | 55 ++------- .../themed/ExchangeQuoteComponent.tsx | 26 +--- src/components/themed/PriceImpactText.tsx | 111 ++++++++++++++++++ 3 files changed, 123 insertions(+), 69 deletions(-) create mode 100644 src/components/themed/PriceImpactText.tsx diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 4eb0fe30126..954e6098026 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -1,5 +1,5 @@ import { useIsFocused } from '@react-navigation/native' -import { add, div, gt, gte, lte, sub, toFixed } from 'biggystring' +import { add, div, gt, gte, toFixed } from 'biggystring' import type { EdgeSwapQuote, EdgeSwapResult } from 'edge-core-js' import React, { useState } from 'react' import { SectionList, type ViewStyle } from 'react-native' @@ -51,11 +51,13 @@ import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { ExchangeQuote } from '../themed/ExchangeQuoteComponent' import { LineTextDivider } from '../themed/LineTextDivider' import { ModalFooter } from '../themed/ModalParts' +import { + calculateQuotePriceImpact, + PRICE_IMPACT_WARNING_THRESHOLD +} from '../themed/PriceImpactText' import { SafeSlider } from '../themed/SafeSlider' import { WalletListSectionHeader } from '../themed/WalletListSectionHeader' -const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 - export interface SwapConfirmationParams { selectedQuote: EdgeSwapQuote quotes: EdgeSwapQuote[] @@ -126,48 +128,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const { request } = selectedQuote const { quoteFor } = request - const priceImpact = React.useMemo(() => { - const { fromWallet, fromTokenId, toTokenId } = request - const toWallet = request.toWallet - if (toWallet == null) { - throw new Error('Swap quote is missing a destination wallet') - } - - const fromExchangeDenom = getExchangeDenom( - fromWallet.currencyConfig, - fromTokenId - ) - const toExchangeDenom = getExchangeDenom(toWallet.currencyConfig, toTokenId) - - const fromExchangeAmount = convertNativeToExchange( - fromExchangeDenom.multiplier - )(selectedQuote.fromNativeAmount) - const toExchangeAmount = convertNativeToExchange( - toExchangeDenom.multiplier - )(selectedQuote.toNativeAmount) - - const fromFiatValue = convertCurrency( - exchangeRates, - fromWallet.currencyInfo.pluginId, - fromTokenId, - defaultIsoFiat, - fromExchangeAmount - ) - const toFiatValue = convertCurrency( - exchangeRates, - toWallet.currencyInfo.pluginId, - toTokenId, - defaultIsoFiat, - toExchangeAmount - ) - - if (lte(fromFiatValue, '0')) return undefined - - const impact = parseFloat( - div(sub(fromFiatValue, toFiatValue), fromFiatValue, 8) - ) - return impact > 0 ? impact : undefined - }, [selectedQuote, exchangeRates, defaultIsoFiat, request]) + const priceImpact = React.useMemo( + () => + calculateQuotePriceImpact(selectedQuote, exchangeRates, defaultIsoFiat), + [selectedQuote, exchangeRates, defaultIsoFiat] + ) const showPriceImpact = priceImpact != null && priceImpact >= PRICE_IMPACT_WARNING_THRESHOLD diff --git a/src/components/themed/ExchangeQuoteComponent.tsx b/src/components/themed/ExchangeQuoteComponent.tsx index d287b602362..86bd3b68eee 100644 --- a/src/components/themed/ExchangeQuoteComponent.tsx +++ b/src/components/themed/ExchangeQuoteComponent.tsx @@ -6,7 +6,6 @@ import { View } from 'react-native' import { useCryptoText } from '../../hooks/useCryptoText' import { formatFiatString, useFiatText } from '../../hooks/useFiatText' import { useTokenDisplayData } from '../../hooks/useTokenDisplayData' -import { formatNumber } from '../../locales/intl' import { lstrings } from '../../locales/strings' import { convertCurrency } from '../../selectors/WalletSelectors' import { useSelector } from '../../types/reactRedux' @@ -16,6 +15,7 @@ import { EdgeCard } from '../cards/EdgeCard' import { CurrencyRow } from '../rows/CurrencyRow' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from './EdgeText' +import { PriceImpactText } from './PriceImpactText' interface Props { fromTo: 'from' | 'to' @@ -194,17 +194,7 @@ export const ExchangeQuote: React.FC = props => { const priceImpactNode = !isFrom && priceImpact != null && priceImpact > 0 ? ( - = 0.15 - ? styles.priceImpactHigh - : priceImpact >= 0.05 - ? styles.priceImpactMedium - : styles.priceImpactLow - } - > - {` (${formatNumber(priceImpact * 100, { toFixed: 2 })}%)`} - + ) : undefined return ( @@ -247,17 +237,5 @@ const getStyles = cacheStyles((theme: Theme) => ({ bottomWarningText: { fontSize: theme.rem(0.75), color: theme.warningText - }, - priceImpactLow: { - fontSize: theme.rem(0.75), - color: theme.deactivatedText - }, - priceImpactMedium: { - fontSize: theme.rem(0.75), - color: theme.warningText - }, - priceImpactHigh: { - fontSize: theme.rem(0.75), - color: theme.dangerText } })) diff --git a/src/components/themed/PriceImpactText.tsx b/src/components/themed/PriceImpactText.tsx new file mode 100644 index 00000000000..027fc532907 --- /dev/null +++ b/src/components/themed/PriceImpactText.tsx @@ -0,0 +1,111 @@ +import { div, lte, sub } from 'biggystring' +import type { EdgeSwapQuote } from 'edge-core-js' +import * as React from 'react' + +import type { GuiExchangeRates } from '../../actions/ExchangeRateActions' +import { formatNumber } from '../../locales/intl' +import { getExchangeDenom } from '../../selectors/DenominationSelectors' +import { convertCurrency } from '../../selectors/WalletSelectors' +import { convertNativeToExchange } from '../../util/utils' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from './EdgeText' + +/** Price impacts at or above this warrant a warning. */ +export const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 + +/** + * The fiat-value fraction a swap quote loses between its from and to sides + * (0.05 = 5%), or undefined when it cannot be computed or is not a loss. + * Works for wallet-to-wallet and swap-to-address quotes alike: the quote's + * destination wallet (synthetic for swap-to-address) carries the real + * `currencyConfig`. + */ +export function calculateQuotePriceImpact( + quote: EdgeSwapQuote, + exchangeRates: GuiExchangeRates, + defaultIsoFiat: string +): number | undefined { + const { request, fromNativeAmount, toNativeAmount } = quote + const { fromWallet, fromTokenId, toWallet, toTokenId } = request + if (toWallet == null) return undefined + + const fromExchangeDenom = getExchangeDenom( + fromWallet.currencyConfig, + fromTokenId + ) + const toExchangeDenom = getExchangeDenom(toWallet.currencyConfig, toTokenId) + + const fromExchangeAmount = convertNativeToExchange( + fromExchangeDenom.multiplier + )(fromNativeAmount) + const toExchangeAmount = convertNativeToExchange(toExchangeDenom.multiplier)( + toNativeAmount + ) + + const fromFiatValue = convertCurrency( + exchangeRates, + fromWallet.currencyInfo.pluginId, + fromTokenId, + defaultIsoFiat, + fromExchangeAmount + ) + const toFiatValue = convertCurrency( + exchangeRates, + toWallet.currencyInfo.pluginId, + toTokenId, + defaultIsoFiat, + toExchangeAmount + ) + + if (lte(fromFiatValue, '0')) return undefined + + const impact = parseFloat( + div(sub(fromFiatValue, toFiatValue), fromFiatValue, 8) + ) + return impact > 0 ? impact : undefined +} + +interface Props { + priceImpact: number | undefined +} + +/** + * The colored ` (x.xx%)` price-delta suffix shared by the swap confirmation + * quote card and the send scene's quote row. + */ +export const PriceImpactText: React.FC = props => { + const { priceImpact } = props + const theme = useTheme() + const styles = getStyles(theme) + + if (priceImpact == null || priceImpact <= 0) return null + + return ( + = 0.15 + ? styles.priceImpactHigh + : priceImpact >= PRICE_IMPACT_WARNING_THRESHOLD + ? styles.priceImpactMedium + : styles.priceImpactLow + } + > + {` (${formatNumber(priceImpact * 100, { toFixed: 2 })}%)`} + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + priceImpactLow: { + color: theme.deactivatedText, + fontSize: theme.rem(0.75) + }, + priceImpactMedium: { + color: theme.warningText, + fontSize: theme.rem(0.75) + }, + priceImpactHigh: { + color: theme.dangerText, + fontSize: theme.rem(0.75) + } +})) From 3767f2a0cec932e15bb6e493ea9e53c20d257814 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:33:31 -0700 Subject: [PATCH 4/8] Add Houdini destination-chain metadata and plugin registration HOUDINI_CHAINS snapshots Houdini's GET /chains intersected with Edge currency pluginIds (per-chain address validation regex + memoNeeded), mirroring the edge-exchange-plugins chain mapping. Register the houdini swap plugin through HOUDINI_INIT env config like other providers. --- src/envConfig.ts | 6 + src/util/corePlugins.ts | 1 + src/util/houdiniChains.ts | 276 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 src/util/houdiniChains.ts diff --git a/src/envConfig.ts b/src/envConfig.ts index 149d1383f28..d33ab4d1e5c 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -322,6 +322,12 @@ export const asEnvConfig = asObject({ ), HOLESKY_INIT: asCorePluginInit(asEvmApiKeys), HEDERA_INIT: asOptional(asBoolean, true), + HOUDINI_INIT: asCorePluginInit( + asObject({ + apiKey: asOptional(asString, ''), + apiSecret: asOptional(asString, '') + }).withRest + ), HYPEREVM_INIT: asCorePluginInit(asEvmApiKeys), LIBERLAND_INIT: asOptional(asBoolean, true), LIFI_INIT: asCorePluginInit( diff --git a/src/util/corePlugins.ts b/src/util/corePlugins.ts index 7205fec20b5..2eb23546373 100644 --- a/src/util/corePlugins.ts +++ b/src/util/corePlugins.ts @@ -94,6 +94,7 @@ export const swapPlugins = { changenow: ENV.CHANGE_NOW_INIT, exolix: ENV.EXOLIX_INIT, godex: ENV.GODEX_INIT, + houdini: ENV.HOUDINI_INIT, lifi: ENV.LIFI_INIT, letsexchange: ENV.LETSEXCHANGE_INIT, nexchange: ENV.NEXCHANGE_INIT, diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts new file mode 100644 index 00000000000..0f0996314e5 --- /dev/null +++ b/src/util/houdiniChains.ts @@ -0,0 +1,276 @@ +import type { EdgeTokenId } from 'edge-core-js' + +/** + * A destination chain HoudiniSwap can pay out to, keyed by the Edge currency + * pluginId. `addressValidation` is Houdini's own per-chain regex, reused for + * client-side validation of pasted destination addresses. `memoNeeded` chains + * show a destination-tag row whose value rides `toAddressInfo.toMemos` to the + * plugin and onward as `destinationTag` on order creation. + */ +export interface HoudiniChain { + pluginId: string + houdiniShortName: string + memoNeeded: boolean + addressValidation: RegExp +} + +/** + * Snapshot of Houdini's `GET /chains` (v2 partner API, fetched 2026-07-02) + * intersected with Edge's currency pluginIds, mirroring the + * edge-exchange-plugins Houdini chain mapping. IBC-family chains are excluded + * there (no trustworthy memo metadata), so they are absent here too. A + * follow-up can source this dynamically from the API once chain metadata is + * exposed through the swap plugin. + */ +export const HOUDINI_CHAINS: HoudiniChain[] = [ + { + pluginId: 'algorand', + houdiniShortName: 'algorand', + memoNeeded: false, + addressValidation: /^[A-Z0-9]{58,58}$/ + }, + { + pluginId: 'arbitrum', + houdiniShortName: 'arbitrum', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'avalanche', + houdiniShortName: 'avalanche', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'base', + houdiniShortName: 'base', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'binancesmartchain', + houdiniShortName: 'bsc', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'bitcoin', + houdiniShortName: 'bitcoin', + memoNeeded: false, + addressValidation: + /^([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39}|bc1[a-z0-9]{59})$/ + }, + { + pluginId: 'bitcoincash', + houdiniShortName: 'bitcoincash', + memoNeeded: false, + addressValidation: + /^([13][a-km-zA-HJ-NP-Z1-9]{25,34})$|^((bitcoincash:)?(q|p)[a-z0-9]{41})$|^((BITCOINCASH:)?(Q|P)[A-Z0-9]{41})$/ + }, + { + pluginId: 'bitcoinsv', + houdiniShortName: 'bsv', + memoNeeded: false, + addressValidation: /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/ + }, + { + pluginId: 'cardano', + houdiniShortName: 'cardano', + memoNeeded: false, + addressValidation: + /^(([1-9A-HJ-NP-Za-km-z]{59})|([0-9A-Za-z]{100,104})|([0-9a-fA-F]{64}))$|^(addr)[0-9A-Za-z]{45,65}$|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$/ + }, + { + pluginId: 'celo', + houdiniShortName: 'celo', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'cosmoshub', + houdiniShortName: 'cosmoshub-4', + memoNeeded: true, + addressValidation: /^(cosmos1)[0-9a-z]{38}$/ + }, + { + pluginId: 'dash', + houdiniShortName: 'dash', + memoNeeded: false, + addressValidation: /^[X|7][0-9A-Za-z]{33}$/ + }, + { + pluginId: 'dogecoin', + houdiniShortName: 'doge', + memoNeeded: false, + addressValidation: /^(D|A|9)[a-km-zA-HJ-NP-Z1-9]{33,34}$/ + }, + { + pluginId: 'ecash', + houdiniShortName: 'eCash', + memoNeeded: false, + addressValidation: + /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[0-9A-Za-z]{42,42}$|^(ecash:)[0-9A-Za-z]{30,70}$/ + }, + { + pluginId: 'ethereum', + houdiniShortName: 'ethereum', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'fantom', + houdiniShortName: 'fantom', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'hedera', + houdiniShortName: 'hedera', + memoNeeded: true, + addressValidation: /^(0.0.)[0-9]{4,40}$/ + }, + { + pluginId: 'hyperevm', + houdiniShortName: 'hyperevm', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'litecoin', + houdiniShortName: 'litecoin', + memoNeeded: false, + addressValidation: /^(L|M|3)[A-Za-z0-9]{33}$|^(ltc1)[0-9A-Za-z]{39}$/ + }, + { + pluginId: 'monero', + houdiniShortName: 'monero', + memoNeeded: false, + addressValidation: /^[48][a-zA-Z|\d]{94}([a-zA-Z|\d]{11})?$/ + }, + { + pluginId: 'opbnb', + houdiniShortName: 'opbnb', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'optimism', + houdiniShortName: 'optimism', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'pivx', + houdiniShortName: 'pivx', + memoNeeded: false, + addressValidation: /^(D)[0-9A-za-z]{33}$/ + }, + { + pluginId: 'polkadot', + houdiniShortName: 'polkadot', + memoNeeded: false, + addressValidation: /^1[1-9A-HJ-NP-Za-km-z]{46,47}$/ + }, + { + pluginId: 'polygon', + houdiniShortName: 'polygon', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'pulsechain', + houdiniShortName: 'pulsechain', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'ripple', + houdiniShortName: 'ripple', + memoNeeded: true, + addressValidation: /^r[1-9A-HJ-NP-Za-km-z]{25,34}$/ + }, + { + pluginId: 'rsk', + houdiniShortName: 'rootstock', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'solana', + houdiniShortName: 'solana', + memoNeeded: false, + addressValidation: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ + }, + { + pluginId: 'sonic', + houdiniShortName: 'sonic', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'stellar', + houdiniShortName: 'xlm', + memoNeeded: true, + addressValidation: /^G[A-D]{1}[A-Z2-7]{54}$/ + }, + { + pluginId: 'sui', + houdiniShortName: 'sui', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{64}$/ + }, + { + pluginId: 'telos', + houdiniShortName: 'telos', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'thorchainrune', + houdiniShortName: 'thorchain', + memoNeeded: true, + addressValidation: /^(thor1)[0-9a-z]{38}$/ + }, + { + pluginId: 'ton', + houdiniShortName: 'ton', + memoNeeded: true, + addressValidation: /^(EQ|UQ)[A-Za-z0-9-_]{46}$/ + }, + { + pluginId: 'tron', + houdiniShortName: 'tron', + memoNeeded: false, + addressValidation: /^T[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'zcash', + houdiniShortName: 'Zcash', + memoNeeded: false, + addressValidation: /^t1[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'zksync', + houdiniShortName: 'zksync-era', + memoNeeded: false, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + } +] + +/** Look up the Houdini destination chain for an Edge asset, if served. */ +export function getHoudiniChain( + pluginId: string, + tokenId: EdgeTokenId +): HoudiniChain | undefined { + // Only native (chain) assets are offered as destinations for now: + if (tokenId != null) return undefined + return HOUDINI_CHAINS.find(chain => chain.pluginId === pluginId) +} + +/** Validate a pasted destination address against the chain's own regex. */ +export function isValidHoudiniAddress( + chain: HoudiniChain, + address: string +): boolean { + return chain.addressValidation.test(address.trim()) +} From ae3d183703ee552dc32ea15bf676c0e3927830e0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:47:26 -0700 Subject: [PATCH 5/8] Turn SendScene2 into a send-to-address swap (Stealth Send) The send scene now offers a "Recipient receives" asset selector (Houdini-served destination chains) and a Stealth Send toggle. Stealth or a cross-asset recipient turns the send into a swap-to-address quote: live quotes via account.fetchSwapQuotes with toAddressInfo, linked "You send"/"Recipient gets" amount rows whose edited side is the guaranteed amount, the shared price-impact indicator, a real expiry countdown that re-quotes, the quote's network fee, and a destination tag row on memoNeeded chains that rides toMemos to the provider. Stealth restricts quotes to Houdini; a plain cross-asset send fans out to every provider. The confirm slider approves the quote and lands on the swap success scene. Plain same-asset sends are unchanged, including multi-recipient UTXO sends, which now also show a total-amount row. Multi-recipient and stealth/cross-asset are mutually exclusive, gated in both directions. Constrained callers (locked or hidden tiles, FIO requests, payment protocol, custom broadcast/completion hooks) keep today's behavior. Cross-chain destination addresses validate against the destination chain's own rules via a new AddressTile2 validation override, since the source wallet cannot parse them. --- .../__snapshots__/SendScene2.ui.test.tsx.snap | 1868 ++++++++++++++--- src/components/scenes/SendScene2.tsx | 710 ++++++- src/components/tiles/AddressTile2.tsx | 26 + src/locales/en_US.ts | 18 + src/locales/strings/enUS.json | 15 + 5 files changed, 2351 insertions(+), 286 deletions(-) diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index b42537c0ea4..245c6e5e964 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -248,7 +248,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="6" + nativeID="9" > @@ -1571,7 +1571,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="13" + nativeID="16" > @@ -3580,7 +3580,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="23" + nativeID="26" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -5589,7 +5701,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` "reduceMotionV": "system", } } - nativeID="34" + nativeID="37" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -7179,7 +7403,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="44" + nativeID="47" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -8749,7 +9085,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="54" + nativeID="57" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -10086,7 +10534,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="63" + nativeID="66" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -11836,7 +12396,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="75" + nativeID="78" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -13537,7 +14209,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="86" + nativeID="89" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -15165,7 +15949,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="97" + nativeID="100" > - Send to Address + Recipient receives - -  - - - Enter - + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + - + Bitcoin (BTC) + + + + + +  + + + + + + + + Send to Address + + + + +  + + + Enter + + + + + + + + + Stealth Send + + + + + + + + + + + + + + + + = props => { // -1 = no max spend, otherwise equal to the index the spendTarget that requested the max spend. const [maxSpendSetter, setMaxSpendSetter] = useState(-1) + // Send-to-address swap state (Stealth Send / cross-asset recipient). The + // recipient asset defaults to the source asset (undefined); picking another + // chain, or enabling stealth, turns the send into a swap-to-address quote. + const [recipientPluginId, setRecipientPluginId] = useState< + string | undefined + >(undefined) + const [stealth, setStealth] = useState(false) + const [destinationTag, setDestinationTag] = useState( + undefined + ) + const [swapQuote, setSwapQuote] = useState( + undefined + ) + const [fetchingSwapQuote, setFetchingSwapQuote] = useState(false) + const [guaranteedSide, setGuaranteedSide] = useState<'send' | 'receive'>( + 'send' + ) + // The fixed receive amount (destination-chain native units) when the user + // edits "Recipient gets"; otherwise the latest quote's estimate. + const [receiveNativeAmount, setReceiveNativeAmount] = useState< + string | undefined + >(undefined) + // Bumped when the quote expires, to force a re-quote: + const [swapQuoteNonce, setSwapQuoteNonce] = useState(0) + const isApprovingSwapRef = React.useRef(false) + const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) const exchangeRates = useSelector( @@ -320,6 +365,44 @@ const SendComponent: React.FC = props => { spendInfo.tokenId = tokenId + // --------------------------------------------------------------------- + // Send-to-address swap mode (Stealth Send / cross-asset recipient) + // --------------------------------------------------------------------- + + // The send-to-address swap UI is offered only when this scene is a plain, + // unconstrained send. Callers that pre-lock tiles, pre-fill an address + // (payment protocol, deep links), pay FIO requests, or take over the + // broadcast/completion flow keep today's behavior untouched. + const swapSendAllowed = + lockTilesMap.address !== true && + lockTilesMap.amount !== true && + lockTilesMap.wallet !== true && + hiddenFeaturesMap.address !== true && + hiddenFeaturesMap.amount !== true && + fioPendingRequest == null && + onDone == null && + alternateBroadcast == null && + beforeTransaction == null && + initSpendInfo?.spendTargets[0]?.publicAddress == null + + // Where the funds land. The recipient asset defaults to the source asset; + // `destChain` carries Houdini's metadata (address regex, memoNeeded) for + // the destination chain when it is served. + const destPluginId = recipientPluginId ?? pluginId + const sameAsset = destPluginId === pluginId && tokenId == null + const crossAsset = recipientPluginId != null && !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAsset) + const destChain = swapSendActive + ? getHoudiniChain(destPluginId, null) + : undefined + const destCurrencyConfig = account.currencyConfig[destPluginId] + const destCurrencyInfo = destCurrencyConfig?.currencyInfo + const destExchangeDenom = + destCurrencyConfig == null + ? undefined + : getExchangeDenom(destCurrencyConfig, null) + const multipleTargets = spendInfo.spendTargets.length > 1 + const updatePendingTxState = React.useCallback(async (): Promise => { if (coreWallet == null || !isEvmWallet(coreWallet)) { setHasPendingTx(false) @@ -558,6 +641,14 @@ const SendComponent: React.FC = props => { (publicAddress === '' && lastAddressEntryMethod === 'scan') if (openCameraRef.current) openCameraRef.current = false + // A cross-chain destination address cannot be parsed by the source + // wallet; validate it against the destination chain's own rules: + const crossChainAddressValidation = + swapSendActive && destPluginId !== pluginId + ? (address: string) => + destChain != null && isValidHoudiniAddress(destChain, address) + : undefined + return ( = props => { isCameraOpen={doOpenCamera} recipientName={recipientName} recipientNameService={recipientNameService} + crossChainAddressValidation={crossChainAddressValidation} navigation={navigation as NavigationBase} /> ) @@ -648,6 +740,8 @@ const SendComponent: React.FC = props => { index: number, spendTarget: EdgeSpendTarget ): React.ReactElement | null => { + // A send-to-address swap renders its own linked amount rows: + if (swapSendActive) return null const { publicAddress, nativeAmount } = spendTarget if (publicAddress != null && hiddenFeaturesMap.amount !== true) { const title = @@ -708,6 +802,12 @@ const SendComponent: React.FC = props => { if (pluginId !== newPluginId || tokenId !== result.tokenId) { setTokenId(result.tokenId) setSpendInfo({ tokenId: result.tokenId, spendTargets: [{}] }) + // A new source asset invalidates the swap-send destination state: + setRecipientPluginId(undefined) + setDestinationTag(undefined) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') } }) .catch((error: unknown) => { @@ -735,7 +835,431 @@ const SendComponent: React.FC = props => { needsScrollToEnd.current = true }) + // --------------------------------------------------------------------- + // Send-to-address swap handlers + rows + // --------------------------------------------------------------------- + + const handleToggleStealth = useHandler((): void => { + if (multipleTargets) return + setStealth(value => !value) + setPinValue(undefined) + }) + + const handleStealthLearnMore = useHandler((): void => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((err: unknown) => { + showError(err) + }) + }) + + const handlePickRecipientAsset = useHandler((): void => { + if (multipleTargets) return + // The radio row's `name` is BOTH the label and the selection key, so it + // must be unique. Several chains share a currency code (ETH on Base / + // Arbitrum / Ethereum), so the label is the unique network display name + // with the currency code as subtext, and a map resolves it back to the + // destination pluginId. + const sourceDisplayName = + tokenId == null + ? coreWallet.currencyInfo.displayName + : coreWallet.currencyConfig.allTokens[tokenId]?.displayName ?? + currencyCode + const displayNameToPluginId = new Map() + displayNameToPluginId.set(sourceDisplayName, undefined) + + const items = [ + { + name: sourceDisplayName, + text: currencyCode, + icon: + }, + ...HOUDINI_CHAINS.filter( + chain => + account.currencyConfig[chain.pluginId] != null && + !(chain.pluginId === pluginId && tokenId == null) + ).map(chain => { + const { currencyCode: chainCode, displayName } = + account.currencyConfig[chain.pluginId].currencyInfo + displayNameToPluginId.set(displayName, chain.pluginId) + return { + name: displayName, + text: chainCode, + icon: ( + + ) + } + }) + ] + const selectedName = + recipientPluginId == null + ? sourceDisplayName + : account.currencyConfig[recipientPluginId]?.currencyInfo.displayName ?? + sourceDisplayName + + Airship.show(bridge => ( + + )) + .then(selected => { + if (selected == null || selected === selectedName) return + if (!displayNameToPluginId.has(selected)) return + setRecipientPluginId(displayNameToPluginId.get(selected)) + // A new destination chain invalidates the entered address and tag: + setDestinationTag(undefined) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + handleResetSendTransaction(spendInfo.spendTargets[0])() + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditYouSend = useHandler((): void => { + const startAmount = zeroString(spendInfo.spendTargets[0].nativeAmount) + ? '' + : div( + spendInfo.spendTargets[0].nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + Airship.show(bridge => ( + + )) + .then(amount => { + if (amount == null || amount === '') return + spendInfo.spendTargets[0].nativeAmount = mul( + amount, + cryptoDisplayDenomination.multiplier + ) + setGuaranteedSide('send') + setSpendInfo({ ...spendInfo }) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditRecipientGets = useHandler((): void => { + if (destExchangeDenom == null) return + const startAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + Airship.show(bridge => ( + + )) + .then(amount => { + if (amount == null || amount === '') return + setReceiveNativeAmount(mul(amount, destExchangeDenom.multiplier)) + setGuaranteedSide('receive') + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditDestinationTag = useHandler((): void => { + Airship.show(bridge => ( + + )) + .then(tag => { + if (tag == null) return + setDestinationTag(tag === '' ? undefined : tag.trim()) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleSwapQuoteExpired = useHandler((): void => { + setSwapQuoteNonce(nonce => nonce + 1) + }) + + /** + * One side of the linked flip inputs. The edited side is the guaranteed + * amount; the other tracks the live quote as an estimate. + */ + const renderSwapAmountRow = ( + title: string, + displayAmount: string, + displayCode: string, + isGuaranteed: boolean, + onPress: () => void + ): React.ReactElement => ( + + + + {`${isGuaranteed ? '' : '~ '}${displayAmount} ${displayCode}`} + + + {isGuaranteed + ? lstrings.stealth_guaranteed + : lstrings.stealth_estimated} + + + + ) + + const renderYouSendRow = (): React.ReactElement => { + const nativeAmount = spendInfo.spendTargets[0].nativeAmount + const displayAmount = zeroString(nativeAmount) + ? '0' + : div( + nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_you_send, + displayAmount, + currencyCode, + guaranteedSide === 'send', + handleEditYouSend + ) + } + + const renderRecipientGetsRow = (): React.ReactElement | null => { + if (destExchangeDenom == null) return null + const displayAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '0' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_recipient_gets, + displayAmount, + destCurrencyInfo?.currencyCode ?? '', + guaranteedSide === 'receive', + handleEditRecipientGets + ) + } + + const renderRecipientReceives = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + const recipientCurrencyCode = + recipientPluginId == null + ? currencyCode + : destCurrencyInfo?.currencyCode ?? currencyCode + const recipientDisplayName = + recipientPluginId == null + ? tokenId == null + ? coreWallet.currencyInfo.displayName + : coreWallet.currencyConfig.allTokens[tokenId]?.displayName ?? + currencyCode + : destCurrencyInfo?.displayName ?? recipientCurrencyCode + return ( + + + + {`${recipientDisplayName} (${recipientCurrencyCode})`} + + + ) + } + + const renderDestinationTagRow = (): React.ReactElement | null => { + if (destChain?.memoNeeded !== true) return null + return ( + + {destinationTag ?? ''} + + ) + } + + const renderSwapQuoteRow = (): React.ReactElement | null => { + if (spendInfo.spendTargets[0].publicAddress == null) return null + if (fetchingSwapQuote) { + return ( + + + {lstrings.stealth_getting_quote} + + + + ) + } + if (swapQuote == null) return null + + // Rate in exchange (standard) units, plus the provider that quoted and + // the shared price-delta indicator: + const fromExchangeAmount = div( + swapQuote.fromNativeAmount, + cryptoExchangeDenomination.multiplier, + DECIMAL_PRECISION + ) + const toExchangeAmount = + destExchangeDenom == null + ? '0' + : div( + swapQuote.toNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + const rate = zeroString(fromExchangeAmount) + ? '0' + : div(toExchangeAmount, fromExchangeAmount, 8) + const providerName = + account.swapConfig[swapQuote.pluginId]?.swapInfo.displayName ?? + swapQuote.pluginId + const priceImpact = calculateQuotePriceImpact( + swapQuote, + exchangeRates, + defaultIsoFiat + ) + + return ( + <> + + + + {`1 ${currencyCode} = ${rate} ${ + destCurrencyInfo?.currencyCode ?? '' + }`} + + + {providerName} + + + {swapQuote.expirationDate == null ? null : ( + + )} + + ) + } + + const renderSwapFeeRow = (): React.ReactElement | null => { + if (swapQuote == null) return null + const { networkFee } = swapQuote + const feeDenom = getExchangeDenom( + coreWallet.currencyConfig, + networkFee.tokenId + ) + const feeDisplayAmount = div( + networkFee.nativeAmount, + feeDenom.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${feeDisplayAmount} ${feeDenom.name}`} + + ) + } + + const renderStealthToggle = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + return ( + + + + {multipleTargets ? ( + + + {lstrings.stealth_multi_recipient_unsupported} + + + ) : stealth ? ( + + + {lstrings.stealth_send_info}{' '} + + {lstrings.stealth_learn_more} + + + + ) : null} + + + ) + } + + /** + * With multiple recipients, show the aggregate on one row instead of making + * the reviewer sum the per-recipient amounts. + */ + const renderMultiRecipientTotal = (): React.ReactElement | null => { + if (!multipleTargets) return null + const totalNativeAmount = spendInfo.spendTargets.reduce( + (prev, target) => add(target.nativeAmount ?? '0', prev), + '0' + ) + const totalDisplayAmount = div( + totalNativeAmount, + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${totalDisplayAmount} ${currencyCode}`} + + ) + } + const renderAddAddress = (): React.ReactElement | null => { + // Stealth and cross-asset sends support exactly one recipient: + if (swapSendActive) return null const { pluginId } = coreWallet.currencyInfo const maxSpendTargets = getSpecialCurrencyInfo(pluginId)?.maxSpendTargets ?? 1 @@ -804,6 +1328,7 @@ const SendComponent: React.FC = props => { } const renderFees = (): React.ReactElement | null => { + if (swapSendActive) return null if ( spendInfo.spendTargets[0].publicAddress != null && spendInfo.spendTargets[0].nativeAmount != null @@ -956,6 +1481,9 @@ const SendComponent: React.FC = props => { } const renderMemoOptions = (): Array => { + // A send-to-address swap's deposit memo comes from the provider, and the + // recipient's tag is entered on the destination-tag row instead: + if (swapSendActive) return [null] const spendTarget: EdgeSpendTarget | undefined = spendInfo.spendTargets[0] if (spendTarget?.publicAddress == null) return [null] @@ -1276,6 +1804,32 @@ const SendComponent: React.FC = props => { const handleSliderComplete = useHandler( async (resetSlider: () => void): Promise => { + // A send-to-address swap submits by approving the live quote. The + // slider is intentionally not reset on success, so a second slide + // cannot fire while the scene transitions to the success scene. + if (swapSendActive) { + if (swapQuote == null || isApprovingSwapRef.current) return + isApprovingSwapRef.current = true + isSendingRef.current = true + try { + const result = await swapQuote.approve() + playSendSound().catch((error: unknown) => { + console.log(error) // Fail quietly + }) + navigation.replace('swapSuccess', { + edgeTransaction: result.transaction, + walletId: coreWallet.id + }) + } catch (err: unknown) { + setError(err) + resetSlider() + } finally { + isApprovingSwapRef.current = false + isSendingRef.current = false + } + return + } + if (edgeTransaction == null) return if (pinSpendingLimitsEnabled && spendingLimitExceeded) { const isAuthorized = await account.checkPin(pinValue ?? '') @@ -1574,6 +2128,13 @@ const SendComponent: React.FC = props => { // Calculate the transaction useAsyncEffect( async () => { + // A send-to-address swap builds its transaction through the swap quote, + // not through makeSpend: + if (swapSendActive) { + setEdgeTransaction(null) + setProcessingAmountChanged(false) + return + } pendingInsufficientFees.current = undefined try { setProcessingAmountChanged(true) @@ -1737,15 +2298,111 @@ const SendComponent: React.FC = props => { } setProcessingAmountChanged(false) }, - [spendInfo, maxSpendSetter, walletId, pinSpendingLimitsEnabled, pinValue], + [ + spendInfo, + maxSpendSetter, + walletId, + pinSpendingLimitsEnabled, + pinValue, + swapSendActive + ], 'SendComponent' ) + // Fetch the send-to-address swap quote. Quotes are requested when the + // guaranteed-side amount commits (not per keystroke), and re-requested when + // the destination, stealth mode, tag, or expiry nonce changes. + useAsyncEffect( + async () => { + if (!swapSendActive) { + setSwapQuote(undefined) + setFetchingSwapQuote(false) + return + } + const toAddress = spendInfo.spendTargets[0].publicAddress + const sendNativeAmount = spendInfo.spendTargets[0].nativeAmount + const quoteNativeAmount = + guaranteedSide === 'send' ? sendNativeAmount : receiveNativeAmount + if ( + toAddress == null || + toAddress === '' || + quoteNativeAmount == null || + zeroString(quoteNativeAmount) + ) { + setSwapQuote(undefined) + return + } + + setFetchingSwapQuote(true) + try { + const toMemos: EdgeMemo[] = + destinationTag == null || destinationTag === '' + ? [] + : [ + { + type: destCurrencyInfo?.memoOptions?.[0]?.type ?? 'text', + value: destinationTag + } + ] + + // Send-to-address flows (Stealth Send and plain cross-asset + // send-to-any) route through the Houdini privacy provider only. + const quotes = await account.fetchSwapQuotes( + { + fromWallet: coreWallet, + fromTokenId: tokenId, + toTokenId: null, + toAddressInfo: { + toPluginId: destPluginId, + toAddress, + toMemos + }, + nativeAmount: quoteNativeAmount, + quoteFor: guaranteedSide === 'send' ? 'from' : 'to' + }, + makeStealthSwapRequestOptions(account) + ) + const quote = quotes[0] + + setSwapQuote(quote) + setError(undefined) + // Update the estimated side from the live quote: + if (guaranteedSide === 'send') { + setReceiveNativeAmount(quote.toNativeAmount) + } else { + spendInfo.spendTargets[0].nativeAmount = quote.fromNativeAmount + setSpendInfo({ ...spendInfo }) + } + needsScrollToEnd.current = true + } catch (err: unknown) { + setSwapQuote(undefined) + setError(err) + } finally { + setFetchingSwapQuote(false) + } + }, + [ + swapSendActive, + spendInfo.spendTargets[0].publicAddress, + guaranteedSide, + guaranteedSide === 'send' + ? spendInfo.spendTargets[0].nativeAmount + : receiveNativeAmount, + destPluginId, + destinationTag, + swapQuoteNonce + ], + 'SendComponent:swapQuote' + ) + const showSlider = spendInfo.spendTargets[0].publicAddress != null let disableSlider = false let disabledText: string | undefined - if ( + if (swapSendActive) { + // A send-to-address swap submits its live quote: + disableSlider = swapQuote == null || fetchingSwapQuote + } else if ( edgeTransaction == null || processingAmountChanged || (zeroString(spendInfo.spendTargets[0].nativeAmount) && @@ -1833,19 +2490,33 @@ const SendComponent: React.FC = props => { {renderSelectedWallet()} {renderSelectFioAddress()} + {swapSendActive && + spendInfo.spendTargets[0].publicAddress != null + ? renderYouSendRow() + : null} + {swapSendActive ? renderSwapFeeRow() : null} + {renderRecipientReceives()} {renderAddressAmountPairs()} + {swapSendActive && + spendInfo.spendTargets[0].publicAddress != null + ? renderRecipientGetsRow() + : null} + {swapSendActive ? renderDestinationTagRow() : null} + {swapSendActive ? renderSwapQuoteRow() : null} {renderTimeout()} {renderAddAddress()} + {renderStealthToggle()} + {renderMultiRecipientTotal()} {renderFees()} {renderMetadataNotes()} {renderMemoOptions()} @@ -1866,6 +2537,11 @@ const SendComponent: React.FC = props => { @@ -1895,6 +2571,36 @@ const getStyles = cacheStyles((theme: Theme) => ({ calcFeeView: { flexDirection: 'row' }, + swapAmountRow: { + alignItems: 'flex-start' + }, + swapAmountText: { + fontSize: theme.rem(1) + }, + swapAssetRow: { + flexDirection: 'row', + alignItems: 'center' + }, + guaranteedHint: { + fontSize: theme.rem(0.75), + color: theme.positiveText + }, + estimatedHint: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, + stealthInfo: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + stealthInfoText: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, + stealthLearnMoreLink: { + fontSize: theme.rem(0.75), + color: theme.textLink + }, calcFeeSpinner: { marginLeft: theme.rem(1) }, diff --git a/src/components/tiles/AddressTile2.tsx b/src/components/tiles/AddressTile2.tsx index 4ce6c84385a..cef0efc7ebb 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -86,6 +86,13 @@ interface Props { * which carry no name-service identity. */ recipientNameService?: NameService | null + /** + * Validates an entered address that belongs to a DIFFERENT chain than + * `coreWallet`'s (a cross-asset send-to-address destination), bypassing the + * wallet's own URI parsing and name-service resolution. Return false to + * reject the address. + */ + crossChainAddressValidation?: (address: string) => boolean navigation: NavigationBase } @@ -102,6 +109,7 @@ export const AddressTile2 = React.forwardRef( onChangeAddress, recipientAddress, resetSendTransaction, + crossChainAddressValidation, title } = props @@ -170,6 +178,24 @@ export const AddressTile2 = React.forwardRef( async (address: string, addressEntryMethod: AddressEntryMethod) => { if (address == null || address.trim() === '') return + // A cross-chain destination cannot go through this wallet's URI + // parsing or name services; validate it against the destination + // chain's own rules and pass it through verbatim. + if (crossChainAddressValidation != null) { + const crossChainAddress = address.trim() + if (!crossChainAddressValidation(crossChainAddress)) { + showToast( + `${lstrings.scan_invalid_address_error_title} ${lstrings.scan_invalid_address_error_description}` + ) + return + } + await onChangeAddress({ + parsedUri: { publicAddress: crossChainAddress }, + addressEntryMethod + }) + return + } + setLoading(true) const enteredInput = address.trim() address = enteredInput diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 49926310b8e..da23b7490d7 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1609,6 +1609,24 @@ const strings = { // Send Scene send_scene_send_from_wallet: 'Send from Wallet', send_scene_send_to_address: 'Send to Address', + stealth_send_toggle: 'Stealth Send', + stealth_swap_toggle: 'Stealth Swap', + stealth_send_info: + 'Uses a route that helps obfuscate the on-chain link between source and destination wallets.', + stealth_swap_info: + 'Routes your swap through multiple exchanges so your source and destination wallets are more obfuscated on-chain.', + stealth_learn_more: 'Learn more', + stealth_you_send: 'You send', + stealth_recipient_gets: 'Recipient gets', + stealth_recipient_receives: 'Recipient receives', + stealth_guaranteed: 'Guaranteed', + stealth_estimated: 'Estimated', + stealth_slide_send: 'Slide to send stealthily', + stealth_quote_rate: 'Exchange Rate', + stealth_quote_expires: 'Quote Expires', + stealth_getting_quote: 'Getting quote...', + stealth_multi_recipient_unsupported: + 'Stealth Send and cross-asset recipients are not available when sending to multiple recipients.', send_scene_error_title: 'Error:', send_scene_metadata_name_title: 'Payee', send_make_spend_xrp_dest_tag_length_error: diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index fe138434cfc..8b43185e424 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1271,6 +1271,21 @@ "loan_welcome_6s": "Welcome to DeFi for Everyone!\n\nUse your %3$s without selling it! Utilize the %2$s DeFi protocol to easily post your %3$s as collateral and borrow USD at rates as low as 1.5%% APR. All without counterparty risk since neither %1$s nor any other company controls your loan collateral.\n\n%1$s simplifies the process by automatically converting and depositing your %3$s into %2$s and withdrawing up to 50%% of it's value in %4$s or even directly depositing into your bank account. Create a loan as little as $%5$s with just $%6$s of %3$s collateral.", "send_scene_send_from_wallet": "Send from Wallet", "send_scene_send_to_address": "Send to Address", + "stealth_send_toggle": "Stealth Send", + "stealth_swap_toggle": "Stealth Swap", + "stealth_send_info": "Uses a route that helps obfuscate the on-chain link between source and destination wallets.", + "stealth_swap_info": "Routes your swap through multiple exchanges so your source and destination wallets are more obfuscated on-chain.", + "stealth_learn_more": "Learn more", + "stealth_you_send": "You send", + "stealth_recipient_gets": "Recipient gets", + "stealth_recipient_receives": "Recipient receives", + "stealth_guaranteed": "Guaranteed", + "stealth_estimated": "Estimated", + "stealth_slide_send": "Slide to send stealthily", + "stealth_quote_rate": "Exchange Rate", + "stealth_quote_expires": "Quote Expires", + "stealth_getting_quote": "Getting quote...", + "stealth_multi_recipient_unsupported": "Stealth Send and cross-asset recipients are not available when sending to multiple recipients.", "send_scene_error_title": "Error:", "send_scene_metadata_name_title": "Payee", "send_make_spend_xrp_dest_tag_length_error": "XRP Destination Tag must be 10 characters or less", From 72e1a0b5e5c8ee38d085b4720ce16cc9fc06b64e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:50:40 -0700 Subject: [PATCH 6/8] Add Stealth Swap to the existing swap flow A Stealth Swap toggle card on the amount-entry scene restricts the quote request to the Houdini privacy provider (all other providers disabled for the request), with the final copy and a working "Learn more" link. The confirmation scene keeps the restriction on re-quotes and renders the powered-by card as a fixed provider: no chevron and no "tap to change provider" hint, via a now-optional PoweredByCard onPress. --- src/components/cards/PoweredByCard.tsx | 25 ++++-- .../scenes/SwapConfirmationScene.tsx | 36 ++++++-- src/components/scenes/SwapCreateScene.tsx | 82 +++++++++++++++++-- src/util/stealthSwap.ts | 27 ++++++ 4 files changed, 149 insertions(+), 21 deletions(-) create mode 100644 src/util/stealthSwap.ts diff --git a/src/components/cards/PoweredByCard.tsx b/src/components/cards/PoweredByCard.tsx index 5b0bdc53782..a96412bbec5 100644 --- a/src/components/cards/PoweredByCard.tsx +++ b/src/components/cards/PoweredByCard.tsx @@ -11,18 +11,23 @@ import { EdgeCard } from './EdgeCard' interface Props { poweredByText: string iconUri?: string - onPress: () => Promise | void + // When omitted, the card is not tappable: no chevron and no + // "tap to change provider" hint are shown (e.g. a fixed-provider swap). + onPress?: () => Promise | void } /** * Small card that displays "Powered by {provider}" with an optional logo. - * Tapping the card triggers `onPress` to change the active provider. + * Tapping the card triggers `onPress` to change the active provider. When + * `onPress` is omitted the card is static (no chevron) to indicate the + * provider cannot be changed. */ export const PoweredByCard: React.FC = (props: Props) => { const { iconUri, poweredByText, onPress } = props const theme = useTheme() const styles = getStyles(theme) const iconSrc = iconUri == null ? {} : { uri: iconUri } + const tappable = onPress != null return ( @@ -40,13 +45,17 @@ export const PoweredByCard: React.FC = (props: Props) => { {poweredByText} - - - {lstrings.tap_to_change_provider} - - + {tappable ? ( + + + {lstrings.tap_to_change_provider} + + + ) : null} - + {tappable ? ( + + ) : null} diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 954e6098026..4ce947cd384 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -25,6 +25,7 @@ import type { GuiSwapInfo } from '../../types/types' import { getSwapPluginIconUri } from '../../util/CdnUris' import { CryptoAmount } from '../../util/CryptoAmount' import { logActivity } from '../../util/logger' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import { logEvent } from '../../util/tracking' import { convertNativeToExchange, DECIMAL_PRECISION } from '../../util/utils' import { AlertCardUi4 } from '../cards/AlertCard' @@ -62,6 +63,13 @@ export interface SwapConfirmationParams { selectedQuote: EdgeSwapQuote quotes: EdgeSwapQuote[] onApprove: () => void + + /** + * A Stealth Swap routes through the Houdini privacy provider as a fixed + * provider: the powered-by card is not tappable and a re-quote keeps the + * provider restriction. + */ + stealth?: boolean } interface Props extends SwapTabSceneProps<'swapConfirmation'> {} @@ -73,7 +81,7 @@ interface Section { export const SwapConfirmationScene: React.FC = (props: Props) => { const { route, navigation } = props - const { quotes, onApprove } = route.params + const { quotes, onApprove, stealth = false } = route.params const dispatch = useDispatch() const theme = useTheme() @@ -185,7 +193,9 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { navigation.replace('swapProcessing', { swapRequest: selectedQuote.request, - swapRequestOptions, + swapRequestOptions: stealth + ? makeStealthSwapRequestOptions(account, swapRequestOptions) + : swapRequestOptions, onCancel: () => { navigation.navigate('swapTab', { screen: 'swapCreate' }) }, @@ -193,7 +203,8 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove + onApprove, + stealth }) } }) @@ -461,11 +472,20 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { /> - + {stealth ? ( + // A stealth swap's provider is fixed, so the card is not + // tappable (no chevron, no "tap to change provider"): + + ) : ( + + )} {selectedQuote.isEstimate && !showPriceImpact ? ( diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index 2245a6a0116..4b243910511 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -25,11 +25,14 @@ import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import { zeroString } from '../../util/utils' +import { openBrowserUri } from '../../util/WebUtils' import { EdgeButton } from '../buttons/EdgeButton' import { KavButtons } from '../buttons/KavButtons' import { SceneButtons } from '../buttons/SceneButtons' import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeCard } from '../cards/EdgeCard' import { EdgeAnim, fadeInDown30, @@ -46,9 +49,16 @@ import { WalletListModal, type WalletListResult } from '../modals/WalletListModal' -import { Airship, showToast, showWarning } from '../services/AirshipInstance' -import { useTheme } from '../services/ThemeContext' +import { + Airship, + showError, + showToast, + showWarning +} from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { UnscaledText } from '../text/UnscaledText' +import { EdgeText } from '../themed/EdgeText' import { LineTextDivider } from '../themed/LineTextDivider' import { SwapInput, @@ -74,6 +84,10 @@ export interface SwapErrorDisplayInfo { error: unknown } +/** Placeholder pending the final marketing URL. */ +const STEALTH_LEARN_MORE_URI = + 'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b' + interface Props extends SwapTabSceneProps<'swapCreate'> {} export const SwapCreateScene: React.FC = props => { @@ -86,6 +100,7 @@ export const SwapCreateScene: React.FC = props => { errorDisplayInfo } = route.params ?? {} const theme = useTheme() + const styles = getStyles(theme) const dispatch = useDispatch() // Input state is the state of the user input @@ -95,6 +110,10 @@ export const SwapCreateScene: React.FC = props => { 'from' | 'to' >('from') + // Stealth Swap: when enabled, the quote routes through the Houdini privacy + // provider as a fixed provider (see SwapConfirmationScene). + const [stealth, setStealth] = useState(false) + const fromInputRef = React.useRef(null) const toInputRef = React.useRef(null) @@ -272,10 +291,13 @@ export const SwapCreateScene: React.FC = props => { errorDisplayInfo: undefined }) - // Start request for quote: + // Start request for quote. A stealth swap restricts the request to the + // Houdini privacy provider: navigation.navigate('swapProcessing', { swapRequest, - swapRequestOptions, + swapRequestOptions: stealth + ? makeStealthSwapRequestOptions(account, swapRequestOptions) + : swapRequestOptions, onCancel: () => { navigation.goBack() }, @@ -283,7 +305,8 @@ export const SwapCreateScene: React.FC = props => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove: resetState + onApprove: resetState, + stealth }) } }) @@ -447,6 +470,16 @@ export const SwapCreateScene: React.FC = props => { Keyboard.dismiss() }) + const handleToggleStealth = useHandler(() => { + setStealth(value => !value) + }) + + const handleStealthLearnMore = useHandler(() => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((err: unknown) => { + showError(err) + }) + }) + const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => { navigation.setParams({ // Update the error state: @@ -613,6 +646,30 @@ export const SwapCreateScene: React.FC = props => { /> )} + {fromWallet != null && toWallet != null ? ( + + + + {stealth ? ( + + + {lstrings.stealth_swap_info}{' '} + + {lstrings.stealth_learn_more} + + + + ) : null} + + + ) : null} {renderAlert()} {isNextHidden || isKeyboardOpen ? null : ( @@ -642,3 +699,18 @@ const MaxButtonText = styled(UnscaledText)(theme => ({ fontSize: theme.rem(0.75), includeFontPadding: false })) + +const getStyles = cacheStyles((theme: Theme) => ({ + stealthInfo: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + stealthInfoText: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + stealthLearnMoreLink: { + color: theme.textLink, + fontSize: theme.rem(0.75) + } +})) diff --git a/src/util/stealthSwap.ts b/src/util/stealthSwap.ts new file mode 100644 index 00000000000..a0abd83f143 --- /dev/null +++ b/src/util/stealthSwap.ts @@ -0,0 +1,27 @@ +import type { + EdgeAccount, + EdgePluginMap, + EdgeSwapRequestOptions +} from 'edge-core-js' + +/** + * Restricts a swap request to the Houdini privacy provider, for Stealth Swap + * and Stealth Send. Every other enabled swap provider is disabled for the + * request, and any preferred-provider override is cleared so it cannot fight + * the restriction. + */ +export function makeStealthSwapRequestOptions( + account: EdgeAccount, + opts: EdgeSwapRequestOptions = {} +): EdgeSwapRequestOptions { + const disabled: EdgePluginMap = { ...opts.disabled } + for (const swapPluginId of Object.keys(account.swapConfig)) { + if (swapPluginId !== 'houdini') disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + preferPluginId: undefined, + preferType: undefined + } +} From 6e267a58052611c0d15dbd8d89b0f6f7b54c8c8c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:52:21 -0700 Subject: [PATCH 7/8] Add Stealth Send and Stealth Swap maestro walks UI walks for the new send-to-address swap surfaces: the recipient-asset selector and toggle card on the send scene (including the destination tag row on an XRP destination) and the Stealth Swap toggle on the swap amount-entry scene. Neither flow requests a live quote. --- CHANGELOG.md | 2 + maestro/14-stealth/stealth-send.yaml | 69 ++++++++++++++++++++++++++++ maestro/14-stealth/stealth-swap.yaml | 52 +++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 maestro/14-stealth/stealth-send.yaml create mode 100644 maestro/14-stealth/stealth-swap.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cebfd37327..d75dccf7d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: Stealth Send: the send scene offers a "Recipient receives" asset selector and a Stealth Send toggle, turning the send into a live swap-to-address quote (Houdini privacy routing when stealth is on, all providers for a plain cross-asset send), with linked You send/Recipient gets amounts, quote expiry countdown, and a destination tag row on memo-required chains. +- added: Stealth Swap: a toggle on the swap amount-entry scene routes the swap through the Houdini privacy provider as a fixed, non-tappable provider. - added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. - changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings". - fixed: Prevent imported Monero wallets from using the Edge LWS backend. Choosing to import now prompts the user to continue with a full node or configure a custom LWS server, matching the wallet settings rule. diff --git a/maestro/14-stealth/stealth-send.yaml b/maestro/14-stealth/stealth-send.yaml new file mode 100644 index 00000000000..cec7fc28cc3 --- /dev/null +++ b/maestro/14-stealth/stealth-send.yaml @@ -0,0 +1,69 @@ +# Stealth Send UI walk. +# +# Opens the Bitcoin wallet's Send scene and walks the send-to-address swap UI +# without entering an address, so no live quote is requested: the "Recipient +# receives" selector (visible before address entry), the Stealth Send toggle +# card with its explainer copy and inline "Learn more" link, and the +# Destination Tag row on a memo-required destination chain (XRP). +# +# Screenshots captured (under ~/.maestro/tests// ): +# stealth-send-01-initial plain same-asset start: add-recipient +# affordance, "Recipient receives" visible +# stealth-send-02-stealth-on toggle expanded with the explainer copy +# stealth-send-03-xrp-tag XRP destination: Destination Tag row +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - stealth +--- +- launchApp +- runFlow: + when: + visible: 'Exit PIN' + commands: + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 1500 } +- runFlow: + when: + visible: 'Security is Our Priority' + commands: + - tapOn: 'Cancel' +# Dismiss the unrelated Stellar/Horizon plugin error toast if present. +- tapOn: + text: '.*Horizon.*' + optional: true + +# Open the Bitcoin wallet, then Send. Gate on the wallet tx-list "Receive" +# button so Send cannot misfire on the home Send. +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin' +- assertVisible: 'Receive' +- tapOn: 'Send' +- waitForAnimationToEnd: + timeout: 3000 + +# Plain same-asset start: add-recipient affordance, no amount/fee rows yet, +# "Recipient receives" visible before address entry. +- assertVisible: 'Stealth Send' +- assertVisible: 'Send to Address' +- assertVisible: 'Recipient receives' +- takeScreenshot: stealth-send-01-initial + +# Stealth on: the toggle card expands with the explainer + Learn more link. +- tapOn: 'Stealth Send' +- assertVisible: 'Uses a route that helps obfuscate.*Learn more.*' +- takeScreenshot: stealth-send-02-stealth-on +- tapOn: 'Stealth Send' + +# Cross-asset memo chain: an XRP destination shows the Destination Tag row. +- tapOn: 'Recipient receives' +- tapOn: + text: 'XRP.*' +- assertVisible: 'Destination Tag' +- takeScreenshot: stealth-send-03-xrp-tag diff --git a/maestro/14-stealth/stealth-swap.yaml b/maestro/14-stealth/stealth-swap.yaml new file mode 100644 index 00000000000..f5b6c8abadf --- /dev/null +++ b/maestro/14-stealth/stealth-swap.yaml @@ -0,0 +1,52 @@ +# Stealth Swap UI walk. +# +# Opens the swap flow from the Bitcoin wallet and checks the Stealth Swap +# toggle card during amount entry: the toggle, the explainer copy, and the +# inline "Learn more" link. No quote is requested. +# +# Screenshots captured (under ~/.maestro/tests// ): +# stealth-swap-01-start amount entry with the Stealth Swap card +# stealth-swap-02-stealth-on toggle expanded with the explainer copy +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - stealth +--- +- launchApp +- runFlow: + when: + visible: 'Exit PIN' + commands: + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 1500 } +- runFlow: + when: + visible: 'Security is Our Priority' + commands: + - tapOn: 'Cancel' +- tapOn: + text: '.*Horizon.*' + optional: true + +# Open the swap flow with a from + to pair so the toggle card renders. +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin' +- assertVisible: 'Receive' +- tapOn: 'Trade' +- tapOn: + text: 'Swap BTC to/from another crypto' +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: 'Stealth Swap' +- takeScreenshot: stealth-swap-01-start + +# Stealth on: the toggle card expands with the explainer + Learn more link. +- tapOn: 'Stealth Swap' +- assertVisible: 'Routes your swap through multiple.*Learn more.*' +- takeScreenshot: stealth-swap-02-stealth-on From 9460a75903c276c3866e6480f99988496ec0dc63 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 18:09:20 -0700 Subject: [PATCH 8/8] test: add missing testIDs for maestro selectors The address tile's Enter/Myself/Scan/Paste entry buttons had no stable selectors. --- .../__snapshots__/AccelerateTxModal.test.tsx.snap | 1 + .../scenes/__snapshots__/SendScene2.ui.test.tsx.snap | 12 ++++++++++++ .../SwapConfirmationScene.test.tsx.snap | 1 + src/components/themed/SafeSlider.tsx | 1 + src/components/tiles/AddressTile2.tsx | 4 ++++ 5 files changed, 19 insertions(+) diff --git a/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap index 2c36f977410..e1503ee0bf6 100644 --- a/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap @@ -619,6 +619,7 @@ exports[`AccelerateTxModalComponent should render with loading props 1`] = ` }, ] } + testID="confirmSliderThumb" > = props => { sliderDisabled ? styles.disabledThumb : null, scrollTranslationStyle ]} + testID="confirmSliderThumb" >