1- import { gt , gte } from 'biggystring'
1+ import { div , gt , gte } from 'biggystring'
22import {
33 asMaybeInsufficientFundsError ,
44 asMaybeSwapAboveLimitError ,
@@ -21,10 +21,17 @@ import { useSwapRequestOptions } from '../../hooks/swap/useSwapRequestOptions'
2121import { useHandler } from '../../hooks/useHandler'
2222import { useWatch } from '../../hooks/useWatch'
2323import { lstrings } from '../../locales/strings'
24+ import { getExchangeDenom } from '../../selectors/DenominationSelectors'
2425import { useDispatch , useSelector } from '../../types/reactRedux'
2526import type { NavigationBase , SwapTabSceneProps } from '../../types/routerTypes'
2627import { getCurrencyCode } from '../../util/CurrencyInfoHelpers'
2728import { getWalletName } from '../../util/CurrencyWalletHelpers'
29+ import {
30+ fetchHoudiniPrivateQuote ,
31+ HOUDINI_DESTINATION_ASSETS ,
32+ isAssetDisabled ,
33+ isPluginDisabled
34+ } from '../../util/houdiniPrivateSend'
2835import { zeroString } from '../../util/utils'
2936import { EdgeButton } from '../buttons/EdgeButton'
3037import { KavButtons } from '../buttons/KavButtons'
@@ -42,12 +49,19 @@ import { SceneWrapper } from '../common/SceneWrapper'
4249import { styled } from '../hoc/styled'
4350import { SwapVerticalIcon } from '../icons/ThemedIcons'
4451import { SceneContainer } from '../layout/SceneContainer'
52+ import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
4553import {
4654 WalletListModal ,
4755 type WalletListResult
4856} from '../modals/WalletListModal'
49- import { Airship , showToast , showWarning } from '../services/AirshipInstance'
57+ import {
58+ Airship ,
59+ showError ,
60+ showToast ,
61+ showWarning
62+ } from '../services/AirshipInstance'
5063import { useTheme } from '../services/ThemeContext'
64+ import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
5165import { UnscaledText } from '../text/UnscaledText'
5266import { LineTextDivider } from '../themed/LineTextDivider'
5367import {
@@ -95,6 +109,14 @@ export const SwapCreateScene: React.FC<Props> = props => {
95109 'from' | 'to'
96110 > ( 'from' )
97111
112+ // When enabled, the swap routes privately through Houdini's swap-to-address
113+ // path (depositing to the destination wallet's address) instead of the normal
114+ // multi-provider wallet-to-wallet flow.
115+ const [ isPrivateSwap , setIsPrivateSwap ] = useState ( false )
116+ // Guards the async private-swap flow so a double-tap cannot launch two
117+ // concurrent quote/approve/broadcast sequences.
118+ const [ privateSwapPending , setPrivateSwapPending ] = useState ( false )
119+
98120 const fromInputRef = React . useRef < SwapInputCardInputRef > ( null )
99121 const toInputRef = React . useRef < SwapInputCardInputRef > ( null )
100122
@@ -103,6 +125,9 @@ export const SwapCreateScene: React.FC<Props> = props => {
103125 const account = useSelector ( state => state . core . account )
104126 const currencyWallets = useWatch ( account , 'currencyWallets' )
105127 const exchangeInfo = useSelector ( state => state . ui . exchangeInfo )
128+ const disablePlugins = useSelector (
129+ state => state . ui . exchangeInfo . swap . disablePlugins
130+ )
106131
107132 const toWallet : EdgeCurrencyWallet | undefined =
108133 toWalletId == null ? undefined : currencyWallets [ toWalletId ]
@@ -130,6 +155,18 @@ export const SwapCreateScene: React.FC<Props> = props => {
130155 const hasMaxSpend =
131156 fromWallet != null && fromWalletSpecialCurrencyInfo . noMaxSpend !== true
132157
158+ // Houdini can only privately route to the NATIVE asset of the chains in its
159+ // destination set, so a token destination (toTokenId != null) is unsupported
160+ // even when its chain appears in the set. A server-side Houdini disable also
161+ // hides the toggle, since the private path is Houdini-only.
162+ const isPrivateSwapSupported =
163+ toWallet != null &&
164+ toTokenId == null &&
165+ ! isPluginDisabled ( disablePlugins , 'houdini' ) &&
166+ HOUDINI_DESTINATION_ASSETS . some (
167+ asset => asset . pluginId === toWallet . currencyInfo . pluginId
168+ )
169+
133170 const isNextHidden =
134171 // Don't show next button if the wallets haven't been selected:
135172 fromWallet == null ||
@@ -149,6 +186,12 @@ export const SwapCreateScene: React.FC<Props> = props => {
149186 } )
150187 } , [ dispatch , navigation ] )
151188
189+ // Keep the private toggle from getting stuck "on" for a destination Houdini
190+ // cannot privately route to (e.g. after the user changes the receive wallet).
191+ React . useEffect ( ( ) => {
192+ if ( isPrivateSwap && ! isPrivateSwapSupported ) setIsPrivateSwap ( false )
193+ } , [ isPrivateSwap , isPrivateSwapSupported ] )
194+
152195 //
153196 // Callbacks
154197 //
@@ -199,17 +242,9 @@ export const SwapCreateScene: React.FC<Props> = props => {
199242 walletId : string ,
200243 tokenId : EdgeTokenId
201244 ) : boolean => {
202- const wallet = currencyWallets [ walletId ] ?? { currencyInfo : { } }
203- const walletPluginId = wallet . currencyInfo . pluginId
204- const walletTokenId = tokenId
205- for ( const disableAsset of disableAssets ) {
206- const { pluginId, tokenId } = disableAsset
207- if ( pluginId !== walletPluginId ) continue
208- if ( tokenId === walletTokenId ) return true
209- if ( tokenId === 'allCoins' ) return true
210- if ( tokenId === 'allTokens' && walletTokenId != null ) return true
211- }
212- return false
245+ const wallet = currencyWallets [ walletId ]
246+ if ( wallet == null ) return false
247+ return isAssetDisabled ( disableAssets , wallet . currencyInfo . pluginId , tokenId )
213248 }
214249
215250 function checkAmountExceedsBalance ( ) : boolean {
@@ -298,6 +333,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
298333 const showWalletListModal = async (
299334 whichWallet : 'from' | 'to'
300335 ) : Promise < void > => {
336+ // Don't let a wallet change underneath an in-flight private quote.
337+ if ( privateSwapPending ) return
301338 const result = await Airship . show < WalletListResult > ( bridge => (
302339 < WalletListModal
303340 bridge = { bridge }
@@ -323,6 +360,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
323360 //
324361
325362 const handleFlipWalletPress = useHandler ( ( ) => {
363+ // Don't let the pair change underneath an in-flight private quote.
364+ if ( privateSwapPending ) return
326365 // Flip params:
327366 navigation . setParams ( {
328367 fromWalletId : toWalletId ,
@@ -381,7 +420,121 @@ export const SwapCreateScene: React.FC<Props> = props => {
381420 }
382421 )
383422
423+ /**
424+ * Route the current amounts through Houdini's swap-to-address path: derive
425+ * the destination address from the chosen receiving wallet, fetch a
426+ * Houdini-only private quote, confirm, approve, then land on the success
427+ * scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
428+ * destination wallet, so the private path runs its own confirm + approve.
429+ */
430+ const executePrivateSwap = useHandler ( async ( ) : Promise < void > => {
431+ if ( privateSwapPending ) return
432+ if ( fromWallet == null || toWallet == null ) return
433+
434+ if ( zeroString ( inputNativeAmount ) ) {
435+ showToast (
436+ `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
437+ )
438+ return
439+ }
440+ if ( checkAmountExceedsBalance ( ) ) return
441+ // Houdini quotes only off the send amount, so a "to" amount cannot drive a
442+ // private quote.
443+ if ( inputNativeAmountFor !== 'from' ) {
444+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
445+ return
446+ }
447+
448+ // Mirror getQuote: honor the exchange-info asset disables for the private
449+ // path too, so a disabled source/destination asset cannot start a swap.
450+ const disableSrc = checkDisableAsset (
451+ exchangeInfo . swap . disableAssets . source ,
452+ fromWallet . id ,
453+ fromTokenId
454+ )
455+ if ( disableSrc ) {
456+ showToast (
457+ sprintf (
458+ lstrings . swap_token_no_enabled_exchanges_2s ,
459+ fromCurrencyCode ,
460+ fromWallet . currencyInfo . displayName
461+ )
462+ )
463+ return
464+ }
465+ const disableDest = checkDisableAsset (
466+ exchangeInfo . swap . disableAssets . destination ,
467+ toWallet . id ,
468+ toTokenId
469+ )
470+ if ( disableDest ) {
471+ showToast (
472+ sprintf (
473+ lstrings . swap_token_no_enabled_exchanges_2s ,
474+ toCurrencyCode ,
475+ toWallet . currencyInfo . displayName
476+ )
477+ )
478+ return
479+ }
480+
481+ setPrivateSwapPending ( true )
482+ try {
483+ const toAddresses = await toWallet . getAddresses ( { tokenId : null } )
484+ const toAddress = toAddresses [ 0 ] ?. publicAddress
485+ if ( toAddress == null ) {
486+ showError ( lstrings . houdini_swap_no_dest_address )
487+ return
488+ }
489+
490+ const quote = await fetchHoudiniPrivateQuote ( account , {
491+ fromWallet,
492+ fromTokenId,
493+ toPluginId : toWallet . currencyInfo . pluginId ,
494+ toTokenId,
495+ toAddress,
496+ nativeAmount : inputNativeAmount ,
497+ disablePlugins
498+ } )
499+
500+ const fromMultiplier = getExchangeDenom (
501+ fromWallet . currencyConfig ,
502+ fromTokenId
503+ ) . multiplier
504+ const toMultiplier = getExchangeDenom (
505+ toWallet . currencyConfig ,
506+ toTokenId
507+ ) . multiplier
508+ const fromDisplay = div ( quote . fromNativeAmount , fromMultiplier , 8 )
509+ const toDisplay = div ( quote . toNativeAmount , toMultiplier , 8 )
510+
511+ const confirmed = await Airship . show < boolean > ( bridge => (
512+ < ConfirmContinueModal
513+ bridge = { bridge }
514+ title = { lstrings . houdini_ps_confirm_send }
515+ body = { `${ fromDisplay } ${ fromCurrencyCode } → ~${ toDisplay } ${ toCurrencyCode } \n\n${ lstrings . houdini_ps_confirm_body } ` }
516+ warning
517+ />
518+ ) )
519+ if ( ! confirmed ) return
520+
521+ const result = await quote . approve ( )
522+ resetState ( )
523+ navigation . push ( 'swapSuccess' , {
524+ edgeTransaction : result . transaction ,
525+ walletId : fromWallet . id
526+ } )
527+ } finally {
528+ setPrivateSwapPending ( false )
529+ }
530+ } )
531+
384532 const handleMaxPress = useHandler ( ( ) => {
533+ if ( isPrivateSwap ) {
534+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
535+ return
536+ }
537+
385538 if ( toWallet == null ) {
386539 showWarning ( lstrings . exchange_select_receiving_wallet , {
387540 trackError : false
@@ -410,10 +563,26 @@ export const SwapCreateScene: React.FC<Props> = props => {
410563 getQuote ( request )
411564 } )
412565
566+ const handleTogglePrivateSwap = useHandler ( ( ) => {
567+ // Don't let the routing change underneath an in-flight private quote.
568+ if ( privateSwapPending ) return
569+ setIsPrivateSwap ( value => ! value )
570+ } )
571+
413572 const handleNext = useHandler ( ( ) => {
414573 // Should only happen if the user initiated the swap from the keyboard
415574 if ( fromWallet == null || toWallet == null ) return
416575
576+ if ( isPrivateSwap ) {
577+ // handleNext feeds the void-typed button onPress, so it must stay
578+ // synchronous; executePrivateSwap owns its own error display, and this
579+ // .catch is the required floating-promise guard.
580+ executePrivateSwap ( ) . catch ( ( error : unknown ) => {
581+ showError ( error )
582+ } )
583+ return
584+ }
585+
417586 if ( zeroString ( inputNativeAmount ) ) {
418587 showToast (
419588 `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
@@ -448,6 +617,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
448617 } )
449618
450619 const handleFromAmountChange = useHandler ( ( amounts : SwapInputCardAmounts ) => {
620+ // Don't let the amount change underneath an in-flight private quote.
621+ if ( privateSwapPending ) return
451622 navigation . setParams ( {
452623 // Update the error state:
453624 ...getNewErrorInfo ( 'amount' )
@@ -461,6 +632,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
461632 } )
462633
463634 const handleToAmountChange = useHandler ( ( amounts : SwapInputCardAmounts ) => {
635+ // Don't let the amount change underneath an in-flight private quote.
636+ if ( privateSwapPending ) return
464637 navigation . setParams ( {
465638 // Update the error state:
466639 ...getNewErrorInfo ( 'amount' )
@@ -530,7 +703,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
530703 primary = { {
531704 label : lstrings . string_next_capitalized ,
532705 onPress : handleNext ,
533- disabled : isNextHidden
706+ disabled : isNextHidden || privateSwapPending
534707 } }
535708 tertiary = { {
536709 label : lstrings . string_cancel_cap ,
@@ -614,12 +787,24 @@ export const SwapCreateScene: React.FC<Props> = props => {
614787 ) }
615788 </ EdgeAnim >
616789 < EdgeAnim enter = { fadeInDown60 } > { renderAlert ( ) } </ EdgeAnim >
790+ { isPrivateSwapSupported ? (
791+ < EdgeAnim enter = { fadeInDown90 } >
792+ < SettingsSwitchRow
793+ disabled = { privateSwapPending }
794+ label = { lstrings . houdini_swap_private_label }
795+ value = { isPrivateSwap }
796+ onPress = { handleTogglePrivateSwap }
797+ />
798+ </ EdgeAnim >
799+ ) : null }
617800 < EdgeAnim enter = { fadeInDown90 } >
618801 { isNextHidden || isKeyboardOpen ? null : (
619802 < SceneButtons
620803 primary = { {
621804 label : lstrings . string_next_capitalized ,
622- onPress : handleNext
805+ onPress : handleNext ,
806+ disabled : privateSwapPending ,
807+ spinner : privateSwapPending
623808 } }
624809 />
625810 ) }
0 commit comments