1- import { gt , gte } from 'biggystring'
1+ import { div , gt , gte } from 'biggystring'
22import {
33 asMaybeInsufficientFundsError ,
44 asMaybeSwapAboveLimitError ,
@@ -21,10 +21,16 @@ 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+ isPluginDisabled
33+ } from '../../util/houdiniPrivateSend'
2834import { zeroString } from '../../util/utils'
2935import { EdgeButton } from '../buttons/EdgeButton'
3036import { KavButtons } from '../buttons/KavButtons'
@@ -42,12 +48,19 @@ import { SceneWrapper } from '../common/SceneWrapper'
4248import { styled } from '../hoc/styled'
4349import { SwapVerticalIcon } from '../icons/ThemedIcons'
4450import { SceneContainer } from '../layout/SceneContainer'
51+ import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
4552import {
4653 WalletListModal ,
4754 type WalletListResult
4855} from '../modals/WalletListModal'
49- import { Airship , showToast , showWarning } from '../services/AirshipInstance'
56+ import {
57+ Airship ,
58+ showError ,
59+ showToast ,
60+ showWarning
61+ } from '../services/AirshipInstance'
5062import { useTheme } from '../services/ThemeContext'
63+ import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
5164import { UnscaledText } from '../text/UnscaledText'
5265import { LineTextDivider } from '../themed/LineTextDivider'
5366import {
@@ -95,6 +108,14 @@ export const SwapCreateScene: React.FC<Props> = props => {
95108 'from' | 'to'
96109 > ( 'from' )
97110
111+ // When enabled, the swap routes privately through Houdini's swap-to-address
112+ // path (depositing to the destination wallet's address) instead of the normal
113+ // multi-provider wallet-to-wallet flow.
114+ const [ isPrivateSwap , setIsPrivateSwap ] = useState ( false )
115+ // Guards the async private-swap flow so a double-tap cannot launch two
116+ // concurrent quote/approve/broadcast sequences.
117+ const [ privateSwapPending , setPrivateSwapPending ] = useState ( false )
118+
98119 const fromInputRef = React . useRef < SwapInputCardInputRef > ( null )
99120 const toInputRef = React . useRef < SwapInputCardInputRef > ( null )
100121
@@ -103,6 +124,9 @@ export const SwapCreateScene: React.FC<Props> = props => {
103124 const account = useSelector ( state => state . core . account )
104125 const currencyWallets = useWatch ( account , 'currencyWallets' )
105126 const exchangeInfo = useSelector ( state => state . ui . exchangeInfo )
127+ const disablePlugins = useSelector (
128+ state => state . ui . exchangeInfo . swap . disablePlugins
129+ )
106130
107131 const toWallet : EdgeCurrencyWallet | undefined =
108132 toWalletId == null ? undefined : currencyWallets [ toWalletId ]
@@ -130,6 +154,18 @@ export const SwapCreateScene: React.FC<Props> = props => {
130154 const hasMaxSpend =
131155 fromWallet != null && fromWalletSpecialCurrencyInfo . noMaxSpend !== true
132156
157+ // Houdini can only privately route to the NATIVE asset of the chains in its
158+ // destination set, so a token destination (toTokenId != null) is unsupported
159+ // even when its chain appears in the set. A server-side Houdini disable also
160+ // hides the toggle, since the private path is Houdini-only.
161+ const isPrivateSwapSupported =
162+ toWallet != null &&
163+ toTokenId == null &&
164+ ! isPluginDisabled ( disablePlugins , 'houdini' ) &&
165+ HOUDINI_DESTINATION_ASSETS . some (
166+ asset => asset . pluginId === toWallet . currencyInfo . pluginId
167+ )
168+
133169 const isNextHidden =
134170 // Don't show next button if the wallets haven't been selected:
135171 fromWallet == null ||
@@ -149,6 +185,12 @@ export const SwapCreateScene: React.FC<Props> = props => {
149185 } )
150186 } , [ dispatch , navigation ] )
151187
188+ // Keep the private toggle from getting stuck "on" for a destination Houdini
189+ // cannot privately route to (e.g. after the user changes the receive wallet).
190+ React . useEffect ( ( ) => {
191+ if ( isPrivateSwap && ! isPrivateSwapSupported ) setIsPrivateSwap ( false )
192+ } , [ isPrivateSwap , isPrivateSwapSupported ] )
193+
152194 //
153195 // Callbacks
154196 //
@@ -381,7 +423,121 @@ export const SwapCreateScene: React.FC<Props> = props => {
381423 }
382424 )
383425
426+ /**
427+ * Route the current amounts through Houdini's swap-to-address path: derive
428+ * the destination address from the chosen receiving wallet, fetch a
429+ * Houdini-only private quote, confirm, approve, then land on the success
430+ * scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
431+ * destination wallet, so the private path runs its own confirm + approve.
432+ */
433+ const executePrivateSwap = useHandler ( async ( ) : Promise < void > => {
434+ if ( privateSwapPending ) return
435+ if ( fromWallet == null || toWallet == null ) return
436+
437+ if ( zeroString ( inputNativeAmount ) ) {
438+ showToast (
439+ `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
440+ )
441+ return
442+ }
443+ if ( checkAmountExceedsBalance ( ) ) return
444+ // Houdini quotes only off the send amount, so a "to" amount cannot drive a
445+ // private quote.
446+ if ( inputNativeAmountFor !== 'from' ) {
447+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
448+ return
449+ }
450+
451+ // Mirror getQuote: honor the exchange-info asset disables for the private
452+ // path too, so a disabled source/destination asset cannot start a swap.
453+ const disableSrc = checkDisableAsset (
454+ exchangeInfo . swap . disableAssets . source ,
455+ fromWallet . id ,
456+ fromTokenId
457+ )
458+ if ( disableSrc ) {
459+ showToast (
460+ sprintf (
461+ lstrings . swap_token_no_enabled_exchanges_2s ,
462+ fromCurrencyCode ,
463+ fromWallet . currencyInfo . displayName
464+ )
465+ )
466+ return
467+ }
468+ const disableDest = checkDisableAsset (
469+ exchangeInfo . swap . disableAssets . destination ,
470+ toWallet . id ,
471+ toTokenId
472+ )
473+ if ( disableDest ) {
474+ showToast (
475+ sprintf (
476+ lstrings . swap_token_no_enabled_exchanges_2s ,
477+ toCurrencyCode ,
478+ toWallet . currencyInfo . displayName
479+ )
480+ )
481+ return
482+ }
483+
484+ setPrivateSwapPending ( true )
485+ try {
486+ const toAddresses = await toWallet . getAddresses ( { tokenId : null } )
487+ const toAddress = toAddresses [ 0 ] ?. publicAddress
488+ if ( toAddress == null ) {
489+ showError ( lstrings . houdini_swap_no_dest_address )
490+ return
491+ }
492+
493+ const quote = await fetchHoudiniPrivateQuote ( account , {
494+ fromWallet,
495+ fromTokenId,
496+ toPluginId : toWallet . currencyInfo . pluginId ,
497+ toTokenId,
498+ toAddress,
499+ nativeAmount : inputNativeAmount ,
500+ disablePlugins
501+ } )
502+
503+ const fromMultiplier = getExchangeDenom (
504+ fromWallet . currencyConfig ,
505+ fromTokenId
506+ ) . multiplier
507+ const toMultiplier = getExchangeDenom (
508+ toWallet . currencyConfig ,
509+ toTokenId
510+ ) . multiplier
511+ const fromDisplay = div ( quote . fromNativeAmount , fromMultiplier , 8 )
512+ const toDisplay = div ( quote . toNativeAmount , toMultiplier , 8 )
513+
514+ const confirmed = await Airship . show < boolean > ( bridge => (
515+ < ConfirmContinueModal
516+ bridge = { bridge }
517+ title = { lstrings . houdini_ps_confirm_send }
518+ body = { `${ fromDisplay } ${ fromCurrencyCode } → ~${ toDisplay } ${ toCurrencyCode } \n\n${ lstrings . houdini_ps_confirm_body } ` }
519+ warning
520+ />
521+ ) )
522+ if ( ! confirmed ) return
523+
524+ const result = await quote . approve ( )
525+ resetState ( )
526+ navigation . push ( 'swapSuccess' , {
527+ edgeTransaction : result . transaction ,
528+ walletId : fromWallet . id
529+ } )
530+ } finally {
531+ setPrivateSwapPending ( false )
532+ }
533+ } )
534+
384535 const handleMaxPress = useHandler ( ( ) => {
536+ if ( isPrivateSwap ) {
537+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
538+ return
539+ }
540+
385541 if ( toWallet == null ) {
386542 showWarning ( lstrings . exchange_select_receiving_wallet , {
387543 trackError : false
@@ -410,10 +566,24 @@ export const SwapCreateScene: React.FC<Props> = props => {
410566 getQuote ( request )
411567 } )
412568
569+ const handleTogglePrivateSwap = useHandler ( ( ) => {
570+ setIsPrivateSwap ( value => ! value )
571+ } )
572+
413573 const handleNext = useHandler ( ( ) => {
414574 // Should only happen if the user initiated the swap from the keyboard
415575 if ( fromWallet == null || toWallet == null ) return
416576
577+ if ( isPrivateSwap ) {
578+ // handleNext feeds the void-typed button onPress, so it must stay
579+ // synchronous; executePrivateSwap owns its own error display, and this
580+ // .catch is the required floating-promise guard.
581+ executePrivateSwap ( ) . catch ( ( error : unknown ) => {
582+ showError ( error )
583+ } )
584+ return
585+ }
586+
417587 if ( zeroString ( inputNativeAmount ) ) {
418588 showToast (
419589 `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
@@ -530,7 +700,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
530700 primary = { {
531701 label : lstrings . string_next_capitalized ,
532702 onPress : handleNext ,
533- disabled : isNextHidden
703+ disabled : isNextHidden || privateSwapPending
534704 } }
535705 tertiary = { {
536706 label : lstrings . string_cancel_cap ,
@@ -614,12 +784,23 @@ export const SwapCreateScene: React.FC<Props> = props => {
614784 ) }
615785 </ EdgeAnim >
616786 < EdgeAnim enter = { fadeInDown60 } > { renderAlert ( ) } </ EdgeAnim >
787+ { isPrivateSwapSupported ? (
788+ < EdgeAnim enter = { fadeInDown90 } >
789+ < SettingsSwitchRow
790+ label = { lstrings . houdini_swap_private_label }
791+ value = { isPrivateSwap }
792+ onPress = { handleTogglePrivateSwap }
793+ />
794+ </ EdgeAnim >
795+ ) : null }
617796 < EdgeAnim enter = { fadeInDown90 } >
618797 { isNextHidden || isKeyboardOpen ? null : (
619798 < SceneButtons
620799 primary = { {
621800 label : lstrings . string_next_capitalized ,
622- onPress : handleNext
801+ onPress : handleNext ,
802+ disabled : privateSwapPending ,
803+ spinner : privateSwapPending
623804 } }
624805 />
625806 ) }
0 commit comments