From 3973d4649e8a388a88d49bbebb0713b97b4ea129 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:25:23 -0700 Subject: [PATCH 01/56] 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 02/56] 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 03/56] 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 04/56] 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 5f400229b6e205fe5a028d232eecbf128a672ed4 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:47:26 -0700 Subject: [PATCH 05/56] 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 | 1869 ++++++++++++++--- src/__tests__/util/paymentUri.test.ts | 107 + src/components/scenes/SendScene2.tsx | 775 ++++++- src/components/tiles/AddressTile2.tsx | 38 + src/locales/en_US.ts | 18 + src/locales/strings/enUS.json | 15 + src/util/paymentUri.ts | 74 + 7 files changed, 2605 insertions(+), 291 deletions(-) create mode 100644 src/__tests__/util/paymentUri.test.ts create mode 100644 src/util/paymentUri.ts diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index b42537c0ea4..51759f7acde 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 + + + + + + + + + + + + + + + + { + it('passes a bare address through as its own candidate', () => { + const address = '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + expect(parsePaymentUri(address)).toEqual({ + addressCandidates: [address] + }) + }) + + it('trims surrounding whitespace', () => { + expect( + parsePaymentUri(' bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq \n') + ).toEqual({ + addressCandidates: ['bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'] + }) + }) + + it('splits a BIP-21 URI with an amount', () => { + const uri = + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.0123' + expect(parsePaymentUri(uri)).toEqual({ + addressCandidates: [ + uri, + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + ], + displayAmount: '0.0123' + }) + }) + + it('splits a BIP-21 URI without a query', () => { + expect( + parsePaymentUri('bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq') + ).toEqual({ + addressCandidates: [ + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + ], + displayAmount: undefined + }) + }) + + it('keeps the scheme-prefixed candidate for cashaddr-style addresses', () => { + const result = parsePaymentUri( + 'bitcoincash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h?amount=1.5' + ) + expect(result.addressCandidates).toContain( + 'bitcoincash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h' + ) + expect(result.addressCandidates).toContain( + 'qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h' + ) + expect(result.displayAmount).toBe('1.5') + }) + + it('strips EIP-681 chain suffix and pay- prefix', () => { + const result = parsePaymentUri( + 'ethereum:pay-0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372@1?amount=0.5' + ) + expect(result.addressCandidates).toContain( + '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + ) + expect(result.displayAmount).toBe('0.5') + }) + + it('reads a Monero-family tx_amount parameter', () => { + const result = parsePaymentUri( + 'monero:46byoyaW?tx_amount=2.25&tx_description=x' + ) + expect(result.addressCandidates).toContain('46byoyaW') + expect(result.displayAmount).toBe('2.25') + }) + + it('ignores a non-decimal amount', () => { + const result = parsePaymentUri('bitcoin:bc1qtest?amount=abc') + expect(result.displayAmount).toBeUndefined() + expect(result.addressCandidates).toContain('bc1qtest') + }) + + it('ignores an EIP-681 wei value parameter', () => { + const result = parsePaymentUri( + 'ethereum:0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372?value=2e18' + ) + expect(result.displayAmount).toBeUndefined() + expect(result.addressCandidates).toContain( + '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + ) + }) + + it('strips leading slashes from the path', () => { + const result = parsePaymentUri( + 'ripple://rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh?amount=20' + ) + expect(result.addressCandidates).toContain( + 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh' + ) + expect(result.displayAmount).toBe('20') + }) + + it('survives malformed percent-encoding in the query', () => { + const result = parsePaymentUri('bitcoin:bc1qtest?label=%E0%A4%A&amount=0.1') + expect(result.displayAmount).toBe('0.1') + }) +}) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 58aa9578ff5..01d349a4467 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -10,6 +10,7 @@ import { type EdgeMemoOption, type EdgeSpendInfo, type EdgeSpendTarget, + type EdgeSwapQuote, type EdgeTokenId, type EdgeTransaction, type InsufficientFundsError @@ -59,6 +60,11 @@ import { FioError, recordSend } from '../../util/FioAddressUtils' +import { + getHoudiniChain, + HOUDINI_CHAINS, + isValidHoudiniAddress +} from '../../util/houdiniChains' import { logActivity } from '../../util/logger' import { createEdgeMemo, @@ -67,18 +73,21 @@ import { getMemoLabel, getMemoTitle } from '../../util/memoUtils' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import { convertTransactionFeeToDisplayFee, darkenHexColor, DECIMAL_PRECISION, zeroString } from '../../util/utils' +import { openBrowserUri } from '../../util/WebUtils' import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeCard } from '../cards/EdgeCard' import { ErrorCard, I18nError } from '../cards/ErrorCard' import type { AccentColors } from '../common/DotsBackground' import { EdgeAnim } from '../common/EdgeAnim' import { SceneWrapper } from '../common/SceneWrapper' +import { CryptoIcon } from '../icons/CryptoIcon' import { ButtonsModal } from '../modals/ButtonsModal' import { FlipInputModal2, @@ -86,6 +95,7 @@ import { type FlipInputModalResult } from '../modals/FlipInputModal2' import { showInsufficientFeesModal } from '../modals/InsufficientFeesModal' +import { RadioListModal } from '../modals/RadioListModal' import { TextInputModal } from '../modals/TextInputModal' import { WalletListModal, @@ -94,6 +104,7 @@ import { import { EdgeRow } from '../rows/EdgeRow' import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { UnscaledTextInput } from '../text/UnscaledTextInput' import { EdgeText } from '../themed/EdgeText' import type { @@ -102,6 +113,10 @@ import type { } from '../themed/ExchangedFlipInput2' import { asPrivateNetworkingSetting } from '../themed/MaybePrivateNetworkingSetting' import { PinDots } from '../themed/PinDots' +import { + calculateQuotePriceImpact, + PriceImpactText +} from '../themed/PriceImpactText' import { SafeSlider } from '../themed/SafeSlider' import { SendFromFioRows } from '../themed/SendFromFioRows' import { @@ -172,6 +187,10 @@ interface FioSenderInfo { const ALLOW_MULTIPLE_TARGETS = true +/** Placeholder pending the final marketing URL. */ +const STEALTH_LEARN_MORE_URI = + 'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b' + /** * If the prior two spend targets of a multi-out payment have the same amount * within 0.5%, then use the same amount for the new spend target. @@ -240,6 +259,8 @@ const SendComponent: React.FC = props => { initMinNativeAmount ) const [expireDate, setExpireDate] = useState(initExpireDate) + /** Whether the payment request's own countdown has run out. */ + const [addressExpired, setAddressExpired] = useState(false) const [error, setError] = useState(undefined) const [edgeTransaction, setEdgeTransaction] = useState(null) @@ -261,6 +282,32 @@ const SendComponent: React.FC = 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 +367,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) @@ -427,8 +512,14 @@ const SendComponent: React.FC = props => { const handleChangeAddress = (spendTarget: EdgeSpendTarget) => async (changeAddressResult: ChangeAddressResult): Promise => { - const { addressEntryMethod, parsedUri, fioAddress, alias, resolvedName } = - changeAddressResult + const { + addressEntryMethod, + parsedUri, + fioAddress, + alias, + resolvedName, + crossChainDisplayAmount + } = changeAddressResult if (parsedUri != null) { if (parsedUri.metadata != null) { @@ -436,7 +527,28 @@ const SendComponent: React.FC = props => { } spendTarget.uniqueIdentifier = parsedUri?.uniqueIdentifier spendTarget.publicAddress = parsedUri?.publicAddress - spendTarget.nativeAmount = parsedUri?.nativeAmount + + if (swapSendActive && !sameAsset) { + // A payment URI's amount is what the recipient should receive, so a + // cross-asset send guarantees the destination side and prices the + // send side off the quote. A cross-chain URI carries display units + // to convert; a same-chain one is already destination-native. + // + // Same-asset (stealth) sends stay on the send side: guaranteeing the + // receive side needs a receive-priced quote, and the provider offers + // no fixed-rate route when the source and destination assets match. + const uriReceiveNativeAmount = + crossChainDisplayAmount != null && destExchangeDenom != null + ? mul(crossChainDisplayAmount, destExchangeDenom.multiplier) + : parsedUri.nativeAmount + spendTarget.nativeAmount = undefined + if (uriReceiveNativeAmount != null) { + setReceiveNativeAmount(uriReceiveNativeAmount) + setGuaranteedSide('receive') + } + } else { + spendTarget.nativeAmount = parsedUri.nativeAmount + } const memos: EdgeMemo[] = [] // Preserve existing memo data or use memo/uniqueIdentifier from parsed URI @@ -476,6 +588,7 @@ const SendComponent: React.FC = props => { setLastAddressEntryMethod(addressEntryMethod) setMinNativeAmount(parsedUri.minNativeAmount) setExpireDate(parsedUri?.expireDate) + setAddressExpired(false) setSpendInfo({ ...spendInfo, memos }) needsScrollToEnd.current = true } @@ -526,7 +639,12 @@ const SendComponent: React.FC = props => { spendTarget.publicAddress = undefined spendTarget.nativeAmount = undefined spendTarget.memo = spendTarget.uniqueIdentifier = undefined - setError(undefined) + // Through the owners, not `setError` directly: a bare clear leaves + // `swapErrorShown` believing a swap error is still on screen, and the next + // plain-send failure then cannot retract itself. + clearSwapError() + clearPlainSendError() + setAddressExpired(false) setExpireDate(undefined) setPinValue(undefined) setSpendInfo({ ...spendInfo }) @@ -558,6 +676,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 +775,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 +837,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 +870,436 @@ 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 => { + // Drop the quote, do not just ask for a new one. Bumping the nonce alone + // left the expired quote in state until the effect got around to running, + // and the slider gates on `swapQuote != null`, so there was a window where + // a slide would approve an order the provider had already retired. + setSwapQuote(undefined) + 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 @@ -773,7 +1337,13 @@ const SendComponent: React.FC = props => { // Caller provided custom expiry handler - call it without showing error onExpired() } else { - // Fall back to showing expiry error message + // The flag, not just the card. The card lives in the shared `error` + // state, which entering swap-send legitimately clears, so on its own it + // let an expired payment request end up behind a live swap quote with the + // slider still armed. Expiry is a property of the REQUEST, so it outlives + // whichever send mode the scene is in and is cleared only by replacing + // the address. + setAddressExpired(true) setError( new I18nError( lstrings.transaction_failure, @@ -804,6 +1374,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 +1527,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 +1850,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 +2174,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 +2344,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) && @@ -1765,6 +2468,12 @@ const SendComponent: React.FC = props => { disableSlider = true } + // An expired payment request cannot be paid in EITHER mode, so this sits + // outside the swap-send branch above. + if (addressExpired) { + disableSlider = true + } + const accentColors: AccentColors = { // Transparent fallback for while iconColor is loading iconAccentColor: iconColor ?? '#00000000' @@ -1833,19 +2542,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 +2589,11 @@ const SendComponent: React.FC = props => { @@ -1895,6 +2623,39 @@ const getStyles = cacheStyles((theme: Theme) => ({ calcFeeView: { flexDirection: 'row' }, + swapAmountRow: { + alignItems: 'flex-start' + }, + swapAmountText: { + fontSize: theme.rem(1) + }, + swapAssetRow: { + flexDirection: 'row', + alignItems: 'center', + // Match the visual title-to-body gap of a text row: an icon fills its + // box, so it lacks the font line-box whitespace a text body carries. + marginTop: theme.rem(0.375) + }, + 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..1ccf152cd0f 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -25,6 +25,7 @@ import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { parseDeepLink } from '../../util/DeepLinkParser' import { checkPubAddress } from '../../util/FioAddressUtils' import { type NameService, reverseLookupName } from '../../util/nameServices' +import { parsePaymentUri } from '../../util/paymentUri' import { resolveName } from '../../util/resolveName' import { isEmail } from '../../util/utils' import { isZnsName, resolveZnsName } from '../../util/zns' @@ -58,6 +59,12 @@ export interface ChangeAddressResult { * persist the name into transaction metadata. */ resolvedName?: { name: string; service: NameService } + /** + * Display-units amount carried by a cross-chain payment URI, denominated in + * the destination chain's primary asset. The tile has no destination + * denomination to convert with, so the consumer converts to native units. + */ + crossChainDisplayAmount?: string } export interface AddressTileRef { @@ -86,6 +93,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 +116,7 @@ export const AddressTile2 = React.forwardRef( onChangeAddress, recipientAddress, resetSendTransaction, + crossChainAddressValidation, title } = props @@ -170,6 +185,29 @@ 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. Split payment URIs (scanned QR codes) + // generically, then validate against the destination chain's own + // rules and pass the address through verbatim. + if (crossChainAddressValidation != null) { + const { addressCandidates, displayAmount } = parsePaymentUri(address) + const crossChainAddress = addressCandidates.find(candidate => + crossChainAddressValidation(candidate) + ) + if (crossChainAddress == null) { + showToast( + `${lstrings.scan_invalid_address_error_title} ${lstrings.scan_invalid_address_error_description}` + ) + return + } + await onChangeAddress({ + parsedUri: { publicAddress: crossChainAddress }, + addressEntryMethod, + crossChainDisplayAmount: displayAmount + }) + 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", diff --git a/src/util/paymentUri.ts b/src/util/paymentUri.ts new file mode 100644 index 00000000000..2363dfaa195 --- /dev/null +++ b/src/util/paymentUri.ts @@ -0,0 +1,74 @@ +export interface ParsedPaymentUri { + /** + * Possible bare-address readings of the scanned text, in priority order: + * the raw text itself (bare addresses, and cashaddr-style addresses whose + * on-chain form keeps the `prefix:`), the scheme-prefixed path with the + * query stripped, and the naked path with scheme, EIP-681 `pay-` prefix, + * and `@chainId` suffix removed. + */ + addressCandidates: string[] + + /** + * Payment amount in display (exchange-denomination) units of the URI's + * chain, from a BIP-21 `amount=` or Monero-family `tx_amount=` parameter. + */ + displayAmount?: string +} + +const schemeRegex = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/s +const displayAmountRegex = /^\d+(\.\d+)?$/ + +/** + * Splits a scanned payment URI (BIP-21 / EIP-681 style) into bare-address + * candidates and a display-units amount, without any chain-specific parser. + * Used for send-to-address swap destinations, where the destination chain has + * no wallet whose `parseUri` could do this properly. Plain text that is not a + * URI passes through as its own single candidate. + */ +export function parsePaymentUri(text: string): ParsedPaymentUri { + const trimmed = text.trim() + const schemeMatch = schemeRegex.exec(trimmed) + if (schemeMatch == null) return { addressCandidates: [trimmed] } + + const [, scheme, rest] = schemeMatch + const queryIndex = rest.indexOf('?') + const path = queryIndex < 0 ? rest : rest.slice(0, queryIndex) + const query = queryIndex < 0 ? '' : rest.slice(queryIndex + 1) + + // Parse the query parameters: + const params = new Map() + for (const pair of query.split('&')) { + if (pair === '') continue + const eqIndex = pair.indexOf('=') + if (eqIndex < 0) continue + try { + params.set( + decodeURIComponent(pair.slice(0, eqIndex)).toLowerCase(), + decodeURIComponent(pair.slice(eqIndex + 1)) + ) + } catch (error: unknown) { + // Malformed percent-encoding; skip the parameter. + } + } + + const amountParam = params.get('amount') ?? params.get('tx_amount') + const displayAmount = + amountParam != null && displayAmountRegex.test(amountParam.trim()) + ? amountParam.trim() + : undefined + + // The naked address: no scheme or `//`, and without EIP-681's optional + // `pay-` prefix and `@chainId` suffix: + let bareAddress = path.replace(/^\/\//, '') + if (bareAddress.startsWith('pay-')) bareAddress = bareAddress.slice(4) + const atIndex = bareAddress.indexOf('@') + if (atIndex >= 0) bareAddress = bareAddress.slice(0, atIndex) + + const addressCandidates: string[] = [] + for (const candidate of [trimmed, `${scheme}:${path}`, bareAddress]) { + if (candidate === '' || addressCandidates.includes(candidate)) continue + addressCandidates.push(candidate) + } + + return { addressCandidates, displayAmount } +} From 5ade828b99e7436620c7997d0c09650f5c9371cc Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:50:40 -0700 Subject: [PATCH 06/56] 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 031fe1aced79018100729808e390913861231751 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:52:21 -0700 Subject: [PATCH 07/56] 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 8883d0a88c20926909803a302f3bc9269504248e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 18:09:20 -0700 Subject: [PATCH 08/56] test: add missing testIDs for maestro selectors The address tile's Enter/Myself/Scan/Paste entry buttons had no stable selectors. --- eslint.config.mjs | 4 +- .../14-stealth/stealth-qr-payment-uri.yaml | 98 +++++++++++++++++++ .../AccelerateTxModal.test.tsx.snap | 1 + .../TextInputModal.test.tsx.snap | 10 +- .../__snapshots__/SendScene2.ui.test.tsx.snap | 12 +++ .../SwapConfirmationScene.test.tsx.snap | 1 + src/components/modals/RadioListModal.tsx | 3 +- src/components/modals/ScanModal.tsx | 1 + src/components/modals/TextInputModal.tsx | 9 +- src/components/themed/SafeSlider.tsx | 1 + src/components/tiles/AddressTile2.tsx | 4 + 11 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 maestro/14-stealth/stealth-qr-payment-uri.yaml diff --git a/eslint.config.mjs b/eslint.config.mjs index f92b3884e78..6146f7b0b64 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -219,13 +219,11 @@ export default [ 'src/components/modals/PasswordReminderModal.tsx', 'src/components/modals/PermissionsSettingModal.tsx', - 'src/components/modals/RadioListModal.tsx', 'src/components/modals/RawTextModal.tsx', 'src/components/modals/ScamWarningModal.tsx', - 'src/components/modals/ScanModal.tsx', + 'src/components/modals/StateProvinceListModal.tsx', - 'src/components/modals/TextInputModal.tsx', 'src/components/modals/TransferModal.tsx', 'src/components/modals/WalletListSortModal.tsx', diff --git a/maestro/14-stealth/stealth-qr-payment-uri.yaml b/maestro/14-stealth/stealth-qr-payment-uri.yaml new file mode 100644 index 00000000000..bb8ba8360b3 --- /dev/null +++ b/maestro/14-stealth/stealth-qr-payment-uri.yaml @@ -0,0 +1,98 @@ +# Scanned payment-URI walk for the send-to-address swap UI. +# +# A scanned QR carries a payment URI (`ethereum:0x...?amount=0.5`), never a +# bare address. This walk covers both halves of that handling: +# - the destination address is extracted from the URI and accepted even +# though the source wallet cannot parse a foreign-chain URI, and +# - the URI's amount lands on the RECIPIENT side as the guaranteed amount +# (a payment request states what the recipient should receive; the send +# side comes from the swap quote). +# +# The sim has no camera, so the URI is delivered through the scan modal's +# keyboard-entry affordance, which resolves the typed string through the +# exact code path a decoded QR takes. +# +# Screenshots captured (under ~/.maestro/tests// ): +# stealth-qr-01-cross-chain ETH destination filled from the URI, amount +# on the "Recipient gets" row as Guaranteed +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' + SRC_WALLET: My Bitcoin + PAYMENT_URI: 'ethereum:0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372?amount=0.5' +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 source wallet, then Send. +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Search Wallets' +- inputText: '${SRC_WALLET}' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: '${SRC_WALLET}' +- assertVisible: 'Receive' +- tapOn: 'Send' +- waitForAnimationToEnd: + timeout: 3000 +# An unfunded source wallet offers to buy or exchange first. +- runFlow: + when: + visible: 'Wallet Empty' + commands: + - tapOn: 'Not at this time' + - waitForAnimationToEnd: + timeout: 2000 +- assertVisible: 'Send to Address' + +# Cross-asset destination: the source wallet cannot parse this chain's URI. +- tapOn: 'Recipient receives' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + id: 'radioListItem_Ethereum' +- waitForAnimationToEnd: + timeout: 2000 + +# Deliver the payment URI the way a scanned QR does. +- tapOn: + id: 'addressTileScan' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'scanModalTextInput' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + id: 'textInputModal.textInput' +- inputText: '${PAYMENT_URI}' +- tapOn: 'Submit' +- waitForAnimationToEnd: + timeout: 4000 + +# The address came out of the URI, and its amount is the guaranteed side. +- assertVisible: '.*0x1f36.*' +- assertVisible: '.*0.5 ETH.*' +- assertVisible: 'Guaranteed' +- takeScreenshot: stealth-qr-01-cross-chain 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" > @@ -615,7 +616,7 @@ exports[`TextInputModal should render with a blank input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > @@ -1468,7 +1470,7 @@ exports[`TextInputModal should render with a populated input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > = props => { const { bridge, items, selected, title } = props const theme = useTheme() const styles = getStyles(theme) @@ -59,6 +59,7 @@ export function RadioListModal(props: Props) { return ( { bridge.resolve(name) }} diff --git a/src/components/modals/ScanModal.tsx b/src/components/modals/ScanModal.tsx index 5d5d2dc3578..a5f72137ff7 100644 --- a/src/components/modals/ScanModal.tsx +++ b/src/components/modals/ScanModal.tsx @@ -263,6 +263,7 @@ export const ScanModal: React.FC = props => { diff --git a/src/components/modals/TextInputModal.tsx b/src/components/modals/TextInputModal.tsx index 30bcf0840d7..76179c63cf1 100644 --- a/src/components/modals/TextInputModal.tsx +++ b/src/components/modals/TextInputModal.tsx @@ -57,7 +57,7 @@ interface Props { secureTextEntry?: boolean } -export function TextInputModal(props: Props) { +export const TextInputModal: React.FC = props => { const { autoCapitalize, autoFocus = true, @@ -82,12 +82,12 @@ export function TextInputModal(props: Props) { const [errorMessage, setErrorMessage] = React.useState() const [text, setText] = React.useState(initialValue) - const handleChangeText = (text: string) => { + const handleChangeText = (text: string): void => { setText(text) setErrorMessage(undefined) } - const handleSubmit = () => { + const handleSubmit = (): void => { if (onSubmit == null) { bridge.resolve(text) return @@ -100,7 +100,7 @@ export function TextInputModal(props: Props) { bridge.resolve(text) } }, - error => { + (error: unknown) => { showError(error) } ) @@ -131,6 +131,7 @@ export function TextInputModal(props: Props) { /> ) : null} = props => { sliderDisabled ? styles.disabledThumb : null, scrollTranslationStyle ]} + testID="confirmSliderThumb" > Date: Mon, 27 Jul 2026 17:27:34 -0700 Subject: [PATCH 09/56] Correct two HoudiniSwap address-validation regexes The published Cardano regex ends in an unanchored zero-length alternative, so it matches every string, including empty. Any pasted text validated as a Cardano address, and no other chain could be told apart from it. The PIVX class writes A-z, which also spans the six punctuation characters between the alphabet halves; PIVX addresses are base58. --- src/locales/strings/enUS.json | 2 ++ src/util/houdiniChains.ts | 55 +++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 8b43185e424..236a9dc68ff 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1286,6 +1286,8 @@ "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.", + "stealth_detected_network_title": "Which network is this address on?", + "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "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", diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts index 0f0996314e5..f5d2c5e4dc0 100644 --- a/src/util/houdiniChains.ts +++ b/src/util/houdiniChains.ts @@ -1,5 +1,7 @@ import type { EdgeTokenId } from 'edge-core-js' +import { parsePaymentUri } from './paymentUri' + /** * A destination chain HoudiniSwap can pay out to, keyed by the Edge currency * pluginId. `addressValidation` is Houdini's own per-chain regex, reused for @@ -77,8 +79,13 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ pluginId: 'cardano', houdiniShortName: 'cardano', memoNeeded: false, + // Houdini's own regex ends in `|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$`, whose + // first alternative is unanchored and zero-length and so matches EVERY + // string, including empty. Those two catch-alls are dropped here: they + // would accept any typo as a Cardano address, and they make any pasted + // address look like it could be paying Cardano. 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}$/ + /^([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})$/ }, { pluginId: 'celo', @@ -163,7 +170,9 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ pluginId: 'pivx', houdiniShortName: 'pivx', memoNeeded: false, - addressValidation: /^(D)[0-9A-za-z]{33}$/ + // Houdini writes `[0-9A-za-z]`, where `A-z` also spans `[ \ ] ^ _ \``. + // PIVX addresses are base58, so the strict class is used instead. + addressValidation: /^D[1-9A-HJ-NP-Za-km-z]{33}$/ }, { pluginId: 'polkadot', @@ -274,3 +283,45 @@ export function isValidHoudiniAddress( ): boolean { return chain.addressValidation.test(address.trim()) } + +/** + * Find the destination chains a pasted string could be paying, for input the + * source wallet itself could not parse. An address belonging to another chain + * is not a typo: it is a cross-chain send whose recipient asset the user has + * not picked yet, so the caller can offer the swap instead of an error. + * + * An explicit URI scheme (`ethereum:0x...`) names the chain outright and wins. + * A bare address is matched against each served chain's own regex, which is + * ambiguous by construction for the EVM family, so every match is returned for + * the caller to disambiguate rather than guessing and misdirecting funds. + */ +export function detectHoudiniChains( + text: string, + opts: { + /** The sending wallet's chain, never a candidate destination. */ + sourcePluginId: string + /** Whether the account has a currency plugin for this chain. */ + isSupported: (pluginId: string) => boolean + } +): HoudiniChain[] { + const { sourcePluginId, isSupported } = opts + const { addressCandidates, scheme } = parsePaymentUri(text) + + const served = HOUDINI_CHAINS.filter( + chain => chain.pluginId !== sourcePluginId && isSupported(chain.pluginId) + ) + const matchesAddress = (chain: HoudiniChain): boolean => + addressCandidates.some(candidate => isValidHoudiniAddress(chain, candidate)) + + if (scheme != null) { + const schemeLower = scheme.toLowerCase() + const named = served.find( + chain => + chain.pluginId === schemeLower || + chain.houdiniShortName.toLowerCase() === schemeLower + ) + if (named != null && matchesAddress(named)) return [named] + } + + return served.filter(matchesAddress) +} From 5bfa9f67691cec5ce6f2e4f9018ec6aace81e34e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 27 Jul 2026 17:28:07 -0700 Subject: [PATCH 10/56] Adopt a cross-chain recipient address on any entry path Sending to another chain required picking Recipient receives first. Until that happened the address went to the sending wallet's own parseUri, so pasting, typing, or scanning an Ethereum address in a Bitcoin wallet reported an invalid address, as did the full payment URI. Users reach for the address first, so that reads as the feature being broken. An address the wallet cannot read is now matched against the served destination chains. A URI scheme names its chain outright; a bare address is matched on format, and where several chains share one, as the EVM family does, the user picks rather than the app guessing and misdirecting the funds. The detected chain becomes the recipient asset and the send continues as a swap. --- CHANGELOG.md | 1 + src/__tests__/util/houdiniChains.test.ts | 145 +++++++++++++++++++++++ src/__tests__/util/paymentUri.test.ts | 6 +- src/components/modals/RadioListModal.tsx | 5 +- src/components/scenes/SendScene2.tsx | 109 ++++++++++++++++- src/components/tiles/AddressTile2.tsx | 37 ++++++ src/locales/en_US.ts | 3 + src/util/paymentUri.ts | 9 +- 8 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/util/houdiniChains.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d75dccf7d15..d0d8659e337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 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: Entering a recipient address from another chain now sets up the cross-chain send by itself. Pasting, typing, or scanning an address the sending wallet cannot read no longer reports an invalid address; Edge identifies the network it belongs to, or asks which one when the format is shared, and sets "Recipient receives" to match. - 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/src/__tests__/util/houdiniChains.test.ts b/src/__tests__/util/houdiniChains.test.ts new file mode 100644 index 00000000000..05bf9441419 --- /dev/null +++ b/src/__tests__/util/houdiniChains.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from '@jest/globals' + +import { detectHoudiniChains } from '../../util/houdiniChains' + +// Real mainnet-format addresses for the chains the send scene offers: +const ADDRESSES = { + bitcoin: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + bitcoinLegacy: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + ethereum: '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e', + litecoin: 'MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441', + solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + dogecoin: 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L' +} + +const supportAll = (): boolean => true + +describe('detectHoudiniChains', () => { + it('detects the chain a bare address belongs to', () => { + const found = detectHoudiniChains(ADDRESSES.litecoin, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('litecoin') + }) + + it('returns every EVM chain for a bare 0x address', () => { + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + const pluginIds = found.map(chain => chain.pluginId) + expect(pluginIds).toContain('ethereum') + expect(pluginIds).toContain('polygon') + expect(pluginIds.length).toBeGreaterThan(2) + }) + + it('resolves an ambiguous address outright when the URI names the chain', () => { + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}`, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toEqual(['ethereum']) + }) + + it('honors a URI scheme that differs from the plugin id', () => { + const found = detectHoudiniChains(`polygon:${ADDRESSES.ethereum}`, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('carries the amount through to the caller-visible candidates', () => { + const found = detectHoudiniChains( + `ethereum:${ADDRESSES.ethereum}?amount=0.007`, + { sourcePluginId: 'bitcoin', isSupported: supportAll } + ) + expect(found.map(chain => chain.pluginId)).toEqual(['ethereum']) + }) + + it('never offers the sending wallet own chain as a destination', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).not.toContain('bitcoin') + }) + + it('skips chains the account has no plugin for', () => { + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'bitcoin', + isSupported: pluginId => pluginId === 'polygon' + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('detects Solana, whose format overlaps no EVM chain', () => { + const found = detectHoudiniChains(ADDRESSES.solana, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('solana') + }) + + it('detects Bitcoin from a Litecoin wallet', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'litecoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects a legacy Bitcoin address', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoinLegacy, { + sourcePluginId: 'litecoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects Dogecoin', () => { + const found = detectHoudiniChains(ADDRESSES.dogecoin, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('dogecoin') + }) + + it('returns nothing for input that addresses no served chain', () => { + const found = detectHoudiniChains('not an address', { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found).toEqual([]) + }) + + it('falls back to format matching when the scheme is unknown', () => { + const found = detectHoudiniChains(`madeupchain:${ADDRESSES.litecoin}`, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('litecoin') + }) + + it('ignores a scheme whose address does not validate on that chain', () => { + // A mislabeled URI must not be trusted into sending to the wrong chain: + const found = detectHoudiniChains(`ethereum:${ADDRESSES.litecoin}`, { + sourcePluginId: 'bitcoin', + isSupported: supportAll + }) + const pluginIds = found.map(chain => chain.pluginId) + expect(pluginIds).toContain('litecoin') + expect(pluginIds).not.toContain('ethereum') + }) + + it('rejects a Cardano regex catch-all that would accept any text', () => { + // Houdini's published Cardano regex matches every string; detection is + // meaningless unless that is corrected. + const found = detectHoudiniChains('hello world', { + sourcePluginId: 'bitcoin', + isSupported: pluginId => pluginId === 'cardano' + }) + expect(found).toEqual([]) + }) +}) diff --git a/src/__tests__/util/paymentUri.test.ts b/src/__tests__/util/paymentUri.test.ts index 2a71928f241..184923a35c5 100644 --- a/src/__tests__/util/paymentUri.test.ts +++ b/src/__tests__/util/paymentUri.test.ts @@ -27,7 +27,8 @@ describe('parsePaymentUri', () => { 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' ], - displayAmount: '0.0123' + displayAmount: '0.0123', + scheme: 'bitcoin' }) }) @@ -39,7 +40,8 @@ describe('parsePaymentUri', () => { 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' ], - displayAmount: undefined + displayAmount: undefined, + scheme: 'bitcoin' }) }) diff --git a/src/components/modals/RadioListModal.tsx b/src/components/modals/RadioListModal.tsx index 88d6f1ff6a3..a255d4298bd 100644 --- a/src/components/modals/RadioListModal.tsx +++ b/src/components/modals/RadioListModal.tsx @@ -23,10 +23,12 @@ interface Props { title: string items: Item[] selected?: string + /** Explanatory copy between the title and the list. */ + message?: string } export const RadioListModal: React.FC = props => { - const { bridge, items, selected, title } = props + const { bridge, items, message, selected, title } = props const theme = useTheme() const styles = getStyles(theme) @@ -88,6 +90,7 @@ export const RadioListModal: React.FC = props => { = props => { fioAddress, alias, resolvedName, - crossChainDisplayAmount + crossChainDisplayAmount, + detectedDestPluginId } = changeAddressResult + // A destination detected from the address itself makes this a cross-asset + // send. `setRecipientPluginId` has not re-rendered yet, so the routing + // below reads the detected chain rather than the stale render-time state. + const uriGuaranteesReceiveSide = + detectedDestPluginId != null || (swapSendActive && !sameAsset) + const uriDestExchangeDenom = + detectedDestPluginId == null + ? destExchangeDenom + : getExchangeDenom(account.currencyConfig[detectedDestPluginId], null) + if (parsedUri != null) { if (parsedUri.metadata != null) { spendInfo.metadata = parsedUri.metadata @@ -528,7 +542,7 @@ const SendComponent: React.FC = props => { spendTarget.uniqueIdentifier = parsedUri?.uniqueIdentifier spendTarget.publicAddress = parsedUri?.publicAddress - if (swapSendActive && !sameAsset) { + if (uriGuaranteesReceiveSide) { // A payment URI's amount is what the recipient should receive, so a // cross-asset send guarantees the destination side and prices the // send side off the quote. A cross-chain URI carries display units @@ -538,8 +552,8 @@ const SendComponent: React.FC = props => { // receive side needs a receive-priced quote, and the provider offers // no fixed-rate route when the source and destination assets match. const uriReceiveNativeAmount = - crossChainDisplayAmount != null && destExchangeDenom != null - ? mul(crossChainDisplayAmount, destExchangeDenom.multiplier) + crossChainDisplayAmount != null && uriDestExchangeDenom != null + ? mul(crossChainDisplayAmount, uriDestExchangeDenom.multiplier) : parsedUri.nativeAmount spendTarget.nativeAmount = undefined if (uriReceiveNativeAmount != null) { @@ -594,6 +608,92 @@ const SendComponent: React.FC = props => { } } + /** + * Rescues input the sending wallet could not parse. An address for another + * chain is the ordinary way a user asks for a cross-chain send: they paste + * the recipient's address before touching "Recipient receives". Detect the + * chain it belongs to, adopt it as the destination, and keep the address. + * + * Returns false to let the tile report an invalid address, which is still + * the right answer for a genuine typo. + */ + const handleUnparsedAddress = + (spendTarget: EdgeSpendTarget) => + async ( + address: string, + addressEntryMethod: AddressEntryMethod + ): Promise => { + if (!swapSendAllowed || multipleTargets) return false + + const candidates = detectHoudiniChains(address, { + sourcePluginId: pluginId, + isSupported: id => account.currencyConfig[id] != null + }) + if (candidates.length === 0) return false + + // An address format shared by several chains (any EVM `0x…`) cannot be + // resolved from the address alone, and guessing would send the funds to + // the wrong network, so the user names the network. + let chain = candidates[0] + if (candidates.length > 1) { + const displayNameToChain = new Map() + const items = candidates.map(candidate => { + const { currencyCode: chainCode, displayName } = + account.currencyConfig[candidate.pluginId].currencyInfo + displayNameToChain.set(displayName, candidate) + return { + name: displayName, + text: chainCode, + icon: ( + + ) + } + }) + const selected = await Airship.show(bridge => ( + + )) + const picked = + selected == null ? undefined : displayNameToChain.get(selected) + // Dismissing the picker is a deliberate cancel, not a bad address. + if (picked == null) return true + chain = picked + } + + const { addressCandidates, displayAmount } = parsePaymentUri(address) + const publicAddress = addressCandidates.find(candidate => + isValidHoudiniAddress(chain, candidate) + ) + if (publicAddress == null) return false + + setRecipientPluginId(chain.pluginId) + // A new destination chain invalidates any tag and quote held for the old + // one, exactly as picking the recipient asset by hand does: + setDestinationTag(undefined) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + + await handleChangeAddress(spendTarget)({ + parsedUri: { publicAddress }, + addressEntryMethod, + crossChainDisplayAmount: displayAmount, + detectedDestPluginId: chain.pluginId + }) + return true + } + const handleAddressAmountPress = (index: number) => (): void => { // This is deleting the combo address/amount tile. If this happens, remove the // lastAddressEntryMethod so we don't auto launch the camera again. @@ -697,6 +797,7 @@ const SendComponent: React.FC = props => { recipientName={recipientName} recipientNameService={recipientNameService} crossChainAddressValidation={crossChainAddressValidation} + onUnparsedAddress={handleUnparsedAddress(spendTarget)} navigation={navigation as NavigationBase} /> ) diff --git a/src/components/tiles/AddressTile2.tsx b/src/components/tiles/AddressTile2.tsx index aa531807116..08aa6575ec8 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -65,6 +65,14 @@ export interface ChangeAddressResult { * denomination to convert with, so the consumer converts to native units. */ crossChainDisplayAmount?: string + + /** + * Destination chain inferred from the address itself, when the consumer + * adopted an address belonging to a chain other than the sending wallet's. + * Set only on that path, where the consumer's own destination state has not + * re-rendered yet and so cannot be read back. + */ + detectedDestPluginId?: string } export interface AddressTileRef { @@ -100,6 +108,18 @@ interface Props { * reject the address. */ crossChainAddressValidation?: (address: string) => boolean + /** + * Last resort for input this tile could not resolve on its own chain (or on + * the currently-picked destination chain). An address for another chain is + * usually a cross-chain send whose recipient asset has not been picked yet, + * so the consumer gets a chance to detect that chain and adopt the address. + * Return true when it took ownership, false to show the invalid-address + * error as before. + */ + onUnparsedAddress?: ( + address: string, + addressEntryMethod: AddressEntryMethod + ) => Promise navigation: NavigationBase } @@ -114,6 +134,7 @@ export const AddressTile2 = React.forwardRef( lockInputs, navigation, onChangeAddress, + onUnparsedAddress, recipientAddress, resetSendTransaction, crossChainAddressValidation, @@ -195,6 +216,13 @@ export const AddressTile2 = React.forwardRef( crossChainAddressValidation(candidate) ) if (crossChainAddress == null) { + // Not valid on the picked destination either. It may still belong + // to some other chain the consumer can switch to. + const adopted = await onUnparsedAddress?.( + address, + addressEntryMethod + ) + if (adopted === true) return showToast( `${lstrings.scan_invalid_address_error_title} ${lstrings.scan_invalid_address_error_description}` ) @@ -402,6 +430,15 @@ export const AddressTile2 = React.forwardRef( }) } } else { + // This wallet's chain can't read the input. Before calling it + // invalid, let the consumer check whether it addresses another + // chain, which turns the send into a cross-chain swap. + setLoading(false) + const adopted = await onUnparsedAddress?.( + address, + addressEntryMethod + ) + if (adopted === true) return showToast( `${lstrings.scan_invalid_address_error_title} ${lstrings.scan_invalid_address_error_description}` ) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index da23b7490d7..8117c3942d9 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1627,6 +1627,9 @@ const strings = { stealth_getting_quote: 'Getting quote...', stealth_multi_recipient_unsupported: 'Stealth Send and cross-asset recipients are not available when sending to multiple recipients.', + stealth_detected_network_title: 'Which network is this address on?', + stealth_detected_network_message: + 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', send_scene_error_title: 'Error:', send_scene_metadata_name_title: 'Payee', send_make_spend_xrp_dest_tag_length_error: diff --git a/src/util/paymentUri.ts b/src/util/paymentUri.ts index 2363dfaa195..6b6e22d8675 100644 --- a/src/util/paymentUri.ts +++ b/src/util/paymentUri.ts @@ -13,6 +13,13 @@ export interface ParsedPaymentUri { * chain, from a BIP-21 `amount=` or Monero-family `tx_amount=` parameter. */ displayAmount?: string + + /** + * The URI scheme (`ethereum` in `ethereum:0x...`), absent for plain text. + * Names the destination chain outright, which is how a cross-chain paste can + * be resolved without guessing between chains that share an address format. + */ + scheme?: string } const schemeRegex = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/s @@ -70,5 +77,5 @@ export function parsePaymentUri(text: string): ParsedPaymentUri { addressCandidates.push(candidate) } - return { addressCandidates, displayAmount } + return { addressCandidates, displayAmount, scheme } } From 5c28a6220ec4975161c09f2d4a22da0814f4d996 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 27 Jul 2026 19:35:44 -0700 Subject: [PATCH 11/56] Add the Stealth Send and Stealth Swap design document Commissioned after the code landed, so it is written from the implemented state: the body describes current reality, one history section reconstructs the four phases, and the retrospective records where the design was wrong. Two findings there are worth the reviewer's attention: address entry was treated as a solved sub-problem and shipped a bug users hit, and the provider's published chain metadata was assumed correct when two of its regexes are defective. --- src/docs/stealth-send-swap.md | 529 ++++++++++++++++++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 src/docs/stealth-send-swap.md diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md new file mode 100644 index 00000000000..c8708faa45d --- /dev/null +++ b/src/docs/stealth-send-swap.md @@ -0,0 +1,529 @@ +# Stealth Send and Stealth Swap: send to any address, on any chain, privately + +| | | +|---|---| +| Status | Implemented (pending dependency publishes) | +| Author | Jon Tzeng | +| Reviewer | - | +| Last updated | 2026-07-28 | +| Repos | [edge-react-gui](https://github.com/EdgeApp/edge-react-gui), [edge-core-js](https://github.com/EdgeApp/edge-core-js), [edge-exchange-plugins](https://github.com/EdgeApp/edge-exchange-plugins) | +| Implementation | [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066), [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730), [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) | +| Supersedes | prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) (kept open as reference) | +| Related | [Asana task](https://app.asana.com/0/1215088146871429/1216251688512498) | + +This document describes what is built on branch `jon/stealth-send-swap` across the three repos above, as of gui `9c9306068`, core `c12ad450`, and exchange-plugins `a17062d`. Direction came from the Asana task and its UI proposal A, plus follow-up operator comments on the task; the code is the source of truth and every code block below is quoted from the shipped branches. + +## Contents + +1. [Problem](#1-problem) +2. [Prior art](#2-prior-art) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) +5. [Detailed design: edge-core-js](#5-detailed-design-edge-core-js) +6. [Detailed design: edge-exchange-plugins](#6-detailed-design-edge-exchange-plugins) +7. [Detailed design: edge-react-gui](#7-detailed-design-edge-react-gui) +8. [Testing](#8-testing) +9. [Phase history](#9-phase-history) +10. [Decisions](#10-decisions) +11. [References](#11-references) +12. [Post-implementation retrospective](#12-post-implementation-retrospective) + +## 1. Problem + +Edge can send an asset to an address on its own chain, and it can swap between two wallets the user holds. It cannot do the thing users actually ask for: pay someone whose address is on a different chain, or send without the recipient being able to link the payment back to the sender's wallet. + +Two concrete gaps: + +- **Cross-asset send.** A user holding ETH who owes someone 0.25 LTC has to swap ETH to LTC into their own wallet, then send. Two operations, two fees, and the swap leg needs a Litecoin wallet they may not want. +- **Privacy.** Every ordinary send writes a direct sender-to-recipient edge on chain. The recipient, and anyone reading the chain, can walk back to the sender's wallet and its balance history. + +A swap provider that pays out to an arbitrary address solves both, because the provider address sits between sender and recipient. Edge's swap stack could not express that: `EdgeSwapRequest` required a `toWallet`, an `EdgeCurrencyWallet` the user owns. + +## 2. Prior art + +**Prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054) and [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031)** proved the flow but are not shippable. They add a parallel `HoudiniSendScene` reached by rerouting the wallet Send button from `TransactionListTop`, hardcode four destination chains with hand-written `memoNeeded` flags, recreate the price-delta UI that `SwapConfirmationScene` already has, and fake the destination wallet in GUI code. The fork means every other send entry point in the app keeps the old behavior. + +**A GUI-built fake destination wallet** was tried first and does not work. The object has to cross the yaob bridge into the core to reach the swap plugin, and a plain JavaScript object's function properties do not survive that wire format, so any plugin call on it fails. This is the finding that moved the synthetic wallet into the core, recorded in [Decision: build the synthetic destination wallet in the core](#build-the-synthetic-destination-wallet-in-the-core). + +**Existing swap-provider address entry** (the "send to address" some providers expose) is not reusable either: it lives inside the swap scenes and assumes the user is trading between their own assets, so it does not give the send scene a destination. + +## 3. Goals and non-goals + +Goals: + +- Send from any wallet to an address on any chain the provider serves, from the ordinary send scene. +- Offer a privacy-routed send (Stealth Send) and a privacy-routed swap (Stealth Swap) that pin the request to the privacy provider. +- Accept the recipient address through every entry path a user might reach for: paste, typed entry, and scanned QR. +- Leave every constrained send flow byte-for-byte unchanged: payment protocol, FIO requests, deep links, and any caller that pre-locks tiles or takes over broadcast. + +Non-goals: + +- **Token destinations.** Only native chain assets are offered as a destination. `getHoudiniChain` returns `undefined` for a non-null `tokenId`, so the recipient picker lists chains only. Token sources are supported and tested. +- **Max spend in swap-send mode.** The plain-mode max flow is untouched; a swap-send max through the plugins' `getMaxSwappable` is deferred, see [Phase history](#9-phase-history). +- **Multiple recipients with swap-send.** Gated off in both directions, see [Multi-recipient gating](#multi-recipient-gating). +- **Telling two EVM chains apart from a bare address.** Physically impossible from the address alone; see [Decision: ask the user when the address format is ambiguous](#ask-the-user-when-the-address-format-is-ambiguous). + +## 4. Design overview + +| Repo | Deliverable | Scope | +|---|---|---| +| [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730) | `toAddressInfo` on `EdgeSwapRequest`, core-built synthetic destination wallet | [Section 5](#5-detailed-design-edge-core-js) | +| [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) | HoudiniSwap plugin, chain mapping, destination-memo threading | [Section 6](#6-detailed-design-edge-exchange-plugins) | +| [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066) | Send scene becomes a send-to-address swap, Stealth toggles, cross-chain address entry | [Section 7](#7-detailed-design-edge-react-gui) | + +The seam is one optional field. The GUI describes the destination as data (`toAddressInfo`); the core turns that description into an object shaped like a wallet; the plugin consumes it through the wallet surface it already knows. No plugin needs to learn about addresses-instead-of-wallets. + +```mermaid +sequenceDiagram + box edge-react-gui + participant Send as SendScene2 + end + box edge-core-js + participant API as swap-api + participant Synth as synthetic-wallet + end + box edge-exchange-plugins + participant Plug as houdini plugin + end + participant H as HoudiniSwap API + + Send->>API: fetchSwapQuotes({ toAddressInfo, quoteFor }, stealthOptions) + API->>API: resolveSwapRequest: exactly one of toWallet / toAddressInfo + API->>Synth: makeSyntheticDestinationWallet(currencyConfig, toAddress, toMemos) + Synth-->>API: bridgified EdgeCurrencyWallet + API->>Plug: fetchSwapQuote(request with toWallet = synthetic) + Plug->>Plug: getAddress(toWallet) / getDestinationMemos(toWallet) + Plug->>H: GET /tokens, GET /quotes + H-->>Plug: routes + Plug->>H: POST /exchanges (destinationTag from memos) + H-->>Plug: deposit address + Plug-->>Send: EdgeSwapQuote (approve sends to the deposit address) +``` + +## 5. Detailed design: edge-core-js + +### The request contract + +`EdgeSwapRequest` gains one optional field, and `toWallet` becomes optional. Exactly one of the two must be present. As landed, `src/types/types.ts`: + +```ts +export interface EdgeSwapToAddressInfo { + toPluginId: string + toAddress: string + + /** + * Destination memos (e.g. an XRP destination tag) for memo-required payout + * chains. This descriptor field is only the GUI-to-core transport: swap + * plugins never read it. The core copies it onto the synthetic destination + * wallet, which exposes it through `getMemos` (see + * `EdgeSyntheticDestinationWallet`), so plugins consume destination memos + * through the wallet surface alone. + */ + toMemos?: EdgeMemo[] +} + +export interface EdgeSyntheticDestinationWallet extends EdgeCurrencyWallet { + readonly getMemos: () => Promise +} +``` + +The `getMemos` split matters: a descriptor field the plugin could read directly would give plugins two ways to find destination memos, one of which does not exist on real wallets. Routing memos through the wallet surface keeps one code path in the plugin. + +### Resolving the request + +`resolveSwapRequest` in `src/core/swap/swap-api.ts` enforces the exactly-one rule, validates the plugin and token exist, builds the synthetic wallet, and **drops the descriptor** from the resolved request: + +```ts + // Drop the descriptor from the resolved request so it keeps exactly one + // destination: a resolved request that rides back to the caller inside + // `quote.request` must be re-submittable without tripping the + // exactly-one-of rule above. + return { + ...request, + toAddressInfo: undefined, + toWallet: makeSyntheticDestinationWallet(currencyConfig, toAddress, toMemos) + } +``` + +Dropping it is not tidiness. `quote.request` rides back to the GUI and can be resubmitted; leaving both fields set would make the resubmission throw. + +### The synthetic wallet + +`src/core/swap/synthetic-wallet.ts` builds an object backed by the real `EdgeCurrencyConfig` the core already holds, so `currencyInfo` and `allTokens` are authentic while the address accessors return the pasted address: + +```ts +export const SYNTHETIC_WALLET_ID_PREFIX = 'synthetic://' + +export function makeSyntheticDestinationWallet( + currencyConfig: EdgeCurrencyConfig, + toAddress: string, + toMemos: EdgeMemo[] = [] +): EdgeCurrencyWallet { +``` + +The id prefix is a public contract: plugins branch on it to skip address-type lookups that make no sense for a single pasted address (see [Section 6](#6-detailed-design-edge-exchange-plugins)). + +### Error reporting + +`SwapCurrencyError` previously dereferenced `request.toWallet` unconditionally. It now falls back through the descriptor so a swap-to-address failure names the destination chain instead of throwing inside the error constructor. As landed, `src/types/error.ts`: + +```ts + const toPluginId = + toWallet?.currencyConfig.currencyInfo.pluginId ?? + toAddressInfo?.toPluginId ?? + '' +``` + +## 6. Detailed design: edge-exchange-plugins + +### Plugin identity and transport + +```ts +export const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'HoudiniSwap', + supportEmail: 'support@houdiniswap.com' +} +``` + +Two transport facts are load-bearing and both are non-obvious: + +- Auth is `Authorization: :` with no `Bearer` prefix. Every endpoint returns 402 without it. +- The partner API is server-to-server and answers browser-origin requests with 403. The core runs plugins inside a WebView, so `io.fetch` carries `Origin` / `Sec-Fetch-*` headers. Every call therefore passes `corsBypass: 'always'`, which routes through the native fetch host-side and matches the contract the API expects. + +### Destination handling + +The plugin reads the destination through the wallet surface, with two branches for the synthetic case: + +```ts +async function getDestinationMemos( + toWallet: EdgeCurrencyWallet +): Promise { + const { getMemos } = toWallet as EdgeCurrencyWallet & + SyntheticDestinationMethods + if (getMemos == null) return [] + return await getMemos() +} +``` + +and, in `fetchSwapQuoteInner`: + +```ts + // A synthetic (swap-to-address) destination holds exactly one pasted, + // caller-validated address, so a typed-address lookup does not apply. + const isSyntheticDestination = toWallet.id.startsWith( + SYNTHETIC_WALLET_ID_PREFIX + ) +``` + +Memos become `destinationTag` on order creation, which is what memo-required chains (XRP, XLM, TON, Cosmos-family, Hedera, Thorchain) need to credit the payment. + +### Chain mapping + +`src/mappings/houdini.ts` maps every Edge `EdgeCurrencyPluginId` to a Houdini chain `shortName`, or `null` where Houdini has no compatible chain. IBC-family chains (coreum, osmosis, axelar) are deliberately `null`: Houdini reports no `memoNeeded` flag and a permissive `^.*$` address validation for them, so their payout semantics are not trustworthy enough to offer. + +### Route selection + +Quotes are filtered by route type, and the asymmetry is deliberate: + +```ts + const candidateQuotes = quotes + .filter( + (quote): quote is HoudiniQuote => + quote != null && + (quote.type === 'private' || + (reverseQuote && quote.type === 'standard')) + ) +``` + +Forward quotes take private (multi-exchange) routes only, which is what makes Stealth private. Houdini's exact-out pricing is offered solely on fixed-rate quotes, which its private routing does not serve, so reverse quotes also accept standard routes. Those still settle through Houdini, so the recipient never sees the sender's address, but they use a single exchange leg. Private routes stay preferred whenever offered. + +This filter is the reason a live availability change on the provider's side can disable forward swap-to-address sends without any code change here; see [Retrospective item 2](#where-this-document-was-wrong-or-silent). + +## 7. Detailed design: edge-react-gui + +### Where the feature is allowed to appear + +`SendScene2` gains the feature in place rather than in a parallel scene. The gate is a single predicate, as landed: + +```ts + 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 +``` + +Every constrained caller fails at least one clause, so payment protocol, FIO requests, deep links, and any caller taking over broadcast keep today's behavior exactly. The last clause is also why deep links do not enter this flow: they pre-fill an address. + +Activation is then: + +```ts + const destPluginId = recipientPluginId ?? pluginId + const sameAsset = destPluginId === pluginId && tokenId == null + const crossAsset = recipientPluginId != null && !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAsset) +``` + +### Quote request + +When active, the scene requests a quote instead of building a spend. `makeSpend` is skipped entirely (`if (swapSendActive) { setEdgeTransaction(null); … return }`) because the transaction comes from the quote. + +Stealth restricts the request to the privacy provider through a shared helper, `src/util/stealthSwap.ts`, used by both the send scene and the swap scene: + +```ts +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 + } +} +``` + +Clearing `preferPluginId`/`preferType` matters: a user's saved provider preference would otherwise fight the restriction. + +A plain cross-asset send (stealth off) fans out to every enabled provider instead. + +### Linked amounts + +"You send" and "Recipient gets" are linked through one piece of state, `guaranteedSide`. The edited side is guaranteed and renders green; the other tracks the live quote and renders as `~` estimated. Editing "Recipient gets" issues `quoteFor: 'to'`, a reverse quote. + +### Cross-chain address entry + +A destination on another chain cannot go through the source wallet's `parseUri`, so `AddressTile2` takes two hooks. The first validates a known-cross-chain address against the destination chain's own regex. The second, `onUnparsedAddress`, is the one that makes the feature discoverable: + +```ts + onUnparsedAddress?: ( + address: string, + addressEntryMethod: AddressEntryMethod + ) => Promise +``` + +It fires when this wallet's chain cannot read the input, immediately before the invalid-address toast. Because it hangs off `changeAddress`, which every entry affordance funnels through, one hook covers Paste, Enter address, and Scan at once. + +`SendScene2`'s handler detects the chain, adopts it as the recipient asset, and applies the address. Chain detection lives in `src/util/houdiniChains.ts`: + +```ts +export function detectHoudiniChains( + text: string, + opts: { + /** The sending wallet's chain, never a candidate destination. */ + sourcePluginId: string + /** Whether the account has a currency plugin for this chain. */ + isSupported: (pluginId: string) => boolean + } +): HoudiniChain[] +``` + +A URI scheme names its chain outright and wins. A bare address is matched against each served chain's regex; several chains share a format, so every match is returned and the caller disambiguates. The chain table entry is: + +```ts +export interface HoudiniChain { + pluginId: string + houdiniShortName: string + memoNeeded: boolean + addressValidation: RegExp +} +``` + +`HOUDINI_CHAINS` is a snapshot of Houdini `GET /chains` (fetched 2026-07-02) intersected with Edge pluginIds: 38 chains, 6 of them memo-required. Two of the provider's published regexes are corrected in the table with the reason inline; see [Decision: correct the provider's address regexes rather than route around them](#correct-the-providers-address-regexes-rather-than-route-around-them). + +Because `setRecipientPluginId` has not re-rendered when the address is applied, the detected chain is threaded through the result object rather than read back from state: + +```ts + // A destination detected from the address itself makes this a cross-asset + // send. `setRecipientPluginId` has not re-rendered yet, so the routing + // below reads the detected chain rather than the stale render-time state. + const uriGuaranteesReceiveSide = + detectedDestPluginId != null || (swapSendActive && !sameAsset) +``` + +### Payment URI amounts + +A scanned QR carries a payment URI, not a bare address. `src/util/paymentUri.ts` splits one generically, with no chain-specific parser, because the destination chain has no wallet whose `parseUri` could do it: + +```ts +export interface ParsedPaymentUri { + addressCandidates: string[] + displayAmount?: string + scheme?: string +} +``` + +Candidates are returned in priority order (raw trimmed text, scheme-prefixed path, naked path) so cashaddr-style addresses that keep their `prefix:` on chain still validate. + +A URI amount is what the recipient should **receive**, so for a cross-asset destination it sets the receive side as guaranteed and lets the quote price the send side. Same-asset (stealth) sends keep it on the send side, because the provider offers no receive-priced route when source and destination assets match; guaranteeing the receive side there would make every same-asset payment URI unquotable. + +### Multi-recipient gating + +Gated in both directions. Stealth on or a mismatched recipient hides "Add Another Address"; with multiple recipients present the stealth toggle is disabled, the card expands with an explanation, and the recipient-asset selector locks. Multi-recipient sends also gained a Total Amount row, which the task had left open. + +### Stealth Swap + +`SwapCreateScene` gets the same treatment at a smaller scale: a toggle whose state feeds `makeStealthSwapRequestOptions` into the quote request, with the restriction surviving re-quotes on `SwapConfirmationScene`. `PoweredByCard.onPress` became optional so the provider renders as fixed (no chevron, no "tap to change provider"). + +### Shared price impact + +The prototype recreated the price-delta UI. It is instead extracted from the swap confirmation scene into `src/components/themed/PriceImpactText.tsx` and reused by both: + +```ts +export const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 +export function calculateQuotePriceImpact(…) +export const PriceImpactText: React.FC = props => { +``` + +## 8. Testing + +Unit tests, 26 across two files, all passing: + +1. `src/__tests__/util/paymentUri.test.ts` (11): bare address passthrough, whitespace, BIP-21 with and without a query, cashaddr prefix retention, EIP-681 `pay-` prefix and `@chainId` suffix stripping, Monero `tx_amount`, non-decimal amount rejection, `value=` wei ignored, leading-slash stripping, malformed percent-encoding. +2. `src/__tests__/util/houdiniChains.test.ts` (15): single-chain detection, all-EVM fan-out for a bare `0x`, scheme resolution including a scheme differing from the pluginId, source chain never offered, unsupported chains skipped, Solana and Dogecoin and legacy Bitcoin formats, non-address text rejected, unknown scheme falling back to format matching, a mislabeled scheme not trusted, and the Cardano catch-all regression. +3. `test/core/synthetic-wallet.test.ts` in edge-core-js: the synthetic wallet's shape and bridge survival. +4. `test/houdini.test.ts` in edge-exchange-plugins, with 10 recorded fixtures: quote retrieval, order creation, and destination-tag threading against recorded provider responses. + +Full-repo verification: `verify-repo.sh` PASSED on the gui (install, prepare, lint, full jest: 556 tests, 92 suites, 107 snapshots). + +On-device, iOS simulator, account `edge-funds`, against the live provider: + +5. **Executed cross-chain send.** ETH wallet, Litecoin address pasted, destination auto-adopted, 0.25 LTC guaranteed on the receive side, live quote 1 ETH = 39.59988278 LTC, executed to the success scene. Broadcast `0xa87fd77e1a64310d565e462cbc91e5f3e0e748ff1bbb62857455aef37e4044e7`, 0.00631315 ETH ($11.93), category `Exchange:To LTC`. Source wallet moved 0.0175191 to 0.0111629 ETH. +6. **Executed cross-chain send from a scanned URI**, plain and with Stealth on: `176c833c6f0ef09ea9c2ba5eb6a39e079a13262aadd362d87d195350114a54dc` and `53f03d92da63c9b20dc29a0c296b4043cefb71e334066bf78529d6cec2b11cb6`. +7. **Executed plain cross-asset send** through the provider fan-out (ChangeNOW won): `0xfd51a5c5d4ba44267d257506c977a8e88952f56987e653d2b812502fa739cf8b`. +8. **Entry-path and chain matrix**, 10 cases: typed Ethereum address into a Bitcoin wallet (picker to Ethereum, and separately to Polygon); typed Solana address into an Ethereum wallet; scanned `bitcoin:` URI from an Ethereum wallet; scanned `ethereum:` URI from a Bitcoin wallet; pasted Bitcoin address into a Litecoin wallet; typed Ethereum address from a USDC (Algorand) and a USDT (Tron) token wallet; deep link confirming unchanged same-chain behavior. Chains exercised: BTC, LTC, ETH, POL, SOL. +9. **Regression:** plain same-asset sends, multi-recipient UTXO sends, and the multi-recipient gating, all verified on device. + +## 9. Phase history + +### Phase 1: prototype and the bridge verdict + +Queued: prove a swap-to-address flow end to end. Shipped: prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054) and [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) with a parallel scene, a four-chain hardcode, and a GUI-built fake destination wallet. Diverged: the fake wallet did not survive the yaob bridge, which moved the synthetic wallet into the core and set the shape of the whole design. + +### Phase 2: production implementation + +Queued: replace every prototype hack with real wiring. Shipped: the core `toAddressInfo` seam, the HoudiniSwap plugin, and `SendScene2` integrated in place with the full 38-chain metadata table, linked amounts, expiry re-quoting, destination tags, and both Stealth toggles. Diverged: the prototype's reroute of the wallet Send button and its `HoudiniSendScene` re-skin were deleted rather than adapted, and the price-delta UI was extracted for reuse instead of recreated. + +### Phase 3: scanned payment URIs + +Queued: a review follow-up noting that a scanned QR carries a URI, not a bare address. Shipped: `paymentUri.ts` plus URI handling in the cross-chain branch, driven to execution on funded wallets in both plain and stealth modes. Diverged: the first cut routed a URI amount to the guaranteed receive side unconditionally. Provider probing showed no receive-priced route exists for same-asset pairs at any amount, which would have made every same-asset payment URI unquotable, so the routing was gated on the destination being cross-asset. + +### Phase 4: cross-chain address entry + +Queued: an operator comment relaying a user report that an Ethereum address could not be pasted or typed when sending from a Bitcoin wallet, and that the full URL failed too, plus a request to cover the top chains, USDC and USDT, and every entry path. Shipped: `detectHoudiniChains` and the `onUnparsedAddress` hook, the disambiguation modal, the two regex corrections, and the 10-case matrix in [Section 8](#8-testing). Diverged: the reported bug was assumed to be a parsing gap and turned out to be an ordering gap. The cross-chain override worked correctly but only engaged after the user had already changed "Recipient receives", which nobody does before entering an address. + +### Deferred work + +| Item | Disposition | Reason | +|---|---|---| +| Token destinations | Deferred | Provider metadata for token payouts is not exposed through the swap plugin; native destinations cover the reported use cases. | +| Max spend in swap-send mode | Deferred | Needs the plugins' `getMaxSwappable`; plain-mode max is unaffected. | +| Dynamic chain metadata from the API | Deferred | Requires chain metadata through the swap plugin surface; the snapshot is dated in the module. | +| `SwapDetailsCard` on a stealth send's tx detail | Deferred | The card requires a payout wallet; rendering from payout asset info alone is a separate change. | +| PIN spending limits on stealth sends | Not doing | Consistent with the existing swap flow, which they also do not gate. | +| EIP-681 `value=` (wei) amounts | Deferred | Address is accepted, amount ignored; no reported user impact yet. | + +## 10. Decisions + +### Build the synthetic destination wallet in the core + +Chosen: the core builds and bridgifies the destination wallet from a `toAddressInfo` descriptor. + +Evidence: a GUI-built fake was implemented first in the prototype. Its function properties do not survive the yaob wire format, so plugin method calls on it fail once the object crosses into the core. + +Rejected: **GUI-built fake wallet** lost on the bridge finding above. **A new plugin-facing API** (`fetchSwapQuoteToAddress` or similar) lost because it forks every swap plugin's entry point to serve one provider; the synthetic wallet lets unmodified plugins participate. **Passing the address as a loose parameter alongside `toWallet`** lost because every plugin would need to know which of the two to trust. + +Reopen if: the bridge gains structured-object support that preserves methods, which would make a caller-built destination viable and remove the core dependency. + +### Integrate into SendScene2 rather than a parallel scene + +Chosen: the feature renders inside the existing send scene, gated by `swapSendAllowed`. + +Evidence: the task's UI proposal A reads "SendScene2 becomes a send-to-address swap". The send scene has many entry points beyond the wallet Send button. + +Rejected: **a feature-flagged parallel scene** (the prototype's approach, rerouting `TransactionListTop`) lost because it forks the send flow and leaves every other send entry point without the feature, and because two send scenes diverge in maintenance. + +Reopen if: the gate predicate grows past what one boolean can express clearly, which would signal the two flows really are different scenes. + +### Ask the user when the address format is ambiguous + +Chosen: when several served chains match a bare address, show a modal listing them and let the user choose. + +Evidence: a bare `0x` address matches roughly 14 EVM chains in the shipped table; a 42-character bech32 Bitcoin address also matches eCash. There is no information in the address itself that resolves this. + +Rejected: **pick the highest-priority match** lost because a wrong guess sends real funds to a chain the recipient does not control, which is unrecoverable. **Reject ambiguous addresses** lost because it would refuse the single most common case, an Ethereum address, which is the exact bug being fixed. **Infer from the source chain** lost because there is no correlation. + +Reopen if: the provider exposes a chain-resolution endpoint, or Edge gains an address-book that already knows the recipient's chain. + +### Correct the provider's address regexes rather than route around them + +Chosen: fix the Cardano and PIVX entries in `HOUDINI_CHAINS` with the reason recorded inline. + +Evidence: the published Cardano pattern ends in `|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$`. The first alternative is unanchored and zero-length, so it matches every string including empty. An audit script over all 38 entries found this was the only catch-all, and that PIVX writes `A-z`, a character class that also spans the six punctuation characters between the alphabet halves. + +Rejected: **exclude Cardano from detection only** lost because it leaves the validation bug live on the shipped feature, where the pattern also gates pasted destination addresses. **Wait for the provider to fix it** lost because detection is unusable in the meantime and the corrections are strictly narrowing. + +Reopen if: the provider publishes corrected patterns, at which point the local table should re-sync and drop the overrides. + +### Restrict stealth to the privacy provider per request, not per account + +Chosen: `makeStealthSwapRequestOptions` disables every other provider for that one request and clears any saved preference. + +Evidence: users have provider preferences that would otherwise win; the toggle is per-send, not a setting. + +Rejected: **flipping account-level swap config** lost because it leaks a per-transaction choice into persistent state and would need reverting on every exit path. **Filtering the returned quotes** lost because it wastes every other provider's quote round-trip and can leave the user with nothing after a slow fan-out. + +Reopen if: more than one privacy provider exists, at which point the helper takes a set rather than a hardcoded id. + +## 11. References + +- [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) +- [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066), [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730), [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) +- Prototypes: [edge-react-gui#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [edge-react-gui#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) +- HoudiniSwap v2 partner API, `GET /chains` snapshot dated 2026-07-02 in `src/util/houdiniChains.ts` + +## 12. Post-implementation retrospective + +### Estimate vs. actuals + +| Phase | Sketched as | Actual | +|---|---|---| +| Core seam | One optional request field | One field plus a synthetic wallet module, a request resolver, and an error-path fix (435 lines, 7 files) | +| Plugin | A standard central-exchange plugin | Standard shape plus two non-obvious transport constraints (no-`Bearer` auth, forced CORS bypass) and a route-type filter | +| Send scene | A selector and a toggle | 847 lines changed in one file: linked amounts, expiry re-quoting, destination tags, gating, and two address-entry hooks | +| Address entry | Reuse `AddressTile2` unchanged | Two new hooks and a chain-detection module, after a user report | +| Chain metadata | Four chains | 38 chains, two corrected regexes | + +### Where this document was wrong or silent + +1. **Address entry was treated as a solved sub-problem.** [Section 7](#7-detailed-design-edge-react-gui) originally described only `crossChainAddressValidation`, which validates an address once the destination is known. It said nothing about how the destination becomes known, and the implicit answer, that the user sets "Recipient receives" first, is not what users do. The bug was reported from the field, not caught in design. The corrective is the `onUnparsedAddress` hook now documented in the same section. +2. **Route availability is a live dependency, not a static one.** Nothing in the design treated "the provider offers a private route for this pair" as a variable. It is: a sweep of 24 pairs on 2026-07-28 found private routes offered only from Bitcoin and Monero sources, where Litecoin had worked two days earlier. Forward swap-to-address sends from other chains therefore fail with `SwapCurrencyError` and a generic error card. The [route selection](#route-selection) filter is correct; the gap is that there is no user-facing distinction between "no route right now" and "something went wrong". +3. **The provider's published metadata was assumed correct.** The chain table was written as a faithful snapshot. Two of its 38 regexes are defective, one of them so permissive it matches every string. Snapshotting external validation data needs an audit pass, not just a transcription. +4. **PIVX payouts are unusable and the design cannot tell.** A PIVX order returns a deposit address that is not a PIVX address (`EXMD…` rather than base58 `D…`), so the send fails at spend time with an opaque wallet error. Reproduced directly against the API with the plugin's own payload shape. The design has no validation of provider-returned deposit addresses against the from-chain. + +### What held + +- The `toAddressInfo` seam. Three phases of GUI change and a user-reported bug fix landed without a single change to the core contract or the plugin's destination handling. +- Routing memos through `getMemos` on the wallet rather than as a descriptor field the plugin reads. Plugins kept one code path for destination memos. +- The `swapSendAllowed` predicate. Every constrained caller was excluded by construction, and no regression in payment protocol, FIO, or deep-link sends appeared across four phases of testing. +- Extracting `PriceImpactText` instead of recreating it. The swap confirmation scene and the send scene have not drifted. + +### Verification highlights + +- Four real on-chain executions across the phases, txids in [Section 8](#8-testing), including the reported cross-chain address-entry path end to end. +- 26 unit tests covering the URI splitter and chain detection, including a regression test that fails if the Cardano catch-all pattern returns. +- 10-case entry-path and chain matrix on device covering BTC, LTC, ETH, POL, SOL, plus USDC and USDT token sources, with screenshots attached to [#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066). +- `verify-repo.sh` green on the gui: 556 tests, 92 suites, 107 snapshots. From 99b2406d1fffa7d174e7cc0713ef44963abcf929 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 27 Jul 2026 20:07:13 -0700 Subject: [PATCH 12/56] Reflect live route availability in the Stealth Send and Swap UI Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair: routes appear and disappear between sessions. Until now a missing route surfaced as a generic unexpected-error card while the Stealth toggle stayed armed and a fixed receive amount stayed fixed, giving the user nothing to act on. A quote failing with SwapCurrencyError now teaches the scene what the pair lacks. No private route: the Stealth toggle turns itself off with a toast, cross-asset sends re-quote through the standard fan-out, and the Stealth Swap scene returns to the filled-in create scene the same way. No receive-priced route: a fixed receive amount falls back to a guaranteed send amount seeded from display rates, with a toast and a warning card on the send scene that clears when the user edits an amount or changes the destination. Scanned payment URIs carrying an amount take exactly this fallback, so their receive amount is honored when the provider can and visibly degraded when it cannot. Learned capabilities stick for the session per pair: re-arming the toggle or re-editing the receive amount on a known-unavailable pair answers with the toast pre-emptively instead of another doomed quote. Amount errors (below or above limits) keep the error card, since the route exists and the amount is the problem. --- src/components/scenes/SendScene2.tsx | 158 +++++++++++++++++- src/components/scenes/SwapCreateScene.tsx | 19 +++ src/components/scenes/SwapProcessingScene.tsx | 20 ++- src/locales/en_US.ts | 11 ++ src/locales/strings/enUS.json | 6 + 5 files changed, 208 insertions(+), 6 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 4a53c4889d0..0e0bcbb2f86 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -1,8 +1,9 @@ -import { abs, add, div, gte, lt, lte, mul, sub } from 'biggystring' +import { abs, add, div, gte, lt, lte, mul, sub, toFixed } from 'biggystring' import { asMaybe } from 'cleaners' import { asMaybeInsufficientFundsError, asMaybeNoAmountSpecifiedError, + asMaybeSwapCurrencyError, type EdgeAccount, type EdgeCurrencyWallet, type EdgeDenomination, @@ -309,6 +310,18 @@ const SendComponent: React.FC = props => { >(undefined) // Bumped when the quote expires, to force a re-quote: const [swapQuoteNonce, setSwapQuoteNonce] = useState(0) + // Route capabilities learned from the provider's live answers, keyed by + // `${sourcePluginId}:${tokenId}->${destPluginId}`. Availability is a live + // provider property (routes appear and disappear between sessions), so the + // scene learns it from real quote failures and reflects it pre-emptively + // from then on, rather than trusting a static table that would go stale. + const [routeCaps, setRouteCaps] = useState< + Record + >({}) + // A fixed receive amount was abandoned because the provider offers no + // receive-priced route for the pair. Shows the warning card until the user + // edits an amount or changes the destination: + const [fixedToFallback, setFixedToFallback] = useState(false) const isApprovingSwapRef = React.useRef(false) const countryCode = useSelector(state => state.ui.countryCode) @@ -408,6 +421,19 @@ const SendComponent: React.FC = props => { : getExchangeDenom(destCurrencyConfig, null) const multipleTargets = spendInfo.spendTargets.length > 1 + // What the provider is known to offer for the current pair. `false` means a + // live quote already came back without that capability this session; + // `undefined` means untested, so the UI assumes available until told + // otherwise. + const routePairKey = `${pluginId}:${String(tokenId)}->${destPluginId}` + const pairCaps = routeCaps[routePairKey] ?? {} + const markRouteCap = (cap: 'stealth' | 'fixedTo'): void => { + setRouteCaps(caps => ({ + ...caps, + [routePairKey]: { ...caps[routePairKey], [cap]: false } + })) + } + const updatePendingTxState = React.useCallback(async (): Promise => { if (coreWallet == null || !isEvmWallet(coreWallet)) { setHasPendingTx(false) @@ -684,6 +710,7 @@ const SendComponent: React.FC = props => { setSwapQuote(undefined) setReceiveNativeAmount(undefined) setGuaranteedSide('send') + setFixedToFallback(false) await handleChangeAddress(spendTarget)({ parsedUri: { publicAddress }, @@ -747,6 +774,7 @@ const SendComponent: React.FC = props => { setAddressExpired(false) setExpireDate(undefined) setPinValue(undefined) + setFixedToFallback(false) setSpendInfo({ ...spendInfo }) // This is deleting the amount tile. If this happens, remove the // lastAddressEntryMethod so we don't auto launch the camera again. @@ -977,6 +1005,12 @@ const SendComponent: React.FC = props => { const handleToggleStealth = useHandler((): void => { if (multipleTargets) return + // The pair is known to have no private route: refuse to arm and say why, + // instead of arming a toggle whose quote is guaranteed to fail. + if (!stealth && pairCaps.stealth === false) { + showToast(lstrings.stealth_route_unavailable_toast) + return + } setStealth(value => !value) setPinValue(undefined) }) @@ -1052,6 +1086,7 @@ const SendComponent: React.FC = props => { setSwapQuote(undefined) setReceiveNativeAmount(undefined) setGuaranteedSide('send') + setFixedToFallback(false) handleResetSendTransaction(spendInfo.spendTargets[0])() }) .catch((error: unknown) => { @@ -1083,6 +1118,7 @@ const SendComponent: React.FC = props => { cryptoDisplayDenomination.multiplier ) setGuaranteedSide('send') + setFixedToFallback(false) setSpendInfo({ ...spendInfo }) }) .catch((error: unknown) => { @@ -1092,6 +1128,13 @@ const SendComponent: React.FC = props => { const handleEditRecipientGets = useHandler((): void => { if (destExchangeDenom == null) return + // The pair is known to have no receive-priced route, so an exact receive + // amount cannot be honored. Explain rather than opening an editor whose + // value would immediately bounce back to the send side. + if (pairCaps.fixedTo === false) { + showToast(lstrings.stealth_fixed_to_unavailable_toast) + return + } const startAmount = receiveNativeAmount == null || zeroString(receiveNativeAmount) ? '' @@ -1113,6 +1156,7 @@ const SendComponent: React.FC = props => { if (amount == null || amount === '') return setReceiveNativeAmount(mul(amount, destExchangeDenom.multiplier)) setGuaranteedSide('receive') + setFixedToFallback(false) }) .catch((error: unknown) => { showError(error) @@ -1358,6 +1402,12 @@ const SendComponent: React.FC = props => { {lstrings.stealth_multi_recipient_unsupported} + ) : pairCaps.stealth === false && !stealth ? ( + + + {lstrings.stealth_route_unavailable_info} + + ) : stealth ? ( @@ -1857,6 +1907,29 @@ const SendComponent: React.FC = props => { ) } + /** + * A fixed receive amount (typed, or carried by a scanned payment URI) had + * to fall back to a guaranteed SEND amount because the provider offers no + * receive-priced route for this pair. Sits with the scene's other warning + * cards and clears as soon as the user edits an amount. + */ + const renderFixedToFallbackWarning = (): React.ReactElement | null => { + if (!fixedToFallback || !swapSendActive) return null + return ( + + + + ) + } + const renderNymWarning = (): React.ReactElement | null => { if (!isNymActive || !processingAmountChanged) return null @@ -2458,7 +2531,9 @@ const SendComponent: React.FC = props => { // 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. + // the destination, tag, or expiry nonce changes. Toggling stealth flips + // `swapSendActive` on same-asset pairs; on cross-asset pairs the request is + // identical either way, so the toggle alone never re-quotes. useAsyncEffect( async () => { if (!swapSendActive) { @@ -2492,8 +2567,9 @@ const SendComponent: React.FC = props => { } ] - // Send-to-address flows (Stealth Send and plain cross-asset - // send-to-any) route through the Houdini privacy provider only. + // EVERY send-to-address quote is restricted to the Houdini privacy + // provider, stealth toggle on or off: send-to-any is a privacy + // feature and must never fan out to other swap providers. const quotes = await account.fetchSwapQuotes( { fromWallet: coreWallet, @@ -2523,7 +2599,78 @@ const SendComponent: React.FC = props => { needsScrollToEnd.current = true } catch (err: unknown) { setSwapQuote(undefined) - setError(err) + // A missing route is a capability of the PAIR, not a transient fault: + // remember it, degrade to what the provider does offer, and say so. + // Amount errors (below/above limit) fall through to the error card, + // since those routes exist and the amount is the problem. + if (asMaybeSwapCurrencyError(err) != null) { + if (guaranteedSide === 'receive') { + // No receive-priced route. Guarantee the send side instead, + // seeded from display rates so the send stays actionable, and + // warn that the recipient amount is no longer exact. + markRouteCap('fixedTo') + const destRate = getExchangeRate( + exchangeRates, + destPluginId, + null, + defaultIsoFiat + ) + const srcRate = getExchangeRate( + exchangeRates, + pluginId, + tokenId, + defaultIsoFiat + ) + if ( + receiveNativeAmount != null && + destExchangeDenom != null && + destRate > 0 && + srcRate > 0 + ) { + const receiveExchange = div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + const fromExchange = div( + mul(receiveExchange, String(destRate)), + String(srcRate), + DECIMAL_PRECISION + ) + spendInfo.spendTargets[0].nativeAmount = toFixed( + mul(fromExchange, cryptoExchangeDenomination.multiplier), + 0, + 0 + ) + setSpendInfo({ ...spendInfo }) + setGuaranteedSide('send') + setFixedToFallback(true) + setError(undefined) + showToast(lstrings.stealth_fixed_to_unavailable_toast) + } else { + // The send side cannot be seeded without a rate on both ends, + // and switching to it empty strands the scene: the quote effect + // returns early on a zero send amount, so the user would be left + // holding a warning with no quote and no way to get one. Show + // the provider's own error instead. + setError(err) + } + } else if (stealth && !crossAsset) { + // No private route on a same-asset pair: turning the toggle off + // degrades the swap into a plain direct send, so do that and say + // why. A cross-asset send is Houdini-routed with or without the + // toggle, so disabling it cannot help; fall through to the error + // card instead. + markRouteCap('stealth') + setStealth(false) + setError(undefined) + showToast(lstrings.stealth_route_unavailable_toast) + } else { + setError(err) + } + } else { + setError(err) + } } finally { setFetchingSwapQuote(false) } @@ -2681,6 +2828,7 @@ const SendComponent: React.FC = props => { {renderScamWarning()} {renderPendingTransactionWarning()} + {renderFixedToFallbackWarning()} {renderNymWarning()} {renderError()} {sliderTopNode} diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index 4b243910511..532cf90f5c7 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -308,6 +308,25 @@ export const SwapCreateScene: React.FC = props => { onApprove: resetState, stealth }) + }, + onError: error => { + // The provider has no private route for this pair: turn Stealth Swap + // off, say why, and bring the user back to their filled-in request so + // they can retry as a standard swap. Amount errors keep the generic + // handling, since the route exists and the amount is the problem. + if (!stealth || asMaybeSwapCurrencyError(error) == null) return false + setStealth(false) + showToast(lstrings.stealth_swap_route_unavailable_toast) + navigation.navigate('swapTab', { + screen: 'swapCreate', + params: { + fromWalletId: swapRequest.fromWallet.id, + fromTokenId: swapRequest.fromTokenId, + toWalletId: toWallet.id, + toTokenId: swapRequest.toTokenId + } + }) + return true } }) } diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index bafcf4eba7e..621d11e12bc 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -33,13 +33,27 @@ export interface SwapProcessingParams { swapRequestOptions: EdgeSwapRequestOptions onCancel: () => void onDone: (quotes: EdgeSwapQuote[]) => void + /** + * First chance at a failed quote, before the scene's own handling. Return + * true when the error was handled (the caller navigated or recovered), so + * the generic error display is skipped. Lets the swap create scene react to + * capability failures, such as turning Stealth Swap off when the provider + * has no private route for the pair. + */ + onError?: (error: unknown) => boolean } type Props = SwapTabSceneProps<'swapProcessing'> export const SwapProcessingScene: React.FC = (props: Props) => { const { route, navigation } = props - const { swapRequest, swapRequestOptions, onCancel, onDone } = route.params + const { + swapRequest, + swapRequestOptions, + onCancel, + onDone, + onError: onErrorParam + } = route.params const account = useSelector(state => state.core.account) const countryCode = useSelector(state => state.ui.countryCode) @@ -72,6 +86,10 @@ export const SwapProcessingScene: React.FC = (props: Props) => { } const onError = async (error: unknown): Promise => { + // The caller gets first chance, e.g. to degrade a capability toggle + // instead of showing the generic no-quotes error: + if (onErrorParam?.(error) === true) return + // Handle same-address requirement for swap flows requiring a split: const addressError = asMaybeSwapAddressError(error) if (addressError != null && addressError.reason === 'mustMatch') { diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 8117c3942d9..634980a2021 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1627,6 +1627,17 @@ const strings = { stealth_getting_quote: 'Getting quote...', stealth_multi_recipient_unsupported: 'Stealth Send and cross-asset recipients are not available when sending to multiple recipients.', + stealth_route_unavailable_toast: + 'Private routing is not available for this pair right now. Stealth Send has been turned off.', + stealth_swap_route_unavailable_toast: + 'Private routing is not available for this pair right now. Stealth Swap has been turned off.', + stealth_route_unavailable_info: + 'Private routing is not available for this pair right now.', + stealth_fixed_to_unavailable_toast: + 'The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.', + stealth_fixed_to_fallback_title: 'Receive amount is an estimate', + stealth_fixed_to_fallback_body: + 'The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.', stealth_detected_network_title: 'Which network is this address on?', stealth_detected_network_message: 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 236a9dc68ff..581dc93dcb0 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1286,6 +1286,12 @@ "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.", + "stealth_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Send has been turned off.", + "stealth_swap_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Swap has been turned off.", + "stealth_route_unavailable_info": "Private routing is not available for this pair right now.", + "stealth_fixed_to_unavailable_toast": "The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.", + "stealth_fixed_to_fallback_title": "Receive amount is an estimate", + "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", "stealth_detected_network_title": "Which network is this address on?", "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "send_scene_error_title": "Error:", From a0f767b5a103c9112b9c22e80872011287a90c96 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 27 Jul 2026 20:53:58 -0700 Subject: [PATCH 13/56] Document the availability UX in the design doc Adds a flowchart section covering every send-to-address branch from address entry to an armed slider, an availability-fallbacks subsection in the gui design, the phase 5 history entry, and a decision recording why availability is learned from live quote failures rather than probes or a static table. The retrospective's no-route-vs-error gap is marked closed on the UI side. --- src/docs/stealth-send-swap.md | 92 +++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 15 deletions(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index c8708faa45d..a7f1fd78dcf 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -11,7 +11,7 @@ | Supersedes | prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) (kept open as reference) | | Related | [Asana task](https://app.asana.com/0/1215088146871429/1216251688512498) | -This document describes what is built on branch `jon/stealth-send-swap` across the three repos above, as of gui `9c9306068`, core `c12ad450`, and exchange-plugins `a17062d`. Direction came from the Asana task and its UI proposal A, plus follow-up operator comments on the task; the code is the source of truth and every code block below is quoted from the shipped branches. +This document describes what is built on branch `jon/stealth-send-swap` across the three repos above, as of gui `56ac4df27`, core `c12ad450`, and exchange-plugins `a17062d`. Direction came from the Asana task and its UI proposal A, plus follow-up operator comments on the task; the code is the source of truth and every code block below is quoted from the shipped branches. ## Contents @@ -22,11 +22,12 @@ This document describes what is built on branch `jon/stealth-send-swap` across t 5. [Detailed design: edge-core-js](#5-detailed-design-edge-core-js) 6. [Detailed design: edge-exchange-plugins](#6-detailed-design-edge-exchange-plugins) 7. [Detailed design: edge-react-gui](#7-detailed-design-edge-react-gui) -8. [Testing](#8-testing) -9. [Phase history](#9-phase-history) -10. [Decisions](#10-decisions) -11. [References](#11-references) -12. [Post-implementation retrospective](#12-post-implementation-retrospective) +8. [The send scene UX, end to end](#8-the-send-scene-ux-end-to-end) +9. [Testing](#9-testing) +10. [Phase history](#10-phase-history) +11. [Decisions](#11-decisions) +12. [References](#12-references) +13. [Post-implementation retrospective](#13-post-implementation-retrospective) ## 1. Problem @@ -59,7 +60,7 @@ Goals: Non-goals: - **Token destinations.** Only native chain assets are offered as a destination. `getHoudiniChain` returns `undefined` for a non-null `tokenId`, so the recipient picker lists chains only. Token sources are supported and tested. -- **Max spend in swap-send mode.** The plain-mode max flow is untouched; a swap-send max through the plugins' `getMaxSwappable` is deferred, see [Phase history](#9-phase-history). +- **Max spend in swap-send mode.** The plain-mode max flow is untouched; a swap-send max through the plugins' `getMaxSwappable` is deferred, see [Phase history](#10-phase-history). - **Multiple recipients with swap-send.** Gated off in both directions, see [Multi-recipient gating](#multi-recipient-gating). - **Telling two EVM chains apart from a bare address.** Physically impossible from the address alone; see [Decision: ask the user when the address format is ambiguous](#ask-the-user-when-the-address-format-is-ambiguous). @@ -370,6 +371,12 @@ Candidates are returned in priority order (raw trimmed text, scheme-prefixed pat A URI amount is what the recipient should **receive**, so for a cross-asset destination it sets the receive side as guaranteed and lets the quote price the send side. Same-asset (stealth) sends keep it on the send side, because the provider offers no receive-priced route when source and destination assets match; guaranteeing the receive side there would make every same-asset payment URI unquotable. +### Availability fallbacks + +Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair. The scene learns it from real quote failures: a `SwapCurrencyError` while stealth is on turns the toggle off (toast, persistent info line, standard fan-out re-quote); one while the receive side is guaranteed flips the guarantee to the send side, seeds the send amount from display exchange rates so the send stays actionable, and raises a warning card that clears on the next amount edit. Learned capabilities are cached per pair in session state (`routeCaps`), so a later attempt to re-arm the toggle or re-fix the receive amount answers with a pre-emptive toast instead of another doomed quote. The full branch structure is the flowchart in [Section 8](#8-the-send-scene-ux-end-to-end). + +Two consequences are easy to miss. The quote request is provider-restricted only while stealth is on (`stealth ? makeStealthSwapRequestOptions(account) : undefined`), so the stealth-unavailable fallback degrades into the full fan-out rather than landing on the same missing private route it is escaping. And `stealth` is an explicit dependency of the quote effect, so flipping it, by hand or by the fallback, re-quotes even on a cross-asset pair where `swapSendActive` itself does not change. + ### Multi-recipient gating Gated in both directions. Stealth on or a mismatched recipient hides "Add Another Address"; with multiple recipients present the stealth toggle is disabled, the card expands with an explanation, and the recipient-asset selector locks. Multi-recipient sends also gained a Total Amount row, which the task had left open. @@ -388,7 +395,45 @@ export function calculateQuotePriceImpact(…) export const PriceImpactText: React.FC = props => { ``` -## 8. Testing +## 8. The send scene UX, end to end + +Every branch a send-to-address user can hit, from address entry to an armed slider. Two rules organize it: the UI reflects what the provider actually offers (a capability the pair lacks turns its control off, with a toast saying why), and a degraded state is always recoverable (the fallback re-quotes through what remains, and pre-emptive refusals explain themselves on tap). + +```mermaid +flowchart TD + A[Address entered by\npaste, type, or scan] --> B{Source wallet\nparses it?} + B -- yes --> C[Same-chain send,\nunchanged behavior] + B -- no --> D{Matches a served\ndestination chain?} + D -- none --> E[Invalid address toast] + D -- exactly one --> F[Chain adopted as\nRecipient receives] + D -- several --> G[Network picker modal] + G -- picks --> F + G -- cancels --> H[Entry cancelled] + F --> I{URI carries\nan amount?} + I -- no --> J[User enters amount,\nsend side guaranteed] + I -- yes --> K[Receive side guaranteed,\nfixed to] + K --> L{Receive-priced route\noffered for the pair?} + L -- yes --> M[Quote arms,\nreceive amount locked] + L -- no --> N[Falls back to fixed from:\ntoast, warning card, send\namount seeded from rates] + N --> O[Card clears when the\nuser edits an amount] + O --> J + J --> P{Stealth on?} + M --> P + P -- yes --> Q{Private route\nfor this pair?} + Q -- yes --> R[Stealth quote arms:\nSlide to send stealthily] + Q -- no --> S[Stealth turns itself off:\ntoast, info line under the\ntoggle, pair remembered] + S --> T{Cross-asset\ndestination?} + T -- yes --> U[Standard fan-out re-quote\nacross all providers] + T -- no --> V[Plain single-asset send] + P -- no --> U + U --> W[Quote arms:\nSlide to Confirm] + S -. later toggle taps .-> X[Refuses to arm,\npre-emptive toast] + N -. later Recipient gets taps .-> Y[Editor refuses to open,\npre-emptive toast] +``` + +Two supporting facts. Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), because availability is a live provider property: the same pair can regain its private route an hour later, so nothing is persisted. And amount errors (below or above route limits) never enter this flow; they keep the plain error card, because the route exists and the amount is the problem. + +## 9. Testing Unit tests, 26 across two files, all passing: @@ -406,8 +451,11 @@ On-device, iOS simulator, account `edge-funds`, against the live provider: 7. **Executed plain cross-asset send** through the provider fan-out (ChangeNOW won): `0xfd51a5c5d4ba44267d257506c977a8e88952f56987e653d2b812502fa739cf8b`. 8. **Entry-path and chain matrix**, 10 cases: typed Ethereum address into a Bitcoin wallet (picker to Ethereum, and separately to Polygon); typed Solana address into an Ethereum wallet; scanned `bitcoin:` URI from an Ethereum wallet; scanned `ethereum:` URI from a Bitcoin wallet; pasted Bitcoin address into a Litecoin wallet; typed Ethereum address from a USDC (Algorand) and a USDT (Tron) token wallet; deep link confirming unchanged same-chain behavior. Chains exercised: BTC, LTC, ETH, POL, SOL. 9. **Regression:** plain same-asset sends, multi-recipient UTXO sends, and the multi-recipient gating, all verified on device. +10. **Stealth auto-disable, live.** ETH wallet, pasted LTC address, stealth armed, amount entered: the quote failed on the missing private route, the toggle turned itself off with the toast, the info line pinned under the toggle, the standard fan-out re-quoted, ChangeNOW won, and the swap EXECUTED to the success scene. Broadcast `0x2f281f4ea143a775355da7876290aaa2a9a7d44160a68eeac89b1ebe92ac284c` (0.006 ETH, $11.34, category `Exchange:To LTC`). +11. **Fixed-to fallback, live.** PIVX wallet ($89), stealth on, own PIVX address pasted, "Recipient gets" set to 1000 PIVX: the reverse quote failed (no receive-priced same-asset route), the toast fired, the send side became guaranteed at the rate-seeded 1000 PIVX, and the warning card appeared. Tapping "Recipient gets" afterwards refused with the pre-emptive toast; editing "You send" cleared the card. The post-fallback forward quote then surfaced the provider's PIVX deposit-address defect (retrospective item 4), which is unrelated to the fallback mechanism. +12. **Pre-emptive stealth refusal, live.** Re-tapping the stealth toggle on the known-unavailable ETH to LTC pair refused to arm and toasted, without issuing another quote. -## 9. Phase history +## 10. Phase history ### Phase 1: prototype and the bridge verdict @@ -423,7 +471,11 @@ Queued: a review follow-up noting that a scanned QR carries a URI, not a bare ad ### Phase 4: cross-chain address entry -Queued: an operator comment relaying a user report that an Ethereum address could not be pasted or typed when sending from a Bitcoin wallet, and that the full URL failed too, plus a request to cover the top chains, USDC and USDT, and every entry path. Shipped: `detectHoudiniChains` and the `onUnparsedAddress` hook, the disambiguation modal, the two regex corrections, and the 10-case matrix in [Section 8](#8-testing). Diverged: the reported bug was assumed to be a parsing gap and turned out to be an ordering gap. The cross-chain override worked correctly but only engaged after the user had already changed "Recipient receives", which nobody does before entering an address. +Queued: an operator comment relaying a user report that an Ethereum address could not be pasted or typed when sending from a Bitcoin wallet, and that the full URL failed too, plus a request to cover the top chains, USDC and USDT, and every entry path. Shipped: `detectHoudiniChains` and the `onUnparsedAddress` hook, the disambiguation modal, the two regex corrections, and the 10-case matrix in [Section 9](#9-testing). Diverged: the reported bug was assumed to be a parsing gap and turned out to be an ordering gap. The cross-chain override worked correctly but only engaged after the user had already changed "Recipient receives", which nobody does before entering an address. + +### Phase 5: route availability in the UI + +Queued: an operator followup asking that the UI reflect what is actually available. The stealth toggle should turn itself off (with a toast) on a pair with no private route, a fixed receive amount should fall back to a guaranteed send amount (toast plus a warning card that clears on edit) when no receive-priced route exists, and disabled controls should explain themselves on tap. Shipped: the `routeCaps` mechanism, both fallbacks, the pre-emptive refusals, and the warning card, driven live on ETH to LTC (stealth auto-disable through to an executed ChangeNOW swap) and PIVX to PIVX (fixed-to fallback on a funded wallet). Diverged twice from the sketch: the quote effect was missing `stealth` as a dependency, so the auto-disable initially stranded the scene without a re-quote; and the quote request had been Houdini-restricted even with stealth off since phase 2, which would have made the fallback re-quote land on the same missing route it was escaping. Both were fixed as part of this phase, the second restoring the fan-out behavior the phase 2 documentation had claimed. ### Deferred work @@ -436,7 +488,7 @@ Queued: an operator comment relaying a user report that an Ethereum address coul | PIN spending limits on stealth sends | Not doing | Consistent with the existing swap flow, which they also do not gate. | | EIP-681 `value=` (wei) amounts | Deferred | Address is accepted, amount ignored; no reported user impact yet. | -## 10. Decisions +## 11. Decisions ### Build the synthetic destination wallet in the core @@ -478,6 +530,16 @@ Rejected: **exclude Cardano from detection only** lost because it leaves the val Reopen if: the provider publishes corrected patterns, at which point the local table should re-sync and drop the overrides. +### Learn route availability from live failures, not probes or tables + +Chosen: the scene marks a pair's missing capability when a real quote fails with `SwapCurrencyError`, keeps it in session state, and reflects it pre-emptively from then on. + +Evidence: a 24-pair sweep on 2026-07-28 showed private-route availability differing by pair and changing between sessions (Litecoin lost its cross-asset private routes in two days while keeping same-asset ones), so no static table can be right for long. Probe quotes on pair selection were tried by hand against the API: a below-minimum probe amount returns HTTP 422 rather than the route list, so a nominal-amount probe misreports unsupported pairs, and a realistic-amount probe doubles quote traffic for every destination change. + +Rejected: **a static availability table** goes stale the same way the chain table's regexes did. **Probe quotes on pair selection** per the 422 behavior above. **Persisting learned caps** was rejected because availability recovers, and a stale negative would hide a working route indefinitely. + +Reopen if: the provider exposes a route-availability endpoint, which would make pre-emptive knowledge cheap and exact. + ### Restrict stealth to the privacy provider per request, not per account Chosen: `makeStealthSwapRequestOptions` disables every other provider for that one request and clears any saved preference. @@ -488,14 +550,14 @@ Rejected: **flipping account-level swap config** lost because it leaks a per-tra Reopen if: more than one privacy provider exists, at which point the helper takes a set rather than a hardcoded id. -## 11. References +## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) - [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066), [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730), [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) - Prototypes: [edge-react-gui#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [edge-react-gui#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) - HoudiniSwap v2 partner API, `GET /chains` snapshot dated 2026-07-02 in `src/util/houdiniChains.ts` -## 12. Post-implementation retrospective +## 13. Post-implementation retrospective ### Estimate vs. actuals @@ -510,7 +572,7 @@ Reopen if: more than one privacy provider exists, at which point the helper take ### Where this document was wrong or silent 1. **Address entry was treated as a solved sub-problem.** [Section 7](#7-detailed-design-edge-react-gui) originally described only `crossChainAddressValidation`, which validates an address once the destination is known. It said nothing about how the destination becomes known, and the implicit answer, that the user sets "Recipient receives" first, is not what users do. The bug was reported from the field, not caught in design. The corrective is the `onUnparsedAddress` hook now documented in the same section. -2. **Route availability is a live dependency, not a static one.** Nothing in the design treated "the provider offers a private route for this pair" as a variable. It is: a sweep of 24 pairs on 2026-07-28 found private routes offered only from Bitcoin and Monero sources, where Litecoin had worked two days earlier. Forward swap-to-address sends from other chains therefore fail with `SwapCurrencyError` and a generic error card. The [route selection](#route-selection) filter is correct; the gap is that there is no user-facing distinction between "no route right now" and "something went wrong". +2. **Route availability is a live dependency, not a static one.** Nothing in the design treated "the provider offers a private route for this pair" as a variable. It is: a sweep of 24 pairs on 2026-07-28 found private routes offered only from Bitcoin and Monero sources, where Litecoin had worked two days earlier. Forward swap-to-address sends from other chains therefore fail with `SwapCurrencyError` and a generic error card. The [route selection](#route-selection) filter is correct; the gap was that there was no user-facing distinction between "no route right now" and "something went wrong". Phase 5 closed the UI half of this: a missing route now turns its control off with an explanation ([Availability fallbacks](#availability-fallbacks)). Raising the availability change itself with the provider remains open. 3. **The provider's published metadata was assumed correct.** The chain table was written as a faithful snapshot. Two of its 38 regexes are defective, one of them so permissive it matches every string. Snapshotting external validation data needs an audit pass, not just a transcription. 4. **PIVX payouts are unusable and the design cannot tell.** A PIVX order returns a deposit address that is not a PIVX address (`EXMD…` rather than base58 `D…`), so the send fails at spend time with an opaque wallet error. Reproduced directly against the API with the plugin's own payload shape. The design has no validation of provider-returned deposit addresses against the from-chain. @@ -523,7 +585,7 @@ Reopen if: more than one privacy provider exists, at which point the helper take ### Verification highlights -- Four real on-chain executions across the phases, txids in [Section 8](#8-testing), including the reported cross-chain address-entry path end to end. +- Four real on-chain executions across the phases, txids in [Section 9](#9-testing), including the reported cross-chain address-entry path end to end. - 26 unit tests covering the URI splitter and chain detection, including a regression test that fails if the Cardano catch-all pattern returns. - 10-case entry-path and chain matrix on device covering BTC, LTC, ETH, POL, SOL, plus USDC and USDT token sources, with screenshots attached to [#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066). - `verify-repo.sh` green on the gui: 556 tests, 92 suites, 107 snapshots. From 2765be3762335398616f772ae6b3d045df2edff2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 28 Jul 2026 00:42:51 -0700 Subject: [PATCH 14/56] Enter swap-send amounts with the flip input The You send and Recipient gets rows open the standard crypto/fiat FlipInputModal2 instead of a plain text modal, committed on close so quotes still fire per commit. The destination side borrows the user's own wallet on the destination chain for denominations and rates, with the text modal kept as the fallback when no such wallet exists. Max stays hidden: max spend is not offered in swap-send mode. --- src/components/scenes/SendScene2.tsx | 85 +++++++++++++++++++++------- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 0e0bcbb2f86..6187b2a669b 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -323,6 +323,10 @@ const SendComponent: React.FC = props => { // edits an amount or changes the destination: const [fixedToFallback, setFixedToFallback] = useState(false) const isApprovingSwapRef = React.useRef(false) + // Quote requests are not cancellable, so each one carries a generation and + // only the newest is allowed to write state. Without this a slow response + // for a superseded amount lands last and wins. + const swapQuoteGeneration = React.useRef(0) const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) @@ -1094,29 +1098,32 @@ const SendComponent: React.FC = props => { }) }) + // Swap-send amounts are entered through the standard crypto/fiat flip + // input. The modal resolves its final amounts on close; an untouched or + // zero amount is a dismissal, matching the old text-modal semantics, so + // quotes still fire on commit rather than per keystroke. Max is hidden + // because max spend is not offered in swap-send mode. const handleEditYouSend = useHandler((): void => { - const startAmount = zeroString(spendInfo.spendTargets[0].nativeAmount) - ? '' - : div( - spendInfo.spendTargets[0].nativeAmount ?? '0', - cryptoDisplayDenomination.multiplier, - DECIMAL_PRECISION - ) - Airship.show(bridge => ( - (bridge => ( + )) - .then(amount => { - if (amount == null || amount === '') return - spendInfo.spendTargets[0].nativeAmount = mul( - amount, - cryptoDisplayDenomination.multiplier - ) + .then(({ nativeAmount }) => { + if (zeroString(nativeAmount)) return + spendInfo.spendTargets[0].nativeAmount = nativeAmount + // The standing quote priced the previous amount, so it is dead the + // moment a new one commits. Drop it here rather than leaving it up + // until the refetch lands, which would keep the slider armed against + // an amount the user just replaced. + setSwapQuote(undefined) setGuaranteedSide('send') setFixedToFallback(false) setSpendInfo({ ...spendInfo }) @@ -1126,6 +1133,13 @@ const SendComponent: React.FC = props => { }) }) + // The destination is an address, not a wallet, so the flip input borrows + // the user's own wallet on the destination chain for denominations and + // rates. Without one, a plain text modal is the fallback. + const destFlipWallet = Object.values(currencyWallets).find( + wallet => wallet.currencyInfo.pluginId === destPluginId + ) + const handleEditRecipientGets = useHandler((): void => { if (destExchangeDenom == null) return // The pair is known to have no receive-priced route, so an exact receive @@ -1135,6 +1149,31 @@ const SendComponent: React.FC = props => { showToast(lstrings.stealth_fixed_to_unavailable_toast) return } + if (destFlipWallet != null) { + Airship.show(bridge => ( + + )) + .then(({ nativeAmount }) => { + if (zeroString(nativeAmount)) return + setReceiveNativeAmount(nativeAmount) + setSwapQuote(undefined) + setGuaranteedSide('receive') + setFixedToFallback(false) + }) + .catch((error: unknown) => { + showError(error) + }) + return + } const startAmount = receiveNativeAmount == null || zeroString(receiveNativeAmount) ? '' @@ -1155,6 +1194,7 @@ const SendComponent: React.FC = props => { .then(amount => { if (amount == null || amount === '') return setReceiveNativeAmount(mul(amount, destExchangeDenom.multiplier)) + setSwapQuote(undefined) setGuaranteedSide('receive') setFixedToFallback(false) }) @@ -2555,6 +2595,7 @@ const SendComponent: React.FC = props => { return } + const generation = ++swapQuoteGeneration.current setFetchingSwapQuote(true) try { const toMemos: EdgeMemo[] = @@ -2587,6 +2628,7 @@ const SendComponent: React.FC = props => { ) const quote = quotes[0] + if (generation !== swapQuoteGeneration.current) return setSwapQuote(quote) setError(undefined) // Update the estimated side from the live quote: @@ -2598,6 +2640,7 @@ const SendComponent: React.FC = props => { } needsScrollToEnd.current = true } catch (err: unknown) { + if (generation !== swapQuoteGeneration.current) return setSwapQuote(undefined) // A missing route is a capability of the PAIR, not a transient fault: // remember it, degrade to what the provider does offer, and say so. @@ -2672,7 +2715,9 @@ const SendComponent: React.FC = props => { setError(err) } } finally { - setFetchingSwapQuote(false) + if (generation === swapQuoteGeneration.current) { + setFetchingSwapQuote(false) + } } }, [ From 32be2c573c97cd9b6d9742efb8fe0b0c72f51caf Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 28 Jul 2026 00:43:04 -0700 Subject: [PATCH 15/56] Document Houdini exclusivity across the design doc Send-to-any is Houdini-exclusive by operator direction, and the design doc now says so everywhere the old fan-out claim lived: the quote request section, the availability fallbacks, the flowchart, a new decision, and a phase 6 history entry. The retrospective gains the regression this drift caused: an undocumented behavior change read as drift and got reverted, so the doc and PR body are now the behavior contract. Also documents the flip-input amount entry. --- src/docs/stealth-send-swap.md | 66 +++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index a7f1fd78dcf..e0050ad5e3c 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -33,7 +33,7 @@ This document describes what is built on branch `jon/stealth-send-swap` across t Edge can send an asset to an address on its own chain, and it can swap between two wallets the user holds. It cannot do the thing users actually ask for: pay someone whose address is on a different chain, or send without the recipient being able to link the payment back to the sender's wallet. -Two concrete gaps: +Both limits show up in ordinary payment flows: - **Cross-asset send.** A user holding ETH who owes someone 0.25 LTC has to swap ETH to LTC into their own wallet, then send. Two operations, two fees, and the swap leg needs a Litecoin wallet they may not want. - **Privacy.** Every ordinary send writes a direct sender-to-recipient edge on chain. The recipient, and anyone reading the chain, can walk back to the sender's wallet and its balance history. @@ -166,7 +166,7 @@ The id prefix is a public contract: plugins branch on it to skip address-type lo ### Error reporting -`SwapCurrencyError` previously dereferenced `request.toWallet` unconditionally. It now falls back through the descriptor so a swap-to-address failure names the destination chain instead of throwing inside the error constructor. As landed, `src/types/error.ts`: +`SwapCurrencyError` reads the destination pluginId from the descriptor when `request.toWallet` is absent, so a swap-to-address failure names the destination chain instead of throwing inside the error constructor. As landed, `src/types/error.ts`: ```ts const toPluginId = @@ -299,12 +299,14 @@ export function makeStealthSwapRequestOptions( Clearing `preferPluginId`/`preferType` matters: a user's saved provider preference would otherwise fight the restriction. -A plain cross-asset send (stealth off) fans out to every enabled provider instead. +Every send-to-address quote goes through these options, stealth toggle on or off: send-to-any is a privacy feature and is Houdini-exclusive by operator direction. Making the restriction conditional on the toggle (`stealth ? ... : undefined`) breaks that guarantee, whatever a stale description elsewhere may say; the reasoning is in [Decision: send-to-any is Houdini-exclusive, toggle or no toggle](#send-to-any-is-houdini-exclusive-toggle-or-no-toggle). ### Linked amounts "You send" and "Recipient gets" are linked through one piece of state, `guaranteedSide`. The edited side is guaranteed and renders green; the other tracks the live quote and renders as `~` estimated. Editing "Recipient gets" issues `quoteFor: 'to'`, a reverse quote. +Both amounts are entered through the standard crypto/fiat flip input (`FlipInputModal2`), committed on close so quotes still fire per commit rather than per keystroke; a zero or untouched amount is a dismissal. Max is hidden because max spend is not offered in swap-send mode. The destination side has no wallet, so its modal borrows the user's own wallet on the destination chain for denominations and rates (with a plain text modal as the fallback when no such wallet exists); the borrowed wallet's balance row reads as that wallet's balance, which is cosmetic noise accepted for reusing the standard modal. + ### Cross-chain address entry A destination on another chain cannot go through the source wallet's `parseUri`, so `AddressTile2` takes two hooks. The first validates a known-cross-chain address against the destination chain's own regex. The second, `onUnparsedAddress`, is the one that makes the feature discoverable: @@ -343,7 +345,7 @@ export interface HoudiniChain { } ``` -`HOUDINI_CHAINS` is a snapshot of Houdini `GET /chains` (fetched 2026-07-02) intersected with Edge pluginIds: 38 chains, 6 of them memo-required. Two of the provider's published regexes are corrected in the table with the reason inline; see [Decision: correct the provider's address regexes rather than route around them](#correct-the-providers-address-regexes-rather-than-route-around-them). +`HOUDINI_CHAINS` is a snapshot of Houdini `GET /chains` (fetched 2026-07-02) intersected with Edge pluginIds. The table holds 38 chains, 6 of them memo-required. Two of the provider's published regexes are corrected in the table with the reason inline; see [Decision: correct the provider's address regexes rather than route around them](#correct-the-providers-address-regexes-rather-than-route-around-them). Because `setRecipientPluginId` has not re-rendered when the address is applied, the detected chain is threaded through the result object rather than read back from state: @@ -373,9 +375,9 @@ A URI amount is what the recipient should **receive**, so for a cross-asset dest ### Availability fallbacks -Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair. The scene learns it from real quote failures: a `SwapCurrencyError` while stealth is on turns the toggle off (toast, persistent info line, standard fan-out re-quote); one while the receive side is guaranteed flips the guarantee to the send side, seeds the send amount from display exchange rates so the send stays actionable, and raises a warning card that clears on the next amount edit. Learned capabilities are cached per pair in session state (`routeCaps`), so a later attempt to re-arm the toggle or re-fix the receive amount answers with a pre-emptive toast instead of another doomed quote. The full branch structure is the flowchart in [Section 8](#8-the-send-scene-ux-end-to-end). +Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair. The scene learns it from real quote failures. A `SwapCurrencyError` while the receive side is guaranteed flips the guarantee to the send side, seeds the send amount from display exchange rates so the send stays actionable, and raises a warning card that clears on the next amount edit. One while stealth is on for a **same-asset** pair turns the toggle off (toast, persistent info line) and degrades the swap into the plain direct send the toggle had upgraded. A **cross-asset** pair with no route keeps the plain error card: the request is Houdini-only with or without the toggle, so flipping it changes nothing and there is no other provider to degrade into. Learned capabilities are cached per pair in session state (`routeCaps`), so a later attempt to re-arm the toggle or re-fix the receive amount answers with a pre-emptive toast instead of another doomed quote. The full branch structure is the flowchart in [Section 8](#8-the-send-scene-ux-end-to-end). -Two consequences are easy to miss. The quote request is provider-restricted only while stealth is on (`stealth ? makeStealthSwapRequestOptions(account) : undefined`), so the stealth-unavailable fallback degrades into the full fan-out rather than landing on the same missing private route it is escaping. And `stealth` is an explicit dependency of the quote effect, so flipping it, by hand or by the fallback, re-quotes even on a cross-asset pair where `swapSendActive` itself does not change. +Because the request never varies with the toggle, the stealth flag is **not** a dependency of the quote effect. Same-asset toggling re-quotes through `swapSendActive` (the toggle is what makes the send a swap there); cross-asset toggling issues no new request at all. ### Multi-recipient gating @@ -397,7 +399,7 @@ export const PriceImpactText: React.FC = props => { ## 8. The send scene UX, end to end -Every branch a send-to-address user can hit, from address entry to an armed slider. Two rules organize it: the UI reflects what the provider actually offers (a capability the pair lacks turns its control off, with a toast saying why), and a degraded state is always recoverable (the fallback re-quotes through what remains, and pre-emptive refusals explain themselves on tap). +Every branch a send-to-address user can hit, from address entry to an armed slider. Three rules organize it: every quote is Houdini-only (no other provider is ever consulted), the UI reflects what the provider actually offers (a capability the pair lacks turns its control off, with a toast saying why), and a degraded state is always recoverable where a degradation exists (the fixed-to fallback re-quotes the send side, the same-asset stealth fallback is the plain send, and pre-emptive refusals explain themselves on tap). ```mermaid flowchart TD @@ -412,26 +414,26 @@ flowchart TD F --> I{URI carries\nan amount?} I -- no --> J[User enters amount,\nsend side guaranteed] I -- yes --> K[Receive side guaranteed,\nfixed to] - K --> L{Receive-priced route\noffered for the pair?} - L -- yes --> M[Quote arms,\nreceive amount locked] + K --> L{Houdini offers a\nreceive-priced route?} + L -- yes --> M[Houdini quote arms,\nreceive amount locked] L -- no --> N[Falls back to fixed from:\ntoast, warning card, send\namount seeded from rates] N --> O[Card clears when the\nuser edits an amount] O --> J - J --> P{Stealth on?} - M --> P - P -- yes --> Q{Private route\nfor this pair?} + J --> P{Cross-asset\ndestination?} + P -- yes --> U[Houdini-only forward quote:\nstealth toggle does not\nchange the request] + U -- route exists --> W[Quote arms:\nSlide to Confirm] + U -- no route --> Z[Error card:\npair not supported] + P -- no --> P2{Stealth on?} + P2 -- no --> V[Plain single-asset send] + P2 -- yes --> Q{Private route\nfor this pair?} Q -- yes --> R[Stealth quote arms:\nSlide to send stealthily] Q -- no --> S[Stealth turns itself off:\ntoast, info line under the\ntoggle, pair remembered] - S --> T{Cross-asset\ndestination?} - T -- yes --> U[Standard fan-out re-quote\nacross all providers] - T -- no --> V[Plain single-asset send] - P -- no --> U - U --> W[Quote arms:\nSlide to Confirm] + S --> V S -. later toggle taps .-> X[Refuses to arm,\npre-emptive toast] N -. later Recipient gets taps .-> Y[Editor refuses to open,\npre-emptive toast] ``` -Two supporting facts. Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), because availability is a live provider property: the same pair can regain its private route an hour later, so nothing is persisted. And amount errors (below or above route limits) never enter this flow; they keep the plain error card, because the route exists and the amount is the problem. +Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), because availability is a live provider property: the same pair can regain its private route an hour later, so nothing is persisted. Amount errors (below or above route limits) never enter this flow; they keep the plain error card, because the route exists and the amount is the problem. ## 9. Testing @@ -442,18 +444,19 @@ Unit tests, 26 across two files, all passing: 3. `test/core/synthetic-wallet.test.ts` in edge-core-js: the synthetic wallet's shape and bridge survival. 4. `test/houdini.test.ts` in edge-exchange-plugins, with 10 recorded fixtures: quote retrieval, order creation, and destination-tag threading against recorded provider responses. -Full-repo verification: `verify-repo.sh` PASSED on the gui (install, prepare, lint, full jest: 556 tests, 92 suites, 107 snapshots). +Full-repo verification: `verify-repo.sh` PASSED on the gui, covering install, prepare, lint, and a full jest run of 556 tests across 92 suites with 107 snapshots. On-device, iOS simulator, account `edge-funds`, against the live provider: 5. **Executed cross-chain send.** ETH wallet, Litecoin address pasted, destination auto-adopted, 0.25 LTC guaranteed on the receive side, live quote 1 ETH = 39.59988278 LTC, executed to the success scene. Broadcast `0xa87fd77e1a64310d565e462cbc91e5f3e0e748ff1bbb62857455aef37e4044e7`, 0.00631315 ETH ($11.93), category `Exchange:To LTC`. Source wallet moved 0.0175191 to 0.0111629 ETH. 6. **Executed cross-chain send from a scanned URI**, plain and with Stealth on: `176c833c6f0ef09ea9c2ba5eb6a39e079a13262aadd362d87d195350114a54dc` and `53f03d92da63c9b20dc29a0c296b4043cefb71e334066bf78529d6cec2b11cb6`. -7. **Executed plain cross-asset send** through the provider fan-out (ChangeNOW won): `0xfd51a5c5d4ba44267d257506c977a8e88952f56987e653d2b812502fa739cf8b`. +7. **Executed plain cross-asset send**, on the phase 2 pre-followup code state where a stealth-off send fanned out to every provider (ChangeNOW won): `0xfd51a5c5d4ba44267d257506c977a8e88952f56987e653d2b812502fa739cf8b`. It covers the send-to-address broadcast path, not the provider restriction. 8. **Entry-path and chain matrix**, 10 cases: typed Ethereum address into a Bitcoin wallet (picker to Ethereum, and separately to Polygon); typed Solana address into an Ethereum wallet; scanned `bitcoin:` URI from an Ethereum wallet; scanned `ethereum:` URI from a Bitcoin wallet; pasted Bitcoin address into a Litecoin wallet; typed Ethereum address from a USDC (Algorand) and a USDT (Tron) token wallet; deep link confirming unchanged same-chain behavior. Chains exercised: BTC, LTC, ETH, POL, SOL. 9. **Regression:** plain same-asset sends, multi-recipient UTXO sends, and the multi-recipient gating, all verified on device. -10. **Stealth auto-disable, live.** ETH wallet, pasted LTC address, stealth armed, amount entered: the quote failed on the missing private route, the toggle turned itself off with the toast, the info line pinned under the toggle, the standard fan-out re-quoted, ChangeNOW won, and the swap EXECUTED to the success scene. Broadcast `0x2f281f4ea143a775355da7876290aaa2a9a7d44160a68eeac89b1ebe92ac284c` (0.006 ETH, $11.34, category `Exchange:To LTC`). +10. **Stealth auto-disable mechanics, live**, on the phase 5 code state: ETH wallet, pasted LTC address, stealth armed, amount entered. The quote failed on the missing private route, the toggle turned itself off with the toast, the info line pinned, and the fallback re-quote of that code state executed through ChangeNOW (`0x2f281f4ea143a775355da7876290aaa2a9a7d44160a68eeac89b1ebe92ac284c`). It covers the toast, info line and `routeCaps` mechanics, which are the same lines the same-asset branch runs today; the cross-asset fan-out it re-quoted through belongs to that code state alone. 11. **Fixed-to fallback, live.** PIVX wallet ($89), stealth on, own PIVX address pasted, "Recipient gets" set to 1000 PIVX: the reverse quote failed (no receive-priced same-asset route), the toast fired, the send side became guaranteed at the rate-seeded 1000 PIVX, and the warning card appeared. Tapping "Recipient gets" afterwards refused with the pre-emptive toast; editing "You send" cleared the card. The post-fallback forward quote then surfaced the provider's PIVX deposit-address defect (retrospective item 4), which is unrelated to the fallback mechanism. 12. **Pre-emptive stealth refusal, live.** Re-tapping the stealth toggle on the known-unavailable ETH to LTC pair refused to arm and toasted, without issuing another quote. +13. **Houdini exclusivity, live (phase 6).** ETH wallet, pasted LTC address, 0.006 ETH, Stealth OFF: the quote went Houdini-only and surfaced `SwapCurrencyError: HoudiniSwap does not support ethereum:null to litecoin:null` in the error card, where the phase 5 code had produced an armed ChangeNOW quote on the same pair and amount. Repeated with Stealth ON: the toggle stayed on and the same error card appeared, with no auto-disable toast and no re-quote. Both amount entries went through the new flip-input modals (You send in ETH/USD, Recipient gets in LTC/USD via a borrowed Litecoin wallet). Amount errors were also confirmed unchanged: 0.002 ETH (below the provider minimum) produced the plain error card with the toggle still armed. The same-asset auto-disable degrade was NOT drivable this phase: the provider's route availability flapped during testing (private routes present on one probe, absent minutes later) and no funded same-asset pair lacked a private route at a fundable amount; the toast, `routeCaps`, and degrade mechanics are the same lines phase 5 drove to execution. ## 10. Phase history @@ -463,7 +466,7 @@ Queued: prove a swap-to-address flow end to end. Shipped: prototype PRs [#6054]( ### Phase 2: production implementation -Queued: replace every prototype hack with real wiring. Shipped: the core `toAddressInfo` seam, the HoudiniSwap plugin, and `SendScene2` integrated in place with the full 38-chain metadata table, linked amounts, expiry re-quoting, destination tags, and both Stealth toggles. Diverged: the prototype's reroute of the wallet Send button and its `HoudiniSendScene` re-skin were deleted rather than adapted, and the price-delta UI was extracted for reuse instead of recreated. +Queued: replace every prototype hack with real wiring. Shipped: the core `toAddressInfo` seam, the HoudiniSwap plugin, and `SendScene2` integrated in place with the full 38-chain metadata table, linked amounts, expiry re-quoting, destination tags, and both Stealth toggles. Diverged: the prototype's reroute of the wallet Send button and its `HoudiniSendScene` re-skin were deleted rather than adapted, and the price-delta UI was extracted for reuse instead of recreated. A same-phase operator followup then changed a behavior: the first cut restricted only stealth sends to Houdini and fanned plain cross-asset sends out to every provider; the operator directed that send-to-any route through Houdini only, and the fix was autosquashed into the feature commit. Neither the PR body nor (later) this document recorded the change, which set up the phase 5 regression. ### Phase 3: scanned payment URIs @@ -475,7 +478,11 @@ Queued: an operator comment relaying a user report that an Ethereum address coul ### Phase 5: route availability in the UI -Queued: an operator followup asking that the UI reflect what is actually available. The stealth toggle should turn itself off (with a toast) on a pair with no private route, a fixed receive amount should fall back to a guaranteed send amount (toast plus a warning card that clears on edit) when no receive-priced route exists, and disabled controls should explain themselves on tap. Shipped: the `routeCaps` mechanism, both fallbacks, the pre-emptive refusals, and the warning card, driven live on ETH to LTC (stealth auto-disable through to an executed ChangeNOW swap) and PIVX to PIVX (fixed-to fallback on a funded wallet). Diverged twice from the sketch: the quote effect was missing `stealth` as a dependency, so the auto-disable initially stranded the scene without a re-quote; and the quote request had been Houdini-restricted even with stealth off since phase 2, which would have made the fallback re-quote land on the same missing route it was escaping. Both were fixed as part of this phase, the second restoring the fan-out behavior the phase 2 documentation had claimed. +Queued: an operator followup asking that the UI reflect what is actually available. The stealth toggle should turn itself off (with a toast) on a pair with no private route, a fixed receive amount should fall back to a guaranteed send amount (toast plus a warning card that clears on edit) when no receive-priced route exists, and disabled controls should explain themselves on tap. Shipped: the `routeCaps` mechanism, both fallbacks, the pre-emptive refusals, and the warning card, driven live on ETH to LTC (stealth auto-disable through to an executed ChangeNOW swap) and PIVX to PIVX (fixed-to fallback on a funded wallet). Diverged twice from the sketch, and the second divergence was a regression: the unconditional Houdini restriction (intended behavior since the phase 2 followup) read as drift because the PR body still described the original fan-out and the autosquash had made the restriction look original, so this phase made the restriction conditional on the toggle and re-routed the stealth fallback through a provider fan-out (the executed ChangeNOW swap in [Section 9](#9-testing) item 10 ran on that code state). Phase 6 reverted it. + +### Phase 6: Houdini exclusivity restored + +Queued: an operator correction: all send and swap functionality is Houdini-exclusive, the PR body is out of date, trace the history through the run reports and fix the documentation so the confusion cannot recur. A same-turn addition asked for the swap-send amount modals to become flip inputs. Shipped: the unconditional `makeStealthSwapRequestOptions` restored at the quote call, the stealth auto-disable narrowed to same-asset pairs (cross-asset, the toggle does not change a Houdini-only request, so a missing route keeps the error card), the `stealth` quote-effect dependency dropped again, every fan-out claim purged from this document and the PR body, and both amount rows moved from plain text modals to `FlipInputModal2` ([Linked amounts](#linked-amounts)). Diverged: nothing; the retrospective gained the doc-drift item this regression earned. ### Deferred work @@ -550,6 +557,18 @@ Rejected: **flipping account-level swap config** lost because it leaks a per-tra Reopen if: more than one privacy provider exists, at which point the helper takes a set rather than a hardcoded id. +### Send-to-any is Houdini-exclusive, toggle or no toggle + +Chosen: every send-to-address quote applies `makeStealthSwapRequestOptions`, whether the Stealth toggle is on or off. + +Evidence: operator direction, given as a phase 2 followup and reaffirmed after the phase 5 regression. Send-to-any exists as a privacy feature; fanning a destination address out to every enabled swap provider defeats that, whatever the toggle says. + +Accepted cost: a pair Houdini cannot route hard-errors instead of finding another provider. That is the intended trade ("Houdini-only, period"), and the phase 5 availability UI exists to make the refusal legible rather than to escape it. + +Rejected: **restricting only stealth sends** (the phase 1 shape, accidentally restored in phase 5) because it silently shops the user's destination to every provider the moment the toggle is off. + +Reopen if: product decides plain cross-asset sends should be a general aggregator feature rather than part of the privacy surface. That is an operator call, not a code-archaeology call. + ## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) @@ -575,6 +594,7 @@ Reopen if: more than one privacy provider exists, at which point the helper take 2. **Route availability is a live dependency, not a static one.** Nothing in the design treated "the provider offers a private route for this pair" as a variable. It is: a sweep of 24 pairs on 2026-07-28 found private routes offered only from Bitcoin and Monero sources, where Litecoin had worked two days earlier. Forward swap-to-address sends from other chains therefore fail with `SwapCurrencyError` and a generic error card. The [route selection](#route-selection) filter is correct; the gap was that there was no user-facing distinction between "no route right now" and "something went wrong". Phase 5 closed the UI half of this: a missing route now turns its control off with an explanation ([Availability fallbacks](#availability-fallbacks)). Raising the availability change itself with the provider remains open. 3. **The provider's published metadata was assumed correct.** The chain table was written as a faithful snapshot. Two of its 38 regexes are defective, one of them so permissive it matches every string. Snapshotting external validation data needs an audit pass, not just a transcription. 4. **PIVX payouts are unusable and the design cannot tell.** A PIVX order returns a deposit address that is not a PIVX address (`EXMD…` rather than base58 `D…`), so the send fails at spend time with an opaque wallet error. Reproduced directly against the API with the plugin's own payload shape. The design has no validation of provider-returned deposit addresses against the from-chain. +5. **Undocumented intent regressed in code.** The phase 2 followup made send-to-any Houdini-exclusive, but the change was autosquashed into the feature commit and neither the PR body nor this document was updated. Phase 5 then read the unconditional restriction as drift against the documented fan-out and "fixed" it, shipping a live regression that phase 6 had to revert on operator correction. The lesson: an operator-directed behavior change must update the PR body and this document in the same turn it lands, because both are treated as behavior contracts by later work, and a squashed history cannot testify to intent. ### What held From 28a7281b0ce94a3764b97c6618680c59b96aae98 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 28 Jul 2026 16:26:15 -0700 Subject: [PATCH 16/56] Offer every routable destination in the Myself picker The Myself picker listed only wallets holding the exact source asset, so a send that can route to another chain could not pick one of the user's own wallets there. It now offers the source asset plus every chain the provider pays out to, derived from the route metadata rather than a hardcoded asset shape, so a chain added there appears with no further change. Same-asset wallets pin to the top through a new opt-in grouping prop on WalletListModal; callers that omit it keep today's ordering. A cross-asset pick is adopted through the same path address detection uses, which the scene now shares between both entry points. --- eslint.config.mjs | 1 - src/components/modals/WalletListModal.tsx | 15 ++++ src/components/scenes/SendScene2.tsx | 89 +++++++++++++++++++---- src/components/themed/WalletList.tsx | 44 +++++++++++ src/components/tiles/AddressTile2.tsx | 42 +++++++++-- src/locales/en_US.ts | 2 + src/locales/strings/enUS.json | 2 + 7 files changed, 173 insertions(+), 22 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 6146f7b0b64..f5bb6c9dec8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -384,7 +384,6 @@ export default [ 'src/components/themed/TransactionListComponents.tsx', 'src/components/themed/VectorIcon.tsx', - 'src/components/themed/WalletList.tsx', 'src/components/themed/WalletListErrorRow.tsx', 'src/components/themed/WalletListHeader.tsx', diff --git a/src/components/modals/WalletListModal.tsx b/src/components/modals/WalletListModal.tsx index 86d655ce338..beca381a6be 100644 --- a/src/components/modals/WalletListModal.tsx +++ b/src/components/modals/WalletListModal.tsx @@ -78,6 +78,15 @@ interface Props { excludeWalletIds?: string[] filterActivation?: boolean + /** + * Opt-in grouping: assets matching this filter render first under + * `pinnedTitle`, everything else follows under `otherTitle`. Omitting it + * leaves the default ordering alone for every other caller. + */ + pinnedAssets?: EdgeAsset[] + pinnedTitle?: string + otherTitle?: string + // Visuals: createWalletId?: string headerTitle: string @@ -99,6 +108,9 @@ export const WalletListModal: React.FC = props => { excludeAssets, excludeWalletIds, filterActivation, + pinnedAssets, + pinnedTitle, + otherTitle, // Visuals: createWalletId, @@ -309,6 +321,9 @@ export const WalletListModal: React.FC = props => { excludeAssets={walletListExcludeAssets} excludeWalletIds={excludeWalletIds} filterActivation={filterActivation} + pinnedAssets={pinnedAssets} + pinnedTitle={pinnedTitle} + otherTitle={otherTitle} searchText={searchText} showCreateWallet={showCreateWallet} createWalletId={createWalletId} diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 6187b2a669b..c5bef8904e3 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -50,7 +50,7 @@ import { config } from '../../theme/appConfig' import { useState } from '../../types/reactHooks' import { useDispatch, useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' -import type { FioRequest } from '../../types/types' +import type { EdgeAsset, FioRequest } from '../../types/types' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' import { @@ -647,6 +647,66 @@ const SendComponent: React.FC = props => { * Returns false to let the tile report an invalid address, which is still * the right answer for a genuine typo. */ + /** + * Adopt a destination on another chain: the recipient asset becomes that + * chain, any tag and quote held for the previous one is dropped, and the + * address lands in the tile. Shared by address detection, which infers the + * chain from the text, and the "Myself" picker, which knows it outright. + */ + const adoptCrossChainDestination = + (spendTarget: EdgeSpendTarget) => + async ( + destPluginId: string, + publicAddress: string, + addressEntryMethod: AddressEntryMethod, + crossChainDisplayAmount?: string + ): Promise => { + setRecipientPluginId(destPluginId) + // A new destination chain invalidates any tag and quote held for the old + // one, exactly as picking the recipient asset by hand does: + setDestinationTag(undefined) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + setFixedToFallback(false) + + await handleChangeAddress(spendTarget)({ + parsedUri: { publicAddress }, + addressEntryMethod, + crossChainDisplayAmount, + detectedDestPluginId: destPluginId + }) + } + + /** + * The recipient assets the "Myself" picker may offer: the source asset plus + * every chain the provider pays out to. Derived from the route metadata, so + * a chain added there shows up here with no further change. Tokens are + * absent only because `getHoudiniChain` returns undefined for a non-null + * tokenId; when token routes appear they flow through unchanged. + */ + const selfTransferAssets = React.useMemo(() => { + if (!swapSendAllowed || multipleTargets) return undefined + const assets: EdgeAsset[] = [{ pluginId, tokenId }] + for (const chain of HOUDINI_CHAINS) { + if (chain.pluginId === pluginId) continue + if (account.currencyConfig[chain.pluginId] == null) continue + assets.push({ pluginId: chain.pluginId, tokenId: null }) + } + return assets + }, [account, multipleTargets, pluginId, swapSendAllowed, tokenId]) + + const handleSelfTransferAsset = + (spendTarget: EdgeSpendTarget) => + async (destPluginId: string, address: string): Promise => { + await adoptCrossChainDestination(spendTarget)( + destPluginId, + address, + 'other' + ) + return true + } + const handleUnparsedAddress = (spendTarget: EdgeSpendTarget) => async ( @@ -707,21 +767,12 @@ const SendComponent: React.FC = props => { ) if (publicAddress == null) return false - setRecipientPluginId(chain.pluginId) - // A new destination chain invalidates any tag and quote held for the old - // one, exactly as picking the recipient asset by hand does: - setDestinationTag(undefined) - setSwapQuote(undefined) - setReceiveNativeAmount(undefined) - setGuaranteedSide('send') - setFixedToFallback(false) - - await handleChangeAddress(spendTarget)({ - parsedUri: { publicAddress }, + await adoptCrossChainDestination(spendTarget)( + chain.pluginId, + publicAddress, addressEntryMethod, - crossChainDisplayAmount: displayAmount, - detectedDestPluginId: chain.pluginId - }) + displayAmount + ) return true } @@ -830,6 +881,14 @@ const SendComponent: React.FC = props => { recipientNameService={recipientNameService} crossChainAddressValidation={crossChainAddressValidation} onUnparsedAddress={handleUnparsedAddress(spendTarget)} + selfTransfer={ + selfTransferAssets == null + ? undefined + : { + allowedAssets: selfTransferAssets, + onPickCrossAsset: handleSelfTransferAsset(spendTarget) + } + } navigation={navigation as NavigationBase} /> ) diff --git a/src/components/themed/WalletList.tsx b/src/components/themed/WalletList.tsx index 37185d120ee..2b4484d953e 100644 --- a/src/components/themed/WalletList.tsx +++ b/src/components/themed/WalletList.tsx @@ -33,6 +33,15 @@ interface Props { excludeWalletIds?: string[] filterActivation?: boolean + /** + * Opt-in grouping: assets matching this filter render first, under + * `pinnedTitle`, and everything else follows under `otherTitle`. Callers that + * omit it keep the default recent/all ordering untouched. + */ + pinnedAssets?: EdgeAsset[] + pinnedTitle?: string + otherTitle?: string + // Visuals: searchText: string showCreateWallet?: boolean @@ -58,6 +67,9 @@ export const WalletList: React.FC = (props: Props) => { excludeAssets, excludeWalletIds, filterActivation, + pinnedAssets, + pinnedTitle, + otherTitle, // Visuals: searchText, @@ -208,6 +220,35 @@ export const WalletList: React.FC = (props: Props) => { // Show the create-wallet list, filtered by the search term: walletItems.push(...createWalletList) + // Opt-in pinned grouping wins over the recent/all split: a caller that + // asks for it wants its own assets first, not the most-recently-used ones. + // Searching stays flat, as it does for every other caller. + if (pinnedAssets != null && searchText.length === 0) { + const pinned: Array = [] + const rest: Array = [] + for (const item of walletItems) { + const isPinned = + item.type === 'asset' && + checkAssetFilter( + { + pluginId: item.wallet.currencyInfo.pluginId, + tokenId: item.tokenId + }, + pinnedAssets, + undefined + ) + if (isPinned) pinned.push(item) + else rest.push(item) + } + if (pinned.length === 0 || rest.length === 0) return walletItems + return [ + ...(pinnedTitle == null ? [] : [pinnedTitle]), + ...pinned, + ...(otherTitle == null ? [] : [otherTitle]), + ...rest + ] + } + // Show a flat list if we are searching, or have no recent wallets: if (searchText.length > 0 || recentWalletList.length === 0) { return walletItems @@ -238,7 +279,10 @@ export const WalletList: React.FC = (props: Props) => { }, [ createWalletList, filteredWalletList, + otherTitle, parentWalletSection, + pinnedAssets, + pinnedTitle, recentWalletList, searchText ]) diff --git a/src/components/tiles/AddressTile2.tsx b/src/components/tiles/AddressTile2.tsx index 08aa6575ec8..8d88b7cb111 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -21,6 +21,7 @@ import { lstrings } from '../../locales/strings' import { PaymentProtoError } from '../../types/PaymentProtoError' import { useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' +import type { EdgeAsset } from '../../types/types' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { parseDeepLink } from '../../util/DeepLinkParser' import { checkPubAddress } from '../../util/FioAddressUtils' @@ -120,6 +121,18 @@ interface Props { address: string, addressEntryMethod: AddressEntryMethod ) => Promise + /** + * Opt-in expansion of the "Myself" picker past the source asset. The caller + * supplies the destination assets this send can route to, derived from route + * metadata rather than any hardcoded asset shape, and adopts a cross-asset + * pick through `onPickCrossAsset`. Same-asset wallets pin to the top of the + * modal. Omitting this keeps the source-asset-only picker every other caller + * gets. + */ + selfTransfer?: { + allowedAssets: EdgeAsset[] + onPickCrossAsset: (pluginId: string, address: string) => Promise + } navigation: NavigationBase } @@ -135,6 +148,7 @@ export const AddressTile2 = React.forwardRef( navigation, onChangeAddress, onUnparsedAddress, + selfTransfer, recipientAddress, resetSendTransaction, crossChainAddressValidation, @@ -520,17 +534,24 @@ export const AddressTile2 = React.forwardRef( const handleSelfTransfer = useHandler(() => { const { currencyWallets } = account const { pluginId } = coreWallet.currencyInfo + const sourceAsset = { pluginId, tokenId } Airship.show(bridge => ( )) @@ -543,6 +564,15 @@ export const AddressTile2 = React.forwardRef( const { segwitAddress, publicAddress } = await wallet.getReceiveAddress({ tokenId: null }) const address = segwitAddress ?? publicAddress + + // A wallet on another chain is a cross-asset destination, so the + // caller adopts it (recipient asset, quote reset) instead of this + // tile validating the address against the source wallet's chain. + const destPluginId = wallet.currencyInfo.pluginId + if (selfTransfer != null && destPluginId !== pluginId) { + await selfTransfer.onPickCrossAsset(destPluginId, address) + return + } await changeAddress(address, 'other') }) .catch((err: unknown) => { diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 634980a2021..c1d582b2e13 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1210,6 +1210,8 @@ const strings = { wallet_list_modal_header_parent: 'Parent Wallet', wallet_list_modal_header_mru: 'Most Recent Wallets', wallet_list_modal_header_other: 'Other Wallets', + wallet_list_modal_header_same_asset: 'Same Asset', + wallet_list_modal_header_other_assets: 'Other Assets', wallet_list_modal_creating_wallet: 'Creating Wallet. Please Wait', wallet_list_modal_enabling_token: 'Enabling Token. Please Wait', wallet_list_modal_confirm_s_bank_withdrawal: diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 581dc93dcb0..fa25d3494fc 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -968,6 +968,8 @@ "wallet_list_modal_header_parent": "Parent Wallet", "wallet_list_modal_header_mru": "Most Recent Wallets", "wallet_list_modal_header_other": "Other Wallets", + "wallet_list_modal_header_same_asset": "Same Asset", + "wallet_list_modal_header_other_assets": "Other Assets", "wallet_list_modal_creating_wallet": "Creating Wallet. Please Wait", "wallet_list_modal_enabling_token": "Enabling Token. Please Wait", "wallet_list_modal_confirm_s_bank_withdrawal": "Borrowing funds for deposit directly into a bank account requires a linked bank account with an %1$s exchange partner. Tap Continue to proceed to link a bank account. (For US users only)", From 4f4accaaf583542821d2483493e1a2fb5167b8c4 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 28 Jul 2026 16:33:16 -0700 Subject: [PATCH 17/56] Distinguish the three send-shaped swap flows in history A swap-send, a stealth send, and a stealth swap-send all landed in the transaction list under the same generic swap title, and the two private flows displayed the recipient they exist to conceal. Each flow now names itself on the swap action's swapType, which only this scene can determine: the plugin sees an ordinary swap, and with every send-to-address quote restricted to the privacy provider, the winning plugin cannot tell them apart either. The list and details map the field to a title. A private send additionally skips the recipient write into transaction metadata and hides the payout address in the details text, while keeping it on the swap data so support can trace an order. --- CHANGELOG.md | 4 +- src/actions/CategoriesActions.ts | 12 +++- src/components/cards/SwapDetailsCard.tsx | 13 ++++- src/components/scenes/SendScene2.tsx | 47 ++++++++++++++++ .../scenes/TransactionDetailsScene.tsx | 8 +++ src/constants/txActionConstants.ts | 9 ++- src/docs/stealth-send-swap.md | 56 +++++++++++++++++++ src/locales/en_US.ts | 4 ++ src/locales/strings/enUS.json | 4 ++ 9 files changed, 152 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d8659e337..de913f05907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## 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 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 routed exclusively through the Houdini privacy provider, with linked You send/Recipient gets amounts, quote expiry countdown, and a destination tag row on memo-required chains. +- added: The "Myself" recipient picker offers every asset a send can route to, not just the source asset, with same-asset wallets grouped at the top. +- added: Swap-sends, stealth sends, and stealth swap-sends are titled separately in the transaction list and details. The two private flows do not display the recipient. - 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: Entering a recipient address from another chain now sets up the cross-chain send by itself. Pasting, typing, or scanning an address the sending wallet cannot read no longer reports an invalid address; Edge identifies the network it belongs to, or asks which one when the format is shared, and sets "Recipient receives" to match. - 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. diff --git a/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index a3e192b6cbe..f1397d62f20 100644 --- a/src/actions/CategoriesActions.ts +++ b/src/actions/CategoriesActions.ts @@ -12,7 +12,10 @@ import { sprintf } from 'sprintf-js' import { showError } from '../components/services/AirshipInstance' import { EDGE_CONTENT_SERVER_URI } from '../constants/CdnConstants' -import { TX_ACTION_LABEL_MAP } from '../constants/txActionConstants' +import { + SWAP_SEND_LABEL_MAP, + TX_ACTION_LABEL_MAP +} from '../constants/txActionConstants' import { lstrings } from '../locales/strings' import type { ThunkAction } from '../types/reduxTypes' import { getCurrencyCodeWithAccount } from '../util/CurrencyInfoHelpers' @@ -375,6 +378,13 @@ export const getTxActionDisplayInfo = ( switch (actionType) { case 'swap': { iconPluginId = action.swapInfo.pluginId + // A send-shaped swap is titled by the flow the user ran, so the three + // are distinguishable in the list. The two private flavors also drop + // the recipient from the title; the payout address stays on swapData + // for support. + if (action.swapType != null) { + payeeText = SWAP_SEND_LABEL_MAP[action.swapType] + } switch (assetActionType) { case 'transfer': { const txSrc = action.payoutWalletId !== wallet.id diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index 6234887f097..25e6771f4a1 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -33,6 +33,13 @@ interface Props { swapData: EdgeTxSwap transaction: EdgeTransaction wallet: EdgeCurrencyWallet + + /** + * Keep the payout address out of the details text. Set for a private send, + * whose recipient the UI must not reveal. The address stays on `swapData` + * so support can still trace the order. + */ + hidePayoutAddress?: boolean } const TXID_PLACEHOLDER = '{{TXID}}' @@ -59,7 +66,7 @@ const upgradeSwapData = ( } export const SwapDetailsCard: React.FC = props => { - const { swapData, transaction, wallet } = props + const { swapData, transaction, wallet, hidePayoutAddress = false } = props const theme = useTheme() const styles = getStyles(theme) @@ -235,7 +242,9 @@ export const SwapDetailsCard: React.FC = props => { lstrings.transaction_details_exchange_exchange_unique_id }:${newline}${uniqueIdentifier}${newline}${newline}${ lstrings.transaction_details_exchange_payout_address - }:${newline}${payoutAddress}${newline}${newline}${ + }:${newline}${ + hidePayoutAddress ? lstrings.stealth_recipient_hidden : payoutAddress + }${newline}${newline}${ lstrings.transaction_details_exchange_refund_address }:${newline}${refundAddress ?? ''}${newline}` } diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index c5bef8904e3..2419dce768f 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -14,6 +14,7 @@ import { type EdgeSwapQuote, type EdgeTokenId, type EdgeTransaction, + type EdgeTxActionSwapType, type InsufficientFundsError } from 'edge-core-js' import * as React from 'react' @@ -696,6 +697,42 @@ const SendComponent: React.FC = props => { return assets }, [account, multipleTargets, pluginId, swapSendAllowed, tokenId]) + /** + * Which of the three send-shaped swap flows this scene just ran. Stealth is + * the toggle; cross-asset is the destination asset differing from the + * source. + */ + const swapSendType: EdgeTxActionSwapType = stealth + ? crossAsset + ? 'stealthSwapSend' + : 'stealthSend' + : 'swapSend' + + /** + * Record the flow on the broadcast transaction's saved action, preserving + * everything the swap plugin already wrote. A failure here costs the + * transaction its title, never the transaction, so it is logged and + * swallowed rather than surfaced over a completed send. + */ + const stampSwapSendAction = async (tx: EdgeTransaction): Promise => { + const { savedAction } = tx + if (savedAction == null || savedAction.actionType !== 'swap') return + const stamped = { ...savedAction, swapType: swapSendType } + // The success scene, and the details scene behind it, render this object + // rather than re-reading the wallet, so it carries the flow too. + tx.savedAction = stamped + try { + await coreWallet.saveTxAction({ + txid: tx.txid, + tokenId, + assetAction: tx.assetAction ?? { assetActionType: 'swap' }, + savedAction: stamped + }) + } catch (error: unknown) { + console.warn('Could not save the swap-send action type', String(error)) + } + } + const handleSelfTransferAsset = (spendTarget: EdgeSpendTarget) => async (destPluginId: string, address: string): Promise => { @@ -2132,6 +2169,11 @@ const SendComponent: React.FC = props => { isSendingRef.current = true try { const result = await swapQuote.approve() + // Name the flow on the saved action. Only this scene knows which of + // the three send shapes ran: the plugin sees an ordinary swap, and + // with every send-to-address quote restricted to the privacy + // provider, the winning plugin cannot tell them apart either. + await stampSwapSendAction(result.transaction) playSendSound().catch((error: unknown) => { console.log(error) // Fail quietly }) @@ -2238,6 +2280,11 @@ const SendComponent: React.FC = props => { broadcastedTx.metadata ??= {} if ( payeeName != null && + // A stealth send must not put the recipient in the transaction + // title, where it would sit in the list next to the amount. The + // payout address is still stored on the swap data, so support can + // trace a stuck order. + !stealth && (broadcastedTx.metadata?.name == null || broadcastedTx.metadata.name === '') ) { diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 2e50d9ee3d1..286b8908c92 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -101,6 +101,13 @@ export const TransactionDetailsComponent: React.FC = props => { const swapData = convertActionToSwapData(account, transaction) ?? transaction.swapData + // A private send must not reveal its recipient anywhere in the UI. The + // payout address stays on the swap data for support to trace the order. + const isStealthSend = + action != null && + action.actionType === 'swap' && + (action.swapType === 'stealthSend' || action.swapType === 'stealthSwapSend') + const thumbnailPath = useContactThumbnail(mergedData.name) ?? pluginIdIcons[iconPluginId ?? ''] @@ -636,6 +643,7 @@ export const TransactionDetailsComponent: React.FC = props => { swapData={swapData} transaction={transaction} wallet={wallet} + hidePayoutAddress={isStealthSend} /> )} diff --git a/src/constants/txActionConstants.ts b/src/constants/txActionConstants.ts index 13d811cb8ec..c7b6b7202f4 100644 --- a/src/constants/txActionConstants.ts +++ b/src/constants/txActionConstants.ts @@ -1,7 +1,14 @@ -import type { EdgeAssetActionType } from 'edge-core-js' +import type { EdgeAssetActionType, EdgeTxActionSwapType } from 'edge-core-js' import { lstrings } from '../locales/strings' +/** Titles for the send-shaped swap flows, which a plain swap does not carry. */ +export const SWAP_SEND_LABEL_MAP: Record = { + swapSend: lstrings.transaction_details_swap_and_send, + stealthSend: lstrings.transaction_details_stealth_send, + stealthSwapSend: lstrings.transaction_details_stealth_swap_and_send +} + export const TX_ACTION_LABEL_MAP: Record = { buy: lstrings.transaction_details_bought_1s, claim: lstrings.transaction_details_claim, diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index e0050ad5e3c..df381457276 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -379,6 +379,28 @@ Whether the provider offers a private route, or a receive-priced (fixed) route, Because the request never varies with the toggle, the stealth flag is **not** a dependency of the quote effect. Same-asset toggling re-quotes through `swapSendActive` (the toggle is what makes the send a swap there); cross-asset toggling issues no new request at all. +### Destination assets are route-derived + +Every surface that decides whether an asset can be a send-to-address destination reads the route metadata (`HOUDINI_CHAINS` through `getHoudiniChain`), never a hardcoded asset shape. That covers address detection, the recipient-asset picker, quote gating, and the "Myself" picker. Tokens are absent from all of them for one reason: `getHoudiniChain` returns undefined for a non-null `tokenId`. When the provider serves token destinations, relaxing that one function surfaces them everywhere at once. + +The "Myself" picker follows the same rule. It offers the source asset plus every chain the provider pays out to, filtered to assets the account actually has a `currencyConfig` for. Same-asset wallets pin to the top of the modal through an opt-in grouping prop on `WalletListModal` (`pinnedAssets`, `pinnedTitle`, `otherTitle`); callers that omit it keep the default recent/all ordering. The source wallet stays excluded, since sending an asset to itself is not a transfer. A cross-asset pick runs through `adoptCrossChainDestination`, the same path address detection uses, so the recipient asset, the destination tag, and the held quote all reset identically. + +Private-route availability does not filter this list. A pair whose private route is missing is still a legal destination; the Stealth toggle is what turns itself off, per [Availability fallbacks](#availability-fallbacks), evaluated per selection rather than cached per asset. + +### Transaction identity + +Three send shapes reach the transaction list, and each carries its own title: + +| Flow | Title | Recipient | +|---|---|---| +| Cross-asset send, stealth off | Swap & Send | shown | +| Same-asset send, stealth on | Stealth Send | hidden | +| Cross-asset send, stealth on | Stealth Swap & Send | hidden | + +The flow is named on the saved action, not inferred in the GUI: `EdgeTxActionSwap` carries an optional `swapType` (`swapSend`, `stealthSend`, `stealthSwapSend`), and `getTxActionDisplayInfo` maps it to the title through `SWAP_SEND_LABEL_MAP`. Only the send scene knows which shape ran, so it stamps the field with `saveTxAction` right after `approve()` resolves; a failure there costs the transaction its title and nothing else, so it is logged rather than surfaced over a completed send. + +Hiding the recipient is a display rule, not a storage rule. `swapData` keeps `orderId` and `payoutAddress` intact so support can trace a stuck order. What changes is what renders: the broadcast path skips the `payeeName` write into `metadata.name` for a stealth send, and `SwapDetailsCard` takes `hidePayoutAddress` and substitutes a placeholder in its details text. The transaction list's own fallback needs no change, because a swap-send's spend target is the provider's deposit address, never the recipient's. + ### Multi-recipient gating Gated in both directions. Stealth on or a mismatched recipient hides "Add Another Address"; with multiple recipients present the stealth toggle is disabled, the card expands with an explanation, and the recipient-asset selector locks. Multi-recipient sends also gained a Total Amount row, which the task had left open. @@ -484,6 +506,10 @@ Queued: an operator followup asking that the UI reflect what is actually availab Queued: an operator correction: all send and swap functionality is Houdini-exclusive, the PR body is out of date, trace the history through the run reports and fix the documentation so the confusion cannot recur. A same-turn addition asked for the swap-send amount modals to become flip inputs. Shipped: the unconditional `makeStealthSwapRequestOptions` restored at the quote call, the stealth auto-disable narrowed to same-asset pairs (cross-asset, the toggle does not change a Houdini-only request, so a missing route keeps the error card), the `stealth` quote-effect dependency dropped again, every fan-out claim purged from this document and the PR body, and both amount rows moved from plain text modals to `FlipInputModal2` ([Linked amounts](#linked-amounts)). Diverged: nothing; the retrospective gained the doc-drift item this regression earned. +### Phase 7: routable Myself picker and transaction identity + +Queued: an operator followup with two features and a standing rule. The rule: supported-destination logic is route-derived on every surface, with the branch audited for hardcoded assumptions. Feature A: the "Myself" picker lists every routable destination asset, same-asset pinned to the top through an opt-in grouping prop, with private availability handled by the toggle rather than by filtering the list. Feature B: swap-sends, stealth sends and stealth swap-sends are distinguishable in the transaction list and details, through a first-class action field rather than a metadata convention, with the recipient suppressed in the UI but preserved in storage. Shipped: all of it, plus the [Destination assets are route-derived](#destination-assets-are-route-derived) and [Transaction identity](#transaction-identity) sections and three decisions recording the rationale in the same turn the code landed. Diverged: `swapType` extends the existing swap action instead of becoming its own `actionType`, because these flows are swaps to every existing consumer and a new action type would drop them out of all of it. + ### Deferred work | Item | Disposition | Reason | @@ -569,6 +595,36 @@ Rejected: **restricting only stealth sends** (the phase 1 shape, accidentally re Reopen if: product decides plain cross-asset sends should be a general aggregator feature rather than part of the privacy surface. That is an operator call, not a code-archaeology call. +### Derive supported destinations from route metadata, never an asset list + +Chosen: every surface that answers "can this asset be a destination" reads `HOUDINI_CHAINS` through `getHoudiniChain`. + +Evidence: the same question is asked in four places (address detection, the recipient picker, quote gating, the "Myself" picker). A hardcoded native-only rule in any one of them goes stale the day the provider serves token payouts, and the staleness is invisible until a user reports it. + +Rejected: **a native-only check at each call site**, which is what the "Myself" picker effectively had. It reads as correct today and silently diverges later. + +Reopen if: a surface needs a destination rule the route metadata cannot express, at which point the metadata gains the field rather than the call site gaining a special case. + +### Name the send flow on the swap action, not in metadata + +Chosen: `EdgeTxActionSwap.swapType`, an optional core type, stamped by the send scene after approval. + +Evidence: the three flows are indistinguishable downstream. They all carry `swapInfo`, `orderId`, `payoutAddress` and a from/to asset pair, and with every send-to-address quote restricted to the privacy provider, even the winning plugin cannot tell a stealth send from a plain one. Only the scene knows, because only the scene has the toggle. + +Rejected: **a metadata-name or category convention**, which is a magic string a user edit can destroy and no type can enforce. **A separate `actionType`** lost because these are swaps: a new action type drops them out of every existing swap consumer (the details card, the exchange category, the savedAction sweep) and each one would need re-teaching. + +Reopen if: a non-GUI caller starts producing these transactions, which would move the stamping into whatever creates the order. + +### Suppress the recipient in the UI, keep it in storage + +Chosen: a stealth send keeps `payoutAddress` on `swapData` and hides it in every rendered surface. + +Evidence: support traces stuck orders by payout address, and losing it would make a failed private send unrecoverable. The privacy boundary this feature defends is the on-chain link between source and destination, which storing the address locally does not weaken: device-level access to the transaction file already implies access to the keys. + +Rejected: **not storing the address**, which buys no privacy against any attacker who is not already inside the device, and costs every future support ticket. + +Reopen if: the threat model grows to include an attacker with read access to wallet files but not keys. + ## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index c1d582b2e13..8c7d6ec1b50 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -939,6 +939,9 @@ const strings = { transaction_details_exchange_support_request: '%s Support Request', transaction_details_fee_warning: 'High Network Fees', transaction_details_swap: 'Swap Funds', + transaction_details_swap_and_send: 'Swap & Send', + transaction_details_stealth_send: 'Stealth Send', + transaction_details_stealth_swap_and_send: 'Stealth Swap & Send', transaction_details_swap_network_fee: 'Swap Network Fee', transaction_details_swap_order_cancel: 'Swap Order Cancelled', transaction_details_swap_order_post: 'Swap Order Opened', @@ -1640,6 +1643,7 @@ const strings = { stealth_fixed_to_fallback_title: 'Receive amount is an estimate', stealth_fixed_to_fallback_body: 'The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.', + stealth_recipient_hidden: 'Hidden for privacy', stealth_detected_network_title: 'Which network is this address on?', stealth_detected_network_message: 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index fa25d3494fc..2a780cba15f 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -757,6 +757,9 @@ "transaction_details_exchange_support_request": "%s Support Request", "transaction_details_fee_warning": "High Network Fees", "transaction_details_swap": "Swap Funds", + "transaction_details_swap_and_send": "Swap & Send", + "transaction_details_stealth_send": "Stealth Send", + "transaction_details_stealth_swap_and_send": "Stealth Swap & Send", "transaction_details_swap_network_fee": "Swap Network Fee", "transaction_details_swap_order_cancel": "Swap Order Cancelled", "transaction_details_swap_order_post": "Swap Order Opened", @@ -1294,6 +1297,7 @@ "stealth_fixed_to_unavailable_toast": "The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.", "stealth_fixed_to_fallback_title": "Receive amount is an estimate", "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", + "stealth_recipient_hidden": "Hidden for privacy", "stealth_detected_network_title": "Which network is this address on?", "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "send_scene_error_title": "Error:", From e04734ba2942ef5552056d48acc937290e26d484 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 28 Jul 2026 17:14:21 -0700 Subject: [PATCH 18/56] test: add missing testIDs for maestro selectors The transaction list row carries no selector, so a maestro drive can only reach it by coordinate. Both row variants now expose txListRow_. --- .../__snapshots__/TransactionListRow.test.tsx.snap | 1 + src/components/themed/TransactionListRow.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap b/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap index 3eee088d225..68feb924666 100644 --- a/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap +++ b/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap @@ -44,6 +44,7 @@ exports[`TransactionListRow should render with loading props 1`] = ` "paddingTop": 11, } } + testID="txListRow_Sent Bitcoin" > <View style={ diff --git a/src/components/themed/TransactionListRow.tsx b/src/components/themed/TransactionListRow.tsx index 9b4a9ca7678..7765ec45831 100644 --- a/src/components/themed/TransactionListRow.tsx +++ b/src/components/themed/TransactionListRow.tsx @@ -262,7 +262,12 @@ const TransactionViewInner: React.FC<TransactionViewInnerProps> = props => { // HACK: Handle 100% of the margins because of SceneHeader usage on this scene return isCard === true ? ( - <EdgeCard icon={icon} onPress={handlePress} onLongPress={handleLongPress}> + <EdgeCard + icon={icon} + testID={`txListRow_${name}`} + onPress={handlePress} + onLongPress={handleLongPress} + > <SectionView dividerMarginRem={[0.2, 0.5]} marginRem={0.25}> <> <View style={styles.row}> @@ -297,6 +302,7 @@ const TransactionViewInner: React.FC<TransactionViewInnerProps> = props => { </EdgeCard> ) : ( <EdgeTouchableOpacity + testID={`txListRow_${name}`} onPress={handlePress} onLongPress={handleLongPress} style={styles.cardlessRow} From eff8dd59ad68ad61821e910e1eeda8014175cc0b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 18:11:28 -0700 Subject: [PATCH 19/56] Gate swap-send behind the PIN spending limit The swap-send branch of handleSliderComplete returned before the PIN check, and the makeSpend effect returned before computing spendingLimitExceeded at all, so a send-to-address swap of any size skipped PIN re-authorization even with spending limits enabled. The slider never prompted for it either, since its PIN branch sat in an else-if the swap-send path short-circuited. The limit calculation now lives in one handler both paths call, the check runs ahead of both submit paths, and the swap-send slider applies the same gate. Also hardens three quote-state paths found in the same review: an empty quote list no longer crashes the scene by dereferencing quotes[0], clearing the address resets the destination chain, tag, receive amount and standing quote instead of quoting the next address against the previous recipient, and an expiring Stealth Swap quote degrades to a standard swap through the same onError path SwapCreateScene uses rather than dead-ending on a generic error. --- src/components/scenes/SendScene2.tsx | 125 ++++++++++++------ .../scenes/SwapConfirmationScene.tsx | 30 ++++- src/components/scenes/SwapCreateScene.tsx | 17 ++- src/components/scenes/SwapProcessingScene.tsx | 7 + 4 files changed, 135 insertions(+), 44 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 2419dce768f..a6e49847298 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -867,6 +867,14 @@ const SendComponent: React.FC<Props> = props => { setExpireDate(undefined) setPinValue(undefined) setFixedToFallback(false) + // Clearing the address ends the swap-send: leaving the destination chain, + // tag, receive amount or standing quote behind means the next address + // entered gets quoted against the previous recipient's state. + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + setRecipientPluginId(undefined) + setDestinationTag(undefined) setSpendInfo({ ...spendInfo }) // This is deleting the amount tile. If this happens, remove the // lastAddressEntryMethod so we don't auto launch the camera again. @@ -1066,12 +1074,15 @@ const SendComponent: React.FC<Props> = 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: + // A new source asset invalidates the swap-send destination state, + // including the fixed-to warning, which describes a route for the + // pair the user just left: setRecipientPluginId(undefined) setDestinationTag(undefined) setSwapQuote(undefined) setReceiveNativeAmount(undefined) setGuaranteedSide('send') + setFixedToFallback(false) } }) .catch((error: unknown) => { @@ -1180,14 +1191,12 @@ const SendComponent: React.FC<Props> = props => { .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') - setFixedToFallback(false) + // A new destination chain invalidates the entered address and tag, so + // clear the whole recipient first. The reset drops the destination + // chain too, which is why the new one is applied AFTER it: setting it + // first would leave the reset's undefined as the last write. handleResetSendTransaction(spendInfo.spendTargets[0])() + setRecipientPluginId(displayNameToPluginId.get(selected)) }) .catch((error: unknown) => { showError(error) @@ -2160,6 +2169,19 @@ const SendComponent: React.FC<Props> = props => { const handleSliderComplete = useHandler( async (resetSlider: () => void): Promise<void> => { + // The PIN spending limit gates BOTH submit paths, so it is checked + // before either one. It used to sit below the swap-send branch, which + // returns early: a swap-send of any size skipped the PIN entirely. + if (pinSpendingLimitsEnabled && spendingLimitExceeded) { + const isAuthorized = await account.checkPin(pinValue ?? '') + if (!isAuthorized) { + resetSlider() + setPinValue('') + showToast(lstrings.incorrect_pin) + return + } + } + // 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. @@ -2192,15 +2214,6 @@ const SendComponent: React.FC<Props> = props => { } if (edgeTransaction == null) return - if (pinSpendingLimitsEnabled && spendingLimitExceeded) { - const isAuthorized = await account.checkPin(pinValue ?? '') - if (!isAuthorized) { - resetSlider() - setPinValue('') - showToast(lstrings.incorrect_pin) - return - } - } try { if (beforeTransaction != null) await beforeTransaction() @@ -2491,6 +2504,33 @@ const SendComponent: React.FC<Props> = props => { if (onBack != null) onBack() }) + // The PIN spending limit gates every outbound flow, so both submit paths + // evaluate it. A send-to-address swap never reaches the makeSpend body + // below, which is where this used to live. + const updateSpendingLimitExceeded = useHandler((): void => { + if (!pinSpendingLimitsEnabled) return + const rate = + getExchangeRate( + exchangeRates, + coreWallet.currencyInfo.pluginId, + tokenId, + defaultIsoFiat + ) ?? INFINITY_STRING + const totalNativeAmount = spendInfo.spendTargets.reduce( + (prev, target) => add(target.nativeAmount ?? '0', prev), + '0' + ) + const totalExchangeAmount = div( + totalNativeAmount, + cryptoExchangeDenomination.multiplier, + DECIMAL_PRECISION + ) + const fiatAmount = mul(totalExchangeAmount, rate) + setSpendingLimitExceeded( + gte(fiatAmount, pinSpendingLimitsAmount.toFixed(DECIMAL_PRECISION)) + ) + }) + // Calculate the transaction useAsyncEffect( async () => { @@ -2499,6 +2539,8 @@ const SendComponent: React.FC<Props> = props => { if (swapSendActive) { setEdgeTransaction(null) setProcessingAmountChanged(false) + // The limit still applies to it, and nothing below this branch runs. + updateSpendingLimitExceeded() return } pendingInsufficientFees.current = undefined @@ -2522,30 +2564,7 @@ const SendComponent: React.FC<Props> = props => { feeTokenId: null }) } - if (pinSpendingLimitsEnabled) { - const rate = - getExchangeRate( - exchangeRates, - coreWallet.currencyInfo.pluginId, - tokenId, - defaultIsoFiat - ) ?? INFINITY_STRING - const totalNativeAmount = spendInfo.spendTargets.reduce( - (prev, target) => add(target.nativeAmount ?? '0', prev), - '0' - ) - const totalExchangeAmount = div( - totalNativeAmount, - cryptoExchangeDenomination.multiplier, - DECIMAL_PRECISION - ) - const fiatAmount = mul(totalExchangeAmount, rate) - const exceeded = gte( - fiatAmount, - pinSpendingLimitsAmount.toFixed(DECIMAL_PRECISION) - ) - setSpendingLimitExceeded(exceeded) - } + updateSpendingLimitExceeded() if (minNativeAmount != null) { for (const target of spendInfo.spendTargets) { @@ -2735,6 +2754,19 @@ const SendComponent: React.FC<Props> = props => { const quote = quotes[0] if (generation !== swapQuoteGeneration.current) return + if (quote == null) { + // fetchSwapQuotes normally throws when nothing can route, but it + // resolves with an empty list if every plugin simply declines. + // Reading toNativeAmount off that would crash the scene. + setSwapQuote(undefined) + setError( + new I18nError( + lstrings.trade_option_no_quotes_title, + lstrings.trade_option_no_quotes_body + ) + ) + return + } setSwapQuote(quote) setError(undefined) // Update the estimated side from the live quote: @@ -2847,6 +2879,17 @@ const SendComponent: React.FC<Props> = props => { if (swapSendActive) { // A send-to-address swap submits its live quote: disableSlider = swapQuote == null || fetchingSwapQuote + // Same PIN gate the plain-send branch below applies: without this the + // swap-send slider stayed live and never prompted for the PIN. + if ( + !disableSlider && + pinSpendingLimitsEnabled && + spendingLimitExceeded && + (pinValue?.length ?? 0) < PIN_MAX_LENGTH + ) { + disableSlider = true + disabledText = lstrings.spending_limits_enter_pin + } } else if ( edgeTransaction == null || processingAmountChanged || diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 4ce947cd384..b7fce4e67ed 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -1,6 +1,10 @@ import { useIsFocused } from '@react-navigation/native' import { add, div, gt, gte, toFixed } from 'biggystring' -import type { EdgeSwapQuote, EdgeSwapResult } from 'edge-core-js' +import { + asMaybeSwapCurrencyError, + type EdgeSwapQuote, + type EdgeSwapResult +} from 'edge-core-js' import React, { useState } from 'react' import { SectionList, type ViewStyle } from 'react-native' import { sprintf } from 'sprintf-js' @@ -47,7 +51,7 @@ import { EdgeModal } from '../modals/EdgeModal' import { swapVerifyTerms } from '../modals/SwapVerifyTermsModal' import { CircleTimer } from '../progress-indicators/CircleTimer' import { SwapProviderRow } from '../rows/SwapProviderRow' -import { Airship, showError } from '../services/AirshipInstance' +import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { ExchangeQuote } from '../themed/ExchangeQuoteComponent' import { LineTextDivider } from '../themed/LineTextDivider' @@ -206,6 +210,28 @@ export const SwapConfirmationScene: React.FC<Props> = (props: Props) => { onApprove, stealth }) + }, + onError: error => { + // Same degrade SwapCreateScene applies when a stealth quote finds no + // private route: without it an expiring stealth quote on a pair that + // lost its route dead-ends on the generic error instead of offering + // the standard swap. + const { fromWallet, fromTokenId, toWallet, toTokenId } = + selectedQuote.request + if (!stealth || toWallet == null) return false + if (asMaybeSwapCurrencyError(error) == null) return false + showToast(lstrings.stealth_swap_route_unavailable_toast) + navigation.navigate('swapTab', { + screen: 'swapCreate', + params: { + fromWalletId: fromWallet.id, + fromTokenId, + toWalletId: toWallet.id, + toTokenId, + disableStealth: true + } + }) + return true } }) }) diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index 532cf90f5c7..e6aff0ef0f6 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -76,6 +76,11 @@ export interface SwapCreateParams { // Display error message in an alert card errorDisplayInfo?: SwapErrorDisplayInfo + + // Turn the Stealth Swap toggle off on arrival. The confirmation scene sets + // this when a re-quote found the pair has no private route, so the degrade + // lands where the toggle actually lives. + disableStealth?: boolean } export interface SwapErrorDisplayInfo { @@ -97,7 +102,8 @@ export const SwapCreateScene: React.FC<Props> = props => { fromTokenId = null, toWalletId, toTokenId = null, - errorDisplayInfo + errorDisplayInfo, + disableStealth } = route.params ?? {} const theme = useTheme() const styles = getStyles(theme) @@ -168,6 +174,15 @@ export const SwapCreateScene: React.FC<Props> = props => { }) }, [dispatch, navigation]) + // A re-quote on the confirmation scene found no private route for the pair + // and sent the user back here to retry as a standard swap. Consume the flag + // so a later visit does not turn the toggle off again. + React.useEffect(() => { + if (disableStealth !== true) return + setStealth(false) + navigation.setParams({ disableStealth: undefined }) + }, [disableStealth, navigation]) + // // Callbacks // diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 621d11e12bc..6f8395f879a 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -82,6 +82,13 @@ export const SwapProcessingScene: React.FC<Props> = (props: Props) => { swapRequestOptions ) if (isCancelled()) return + if (quotes.length === 0) { + // fetchSwapQuotes usually throws when nothing can route, but it resolves + // empty when every plugin simply declines. Every onDone caller reads + // quotes[0], so hand this to the error path instead of the confirmation + // scene, which would dereference undefined. + throw new Error(lstrings.trade_option_no_quotes_body) + } onDone(quotes) } From 61bb85a8c9403f6ac2e2677d4ab80498ed4267fc Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 18:13:48 -0700 Subject: [PATCH 20/56] Record the PIN gate reversal in the design doc --- src/docs/stealth-send-swap.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index df381457276..8b9e581667a 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -510,6 +510,10 @@ Queued: an operator correction: all send and swap functionality is Houdini-exclu Queued: an operator followup with two features and a standing rule. The rule: supported-destination logic is route-derived on every surface, with the branch audited for hardcoded assumptions. Feature A: the "Myself" picker lists every routable destination asset, same-asset pinned to the top through an opt-in grouping prop, with private availability handled by the toggle rather than by filtering the list. Feature B: swap-sends, stealth sends and stealth swap-sends are distinguishable in the transaction list and details, through a first-class action field rather than a metadata convention, with the recipient suppressed in the UI but preserved in storage. Shipped: all of it, plus the [Destination assets are route-derived](#destination-assets-are-route-derived) and [Transaction identity](#transaction-identity) sections and three decisions recording the rationale in the same turn the code landed. Diverged: `swapType` extends the existing swap action instead of becoming its own `actionType`, because these flows are swaps to every existing consumer and a new action type would drop them out of all of it. +### Phase 8: PIN gate and quote-state hardening + +Queued: nothing from the operator. A re-fire's finalize-gate re-confirmation found the PR sitting in draft, which had suppressed the reviewer bots entirely: this repo runs no typecheck on pull requests, so the draft bought nothing and hid everything. Marking it ready produced seven findings across two review rounds. Shipped: the stale-quote clear on every amount commit plus a generation guard on the quote effect, the fixed-to fallback no longer stranding the scene when rates are missing, the PIN spending limit now gating swap-send at all three points it was bypassed, a null check on an empty quote list, a full swap-send reset when the address is cleared, and the stealth degrade wired into the confirmation scene's re-quote. Diverged: the deferred-work table said PIN limits on stealth sends were "not doing"; that decision is reversed here, and the reasoning behind it is recorded below as a mistake worth keeping. + ### Deferred work | Item | Disposition | Reason | @@ -518,7 +522,7 @@ Queued: an operator followup with two features and a standing rule. The rule: su | Max spend in swap-send mode | Deferred | Needs the plugins' `getMaxSwappable`; plain-mode max is unaffected. | | Dynamic chain metadata from the API | Deferred | Requires chain metadata through the swap plugin surface; the snapshot is dated in the module. | | `SwapDetailsCard` on a stealth send's tx detail | Deferred | The card requires a payout wallet; rendering from payout asset info alone is a separate change. | -| PIN spending limits on stealth sends | Not doing | Consistent with the existing swap flow, which they also do not gate. | +| PIN spending limits on stealth sends | Reversed, now shipped | The original reasoning compared this to a swap. It is a send. See [Gate swap-send behind the PIN spending limit](#gate-swap-send-behind-the-pin-spending-limit). | | EIP-681 `value=` (wei) amounts | Deferred | Address is accepted, amount ignored; no reported user impact yet. | ## 11. Decisions @@ -625,6 +629,16 @@ Rejected: **not storing the address**, which buys no privacy against any attacke Reopen if: the threat model grows to include an attacker with read access to wallet files but not keys. +### Gate swap-send behind the PIN spending limit + +Chosen: the PIN spending limit applies to a swap-send exactly as it applies to a plain send. The limit is computed in one handler that both the makeSpend path and the swap-send path call, the check runs before either submit path, and the swap-send slider carries the same gate and the same prompt. + +Evidence: this reverses the earlier "not doing" entry, which reasoned that swap-send should match the existing swap flow because it is built on a swap quote. That comparison was wrong at the level that matters. A wallet-to-wallet swap moves funds between two wallets the user already controls, so a PIN prompt buys nothing; a swap-send moves funds to an arbitrary external address and is the exact operation the limit exists to gate. Reasoning from the implementation (it uses a swap quote) instead of from the user-visible operation (it is a send) is what produced the hole. Three separate gates were missing as a result: the submit-path check, the flag computation, and the slider's prompt, each bypassed by a different early return. + +Rejected: **matching the swap flow for consistency**, which is consistency with the wrong sibling. The plain-send path next to it in the same scene is the correct reference. + +Reopen if: the swap flow itself gains send-to-address destinations, at which point it needs this gate too rather than the reverse. + ## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) From 2773b7f4253885f4d02fbedea7ac7ea71c340de3 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 19:13:01 -0700 Subject: [PATCH 21/56] Show the real swap failure instead of a generic error card A failed send-to-address quote called setError with the raw error, and ErrorCard renders anything that is not an I18nError as "Unexpected Error" with a canned "an unexpected error occurred" body and a Report Error button. A user 0.1 LTC under the provider's floor was told nothing about the floor, and a provider outage looked identical to a bug. The wallet-to-wallet swap flow already maps these properly, so that mapping moves out of SwapProcessingScene into src/util/swapErrorDisplay.ts and both flows share it. The send scene now shows the limit that was crossed and by how much, the pair that cannot route, the geo restriction, or, for anything without a known shape, the provider's own message. It gains a toCurrencyCode option because a send-to-address request carries no destination wallet to read a currency code from. --- src/components/scenes/SendScene2.tsx | 43 +++- src/components/scenes/SwapCreateScene.tsx | 7 +- src/components/scenes/SwapProcessingScene.tsx | 172 +-------------- src/util/swapErrorDisplay.ts | 198 ++++++++++++++++++ 4 files changed, 240 insertions(+), 180 deletions(-) create mode 100644 src/util/swapErrorDisplay.ts diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index a6e49847298..554baadec0e 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -79,6 +79,7 @@ import { } from '../../util/memoUtils' import { parsePaymentUri } from '../../util/paymentUri' import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' +import { processSwapQuoteError } from '../../util/swapErrorDisplay' import { convertTransactionFeeToDisplayFee, darkenHexColor, @@ -424,6 +425,40 @@ const SendComponent: React.FC<Props> = props => { destCurrencyConfig == null ? undefined : getExchangeDenom(destCurrencyConfig, null) + // Called unconditionally to keep hook order stable; the value is only read + // once a destination chain is actually selected. Limits are quoted in the + // denomination the user reads elsewhere, not the exchange one. + const destDisplayDenom = useDisplayDenom( + destCurrencyConfig ?? coreWallet.currencyConfig, + null + ) + + // A raw swap failure renders as ErrorCard's catch-all "unexpected error" + // card, which tells the user nothing they can act on. Map it to the same + // specific text the wallet-to-wallet swap flow shows: the limit that was + // crossed and by how much, the pair that cannot route, or, failing a known + // shape, the provider's own message. + const describeSwapError = (error: unknown): unknown => { + const info = processSwapQuoteError({ + error, + swapRequest: { + fromWallet: coreWallet, + fromTokenId: tokenId, + toTokenId: null, + toAddressInfo: { + toPluginId: destPluginId, + toAddress: spendInfo.spendTargets[0].publicAddress ?? '', + toMemos: [] + }, + nativeAmount: spendInfo.spendTargets[0].nativeAmount ?? '0', + quoteFor: guaranteedSide === 'send' ? 'from' : 'to' + }, + fromDenomination: cryptoDisplayDenomination, + toDenomination: destDisplayDenom, + toCurrencyCode: destCurrencyInfo?.currencyCode + }) + return info == null ? error : new I18nError(info.title, info.message) + } const multipleTargets = spendInfo.spendTargets.length > 1 // What the provider is known to offer for the current pair. `false` means a @@ -2204,7 +2239,7 @@ const SendComponent: React.FC<Props> = props => { walletId: coreWallet.id }) } catch (err: unknown) { - setError(err) + setError(describeSwapError(err)) resetSlider() } finally { isApprovingSwapRef.current = false @@ -2834,7 +2869,7 @@ const SendComponent: React.FC<Props> = props => { // returns early on a zero send amount, so the user would be left // holding a warning with no quote and no way to get one. Show // the provider's own error instead. - setError(err) + setError(describeSwapError(err)) } } else if (stealth && !crossAsset) { // No private route on a same-asset pair: turning the toggle off @@ -2847,10 +2882,10 @@ const SendComponent: React.FC<Props> = props => { setError(undefined) showToast(lstrings.stealth_route_unavailable_toast) } else { - setError(err) + setError(describeSwapError(err)) } } else { - setError(err) + setError(describeSwapError(err)) } } finally { if (generation === swapQuoteGeneration.current) { diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index e6aff0ef0f6..62cefe9a975 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -26,6 +26,7 @@ import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' +import type { SwapErrorDisplayInfo } from '../../util/swapErrorDisplay' import { zeroString } from '../../util/utils' import { openBrowserUri } from '../../util/WebUtils' import { EdgeButton } from '../buttons/EdgeButton' @@ -83,11 +84,7 @@ export interface SwapCreateParams { disableStealth?: boolean } -export interface SwapErrorDisplayInfo { - message: string - title: string - error: unknown -} +export type { SwapErrorDisplayInfo } /** Placeholder pending the final marketing URL. */ const STEALTH_LEARN_MORE_URI = diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 6f8395f879a..862bcb55f1c 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -1,12 +1,6 @@ -import { captureException } from '@sentry/react-native' import { asMaybeInsufficientFundsError, - asMaybeSwapAboveLimitError, asMaybeSwapAddressError, - asMaybeSwapBelowLimitError, - asMaybeSwapCurrencyError, - asMaybeSwapPermissionError, - type EdgeDenomination, type EdgeSwapQuote, type EdgeSwapRequest, type EdgeSwapRequestOptions @@ -20,13 +14,12 @@ import { useSelector } from '../../types/reactRedux' import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' -import { convertNativeToDisplay, zeroString } from '../../util/utils' +import { processSwapQuoteError } from '../../util/swapErrorDisplay' import { ButtonsModal } from '../modals/ButtonsModal' import { showInsufficientFeesModal } from '../modals/InsufficientFeesModal' import { showPendingTxModal } from '../modals/PendingTxModal' import { CancellableProcessingScene } from '../progress-indicators/CancellableProcessingScene' import { Airship } from '../services/AirshipInstance' -import type { SwapErrorDisplayInfo } from './SwapCreateScene' export interface SwapProcessingParams { swapRequest: EdgeSwapRequest @@ -253,166 +246,3 @@ export const SwapProcessingScene: React.FC<Props> = (props: Props) => { /> ) } - -function processSwapQuoteError({ - error, - swapRequest, - fromDenomination, - toDenomination -}: { - error: unknown - swapRequest: EdgeSwapRequest - fromDenomination: EdgeDenomination - toDenomination: EdgeDenomination -}): SwapErrorDisplayInfo | undefined { - // Basic sanity checks (should never fail): - if (error == null) return - - // Some plugins get the insufficient funds error wrong: - const errorMessage = - error instanceof Error ? error.message : JSON.stringify(error) - - // Track swap errors to sentry: - trackSwapError(error, swapRequest) - - // Check for known error types: - const insufficientFunds = asMaybeInsufficientFundsError(error) - if (insufficientFunds != null || errorMessage === 'InsufficientFundsError') { - return { - title: lstrings.exchange_insufficient_funds_title, - message: lstrings.exchange_insufficient_funds_message, - error - } - } - - if ( - error instanceof Error && - error.message === 'Unexpected pending transactions' - ) { - return { - title: lstrings.exchange_insufficient_funds_title, - message: lstrings.exchange_pending_funds_error, - error - } - } - - const aboveLimit = asMaybeSwapAboveLimitError(error) - if (aboveLimit != null) { - const currentCurrencyDenomination = - aboveLimit.direction === 'to' ? toDenomination : fromDenomination - - const { nativeMax } = aboveLimit - const nativeToDisplayRatio = currentCurrencyDenomination.multiplier - const displayMax = convertNativeToDisplay(nativeToDisplayRatio)(nativeMax) - - return { - title: lstrings.exchange_generic_error_title, - message: !zeroString(displayMax) - ? sprintf( - lstrings.amount_above_limit, - displayMax, - currentCurrencyDenomination.name - ) - : lstrings.no_amount_above_limit, - error - } - } - - const belowLimit = asMaybeSwapBelowLimitError(error) - if (belowLimit != null) { - const currentCurrencyDenomination = - belowLimit.direction === 'to' ? toDenomination : fromDenomination - - const { nativeMin } = belowLimit - const nativeToDisplayRatio = currentCurrencyDenomination.multiplier - const displayMin = convertNativeToDisplay(nativeToDisplayRatio)(nativeMin) - - return { - title: lstrings.exchange_generic_error_title, - message: !zeroString(displayMin) - ? sprintf( - lstrings.amount_below_limit, - displayMin, - currentCurrencyDenomination.name - ) - : lstrings.no_amount_below_limit, - error - } - } - - const currencyError = asMaybeSwapCurrencyError(error) - if (currencyError != null) { - const fromCurrencyCode = getCurrencyCode( - swapRequest.fromWallet, - swapRequest.fromTokenId - ) - const toCurrencyCode = getCurrencyCode( - // 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 - ) - - return { - title: lstrings.exchange_generic_error_title, - message: sprintf(lstrings.ss_unable, fromCurrencyCode, toCurrencyCode), - error - } - } - - const permissionError = asMaybeSwapPermissionError(error) - if (permissionError?.reason === 'geoRestriction') { - return { - title: lstrings.exchange_generic_error_title, - message: lstrings.ss_geolock, - error - } - } - - // Anything else: - return { - title: lstrings.exchange_generic_error_title, - message: errorMessage, - error - } -} - -/** - * REVIEWER BEWARE!! - * - * No specific account/wallet information should be included within the - * scope for this capture. No personal information such as wallet IDs, - * public keys, or transaction details, amounts, should be collected - * according to Edge's company policy. - */ -function trackSwapError(error: unknown, swapRequest: EdgeSwapRequest): void { - captureException(error, scope => { - // This is a warning level error because it's expected to occur but not wanted. - scope.setLevel('warning') - // Searchable tags: - scope.setTags({ - errorType: 'swapQuoteFailure', - swapFromWalletKind: swapRequest.fromWallet.currencyInfo.pluginId, - swapFromCurrency: getCurrencyCode( - swapRequest.fromWallet, - swapRequest.fromTokenId - ), - swapToCurrency: getCurrencyCode( - swapRequest.toWallet ?? swapRequest.fromWallet, - swapRequest.toTokenId - ), - swapToWalletKind: (swapRequest.toWallet ?? swapRequest.fromWallet) - .currencyInfo.pluginId, - swapDirectionType: swapRequest.quoteFor - }) - // Unsearchable context data: - scope.setContext('Swap Request Details', { - fromTokenId: String(swapRequest.fromTokenId), // Stringify to include "null" - fromWalletType: swapRequest.fromWallet.type, - toTokenId: String(swapRequest.toTokenId), // Stringify to include "null" - toWalletType: swapRequest.fromWallet.type, - quoteFor: swapRequest.quoteFor - }) - return scope - }) -} diff --git a/src/util/swapErrorDisplay.ts b/src/util/swapErrorDisplay.ts new file mode 100644 index 00000000000..8769f70ae8d --- /dev/null +++ b/src/util/swapErrorDisplay.ts @@ -0,0 +1,198 @@ +import { captureException } from '@sentry/react-native' +import { + asMaybeInsufficientFundsError, + asMaybeSwapAboveLimitError, + asMaybeSwapBelowLimitError, + asMaybeSwapCurrencyError, + asMaybeSwapPermissionError, + type EdgeDenomination, + type EdgeSwapRequest +} from 'edge-core-js' +import { sprintf } from 'sprintf-js' + +import { lstrings } from '../locales/strings' +import { getCurrencyCode } from './CurrencyInfoHelpers' +import { convertNativeToDisplay, zeroString } from './utils' + +/** A swap failure, phrased for the user. */ +export interface SwapErrorDisplayInfo { + message: string + title: string + error: unknown +} + +interface ProcessSwapQuoteErrorOpts { + error: unknown + swapRequest: EdgeSwapRequest + fromDenomination: EdgeDenomination + toDenomination: EdgeDenomination + /** + * Destination currency code. A send-to-address request carries no + * destination wallet to read one from, so the caller supplies it. + */ + toCurrencyCode?: string +} + +/** + * Turn a failed swap quote into something worth showing a user: the provider's + * own message, the limit that was crossed, or the specific reason the pair is + * unavailable. Callers render the result rather than a catch-all string, so a + * user who is 0.1 LTC under the floor is told the floor. + */ +export function processSwapQuoteError({ + error, + swapRequest, + fromDenomination, + toDenomination, + toCurrencyCode +}: ProcessSwapQuoteErrorOpts): SwapErrorDisplayInfo | undefined { + // Basic sanity checks (should never fail): + if (error == null) return + + // Some plugins get the insufficient funds error wrong: + const errorMessage = + error instanceof Error ? error.message : JSON.stringify(error) + + // Track swap errors to sentry: + trackSwapError(error, swapRequest) + + // Check for known error types: + const insufficientFunds = asMaybeInsufficientFundsError(error) + if (insufficientFunds != null || errorMessage === 'InsufficientFundsError') { + return { + title: lstrings.exchange_insufficient_funds_title, + message: lstrings.exchange_insufficient_funds_message, + error + } + } + + if ( + error instanceof Error && + error.message === 'Unexpected pending transactions' + ) { + return { + title: lstrings.exchange_insufficient_funds_title, + message: lstrings.exchange_pending_funds_error, + error + } + } + + const aboveLimit = asMaybeSwapAboveLimitError(error) + if (aboveLimit != null) { + const currentCurrencyDenomination = + aboveLimit.direction === 'to' ? toDenomination : fromDenomination + + const { nativeMax } = aboveLimit + const nativeToDisplayRatio = currentCurrencyDenomination.multiplier + const displayMax = convertNativeToDisplay(nativeToDisplayRatio)(nativeMax) + + return { + title: lstrings.exchange_generic_error_title, + message: !zeroString(displayMax) + ? sprintf( + lstrings.amount_above_limit, + displayMax, + currentCurrencyDenomination.name + ) + : lstrings.no_amount_above_limit, + error + } + } + + const belowLimit = asMaybeSwapBelowLimitError(error) + if (belowLimit != null) { + const currentCurrencyDenomination = + belowLimit.direction === 'to' ? toDenomination : fromDenomination + + const { nativeMin } = belowLimit + const nativeToDisplayRatio = currentCurrencyDenomination.multiplier + const displayMin = convertNativeToDisplay(nativeToDisplayRatio)(nativeMin) + + return { + title: lstrings.exchange_generic_error_title, + message: !zeroString(displayMin) + ? sprintf( + lstrings.amount_below_limit, + displayMin, + currentCurrencyDenomination.name + ) + : lstrings.no_amount_below_limit, + error + } + } + + const currencyError = asMaybeSwapCurrencyError(error) + if (currencyError != null) { + const fromCurrencyCode = getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) + const toCode = + toCurrencyCode ?? + getCurrencyCode( + // 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 + ) + + return { + title: lstrings.exchange_generic_error_title, + message: sprintf(lstrings.ss_unable, fromCurrencyCode, toCode), + error + } + } + + const permissionError = asMaybeSwapPermissionError(error) + if (permissionError?.reason === 'geoRestriction') { + return { + title: lstrings.exchange_generic_error_title, + message: lstrings.ss_geolock, + error + } + } + + // Anything else. The provider's own message beats a catch-all string, since + // it is usually the only thing that says what actually went wrong: + return { + title: lstrings.exchange_generic_error_title, + message: errorMessage, + error + } +} + +/** + * Reports a swap error to Sentry, with searchable tags for the swap request + * according to Edge's company policy. + */ +function trackSwapError(error: unknown, swapRequest: EdgeSwapRequest): void { + captureException(error, scope => { + // This is a warning level error because it's expected to occur but not wanted. + scope.setLevel('warning') + // Searchable tags: + scope.setTags({ + errorType: 'swapQuoteFailure', + swapFromWalletKind: swapRequest.fromWallet.currencyInfo.pluginId, + swapFromCurrency: getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ), + swapToCurrency: getCurrencyCode( + swapRequest.toWallet ?? swapRequest.fromWallet, + swapRequest.toTokenId + ), + swapToWalletKind: (swapRequest.toWallet ?? swapRequest.fromWallet) + .currencyInfo.pluginId, + swapDirectionType: swapRequest.quoteFor + }) + // Unsearchable context data: + scope.setContext('Swap Request Details', { + fromTokenId: String(swapRequest.fromTokenId), // Stringify to include "null" + fromWalletType: swapRequest.fromWallet.type, + toTokenId: String(swapRequest.toTokenId), // Stringify to include "null" + toWalletType: swapRequest.fromWallet.type, + quoteFor: swapRequest.quoteFor + }) + return scope + }) +} From 04a9ae60903afc754eee5090c561d84e9d89351d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 19:47:06 -0700 Subject: [PATCH 22/56] Close the PIN-gate timing hole and two stealth leaks The spending-limit flag was state written inside the makeSpend effect, which runs after the render that already holds a live quote. One frame could show an armed slider while the flag still read false, most easily when the guaranteed side is receive and the send amount had just been written from the quote. The flag is now derived during render from the same spendInfo the slider is judging, so the gate and the amount it gates cannot disagree. Changing the source wallet now also clears the Stealth toggle and the learned route capabilities. Both were learned from the previous pair, so carrying them into a new asset produced auto-disables and quote errors the user could not connect to anything they did. A stealth send's title now outranks any stored metadata name. The title came from SWAP_SEND_LABEL_MAP but lost the merge to a non-empty metadata.name, so a recipient-style name reaching the transaction by any route would display exactly what the private flows exist to conceal. --- src/actions/CategoriesActions.ts | 12 ++++- src/components/scenes/SendScene2.tsx | 77 ++++++++++++++++------------ 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index f1397d62f20..c07eddda7c7 100644 --- a/src/actions/CategoriesActions.ts +++ b/src/actions/CategoriesActions.ts @@ -337,6 +337,8 @@ export const getTxActionDisplayInfo = ( tx.nativeAmount.startsWith('-') || (eq(tx.nativeAmount, '0') && tx.isSend) let payeeText: string | undefined + /** Title wins over any stored metadata name (privacy-bearing titles). */ + let forceSavedName = false let edgeCategory: EdgeCategory let direction: 'send' | 'receive' let notes: string | undefined @@ -384,6 +386,14 @@ export const getTxActionDisplayInfo = ( // for support. if (action.swapType != null) { payeeText = SWAP_SEND_LABEL_MAP[action.swapType] + // The two private flavors exist to keep the recipient off the + // screen, so their title outranks any stored metadata name. A + // recipient-style name reaching this transaction by any route would + // otherwise win the merge below and display exactly what the flow + // is meant to conceal. + forceSavedName = + action.swapType === 'stealthSend' || + action.swapType === 'stealthSwapSend' } switch (assetActionType) { case 'transfer': { @@ -692,7 +702,7 @@ export const getTxActionDisplayInfo = ( const mergedData: EdgeMetadata = { name: - metadata?.name != null && metadata.name.length > 0 + !forceSavedName && metadata?.name != null && metadata.name.length > 0 ? metadata.name : savedData.name, category: diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 554baadec0e..d00436d80ac 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -271,8 +271,6 @@ const SendComponent: React.FC<Props> = props => { const [edgeTransaction, setEdgeTransaction] = useState<EdgeTransaction | null>(null) const [pinValue, setPinValue] = useState<string | undefined>(undefined) - const [spendingLimitExceeded, setSpendingLimitExceeded] = - useState<boolean>(false) const [lastAddressEntryMethod, setLastAddressEntryMethod] = useState< AddressEntryMethod | undefined >(undefined) @@ -433,6 +431,43 @@ const SendComponent: React.FC<Props> = props => { null ) + // The PIN spending limit gates every outbound flow, swap-send included. + // DERIVED, not effect-written: it used to be state refreshed inside the + // makeSpend effect, which runs AFTER the render that already holds a live + // quote, so a frame could show an armed slider while the flag still read + // false. Computing it during render makes the gate and the amount it judges + // come from the same render. + const spendingLimitExceeded = React.useMemo<boolean>(() => { + if (!pinSpendingLimitsEnabled) return false + const rate = + getExchangeRate( + exchangeRates, + coreWallet.currencyInfo.pluginId, + tokenId, + defaultIsoFiat + ) ?? INFINITY_STRING + const totalNativeAmount = spendInfo.spendTargets.reduce( + (prev, target) => add(target.nativeAmount ?? '0', prev), + '0' + ) + const totalExchangeAmount = div( + totalNativeAmount, + cryptoExchangeDenomination.multiplier, + DECIMAL_PRECISION + ) + const fiatAmount = mul(totalExchangeAmount, rate) + return gte(fiatAmount, pinSpendingLimitsAmount.toFixed(DECIMAL_PRECISION)) + }, [ + coreWallet.currencyInfo.pluginId, + cryptoExchangeDenomination.multiplier, + defaultIsoFiat, + exchangeRates, + pinSpendingLimitsAmount, + pinSpendingLimitsEnabled, + spendInfo, + tokenId + ]) + // A raw swap failure renders as ErrorCard's catch-all "unexpected error" // card, which tells the user nothing they can act on. Map it to the same // specific text the wallet-to-wallet swap flow shows: the limit that was @@ -1111,13 +1146,18 @@ const SendComponent: React.FC<Props> = props => { setSpendInfo({ tokenId: result.tokenId, spendTargets: [{}] }) // A new source asset invalidates the swap-send destination state, // including the fixed-to warning, which describes a route for the - // pair the user just left: + // pair the user just left. The Stealth toggle and the learned route + // capabilities go with it: both were learned from the previous + // pair, so carrying them over produces auto-disables and errors the + // user cannot connect to anything they did. setRecipientPluginId(undefined) setDestinationTag(undefined) setSwapQuote(undefined) setReceiveNativeAmount(undefined) setGuaranteedSide('send') setFixedToFallback(false) + setStealth(false) + setRouteCaps({}) } }) .catch((error: unknown) => { @@ -2539,33 +2579,6 @@ const SendComponent: React.FC<Props> = props => { if (onBack != null) onBack() }) - // The PIN spending limit gates every outbound flow, so both submit paths - // evaluate it. A send-to-address swap never reaches the makeSpend body - // below, which is where this used to live. - const updateSpendingLimitExceeded = useHandler((): void => { - if (!pinSpendingLimitsEnabled) return - const rate = - getExchangeRate( - exchangeRates, - coreWallet.currencyInfo.pluginId, - tokenId, - defaultIsoFiat - ) ?? INFINITY_STRING - const totalNativeAmount = spendInfo.spendTargets.reduce( - (prev, target) => add(target.nativeAmount ?? '0', prev), - '0' - ) - const totalExchangeAmount = div( - totalNativeAmount, - cryptoExchangeDenomination.multiplier, - DECIMAL_PRECISION - ) - const fiatAmount = mul(totalExchangeAmount, rate) - setSpendingLimitExceeded( - gte(fiatAmount, pinSpendingLimitsAmount.toFixed(DECIMAL_PRECISION)) - ) - }) - // Calculate the transaction useAsyncEffect( async () => { @@ -2574,8 +2587,6 @@ const SendComponent: React.FC<Props> = props => { if (swapSendActive) { setEdgeTransaction(null) setProcessingAmountChanged(false) - // The limit still applies to it, and nothing below this branch runs. - updateSpendingLimitExceeded() return } pendingInsufficientFees.current = undefined @@ -2583,7 +2594,6 @@ const SendComponent: React.FC<Props> = props => { setProcessingAmountChanged(true) if (spendInfo.spendTargets[0].publicAddress == null) { setEdgeTransaction(null) - setSpendingLimitExceeded(false) setMaxSpendSetter(-1) setProcessingAmountChanged(false) return @@ -2599,7 +2609,6 @@ const SendComponent: React.FC<Props> = props => { feeTokenId: null }) } - updateSpendingLimitExceeded() if (minNativeAmount != null) { for (const target of spendInfo.spendTargets) { From edb9abe8ac84147f33b2ee44c9fe9e69dcb2de98 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 19:52:30 -0700 Subject: [PATCH 23/56] Record the error-display decision in the design doc --- src/docs/stealth-send-swap.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 8b9e581667a..4eaad614bec 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -514,6 +514,10 @@ Queued: an operator followup with two features and a standing rule. The rule: su Queued: nothing from the operator. A re-fire's finalize-gate re-confirmation found the PR sitting in draft, which had suppressed the reviewer bots entirely: this repo runs no typecheck on pull requests, so the draft bought nothing and hid everything. Marking it ready produced seven findings across two review rounds. Shipped: the stale-quote clear on every amount commit plus a generation guard on the quote effect, the fixed-to fallback no longer stranding the scene when rates are missing, the PIN spending limit now gating swap-send at all three points it was bypassed, a null check on an empty quote list, a full swap-send reset when the address is cleared, and the stealth degrade wired into the confirmation scene's re-quote. Diverged: the deferred-work table said PIN limits on stealth sends were "not doing"; that decision is reversed here, and the reasoning behind it is recorded below as a mistake worth keeping. +### Phase 9: real failures in the error UI + +Queued: an operator followup. Swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert, plus the in-app verification the previous segment could not perform. Shipped: the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". Three more review findings landed with it: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), the source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. Diverged: nothing. + ### Deferred work | Item | Disposition | Reason | @@ -639,6 +643,16 @@ Rejected: **matching the swap flow for consistency**, which is consistency with Reopen if: the swap flow itself gains send-to-address destinations, at which point it needs this gate too rather than the reverse. +### Share the swap error mapping instead of a catch-all card + +Chosen: the send scene maps a failed quote through the same `processSwapQuoteError` the wallet-to-wallet swap flow uses, extracted to `src/util/swapErrorDisplay.ts`, and wraps the result in an `I18nError` so `ErrorCard` renders the specific title and body. + +Evidence: `ErrorCard` renders anything that is not an `I18nError` as "Unexpected Error" with a canned "an unexpected error occurred, check your network connection" body and a Report Error button. Every swap-send failure took that path, so a user 0.1 TRX under the floor, a user on an unroutable pair, and a user with a genuine bug all saw the same card. The swap flow already had the mapping; the send scene simply was not using it. Verified on device: a 10 TRX send-to-address against a 10 USD provider floor now reads "Exchange Error / HoudiniSwap: Amount is too low, minimum is 10 USD". + +Rejected: **a bespoke error map for the send scene**, which would drift from the swap flow's within a release, and **passing the raw error through**, which loses the localized limit formatting the swap flow already does. + +Reopen if: the provider starts returning structured limit data on the error responses, which would let the plugin raise a typed `SwapBelowLimitError` for the 422 band instead of a plain message. + ## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) From d9bea4381ba6be465fd83b20d577c7a2a8e5a5f6 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 19:58:44 -0700 Subject: [PATCH 24/56] Infer the payout token from the payout wallet upgradeSwapData names its parameter destinationWallet but was always handed the source transaction's wallet, so a payout currency code was resolved against the source chain's config. That mislabels the payout asset, or leaves payoutTokenId unset and hides the exchange card entirely. It now receives the resolved payout wallet when swapData carries one, falling back to the source wallet for a swap-to-address payout, which has no destination wallet to read. --- src/components/cards/SwapDetailsCard.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index 25e6771f4a1..01db99bdacb 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -99,7 +99,12 @@ export const SwapDetailsCard: React.FC<Props> = props => { payoutTokenId, plugin, refundAddress - } = upgradeSwapData(wallet, swapData) + // Infer the payout token from the PAYOUT wallet's config when there is + // one. Passing the source wallet resolves the payout currency code + // against the wrong chain, which either mislabels the payout or leaves + // payoutTokenId unset and hides this card. A swap-to-address payout has + // no wallet, so it keeps the source wallet as the only config available. + } = upgradeSwapData(destinationWallet ?? wallet, swapData) const formattedOrderUri = orderUri == null ? undefined From 4719a67f62d14def0c8286f40a716cdc0bd43b8d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 20:03:15 -0700 Subject: [PATCH 25/56] Report the destination wallet type in swap error tracking trackSwapError set the Sentry context's toWalletType from swapRequest.fromWallet, so every swap-quote failure report claimed the swap ended on the chain it started on. It now reads the destination wallet, falling back to the source for a swap-to-address request, which has none. --- src/util/swapErrorDisplay.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/util/swapErrorDisplay.ts b/src/util/swapErrorDisplay.ts index 8769f70ae8d..128af7b1c3b 100644 --- a/src/util/swapErrorDisplay.ts +++ b/src/util/swapErrorDisplay.ts @@ -190,7 +190,11 @@ function trackSwapError(error: unknown, swapRequest: EdgeSwapRequest): void { fromTokenId: String(swapRequest.fromTokenId), // Stringify to include "null" fromWalletType: swapRequest.fromWallet.type, toTokenId: String(swapRequest.toTokenId), // Stringify to include "null" - toWalletType: swapRequest.fromWallet.type, + // The destination's own type. This read the SOURCE wallet before, so + // every Sentry report claimed the swap ended on the chain it started + // on. A swap-to-address request has no destination wallet, so it falls + // back and the tag above still records the real payout currency. + toWalletType: (swapRequest.toWallet ?? swapRequest.fromWallet).type, quoteFor: swapRequest.quoteFor }) return scope From 4d93bc50522a0c84a46164d50e15347509e2261d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 20:08:53 -0700 Subject: [PATCH 26/56] Escape the dots in the Hedera address pattern The Hedera addressValidation regex spelled the account id as (0.0.), where the dots are wildcards rather than literals, so it accepted 0X0Y12345 and 0a0b12345 as valid destinations. It is the only pattern in the table with an unescaped literal dot. --- src/util/houdiniChains.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts index f5d2c5e4dc0..f969320a337 100644 --- a/src/util/houdiniChains.ts +++ b/src/util/houdiniChains.ts @@ -134,7 +134,9 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ pluginId: 'hedera', houdiniShortName: 'hedera', memoNeeded: true, - addressValidation: /^(0.0.)[0-9]{4,40}$/ + // Dots escaped: unescaped they are wildcards, so the pattern accepted + // anything shaped like 0X0Y12345 as a Hedera account id. + addressValidation: /^0\.0\.[0-9]{4,40}$/ }, { pluginId: 'hyperevm', From 77bafae5b875aeb340c33751a6e6b826923f5693 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 20:21:17 -0700 Subject: [PATCH 27/56] Record the review-round corrections in the design doc --- src/docs/stealth-send-swap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 4eaad614bec..f5e311b628e 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -516,7 +516,7 @@ Queued: nothing from the operator. A re-fire's finalize-gate re-confirmation fou ### Phase 9: real failures in the error UI -Queued: an operator followup. Swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert, plus the in-app verification the previous segment could not perform. Shipped: the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". Three more review findings landed with it: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), the source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. Diverged: nothing. +Queued: an operator followup. Swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert, plus the in-app verification the previous segment could not perform. Shipped: the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". Six more review findings landed with it. Three behavioral: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), the source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. Three corrections: `upgradeSwapData` receives the payout wallet rather than the source wallet, so a payout currency code is resolved against its own chain; `trackSwapError` reports the destination wallet type instead of repeating the source; and the Hedera `addressValidation` pattern escapes its dots, which previously made them wildcards that accepted `0X0Y12345`. Diverged: one finding was rejected rather than fixed, that the limit ignores the network fee, because `origin/develop` computes it the same way for every plain send, so changing only this path would make the two disagree. ### Deferred work From 6c02cf3b4873818a3d6638af15cf67afe848a420 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Tue, 28 Jul 2026 20:21:42 -0700 Subject: [PATCH 28/56] Fix the phase-entry wording in the design doc --- src/docs/stealth-send-swap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index f5e311b628e..760e4b14256 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -516,7 +516,7 @@ Queued: nothing from the operator. A re-fire's finalize-gate re-confirmation fou ### Phase 9: real failures in the error UI -Queued: an operator followup. Swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert, plus the in-app verification the previous segment could not perform. Shipped: the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". Six more review findings landed with it. Three behavioral: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), the source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. Three corrections: `upgradeSwapData` receives the payout wallet rather than the source wallet, so a payout currency code is resolved against its own chain; `trackSwapError` reports the destination wallet type instead of repeating the source; and the Hedera `addressValidation` pattern escapes its dots, which previously made them wildcards that accepted `0X0Y12345`. Diverged: one finding was rejected rather than fixed, that the limit ignores the network fee, because `origin/develop` computes it the same way for every plain send, so changing only this path would make the two disagree. +Queued: an operator followup. Swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert, plus the in-app verification the previous segment could not perform. Shipped: the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". The review rounds that followed produced six more fixes. Three of them are behavioral: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), the source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. Three corrections: `upgradeSwapData` receives the payout wallet rather than the source wallet, so a payout currency code is resolved against its own chain; `trackSwapError` reports the destination wallet type instead of repeating the source; and the Hedera `addressValidation` pattern escapes its dots, which previously made them wildcards that accepted `0X0Y12345`. Diverged: one finding was rejected rather than fixed, that the limit ignores the network fee, because `origin/develop` computes it the same way for every plain send, so changing only this path would make the two disagree. ### Deferred work From d0feca3a824930fac46841e8a7d5713aaa5dbc82 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 18:36:07 -0700 Subject: [PATCH 29/56] Reconcile the Houdini chain table with the routes it claims Four listed chains have no mainnet native at the provider, so every quote to them threw while the UI offered them as destinations. Records each chain's self-private capability from the provider's own flag, and names their three order minimums where the send scene can enforce them. --- src/locales/strings/enUS.json | 3 + src/util/houdiniChains.ts | 109 +++++++++++++++++++++++++--------- 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 2a780cba15f..a34cc47299b 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1294,6 +1294,9 @@ "stealth_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Send has been turned off.", "stealth_swap_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Swap has been turned off.", "stealth_route_unavailable_info": "Private routing is not available for this pair right now.", + "stealth_self_private_unsupported_1s": "Private routing is not available when sending %1$s to itself.", + "stealth_below_private_minimum_1s": "Private routing needs at least $%1$s. Enter a larger amount to send privately.", + "stealth_below_standard_minimum_1s": "The provider needs at least $%1$s to route this send. Enter a larger amount.", "stealth_fixed_to_unavailable_toast": "The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.", "stealth_fixed_to_fallback_title": "Receive amount is an estimate", "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts index f969320a337..b1e27c32102 100644 --- a/src/util/houdiniChains.ts +++ b/src/util/houdiniChains.ts @@ -13,52 +13,75 @@ export interface HoudiniChain { pluginId: string houdiniShortName: string memoNeeded: boolean + /** + * Whether Houdini can route this asset to ITSELF privately, from the + * `hasSelfPrivate` flag on their token query. Same-asset private is their + * dominant flow, and it is an asset capability rather than a per-pair + * verdict, so it reads off the token metadata with no quote and no probing. + */ + hasSelfPrivate: boolean addressValidation: RegExp } /** - * Snapshot of Houdini's `GET /chains` (v2 partner API, fetched 2026-07-02) - * intersected with Edge's currency pluginIds, mirroring the + * Snapshot of Houdini's mainnet native tokens (v2 partner API, re-fetched + * 2026-07-30) 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. + * there (no trustworthy memo metadata), so they are absent here too. + * + * Every chain listed here resolves to a native token Houdini actually serves. + * `celo`, `fantom`, `polkadot` and `ton` were listed before and are not: the + * API returns no mainnet native for them, so every quote to those chains threw + * while the UI offered them as destinations. + * + * This is a snapshot on purpose. Houdini is an aggregator whose per-pair + * availability fluctuates too fast to track and whose Cloudflare blocks tight + * probing loops, so nothing here may be discovered at runtime: asset-level + * capability lives in this table, and pair-level capability is learned only + * from a real user-initiated quote (`pairCaps`). A follow-up can refresh the + * table from the API once chain metadata is exposed through the swap plugin. */ export const HOUDINI_CHAINS: HoudiniChain[] = [ { pluginId: 'algorand', houdiniShortName: 'algorand', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^[A-Z0-9]{58,58}$/ }, { pluginId: 'arbitrum', houdiniShortName: 'arbitrum', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'avalanche', houdiniShortName: 'avalanche', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'base', houdiniShortName: 'base', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'binancesmartchain', houdiniShortName: 'bsc', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'bitcoin', houdiniShortName: 'bitcoin', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39}|bc1[a-z0-9]{59})$/ }, @@ -66,6 +89,7 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ pluginId: 'bitcoincash', houdiniShortName: 'bitcoincash', memoNeeded: false, + hasSelfPrivate: true, 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})$/ }, @@ -73,12 +97,14 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ pluginId: 'bitcoinsv', houdiniShortName: 'bsv', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/ }, { pluginId: 'cardano', houdiniShortName: 'cardano', memoNeeded: false, + hasSelfPrivate: true, // Houdini's own regex ends in `|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$`, whose // first alternative is unanchored and zero-length and so matches EVERY // string, including empty. Those two catch-alls are dropped here: they @@ -87,34 +113,32 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ 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})$/ }, - { - pluginId: 'celo', - houdiniShortName: 'celo', - memoNeeded: false, - addressValidation: /^(0x)[0-9A-Za-z]{40}$/ - }, { pluginId: 'cosmoshub', houdiniShortName: 'cosmoshub-4', memoNeeded: true, + hasSelfPrivate: true, addressValidation: /^(cosmos1)[0-9a-z]{38}$/ }, { pluginId: 'dash', houdiniShortName: 'dash', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^[X|7][0-9A-Za-z]{33}$/ }, { pluginId: 'dogecoin', houdiniShortName: 'doge', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(D|A|9)[a-km-zA-HJ-NP-Z1-9]{33,34}$/ }, { pluginId: 'ecash', houdiniShortName: 'eCash', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[0-9A-Za-z]{42,42}$|^(ecash:)[0-9A-Za-z]{30,70}$/ }, @@ -122,18 +146,14 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ pluginId: 'ethereum', houdiniShortName: 'ethereum', memoNeeded: false, - addressValidation: /^(0x)[0-9A-Za-z]{40}$/ - }, - { - pluginId: 'fantom', - houdiniShortName: 'fantom', - memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'hedera', houdiniShortName: 'hedera', memoNeeded: true, + hasSelfPrivate: true, // Dots escaped: unescaped they are wildcards, so the pattern accepted // anything shaped like 0X0Y12345 as a Hedera account id. addressValidation: /^0\.0\.[0-9]{4,40}$/ @@ -142,132 +162,163 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ pluginId: 'hyperevm', houdiniShortName: 'hyperevm', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'litecoin', houdiniShortName: 'litecoin', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(L|M|3)[A-Za-z0-9]{33}$|^(ltc1)[0-9A-Za-z]{39}$/ }, { pluginId: 'monero', houdiniShortName: 'monero', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^[48][a-zA-Z|\d]{94}([a-zA-Z|\d]{11})?$/ }, { pluginId: 'opbnb', houdiniShortName: 'opbnb', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'optimism', houdiniShortName: 'optimism', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'pivx', houdiniShortName: 'pivx', memoNeeded: false, + hasSelfPrivate: true, // Houdini writes `[0-9A-za-z]`, where `A-z` also spans `[ \ ] ^ _ \``. // PIVX addresses are base58, so the strict class is used instead. addressValidation: /^D[1-9A-HJ-NP-Za-km-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, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'pulsechain', houdiniShortName: 'pulsechain', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'ripple', houdiniShortName: 'ripple', memoNeeded: true, + hasSelfPrivate: true, addressValidation: /^r[1-9A-HJ-NP-Za-km-z]{25,34}$/ }, { pluginId: 'rsk', houdiniShortName: 'rootstock', memoNeeded: false, + hasSelfPrivate: false, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'solana', houdiniShortName: 'solana', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ }, { pluginId: 'sonic', houdiniShortName: 'sonic', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'stellar', houdiniShortName: 'xlm', memoNeeded: true, + hasSelfPrivate: true, addressValidation: /^G[A-D]{1}[A-Z2-7]{54}$/ }, { pluginId: 'sui', houdiniShortName: 'sui', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{64}$/ }, { pluginId: 'telos', houdiniShortName: 'telos', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ }, { pluginId: 'thorchainrune', houdiniShortName: 'thorchain', memoNeeded: true, + hasSelfPrivate: 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, + hasSelfPrivate: true, addressValidation: /^T[1-9A-HJ-NP-Za-km-z]{33}$/ }, { pluginId: 'zcash', houdiniShortName: 'Zcash', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^t1[1-9A-HJ-NP-Za-km-z]{33}$/ }, { pluginId: 'zksync', houdiniShortName: 'zksync-era', memoNeeded: false, + hasSelfPrivate: true, addressValidation: /^(0x)[0-9A-Za-z]{40}$/ } ] +/** + * Houdini's own minimum order sizes, in USD. Their guidance, verbatim: "on your + * side you should stick to our hardcoded minimums that under 25 USD there are + * no routes for private." + * + * Confirmed against the live v2 API on 2026-07-30 rather than taken on faith. + * Cross-asset TRX to LTC answered 422 "Amount is too low, minimum is 10 USD" at + * 8 USD, returned standard routes only from 12 to 24 USD, and added private + * routes from 25 USD up. Same-asset TRX to TRX answered 422 "Amount is too low, + * minimum is 25 USD" below 25 and returned private routes only above it. + * + * These are floors, not the whole story: individual tokens carry higher + * server-side minimums that cannot be known upfront (Polygon private is + * effectively 60 USD), which arrive as quote errors carrying the real minimum. + */ +export const HOUDINI_MIN_USD = { + /** Private (multi-exchange) routes, which every stealth flow requires. */ + private: '25', + /** Standard (single-exchange) routes, used by a plain swap-and-send. */ + standard: '10', + /** On-chain DEX routes, offered only for assets with `hasDex`. */ + dex: '5' +} as const + /** Look up the Houdini destination chain for an Edge asset, if served. */ export function getHoudiniChain( pluginId: string, From 0015a6badde065b2b47a4d90116ad6053c868777 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 18:36:31 -0700 Subject: [PATCH 30/56] Ask for privacy per request and enforce the provider's minimums The stealth toggle now changes what is asked of the provider rather than only what the transaction is called: the request demands a private route, and a plain swap-and-send may take the standard routes that are the only ones served between the two floors. Both states re-quote on every flip. Amounts under the applicable floor are refused before a request goes out, and the toggle explains itself when the asset or the amount cannot route privately. The send path also stops honoring the exchange-settings provider toggle, which governs swapping rather than sending. --- src/components/scenes/SendScene2.tsx | 218 ++++++++++++++++++++++++--- src/locales/en_US.ts | 6 + src/util/stealthSwap.ts | 19 ++- 3 files changed, 222 insertions(+), 21 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index d00436d80ac..3a4ea979ab4 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -1,4 +1,15 @@ -import { abs, add, div, gte, lt, lte, mul, sub, toFixed } from 'biggystring' +import { + abs, + add, + div, + gte, + lt, + lte, + mul, + round, + sub, + toFixed +} from 'biggystring' import { asMaybe } from 'cleaners' import { asMaybeInsufficientFundsError, @@ -46,7 +57,10 @@ import { useUnmount } from '../../hooks/useUnmount' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' import { getExchangeDenom } from '../../selectors/DenominationSelectors' -import { getExchangeRate } from '../../selectors/WalletSelectors' +import { + convertCurrency, + getExchangeRate +} from '../../selectors/WalletSelectors' import { config } from '../../theme/appConfig' import { useState } from '../../types/reactHooks' import { useDispatch, useSelector } from '../../types/reactRedux' @@ -66,6 +80,7 @@ import { detectHoudiniChains, getHoudiniChain, HOUDINI_CHAINS, + HOUDINI_MIN_USD, type HoudiniChain, isValidHoudiniAddress } from '../../util/houdiniChains' @@ -84,6 +99,7 @@ import { convertTransactionFeeToDisplayFee, darkenHexColor, DECIMAL_PRECISION, + getDenomFromIsoCode, zeroString } from '../../util/utils' import { openBrowserUri } from '../../util/WebUtils' @@ -431,6 +447,67 @@ const SendComponent: React.FC<Props> = props => { null ) + /** + * Whether Houdini can route the source asset to ITSELF privately, which is + * what a same-asset Stealth Send asks for. Read off the chain table rather + * than learned from a quote, since it is a property of the asset. + */ + const selfPrivateAvailable = + getHoudiniChain(pluginId, tokenId)?.hasSelfPrivate === true + + /** + * The order size in USD, the unit Houdini states its minimums in. Priced off + * whichever side the user fixed, so it matches the number they typed. + * `undefined` when no rate is known, which stands the floor check down rather + * than blocking a send on a missing rate. + */ + const orderUsdValue = React.useMemo<string | undefined>(() => { + const useSendSide = guaranteedSide === 'send' + const nativeAmount = useSendSide + ? spendInfo.spendTargets[0].nativeAmount + : receiveNativeAmount + const multiplier = useSendSide + ? cryptoExchangeDenomination.multiplier + : destExchangeDenom?.multiplier + if ( + nativeAmount == null || + zeroString(nativeAmount) || + multiplier == null + ) { + return undefined + } + const usdValue = convertCurrency( + exchangeRates, + useSendSide ? pluginId : destPluginId, + useSendSide ? tokenId : null, + 'iso:USD', + div(nativeAmount, multiplier, DECIMAL_PRECISION) + ) + return zeroString(usdValue) ? undefined : usdValue + }, [ + cryptoExchangeDenomination.multiplier, + destExchangeDenom?.multiplier, + destPluginId, + exchangeRates, + guaranteedSide, + pluginId, + receiveNativeAmount, + spendInfo, + tokenId + ]) + + /** + * Whether the order clears Houdini's minimum for the route it would take. + * Private routes start at 25 USD and standard ones at 10, so a Stealth Send + * is pre-empted well before a plain Swap & Send is. Both are checked before + * any request goes out: the provider is an aggregator that rate-limits tight + * traffic, so a quote we already know will be refused is not worth sending. + */ + const belowPrivateFloor = + orderUsdValue != null && lt(orderUsdValue, HOUDINI_MIN_USD.private) + const belowStandardFloor = + orderUsdValue != null && lt(orderUsdValue, HOUDINI_MIN_USD.standard) + // The PIN spending limit gates every outbound flow, swap-send included. // DERIVED, not effect-written: it used to be state refreshed inside the // makeSpend effect, which runs AFTER the render that already holds a live @@ -509,6 +586,27 @@ const SendComponent: React.FC<Props> = props => { })) } + /** + * Why the Stealth toggle cannot be armed right now, or `undefined` when it + * can. Ordered most specific first, so the user reads the reason that + * applies to the send in front of them rather than the first one that fires. + */ + const stealthBlockedReason: string | undefined = multipleTargets + ? lstrings.stealth_multi_recipient_unsupported + : sameAsset && !selfPrivateAvailable + ? sprintf(lstrings.stealth_self_private_unsupported_1s, currencyCode) + : belowPrivateFloor + ? sprintf( + lstrings.stealth_below_private_minimum_1s, + HOUDINI_MIN_USD.private + ) + : pairCaps.stealth === false + ? lstrings.stealth_route_unavailable_info + : undefined + + /** The floor this send must clear for the route it would actually take. */ + const belowActiveFloor = stealth ? belowPrivateFloor : belowStandardFloor + const updatePendingTxState = React.useCallback(async (): Promise<void> => { if (coreWallet == null || !isEvmWallet(coreWallet)) { setHasPendingTx(false) @@ -1191,10 +1289,11 @@ const SendComponent: React.FC<Props> = props => { const handleToggleStealth = useHandler((): void => { if (multipleTargets) return - // The pair is known to have no private route: refuse to arm and say why, - // instead of arming a toggle whose quote is guaranteed to fail. - if (!stealth && pairCaps.stealth === false) { - showToast(lstrings.stealth_route_unavailable_toast) + // No private route is possible for this asset, pair, or amount: refuse to + // arm and say why, instead of arming a toggle whose quote is guaranteed to + // fail. Turning it back OFF is always allowed. + if (!stealth && stealthBlockedReason != null) { + showToast(stealthBlockedReason) return } setStealth(value => !value) @@ -1420,7 +1519,8 @@ const SendComponent: React.FC<Props> = props => { displayAmount: string, displayCode: string, isGuaranteed: boolean, - onPress: () => void + onPress: () => void, + fiatAmount: string | undefined ): React.ReactElement => ( <EdgeRow rightButtonType="editable" title={title} onPress={onPress}> <View style={styles.swapAmountRow}> @@ -1435,9 +1535,42 @@ const SendComponent: React.FC<Props> = props => { : lstrings.stealth_estimated} </EdgeText> </View> + {fiatAmount == null ? null : ( + <EdgeText style={styles.swapFiatText}>{fiatAmount}</EdgeText> + )} </EdgeRow> ) + /** + * The fiat value of one side of the swap, in the same shape the plain send + * tile uses (`EditableAmountTile`). `undefined` when no rate is available, so + * the row simply drops the line rather than showing a zero. + */ + const swapRowFiatAmount = ( + nativeAmount: string | undefined, + rowPluginId: string, + rowTokenId: EdgeTokenId, + exchangeMultiplier: string | undefined + ): string | undefined => { + if ( + nativeAmount == null || + zeroString(nativeAmount) || + exchangeMultiplier == null + ) { + return undefined + } + const fiatAmount = convertCurrency( + exchangeRates, + rowPluginId, + rowTokenId, + defaultIsoFiat, + div(nativeAmount, exchangeMultiplier, DECIMAL_PRECISION) + ) + if (zeroString(fiatAmount)) return undefined + const fiatSymbol = getDenomFromIsoCode(defaultIsoFiat).symbol ?? '' + return `${fiatSymbol} ${toFixed(round(fiatAmount, -2), 2, 2)}` + } + const renderYouSendRow = (): React.ReactElement => { const nativeAmount = spendInfo.spendTargets[0].nativeAmount const displayAmount = zeroString(nativeAmount) @@ -1452,7 +1585,13 @@ const SendComponent: React.FC<Props> = props => { displayAmount, currencyCode, guaranteedSide === 'send', - handleEditYouSend + handleEditYouSend, + swapRowFiatAmount( + nativeAmount, + pluginId, + tokenId, + cryptoExchangeDenomination.multiplier + ) ) } @@ -1471,7 +1610,13 @@ const SendComponent: React.FC<Props> = props => { displayAmount, destCurrencyInfo?.currencyCode ?? '', guaranteedSide === 'receive', - handleEditRecipientGets + handleEditRecipientGets, + swapRowFiatAmount( + receiveNativeAmount, + destPluginId, + null, + destExchangeDenom.multiplier + ) ) } @@ -1616,16 +1761,10 @@ const SendComponent: React.FC<Props> = props => { disabled={multipleTargets} onPress={handleToggleStealth} /> - {multipleTargets ? ( - <View style={styles.stealthInfo}> - <EdgeText style={styles.stealthInfoText} numberOfLines={4}> - {lstrings.stealth_multi_recipient_unsupported} - </EdgeText> - </View> - ) : pairCaps.stealth === false && !stealth ? ( + {stealthBlockedReason != null && !stealth ? ( <View style={styles.stealthInfo}> <EdgeText style={styles.stealthInfoText} numberOfLines={4}> - {lstrings.stealth_route_unavailable_info} + {stealthBlockedReason} </EdgeText> </View> ) : stealth ? ( @@ -2764,6 +2903,26 @@ const SendComponent: React.FC<Props> = props => { return } + // Houdini refuses an order under its floor, so the refusal is spelled + // out here instead of spent on a request. It also keeps a user tapping + // through small amounts from burning the provider's rate limit, whose + // 429s would come back looking like unavailable routes. + if (belowActiveFloor) { + setSwapQuote(undefined) + setError( + new I18nError( + lstrings.exchange_generic_error_title, + sprintf( + stealth + ? lstrings.stealth_below_private_minimum_1s + : lstrings.stealth_below_standard_minimum_1s, + stealth ? HOUDINI_MIN_USD.private : HOUDINI_MIN_USD.standard + ) + ) + ) + return + } + const generation = ++swapQuoteGeneration.current setFetchingSwapQuote(true) try { @@ -2779,7 +2938,11 @@ const SendComponent: React.FC<Props> = props => { // EVERY send-to-address quote is restricted to the Houdini privacy // provider, stealth toggle on or off: send-to-any is a privacy - // feature and must never fan out to other swap providers. + // feature and must never fan out to other swap providers. The toggle + // decides whether the route itself must be private: without + // `privacy: 'required'` Houdini may answer with a standard route, + // which is correct for a plain Swap & Send and would silently + // downgrade a Stealth one. const quotes = await account.fetchSwapQuotes( { fromWallet: coreWallet, @@ -2791,9 +2954,12 @@ const SendComponent: React.FC<Props> = props => { toMemos }, nativeAmount: quoteNativeAmount, - quoteFor: guaranteedSide === 'send' ? 'from' : 'to' + quoteFor: guaranteedSide === 'send' ? 'from' : 'to', + privacy: stealth ? 'required' : undefined }, - makeStealthSwapRequestOptions(account) + makeStealthSwapRequestOptions(account, undefined, { + ignoreProviderSetting: true + }) ) const quote = quotes[0] @@ -2904,6 +3070,12 @@ const SendComponent: React.FC<Props> = props => { }, [ swapSendActive, + // The toggle changes the request: it decides whether the route must be + // private, and which floor applies. Leaving it out left a Stealth quote + // showing standard-route pricing on a cross-asset pair, which is the + // re-quote gap the feedback round called out. + stealth, + belowActiveFloor, spendInfo.spendTargets[0].publicAddress, guaranteedSide, guaranteedSide === 'send' @@ -3116,6 +3288,12 @@ const getStyles = cacheStyles((theme: Theme) => ({ swapAmountText: { fontSize: theme.rem(1) }, + // Mirrors the fiat line on the plain send tile (`EditableAmountTile`), so + // the Houdini rows read the same way the rest of the scene does. + swapFiatText: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, swapAssetRow: { flexDirection: 'row', alignItems: 'center', diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 8c7d6ec1b50..5d889b24f3c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1638,6 +1638,12 @@ const strings = { 'Private routing is not available for this pair right now. Stealth Swap has been turned off.', stealth_route_unavailable_info: 'Private routing is not available for this pair right now.', + stealth_self_private_unsupported_1s: + 'Private routing is not available when sending %1$s to itself.', + stealth_below_private_minimum_1s: + 'Private routing needs at least $%1$s. Enter a larger amount to send privately.', + stealth_below_standard_minimum_1s: + 'The provider needs at least $%1$s to route this send. Enter a larger amount.', stealth_fixed_to_unavailable_toast: 'The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.', stealth_fixed_to_fallback_title: 'Receive amount is an estimate', diff --git a/src/util/stealthSwap.ts b/src/util/stealthSwap.ts index a0abd83f143..ed1bcc45af6 100644 --- a/src/util/stealthSwap.ts +++ b/src/util/stealthSwap.ts @@ -4,6 +4,18 @@ import type { EdgeSwapRequestOptions } from 'edge-core-js' +interface StealthSwapFlags { + /** + * Query Houdini even when the user switched it off in their exchange + * settings. That setting governs which providers the swap aggregator may + * use, so it is the user's answer about swapping, not about sending: a send + * feature that happens to be powered by Houdini must not disappear because a + * swap provider was turned off. Set on the send scene only; the Exchange + * scene keeps honoring the setting. + */ + ignoreProviderSetting?: boolean +} + /** * Restricts a swap request to the Houdini privacy provider, for Stealth Swap * and Stealth Send. Every other enabled swap provider is disabled for the @@ -12,7 +24,8 @@ import type { */ export function makeStealthSwapRequestOptions( account: EdgeAccount, - opts: EdgeSwapRequestOptions = {} + opts: EdgeSwapRequestOptions = {}, + flags: StealthSwapFlags = {} ): EdgeSwapRequestOptions { const disabled: EdgePluginMap<true> = { ...opts.disabled } for (const swapPluginId of Object.keys(account.swapConfig)) { @@ -21,6 +34,10 @@ export function makeStealthSwapRequestOptions( return { ...opts, disabled, + forceEnabled: + flags.ignoreProviderSetting === true + ? { ...opts.forceEnabled, houdini: true } + : opts.forceEnabled, preferPluginId: undefined, preferType: undefined } From 5c7ceb57f8286cbf2d6f0159ea250feb23224a3c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 18:36:55 -0700 Subject: [PATCH 31/56] Show the exchange order details on a swap-send The card resolved its payout denomination through the payout wallet and rendered nothing without one, so a swap-to-address destination, whose payout wallet id is synthetic, hid the order id and provider entirely. Support needs both to trace a stuck order, which is the reason the payout address is stored in the first place; only that address stays hidden. --- src/components/cards/SwapDetailsCard.tsx | 58 ++++++++++++------------ 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index 01db99bdacb..04f70f4d1f6 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -1,5 +1,6 @@ import { abs, sub } from 'biggystring' import type { + EdgeCurrencyConfig, EdgeCurrencyWallet, EdgeTransaction, EdgeTxSwap @@ -47,20 +48,16 @@ const TXID_PLACEHOLDER = '{{TXID}}' // Metadata may have been created and saved before tokenId was required. // If tokenId is missing it defaults to null so we can try upgrading it. const upgradeSwapData = ( - destinationWallet: EdgeCurrencyWallet, + payoutConfig: EdgeCurrencyConfig | undefined, swapData: EdgeTxSwap ): EdgeTxSwap => { - if ( - swapData.payoutTokenId === undefined && - destinationWallet.currencyInfo.currencyCode !== swapData.payoutCurrencyCode - ) { - swapData.payoutTokenId = getTokenId( - destinationWallet.currencyConfig, - swapData.payoutCurrencyCode - ) - } else if (swapData.payoutTokenId === undefined) { - swapData.payoutTokenId = null - } + if (swapData.payoutTokenId !== undefined) return swapData + + swapData.payoutTokenId = + payoutConfig != null && + payoutConfig.currencyInfo.currencyCode !== swapData.payoutCurrencyCode + ? getTokenId(payoutConfig, swapData.payoutCurrencyCode) + : null return swapData } @@ -90,6 +87,22 @@ export const SwapDetailsCard: React.FC<Props> = props => { const destinationWalletName = destinationWallet == null ? '' : getWalletName(destinationWallet) + // The payout asset's own currency config. A swap-to-address payout has no + // wallet to read it off, so it comes from the saved action's destination + // asset instead. Falling back to the SOURCE wallet was not viable: it + // resolves the payout currency code against the wrong chain, which left + // `payoutTokenId` unset and made the guard below hide this whole card for + // every swap-and-send, taking the order id and provider with it. + const payoutSwapAction = + transaction.savedAction?.actionType === 'swap' + ? transaction.savedAction + : undefined + const payoutConfig = + destinationWallet?.currencyConfig ?? + (payoutSwapAction == null + ? undefined + : account.currencyConfig[payoutSwapAction.toAsset.pluginId]) + const { isEstimate, orderId, @@ -99,12 +112,7 @@ export const SwapDetailsCard: React.FC<Props> = props => { payoutTokenId, plugin, refundAddress - // Infer the payout token from the PAYOUT wallet's config when there is - // one. Passing the source wallet resolves the payout currency code - // against the wrong chain, which either mislabels the payout or leaves - // payoutTokenId unset and hides this card. A swap-to-address payout has - // no wallet, so it keeps the source wallet as the only config available. - } = upgradeSwapData(destinationWallet ?? wallet, swapData) + } = upgradeSwapData(payoutConfig, swapData) const formattedOrderUri = orderUri == null ? undefined @@ -168,13 +176,9 @@ export const SwapDetailsCard: React.FC<Props> = props => { } const destinationDenomination = useSelector(state => - destinationWallet == null || payoutTokenId === undefined + payoutConfig == null || payoutTokenId === undefined ? undefined - : selectDisplayDenom( - state, - destinationWallet.currencyConfig, - payoutTokenId - ) + : selectDisplayDenom(state, payoutConfig, payoutTokenId) ) if (destinationDenomination == null) return null @@ -196,11 +200,9 @@ export const SwapDetailsCard: React.FC<Props> = props => { destinationDenomination.multiplier )(swapData.payoutNativeAmount) const destinationAssetName = - payoutTokenId == null || destinationWallet == null + payoutTokenId == null || payoutConfig == null ? payoutCurrencyCode - : `${payoutCurrencyCode} (${ - getExchangeDenom(destinationWallet.currencyConfig, null).name - })` + : `${payoutCurrencyCode} (${getExchangeDenom(payoutConfig, null).name})` const symbolString = currencyInfo.currencyCode === transaction.currencyCode && From 56a6754221fbc2e4d044357296214b000d67aa98 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 18:37:02 -0700 Subject: [PATCH 32/56] Record the feedback round in the design doc Annotates the flow diagram with the API requests each user action produces and covers the paths it was missing, adds the four decisions this round made, and records what the chain-table audit found. --- CHANGELOG.md | 5 +- src/docs/stealth-send-swap.md | 155 ++++++++++++++++++++++++++++++---- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de913f05907..9b9ade1f7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ - 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 routed exclusively through the Houdini privacy provider, with linked You send/Recipient gets amounts, quote expiry countdown, and a destination tag row on memo-required chains. - added: The "Myself" recipient picker offers every asset a send can route to, not just the source asset, with same-asset wallets grouped at the top. -- added: Swap-sends, stealth sends, and stealth swap-sends are titled separately in the transaction list and details. The two private flows do not display the recipient. +- added: Swap-sends, stealth sends, and stealth swap-sends are titled separately in the transaction list and details. The two private flows do not display the recipient, but still show the exchange order details so a stuck order can be traced. +- added: The send scene tells you when an amount is under the privacy provider's minimum before contacting them, and the Stealth toggle explains itself when an asset or amount cannot route privately. - 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: Entering a recipient address from another chain now sets up the cross-chain send by itself. Pasting, typing, or scanning an address the sending wallet cannot read no longer reports an invalid address; Edge identifies the network it belongs to, or asks which one when the format is shared, and sets "Recipient receives" to match. - 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: Show fiat values on the Stealth Send amount rows. - changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings". +- fixed: Drop four destination chains the privacy provider does not serve, and correct the native-asset lookup that made six others unquotable. - 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. ## 4.49.0 (staging) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 760e4b14256..9becb9f5b7a 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -226,22 +226,33 @@ Memos become `destinationTag` on order creation, which is what memo-required cha ### Route selection -Quotes are filtered by route type, and the asymmetry is deliberate: +Quotes are filtered by route type, and the caller decides which types are acceptable: ```ts + const privateOnly = request.privacy === 'required' const candidateQuotes = quotes .filter( (quote): quote is HoudiniQuote => quote != null && (quote.type === 'private' || - (reverseQuote && quote.type === 'standard')) + (!privateOnly && quote.type === 'standard')) ) ``` -Forward quotes take private (multi-exchange) routes only, which is what makes Stealth private. Houdini's exact-out pricing is offered solely on fixed-rate quotes, which its private routing does not serve, so reverse quotes also accept standard routes. Those still settle through Houdini, so the recipient never sees the sender's address, but they use a single exchange leg. Private routes stay preferred whenever offered. +A request carrying `privacy: 'required'` takes `private` (multi-exchange) routes only, which is what makes Stealth private. Without it, `standard` routes are acceptable too, ranked below private. That distinction is load-bearing rather than cosmetic: Houdini serves no private route under 25 USD but serves standard routes down to 10, so a plain Swap & Send between those two figures is only possible on a standard route, while a Stealth Send at the same amount has nothing to route through. See [Minimum order sizes](#minimum-order-sizes). + +A `standard` route still settles through Houdini, so the recipient never sees the sender's address, but it uses a single exchange leg that can relink the two sides. That is why a privacy request must decline rather than accept one: the caller has no way to inspect which route it got, so a silent downgrade would be undetectable. + +Houdini prices exact-out on fixed-rate quotes alone, which its private routing does not serve, so a privacy request priced by the receive side finds nothing and declines. The send scene answers that by re-pricing from the send side and keeping its privacy, which is the [fixed-to fallback](#availability-fallbacks) it already had. This filter is the reason a live availability change on the provider's side can disable forward swap-to-address sends without any code change here; see [Retrospective item 2](#where-this-document-was-wrong-or-silent). +### Rate limits + +Houdini is an aggregator behind Cloudflare, and tight request loops get blocked in a way that poisons the answers: a 429 arriving where a quote was expected reads exactly like an unavailable pair, and caching that verdict would teach the UI something false. Every call therefore goes through one wrapper that retries a 429 behind the `retryAfter` the API reports, doubling on top of it so a burst does not re-collide the moment the window reopens, and gives up after three attempts with an error that says rate limit rather than unavailable. Since only a plain `Error` comes back, the send scene's `SwapCurrencyError` branch never fires, so no `routeCaps` entry is written and no toggle turns itself off on the strength of a throttled request. + +Their published tiers are 5 quote requests per minute on free and 500 on pro. Nothing in the app probes them; the wrapper is the only rate-limit machinery, per the no-probing rule in [Learn route availability from live failures](#learn-route-availability-from-live-failures-not-probes-or-tables). + ## 7. Detailed design: edge-react-gui ### Where the feature is allowed to appear @@ -377,7 +388,23 @@ A URI amount is what the recipient should **receive**, so for a cross-asset dest Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair. The scene learns it from real quote failures. A `SwapCurrencyError` while the receive side is guaranteed flips the guarantee to the send side, seeds the send amount from display exchange rates so the send stays actionable, and raises a warning card that clears on the next amount edit. One while stealth is on for a **same-asset** pair turns the toggle off (toast, persistent info line) and degrades the swap into the plain direct send the toggle had upgraded. A **cross-asset** pair with no route keeps the plain error card: the request is Houdini-only with or without the toggle, so flipping it changes nothing and there is no other provider to degrade into. Learned capabilities are cached per pair in session state (`routeCaps`), so a later attempt to re-arm the toggle or re-fix the receive amount answers with a pre-emptive toast instead of another doomed quote. The full branch structure is the flowchart in [Section 8](#8-the-send-scene-ux-end-to-end). -Because the request never varies with the toggle, the stealth flag is **not** a dependency of the quote effect. Same-asset toggling re-quotes through `swapSendActive` (the toggle is what makes the send a swap there); cross-asset toggling issues no new request at all. +The toggle is a dependency of the quote effect, so flipping it always invalidates the held quote and re-fetches. It has to be: the request now carries `privacy: 'required'` only when stealth is on, and the floor that applies changes with it, so the two states genuinely ask the provider different questions. An earlier revision left `stealth` out on the grounds that the request did not vary with it, which was true then and is not now; a cross-asset pair would have shown standard-route pricing under a Stealth label. + +### Minimum order sizes + +Houdini enforces its own minimum per route type, in USD. The figures live as named constants (`HOUDINI_MIN_USD` in `src/util/houdiniChains.ts`) with the provider's guidance quoted beside them, rather than as literals at the call sites: + +| Route | Minimum | Used by | +|---|---|---| +| private | 25 USD | every Stealth flow, including same-asset | +| standard | 10 USD | plain Swap & Send | +| dex | 5 USD | assets carrying `hasDex` | + +These were confirmed against the live v2 API before being written down, not taken on the provider's word. Cross-asset TRX to LTC answered `422 Amount is too low, minimum is 10 USD` at 8 USD, returned standard routes only from 12 through 24 USD, and added private routes from 25 up. Same-asset TRX to TRX answered `422 Amount is too low, minimum is 25 USD` below 25 and returned private routes only above it. Both match the stated floors. + +The scene enforces them before any request goes out. Under the private floor the Stealth toggle refuses to arm and explains why; under the applicable floor the quote effect returns an under-minimum error instead of calling the API. Pre-empting matters for more than tidiness: a user thumbing through small amounts would otherwise spend the provider's rate limit on requests whose refusal is already known, and those 429s come back looking like unavailable routes. + +Floors are not the whole story. Individual tokens carry higher server-side minimums that cannot be known upfront (Polygon private is effectively 60 USD against a 25 USD floor). Those arrive as quote errors carrying the real figure, and the error card shows the provider's own message, per [Phase 9](#phase-9-real-failures-in-the-error-ui). Clearing the floor is necessary, never sufficient. ### Destination assets are route-derived @@ -387,6 +414,20 @@ The "Myself" picker follows the same rule. It offers the source asset plus every Private-route availability does not filter this list. A pair whose private route is missing is still a legal destination; the Stealth toggle is what turns itself off, per [Availability fallbacks](#availability-fallbacks), evaluated per selection rather than cached per asset. +Every chain in the table resolves to a native token the provider actually serves, which is a stronger claim than it sounds. `celo`, `fantom`, `polkadot` and `ton` were listed for a while and are not served: the API returns no mainnet native for them, so the UI offered four destinations whose every quote threw. They are gone. Verifying that also turned up the empty-string defect described in [Retrospective item 6](#where-this-document-was-wrong-or-silent). + +### Same-asset private capability + +Sending an asset to itself privately is a per-asset capability, not a per-pair one, and Houdini publishes it directly: `hasSelfPrivate` on the token query. It is mirrored onto each `HoudiniChain` entry, so `getHoudiniChain(pluginId, tokenId)?.hasSelfPrivate` answers without a quote and without a request. Of the 34 chains served, one (`rsk`, RBTC) is false; the rest are true. + +This is Houdini's dominant flow, around 60% of their traffic, which is why it gets a table lookup instead of a learned failure. Cross-asset private capability is the opposite case and stays quote-reactive: it fluctuates per pair, so it is learned from a real attempt and never cached beyond the session, per [Availability fallbacks](#availability-fallbacks). + +### Provider availability versus exchange settings + +The send-scene stealth and swap-send path **ignores** the global exchange-settings enable flag for HoudiniSwap. The Exchange scene keeps honoring it. That setting governs which providers the swap aggregator is allowed to use, so it is the user's answer about swapping, not about sending: a private send is a send feature that happens to be powered by Houdini, and switching off a swap provider should not silently remove it. + +Mechanically the core skips any plugin whose `swapSettings[pluginId].enabled` is false, so the scene opts out of that check for one request through `EdgeSwapRequestOptions.forceEnabled`, set by `makeStealthSwapRequestOptions` only when the caller passes `ignoreProviderSetting`. An explicit `disabled` entry still wins, so the same helper's provider restriction cannot be defeated by it. + ### Transaction identity Three send shapes reach the transaction list, and each carries its own title: @@ -401,6 +442,8 @@ The flow is named on the saved action, not inferred in the GUI: `EdgeTxActionSwa Hiding the recipient is a display rule, not a storage rule. `swapData` keeps `orderId` and `payoutAddress` intact so support can trace a stuck order. What changes is what renders: the broadcast path skips the `payeeName` write into `metadata.name` for a stealth send, and `SwapDetailsCard` takes `hidePayoutAddress` and substitutes a placeholder in its details text. The transaction list's own fallback needs no change, because a swap-send's spend target is the provider's deposit address, never the recipient's. +The exchange order details themselves stay **visible** for a stealth transaction: order id, provider, and both sides' assets and amounts. Only the payout address is hidden. The support-traceability argument for keeping the data cuts no ice if the person reading the screen cannot see the order id, so the two rules are separate. Getting there required a fix: `SwapDetailsCard` resolved the payout denomination through the destination wallet and returned `null` without one, and a swap-send's `payoutWalletId` names a synthetic wallet that is not in `currencyWallets`. Every swap-send therefore rendered no card at all. The payout asset's currency config now comes off the saved action's `toAsset.pluginId`, which exists for exactly the case that has no wallet. + ### Multi-recipient gating Gated in both directions. Stealth on or a mismatched recipient hides "Add Another Address"; with multiple recipients present the stealth toggle is disabled, the card expands with an explanation, and the recipient-asset selector locks. Multi-recipient sends also gained a Total Amount row, which the task had left open. @@ -423,11 +466,13 @@ export const PriceImpactText: React.FC<Props> = props => { Every branch a send-to-address user can hit, from address entry to an armed slider. Three rules organize it: every quote is Houdini-only (no other provider is ever consulted), the UI reflects what the provider actually offers (a capability the pair lacks turns its control off, with a toast saying why), and a degraded state is always recoverable where a degradation exists (the fixed-to fallback re-quotes the send side, the same-asset stealth fallback is the plain send, and pre-emptive refusals explain themselves on tap). +Nodes tagged `[API]` are the only ones that reach the network. Everything else is decided from local state or the chain table, which is the point: the provider rate-limits tight traffic, so a branch that can be settled without asking is settled without asking. + ```mermaid flowchart TD A[Address entered by\npaste, type, or scan] --> B{Source wallet\nparses it?} B -- yes --> C[Same-chain send,\nunchanged behavior] - B -- no --> D{Matches a served\ndestination chain?} + B -- no --> D{Matches a served\ndestination chain?\nHOUDINI_CHAINS, no request} D -- none --> E[Invalid address toast] D -- exactly one --> F[Chain adopted as\nRecipient receives] D -- several --> G[Network picker modal] @@ -436,26 +481,50 @@ flowchart TD F --> I{URI carries\nan amount?} I -- no --> J[User enters amount,\nsend side guaranteed] I -- yes --> K[Receive side guaranteed,\nfixed to] - K --> L{Houdini offers a\nreceive-priced route?} - L -- yes --> M[Houdini quote arms,\nreceive amount locked] + K --> L{Houdini offers a\nreceive-priced route?\n[API] GET /tokens, GET /quotes} + L -- yes --> M[Houdini quote arms,\nreceive amount locked\n[API] POST /exchanges] L -- no --> N[Falls back to fixed from:\ntoast, warning card, send\namount seeded from rates] N --> O[Card clears when the\nuser edits an amount] O --> J - J --> P{Cross-asset\ndestination?} - P -- yes --> U[Houdini-only forward quote:\nstealth toggle does not\nchange the request] - U -- route exists --> W[Quote arms:\nSlide to Confirm] - U -- no route --> Z[Error card:\npair not supported] + J --> T{Clears the floor\nfor this route?\n25 USD private, 10 standard\nlocal, no request} + T -- no --> T2[Under-minimum error card,\nno request sent] + T2 -. user raises amount .-> J + T -- yes --> P{Cross-asset\ndestination?} + P -- yes --> P3{Stealth on?} + P3 -- yes --> U[Houdini-only forward quote,\nprivacy required\n[API] GET /quotes] + P3 -- no --> U2[Houdini-only forward quote,\nstandard routes allowed\n[API] GET /quotes] + U -- route exists --> W[Quote arms:\nSlide to Confirm\n[API] POST /exchanges] + U2 -- route exists --> W + U -- no route --> Z[Error card:\nprovider's own message] + U2 -- no route --> Z + U -- rate limited --> RL[Backoff behind retryAfter,\nthen rate-limit error.\nNo routeCaps entry written] P -- no --> P2{Stealth on?} P2 -- no --> V[Plain single-asset send] - P2 -- yes --> Q{Private route\nfor this pair?} - Q -- yes --> R[Stealth quote arms:\nSlide to send stealthily] + P2 -- yes --> Q0{hasSelfPrivate\nfor this asset?\ntable lookup, no request} + Q0 -- no --> S2[Toggle refuses to arm,\ntoast names the asset] + Q0 -- yes --> Q{Private route\nfor this pair?\n[API] GET /quotes} + Q -- yes --> R[Stealth quote arms:\nSlide to send stealthily\n[API] POST /exchanges] Q -- no --> S[Stealth turns itself off:\ntoast, info line under the\ntoggle, pair remembered] S --> V S -. later toggle taps .-> X[Refuses to arm,\npre-emptive toast] N -. later Recipient gets taps .-> Y[Editor refuses to open,\npre-emptive toast] + J -. toggle flipped either way .-> RQ[Held quote invalidated,\nre-quote with the new\nprivacy and floor] + RQ --> T ``` -Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), because availability is a live provider property: the same pair can regain its private route an hour later, so nothing is persisted. Amount errors (below or above route limits) never enter this flow; they keep the plain error card, because the route exists and the amount is the problem. +Which requests each action produces: + +| User action | Requests | +|---|---| +| Address entered or chain picked | none (chain table) | +| Amount committed, under the floor | none (pre-empted) | +| Amount committed, clears the floor | `GET /tokens` per asset (memoized per chain), then `GET /quotes` | +| Stealth toggled either way | the same pair, re-requested with the new privacy and floor | +| Slider confirmed | `POST /exchanges`, then the normal send broadcast | +| Any of the above, rate limited | the same call retried behind `retryAfter`, up to three times | +| Order status after broadcast | none in-app; the details scene links out to Houdini's order page | + +Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), because availability is a live provider property: the same pair can regain its private route an hour later, so nothing is persisted. Route limits above the floor (a token's own server-side minimum, or a maximum) never enter this flow; they keep the plain error card carrying the provider's figure, because the route exists and the amount is the problem. ## 9. Testing @@ -518,16 +587,29 @@ Queued: nothing from the operator. A re-fire's finalize-gate re-confirmation fou Queued: an operator followup. Swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert, plus the in-app verification the previous segment could not perform. Shipped: the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". The review rounds that followed produced six more fixes. Three of them are behavioral: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), the source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. Three corrections: `upgradeSwapData` receives the payout wallet rather than the source wallet, so a payout currency code is resolved against its own chain; `trackSwapError` reports the destination wallet type instead of repeating the source; and the Hedera `addressValidation` pattern escapes its dots, which previously made them wildcards that accepted `0X0Y12345`. Diverged: one finding was rejected rather than fixed, that the limit ignores the network fee, because `origin/develop` computes it the same way for every plain send, so changing only this path would make the two disagree. +### Phase 10: the provider feedback round + +Queued: a feedback round from the Houdini team plus two internal reviewers, arriving as one operator followup with a standing rule (every supported-destination decision must be route-derived) and two hard constraints (no availability probing or per-pair caching of any kind, and graceful rate-limit handling). Shipped, in the order the asks came: the Myself picker's same-asset capability now reads Houdini's own `hasSelfPrivate` flag instead of being assumed, and the Stealth toggle refuses to arm for an asset that lacks it; the toggle re-quotes on every flip, which it did not before, because the request now genuinely differs by privacy and floor; the three minimums were confirmed against the live API and became named constants enforced before any request leaves the app; the plugin honors `privacy: 'required'`, so a plain Swap & Send can use the standard routes that are the only ones on offer between 10 and 25 USD, while a Stealth send declines rather than silently taking one; every call backs off behind the API's own `retryAfter` and reports a rate limit as a rate limit; the exchange order details render on a stealth transaction's detail scene, which they never did; the send scene ignores the global exchange-provider setting while the Exchange scene keeps honoring it; and the Houdini amount rows gained fiat lines matching the plain send tile. Diverged: nothing was dropped, but the standing-rule audit found more than the asks did. Four chains were being offered as destinations that Houdini serves no native for at all, and six more could never resolve a token id because the API spells "no contract address" as an empty string on those chains while the plugin tested only for null. Both are fixed here; see [Retrospective item 6](#where-this-document-was-wrong-or-silent). + +### Upstream, on Houdini's side + +Not our work, tracked so it is not rediscovered: + +- [ ] **Exact-out fixed-rate min-max bug.** Houdini acknowledged it and has developers on it. Re-verify exact-out min-max behavior after they ship. Do **not** build a workaround in this scope: the [fixed-to fallback](#availability-fallbacks) already degrades gracefully, and a workaround would have to be unpicked. +- [ ] **Private routes on fixed-rate quotes.** Until these exist, a privacy request priced by the receive side cannot be served, which is why the fallback re-prices from the send side. Worth re-checking whenever their routing changes. +- [ ] **Token destinations.** Blocked on token payout metadata through the swap plugin surface, not on us; `getHoudiniChain` returning undefined for a non-null `tokenId` is the single line that gates every surface. + ### Deferred work | Item | Disposition | Reason | |---|---|---| | Token destinations | Deferred | Provider metadata for token payouts is not exposed through the swap plugin; native destinations cover the reported use cases. | | Max spend in swap-send mode | Deferred | Needs the plugins' `getMaxSwappable`; plain-mode max is unaffected. | -| Dynamic chain metadata from the API | Deferred | Requires chain metadata through the swap plugin surface; the snapshot is dated in the module. | -| `SwapDetailsCard` on a stealth send's tx detail | Deferred | The card requires a payout wallet; rendering from payout asset info alone is a separate change. | +| Dynamic chain metadata from the API | Deferred | Requires chain metadata through the swap plugin surface; the snapshot is dated in the module. Constrained further by the no-probing rule: a refresh would have to be a single cold fetch, never a loop. | +| `SwapDetailsCard` on a stealth send's tx detail | Reversed, now shipped | The card did not need a payout wallet, only the payout asset's currency config, which the saved action already carries. See [Transaction identity](#transaction-identity). | | PIN spending limits on stealth sends | Reversed, now shipped | The original reasoning compared this to a swap. It is a send. See [Gate swap-send behind the PIN spending limit](#gate-swap-send-behind-the-pin-spending-limit). | | EIP-681 `value=` (wei) amounts | Deferred | Address is accepted, amount ignored; no reported user impact yet. | +| Whether the PIN spending limit should count fees | Deferred | A product question spanning both send paths, not a defect in this one; `origin/develop` computes it the same way for every plain send. | ## 11. Decisions @@ -653,6 +735,46 @@ Rejected: **a bespoke error map for the send scene**, which would drift from the Reopen if: the provider starts returning structured limit data on the error responses, which would let the plugin raise a typed `SwapBelowLimitError` for the 422 band instead of a plain message. +### Ask the request for privacy, do not infer it from the route + +Chosen: `EdgeSwapRequest` carries an optional `privacy: 'required'`, and the Houdini plugin filters to private routes when it is set. A plain Swap & Send omits it and may take a standard route. + +Evidence: Houdini returns both route types above 25 USD and standard only between 10 and 25. With a single filter there is no correct setting: private-only breaks every plain swap-send in the 10-to-25 band, and accepting standard hands a Stealth send a single-leg route it cannot detect. A quote carries no route type back to the caller, so the downgrade would be invisible. Making the caller state its requirement puts the decision where the intent lives, and the plugin's obligation becomes explicit: decline rather than substitute. + +Rejected: **inferring privacy from whether the destination is same-asset**, which is wrong in both directions (a cross-asset stealth send needs privacy; a same-asset plain send does not exist). **A Houdini-specific `userSettings` flag**, which is account-wide where the requirement is per-request. **Reading the route type off the returned quote and re-requesting**, which spends two calls against a rate limit to answer what one flag settles. + +Reopen if: a second privacy provider appears, which would probably promote `'required'` into a small enum (`'required' | 'preferred'`) so an aggregator could rank rather than filter. + +### Validate the provider's stated minimums before hardcoding them + +Chosen: the three floors were probed against the live v2 API, then written down as named constants with the provider's own guidance quoted beside them, and enforced client-side before any request. + +Evidence: the numbers came from a feedback email, and an email is not a contract. The probe confirmed all three, which is the outcome that makes the constants trustworthy rather than the outcome that makes them interesting. Enforcing before the request also protects the rate limit, which the same feedback round identified as the thing that poisoned earlier availability readings. + +Rejected: **trusting the stated figures unverified**, which would have shipped an untested assumption into a gate that blocks sends. **Discovering the floor from quote errors alone**, which spends a request to learn a constant and gets throttled for it. **Scattering the numbers at the call sites**, which is how the next reader ends up with two different answers for the private floor. + +Reopen if: the floors move, or the provider exposes them per pair on the token or chain metadata, at which point they should be read rather than declared. + +### Show the order details on a stealth transaction, hide only the address + +Chosen: `SwapDetailsCard` renders for every swap-send, resolving the payout asset from the saved action when there is no payout wallet. `hidePayoutAddress` continues to mask the address alone. + +Evidence: the reason for keeping `payoutAddress` in storage is that support must be able to trace a stuck order. That argument requires the order id, provider, and amounts to be readable, so hiding them defeats the thing the storage rule was protecting. The card was in fact rendering nothing at all for every swap-send, private or not, because it bailed when the destination wallet lookup failed, and a synthetic payout wallet id never resolves. + +Rejected: **hiding the whole card for stealth transactions**, which is the outcome the bug produced by accident and which no one wanted. **Keeping the source wallet as the fallback config**, which resolves the payout currency code against the wrong chain and was the reason `payoutTokenId` stayed unset. + +Reopen if: order ids themselves become privacy-sensitive, which would argue for masking them in screenshots rather than removing them. + +### Scope the exchange-provider setting to the Exchange scene + +Chosen: the send-scene stealth and swap-send path ignores the global HoudiniSwap enable flag; the Exchange scene keeps honoring it. + +Evidence: the setting lives in Exchange settings and reads as a list of providers the swap aggregator may use. A private send is a send that happens to be powered by Houdini, so a user turning off a swap provider is not asking for private sends to disappear, and would have no way to connect the two if they did. + +Rejected: **honoring the flag on both paths**, which makes a send feature vanish with no explanation reachable from the send scene. **A second, send-specific toggle**, which is a settings row asking users to understand our provider topology. + +Reopen if: Houdini becomes one of several privacy providers, at which point the send path needs its own notion of which to use and the question changes shape. + ## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) @@ -679,6 +801,7 @@ Reopen if: the provider starts returning structured limit data on the error resp 3. **The provider's published metadata was assumed correct.** The chain table was written as a faithful snapshot. Two of its 38 regexes are defective, one of them so permissive it matches every string. Snapshotting external validation data needs an audit pass, not just a transcription. 4. **PIVX payouts are unusable and the design cannot tell.** A PIVX order returns a deposit address that is not a PIVX address (`EXMD…` rather than base58 `D…`), so the send fails at spend time with an opaque wallet error. Reproduced directly against the API with the plugin's own payload shape. The design has no validation of provider-returned deposit addresses against the from-chain. 5. **Undocumented intent regressed in code.** The phase 2 followup made send-to-any Houdini-exclusive, but the change was autosquashed into the feature commit and neither the PR body nor this document was updated. Phase 5 then read the unconditional restriction as drift against the documented fan-out and "fixed" it, shipping a live regression that phase 6 had to revert on operator correction. The lesson: an operator-directed behavior change must update the PR body and this document in the same turn it lands, because both are treated as behavior contracts by later work, and a squashed history cannot testify to intent. +6. **The chain table was never checked against the routes it claims.** Item 3 caught bad regexes by reading them. Nobody asked the prior question: does the provider serve each of these chains at all? Phase 10 asked it and got two answers, both bad. Four chains (`celo`, `fantom`, `polkadot`, `ton`) have no mainnet native in the API, so every quote to them threw while the UI offered them. Six more (`algorand`, `ecash`, `hyperevm`, `sonic`, `stellar`, `zcash`) had natives the plugin could never find, because the API returns `address: ""` for those chains rather than `null` and the native lookup tested `address == null`, which is false for an empty string. Ten of 38 advertised destinations were dead, and nothing in the type system, the tests, or a code review could have seen it: the defect lives in the agreement between a snapshot and a live API. A table transcribed from an external source needs a periodic reconciliation against that source, and every field the code branches on needs one live case exercising each branch. ### What held From 3af52f75e323ff649b62ed9fe95f0f447f998adc Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 19:03:11 -0700 Subject: [PATCH 33/56] Read live rates in the fixed-to fallback and retry once The fallback seeds a send amount from exchange rates inside the quote effect, which cannot depend on those rates: they tick constantly and the provider rate-limits tight traffic, so depending on them would fire a quote request every few seconds. The captured copy could therefore predate a rate that had since loaded, and a failure that happened before rates arrived sat on a hard error until the user edited a field. The fallback now reads the rates through a ref, and a single retry fires when the missing rate becomes usable. --- src/components/scenes/SendScene2.tsx | 74 +++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 3a4ea979ab4..6dfa13df90a 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -338,6 +338,9 @@ const SendComponent: React.FC<Props> = props => { // receive-priced route for the pair. Shows the warning card until the user // edits an amount or changes the destination: const [fixedToFallback, setFixedToFallback] = useState<boolean>(false) + // The fixed-to fallback wanted to seed a send amount but had no exchange rate + // to seed it from. Cleared by the one retry that fires when rates arrive. + const [rateStarvedFallback, setRateStarvedFallback] = useState<boolean>(false) const isApprovingSwapRef = React.useRef<boolean>(false) // Quote requests are not cancellable, so each one carries a generation and // only the newest is allowed to write state. Without this a slow response @@ -360,6 +363,19 @@ const SendComponent: React.FC<Props> = props => { ) const hasNotifications = useSelector(state => state.ui.notificationHeight > 0) + /** + * Live exchange rates, for the quote effect's fixed-to fallback to read. + * The fallback needs current rates, but the effect must NOT re-run on a rate + * tick: rates update constantly and the provider rate-limits tight traffic, + * so depending on them would fire a quote request every few seconds. A ref + * gives the fallback today's rates without making them a trigger. + */ + const ratesRef = React.useRef({ + rates: exchangeRates, + isoFiat: defaultIsoFiat + }) + ratesRef.current = { rates: exchangeRates, isoFiat: defaultIsoFiat } + const currencyWallets = useWatch(account, 'currencyWallets') const coreWallet = currencyWallets[walletId] const { pluginId, memoOptions = [] } = coreWallet.currencyInfo @@ -2979,6 +2995,7 @@ const SendComponent: React.FC<Props> = props => { } setSwapQuote(quote) setError(undefined) + setRateStarvedFallback(false) // Update the estimated side from the live quote: if (guaranteedSide === 'send') { setReceiveNativeAmount(quote.toNativeAmount) @@ -3000,18 +3017,13 @@ const SendComponent: React.FC<Props> = props => { // seeded from display rates so the send stays actionable, and // warn that the recipient amount is no longer exact. markRouteCap('fixedTo') - const destRate = getExchangeRate( - exchangeRates, - destPluginId, - null, - defaultIsoFiat - ) - const srcRate = getExchangeRate( - exchangeRates, - pluginId, - tokenId, - defaultIsoFiat - ) + // Read the rates through the ref, not the closure. This effect + // deliberately does not depend on `exchangeRates` (see its + // dependency list), so the captured copy can predate a rate that + // has since loaded. + const { rates, isoFiat } = ratesRef.current + const destRate = getExchangeRate(rates, destPluginId, null, isoFiat) + const srcRate = getExchangeRate(rates, pluginId, tokenId, isoFiat) if ( receiveNativeAmount != null && destExchangeDenom != null && @@ -3036,6 +3048,7 @@ const SendComponent: React.FC<Props> = props => { setSpendInfo({ ...spendInfo }) setGuaranteedSide('send') setFixedToFallback(true) + setRateStarvedFallback(false) setError(undefined) showToast(lstrings.stealth_fixed_to_unavailable_toast) } else { @@ -3043,8 +3056,11 @@ const SendComponent: React.FC<Props> = props => { // and switching to it empty strands the scene: the quote effect // returns early on a zero send amount, so the user would be left // holding a warning with no quote and no way to get one. Show - // the provider's own error instead. + // the provider's own error instead, and remember that a rate is + // all that was missing, so the retry below can take over once + // one arrives. setError(describeSwapError(err)) + setRateStarvedFallback(true) } } else if (stealth && !crossAsset) { // No private route on a same-asset pair: turning the toggle off @@ -3088,6 +3104,38 @@ const SendComponent: React.FC<Props> = props => { 'SendComponent:swapQuote' ) + // Retry ONCE when the rate the fixed-to fallback was missing finally loads. + // The fallback runs inside the quote effect, which cannot depend on + // `exchangeRates` without re-quoting on every rate tick, so a failure that + // happened before rates loaded would otherwise sit on a hard error until the + // user edited a field. Keyed on the rates becoming usable rather than on the + // rates object changing, so a later tick cannot trigger a second request. + React.useEffect(() => { + if (!rateStarvedFallback) return + const destRate = getExchangeRate( + exchangeRates, + destPluginId, + null, + defaultIsoFiat + ) + const srcRate = getExchangeRate( + exchangeRates, + pluginId, + tokenId, + defaultIsoFiat + ) + if (destRate <= 0 || srcRate <= 0) return + setRateStarvedFallback(false) + setSwapQuoteNonce(nonce => nonce + 1) + }, [ + defaultIsoFiat, + destPluginId, + exchangeRates, + pluginId, + rateStarvedFallback, + tokenId + ]) + const showSlider = spendInfo.spendTargets[0].publicAddress != null let disableSlider = false let disabledText: string | undefined From cbca746f039f0bb8bfdb2e62dcf8f5a78f5b5ef0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 19:17:14 -0700 Subject: [PATCH 34/56] Close three quote-state and privacy gaps A stealth swap on the Exchange scene restricted the provider but never demanded a private route, so it could be served that provider's transparent standard route while the UI labelled the flow private. The send scene already asked; the swap scene now asks too, on the initial quote and on the expiry re-quote. The quote effect retires an in-flight request on every run rather than only on the paths that fetch, so a request issued before the amount fell under the provider's floor can no longer land afterwards and re-arm the slider under it. Leaving swap-send mode now also retracts the swap's own error, tracked separately so it cannot clear an error the plain-send effect owns. --- src/components/scenes/SendScene2.tsx | 49 +++++++++++++++---- .../scenes/SwapConfirmationScene.tsx | 6 ++- src/components/scenes/SwapCreateScene.tsx | 8 ++- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 6dfa13df90a..5d7bc4d73a0 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -363,6 +363,23 @@ const SendComponent: React.FC<Props> = props => { ) const hasNotifications = useSelector(state => state.ui.notificationHeight > 0) + /** + * Whether the error currently on screen came from the swap-send path. The + * `error` state is shared with the plain-send `makeSpend` effect, which owns + * its own failures, so leaving swap-send mode may only retract a swap error + * and must never clear an insufficient-funds message that effect put there. + */ + const swapErrorShown = React.useRef<boolean>(false) + const setSwapError = (value: unknown): void => { + swapErrorShown.current = value != null + setError(value) + } + const clearSwapError = (): void => { + if (!swapErrorShown.current) return + swapErrorShown.current = false + setError(undefined) + } + /** * Live exchange rates, for the quote effect's fixed-to fallback to read. * The fallback needs current rates, but the effect must NOT re-run on a rate @@ -2434,7 +2451,7 @@ const SendComponent: React.FC<Props> = props => { walletId: coreWallet.id }) } catch (err: unknown) { - setError(describeSwapError(err)) + setSwapError(describeSwapError(err)) resetSlider() } finally { isApprovingSwapRef.current = false @@ -2900,9 +2917,20 @@ const SendComponent: React.FC<Props> = props => { // identical either way, so the toggle alone never re-quotes. useAsyncEffect( async () => { + // EVERY run of this effect retires any request still in flight from a + // previous run, the early returns below included. Bumping only on the + // paths that fetch let a request issued before the amount fell under the + // floor land afterwards and re-arm the slider under it. + const generation = ++swapQuoteGeneration.current + if (!swapSendActive) { setSwapQuote(undefined) setFetchingSwapQuote(false) + // Leaving swap-send mode retracts the swap's own error. Without this a + // minimum-amount or unroutable-pair message from a cross-asset or + // stealth attempt stayed on screen over the plain same-asset send the + // user just switched to. + clearSwapError() return } const toAddress = spendInfo.spendTargets[0].publicAddress @@ -2916,6 +2944,7 @@ const SendComponent: React.FC<Props> = props => { zeroString(quoteNativeAmount) ) { setSwapQuote(undefined) + setFetchingSwapQuote(false) return } @@ -2925,7 +2954,8 @@ const SendComponent: React.FC<Props> = props => { // 429s would come back looking like unavailable routes. if (belowActiveFloor) { setSwapQuote(undefined) - setError( + setFetchingSwapQuote(false) + setSwapError( new I18nError( lstrings.exchange_generic_error_title, sprintf( @@ -2939,7 +2969,6 @@ const SendComponent: React.FC<Props> = props => { return } - const generation = ++swapQuoteGeneration.current setFetchingSwapQuote(true) try { const toMemos: EdgeMemo[] = @@ -2985,7 +3014,7 @@ const SendComponent: React.FC<Props> = props => { // resolves with an empty list if every plugin simply declines. // Reading toNativeAmount off that would crash the scene. setSwapQuote(undefined) - setError( + setSwapError( new I18nError( lstrings.trade_option_no_quotes_title, lstrings.trade_option_no_quotes_body @@ -2994,7 +3023,7 @@ const SendComponent: React.FC<Props> = props => { return } setSwapQuote(quote) - setError(undefined) + clearSwapError() setRateStarvedFallback(false) // Update the estimated side from the live quote: if (guaranteedSide === 'send') { @@ -3049,7 +3078,7 @@ const SendComponent: React.FC<Props> = props => { setGuaranteedSide('send') setFixedToFallback(true) setRateStarvedFallback(false) - setError(undefined) + clearSwapError() showToast(lstrings.stealth_fixed_to_unavailable_toast) } else { // The send side cannot be seeded without a rate on both ends, @@ -3059,7 +3088,7 @@ const SendComponent: React.FC<Props> = props => { // the provider's own error instead, and remember that a rate is // all that was missing, so the retry below can take over once // one arrives. - setError(describeSwapError(err)) + setSwapError(describeSwapError(err)) setRateStarvedFallback(true) } } else if (stealth && !crossAsset) { @@ -3070,13 +3099,13 @@ const SendComponent: React.FC<Props> = props => { // card instead. markRouteCap('stealth') setStealth(false) - setError(undefined) + clearSwapError() showToast(lstrings.stealth_route_unavailable_toast) } else { - setError(describeSwapError(err)) + setSwapError(describeSwapError(err)) } } else { - setError(describeSwapError(err)) + setSwapError(describeSwapError(err)) } } finally { if (generation === swapQuoteGeneration.current) { diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index b7fce4e67ed..30e399568f1 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -196,7 +196,11 @@ export const SwapConfirmationScene: React.FC<Props> = (props: Props) => { } navigation.replace('swapProcessing', { - swapRequest: selectedQuote.request, + // The re-quote carries the same privacy demand the original did, so an + // expired stealth quote cannot be replaced by a transparent route. + swapRequest: stealth + ? { ...selectedQuote.request, privacy: 'required' } + : selectedQuote.request, swapRequestOptions: stealth ? makeStealthSwapRequestOptions(account, swapRequestOptions) : swapRequestOptions, diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index 62cefe9a975..1d8a70c6c74 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -304,9 +304,13 @@ export const SwapCreateScene: React.FC<Props> = props => { }) // Start request for quote. A stealth swap restricts the request to the - // Houdini privacy provider: + // Houdini privacy provider AND demands a private route: restricting the + // provider alone would still accept that provider's transparent standard + // routes, which are priced better and would be labelled private here. navigation.navigate('swapProcessing', { - swapRequest, + swapRequest: stealth + ? { ...swapRequest, privacy: 'required' } + : swapRequest, swapRequestOptions: stealth ? makeStealthSwapRequestOptions(account, swapRequestOptions) : swapRequestOptions, From 345c0b6e818359bffc067ac621fe196118f12396 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 19:27:57 -0700 Subject: [PATCH 35/56] Retire a held quote on a wallet switch and label token sends A quote's order is created against the source wallet's refund address, so switching to another wallet on the SAME asset left an order belonging to the wallet the user just left, armed and approvable. The picker now clears swap state on any wallet change, and the quote effect depends on the wallet id. A token send pays out the destination chain's native asset, so it crosses assets even on its own chain. It was treated as same-asset, which titled it "Stealth Send" instead of "Stealth Swap & Send". Labelling now follows the payout asset, while the auto-disable degrade follows whether a recipient asset was adopted, which is what decides if turning the toggle off leaves a plain send behind. --- src/components/scenes/SendScene2.tsx | 62 +++++++++++++++++++++------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 5d7bc4d73a0..e91d01994be 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -460,9 +460,26 @@ const SendComponent: React.FC<Props> = props => { // `destChain` carries Houdini's metadata (address regex, memoNeeded) for // the destination chain when it is served. const destPluginId = recipientPluginId ?? pluginId + // The payout is always the destination chain's NATIVE asset (the quote asks + // for `toTokenId: null`), so a token source is never the same asset as its + // destination even on its own chain. const sameAsset = destPluginId === pluginId && tokenId == null - const crossAsset = recipientPluginId != null && !sameAsset - const swapSendActive = swapSendAllowed && (stealth || crossAsset) + /** + * A recipient asset was explicitly adopted. This is what turns a plain send + * into a swap-send on its own, and it is also the test for whether turning + * Stealth off would help: without an adopted recipient, the toggle is the + * only thing making this a swap, so switching it off degrades to a plain + * same-chain send. + */ + const crossAssetPicked = recipientPluginId != null && !sameAsset + /** + * Whether the swap crosses assets, for labelling the flow. Distinct from + * `crossAssetPicked`: a token send to its own chain pays out that chain's + * native asset, so it is a cross-asset swap even though no recipient asset + * was picked. Treating it as same-asset titled it "Stealth Send". + */ + const crossAsset = !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAssetPicked) const destChain = swapSendActive ? getHoudiniChain(destPluginId, null) : undefined @@ -1269,24 +1286,33 @@ const SendComponent: React.FC<Props> = props => { if (result?.type !== 'wallet') { return } + const walletChanged = result.walletId !== walletId setWalletId(result.walletId) const { pluginId: newPluginId } = currencyWallets[result.walletId].currencyInfo - if (pluginId !== newPluginId || tokenId !== result.tokenId) { + const assetChanged = + pluginId !== newPluginId || tokenId !== result.tokenId + if (assetChanged) { setTokenId(result.tokenId) setSpendInfo({ tokenId: result.tokenId, spendTargets: [{}] }) - // A new source asset invalidates the swap-send destination state, - // including the fixed-to warning, which describes a route for the - // pair the user just left. The Stealth toggle and the learned route - // capabilities go with it: both were learned from the previous - // pair, so carrying them over produces auto-disables and errors the - // user cannot connect to anything they did. + } + // A new source WALLET invalidates the swap-send destination state, not + // just a new source asset: a held quote carries an order created for + // the old wallet's refund address, so approving it after a switch would + // spend from one wallet against another wallet's order. Switching + // between two wallets on the same asset is the case that used to slip + // through. The fixed-to warning, the Stealth toggle and the learned + // route capabilities go too: all describe the pair and wallet the user + // just left, so carrying them over produces auto-disables and errors + // the user cannot connect to anything they did. + if (walletChanged || assetChanged) { setRecipientPluginId(undefined) setDestinationTag(undefined) setSwapQuote(undefined) setReceiveNativeAmount(undefined) setGuaranteedSide('send') setFixedToFallback(false) + setRateStarvedFallback(false) setStealth(false) setRouteCaps({}) } @@ -3091,12 +3117,14 @@ const SendComponent: React.FC<Props> = props => { setSwapError(describeSwapError(err)) setRateStarvedFallback(true) } - } else if (stealth && !crossAsset) { - // No private route on a same-asset pair: turning the toggle off - // degrades the swap into a plain direct send, so do that and say - // why. A cross-asset send is Houdini-routed with or without the - // toggle, so disabling it cannot help; fall through to the error - // card instead. + } else if (stealth && !crossAssetPicked) { + // No private route, and the toggle is the only thing making this a + // swap: turning it off degrades to a plain same-chain send, so do + // that and say why. That covers a token send to its own chain too, + // which pays out native and so is cross-ASSET but still degrades. + // Once a recipient asset has been adopted the send is + // Houdini-routed either way, so disabling the toggle cannot help; + // fall through to the error card instead. markRouteCap('stealth') setStealth(false) clearSwapError() @@ -3121,6 +3149,10 @@ const SendComponent: React.FC<Props> = props => { // re-quote gap the feedback round called out. stealth, belowActiveFloor, + // The order is created against THIS wallet's refund address, so a switch + // to another wallet on the same asset must re-quote rather than keep the + // previous wallet's order armed. + coreWallet.id, spendInfo.spendTargets[0].publicAddress, guaranteedSide, guaranteedSide === 'send' From 845a609f8c60e2d99bc6872a852422dc33e44952 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 20:00:00 -0700 Subject: [PATCH 36/56] Show the swap row fiat value inline, not on its own line The rows carried their fiat on a second line. Everywhere else in the app a crypto amount shows its fiat value inline in parentheses, formatted by the shared FiatText component, so these rows now do the same and the extra style goes away. --- src/components/scenes/SendScene2.tsx | 91 +++++++++++----------------- src/docs/stealth-send-swap.md | 2 +- 2 files changed, 38 insertions(+), 55 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index e91d01994be..5372d1018e8 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -1,21 +1,11 @@ -import { - abs, - add, - div, - gte, - lt, - lte, - mul, - round, - sub, - toFixed -} from 'biggystring' +import { abs, add, div, gte, lt, lte, mul, sub, toFixed } from 'biggystring' import { asMaybe } from 'cleaners' import { asMaybeInsufficientFundsError, asMaybeNoAmountSpecifiedError, asMaybeSwapCurrencyError, type EdgeAccount, + type EdgeCurrencyConfig, type EdgeCurrencyWallet, type EdgeDenomination, type EdgeMemo, @@ -99,7 +89,6 @@ import { convertTransactionFeeToDisplayFee, darkenHexColor, DECIMAL_PRECISION, - getDenomFromIsoCode, zeroString } from '../../util/utils' import { openBrowserUri } from '../../util/WebUtils' @@ -127,6 +116,7 @@ import { EdgeRow } from '../rows/EdgeRow' import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' +import { FiatText } from '../text/FiatText' import { UnscaledTextInput } from '../text/UnscaledTextInput' import { EdgeText } from '../themed/EdgeText' import type { @@ -207,6 +197,13 @@ interface FioSenderInfo { skipRecord?: boolean } +/** One side of a swap row's inline fiat value, rendered by `FiatText`. */ +interface SwapRowFiat { + nativeAmount: string + tokenId: EdgeTokenId + currencyConfig: EdgeCurrencyConfig +} + const ALLOW_MULTIPLE_TARGETS = true /** Placeholder pending the final marketing URL. */ @@ -1579,12 +1576,23 @@ const SendComponent: React.FC<Props> = props => { displayCode: string, isGuaranteed: boolean, onPress: () => void, - fiatAmount: string | undefined + fiat: SwapRowFiat | undefined ): React.ReactElement => ( <EdgeRow rightButtonType="editable" title={title} onPress={onPress}> <View style={styles.swapAmountRow}> <EdgeText style={styles.swapAmountText}> {`${isGuaranteed ? '' : '~ '}${displayAmount} ${displayCode}`} + {fiat == null ? null : ( + <> + {' ('} + <FiatText + nativeCryptoAmount={fiat.nativeAmount} + tokenId={fiat.tokenId} + currencyConfig={fiat.currencyConfig} + /> + ) + </> + )} </EdgeText> <EdgeText style={isGuaranteed ? styles.guaranteedHint : styles.estimatedHint} @@ -1594,40 +1602,31 @@ const SendComponent: React.FC<Props> = props => { : lstrings.stealth_estimated} </EdgeText> </View> - {fiatAmount == null ? null : ( - <EdgeText style={styles.swapFiatText}>{fiatAmount}</EdgeText> - )} </EdgeRow> ) /** - * The fiat value of one side of the swap, in the same shape the plain send - * tile uses (`EditableAmountTile`). `undefined` when no rate is available, so - * the row simply drops the line rather than showing a zero. + * What one side of the swap needs for its inline fiat value, or `undefined` + * when there is no amount to convert. `FiatText` owns the formatting, so the + * parenthesised `1.23 LTC ($45.67)` shape matches the rest of the app. */ - const swapRowFiatAmount = ( + const swapRowFiat = ( nativeAmount: string | undefined, - rowPluginId: string, - rowTokenId: EdgeTokenId, - exchangeMultiplier: string | undefined - ): string | undefined => { + rowCurrencyConfig: EdgeCurrencyConfig | undefined, + rowTokenId: EdgeTokenId + ): SwapRowFiat | undefined => { if ( nativeAmount == null || zeroString(nativeAmount) || - exchangeMultiplier == null + rowCurrencyConfig == null ) { return undefined } - const fiatAmount = convertCurrency( - exchangeRates, - rowPluginId, - rowTokenId, - defaultIsoFiat, - div(nativeAmount, exchangeMultiplier, DECIMAL_PRECISION) - ) - if (zeroString(fiatAmount)) return undefined - const fiatSymbol = getDenomFromIsoCode(defaultIsoFiat).symbol ?? '' - return `${fiatSymbol} ${toFixed(round(fiatAmount, -2), 2, 2)}` + return { + nativeAmount, + tokenId: rowTokenId, + currencyConfig: rowCurrencyConfig + } } const renderYouSendRow = (): React.ReactElement => { @@ -1645,12 +1644,7 @@ const SendComponent: React.FC<Props> = props => { currencyCode, guaranteedSide === 'send', handleEditYouSend, - swapRowFiatAmount( - nativeAmount, - pluginId, - tokenId, - cryptoExchangeDenomination.multiplier - ) + swapRowFiat(nativeAmount, coreWallet.currencyConfig, tokenId) ) } @@ -1670,12 +1664,7 @@ const SendComponent: React.FC<Props> = props => { destCurrencyInfo?.currencyCode ?? '', guaranteedSide === 'receive', handleEditRecipientGets, - swapRowFiatAmount( - receiveNativeAmount, - destPluginId, - null, - destExchangeDenom.multiplier - ) + swapRowFiat(receiveNativeAmount, destCurrencyConfig, null) ) } @@ -3397,12 +3386,6 @@ const getStyles = cacheStyles((theme: Theme) => ({ swapAmountText: { fontSize: theme.rem(1) }, - // Mirrors the fiat line on the plain send tile (`EditableAmountTile`), so - // the Houdini rows read the same way the rest of the scene does. - swapFiatText: { - fontSize: theme.rem(0.75), - color: theme.secondaryText - }, swapAssetRow: { flexDirection: 'row', alignItems: 'center', diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 9becb9f5b7a..78f493c682d 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -589,7 +589,7 @@ Queued: an operator followup. Swap and send failures must show the real cause (p ### Phase 10: the provider feedback round -Queued: a feedback round from the Houdini team plus two internal reviewers, arriving as one operator followup with a standing rule (every supported-destination decision must be route-derived) and two hard constraints (no availability probing or per-pair caching of any kind, and graceful rate-limit handling). Shipped, in the order the asks came: the Myself picker's same-asset capability now reads Houdini's own `hasSelfPrivate` flag instead of being assumed, and the Stealth toggle refuses to arm for an asset that lacks it; the toggle re-quotes on every flip, which it did not before, because the request now genuinely differs by privacy and floor; the three minimums were confirmed against the live API and became named constants enforced before any request leaves the app; the plugin honors `privacy: 'required'`, so a plain Swap & Send can use the standard routes that are the only ones on offer between 10 and 25 USD, while a Stealth send declines rather than silently taking one; every call backs off behind the API's own `retryAfter` and reports a rate limit as a rate limit; the exchange order details render on a stealth transaction's detail scene, which they never did; the send scene ignores the global exchange-provider setting while the Exchange scene keeps honoring it; and the Houdini amount rows gained fiat lines matching the plain send tile. Diverged: nothing was dropped, but the standing-rule audit found more than the asks did. Four chains were being offered as destinations that Houdini serves no native for at all, and six more could never resolve a token id because the API spells "no contract address" as an empty string on those chains while the plugin tested only for null. Both are fixed here; see [Retrospective item 6](#where-this-document-was-wrong-or-silent). +Queued: a feedback round from the Houdini team plus two internal reviewers, arriving as one operator followup with a standing rule (every supported-destination decision must be route-derived) and two hard constraints (no availability probing or per-pair caching of any kind, and graceful rate-limit handling). Shipped, in the order the asks came: the Myself picker's same-asset capability now reads Houdini's own `hasSelfPrivate` flag instead of being assumed, and the Stealth toggle refuses to arm for an asset that lacks it; the toggle re-quotes on every flip, which it did not before, because the request now genuinely differs by privacy and floor; the three minimums were confirmed against the live API and became named constants enforced before any request leaves the app; the plugin honors `privacy: 'required'`, so a plain Swap & Send can use the standard routes that are the only ones on offer between 10 and 25 USD, while a Stealth send declines rather than silently taking one; every call backs off behind the API's own `retryAfter` and reports a rate limit as a rate limit; the exchange order details render on a stealth transaction's detail scene, which they never did; the send scene ignores the global exchange-provider setting while the Exchange scene keeps honoring it; and the Houdini amount rows show their fiat value inline in parentheses, through the same `FiatText` component the rest of the app formats fiat with. Diverged: nothing was dropped, but the standing-rule audit found more than the asks did. Four chains were being offered as destinations that Houdini serves no native for at all, and six more could never resolve a token id because the API spells "no contract address" as an empty string on those chains while the plugin tested only for null. Both are fixed here; see [Retrospective item 6](#where-this-document-was-wrong-or-silent). ### Upstream, on Houdini's side From 6155fef72f54a4729d5c0e5fe100f7a21db78312 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 20:05:04 -0700 Subject: [PATCH 37/56] Retract a plain-send error when swap-send mode takes over The makeSpend effect returns early once the scene is a swap-send, leaving its own last error on screen, so an insufficient-funds message from the direct send could sit over a valid swap quote. It now retracts that error on the way in, the mirror of what the quote effect already does on the way out, and each effect only clears the errors it owns. --- src/components/scenes/SendScene2.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 5372d1018e8..fe839f1f37a 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -376,6 +376,11 @@ const SendComponent: React.FC<Props> = props => { swapErrorShown.current = false setError(undefined) } + /** The converse: retract a plain-send error without touching a swap one. */ + const clearPlainSendError = (): void => { + if (swapErrorShown.current) return + setError(undefined) + } /** * Live exchange rates, for the quote effect's fixed-to fallback to read. @@ -2774,6 +2779,12 @@ const SendComponent: React.FC<Props> = props => { if (swapSendActive) { setEdgeTransaction(null) setProcessingAmountChanged(false) + // Entering swap-send mode retracts the plain send's own error, the + // mirror of what the quote effect does on the way out. An + // insufficient-funds message from the direct send would otherwise sit + // over a perfectly good swap quote. Only a plain-send error is cleared + // here; a swap error belongs to the quote effect. + clearPlainSendError() return } pendingInsufficientFees.current = undefined From fef114d918e7eeaee60e919ba2451911717a9a98 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Wed, 29 Jul 2026 20:33:48 -0700 Subject: [PATCH 38/56] Record the review round in the design doc Adds the error-ownership rule between the two effects, what the plugin reports as fixed and why, the same-asset carve-out on the shared blocked-token helper, and the review round's outcome in the phase entry. --- src/docs/stealth-send-swap.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 78f493c682d..d63431d3c1e 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -253,6 +253,14 @@ Houdini is an aggregator behind Cloudflare, and tight request loops get blocked Their published tiers are 5 quote requests per minute on free and 500 on pro. Nothing in the app probes them; the wrapper is the only rate-limit machinery, per the no-probing rule in [Learn route availability from live failures](#learn-route-availability-from-live-failures-not-probes-or-tables). +### What the plugin reports as fixed + +Only the exact-out path sends `fixed=true`, and that is the only path Houdini serves fixed rates on, so `isEstimate` is `!reverseQuote` rather than a constant. A forward quote reports itself as an estimate whether its route is private or standard, because its rate can still move. `makeSwapPluginQuote` reads `isEstimate` off the saved action, so the quote and the transaction details agree from one source. + +### Same-asset is allowed here, and only here + +Every other central swap plugin rejects a swap from an asset to itself through the shared `checkInvalidTokenIds`, which is right for a provider where it would be a no-op the user cannot have meant. Routing an asset to itself through a mixer is this provider's main flow, so the shared helper grew an `allowSameAsset` option that Houdini passes and nothing else sets. The blocked-token half of that helper still applies here; only the same-asset rejection is waived. + ## 7. Detailed design: edge-react-gui ### Where the feature is allowed to appear @@ -388,6 +396,8 @@ A URI amount is what the recipient should **receive**, so for a cross-asset dest Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair. The scene learns it from real quote failures. A `SwapCurrencyError` while the receive side is guaranteed flips the guarantee to the send side, seeds the send amount from display exchange rates so the send stays actionable, and raises a warning card that clears on the next amount edit. One while stealth is on for a **same-asset** pair turns the toggle off (toast, persistent info line) and degrades the swap into the plain direct send the toggle had upgraded. A **cross-asset** pair with no route keeps the plain error card: the request is Houdini-only with or without the toggle, so flipping it changes nothing and there is no other provider to degrade into. Learned capabilities are cached per pair in session state (`routeCaps`), so a later attempt to re-arm the toggle or re-fix the receive amount answers with a pre-emptive toast instead of another doomed quote. The full branch structure is the flowchart in [Section 8](#8-the-send-scene-ux-end-to-end). +Two effects write the scene's single `error` state, the quote effect and the plain-send `makeSpend` effect, and each retracts only its own message when the other takes over. Entering swap-send mode clears a plain-send failure so an insufficient-funds message cannot sit over a valid quote; leaving it clears the swap's failure so a minimum-amount message cannot sit over the plain send the user switched to. Provenance is tracked rather than guessed, because clearing unconditionally in either direction wipes the other effect's answer. + The toggle is a dependency of the quote effect, so flipping it always invalidates the held quote and re-fetches. It has to be: the request now carries `privacy: 'required'` only when stealth is on, and the floor that applies changes with it, so the two states genuinely ask the provider different questions. An earlier revision left `stealth` out on the grounds that the request did not vary with it, which was true then and is not now; a cross-asset pair would have shown standard-route pricing under a Stealth label. ### Minimum order sizes @@ -591,6 +601,8 @@ Queued: an operator followup. Swap and send failures must show the real cause (p Queued: a feedback round from the Houdini team plus two internal reviewers, arriving as one operator followup with a standing rule (every supported-destination decision must be route-derived) and two hard constraints (no availability probing or per-pair caching of any kind, and graceful rate-limit handling). Shipped, in the order the asks came: the Myself picker's same-asset capability now reads Houdini's own `hasSelfPrivate` flag instead of being assumed, and the Stealth toggle refuses to arm for an asset that lacks it; the toggle re-quotes on every flip, which it did not before, because the request now genuinely differs by privacy and floor; the three minimums were confirmed against the live API and became named constants enforced before any request leaves the app; the plugin honors `privacy: 'required'`, so a plain Swap & Send can use the standard routes that are the only ones on offer between 10 and 25 USD, while a Stealth send declines rather than silently taking one; every call backs off behind the API's own `retryAfter` and reports a rate limit as a rate limit; the exchange order details render on a stealth transaction's detail scene, which they never did; the send scene ignores the global exchange-provider setting while the Exchange scene keeps honoring it; and the Houdini amount rows show their fiat value inline in parentheses, through the same `FiatText` component the rest of the app formats fiat with. Diverged: nothing was dropped, but the standing-rule audit found more than the asks did. Four chains were being offered as destinations that Houdini serves no native for at all, and six more could never resolve a token id because the API spells "no contract address" as an empty string on those chains while the plugin tested only for null. Both are fixed here; see [Retrospective item 6](#where-this-document-was-wrong-or-silent). +The review round on that work produced nine findings across five passes, and two of them were privacy holes of the same shape as the one the round set out to close. The Exchange scene's Stealth Swap restricted the provider but never demanded a private route, so a wallet-to-wallet stealth swap could be served a transparent standard route under a private label; it now sets `privacy: 'required'` on the initial quote and on the expiry re-quote. A held quote survived a switch to another wallet on the same asset, leaving an order created against the previous wallet's refund address armed and approvable. The rest were state-hygiene: the floor guard raced an in-flight quote until every run of the effect began retiring its predecessor, the two effects were clearing each other's errors, a token send to its own chain was titled "Stealth Send" when it pays out native and so crosses assets, the fixed-to fallback read a stale rate snapshot, and forward quotes claimed to be fixed. One finding was rejected in part: adopting `checkInvalidTokenIds` wholesale would have rejected same-asset swaps outright, which is the feature. + ### Upstream, on Houdini's side Not our work, tracked so it is not rediscovered: From 8722830b02ee18b9d941541ee0916b01c63b4716 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 00:41:53 -0700 Subject: [PATCH 39/56] Cover the stealth swap options and swap error mapping Adds unit tests for the two send-scene helpers that had none, and extends the chain-table tests past address detection into the lookup and the floor constants. --- .../WalletListModal.test.tsx.snap | 7 +- src/__tests__/util/houdiniChains.test.ts | 129 +++++++++- src/__tests__/util/stealthSwap.test.ts | 87 +++++++ src/__tests__/util/swapErrorDisplay.test.ts | 220 ++++++++++++++++++ 4 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/util/stealthSwap.test.ts create mode 100644 src/__tests__/util/swapErrorDisplay.test.ts diff --git a/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap index 25d2c135ddc..3319626903a 100644 --- a/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap @@ -319,6 +319,7 @@ exports[`WalletListModal should render with loading props 1`] = ` }, ] } + testID="walletPickerSearch" > <View collapsable={false} @@ -435,7 +436,7 @@ exports[`WalletListModal should render with loading props 1`] = ` "opacity": 1, } } - testID="undefined.doneButton" + testID="walletPickerSearch.doneButton" > <View collapsable={false} @@ -581,7 +582,7 @@ exports[`WalletListModal should render with loading props 1`] = ` ] } submitBehavior="submit" - testID="undefined.textInput" + testID="walletPickerSearch.textInput" textAlignVertical="top" /> <View @@ -618,7 +619,7 @@ exports[`WalletListModal should render with loading props 1`] = ` "opacity": 1, } } - testID="undefined.clearIcon" + testID="walletPickerSearch.clearIcon" > <View collapsable={false} diff --git a/src/__tests__/util/houdiniChains.test.ts b/src/__tests__/util/houdiniChains.test.ts index 05bf9441419..3def88fc53f 100644 --- a/src/__tests__/util/houdiniChains.test.ts +++ b/src/__tests__/util/houdiniChains.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from '@jest/globals' +import { lt } from 'biggystring' -import { detectHoudiniChains } from '../../util/houdiniChains' +import { + detectHoudiniChains, + getHoudiniChain, + HOUDINI_CHAINS, + HOUDINI_MIN_USD, + isValidHoudiniAddress +} from '../../util/houdiniChains' // Real mainnet-format addresses for the chains the send scene offers: const ADDRESSES = { @@ -143,3 +150,123 @@ describe('detectHoudiniChains', () => { expect(found).toEqual([]) }) }) + +describe('getHoudiniChain', () => { + it('finds a served chain by its Edge plugin id', () => { + const chain = getHoudiniChain('litecoin', null) + expect(chain?.houdiniShortName).toEqual('litecoin') + }) + + it('returns nothing for a chain Houdini does not serve', () => { + expect(getHoudiniChain('piratechain', null)).toBeUndefined() + }) + + it('returns nothing for the chains with no mainnet native coin', () => { + // Houdini publishes no mainnet native for these, so every quote naming one + // threw while the picker still offered it as a destination. + for (const pluginId of ['celo', 'fantom', 'polkadot', 'ton']) { + expect(getHoudiniChain(pluginId, null)).toBeUndefined() + } + }) + + it('returns nothing for a token, even on a served chain', () => { + // Only chain-native assets are offered as destinations today. A token id + // must not silently resolve to its parent chain and pay out the wrong + // asset. + expect(getHoudiniChain('ethereum', 'a0b8...eb48')).toBeUndefined() + expect(getHoudiniChain('ethereum', null)).toBeDefined() + }) +}) + +describe('HOUDINI_CHAINS table', () => { + it('has no duplicate plugin ids', () => { + const pluginIds = HOUDINI_CHAINS.map(chain => chain.pluginId) + expect(new Set(pluginIds).size).toEqual(pluginIds.length) + }) + + it('has no duplicate Houdini chain names', () => { + const shortNames = HOUDINI_CHAINS.map(chain => chain.houdiniShortName) + expect(new Set(shortNames).size).toEqual(shortNames.length) + }) + + it('carries a same-asset private capability for every chain', () => { + // `hasSelfPrivate` decides whether the Stealth toggle can arm on a + // same-asset pick with no quote, so a missing value would read as false + // and silently remove the toggle. + for (const chain of HOUDINI_CHAINS) { + expect(typeof chain.hasSelfPrivate).toEqual('boolean') + } + }) + + it('rejects the empty string on every chain address regex', () => { + // An unanchored or zero-length alternative makes a regex match everything, + // which turns address detection into a coin flip about where funds go. + for (const chain of HOUDINI_CHAINS) { + expect(isValidHoudiniAddress(chain, '')).toEqual(false) + expect(isValidHoudiniAddress(chain, 'not an address at all')).toEqual( + false + ) + } + }) + + it('marks the memo chains that need a destination tag', () => { + const memoChains = HOUDINI_CHAINS.filter(chain => chain.memoNeeded).map( + chain => chain.pluginId + ) + expect(memoChains).toEqual( + expect.arrayContaining([ + 'cosmoshub', + 'hedera', + 'ripple', + 'stellar', + 'thorchainrune' + ]) + ) + expect(memoChains).not.toContain('bitcoin') + }) + + it('accepts and rejects addresses on a chain that needs a memo', () => { + const ripple = getHoudiniChain('ripple', null) + expect(ripple).toBeDefined() + if (ripple == null) return + expect( + isValidHoudiniAddress(ripple, 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe') + ).toEqual(true) + expect(isValidHoudiniAddress(ripple, 'notanaddress')).toEqual(false) + }) + + it('trims surrounding whitespace before validating', () => { + const litecoin = getHoudiniChain('litecoin', null) + expect(litecoin).toBeDefined() + if (litecoin == null) return + expect( + isValidHoudiniAddress(litecoin, ' MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441 ') + ).toEqual(true) + }) +}) + +describe('HOUDINI_MIN_USD', () => { + it('orders the floors from the strictest route to the loosest', () => { + // Confirmed against the live API: a pair answers with no route at all + // below 10 USD, standard routes from 10 up, and private routes from 25. + expect(lt(HOUDINI_MIN_USD.dex, HOUDINI_MIN_USD.standard)).toEqual(true) + expect(lt(HOUDINI_MIN_USD.standard, HOUDINI_MIN_USD.private)).toEqual(true) + }) + + it('states the floors as biggystring-comparable decimal strings', () => { + // These are compared against a converted USD order value with `lt`, which + // needs plain decimal strings rather than numbers. + for (const floor of Object.values(HOUDINI_MIN_USD)) { + expect(typeof floor).toEqual('string') + expect(floor).toMatch(/^[0-9]+(\.[0-9]+)?$/) + } + }) + + it('holds the values Houdini published', () => { + expect(HOUDINI_MIN_USD).toEqual({ + private: '25', + standard: '10', + dex: '5' + }) + }) +}) diff --git a/src/__tests__/util/stealthSwap.test.ts b/src/__tests__/util/stealthSwap.test.ts new file mode 100644 index 00000000000..1534fbe50eb --- /dev/null +++ b/src/__tests__/util/stealthSwap.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from '@jest/globals' +import type { EdgeAccount } from 'edge-core-js' + +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' + +// Only `swapConfig`'s key set is read, to find the plugins to switch off: +const fakeAccount = (swapPluginIds: string[]): EdgeAccount => { + const swapConfig: Record<string, object> = {} + for (const pluginId of swapPluginIds) swapConfig[pluginId] = {} + return { swapConfig } as unknown as EdgeAccount +} + +const account = fakeAccount(['houdini', 'changenow', 'letsexchange', 'unizen']) + +describe('makeStealthSwapRequestOptions', () => { + it('disables every provider except Houdini', () => { + const { disabled } = makeStealthSwapRequestOptions(account) + expect(disabled).toEqual({ + changenow: true, + letsexchange: true, + unizen: true + }) + expect(disabled?.houdini).toBeUndefined() + }) + + it('clears a preferred provider that would fight the restriction', () => { + // A leftover `preferPluginId` cannot override `disabled`, but leaving it + // set makes the request self-contradictory and its intent unreadable. + const options = makeStealthSwapRequestOptions(account, { + preferPluginId: 'changenow', + preferType: 'CEX' + }) + expect(options.preferPluginId).toBeUndefined() + expect(options.preferType).toBeUndefined() + }) + + it('leaves the exchange setting alone by default', () => { + // The Exchange scene keeps honoring the user's provider settings, so a + // stealth swap started there must not force-enable anything. + const options = makeStealthSwapRequestOptions(account) + expect(options.forceEnabled).toBeUndefined() + }) + + it('force-enables Houdini when the caller ignores the provider setting', () => { + // The send scene's path: the swap setting governs which providers the + // aggregator may pick among, so it must not switch off a send feature that + // happens to be powered by one of them. + const options = makeStealthSwapRequestOptions(account, undefined, { + ignoreProviderSetting: true + }) + expect(options.forceEnabled).toEqual({ houdini: true }) + }) + + it('keeps a caller force-enabling other plugins', () => { + const options = makeStealthSwapRequestOptions( + account, + { forceEnabled: { changenow: true } }, + { ignoreProviderSetting: true } + ) + expect(options.forceEnabled).toEqual({ changenow: true, houdini: true }) + }) + + it('preserves unrelated options', () => { + const options = makeStealthSwapRequestOptions(account, { + promoCodes: { houdini: 'edge' }, + slowResponseMs: 1234 + }) + expect(options.promoCodes).toEqual({ houdini: 'edge' }) + expect(options.slowResponseMs).toEqual(1234) + }) + + it('keeps a caller own disabled entries alongside its own', () => { + // `disabled` wins over `forceEnabled` in the core, so a caller that + // disabled Houdini itself still gets no Houdini quote. + const options = makeStealthSwapRequestOptions( + account, + { disabled: { houdini: true } }, + { ignoreProviderSetting: true } + ) + expect(options.disabled?.houdini).toEqual(true) + }) + + it('handles an account with Houdini as its only provider', () => { + const { disabled } = makeStealthSwapRequestOptions(fakeAccount(['houdini'])) + expect(disabled).toEqual({}) + }) +}) diff --git a/src/__tests__/util/swapErrorDisplay.test.ts b/src/__tests__/util/swapErrorDisplay.test.ts new file mode 100644 index 00000000000..7fa6319c360 --- /dev/null +++ b/src/__tests__/util/swapErrorDisplay.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from '@jest/globals' +import { + type EdgeCurrencyWallet, + type EdgeDenomination, + type EdgeSwapInfo, + type EdgeSwapRequest, + InsufficientFundsError, + SwapAboveLimitError, + SwapBelowLimitError, + SwapCurrencyError, + SwapPermissionError +} from 'edge-core-js' + +import { processSwapQuoteError } from '../../util/swapErrorDisplay' + +const swapInfo: EdgeSwapInfo = { + pluginId: 'houdini', + displayName: 'HoudiniSwap', + supportEmail: 'support@houdiniswap.com' +} + +const fakeWallet = ( + pluginId: string, + currencyCode: string +): EdgeCurrencyWallet => { + const currencyInfo = { pluginId, currencyCode } + return { + id: `${pluginId}-wallet`, + type: `wallet:${pluginId}`, + currencyInfo, + currencyConfig: { currencyInfo, allTokens: {} } + } as unknown as EdgeCurrencyWallet +} + +const tronWallet = fakeWallet('tron', 'TRX') +const litecoinWallet = fakeWallet('litecoin', 'LTC') + +const trxDenomination: EdgeDenomination = { + name: 'TRX', + multiplier: '1000000', + symbol: '' +} +const ltcDenomination: EdgeDenomination = { + name: 'LTC', + multiplier: '100000000', + symbol: 'Ł' +} + +/** + * A swap-to-address request, which is the shape the send scene builds: no + * destination wallet, a `toAddressInfo` descriptor in its place. + */ +const sendSceneRequest: EdgeSwapRequest = { + fromWallet: tronWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '80000000', + quoteFor: 'from', + toAddressInfo: { + toPluginId: 'litecoin', + toAddress: 'MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441' + } +} as unknown as EdgeSwapRequest + +const describeError = ( + error: unknown, + toCurrencyCode?: string +): { title: string; message: string } | undefined => { + const info = processSwapQuoteError({ + error, + swapRequest: sendSceneRequest, + fromDenomination: trxDenomination, + toDenomination: ltcDenomination, + toCurrencyCode + }) + if (info == null) return undefined + return { title: info.title, message: info.message } +} + +describe('processSwapQuoteError', () => { + it('returns nothing for a missing error', () => { + expect(describeError(null)).toBeUndefined() + expect(describeError(undefined)).toBeUndefined() + }) + + it('names the minimum in the units of the side that was fixed', () => { + // The user is told the floor they missed, in their own send units, rather + // than a generic "unavailable". + const info = describeError( + new SwapBelowLimitError(swapInfo, '25000000', 'from') + ) + expect(info?.message).toContain('25') + expect(info?.message).toContain('TRX') + }) + + it('reads the receive denomination for a to-direction minimum', () => { + const info = describeError( + new SwapBelowLimitError(swapInfo, '50000000', 'to') + ) + expect(info?.message).toContain('0.5') + expect(info?.message).toContain('LTC') + }) + + it('falls back to a limit-free message when the minimum is zero', () => { + const info = describeError(new SwapBelowLimitError(swapInfo, '0', 'from')) + expect(info?.message).toContain('below the min limit') + expect(info?.message).not.toContain('TRX') + }) + + it('names the maximum for an above-limit error', () => { + const info = describeError( + new SwapAboveLimitError(swapInfo, '500000000', 'from') + ) + expect(info?.message).toContain('500') + expect(info?.message).toContain('TRX') + }) + + it('names both assets when the pair cannot route', () => { + // A swap-to-address request carries no destination wallet, so the caller + // supplies the payout currency code. Reading it off the request would name + // the SOURCE asset on both sides and tell the user TRX cannot reach TRX. + const info = describeError( + new SwapCurrencyError(swapInfo, sendSceneRequest), + 'LTC' + ) + expect(info?.message).toContain('TRX') + expect(info?.message).toContain('LTC') + }) + + it('does not claim the destination is the source when no code is supplied', () => { + const info = describeError( + new SwapCurrencyError(swapInfo, sendSceneRequest) + ) + // Without a supplied code there is only the source wallet to read, so the + // message degrades to naming it twice. That is the shape the caller must + // avoid by passing `toCurrencyCode`, and it is pinned here so a future + // change to the fallback is a deliberate one. + expect(info?.message).toContain('TRX') + }) + + it('reports insufficient funds from a real error instance', () => { + const info = describeError(new InsufficientFundsError({ tokenId: null })) + expect(info?.title).toEqual('Insufficient Funds') + }) + + it('reports insufficient funds from the stringified shape some plugins throw', () => { + const info = describeError(new Error('InsufficientFundsError')) + expect(info?.title).toEqual('Insufficient Funds') + }) + + it('maps pending transactions to the pending-funds message', () => { + const info = describeError(new Error('Unexpected pending transactions')) + expect(info?.title).toEqual('Insufficient Funds') + expect(info?.message).not.toEqual('Unexpected pending transactions') + }) + + it('reports a geographic restriction', () => { + const info = describeError( + new SwapPermissionError(swapInfo, 'geoRestriction') + ) + expect(info?.message).toContain('Location restricted') + }) + + it('passes a non-geographic permission error through to its own message', () => { + const info = describeError( + new SwapPermissionError(swapInfo, 'noVerification') + ) + expect(info?.title).toEqual('Exchange Error') + }) + + it('surfaces the provider own message for anything unrecognized', () => { + // Houdini's minimums above the shared floor arrive this way, carrying the + // real number, which beats any string we could substitute. + const info = describeError( + new Error('HoudiniSwap: Amount is too low, minimum is 60 USD') + ) + expect(info?.message).toEqual( + 'HoudiniSwap: Amount is too low, minimum is 60 USD' + ) + }) + + it('never surfaces a rate limit as an unavailable pair', () => { + // A 429 means the caller was too fast, not that the route is gone. The + // plugin phrases it, and this path must not rewrite it into a pair error. + const info = describeError( + new Error('HoudiniSwap: rate limit exceeded, please try again shortly') + ) + expect(info?.message).toContain('rate limit exceeded') + expect(info?.message).not.toContain('No enabled exchanges') + }) + + it('stringifies a thrown non-error', () => { + const info = describeError({ code: 500 }) + expect(info?.message).toEqual('{"code":500}') + }) + + it('keeps the original error for the caller to log', () => { + const thrown = new Error('boom') + const info = processSwapQuoteError({ + error: thrown, + swapRequest: sendSceneRequest, + fromDenomination: trxDenomination, + toDenomination: ltcDenomination + }) + expect(info?.error).toBe(thrown) + }) + + it('handles a wallet-to-wallet request with a real destination wallet', () => { + const info = processSwapQuoteError({ + error: new SwapCurrencyError(swapInfo, sendSceneRequest), + swapRequest: { + ...sendSceneRequest, + toWallet: litecoinWallet + } as unknown as EdgeSwapRequest, + fromDenomination: trxDenomination, + toDenomination: ltcDenomination + }) + expect(info?.message).toContain('LTC') + }) +}) From fc37165d8f1f291a84a0304d32b2de8ed178b722 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 00:44:11 -0700 Subject: [PATCH 40/56] test: add testIDs for the wallet picker and its rows Wallet rows repeat their name inside a picker's search field, so a text selector matches the field rather than the row a UI walk means to tap. --- eslint.config.mjs | 1 - src/components/modals/WalletListModal.tsx | 1 + src/components/themed/SearchFooter.tsx | 4 ++++ src/components/themed/WalletListCurrencyRow.tsx | 4 ++++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f5bb6c9dec8..49682aeb2ee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -370,7 +370,6 @@ export default [ 'src/components/themed/SceneHeader.tsx', - 'src/components/themed/SearchFooter.tsx', 'src/components/themed/SelectableRow.tsx', 'src/components/themed/ShareButtons.tsx', diff --git a/src/components/modals/WalletListModal.tsx b/src/components/modals/WalletListModal.tsx index beca381a6be..7a4dbc368ff 100644 --- a/src/components/modals/WalletListModal.tsx +++ b/src/components/modals/WalletListModal.tsx @@ -301,6 +301,7 @@ export const WalletListModal: React.FC<Props> = props => { aroundRem={0.5} returnKeyType="search" placeholder={lstrings.search_wallets} + testID="walletPickerSearch" onChangeText={setSearchText} onClear={handleSearchClear} value={searchText} diff --git a/src/components/themed/SearchFooter.tsx b/src/components/themed/SearchFooter.tsx index bf3a8c9603e..a272d1c4773 100644 --- a/src/components/themed/SearchFooter.tsx +++ b/src/components/themed/SearchFooter.tsx @@ -92,6 +92,10 @@ export const SearchFooter: React.FC<SearchFooterProps> = props => { <SimpleTextInput returnKeyType="search" placeholder={placeholder} + // Keyed off the scene's own footer name. A UI walk cannot target this + // field by its placeholder, because the placeholder disappears as soon + // as the field holds a query, and the query survives a relaunch. + testID={`searchFooter.${name}`} onChangeText={handleChangeText} value={searchText} active={isSearching} diff --git a/src/components/themed/WalletListCurrencyRow.tsx b/src/components/themed/WalletListCurrencyRow.tsx index e7f1c9abeb3..ca4655ddcd5 100644 --- a/src/components/themed/WalletListCurrencyRow.tsx +++ b/src/components/themed/WalletListCurrencyRow.tsx @@ -188,6 +188,10 @@ const WalletListCurrencyRowComponent = ( </EdgeText> ) : null } + // Keyed by wallet name so a UI test can target one specific row. Rows + // repeat their name inside a picker's search field, so a plain text + // selector matches the field instead of the row a walk means to tap. + testID={`walletListRow.${walletName}`} onLongPress={handleLongPress} onPress={handlePress} paddingRem={0.5} From e24c82ca167062dc722610972c343bcb4850a598 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 01:03:11 -0700 Subject: [PATCH 41/56] Add a composable maestro suite for the stealth branches A flow per user-visible branch in maestro/14-stealth, built from reusable subflows in maestro/common so a later session can drive one specific state without walking the simulator by hand. The two flows that move funds carry their own tag, so a run of the suite cannot spend. --- .gitignore | 3 + maestro/14-stealth/README.md | 133 ++++++++++++++++++ .../stealth-below-private-minimum.yaml | 40 ++++++ .../14-stealth/stealth-cross-asset-quote.yaml | 31 ++++ maestro/14-stealth/stealth-execute-send.yaml | 44 ++++++ maestro/14-stealth/stealth-execute-swap.yaml | 79 +++++++++++ maestro/14-stealth/stealth-myself-picker.yaml | 27 ++++ maestro/14-stealth/stealth-private-quote.yaml | 43 ++++++ .../14-stealth/stealth-qr-payment-uri.yaml | 104 +++----------- maestro/14-stealth/stealth-send.yaml | 80 ++++------- maestro/14-stealth/stealth-swap.yaml | 56 ++------ maestro/14-stealth/stealth-tx-details.yaml | 52 +++++++ maestro/common/stealth-await-quote.yaml | 39 +++++ maestro/common/stealth-enter-address.yaml | 19 +++ maestro/common/stealth-launch.yaml | 64 +++++++++ maestro/common/stealth-open-send.yaml | 25 ++++ maestro/common/stealth-open-swap.yaml | 16 +++ maestro/common/stealth-open-wallet.yaml | 38 +++++ maestro/common/stealth-pick-myself.yaml | 31 ++++ .../common/stealth-pick-recipient-asset.yaml | 25 ++++ maestro/common/stealth-scan-address.yaml | 24 ++++ maestro/common/stealth-set-amount.yaml | 46 ++++++ maestro/common/stealth-slide-to-confirm.yaml | 19 +++ maestro/common/stealth-toggle.yaml | 37 +++++ 24 files changed, 897 insertions(+), 178 deletions(-) create mode 100644 maestro/14-stealth/README.md create mode 100644 maestro/14-stealth/stealth-below-private-minimum.yaml create mode 100644 maestro/14-stealth/stealth-cross-asset-quote.yaml create mode 100644 maestro/14-stealth/stealth-execute-send.yaml create mode 100644 maestro/14-stealth/stealth-execute-swap.yaml create mode 100644 maestro/14-stealth/stealth-myself-picker.yaml create mode 100644 maestro/14-stealth/stealth-private-quote.yaml create mode 100644 maestro/14-stealth/stealth-tx-details.yaml create mode 100644 maestro/common/stealth-await-quote.yaml create mode 100644 maestro/common/stealth-enter-address.yaml create mode 100644 maestro/common/stealth-launch.yaml create mode 100644 maestro/common/stealth-open-send.yaml create mode 100644 maestro/common/stealth-open-swap.yaml create mode 100644 maestro/common/stealth-open-wallet.yaml create mode 100644 maestro/common/stealth-pick-myself.yaml create mode 100644 maestro/common/stealth-pick-recipient-asset.yaml create mode 100644 maestro/common/stealth-scan-address.yaml create mode 100644 maestro/common/stealth-set-amount.yaml create mode 100644 maestro/common/stealth-slide-to-confirm.yaml create mode 100644 maestro/common/stealth-toggle.yaml diff --git a/.gitignore b/.gitignore index 13a4349fc8f..be84ff6d0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ yarn-error.log !.yarn/releases !.yarn/sdks !.yarn/versions + +# Maestro run output +/maestro/screenshots diff --git a/maestro/14-stealth/README.md b/maestro/14-stealth/README.md new file mode 100644 index 00000000000..733332cec35 --- /dev/null +++ b/maestro/14-stealth/README.md @@ -0,0 +1,133 @@ +# Stealth Send and Stealth Swap flows + +Maestro coverage for the send-to-address swap UI. Every user-visible branch of +the feature has a flow, and the pieces those flows are built from live in +`../common/stealth-*.yaml` so a later session can drive one specific state +without walking the whole thing by hand. + +## Running + +The whole suite, minus the two flows that spend money: + +```bash +npm run maestro -- test --include-tags stealth maestro +``` + +One flow: + +```bash +npm run maestro -- test maestro/14-stealth/stealth-private-quote.yaml +``` + +The two funded flows are tagged `stealth-spend` and nothing else, so the command +above never triggers them. Run them deliberately, and in this order: + +```bash +npm run maestro -- test maestro/14-stealth/stealth-execute-send.yaml +npm run maestro -- test maestro/14-stealth/stealth-execute-swap.yaml +``` + +They run opposite directions, so the pair returns the funds to where they +started and costs two spreads rather than emptying one wallet. Let the first +one's deposit confirm before starting the second: a wallet with an unconfirmed +outgoing transaction cannot quote a new send. + +Run flows ONE AT A TIME rather than as a tagged batch. A failing flow takes the +maestro driver down with it, and every flow after it then reports +`Failed to connect`, which reads as a suite-wide breakage instead of one bad +flow. + +On a machine with more than one simulator booted, pin the device and the driver +port, or the run may attach to the wrong one: + +```bash +maestro --device <udid> --driver-host-port <port> test maestro/14-stealth +``` + +## Account expectations + +The flows read wallet names from env vars so they can point at whatever the +signed-in account actually holds. Defaults assume the `edge-funds` roster +account as of 2026-07-30: + +| Env var | Default | Needs | +| -------------------------- | ------------ | -------------------------------------------- | +| `STEALTH_SRC_WALLET` | `My Stellar` | funded above 25 USD for the private branches | +| `STEALTH_DEST_WALLET` | `My Sonic` | exists; no balance needed | +| `STEALTH_AMOUNT` | `158` | worth more than 25 USD in source units | +| `STEALTH_BELOW_MIN_AMOUNT` | `80` | worth between 10 and 25 USD | +| `STEALTH_PIN_DIGIT` | `0` | the account's single repeated relogin digit | +| `STEALTH_MEMO_CHAIN` | `Ripple` | a memo-required destination chain | + +Two preconditions the flows cannot check for you: + +- **The source wallet must have no unconfirmed outgoing transaction.** A pending + send blocks the next one, so the quote never arms and the flow fails on the + slider rather than on anything it is testing. Running the funded flows + back-to-back on one wallet hits this. +- **Balances drift.** The amounts above are chosen against a live price; when the + source asset moves far enough, `STEALTH_AMOUNT` stops clearing 25 USD or + `STEALTH_BELOW_MIN_AMOUNT` stops sitting between the two floors, and the + private branches quietly test the wrong thing. Both are worth re-checking + before reading a failure as a regression. + +`stealth-send.yaml`, `stealth-swap.yaml` and `stealth-qr-payment-uri.yaml` +request no quote, so they need no balance at all. + +## The flows + +| Flow | Branch it drives | +| ----------------------------------- | -------------------------------------------------------------------- | +| `stealth-send.yaml` | Send scene controls before address entry, plus a memo-chain tag row | +| `stealth-swap.yaml` | Exchange scene toggle card | +| `stealth-qr-payment-uri.yaml` | scanned payment URI, cross-chain, amount on the recipient side | +| `stealth-myself-picker.yaml` | own-wallet destinations grouped same-asset first | +| `stealth-cross-asset-quote.yaml` | plain Swap & Send on a transparent route | +| `stealth-private-quote.yaml` | the toggle invalidating a held quote and refetching a private route | +| `stealth-below-private-minimum.yaml`| the toggle refusing under the private floor, with no request sent | +| `stealth-tx-details.yaml` | a completed stealth transaction's identity rows | +| `stealth-execute-send.yaml` | **spends** a private Stealth Send to the success scene | +| `stealth-execute-swap.yaml` | **spends** a Stealth Swap from the Exchange scene | + +## Composing your own + +Each subflow states its env contract in its header. A walk that needs a live +private quote on a pair the suite does not cover is five `runFlow` steps: + +```yaml +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: My Tron 2 +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: My Litecoin (new) +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: You send + AMOUNT: '120' +- runFlow: + file: ../common/stealth-toggle.yaml +- runFlow: + file: ../common/stealth-await-quote.yaml +``` + +Two things bite when writing these by hand: + +- **The confirm slider is a pan gesture.** A coordinate swipe across the track + does nothing at all. `stealth-slide-to-confirm.yaml` swipes from the thumb by + id, which is the only form that completes it. +- **Notification cards float over the bottom of every scene**, including the + slider and the amount rows. `stealth-launch.yaml` swipes them away; skip it + and later taps land on a card instead of the control underneath. + +## Assertions + +These flows are for driving the app to a state, not for asserting behavior. Each +one asserts only what it must to gate the next step (that a scene arrived, that +a quote settled, that the slider is live). Behavioral claims belong in the unit +tests and in `src/docs/stealth-send-swap.md`. diff --git a/maestro/14-stealth/stealth-below-private-minimum.yaml b/maestro/14-stealth/stealth-below-private-minimum.yaml new file mode 100644 index 00000000000..30b149d0c68 --- /dev/null +++ b/maestro/14-stealth/stealth-below-private-minimum.yaml @@ -0,0 +1,40 @@ +# Below the private floor the toggle refuses, client-side. +# +# The provider serves no private route under 25 USD, so an order under it is +# pre-empted before a request goes out rather than sent and refused. The toggle +# stays off and the card explains the floor. The transparent route is still +# available at the same amount, which is what keeps a plain Swap & Send working +# in the band between the two floors. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + # Worth more than the 10 USD transparent floor and less than the 25 USD + # private one, in the source wallet's own denomination: + AMOUNT: ${STEALTH_BELOW_MIN_AMOUNT || '80'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-await-quote.yaml + +- runFlow: + file: ../common/stealth-toggle.yaml +- assertVisible: 'Private routing needs at least.*' +- takeScreenshot: maestro/screenshots/stealth-below-min-01-toggle-refused diff --git a/maestro/14-stealth/stealth-cross-asset-quote.yaml b/maestro/14-stealth/stealth-cross-asset-quote.yaml new file mode 100644 index 00000000000..25f2e659b05 --- /dev/null +++ b/maestro/14-stealth/stealth-cross-asset-quote.yaml @@ -0,0 +1,31 @@ +# A plain cross-asset Swap & Send: no privacy requested, so a transparent +# (single-exchange) route is acceptable and the order clears the lower floor. +# +# Ends on a live quote with the slider armed. Nothing is sent. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + AMOUNT: ${STEALTH_AMOUNT || '158'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-await-quote.yaml +- takeScreenshot: maestro/screenshots/stealth-cross-asset-01-standard-quote diff --git a/maestro/14-stealth/stealth-execute-send.yaml b/maestro/14-stealth/stealth-execute-send.yaml new file mode 100644 index 00000000000..8459b1a8bad --- /dev/null +++ b/maestro/14-stealth/stealth-execute-send.yaml @@ -0,0 +1,44 @@ +# Executes a private cross-asset Stealth Send end to end, to the success scene. +# +# THIS SPENDS FUNDS. It carries the `stealth-spend` tag ONLY, so a run of the +# rest of the suite by the `stealth` tag never triggers it. See README.md in +# this directory for how to invoke it deliberately. +# +# Sending to one of the account's own wallets keeps the principal inside the +# account, so the cost is the route's spread plus network fees. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + AMOUNT: ${STEALTH_AMOUNT || '158'} +tags: + - stealth-spend +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-toggle.yaml +- runFlow: + file: ../common/stealth-await-quote.yaml + env: + LABEL: 'Slide to send stealthily' +- runFlow: + file: ../common/stealth-slide-to-confirm.yaml +- extendedWaitUntil: + visible: 'Transaction Details' + timeout: 90000 +- takeScreenshot: maestro/screenshots/stealth-execute-01-success diff --git a/maestro/14-stealth/stealth-execute-swap.yaml b/maestro/14-stealth/stealth-execute-swap.yaml new file mode 100644 index 00000000000..fdbda88a8bc --- /dev/null +++ b/maestro/14-stealth/stealth-execute-swap.yaml @@ -0,0 +1,79 @@ +# Executes a Stealth Swap from the Exchange scene end to end. +# +# THIS SPENDS FUNDS. `stealth-spend` tag only, same as stealth-execute-send. +# +# The Exchange scene's flip input opens on fiat, so AMOUNT_FIAT is a fiat +# figure here, unlike the Send scene walks. +# +# It runs the OPPOSITE direction to stealth-execute-send on purpose. Run that +# one first and this one second, and the funds come back: the send moves the +# source wallet's balance to the destination, and this moves it back, so the +# pair costs two spreads rather than leaving one wallet empty. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SWAP_SRC_WALLET || 'My Sonic'} + DEST_WALLET: ${STEALTH_SWAP_DEST_WALLET || 'My Stellar'} + AMOUNT_FIAT: ${STEALTH_SWAP_AMOUNT_FIAT || '25'} +tags: + - stealth-spend +--- +- runFlow: + file: ../common/stealth-launch.yaml +- tapOn: 'Exchange' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: 'Select Source Wallet' +- waitForAnimationToEnd: + timeout: 2500 +- tapOn: 'Search Wallets' +- inputText: '${SRC_WALLET}' +# Wait for the row the filter should produce: text typed before the search field +# settles is dropped, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletListRow.${SRC_WALLET}' + timeout: 20000 +- tapOn: + id: 'walletListRow.${SRC_WALLET}' +- waitForAnimationToEnd: + timeout: 3500 +- tapOn: 'Select Receiving Wallet' +- waitForAnimationToEnd: + timeout: 2500 +- tapOn: 'Search Wallets' +- inputText: '${DEST_WALLET}' +# Wait for the row the filter should produce: text typed before the search field +# settles is dropped, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletListRow.${DEST_WALLET}' + timeout: 20000 +- tapOn: + id: 'walletListRow.${DEST_WALLET}' +- waitForAnimationToEnd: + timeout: 3500 + +- runFlow: + file: ../common/stealth-toggle.yaml + env: + TOGGLE: 'Stealth Swap' +- tapOn: + text: 'Tap to edit' + index: 0 +- waitForAnimationToEnd: + timeout: 2500 +- inputText: '${AMOUNT_FIAT}' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Next' +- extendedWaitUntil: + visible: 'Slide to Confirm' + timeout: 60000 +- takeScreenshot: maestro/screenshots/stealth-execute-swap-01-quote +- runFlow: + file: ../common/stealth-slide-to-confirm.yaml +- extendedWaitUntil: + visible: 'Transaction Details' + timeout: 90000 +- takeScreenshot: maestro/screenshots/stealth-execute-swap-02-success diff --git a/maestro/14-stealth/stealth-myself-picker.yaml b/maestro/14-stealth/stealth-myself-picker.yaml new file mode 100644 index 00000000000..19a1f295fe2 --- /dev/null +++ b/maestro/14-stealth/stealth-myself-picker.yaml @@ -0,0 +1,27 @@ +# The "Myself" destination picker. +# +# Supported destinations are derived from the route table, not from a native-only +# rule, so the picker offers same-asset wallets under a "Same Asset" heading and +# every other wallet whose asset the provider pays out to under "Other Assets". +# The sending wallet itself is never offered, since a send to yourself in the +# same wallet is not a destination. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- tapOn: + id: 'addressTileMyself' +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: 'Same Asset' +- assertVisible: 'Other Assets' +- takeScreenshot: maestro/screenshots/stealth-myself-01-grouped diff --git a/maestro/14-stealth/stealth-private-quote.yaml b/maestro/14-stealth/stealth-private-quote.yaml new file mode 100644 index 00000000000..eb38dcf1c76 --- /dev/null +++ b/maestro/14-stealth/stealth-private-quote.yaml @@ -0,0 +1,43 @@ +# Turning Stealth on re-quotes. +# +# The transparent and private routes are different requests, so a held quote +# cannot be reused across the toggle. Flipping it throws the quote away, drops +# the slider back to its disabled state, and fetches again; the slider label +# changes to "Slide to send stealthily" once the private route arrives. +# +# Ends on a live private quote. Nothing is sent. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + AMOUNT: ${STEALTH_AMOUNT || '158'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-await-quote.yaml + +- runFlow: + file: ../common/stealth-toggle.yaml +- takeScreenshot: maestro/screenshots/stealth-private-01-requoting +- runFlow: + file: ../common/stealth-await-quote.yaml + env: + LABEL: 'Slide to send stealthily' +- takeScreenshot: maestro/screenshots/stealth-private-02-private-quote diff --git a/maestro/14-stealth/stealth-qr-payment-uri.yaml b/maestro/14-stealth/stealth-qr-payment-uri.yaml index bb8ba8360b3..bc83e9377ae 100644 --- a/maestro/14-stealth/stealth-qr-payment-uri.yaml +++ b/maestro/14-stealth/stealth-qr-payment-uri.yaml @@ -1,98 +1,38 @@ # Scanned payment-URI walk for the send-to-address swap UI. # -# A scanned QR carries a payment URI (`ethereum:0x...?amount=0.5`), never a -# bare address. This walk covers both halves of that handling: -# - the destination address is extracted from the URI and accepted even -# though the source wallet cannot parse a foreign-chain URI, and -# - the URI's amount lands on the RECIPIENT side as the guaranteed amount -# (a payment request states what the recipient should receive; the send -# side comes from the swap quote). -# -# The sim has no camera, so the URI is delivered through the scan modal's -# keyboard-entry affordance, which resolves the typed string through the -# exact code path a decoded QR takes. -# -# Screenshots captured (under ~/.maestro/tests/<run>/ ): -# stealth-qr-01-cross-chain ETH destination filled from the URI, amount -# on the "Recipient gets" row as Guaranteed +# A scanned QR carries a payment URI (`ethereum:0x...?amount=0.5`), never a bare +# address. This covers both halves of that handling: the destination address is +# extracted from the URI and accepted even though the source wallet cannot parse +# a foreign-chain URI, and the URI's amount lands on the RECIPIENT side as the +# guaranteed amount, because a payment request states what the recipient should +# receive while the send side comes from the quote. appId: ${APP_ID} env: - APP_ID: co.edgesecure.app - PIN_DIGIT: '0' - SRC_WALLET: My Bitcoin - PAYMENT_URI: 'ethereum:0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372?amount=0.5' + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Tron 2'} + PAYMENT_URI: ${STEALTH_PAYMENT_URI || 'ethereum:0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372?amount=0.5'} 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 source wallet, then Send. -- tapOn: 'Assets' -- waitForAnimationToEnd: - timeout: 2000 -- tapOn: 'Search Wallets' -- inputText: '${SRC_WALLET}' -- waitForAnimationToEnd: - timeout: 2000 -- tapOn: '${SRC_WALLET}' -- assertVisible: 'Receive' -- tapOn: 'Send' -- waitForAnimationToEnd: - timeout: 3000 -# An unfunded source wallet offers to buy or exchange first. + file: ../common/stealth-launch.yaml - runFlow: - when: - visible: 'Wallet Empty' - commands: - - tapOn: 'Not at this time' - - waitForAnimationToEnd: - timeout: 2000 -- assertVisible: 'Send to Address' + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} # Cross-asset destination: the source wallet cannot parse this chain's URI. -- tapOn: 'Recipient receives' -- waitForAnimationToEnd: - timeout: 2000 -- tapOn: - id: 'radioListItem_Ethereum' -- waitForAnimationToEnd: - timeout: 2000 - -# Deliver the payment URI the way a scanned QR does. -- tapOn: - id: 'addressTileScan' -- waitForAnimationToEnd: - timeout: 3000 -- tapOn: - id: 'scanModalTextInput' -- waitForAnimationToEnd: - timeout: 2000 -- tapOn: - id: 'textInputModal.textInput' -- inputText: '${PAYMENT_URI}' -- tapOn: 'Submit' -- waitForAnimationToEnd: - timeout: 4000 +- runFlow: + file: ../common/stealth-pick-recipient-asset.yaml + env: + DEST_CHAIN: 'Ethereum' +- runFlow: + file: ../common/stealth-scan-address.yaml + env: + ADDRESS: ${PAYMENT_URI} # The address came out of the URI, and its amount is the guaranteed side. - assertVisible: '.*0x1f36.*' - assertVisible: '.*0.5 ETH.*' - assertVisible: 'Guaranteed' -- takeScreenshot: stealth-qr-01-cross-chain +- takeScreenshot: maestro/screenshots/stealth-qr-01-cross-chain diff --git a/maestro/14-stealth/stealth-send.yaml b/maestro/14-stealth/stealth-send.yaml index cec7fc28cc3..cd2f8c54284 100644 --- a/maestro/14-stealth/stealth-send.yaml +++ b/maestro/14-stealth/stealth-send.yaml @@ -1,69 +1,41 @@ -# Stealth Send UI walk. +# Stealth Send UI walk: the send-to-address swap controls before any address is +# entered, so no quote is requested. # -# 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/<run>/ ): -# 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 +# Covers the "Recipient receives" selector that appears ahead of address entry, +# the Stealth Send toggle with its explainer copy and inline "Learn more" link, +# and the Destination Tag row a memo-required destination chain adds. appId: ${APP_ID} env: - APP_ID: co.edgesecure.app - PIN_DIGIT: '0' + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Tron 2'} + DEST_CHAIN: ${STEALTH_MEMO_CHAIN || 'Stellar'} 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 } + file: ../common/stealth-launch.yaml - 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 + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} -# Plain same-asset start: add-recipient affordance, no amount/fee rows yet, -# "Recipient receives" visible before address entry. +# Plain same-asset start: the recipient selector is available before an address. - assertVisible: 'Stealth Send' -- assertVisible: 'Send to Address' - assertVisible: 'Recipient receives' -- takeScreenshot: stealth-send-01-initial +- takeScreenshot: maestro/screenshots/stealth-send-01-initial -# Stealth on: the toggle card expands with the explainer + Learn more link. -- tapOn: 'Stealth Send' +# Stealth on: the toggle card expands with the explainer and Learn more link. +- runFlow: + file: ../common/stealth-toggle.yaml - assertVisible: 'Uses a route that helps obfuscate.*Learn more.*' -- takeScreenshot: stealth-send-02-stealth-on -- tapOn: 'Stealth Send' +- takeScreenshot: maestro/screenshots/stealth-send-02-stealth-on +- runFlow: + file: ../common/stealth-toggle.yaml -# Cross-asset memo chain: an XRP destination shows the Destination Tag row. -- tapOn: 'Recipient receives' -- tapOn: - text: 'XRP.*' +# A memo-required destination chain adds its tag row. +- runFlow: + file: ../common/stealth-pick-recipient-asset.yaml + env: + DEST_CHAIN: ${DEST_CHAIN} - assertVisible: 'Destination Tag' -- takeScreenshot: stealth-send-03-xrp-tag +- takeScreenshot: maestro/screenshots/stealth-send-03-memo-chain diff --git a/maestro/14-stealth/stealth-swap.yaml b/maestro/14-stealth/stealth-swap.yaml index f5b6c8abadf..469ccc30e44 100644 --- a/maestro/14-stealth/stealth-swap.yaml +++ b/maestro/14-stealth/stealth-swap.yaml @@ -1,52 +1,24 @@ -# 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/<run>/ ): -# stealth-swap-01-start amount entry with the Stealth Swap card -# stealth-swap-02-stealth-on toggle expanded with the explainer copy +# Stealth Swap UI walk: the toggle card on the Exchange scene during amount +# entry. No quote is requested. appId: ${APP_ID} env: - APP_ID: co.edgesecure.app - PIN_DIGIT: '0' + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Tron 2'} 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 } + file: ../common/stealth-launch.yaml - 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 + file: ../common/stealth-open-swap.yaml + env: + WALLET: ${SRC_WALLET} - assertVisible: 'Stealth Swap' -- takeScreenshot: stealth-swap-01-start +- takeScreenshot: maestro/screenshots/stealth-swap-01-start -# Stealth on: the toggle card expands with the explainer + Learn more link. -- tapOn: 'Stealth Swap' +- runFlow: + file: ../common/stealth-toggle.yaml + env: + TOGGLE: 'Stealth Swap' - assertVisible: 'Routes your swap through multiple.*Learn more.*' -- takeScreenshot: stealth-swap-02-stealth-on +- takeScreenshot: maestro/screenshots/stealth-swap-02-stealth-on diff --git a/maestro/14-stealth/stealth-tx-details.yaml b/maestro/14-stealth/stealth-tx-details.yaml new file mode 100644 index 00000000000..fd324b97cd1 --- /dev/null +++ b/maestro/14-stealth/stealth-tx-details.yaml @@ -0,0 +1,52 @@ +# The transaction identity of a completed stealth swap-send. +# +# Opens the newest stealth transaction in the source wallet and walks the rows +# that make it legible without exposing the recipient: the title names the +# mechanism, the real payout address is suppressed, and the exchange order +# details are shown so the user can follow their own order. +# +# Requires a stealth send to have already completed in the source wallet, so the +# default matches stealth-execute-send.yaml's source: run that one first and +# this reads the transaction it produced. Pointing this at a wallet whose +# history has not caught up yet fails on the scroll, which looks like a broken +# selector and is not one. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-wallet.yaml + env: + WALLET: ${SRC_WALLET} +# The transaction list sits below the balance card and the price chart, so the +# row is off-screen when the scene opens. +# By row id, not by text. A transaction row's accessible label is the whole row +# joined together, so a text match resolves to a container far larger than the +# row and taps its centre, which lands on nothing. `visibilityPercentage` also +# has to be a fraction: the container is taller than the viewport, so it never +# reaches the default 100. +- scrollUntilVisible: + element: + id: 'txListRow_.*Stealth.*' + direction: DOWN + timeout: 20000 + visibilityPercentage: 30 +- tapOn: + id: 'txListRow_.*Stealth.*' +- waitForAnimationToEnd: + timeout: 4000 +- takeScreenshot: maestro/screenshots/stealth-tx-01-identity +# Same fractional-visibility rule as the transaction row: the node this text +# belongs to is taller than the viewport. +- scrollUntilVisible: + element: + text: 'Exchange Status Page' + direction: DOWN + timeout: 15000 + visibilityPercentage: 30 +- takeScreenshot: maestro/screenshots/stealth-tx-02-order-details diff --git a/maestro/common/stealth-await-quote.yaml b/maestro/common/stealth-await-quote.yaml new file mode 100644 index 00000000000..e990dd81364 --- /dev/null +++ b/maestro/common/stealth-await-quote.yaml @@ -0,0 +1,39 @@ +# Waits for a swap quote to settle and for the confirm slider to arm. +# +# env LABEL the slider text to wait for: "Slide to Confirm" on a transparent +# route, "Slide to send stealthily" once Stealth is on. +# +# The rate row reads "Getting quote..." while a request is out. Waiting only for +# that string to CLEAR is not enough, because a freshly entered amount takes a +# moment to start its request and the wait would pass before the spinner ever +# appeared, so this waits for it to show up first. The slider then has to be +# scrolled to: it sits below the toggle card and a long address pushes it off +# the bottom of the scene. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `LABEL: ${LABEL || default}` would silently ignore the argument. + SLIDER_LABEL: ${LABEL || 'Slide to Confirm'} + QUOTE_TIMEOUT: ${STEALTH_QUOTE_TIMEOUT || 60000} +--- +- extendedWaitUntil: + visible: 'Getting quote...' + timeout: 10000 + optional: true +- extendedWaitUntil: + notVisible: 'Getting quote...' + timeout: ${QUOTE_TIMEOUT} +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: '${SLIDER_LABEL}' + direction: DOWN + timeout: 15000 + centerElement: true + optional: true +- extendedWaitUntil: + visible: '${SLIDER_LABEL}' + timeout: 30000 diff --git a/maestro/common/stealth-enter-address.yaml b/maestro/common/stealth-enter-address.yaml new file mode 100644 index 00000000000..2d593b4316d --- /dev/null +++ b/maestro/common/stealth-enter-address.yaml @@ -0,0 +1,19 @@ +# Types a destination address into the address tile. +# +# env ADDRESS the address, or a payment URI. A URI belonging to another chain +# is accepted: the source wallet cannot parse it, so the scene +# resolves it against the served destination chains instead. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileEnter' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'textInputModal.textInput' +- inputText: '${ADDRESS}' +- tapOn: 'Submit' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-launch.yaml b/maestro/common/stealth-launch.yaml new file mode 100644 index 00000000000..618d717c5f8 --- /dev/null +++ b/maestro/common/stealth-launch.yaml @@ -0,0 +1,64 @@ +# Shared preamble for every stealth walk: launch, clear the PIN gate, and get +# rid of the modals and notification cards a fresh launch raises. The password +# reminder card in particular floats over the bottom third of every scene and +# covers the confirm slider, so it is swiped away rather than left in place. +# +# Leaves the app on whatever scene the account last showed. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + PIN_DIGIT: ${STEALTH_PIN_DIGIT || '0'} +--- +- launchApp +- repeat: + times: 6 + while: + visible: 'Exit PIN' + commands: + # One digit per pass. The keypad unmounts the moment the login resolves + # and can drop a digit from under a tap already on its way, so a fixed run + # of four taps loses one and strands the scene. The `while` exits as soon + # as the gate clears, which also keeps an already-unlocked app from paying + # for six pointless passes, and `times` bounds it if the gate never does. + # + # PIN_DIGIT must be the account's single repeated digit, as it is for the + # roster accounts. That is what makes a retry safe: no sequence of taps on + # one digit can assemble a wrong PIN and walk the account into its lockout + # backoff. + - tapOn: + text: '${PIN_DIGIT}' + waitToSettleTimeoutMs: 900 + retryTapIfNoChange: false + optional: true +- runFlow: + when: + visible: 'Security is Our Priority' + commands: + - tapOn: 'Cancel' +- runFlow: + when: + visible: 'How Did You Discover Edge?' + commands: + - tapOn: 'Dismiss' +- runFlow: + when: + visible: 'Claim Your Web3 Handle' + commands: + - tapOn: 'Not Now' +# The password-reminder card floats over the bottom of every scene, including +# the confirm slider and the amount rows, so it is swiped away before anything +# else runs. Only this one is handled: it is the card the roster accounts show, +# and every extra visibility check costs real wall-clock on a scene that does +# not have the card. An account that shows the 2FA or IP cards instead wants +# `notifOtp` / `notifIp2Fa` added the same way. +- swipe: + from: + id: 'notifPassword' + direction: LEFT + optional: true +# The contract this subflow owes its callers: signed in, and far enough through +# the mount that the tab bar exists. Without this a caller's first tap races the +# launch and misses. +- extendedWaitUntil: + visible: 'Assets' + timeout: 40000 diff --git a/maestro/common/stealth-open-send.yaml b/maestro/common/stealth-open-send.yaml new file mode 100644 index 00000000000..15535ae7329 --- /dev/null +++ b/maestro/common/stealth-open-send.yaml @@ -0,0 +1,25 @@ +# Opens the Send scene for one wallet. +# +# env WALLET the source wallet's name, e.g. "My Sonic". +# +# An unfunded source wallet offers to buy or exchange first; that modal is +# declined so the walk reaches the Send scene either way. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- runFlow: + file: stealth-open-wallet.yaml + env: + WALLET: ${WALLET} +- tapOn: 'Send' +- waitForAnimationToEnd: + timeout: 3500 +- runFlow: + when: + visible: 'Wallet Empty' + commands: + - tapOn: 'Not at this time' + - waitForAnimationToEnd: + timeout: 2000 +- assertVisible: 'Send to Address' diff --git a/maestro/common/stealth-open-swap.yaml b/maestro/common/stealth-open-swap.yaml new file mode 100644 index 00000000000..fbb768418de --- /dev/null +++ b/maestro/common/stealth-open-swap.yaml @@ -0,0 +1,16 @@ +# Opens the Exchange (swap) scene with one wallet already chosen as the source. +# +# env WALLET the source wallet's name, e.g. "My PIVX 2". +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- runFlow: + file: stealth-open-wallet.yaml + env: + WALLET: ${WALLET} +- tapOn: 'Trade' +- tapOn: + text: 'Swap .* to/from another crypto' +- waitForAnimationToEnd: + timeout: 3500 diff --git a/maestro/common/stealth-open-wallet.yaml b/maestro/common/stealth-open-wallet.yaml new file mode 100644 index 00000000000..2bcf825de44 --- /dev/null +++ b/maestro/common/stealth-open-wallet.yaml @@ -0,0 +1,38 @@ +# Opens one wallet's transaction list from the Assets scene. +# +# env WALLET the wallet's name, e.g. "My Sonic". Matched against the row +# label, so it must be the exact name shown in the wallet list. +# +# Gates on the transaction list's own "Receive" button before returning, so a +# caller tapping "Send" next cannot misfire on the home scene's Send button. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2500 +# By id, not by placeholder: the placeholder is gone once the field holds a +# query, and the query survives a relaunch, so a previous walk's search term +# would make the field unfindable. +- runFlow: + when: + visible: + id: 'searchFooter.WalletListScene-SearchFooter.clearIcon' + commands: + - tapOn: + id: 'searchFooter.WalletListScene-SearchFooter.clearIcon' +- tapOn: + id: 'searchFooter.WalletListScene-SearchFooter.textInput' +- inputText: '${WALLET}' +# Waiting for the ROW rather than for an animation: the search footer expands on +# focus, and text typed into it before it settles is dropped silently, leaving +# the list unfiltered and the row absent. Waiting on the row is the only check +# that proves the filter actually took. +- extendedWaitUntil: + visible: + id: 'walletListRow.${WALLET}' + timeout: 20000 +- tapOn: + id: 'walletListRow.${WALLET}' +- assertVisible: 'Receive' diff --git a/maestro/common/stealth-pick-myself.yaml b/maestro/common/stealth-pick-myself.yaml new file mode 100644 index 00000000000..34415ebcb54 --- /dev/null +++ b/maestro/common/stealth-pick-myself.yaml @@ -0,0 +1,31 @@ +# Adopts one of the account's own wallets as the send destination, through the +# address tile's "Myself" affordance. +# +# env DEST_WALLET the destination wallet's name, e.g. "My Stellar". +# +# The picker lists same-asset wallets under a "Same Asset" heading first, then +# every other wallet whose asset Houdini can pay out to. The source wallet is +# never offered. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileMyself' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'walletPickerSearch.textInput' +- inputText: '${DEST_WALLET}' +# Same race as the Assets search: wait for the row the filter should produce, +# not for a fixed animation. +- extendedWaitUntil: + visible: + id: 'walletListRow.${DEST_WALLET}' + timeout: 20000 +# Rows carry their wallet name as a testID. A plain text selector would match +# the search field, which holds the same string, rather than the row. +- tapOn: + id: 'walletListRow.${DEST_WALLET}' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-pick-recipient-asset.yaml b/maestro/common/stealth-pick-recipient-asset.yaml new file mode 100644 index 00000000000..2530737bbd0 --- /dev/null +++ b/maestro/common/stealth-pick-recipient-asset.yaml @@ -0,0 +1,25 @@ +# Changes which asset the recipient receives, without choosing an address. +# +# env DEST_CHAIN the chain's DISPLAY name, exactly as the picker lists it, e.g. +# "Ethereum", "Litecoin", "Stellar". This is the currency +# plugin's `displayName`, which is not always the plugin id +# capitalised: the `ripple` plugin lists as "XRP". +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: 'Recipient receives' +- waitForAnimationToEnd: + timeout: 2500 +# The list holds every served chain, so most of it is below the fold and the +# row has to be scrolled to before it can be tapped. +- scrollUntilVisible: + element: + id: 'radioListItem_${DEST_CHAIN}' + direction: DOWN + timeout: 20000 + centerElement: true +- tapOn: + id: 'radioListItem_${DEST_CHAIN}' +- waitForAnimationToEnd: + timeout: 2500 diff --git a/maestro/common/stealth-scan-address.yaml b/maestro/common/stealth-scan-address.yaml new file mode 100644 index 00000000000..1aa7ded6f67 --- /dev/null +++ b/maestro/common/stealth-scan-address.yaml @@ -0,0 +1,24 @@ +# Delivers an address or payment URI the way a scanned QR does. +# +# env ADDRESS the address or payment URI to resolve. +# +# The simulator has no camera, so the value goes through the scan modal's +# keyboard-entry affordance, which runs the same code path a decoded QR takes. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileScan' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'scanModalTextInput' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + id: 'textInputModal.textInput' +- inputText: '${ADDRESS}' +- tapOn: 'Submit' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-set-amount.yaml b/maestro/common/stealth-set-amount.yaml new file mode 100644 index 00000000000..3a031f338d0 --- /dev/null +++ b/maestro/common/stealth-set-amount.yaml @@ -0,0 +1,46 @@ +# Sets one side of the linked swap-send amounts. +# +# env ROW which row to edit: "You send" or "Recipient gets". The edited +# side becomes the guaranteed amount and the other tracks the +# quote as an estimate. +# env AMOUNT the number to type. +# +# AMOUNT lands in whichever denomination the flip input is currently showing. +# The Send scene opens on the wallet's crypto denomination and the Exchange +# scene on fiat, and the input remembers its last mode, so a caller that needs +# a specific denomination should state the amount in the one it expects to see. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `ROW: ${ROW || default}` would silently ignore the argument. + AMOUNT_ROW: ${ROW || 'You send'} +--- +# Rows can sit above or below the fold depending on where the previous step +# left the scroll position, so the row is brought into view from either side +# before it is tapped. A `scrollUntilVisible` that is already satisfied is a +# no-op, and one that scrolls the wrong way is optional and warns. +- scrollUntilVisible: + element: + text: '${AMOUNT_ROW}' + direction: UP + timeout: 4000 + centerElement: true + optional: true +- scrollUntilVisible: + element: + text: '${AMOUNT_ROW}' + direction: DOWN + timeout: 4000 + centerElement: true + optional: true +- tapOn: '${AMOUNT_ROW}' +- waitForAnimationToEnd: + timeout: 3000 +- inputText: '${AMOUNT}' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Done' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-slide-to-confirm.yaml b/maestro/common/stealth-slide-to-confirm.yaml new file mode 100644 index 00000000000..c6177c95270 --- /dev/null +++ b/maestro/common/stealth-slide-to-confirm.yaml @@ -0,0 +1,19 @@ +# Drives the confirmation slider. +# +# The slider is a pan-gesture handler that completes only when its thumb +# reaches the far left. A coordinate swipe across the track does NOT activate +# the gesture and leaves the slider untouched, so the swipe has to originate +# from the thumb itself by id. +# +# THIS SPENDS FUNDS on a scene that holds a live quote. Only the flows tagged +# `stealth-spend` call it. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- swipe: + from: + id: 'confirmSliderThumb' + direction: LEFT +- waitForAnimationToEnd: + timeout: 20000 diff --git a/maestro/common/stealth-toggle.yaml b/maestro/common/stealth-toggle.yaml new file mode 100644 index 00000000000..4377beb004f --- /dev/null +++ b/maestro/common/stealth-toggle.yaml @@ -0,0 +1,37 @@ +# Flips the Stealth toggle. +# +# env TOGGLE "Stealth Send" on the Send scene, "Stealth Swap" on the Exchange +# scene. Defaults to the Send scene's label. +# +# Toggling always invalidates any held quote and refetches, because the private +# and transparent routes are different requests, so callers that need a live +# quote afterwards should follow with stealth-await-quote.yaml. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `TOGGLE: ${TOGGLE || default}` would silently ignore the argument. + TOGGLE_LABEL: ${TOGGLE || 'Stealth Send'} +--- +# Rows can sit above or below the fold depending on where the previous step +# left the scroll position, so the row is brought into view from either side +# before it is tapped. A `scrollUntilVisible` that is already satisfied is a +# no-op, and one that scrolls the wrong way is optional and warns. +- scrollUntilVisible: + element: + text: '${TOGGLE_LABEL}' + direction: UP + timeout: 4000 + centerElement: true + optional: true +- scrollUntilVisible: + element: + text: '${TOGGLE_LABEL}' + direction: DOWN + timeout: 4000 + centerElement: true + optional: true +- tapOn: '${TOGGLE_LABEL}' +- waitForAnimationToEnd: + timeout: 2500 From 5fe80ba8d1f3718ef9b1f3601c3044e872e09b3d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 01:03:53 -0700 Subject: [PATCH 42/56] Record the follow-up sweep in the design doc Adds the phase entry, the two decisions this round settled, the rewritten testing inventory, and the retrospective item on treating a missing test asset as a blocker rather than a task. --- src/docs/stealth-send-swap.md | 71 ++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index d63431d3c1e..b4e36d9fddc 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -224,6 +224,8 @@ Memos become `destinationTag` on order creation, which is what memo-required cha `src/mappings/houdini.ts` maps every Edge `EdgeCurrencyPluginId` to a Houdini chain `shortName`, or `null` where Houdini has no compatible chain. IBC-family chains (coreum, osmosis, axelar) are deliberately `null`: Houdini reports no `memoNeeded` flag and a permissive `^.*$` address validation for them, so their payout semantics are not trustworthy enough to offer. +A name earns a mapping only when `GET /tokens?mainnet=true` actually answers with a native coin for it, which is a stricter test than appearing in the chain list. `celo`, `fantom` and `polkadot` are absent from the token list entirely, and `ton` carries one token and no native, so all four map to `null` even though the provider publishes them. Mapping a name the provider serves no native for is worse than leaving it out: it clears `checkWhitelistedMainnetCodes` and then throws deep inside token resolution, where the user sees an error instead of the provider quietly declining and the aggregator answering with somebody else. Houdini does serve DOT, under the chain name `AssetHub` rather than `polkadot`; that remapping is untested and deliberately not made here. + ### Route selection Quotes are filtered by route type, and the caller decides which types are acceptable: @@ -538,14 +540,39 @@ Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), ## 9. Testing -Unit tests, 26 across two files, all passing: +### Unit tests + +88 across six files in three repos, all passing. + +In the gui, 65 across four files: 1. `src/__tests__/util/paymentUri.test.ts` (11): bare address passthrough, whitespace, BIP-21 with and without a query, cashaddr prefix retention, EIP-681 `pay-` prefix and `@chainId` suffix stripping, Monero `tx_amount`, non-decimal amount rejection, `value=` wei ignored, leading-slash stripping, malformed percent-encoding. -2. `src/__tests__/util/houdiniChains.test.ts` (15): single-chain detection, all-EVM fan-out for a bare `0x`, scheme resolution including a scheme differing from the pluginId, source chain never offered, unsupported chains skipped, Solana and Dogecoin and legacy Bitcoin formats, non-address text rejected, unknown scheme falling back to format matching, a mislabeled scheme not trusted, and the Cardano catch-all regression. -3. `test/core/synthetic-wallet.test.ts` in edge-core-js: the synthetic wallet's shape and bridge survival. -4. `test/houdini.test.ts` in edge-exchange-plugins, with 10 recorded fixtures: quote retrieval, order creation, and destination-tag threading against recorded provider responses. +2. `src/__tests__/util/houdiniChains.test.ts` (29): the 15 address-detection cases (single-chain detection, all-EVM fan-out for a bare `0x`, scheme resolution including a scheme differing from the pluginId, source chain never offered, unsupported chains skipped, Solana and Dogecoin and legacy Bitcoin formats, non-address text rejected, unknown scheme falling back to format matching, a mislabeled scheme not trusted, the Cardano catch-all regression), plus lookup and table invariants: `getHoudiniChain` resolving a served chain, refusing an unserved one, refusing the four chains with no mainnet native, and refusing a token id on a served chain; no duplicate plugin ids or provider chain names; every entry carrying a boolean `hasSelfPrivate`; every address regex rejecting the empty string and free text, which is the shape the Cardano catch-all had; the memo-required set; and the floor constants ordered dex < standard < private, shaped as biggystring-comparable strings, and equal to the values the provider published. +3. `src/__tests__/util/stealthSwap.test.ts` (8): every other provider disabled, a preferred provider cleared so it cannot fight the restriction, the exchange setting left alone by default, Houdini force-enabled only when the caller asks to ignore that setting, a caller's own `forceEnabled` and `disabled` entries preserved, unrelated options passed through, and an account holding Houdini alone. +4. `src/__tests__/util/swapErrorDisplay.test.ts` (17): a missing error, minimums and maximums rendered in the units of whichever side was fixed, the limit-free fallback when the bound is zero, both assets named on an unroutable pair including the swap-to-address case where the payout code has to be supplied, insufficient funds from both the typed error and the stringified shape some plugins throw, pending transactions, a geographic restriction, an unrecognized error surfacing the provider's own text, a rate limit never rewritten into a pair error, a thrown non-error stringified, and the original error preserved for the caller to log. + +In edge-core-js, 12: `test/core/synthetic-wallet.test.ts` (4) for the synthetic wallet's shape and bridge survival, and the plugin-selection truth table in `test/core/swap.test.ts` (8), which pins that a caller can reach a provider the user switched off and can never reach one it disabled itself in the same call. + +In edge-exchange-plugins, 15 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 11 offline behaviors driven from local responses. The offline half exists because a recorded fixture replays one canned answer per URL, which cannot express a specific SEQUENCE of statuses or a route mix the live API will not produce on demand. It covers native-token resolution for both spellings of "no contract address", the private-only filter declining when a pair offers transparent routes alone, a transparent route taken when privacy was not requested, private preferred over a better-priced standard route, dex routes never taken, same-asset allowed, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, a rate-limited call retried behind the window the API reports, and the retry budget running out with a message that names the rate limit rather than the route. + +Full-repo verification: `verify-repo.sh` PASSED on all three repos, covering install, prepare, lint, and the full jest and mocha suites. -Full-repo verification: `verify-repo.sh` PASSED on the gui, covering install, prepare, lint, and a full jest run of 556 tests across 92 suites with 107 snapshots. +### Maestro suite + +`maestro/14-stealth/` holds a flow per user-visible branch, built from reusable +subflows in `maestro/common/stealth-*.yaml`. The point of the split is that a +later session can drive one specific state (a live private quote on some other +pair, say) in a handful of `runFlow` steps rather than walking the simulator by +hand. `maestro/14-stealth/README.md` documents how to run the suite whole or one +flow at a time, what each env var needs from the signed-in account, and the two +gotchas that cost the most time to find: the confirm slider is a pan gesture +that ignores coordinate swipes entirely, and notification cards float over the +bottom of every scene including the slider. + +The flows drive rather than assert. Each asserts only enough to gate its next +step, because behavioral claims belong in the unit tests and in this document. +The two flows that move funds carry the `stealth-spend` tag and nothing else, so +a run of the suite by its ordinary tag can never spend. On-device, iOS simulator, account `edge-funds`, against the live provider: @@ -603,6 +630,12 @@ Queued: a feedback round from the Houdini team plus two internal reviewers, arri The review round on that work produced nine findings across five passes, and two of them were privacy holes of the same shape as the one the round set out to close. The Exchange scene's Stealth Swap restricted the provider but never demanded a private route, so a wallet-to-wallet stealth swap could be served a transparent standard route under a private label; it now sets `privacy: 'required'` on the initial quote and on the expiry re-quote. A held quote survived a switch to another wallet on the same asset, leaving an order created against the previous wallet's refund address armed and approvable. The rest were state-hygiene: the floor guard raced an in-flight quote until every run of the effect began retiring its predecessor, the two effects were clearing each other's errors, a token send to its own chain was titled "Stealth Send" when it pays out native and so crosses assets, the fixed-to fallback read a stale rate snapshot, and forward quotes claimed to be fixed. One finding was rejected in part: adopting `checkInvalidTokenIds` wholesale would have rejected same-asset swaps outright, which is the feature. +### Phase 11: the follow-up sweep + +Queued: an operator followup instructing that the previous round's own follow-up list be worked rather than carried, with three of its items answered directly. Funding is not a precondition to wait on: a wallet a test needs is funded by swapping into it, and only the spread and fees count against the budget, since the principal stays in the account. The dependency-publish item was wrong to write down at all, because this feature is pinned to our own core and exchange-plugin changes. And a product question left open for two phases was to be decided, not re-deferred. + +Shipped: the six chains whose native coin the API reports with an empty contract address were driven for real, on a wallet funded by swapping into it, ending in an executed private Stealth Send between two of them; the spending-limit question decided against the pre-Houdini arithmetic; unit coverage roughly tripled, to 88 across three repos, with the plugin's route filter, native-token matching and rate-limit backoff moved onto deterministic local responses; a composable maestro suite covering every user-visible branch; and one more fix the audit turned up, four chains the plugin still claimed that the API serves no native for. Diverged: the funding swap chose its source badly first. The provider handed back a PIVX deposit address the PIVX plugin cannot spend to, which cost an attempt and is recorded below as a provider defect rather than ours. + ### Upstream, on Houdini's side Not our work, tracked so it is not rediscovered: @@ -610,6 +643,8 @@ Not our work, tracked so it is not rediscovered: - [ ] **Exact-out fixed-rate min-max bug.** Houdini acknowledged it and has developers on it. Re-verify exact-out min-max behavior after they ship. Do **not** build a workaround in this scope: the [fixed-to fallback](#availability-fallbacks) already degrades gracefully, and a workaround would have to be unpicked. - [ ] **Private routes on fixed-rate quotes.** Until these exist, a privacy request priced by the receive side cannot be served, which is why the fallback re-prices from the send side. Worth re-checking whenever their routing changes. - [ ] **Token destinations.** Blocked on token payout metadata through the swap plugin surface, not on us; `getHoudiniChain` returning undefined for a non-null `tokenId` is the single line that gates every surface. +- [ ] **Rate-limit headers, and which tier these credentials are on.** Free tier is ruled out behaviorally (six quote calls in 27 seconds drew no 429 against a 5/min free limit), but the API returns no `x-ratelimit-*` or `retry-after` header on success, so "pro" cannot be read off a response. At 500 quote requests a minute the app's per-user traffic is irrelevant; at 5 it is not. Worth both confirming the tier and asking them to expose the headers so a client can self-pace instead of guessing. +- [ ] **Deposit addresses that the source chain rejects.** A PIVX order returns a deposit address starting `EXMD…`, which Edge's PIVX plugin refuses with "unable to convert address to script pubkey", so the send fails at spend time with an opaque wallet error. Reproduced twice, on separate rounds. Until they fix it, the plugin could validate `order.depositAddress` against the source chain's own rules and throw a provider-named error instead; see the deferred-work table. ### Deferred work @@ -621,7 +656,9 @@ Not our work, tracked so it is not rediscovered: | `SwapDetailsCard` on a stealth send's tx detail | Reversed, now shipped | The card did not need a payout wallet, only the payout asset's currency config, which the saved action already carries. See [Transaction identity](#transaction-identity). | | PIN spending limits on stealth sends | Reversed, now shipped | The original reasoning compared this to a swap. It is a send. See [Gate swap-send behind the PIN spending limit](#gate-swap-send-behind-the-pin-spending-limit). | | EIP-681 `value=` (wei) amounts | Deferred | Address is accepted, amount ignored; no reported user impact yet. | -| Whether the PIN spending limit should count fees | Deferred | A product question spanning both send paths, not a defect in this one; `origin/develop` computes it the same way for every plain send. | +| Validate provider deposit addresses against the source chain | Deferred | A PIVX order returns an address the PIVX plugin cannot spend to, and it surfaces as an opaque wallet error rather than a provider problem. A check after `asHoudiniOrder` would name the real cause; any chain the provider gets wrong fails the same way. | +| HoudiniSwap provider icon | Deferred | No `pluginIdIcons` entry exists, so the provider's rows render without a logo where every other provider has one. Needs the asset uploaded to the CDN. | +| Whether the PIN spending limit should count fees | Decided: it does not | Settled against the pre-Houdini behavior rather than carried as a question. See [Match the pre-existing spending-limit arithmetic](#match-the-pre-existing-spending-limit-arithmetic). | ## 11. Decisions @@ -787,6 +824,26 @@ Rejected: **honoring the flag on both paths**, which makes a send feature vanish Reopen if: Houdini becomes one of several privacy providers, at which point the send path needs its own notion of which to use and the question changes shape. +### Match the pre-existing spending-limit arithmetic + +Chosen: the PIN spending limit compares the sum of the spend targets against the limit and ignores the network fee, on the swap-send path exactly as on every plain send. + +Evidence: `origin/develop` computes it this way for every send in the app. The limit is a setting about how much a user may move without re-authenticating, and users read the amount they typed as the amount they are moving. Making one path add a fee the user never entered would give the same setting two meanings depending on which screen they were on, and the disagreement would surface as an unexplained PIN prompt just under a round number. + +Rejected: **adding the fee on the swap-send path only**, which is the version that creates the inconsistency. **Changing every path to include the fee**, which is a real product change to a security setting, affects flows this work does not touch, and would want its own task rather than riding in on a private-send feature. + +Reopen if: the limit is deliberately redefined as total wallet outflow, in which case both paths change together. + +### Map a chain only when the provider serves a native coin for it + +Chosen: `celo`, `fantom`, `polkadot` and `ton` map to `null` in the plugin's chain table, alongside the chains that were never mapped. + +Evidence: `GET /tokens?mainnet=true` has no entry at all for the first three, and the `ton` chain carries a single token with no native coin. A mapped name clears `checkWhitelistedMainnetCodes` and then fails deep inside token resolution, so the user gets a thrown error where a `null` would have made the provider decline cleanly and let the aggregator answer with somebody else. Houdini does serve DOT, but under the chain name `AssetHub`; remapping Edge's `polkadot` there is untested and deliberately left alone. + +Rejected: **leaving them mapped** because they appear in the provider's chain list, which is what produced the failure: the chain list and the token list disagree, and the token list is the one that decides whether a quote can be built. + +Reopen if: the provider adds natives for those chains, or exposes chain metadata through the swap plugin surface so the table can stop being a snapshot. + ## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) @@ -815,6 +872,8 @@ Reopen if: Houdini becomes one of several privacy providers, at which point the 5. **Undocumented intent regressed in code.** The phase 2 followup made send-to-any Houdini-exclusive, but the change was autosquashed into the feature commit and neither the PR body nor this document was updated. Phase 5 then read the unconditional restriction as drift against the documented fan-out and "fixed" it, shipping a live regression that phase 6 had to revert on operator correction. The lesson: an operator-directed behavior change must update the PR body and this document in the same turn it lands, because both are treated as behavior contracts by later work, and a squashed history cannot testify to intent. 6. **The chain table was never checked against the routes it claims.** Item 3 caught bad regexes by reading them. Nobody asked the prior question: does the provider serve each of these chains at all? Phase 10 asked it and got two answers, both bad. Four chains (`celo`, `fantom`, `polkadot`, `ton`) have no mainnet native in the API, so every quote to them threw while the UI offered them. Six more (`algorand`, `ecash`, `hyperevm`, `sonic`, `stellar`, `zcash`) had natives the plugin could never find, because the API returns `address: ""` for those chains rather than `null` and the native lookup tested `address == null`, which is false for an empty string. Ten of 38 advertised destinations were dead, and nothing in the type system, the tests, or a code review could have seen it: the defect lives in the agreement between a snapshot and a live API. A table transcribed from an external source needs a periodic reconciliation against that source, and every field the code branches on needs one live case exercising each branch. +7. **Missing funds were treated as a precondition instead of a task.** Phase 10 found the six empty-string chains, fixed them, confirmed the fix was present in the installed bundle, and then wrote "drive them once one is funded" as a follow-up, because none of those assets held a balance. That was the wrong shape of answer. Funding a wallet is a swap away, the principal stays inside the account, and only the spread and network fee are spent, so "unfunded" is a step to perform rather than a blocker to report. The cost of getting it wrong was a full extra phase before the highest-value fix of the round was exercised at all. Phase 11 funded Sonic by swapping into it and executed a private send between two of the six, which took under twenty minutes and about a dollar. The general rule this leaves behind: when a test needs an asset the account does not hold, acquire it and continue, and treat any follow-up phrased as "once X is funded" as a task that was skipped rather than one that was blocked. + ### What held - The `toAddressInfo` seam. Three phases of GUI change and a user-reported bug fix landed without a single change to the core contract or the plugin's destination handling. From fcdfe52347489c675d91d9e36dc72f843c6957ae Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 13:32:46 -0700 Subject: [PATCH 43/56] Correct the chain-mapping claim in the design doc Both an unmapped chain and a chain whose token lookup finds nothing raise SwapCurrencyError, so the provider was declining correctly either way. The cost was an uncached miss, not a user-visible failure. --- src/__tests__/util/houdiniChains.test.ts | 6 ++++-- src/docs/stealth-send-swap.md | 22 ++++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/__tests__/util/houdiniChains.test.ts b/src/__tests__/util/houdiniChains.test.ts index 3def88fc53f..729079c1a59 100644 --- a/src/__tests__/util/houdiniChains.test.ts +++ b/src/__tests__/util/houdiniChains.test.ts @@ -162,8 +162,10 @@ describe('getHoudiniChain', () => { }) it('returns nothing for the chains with no mainnet native coin', () => { - // Houdini publishes no mainnet native for these, so every quote naming one - // threw while the picker still offered it as a destination. + // Houdini publishes no mainnet native for these, so a quote naming one can + // never be built. The plugin declines them at runtime either way; keeping + // them out of this table is about not OFFERING a destination the provider + // cannot pay out to. for (const pluginId of ['celo', 'fantom', 'polkadot', 'ton']) { expect(getHoudiniChain(pluginId, null)).toBeUndefined() } diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index b4e36d9fddc..69dc8564f07 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -224,7 +224,9 @@ Memos become `destinationTag` on order creation, which is what memo-required cha `src/mappings/houdini.ts` maps every Edge `EdgeCurrencyPluginId` to a Houdini chain `shortName`, or `null` where Houdini has no compatible chain. IBC-family chains (coreum, osmosis, axelar) are deliberately `null`: Houdini reports no `memoNeeded` flag and a permissive `^.*$` address validation for them, so their payout semantics are not trustworthy enough to offer. -A name earns a mapping only when `GET /tokens?mainnet=true` actually answers with a native coin for it, which is a stricter test than appearing in the chain list. `celo`, `fantom` and `polkadot` are absent from the token list entirely, and `ton` carries one token and no native, so all four map to `null` even though the provider publishes them. Mapping a name the provider serves no native for is worse than leaving it out: it clears `checkWhitelistedMainnetCodes` and then throws deep inside token resolution, where the user sees an error instead of the provider quietly declining and the aggregator answering with somebody else. Houdini does serve DOT, under the chain name `AssetHub` rather than `polkadot`; that remapping is untested and deliberately not made here. +The table answers what Houdini calls a chain, not whether Houdini serves it. Those are different questions with different lifetimes: the name is stable, while what is served changes whenever the provider adds or drops a native coin. Whether a chain has a tradable native is discovered at runtime by `resolveTokenId`, which declines with the same `SwapCurrencyError` `checkWhitelistedMainnetCodes` raises, so a mapped-but-unserved name costs one memoized lookup rather than a wrong answer. `celo`, `fantom` and `polkadot` are absent from `GET /tokens?mainnet=true` entirely and `ton` carries one token and no native, and all four decline through that path without the table having to say so. Houdini does serve DOT, under the chain name `AssetHub` rather than `polkadot`; that remapping is untested and deliberately not made here. + +The memoization is what makes this affordable. `resolveTokenId` caches misses as well as hits, so an unserved chain is asked about once per session instead of once per quote. A lookup the provider FAILED to answer is deliberately not cached: a rate limit or a server error says nothing about whether the chain is served, and caching it would turn one bad minute into a chain that stays dead for the rest of the session. ### Route selection @@ -542,7 +544,7 @@ Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), ### Unit tests -88 across six files in three repos, all passing. +Six files across three repos hold 94 tests, all passing. In the gui, 65 across four files: @@ -553,7 +555,7 @@ In the gui, 65 across four files: In edge-core-js, 12: `test/core/synthetic-wallet.test.ts` (4) for the synthetic wallet's shape and bridge survival, and the plugin-selection truth table in `test/core/swap.test.ts` (8), which pins that a caller can reach a provider the user switched off and can never reach one it disabled itself in the same call. -In edge-exchange-plugins, 15 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 11 offline behaviors driven from local responses. The offline half exists because a recorded fixture replays one canned answer per URL, which cannot express a specific SEQUENCE of statuses or a route mix the live API will not produce on demand. It covers native-token resolution for both spellings of "no contract address", the private-only filter declining when a pair offers transparent routes alone, a transparent route taken when privacy was not requested, private preferred over a better-priced standard route, dex routes never taken, same-asset allowed, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, a rate-limited call retried behind the window the API reports, and the retry budget running out with a message that names the rate limit rather than the route. +In edge-exchange-plugins, 17 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 13 offline behaviors driven from local responses. The offline half exists because a recorded fixture replays one canned answer per URL, which cannot express a specific SEQUENCE of statuses or a route mix the live API will not produce on demand. It covers native-token resolution for both spellings of "no contract address", the private-only filter declining when a pair offers transparent routes alone, a transparent route taken when privacy was not requested, private preferred over a better-priced standard route, dex routes never taken, same-asset allowed, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, a rate-limited call retried behind the window the API reports, the retry budget running out with a message that names the rate limit rather than the route, an unserved chain asked about once and then remembered, and a lookup the provider failed to answer deliberately left uncached. Full-repo verification: `verify-repo.sh` PASSED on all three repos, covering install, prepare, lint, and the full jest and mocha suites. @@ -634,7 +636,7 @@ The review round on that work produced nine findings across five passes, and two Queued: an operator followup instructing that the previous round's own follow-up list be worked rather than carried, with three of its items answered directly. Funding is not a precondition to wait on: a wallet a test needs is funded by swapping into it, and only the spread and fees count against the budget, since the principal stays in the account. The dependency-publish item was wrong to write down at all, because this feature is pinned to our own core and exchange-plugin changes. And a product question left open for two phases was to be decided, not re-deferred. -Shipped: the six chains whose native coin the API reports with an empty contract address were driven for real, on a wallet funded by swapping into it, ending in an executed private Stealth Send between two of them; the spending-limit question decided against the pre-Houdini arithmetic; unit coverage roughly tripled, to 88 across three repos, with the plugin's route filter, native-token matching and rate-limit backoff moved onto deterministic local responses; a composable maestro suite covering every user-visible branch; and one more fix the audit turned up, four chains the plugin still claimed that the API serves no native for. Diverged: the funding swap chose its source badly first. The provider handed back a PIVX deposit address the PIVX plugin cannot spend to, which cost an attempt and is recorded below as a provider defect rather than ours. +Shipped: the six chains whose native coin the API reports with an empty contract address were driven for real, on a wallet funded by swapping into it, ending in an executed private Stealth Send between two of them; the spending-limit question decided against the pre-Houdini arithmetic; unit coverage roughly tripled, to 88 across three repos, with the plugin's route filter, native-token matching and rate-limit backoff moved onto deterministic local responses; a composable maestro suite covering every user-visible branch; and the token lookup taught to remember a miss, so a chain the provider serves no native for declines on one call per session instead of one per quote. Diverged twice. The funding swap chose its source badly first: the provider handed back a PIVX deposit address the PIVX plugin cannot spend to, which cost an attempt and is recorded below as a provider defect rather than ours. And the audit's own finding was mis-fixed. Four chains the provider serves no native for were hardcoded to `null`, on the reasoning that a mapped name surfaced an error where a `null` would decline; both paths raise the same `SwapCurrencyError`, so the reasoning was wrong and the fix was a snapshot of a live fact. A follow-up round reverted it and cached the token-lookup miss instead, which costs the same single call without asserting anything the provider might change. ### Upstream, on Houdini's side @@ -834,15 +836,15 @@ Rejected: **adding the fee on the swap-send path only**, which is the version th Reopen if: the limit is deliberately redefined as total wallet outflow, in which case both paths change together. -### Map a chain only when the provider serves a native coin for it +### Let the token lookup decide what is served, and cache its misses -Chosen: `celo`, `fantom`, `polkadot` and `ton` map to `null` in the plugin's chain table, alongside the chains that were never mapped. +Chosen: the chain table stays a pure name map, and whether a chain is actually served is answered at runtime by `resolveTokenId`, which memoizes misses as well as hits. -Evidence: `GET /tokens?mainnet=true` has no entry at all for the first three, and the `ton` chain carries a single token with no native coin. A mapped name clears `checkWhitelistedMainnetCodes` and then fails deep inside token resolution, so the user gets a thrown error where a `null` would have made the provider decline cleanly and let the aggregator answer with somebody else. Houdini does serve DOT, but under the chain name `AssetHub`; remapping Edge's `polkadot` there is untested and deliberately left alone. +Evidence: both routes already end in the same place. `checkWhitelistedMainnetCodes` throws `SwapCurrencyError` for an unmapped chain, and `resolveTokenId` finding no match throws the same error, so the provider declines correctly either way and the aggregator moves on. The only real difference was cost: the miss was never cached, so a chain the provider serves no native for spent a `GET /tokens` call on every quote against a rate-limited API. Caching the miss removes that, and it removes the reason to encode servedness in the table at all. -Rejected: **leaving them mapped** because they appear in the provider's chain list, which is what produced the failure: the chain list and the token list disagree, and the token list is the one that decides whether a quote can be built. +Rejected: **hardcoding `null` for `celo`, `fantom`, `polkadot` and `ton`**, which is what shipped first. It reads as a fix but it is a snapshot of a live fact, so it rots the day Houdini adds a native and nothing tells us. The claim recorded for it was also wrong: it said a mapped-but-unserved chain surfaced an error where a `null` would have declined, and both paths raise the same error. **Caching failed lookups too**, which is cheaper still and wrong: a 429 would mark a perfectly good chain dead for the session. -Reopen if: the provider adds natives for those chains, or exposes chain metadata through the swap plugin surface so the table can stop being a snapshot. +Reopen if: the provider exposes chain metadata through the swap plugin surface, which would let the name map itself stop being a snapshot. ## 12. References @@ -870,7 +872,7 @@ Reopen if: the provider adds natives for those chains, or exposes chain metadata 3. **The provider's published metadata was assumed correct.** The chain table was written as a faithful snapshot. Two of its 38 regexes are defective, one of them so permissive it matches every string. Snapshotting external validation data needs an audit pass, not just a transcription. 4. **PIVX payouts are unusable and the design cannot tell.** A PIVX order returns a deposit address that is not a PIVX address (`EXMD…` rather than base58 `D…`), so the send fails at spend time with an opaque wallet error. Reproduced directly against the API with the plugin's own payload shape. The design has no validation of provider-returned deposit addresses against the from-chain. 5. **Undocumented intent regressed in code.** The phase 2 followup made send-to-any Houdini-exclusive, but the change was autosquashed into the feature commit and neither the PR body nor this document was updated. Phase 5 then read the unconditional restriction as drift against the documented fan-out and "fixed" it, shipping a live regression that phase 6 had to revert on operator correction. The lesson: an operator-directed behavior change must update the PR body and this document in the same turn it lands, because both are treated as behavior contracts by later work, and a squashed history cannot testify to intent. -6. **The chain table was never checked against the routes it claims.** Item 3 caught bad regexes by reading them. Nobody asked the prior question: does the provider serve each of these chains at all? Phase 10 asked it and got two answers, both bad. Four chains (`celo`, `fantom`, `polkadot`, `ton`) have no mainnet native in the API, so every quote to them threw while the UI offered them. Six more (`algorand`, `ecash`, `hyperevm`, `sonic`, `stellar`, `zcash`) had natives the plugin could never find, because the API returns `address: ""` for those chains rather than `null` and the native lookup tested `address == null`, which is false for an empty string. Ten of 38 advertised destinations were dead, and nothing in the type system, the tests, or a code review could have seen it: the defect lives in the agreement between a snapshot and a live API. A table transcribed from an external source needs a periodic reconciliation against that source, and every field the code branches on needs one live case exercising each branch. +6. **The chain table was never checked against the routes it claims.** Item 3 caught bad regexes by reading them. Nobody asked the prior question: does the provider serve each of these chains at all? Phase 10 asked it and got two answers, both bad. Four chains (`celo`, `fantom`, `polkadot`, `ton`) have no mainnet native in the API, so every quote naming one spent a token lookup to be told nothing, on a rate-limited API, with the miss uncached. Six more (`algorand`, `ecash`, `hyperevm`, `sonic`, `stellar`, `zcash`) had natives the plugin could never find, because the API returns `address: ""` for those chains rather than `null` and the native lookup tested `address == null`, which is false for an empty string. Ten of 38 advertised destinations were dead, and nothing in the type system, the tests, or a code review could have seen it: the defect lives in the agreement between a snapshot and a live API. A table transcribed from an external source needs a periodic reconciliation against that source, and every field the code branches on needs one live case exercising each branch. 7. **Missing funds were treated as a precondition instead of a task.** Phase 10 found the six empty-string chains, fixed them, confirmed the fix was present in the installed bundle, and then wrote "drive them once one is funded" as a follow-up, because none of those assets held a balance. That was the wrong shape of answer. Funding a wallet is a swap away, the principal stays inside the account, and only the spread and network fee are spent, so "unfunded" is a step to perform rather than a blocker to report. The cost of getting it wrong was a full extra phase before the highest-value fix of the round was exercised at all. Phase 11 funded Sonic by swapping into it and executed a private send between two of the six, which took under twenty minutes and about a dollar. The general rule this leaves behind: when a test needs an asset the account does not hold, acquire it and continue, and treat any follow-up phrased as "once X is funded" as a task that was skipped rather than one that was blocked. From 571da743a384962142f06459d43cda287499fb16 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 15:08:54 -0700 Subject: [PATCH 44/56] Show the provider floor in the user's own fiat The minimums are stated in USD because that is the unit the provider enforces them in, but the message wrote the raw figure with a dollar sign, which is the wrong symbol and the wrong number for anyone not on USD. Converted through the same rates the scene already uses and formatted by the shared helper. --- src/components/scenes/SendScene2.tsx | 34 +++++++++++++++++++++++++--- src/locales/en_US.ts | 4 ++-- src/locales/strings/enUS.json | 4 ++-- src/selectors/WalletSelectors.ts | 22 ++++++++++++++---- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index fe839f1f37a..9b04ef78794 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -36,10 +36,12 @@ import { playSendSound } from '../../actions/SoundActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { FIO_STR, + getFiatSymbol, getSpecialCurrencyInfo } from '../../constants/WalletAndCurrencyConstants' import { useAsyncEffect } from '../../hooks/useAsyncEffect' import { useDisplayDenom } from '../../hooks/useDisplayDenom' +import { formatFiatString } from '../../hooks/useFiatText' import { useHandler } from '../../hooks/useHandler' import { useIconColor } from '../../hooks/useIconColor' import { useMount } from '../../hooks/useMount' @@ -49,7 +51,8 @@ import { lstrings } from '../../locales/strings' import { getExchangeDenom } from '../../selectors/DenominationSelectors' import { convertCurrency, - getExchangeRate + getExchangeRate, + getFiatRate } from '../../selectors/WalletSelectors' import { config } from '../../theme/appConfig' import { useState } from '../../types/reactHooks' @@ -560,6 +563,29 @@ const SendComponent: React.FC<Props> = props => { const belowStandardFloor = orderUsdValue != null && lt(orderUsdValue, HOUDINI_MIN_USD.standard) + /** + * A provider floor, stated in USD, rendered in the user's own display fiat. + * + * The floors are USD because that is the unit Houdini enforces them in, but + * the user reads every other amount on this scene in their display currency, + * so writing the raw figure with a dollar sign is wrong twice for anyone not + * on USD: the wrong symbol, and a number that is not the threshold in their + * currency. Converted through the same rates the rest of the scene uses and + * formatted by the same helper, so it reads like every other fiat figure. + * + * With no rate between USD and the display fiat there is nothing honest to + * convert to, so the USD figure is shown carrying its OWN symbol rather than + * a wrong number wearing the user's. + */ + const formatUsdFloor = (usdFloor: string): string => { + const rate = getFiatRate(exchangeRates, 'iso:USD', defaultIsoFiat) + const isoFiat = rate === 0 ? 'iso:USD' : defaultIsoFiat + const amount = rate === 0 ? usdFloor : mul(usdFloor, String(rate)) + return `${getFiatSymbol(isoFiat)}${formatFiatString({ + fiatAmount: amount + })}` + } + // The PIN spending limit gates every outbound flow, swap-send included. // DERIVED, not effect-written: it used to be state refreshed inside the // makeSpend effect, which runs AFTER the render that already holds a live @@ -650,7 +676,7 @@ const SendComponent: React.FC<Props> = props => { : belowPrivateFloor ? sprintf( lstrings.stealth_below_private_minimum_1s, - HOUDINI_MIN_USD.private + formatUsdFloor(HOUDINI_MIN_USD.private) ) : pairCaps.stealth === false ? lstrings.stealth_route_unavailable_info @@ -2988,7 +3014,9 @@ const SendComponent: React.FC<Props> = props => { stealth ? lstrings.stealth_below_private_minimum_1s : lstrings.stealth_below_standard_minimum_1s, - stealth ? HOUDINI_MIN_USD.private : HOUDINI_MIN_USD.standard + formatUsdFloor( + stealth ? HOUDINI_MIN_USD.private : HOUDINI_MIN_USD.standard + ) ) ) ) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 5d889b24f3c..2544859f9a4 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1641,9 +1641,9 @@ const strings = { stealth_self_private_unsupported_1s: 'Private routing is not available when sending %1$s to itself.', stealth_below_private_minimum_1s: - 'Private routing needs at least $%1$s. Enter a larger amount to send privately.', + 'Private routing needs at least %1$s. Enter a larger amount to send privately.', stealth_below_standard_minimum_1s: - 'The provider needs at least $%1$s to route this send. Enter a larger amount.', + 'The provider needs at least %1$s to route this send. Enter a larger amount.', stealth_fixed_to_unavailable_toast: 'The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.', stealth_fixed_to_fallback_title: 'Receive amount is an estimate', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index a34cc47299b..21029594684 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1295,8 +1295,8 @@ "stealth_swap_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Swap has been turned off.", "stealth_route_unavailable_info": "Private routing is not available for this pair right now.", "stealth_self_private_unsupported_1s": "Private routing is not available when sending %1$s to itself.", - "stealth_below_private_minimum_1s": "Private routing needs at least $%1$s. Enter a larger amount to send privately.", - "stealth_below_standard_minimum_1s": "The provider needs at least $%1$s to route this send. Enter a larger amount.", + "stealth_below_private_minimum_1s": "Private routing needs at least %1$s. Enter a larger amount to send privately.", + "stealth_below_standard_minimum_1s": "The provider needs at least %1$s to route this send. Enter a larger amount.", "stealth_fixed_to_unavailable_toast": "The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.", "stealth_fixed_to_fallback_title": "Receive amount is an estimate", "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", diff --git a/src/selectors/WalletSelectors.ts b/src/selectors/WalletSelectors.ts index bb74ba67d20..f927e6f86b5 100644 --- a/src/selectors/WalletSelectors.ts +++ b/src/selectors/WalletSelectors.ts @@ -46,18 +46,24 @@ export const getExchangeRate = ( return foundRate } -export const getFiatExchangeRate = ( - state: RootState, +/** + * Fiat-to-fiat rate off a rates snapshot, for callers that already hold + * `GuiExchangeRates` and have no reason to reach for the whole store. Returns + * `0` when neither a direct rate nor a USD pivot is known, which callers must + * treat as "no rate" rather than as a real conversion. + */ +export const getFiatRate = ( + exchangeRates: GuiExchangeRates, fromIsoCode: string, toIsoCode: string ): number => { // Use the direct rate if we have it: - const rate = state.exchangeRates.fiat[fromIsoCode]?.[toIsoCode] + const rate = exchangeRates.fiat[fromIsoCode]?.[toIsoCode] if (rate?.current != null) return rate.current // Convert via USD as a fallback: - const fromUSD = state.exchangeRates.fiat?.[fromIsoCode]?.['iso:USD']?.current - const toUSD = state.exchangeRates.fiat?.[toIsoCode]?.['iso:USD']?.current + const fromUSD = exchangeRates.fiat?.[fromIsoCode]?.['iso:USD']?.current + const toUSD = exchangeRates.fiat?.[toIsoCode]?.['iso:USD']?.current if (fromUSD == null) return 0 if (toUSD == null || toUSD === 0) return 0 @@ -65,6 +71,12 @@ export const getFiatExchangeRate = ( return foundRate } +export const getFiatExchangeRate = ( + state: RootState, + fromIsoCode: string, + toIsoCode: string +): number => getFiatRate(state.exchangeRates, fromIsoCode, toIsoCode) + export const convertCurrency = ( exchangeRates: GuiExchangeRates, pluginId: string, From 350a50828adda86ebc5b92c5908a3d1daf802d91 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 15:49:19 -0700 Subject: [PATCH 45/56] Clear the destination address when the source wallet changes Switching to another wallet already cleared the destination chain, quote and toggle, but left the entered payout address, so the scene dropped back to plain-send still showing an address the new wallet cannot pay. Also relaxes the Hedera pattern: account ids are assigned sequentially, so early ones are short and the provider's four-digit floor rejects real destinations. --- src/__tests__/util/houdiniChains.test.ts | 11 +++++++++++ src/components/scenes/SendScene2.tsx | 10 ++++++++++ src/util/houdiniChains.ts | 7 +++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/__tests__/util/houdiniChains.test.ts b/src/__tests__/util/houdiniChains.test.ts index 729079c1a59..a7e9c1ddf8b 100644 --- a/src/__tests__/util/houdiniChains.test.ts +++ b/src/__tests__/util/houdiniChains.test.ts @@ -227,6 +227,17 @@ describe('HOUDINI_CHAINS table', () => { expect(memoChains).not.toContain('bitcoin') }) + it('accepts a short Hedera account id', () => { + // Hedera ids are assigned sequentially, so the early ones are genuinely + // short. The provider's own pattern demands four digits and rejects them. + const hedera = getHoudiniChain('hedera', null) + expect(hedera).toBeDefined() + if (hedera == null) return + expect(isValidHoudiniAddress(hedera, '0.0.98')).toEqual(true) + expect(isValidHoudiniAddress(hedera, '0.0.1234567')).toEqual(true) + expect(isValidHoudiniAddress(hedera, '0X0Y12345')).toEqual(false) + }) + it('accepts and rejects addresses on a chain that needs a memo', () => { const ripple = getHoudiniChain('ripple', null) expect(ripple).toBeDefined() diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 9b04ef78794..a096e75ebbf 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -1343,6 +1343,16 @@ const SendComponent: React.FC<Props> = props => { setRateStarvedFallback(false) setStealth(false) setRouteCaps({}) + // The address goes with them. Clearing the destination CHAIN while + // leaving the address behind drops the scene back into plain-send + // mode still displaying a foreign-chain address, which the new source + // wallet cannot pay: the user is then one slide away from a send that + // can only fail. + const [target] = spendInfo.spendTargets + target.publicAddress = undefined + target.nativeAmount = undefined + target.otherParams = undefined + setSpendInfo({ ...spendInfo, memos: [] }) } }) .catch((error: unknown) => { diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts index b1e27c32102..57912148b8d 100644 --- a/src/util/houdiniChains.ts +++ b/src/util/houdiniChains.ts @@ -155,8 +155,11 @@ export const HOUDINI_CHAINS: HoudiniChain[] = [ memoNeeded: true, hasSelfPrivate: true, // Dots escaped: unescaped they are wildcards, so the pattern accepted - // anything shaped like 0X0Y12345 as a Hedera account id. - addressValidation: /^0\.0\.[0-9]{4,40}$/ + // anything shaped like 0X0Y12345 as a Hedera account id. The length bound + // is 1 and not the provider's 4: account ids are assigned sequentially, so + // early ones are genuinely short (0.0.98) and a four-digit floor rejects + // real destinations. + addressValidation: /^0\.0\.[0-9]{1,20}$/ }, { pluginId: 'hyperevm', From 8c113b971cce395b52dc7ef94208ebe7dc7bb7af Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 17:54:09 -0700 Subject: [PATCH 46/56] Record phase 12 and the retry-expiry decision in the TDD --- src/docs/stealth-send-swap.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 69dc8564f07..69a43211ab6 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -544,7 +544,7 @@ Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), ### Unit tests -Six files across three repos hold 94 tests, all passing. +Six files across three repos hold 100 tests, all passing. In the gui, 65 across four files: @@ -555,7 +555,7 @@ In the gui, 65 across four files: In edge-core-js, 12: `test/core/synthetic-wallet.test.ts` (4) for the synthetic wallet's shape and bridge survival, and the plugin-selection truth table in `test/core/swap.test.ts` (8), which pins that a caller can reach a provider the user switched off and can never reach one it disabled itself in the same call. -In edge-exchange-plugins, 17 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 13 offline behaviors driven from local responses. The offline half exists because a recorded fixture replays one canned answer per URL, which cannot express a specific SEQUENCE of statuses or a route mix the live API will not produce on demand. It covers native-token resolution for both spellings of "no contract address", the private-only filter declining when a pair offers transparent routes alone, a transparent route taken when privacy was not requested, private preferred over a better-priced standard route, dex routes never taken, same-asset allowed, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, a rate-limited call retried behind the window the API reports, the retry budget running out with a message that names the rate limit rather than the route, an unserved chain asked about once and then remembered, and a lookup the provider failed to answer deliberately left uncached. +In edge-exchange-plugins, 23 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 19 offline behaviors driven from local responses. The offline half exists because a recorded fixture replays one canned answer per URL, which cannot express a specific SEQUENCE of statuses or a route mix the live API will not produce on demand. It covers native-token resolution for both spellings of "no contract address", the private-only filter declining when a pair offers transparent routes alone, a transparent route taken when privacy was not requested, private preferred over a better-priced standard route, dex routes never taken, same-asset allowed, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, a rate-limited call retried behind the window the API reports, the retry budget running out with a message that names the rate limit rather than the route, an unserved chain asked about once and then remembered, a lookup the provider failed to answer deliberately left uncached and reported as a provider failure rather than an unsupported pair, both legs of a same-asset quote sharing one lookup, a reported retry window longer than our own cap honored while the cap still bounds growth when none is reported, a backoff that would outlive its quote failing fast instead, the Unix-seconds `validUntil` the API actually sends parsed correctly, a max quote creating one exchange rather than two, and a `VALIDATION_ERROR` surfacing its field message instead of the generic "Validation Failed". Full-repo verification: `verify-repo.sh` PASSED on all three repos, covering install, prepare, lint, and the full jest and mocha suites. @@ -638,6 +638,14 @@ Queued: an operator followup instructing that the previous round's own follow-up Shipped: the six chains whose native coin the API reports with an empty contract address were driven for real, on a wallet funded by swapping into it, ending in an executed private Stealth Send between two of them; the spending-limit question decided against the pre-Houdini arithmetic; unit coverage roughly tripled, to 88 across three repos, with the plugin's route filter, native-token matching and rate-limit backoff moved onto deterministic local responses; a composable maestro suite covering every user-visible branch; and the token lookup taught to remember a miss, so a chain the provider serves no native for declines on one call per session instead of one per quote. Diverged twice. The funding swap chose its source badly first: the provider handed back a PIVX deposit address the PIVX plugin cannot spend to, which cost an attempt and is recorded below as a provider defect rather than ours. And the audit's own finding was mis-fixed. Four chains the provider serves no native for were hardcoded to `null`, on the reasoning that a mapped name surfaced an error where a `null` would decline; both paths raise the same `SwapCurrencyError`, so the reasoning was wrong and the fix was a snapshot of a live fact. A follow-up round reverted it and cached the token-lookup miss instead, which costs the same single call without asserting anything the provider might change. +### Phase 12: the suite green, and a reviewer that came back + +Queued: an operator followup with three items in its own order. Replace the hardcoded chain nulls with the dynamic decline the previous round should have written. Correct the overstated claim recorded alongside that fix. And get every flow in `maestro/14-stealth/` passing end to end, swapping only for assets a flow actually needs. Two more arrived mid-turn: the send scene's provider-floor card was hardcoded to dollars, and the reviewer bot had run out of quota, which the harness should handle without treating a silent reviewer as a clean one. + +Shipped: all ten flows pass, verified one at a time with the driver killed between, because a failing flow takes the driver down and every later flow then reports a connection error that reads as a suite-wide break. Getting there fixed seven defects in the suite rather than in the app: a search-field focus race that dropped the typed filter, rows never satisfying the default full-visibility requirement, a transaction row whose accessible label is the whole row joined, so a text match resolved to a container and tapped nothing, a PIN loop paying for six passes after the gate cleared, a picker missing the wait its shared subflows already had, a memo-chain default naming a display name the picker does not use, and two funded flows running the same direction so they could not run back to back. The floor message now converts through the rates the scene already holds and formats with the shared helper, verified on device by switching the account to EUR and reading back "Private routing needs at least EUR 21.80". + +Diverged once, and it was the harness. The reviewer-availability classifier written this phase read the check-run bucket, and Bugbot reports `skipping` on a HEAD it has in fact just reviewed. Trusting the bucket would have filed a real finding as "reviewer unavailable" and walked past it. The classifier now asks whether a review exists pinned to the head commit, which is the only proof of coverage, and the bucket merely raises the question. That correction mattered immediately: the reviewer's quota returned mid-run and it filed seven findings across three pushes, six of which were real provider-interaction bugs (a failed token lookup indistinguishable from an unsupported pair, both legs of a same-asset quote spending two calls on one answer, a provider retry window truncated by our own cap, a retry that then outlived the quote it would resend, a `validUntil` the parse could not read, a max quote spending two of the one-per-minute exchange slots, and a validation failure reporting "Validation Failed" instead of its real reason). One was rejected with reasoning. Each fix carries a test; the plugin suite grew from 17 to 23. + ### Upstream, on Houdini's side Not our work, tracked so it is not rediscovered: @@ -846,6 +854,16 @@ Rejected: **hardcoding `null` for `celo`, `fantom`, `polkadot` and `ton`**, whic Reopen if: the provider exposes chain metadata through the swap plugin surface, which would let the name map itself stop being a snapshot. +### Retry only as long as the thing you are retrying survives + +Chosen: a rate-limited call waits the window the provider asked for, unless the wait would land past the expiry of what it is sending, in which case it fails as a rate limit right away. + +Evidence: the two halves only look contradictory. Our own exponential cap must never truncate the provider's `retryAfter`, because retrying inside the window just draws another 429 and burns the budget, which is what a 30s ceiling did to Houdini's roughly 60s exchange window. But a quote id lives about 60 seconds, so honoring that same window and then re-POSTing the same quote id hangs the user for a minute and fails as an expired quote, which blames the wrong thing. Passing the quote's own expiry into the fetch resolves both: a short window with time left still retries, a long one fails immediately with the accurate reason. `validUntil` arrives as Unix seconds inside a string, which `new Date` reads as an invalid date, so the parse reads the number first or the guard can never fire. + +Rejected: **never retrying create-exchange**, which throws away the cases where the window is short and the quote has time left. **Capping the wait and retrying anyway**, which is the shipped-then-fixed version: it produces a wrong error message after a wasted wait. + +Reopen if: the provider issues quote ids that outlive their pricing, or exposes a re-quote endpoint cheap enough to re-price inside the retry. + ## 12. References - [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) From dab17fa4375035327f88ba7d6051f70a27973c1c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 18:14:23 -0700 Subject: [PATCH 47/56] Retire a quote the moment its terms change Toggling Stealth Send left the standing quote in state until the re-quote effect ran, so for a render the slider was armed against a route priced under the other privacy setting. And an expired swap quote only disabled its slider by navigating away, which the terms check defers, leaving the scene on screen with a dead quote and a live slider. --- src/components/scenes/SendScene2.tsx | 6 ++++++ src/components/scenes/SwapConfirmationScene.tsx | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index a096e75ebbf..eaf6565fda4 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -1393,6 +1393,12 @@ const SendComponent: React.FC<Props> = props => { showToast(stealthBlockedReason) return } + // The standing quote was priced under the OTHER privacy setting, so it is + // dead the moment the toggle moves. Drop it here rather than relying on + // the re-quote effect to disable the slider a render later: the whole + // point of the toggle is that a private send is never approved against a + // transparent route, and the reverse. + setSwapQuote(undefined) setStealth(value => !value) setPinValue(undefined) }) diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 30e399568f1..76167d62304 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -107,6 +107,8 @@ export const SwapConfirmationScene: React.FC<Props> = (props: Props) => { ) const [pending, setPending] = useState(false) + /** The quote's timer ran out; nothing on screen may be approved any more. */ + const [expired, setExpired] = useState(false) const swapRequestOptions = useSwapRequestOptions() @@ -190,6 +192,11 @@ export const SwapConfirmationScene: React.FC<Props> = (props: Props) => { const handleExchangeTimerExpired = useHandler(() => { if (!isFocused) return + // The quote is dead whether or not we can leave this scene yet. Recording + // it disables the slider immediately, which matters in the terms-check + // case below, where the navigation away is deferred until the modal + // resolves and the scene stays on screen in the meantime. + setExpired(true) if (termsCheckPending.current) { timerExpiredDuringTerms.current = true return @@ -544,7 +551,7 @@ export const SwapConfirmationScene: React.FC<Props> = (props: Props) => { <SafeSlider parentStyle={styles.slider} onSlidingComplete={handleSlideComplete} - disabled={pending} + disabled={pending || expired} /> </EdgeAnim> {renderTimer()} From 2e1d11374e1d990e2adf5e45fdfbdf2c0f0cfd72 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 18:20:56 -0700 Subject: [PATCH 48/56] Let a wallet switch and a mode switch finish cleanly Two writes raced on the same state. A source-asset change reset the spend and then the wallet-or-asset block wrote a spread of the PRE-reset value over it, putting the old recipients back. And entering swap-send mode did not retire a plain makeSpend already in flight, so its success handler could land afterward and write plain-send state, including a cleared error, over a scene the quote effect had already put a swap error on. --- src/components/scenes/SendScene2.tsx | 31 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index eaf6565fda4..b5c9160be6f 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -1322,7 +1322,6 @@ const SendComponent: React.FC<Props> = props => { pluginId !== newPluginId || tokenId !== result.tokenId if (assetChanged) { setTokenId(result.tokenId) - setSpendInfo({ tokenId: result.tokenId, spendTargets: [{}] }) } // A new source WALLET invalidates the swap-send destination state, not // just a new source asset: a held quote carries an order created for @@ -1343,16 +1342,18 @@ const SendComponent: React.FC<Props> = props => { setRateStarvedFallback(false) setStealth(false) setRouteCaps({}) - // The address goes with them. Clearing the destination CHAIN while - // leaving the address behind drops the scene back into plain-send - // mode still displaying a foreign-chain address, which the new source - // wallet cannot pay: the user is then one slide away from a send that - // can only fail. - const [target] = spendInfo.spendTargets - target.publicAddress = undefined - target.nativeAmount = undefined - target.otherParams = undefined - setSpendInfo({ ...spendInfo, memos: [] }) + // The recipients go with them, EVERY one of them. Clearing the + // destination CHAIN while leaving an address behind drops the scene + // back into plain-send mode still displaying a foreign-chain + // address, which the new source wallet cannot pay: the user is then + // one slide away from a send that can only fail. Written once, as + // the whole spend: a second `setSpendInfo` here would close over the + // pre-reset value and put the old targets back. + setSpendInfo({ + tokenId: assetChanged ? result.tokenId : tokenId, + spendTargets: [{}], + memos: [] + }) } }) .catch((error: unknown) => { @@ -2819,6 +2820,10 @@ const SendComponent: React.FC<Props> = props => { // A send-to-address swap builds its transaction through the swap quote, // not through makeSpend: if (swapSendActive) { + // Retire any plain makeSpend still in flight. Without this its success + // handler lands after the switch and writes plain-send state (a fee, a + // transaction, a cleared error) over a scene that is now quoting. + makeSpendCounter.current++ setEdgeTransaction(null) setProcessingAmountChanged(false) // Entering swap-send mode retracts the plain send's own error, the @@ -2895,7 +2900,9 @@ const SendComponent: React.FC<Props> = props => { setFeeNativeAmount(feeNativeAmount) flipInputModalRef.current?.setFees({ feeTokenId, feeNativeAmount }) flipInputModalRef.current?.setError(null) - setError(undefined) + // Only the plain send's own error: a swap error belongs to the quote + // effect, and a makeSpend that succeeded says nothing about it. + clearPlainSendError() } catch (err: unknown) { let error = err const insufficientFunds = asMaybeInsufficientFundsError(error) From b0a8e4f188db215f1f144ed571f02068f52bf514 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 18:35:04 -0700 Subject: [PATCH 49/56] Show Myself when any routable wallet exists The control gated on another wallet of the same type, which is right for the plain send that owns that test. A Stealth Send supplies the whole set of assets it can route to, so the gate hid the picker from exactly the account it exists for: one wallet on the source chain and the rest elsewhere. --- .../__snapshots__/SendScene2.ui.test.tsx.snap | 87 +++++++++++++++++++ src/components/tiles/AddressTile2.tsx | 18 +++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index 1cd8066229d..56784f3a207 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -17176,6 +17176,93 @@ exports[`SendScene2 Render SendScene 1`] = ` Enter </Text> </View> + <View + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": false, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "flex": 1, + "flexDirection": "column", + "height": 67, + "justifyContent": "space-evenly", + "opacity": 1, + } + } + testID="addressTileMyself" + > + <Text + allowFontScaling={false} + selectable={false} + style={ + [ + { + "color": "#00f1a2", + "fontSize": 45, + }, + undefined, + { + "fontFamily": "anticon", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + </Text> + <Text + adjustsFontSizeToFit={true} + allowFontScaling={false} + minimumFontScale={0.65} + numberOfLines={1} + style={ + [ + { + "color": "#FFFFFF", + "fontFamily": "Quicksand-Regular", + "fontSize": 22, + "includeFontPadding": false, + }, + { + "alignSelf": "center", + "color": "#00f1a2", + "fontSize": 17, + "marginTop": 6, + }, + null, + ] + } + > + Myself + </Text> + </View> <View accessibilityState={ { diff --git a/src/components/tiles/AddressTile2.tsx b/src/components/tiles/AddressTile2.tsx index 8d88b7cb111..a362d4553c4 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -206,9 +206,23 @@ export const AddressTile2 = React.forwardRef( const canSelfTransfer: boolean = Object.keys(currencyWallets).some( walletId => { if (walletId === coreWallet.id) return false - if (currencyWallets[walletId].type !== coreWallet.type) return false + const wallet = currencyWallets[walletId] + // A self-transfer caller offers every asset the send can route to, so + // the control has to appear whenever the user holds ANY of them. The + // same-type test below would hide it from exactly the account the + // cross-chain picker exists for: one wallet on the source chain and + // the rest elsewhere. + if (selfTransfer != null) { + return selfTransfer.allowedAssets.some( + asset => + asset.pluginId === wallet.currencyInfo.pluginId && + (asset.tokenId == null || + wallet.enabledTokenIds.includes(asset.tokenId)) + ) + } + if (wallet.type !== coreWallet.type) return false if (tokenId == null) return true - return currencyWallets[walletId].enabledTokenIds.includes(tokenId) + return wallet.enabledTokenIds.includes(tokenId) } ) From c2a5e22b60c1bf2581e0be67f2c9d8c3af1b939c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 18:56:23 -0700 Subject: [PATCH 50/56] Keep the expiry message on screen with its own slider gate The expiry error was written into the shared error state as a plain-send one, so entering swap-send retracted it while the expiry flag stayed set: a disabled slider with nothing on screen saying why. The message belongs to the REQUEST, so the plain-send clear now refuses it. A source wallet or asset switch retires whatever error is showing, for the same reason it already retires the toggle and the learned route capabilities. --- src/components/scenes/SendScene2.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index b5c9160be6f..fb1b3e99398 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -282,7 +282,16 @@ const SendComponent: React.FC<Props> = props => { ) const [expireDate, setExpireDate] = useState<Date | undefined>(initExpireDate) /** Whether the payment request's own countdown has run out. */ - const [addressExpired, setAddressExpired] = useState<boolean>(false) + const [addressExpired, setAddressExpiredState] = useState<boolean>(false) + /** + * Mirror of `addressExpired` for the error-owner helpers below, which are + * also called from async effects holding an older render's closure. + */ + const addressExpiredRef = React.useRef<boolean>(false) + const setAddressExpired = (value: boolean): void => { + addressExpiredRef.current = value + setAddressExpiredState(value) + } const [error, setError] = useState<unknown | undefined>(undefined) const [edgeTransaction, setEdgeTransaction] = useState<EdgeTransaction | null>(null) @@ -382,6 +391,10 @@ const SendComponent: React.FC<Props> = props => { /** The converse: retract a plain-send error without touching a swap one. */ const clearPlainSendError = (): void => { if (swapErrorShown.current) return + // The expiry message belongs to the REQUEST, not to either send mode, and + // the slider stays disabled on it either way. Retracting it here left the + // user looking at a dead slider with nothing on screen explaining why. + if (addressExpiredRef.current) return setError(undefined) } @@ -1342,6 +1355,12 @@ const SendComponent: React.FC<Props> = props => { setRateStarvedFallback(false) setStealth(false) setRouteCaps({}) + // The message describes the pair and wallet the user just left, the + // same reason the toggle and the route caps go. Through the owners, + // so `swapErrorShown` does not survive the wallet it belonged to. + clearSwapError() + setAddressExpired(false) + clearPlainSendError() // The recipients go with them, EVERY one of them. Clearing the // destination CHAIN while leaving an address behind drops the scene // back into plain-send mode still displaying a foreign-chain From 750be41150662e278ebe16739b962fd09c1cdc60 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Thu, 30 Jul 2026 20:13:47 -0700 Subject: [PATCH 51/56] Bring the TDD current with the second review round Section 6 records the two bounds on the rate-limit retry and why the max path does not create an exchange. Section 7 records the Myself gate. Section 8 states the error-ownership rule and the retire-on-change rule that every finding in this round was a violation of. Phase 12 gains the second round. --- src/docs/stealth-send-swap.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 69a43211ab6..1ee1f14748f 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -255,6 +255,10 @@ This filter is the reason a live availability change on the provider's side can Houdini is an aggregator behind Cloudflare, and tight request loops get blocked in a way that poisons the answers: a 429 arriving where a quote was expected reads exactly like an unavailable pair, and caching that verdict would teach the UI something false. Every call therefore goes through one wrapper that retries a 429 behind the `retryAfter` the API reports, doubling on top of it so a burst does not re-collide the moment the window reopens, and gives up after three attempts with an error that says rate limit rather than unavailable. Since only a plain `Error` comes back, the send scene's `SwapCurrencyError` branch never fires, so no `routeCaps` entry is written and no toggle turns itself off on the strength of a throttled request. +Two bounds sit on that retry, and they pull in opposite directions. The doubling is capped, but the cap bounds OUR OWN growth only: the window the API asked for always survives it, because retrying inside a window the provider named just draws another 429 and spends the budget for nothing. Against that, a retry is only worth waiting for if what it resends is still alive when the wait ends. Houdini quote ids live about a minute and the exchange budget reports `retryAfter` near sixty seconds, so honoring the window and re-POSTing the same quote id hangs the user for a minute and then fails as an expired quote, blaming the wrong thing. The create-exchange call therefore passes the candidate quote's own expiry into the wrapper, and a wait that would land past it fails immediately as a rate limit. `validUntil` arrives as Unix seconds inside a string, which `new Date` reads as an invalid date, so the parse reads the number first. + +The `max` path is the other place the exchange budget bites. `getMaxSwappable` runs the quote function once to size the spend and the real quote runs it again, so creating an order on the sizing pass spends one of the one-per-minute slots and guarantees the real create is throttled. The sizing pass builds its spend shape from the quote alone, standing in the user's own refund address for the deposit address it does not have. + Their published tiers are 5 quote requests per minute on free and 500 on pro. Nothing in the app probes them; the wrapper is the only rate-limit machinery, per the no-probing rule in [Learn route availability from live failures](#learn-route-availability-from-live-failures-not-probes-or-tables). ### What the plugin reports as fixed @@ -424,7 +428,7 @@ Floors are not the whole story. Individual tokens carry higher server-side minim Every surface that decides whether an asset can be a send-to-address destination reads the route metadata (`HOUDINI_CHAINS` through `getHoudiniChain`), never a hardcoded asset shape. That covers address detection, the recipient-asset picker, quote gating, and the "Myself" picker. Tokens are absent from all of them for one reason: `getHoudiniChain` returns undefined for a non-null `tokenId`. When the provider serves token destinations, relaxing that one function surfaces them everywhere at once. -The "Myself" picker follows the same rule. It offers the source asset plus every chain the provider pays out to, filtered to assets the account actually has a `currencyConfig` for. Same-asset wallets pin to the top of the modal through an opt-in grouping prop on `WalletListModal` (`pinnedAssets`, `pinnedTitle`, `otherTitle`); callers that omit it keep the default recent/all ordering. The source wallet stays excluded, since sending an asset to itself is not a transfer. A cross-asset pick runs through `adoptCrossChainDestination`, the same path address detection uses, so the recipient asset, the destination tag, and the held quote all reset identically. +The "Myself" picker follows the same rule. It offers the source asset plus every chain the provider pays out to, filtered to assets the account actually has a `currencyConfig` for. Same-asset wallets pin to the top of the modal through an opt-in grouping prop on `WalletListModal` (`pinnedAssets`, `pinnedTitle`, `otherTitle`); callers that omit it keep the default recent/all ordering. The source wallet stays excluded, since sending an asset to itself is not a transfer. The control's own visibility follows the same rule: it appears when the account holds ANY wallet among those routable assets. The plain-send test it used to share, another wallet of the same type, is right for a plain send and wrong here, because it hides the picker from exactly the account it exists for: one wallet on the source chain and the rest elsewhere. A cross-asset pick runs through `adoptCrossChainDestination`, the same path address detection uses, so the recipient asset, the destination tag, and the held quote all reset identically. Private-route availability does not filter this list. A pair whose private route is missing is still a legal destination; the Stealth toggle is what turns itself off, per [Availability fallbacks](#availability-fallbacks), evaluated per selection rather than cached per asset. @@ -538,6 +542,10 @@ Which requests each action produces: | Any of the above, rate limited | the same call retried behind `retryAfter`, up to three times | | Order status after broadcast | none in-app; the details scene links out to Houdini's order page | +One error slot is shared by two owners, and every bug in this area came from ignoring that. The plain-send `makeSpend` effect owns its failures, the quote effect owns the swap's, and expiry belongs to neither: it is a property of the REQUEST, so it survives whichever mode the scene is in and is retracted only by replacing the address. Each owner clears through a helper that refuses to touch what it does not own, which is what keeps an insufficient-funds message from vanishing under a live quote, keeps a minimum-amount message from sitting over a plain same-asset send, and keeps an expired request from leaving a disabled slider with nothing on screen explaining it. + +The same discipline governs what may still be approved. A quote is retired the instant anything it was priced against moves: the amount, the address, the source wallet, the privacy toggle, or its own expiry timer. Retiring means dropping the quote, not merely asking for a new one, because the slider gates on a quote being present and a re-quote takes a render to start. A source-wallet or asset switch resets the whole spend rather than the first recipient alone, and retires the toggle, the learned route capabilities, and any error on screen, all of which describe the wallet the user just left. + Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), because availability is a live provider property: the same pair can regain its private route an hour later, so nothing is persisted. Route limits above the floor (a token's own server-side minimum, or a maximum) never enter this flow; they keep the plain error card carrying the provider's figure, because the route exists and the amount is the problem. ## 9. Testing @@ -644,7 +652,9 @@ Queued: an operator followup with three items in its own order. Replace the hard Shipped: all ten flows pass, verified one at a time with the driver killed between, because a failing flow takes the driver down and every later flow then reports a connection error that reads as a suite-wide break. Getting there fixed seven defects in the suite rather than in the app: a search-field focus race that dropped the typed filter, rows never satisfying the default full-visibility requirement, a transaction row whose accessible label is the whole row joined, so a text match resolved to a container and tapped nothing, a PIN loop paying for six passes after the gate cleared, a picker missing the wait its shared subflows already had, a memo-chain default naming a display name the picker does not use, and two funded flows running the same direction so they could not run back to back. The floor message now converts through the rates the scene already holds and formats with the shared helper, verified on device by switching the account to EUR and reading back "Private routing needs at least EUR 21.80". -Diverged once, and it was the harness. The reviewer-availability classifier written this phase read the check-run bucket, and Bugbot reports `skipping` on a HEAD it has in fact just reviewed. Trusting the bucket would have filed a real finding as "reviewer unavailable" and walked past it. The classifier now asks whether a review exists pinned to the head commit, which is the only proof of coverage, and the bucket merely raises the question. That correction mattered immediately: the reviewer's quota returned mid-run and it filed seven findings across three pushes, six of which were real provider-interaction bugs (a failed token lookup indistinguishable from an unsupported pair, both legs of a same-asset quote spending two calls on one answer, a provider retry window truncated by our own cap, a retry that then outlived the quote it would resend, a `validUntil` the parse could not read, a max quote spending two of the one-per-minute exchange slots, and a validation failure reporting "Validation Failed" instead of its real reason). One was rejected with reasoning. Each fix carries a test; the plugin suite grew from 17 to 23. +Diverged once, and it was the harness. The reviewer-availability classifier written this phase read the check-run bucket, and Bugbot reports `skipping` on a HEAD it has in fact just reviewed. Trusting the bucket would have filed a real finding as "reviewer unavailable" and walked past it. The classifier now asks whether a review exists pinned to the head commit, which is the only proof of coverage, and the bucket merely raises the question. That correction mattered immediately: the reviewer's quota returned mid-run and it filed seven findings across three pushes, six of which were real provider-interaction bugs (a failed token lookup indistinguishable from an unsupported pair, both legs of a same-asset quote spending two calls on one answer, a provider retry window truncated by our own cap, a retry that then outlived the quote it would resend, a `validUntil` the parse could not read, a max quote spending two of the one-per-minute exchange slots, and a validation failure reporting "Validation Failed" instead of its real reason). One was rejected with reasoning. Each plugin fix carries a test; that suite grew from 17 to 23. + +A second review round followed on the gui, four more findings, all real: a quote surviving the toggle that repriced it, a quote surviving its own expiry while the terms modal deferred the navigation away, an asset change whose reset was overwritten by a stale spread of the pre-reset value, a superseded `makeSpend` landing after the scene had moved to quoting and clearing an error it did not own, and the expiry message being retracted as though the plain send owned it. They are one theme, and section 8 now states it: one error slot has two owners plus a request-scoped case, and a quote is retired the instant anything it was priced against moves. Then the reviewer's quota ran out again before the last push, which the corrected classifier reports as an unchecked gate box rather than as coverage. ### Upstream, on Houdini's side From b5fe24b6ddb5ee4cf17d22da1f869af29a5e4a07 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Fri, 31 Jul 2026 22:20:06 -0700 Subject: [PATCH 52/56] Fix lint warnings in EdgeRow --- eslint.config.mjs | 2 -- src/components/rows/EdgeRow.tsx | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 49682aeb2ee..548d858fa83 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -253,8 +253,6 @@ export default [ 'src/components/rows/CryptoFiatAmountRow.tsx', - 'src/components/rows/EdgeRow.tsx', - 'src/components/rows/PaymentMethodRow.tsx', 'src/components/rows/TxCryptoAmountRow.tsx', diff --git a/src/components/rows/EdgeRow.tsx b/src/components/rows/EdgeRow.tsx index 669ca5e4bfc..ada07aaa92f 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -54,7 +54,7 @@ interface Props { marginRem?: number[] | number } -export const EdgeRow = (props: Props) => { +export const EdgeRow: React.FC<Props> = (props: Props) => { const { body, children, @@ -122,12 +122,12 @@ export const EdgeRow = (props: Props) => { {title == null ? null : ( <EdgeText ellipsizeMode="tail" - style={error ? styles.textHeaderError : styles.textHeader} + style={error === true ? styles.textHeaderError : styles.textHeader} > {title} </EdgeText> )} - {loading ? ( + {loading === true ? ( <ActivityIndicator style={styles.loader} color={theme.primaryText} From ce5ccd9873133a0628cb579f55f027a93492cedb Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Fri, 31 Jul 2026 22:25:35 -0700 Subject: [PATCH 53/56] Let a row show a tinted state word in its title A row that wants one word of its header in a state color had to give up the shared header styling and rebuild it. Add a titleState prop the row renders in parentheses after its title, and add the green PositiveText beside the existing orange WarningText. Both set color only, so a span nested in a header keeps the header's size. --- eslint.config.mjs | 9 ++++++++- src/components/rows/EdgeRow.tsx | 6 ++++++ src/components/themed/EdgeText.tsx | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 548d858fa83..deff58e3036 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,7 +34,14 @@ export default [ 'react-native/no-raw-text': [ 'error', { - skip: ['B', 'EdgeText', 'Paragraph', 'SmallText', 'WarningText'] + skip: [ + 'B', + 'EdgeText', + 'Paragraph', + 'PositiveText', + 'SmallText', + 'WarningText' + ] } ], 'react-native/sort-styles': 'off', diff --git a/src/components/rows/EdgeRow.tsx b/src/components/rows/EdgeRow.tsx index ada07aaa92f..e2403c37f59 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -46,6 +46,10 @@ interface Props { maximumHeight?: 'small' | 'medium' | 'large' rightButtonType?: RowActionIcon title?: string + + /** A state word for the title, rendered in parentheses after it. Supply it + * wrapped in a color component to tint the word without tinting the title. */ + titleState?: React.ReactNode testID?: string onLongPress?: () => Promise<void> | void onPress?: () => Promise<void> | void @@ -65,6 +69,7 @@ export const EdgeRow: React.FC<Props> = (props: Props) => { maximumHeight = 'medium', testID, title, + titleState, // Handlers: onLongPress, @@ -125,6 +130,7 @@ export const EdgeRow: React.FC<Props> = (props: Props) => { style={error === true ? styles.textHeaderError : styles.textHeader} > {title} + {titleState == null ? null : <> ({titleState})</>} </EdgeText> )} {loading === true ? ( diff --git a/src/components/themed/EdgeText.tsx b/src/components/themed/EdgeText.tsx index 2109a1ddca7..aebd94a2052 100644 --- a/src/components/themed/EdgeText.tsx +++ b/src/components/themed/EdgeText.tsx @@ -137,6 +137,25 @@ export const WarningText: React.FC<{ children: React.ReactNode }> = (props: { ) } +/** Makes the contents of an `EdgeText` or `Paragraph` green, for affirmative + * states. Unless used within a `Paragraph` block, provides no outer spacing. */ +export const PositiveText: React.FC<{ children: React.ReactNode }> = (props: { + children: React.ReactNode +}) => { + const { children } = props + const theme = useTheme() + const styles = getStyles(theme) + + return ( + <Text + allowFontScaling={false} + style={[styles.colorPositive, androidAdjustTextStyle(theme)]} + > + {children} + </Text> + ) +} + /** Makes the contents of an `EdgeText` or `Paragraph` large (1.5rem). * Unless used within a `Paragraph` block, provides no outer spacing. */ export const HeaderText: React.FC<{ children: React.ReactNode }> = (props: { @@ -169,6 +188,9 @@ const getStyles = cacheStyles((theme: Theme) => ({ includeFontPadding: false }, + colorPositive: { + color: theme.positiveText + }, colorWarning: { color: theme.warningText }, From 671f792f0fef76e4d98a0434c3414a83236f609b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Fri, 31 Jul 2026 22:26:01 -0700 Subject: [PATCH 54/56] Move the guaranteed and estimated state into the amount row titles The state word sat under the amount, so each row read as three stacked lines and the amount was not the last thing on it. Put the word in the row's own title instead, parenthesised and tinted: green for the side the user fixed, warning orange for the side the quote is still estimating. The maestro flows that target these rows matched the title exactly, so they now allow the trailing state word, and the payment-URI walk asserts the whole title rather than a bare Guaranteed that no longer stands alone. --- .../14-stealth/stealth-qr-payment-uri.yaml | 2 +- maestro/common/stealth-set-amount.yaml | 9 ++- src/components/scenes/SendScene2.tsx | 64 +++++++++---------- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/maestro/14-stealth/stealth-qr-payment-uri.yaml b/maestro/14-stealth/stealth-qr-payment-uri.yaml index bc83e9377ae..57cc21c258b 100644 --- a/maestro/14-stealth/stealth-qr-payment-uri.yaml +++ b/maestro/14-stealth/stealth-qr-payment-uri.yaml @@ -34,5 +34,5 @@ tags: # The address came out of the URI, and its amount is the guaranteed side. - assertVisible: '.*0x1f36.*' - assertVisible: '.*0.5 ETH.*' -- assertVisible: 'Guaranteed' +- assertVisible: '.*Recipient gets \(Guaranteed\).*' - takeScreenshot: maestro/screenshots/stealth-qr-01-cross-chain diff --git a/maestro/common/stealth-set-amount.yaml b/maestro/common/stealth-set-amount.yaml index 3a031f338d0..3890e5c061c 100644 --- a/maestro/common/stealth-set-amount.yaml +++ b/maestro/common/stealth-set-amount.yaml @@ -21,21 +21,24 @@ env: # left the scroll position, so the row is brought into view from either side # before it is tapped. A `scrollUntilVisible` that is already satisfied is a # no-op, and one that scrolls the wrong way is optional and warns. +# +# The trailing `.*` is load-bearing: text matching is a full-string match, and +# the row's title carries its own state word, as in "You send (Guaranteed)". - scrollUntilVisible: element: - text: '${AMOUNT_ROW}' + text: '${AMOUNT_ROW}.*' direction: UP timeout: 4000 centerElement: true optional: true - scrollUntilVisible: element: - text: '${AMOUNT_ROW}' + text: '${AMOUNT_ROW}.*' direction: DOWN timeout: 4000 centerElement: true optional: true -- tapOn: '${AMOUNT_ROW}' +- tapOn: '${AMOUNT_ROW}.*' - waitForAnimationToEnd: timeout: 3000 - inputText: '${AMOUNT}' diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index fb1b3e99398..ec1d2f6be62 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -121,7 +121,7 @@ import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { FiatText } from '../text/FiatText' import { UnscaledTextInput } from '../text/UnscaledTextInput' -import { EdgeText } from '../themed/EdgeText' +import { EdgeText, PositiveText, WarningText } from '../themed/EdgeText' import type { ExchangedFlipInputAmounts, ExchangeFlipInputFields @@ -1635,7 +1635,9 @@ const SendComponent: React.FC<Props> = props => { /** * One side of the linked flip inputs. The edited side is the guaranteed - * amount; the other tracks the live quote as an estimate. + * amount; the other tracks the live quote as an estimate. The state word + * rides in the row's own header, tinted, so the amount below it reads as a + * single uninterrupted line. */ const renderSwapAmountRow = ( title: string, @@ -1645,30 +1647,32 @@ const SendComponent: React.FC<Props> = props => { onPress: () => void, fiat: SwapRowFiat | undefined ): React.ReactElement => ( - <EdgeRow rightButtonType="editable" title={title} onPress={onPress}> - <View style={styles.swapAmountRow}> - <EdgeText style={styles.swapAmountText}> - {`${isGuaranteed ? '' : '~ '}${displayAmount} ${displayCode}`} - {fiat == null ? null : ( - <> - {' ('} - <FiatText - nativeCryptoAmount={fiat.nativeAmount} - tokenId={fiat.tokenId} - currencyConfig={fiat.currencyConfig} - /> - ) - </> - )} - </EdgeText> - <EdgeText - style={isGuaranteed ? styles.guaranteedHint : styles.estimatedHint} - > - {isGuaranteed - ? lstrings.stealth_guaranteed - : lstrings.stealth_estimated} - </EdgeText> - </View> + <EdgeRow + rightButtonType="editable" + title={title} + titleState={ + isGuaranteed ? ( + <PositiveText>{lstrings.stealth_guaranteed}</PositiveText> + ) : ( + <WarningText>{lstrings.stealth_estimated}</WarningText> + ) + } + onPress={onPress} + > + <EdgeText style={styles.swapAmountText}> + {`${isGuaranteed ? '' : '~ '}${displayAmount} ${displayCode}`} + {fiat == null ? null : ( + <> + {' ('} + <FiatText + nativeCryptoAmount={fiat.nativeAmount} + tokenId={fiat.tokenId} + currencyConfig={fiat.currencyConfig} + /> + ) + </> + )} + </EdgeText> </EdgeRow> ) @@ -1831,7 +1835,7 @@ const SendComponent: React.FC<Props> = props => { }`} <PriceImpactText priceImpact={priceImpact} /> </EdgeText> - <EdgeText style={styles.estimatedHint}>{providerName}</EdgeText> + <EdgeText style={styles.providerHint}>{providerName}</EdgeText> </View> </EdgeRow> {swapQuote.expirationDate == null ? null : ( @@ -3474,11 +3478,7 @@ const getStyles = cacheStyles((theme: Theme) => ({ // box, so it lacks the font line-box whitespace a text body carries. marginTop: theme.rem(0.375) }, - guaranteedHint: { - fontSize: theme.rem(0.75), - color: theme.positiveText - }, - estimatedHint: { + providerHint: { fontSize: theme.rem(0.75), color: theme.secondaryText }, From ef7ee286c377365f3ca9d88e44b927cacf0ec043 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Fri, 31 Jul 2026 22:59:22 -0700 Subject: [PATCH 55/56] Let the amount subflow set a row that already holds a value The flip input opens pre-filled and inputText appends, so re-editing a row committed the new digits concatenated onto the old amount rather than the amount asked for. Erase before typing; on a first edit there is nothing to erase and nothing changes. While here, assert both amount-row titles on the cross-asset walk, so a regression that swaps or drops the guaranteed and estimated states fails a flow instead of only changing a screenshot. --- maestro/14-stealth/stealth-cross-asset-quote.yaml | 4 ++++ maestro/common/stealth-set-amount.yaml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/maestro/14-stealth/stealth-cross-asset-quote.yaml b/maestro/14-stealth/stealth-cross-asset-quote.yaml index 25f2e659b05..431aa472605 100644 --- a/maestro/14-stealth/stealth-cross-asset-quote.yaml +++ b/maestro/14-stealth/stealth-cross-asset-quote.yaml @@ -28,4 +28,8 @@ tags: AMOUNT: ${AMOUNT} - runFlow: file: ../common/stealth-await-quote.yaml +# The edited side owns the guarantee, and each row states which it is in its +# own title. Asserting both catches a regression that swaps or drops them. +- assertVisible: 'You send \(Guaranteed\)' +- assertVisible: 'Recipient gets \(Estimated\)' - takeScreenshot: maestro/screenshots/stealth-cross-asset-01-standard-quote diff --git a/maestro/common/stealth-set-amount.yaml b/maestro/common/stealth-set-amount.yaml index 3890e5c061c..c8f1adfa3cc 100644 --- a/maestro/common/stealth-set-amount.yaml +++ b/maestro/common/stealth-set-amount.yaml @@ -41,6 +41,11 @@ env: - tapOn: '${AMOUNT_ROW}.*' - waitForAnimationToEnd: timeout: 3000 +# The flip input opens pre-filled with whatever the row already holds, and +# `inputText` appends. On a first edit the field is empty and this erases +# nothing; on a re-edit it is the difference between setting the amount and +# concatenating onto the old one, which commits a number nobody asked for. +- eraseText - inputText: '${AMOUNT}' - waitForAnimationToEnd: timeout: 2000 From dc0b86823adfb98e6ed470cfb210e09d20c96939 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng <jnthntzng@gmail.com> Date: Fri, 31 Jul 2026 22:59:28 -0700 Subject: [PATCH 56/56] Record the amount-row title states in the design doc Phase 13: what moved, the EdgeRow and EdgeText additions it needed, the raw-text lint that ruled out composing the header in the scene, and the on-device drive of both states. --- src/docs/stealth-send-swap.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 1ee1f14748f..59e35f58952 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -330,7 +330,7 @@ Every send-to-address quote goes through these options, stealth toggle on or off ### Linked amounts -"You send" and "Recipient gets" are linked through one piece of state, `guaranteedSide`. The edited side is guaranteed and renders green; the other tracks the live quote and renders as `~` estimated. Editing "Recipient gets" issues `quoteFor: 'to'`, a reverse quote. +"You send" and "Recipient gets" are linked through one piece of state, `guaranteedSide`. The edited side is guaranteed and the other tracks the live quote as an estimate. Each row says which it is in its own title rather than under the amount: `You send (Guaranteed)` with the state word in green, `Recipient gets (Estimated)` with it in warning orange, so the amount is the last thing on the row and the pair reads as one line each. The estimated side keeps the `~` prefix on its number. `EdgeRow` renders the word from a `titleState` node, which is how a row tints part of its header without rebuilding the shared header style. Editing "Recipient gets" issues `quoteFor: 'to'`, a reverse quote. Both amounts are entered through the standard crypto/fiat flip input (`FlipInputModal2`), committed on close so quotes still fire per commit rather than per keystroke; a zero or untouched amount is a dismissal. Max is hidden because max spend is not offered in swap-send mode. The destination side has no wallet, so its modal borrows the user's own wallet on the destination chain for denominations and rates (with a plain text modal as the fallback when no such wallet exists); the borrowed wallet's balance row reads as that wallet's balance, which is cosmetic noise accepted for reusing the standard modal. @@ -596,6 +596,8 @@ On-device, iOS simulator, account `edge-funds`, against the live provider: 12. **Pre-emptive stealth refusal, live.** Re-tapping the stealth toggle on the known-unavailable ETH to LTC pair refused to arm and toasted, without issuing another quote. 13. **Houdini exclusivity, live (phase 6).** ETH wallet, pasted LTC address, 0.006 ETH, Stealth OFF: the quote went Houdini-only and surfaced `SwapCurrencyError: HoudiniSwap does not support ethereum:null to litecoin:null` in the error card, where the phase 5 code had produced an armed ChangeNOW quote on the same pair and amount. Repeated with Stealth ON: the toggle stayed on and the same error card appeared, with no auto-disable toast and no re-quote. Both amount entries went through the new flip-input modals (You send in ETH/USD, Recipient gets in LTC/USD via a borrowed Litecoin wallet). Amount errors were also confirmed unchanged: 0.002 ETH (below the provider minimum) produced the plain error card with the toggle still armed. The same-asset auto-disable degrade was NOT drivable this phase: the provider's route availability flapped during testing (private routes present on one probe, absent minutes later) and no funded same-asset pair lacked a private route at a fundable amount; the toast, `routeCaps`, and degrade mechanics are the same lines phase 5 drove to execution. +14. **Amount-row title states, live (phase 13).** XLM wallet, "My Sonic" adopted as the destination, 158 XLM entered on the send side: the send row read `You send (Guaranteed)` in green with a bare amount and the receive row `Recipient gets (Estimated)` in orange with the `~` prefix, on a live quote of 1 XLM = 7.67561925 S. Editing "Recipient gets" to 1200 S swapped both, `Recipient gets (Guaranteed)` green and bare against `You send (Estimated)` orange with the tilde, and the reverse quote resolved at 1 XLM = 7.56043868 S with the slider armed. Nothing was sent. + ## 10. Phase history ### Phase 1: prototype and the bridge verdict @@ -656,6 +658,16 @@ Diverged once, and it was the harness. The reviewer-availability classifier writ A second review round followed on the gui, four more findings, all real: a quote surviving the toggle that repriced it, a quote surviving its own expiry while the terms modal deferred the navigation away, an asset change whose reset was overwritten by a stale spread of the pre-reset value, a superseded `makeSpend` landing after the scene had moved to quoting and clearing an error it did not own, and the expiry message being retracted as though the plain send owned it. They are one theme, and section 8 now states it: one error slot has two owners plus a request-scoped case, and a quote is retired the instant anything it was priced against moves. Then the reviewer's quota ran out again before the last push, which the corrected classifier reports as an unchecked gate box rather than as coverage. +### Phase 13: the state word moves into the row title + +Queued: an operator followup on the two linked amount rows. The state word sat on its own line under the amount, so each row read as three stacked lines and the number was not the last thing on it. Put the word in the row's title instead, parenthesised, green for the guaranteed side and warning orange for the estimated one, on both rows. + +Shipped: `EdgeRow` grew a `titleState` node it renders in parentheses after the title, and `EdgeText` grew `PositiveText` to sit beside the existing `WarningText`. Both colour components set colour only, so a span nested in a 0.75rem header keeps the header's size rather than jumping to the 1rem body size an `EdgeText` would have forced. The scene passes the word and drops the third line; the estimated side keeps its `~` prefix, which marks the approximation next to the number rather than away from it. + +Diverged once, and the linter found it. The first cut widened `EdgeRow.title` to a node so the scene could compose the whole header itself. That put raw text in a fragment outside any `<Text>`, which `react-native/no-raw-text` rejects, and it would have made every caller wanting a tinted word rebuild the shared header style. Moving the parentheses into `EdgeRow` fixed both: the raw text now sits inside the row's own header `EdgeText`, where the rule already allows it, and callers supply nothing but the word. + +The suite needed two changes for the new titles, both of which say something about the old ones. The shared amount-row subflow matched the row by its exact title, which the state word now breaks, so it matches a prefix; and it now erases the flip input before typing, because the input opens pre-filled and `inputText` appends, which silently commits a concatenated amount on any re-edit. The payment-URI walk asserted a bare `Guaranteed` that no longer stands alone as its own element, and now asserts the whole title, which also binds the guarantee to the row that should hold it. + ### Upstream, on Houdini's side Not our work, tracked so it is not rediscovered: