1- import { gt , gte } from 'biggystring'
1+ import { div , gt , gte } from 'biggystring'
22import {
33 asMaybeInsufficientFundsError ,
44 asMaybeSwapAboveLimitError ,
@@ -25,6 +25,11 @@ import { useDispatch, useSelector } from '../../types/reactRedux'
2525import type { NavigationBase , SwapTabSceneProps } from '../../types/routerTypes'
2626import { getCurrencyCode } from '../../util/CurrencyInfoHelpers'
2727import { getWalletName } from '../../util/CurrencyWalletHelpers'
28+ import {
29+ fetchHoudiniPrivateQuote ,
30+ getPrimaryMultiplier ,
31+ HOUDINI_DESTINATION_ASSETS
32+ } from '../../util/houdiniPrivateSend'
2833import { zeroString } from '../../util/utils'
2934import { EdgeButton } from '../buttons/EdgeButton'
3035import { KavButtons } from '../buttons/KavButtons'
@@ -42,12 +47,19 @@ import { SceneWrapper } from '../common/SceneWrapper'
4247import { styled } from '../hoc/styled'
4348import { SwapVerticalIcon } from '../icons/ThemedIcons'
4449import { SceneContainer } from '../layout/SceneContainer'
50+ import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
4551import {
4652 WalletListModal ,
4753 type WalletListResult
4854} from '../modals/WalletListModal'
49- import { Airship , showToast , showWarning } from '../services/AirshipInstance'
55+ import {
56+ Airship ,
57+ showError ,
58+ showToast ,
59+ showWarning
60+ } from '../services/AirshipInstance'
5061import { useTheme } from '../services/ThemeContext'
62+ import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
5163import { UnscaledText } from '../text/UnscaledText'
5264import { LineTextDivider } from '../themed/LineTextDivider'
5365import {
@@ -95,6 +107,14 @@ export const SwapCreateScene: React.FC<Props> = props => {
95107 'from' | 'to'
96108 > ( 'from' )
97109
110+ // When enabled, the swap routes privately through Houdini's swap-to-address
111+ // path (depositing to the destination wallet's address) instead of the normal
112+ // multi-provider wallet-to-wallet flow.
113+ const [ isPrivateSwap , setIsPrivateSwap ] = useState ( false )
114+ // Guards the async private-swap flow so a double-tap cannot launch two
115+ // concurrent quote/approve/broadcast sequences.
116+ const [ privateSwapPending , setPrivateSwapPending ] = useState ( false )
117+
98118 const fromInputRef = React . useRef < SwapInputCardInputRef > ( null )
99119 const toInputRef = React . useRef < SwapInputCardInputRef > ( null )
100120
@@ -130,6 +150,16 @@ export const SwapCreateScene: React.FC<Props> = props => {
130150 const hasMaxSpend =
131151 fromWallet != null && fromWalletSpecialCurrencyInfo . noMaxSpend !== true
132152
153+ // Houdini can only privately route to the NATIVE asset of the chains in its
154+ // destination set, so a token destination (toTokenId != null) is unsupported
155+ // even when its chain appears in the set.
156+ const isPrivateSwapSupported =
157+ toWallet != null &&
158+ toTokenId == null &&
159+ HOUDINI_DESTINATION_ASSETS . some (
160+ asset => asset . pluginId === toWallet . currencyInfo . pluginId
161+ )
162+
133163 const isNextHidden =
134164 // Don't show next button if the wallets haven't been selected:
135165 fromWallet == null ||
@@ -149,6 +179,12 @@ export const SwapCreateScene: React.FC<Props> = props => {
149179 } )
150180 } , [ dispatch , navigation ] )
151181
182+ // Keep the private toggle from getting stuck "on" for a destination Houdini
183+ // cannot privately route to (e.g. after the user changes the receive wallet).
184+ React . useEffect ( ( ) => {
185+ if ( isPrivateSwap && ! isPrivateSwapSupported ) setIsPrivateSwap ( false )
186+ } , [ isPrivateSwap , isPrivateSwapSupported ] )
187+
152188 //
153189 // Callbacks
154190 //
@@ -381,7 +417,89 @@ export const SwapCreateScene: React.FC<Props> = props => {
381417 }
382418 )
383419
420+ /**
421+ * Route the current amounts through Houdini's swap-to-address path: derive
422+ * the destination address from the chosen receiving wallet, fetch a
423+ * Houdini-only private quote, confirm, approve, then land on the success
424+ * scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
425+ * destination wallet, so the private path runs its own confirm + approve.
426+ */
427+ const executePrivateSwap = useHandler ( async ( ) : Promise < void > => {
428+ if ( privateSwapPending ) return
429+ if ( fromWallet == null || toWallet == null ) return
430+
431+ if ( zeroString ( inputNativeAmount ) ) {
432+ showToast (
433+ `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
434+ )
435+ return
436+ }
437+ if ( checkAmountExceedsBalance ( ) ) return
438+ // Houdini quotes only off the send amount, so a "to" amount cannot drive a
439+ // private quote.
440+ if ( inputNativeAmountFor !== 'from' ) {
441+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
442+ return
443+ }
444+
445+ setPrivateSwapPending ( true )
446+ try {
447+ const toAddresses = await toWallet . getAddresses ( { tokenId : null } )
448+ const toAddress = toAddresses [ 0 ] ?. publicAddress
449+ if ( toAddress == null ) {
450+ showError ( lstrings . houdini_swap_no_dest_address )
451+ return
452+ }
453+
454+ const quote = await fetchHoudiniPrivateQuote ( account , {
455+ fromWallet,
456+ fromTokenId,
457+ toPluginId : toWallet . currencyInfo . pluginId ,
458+ toTokenId,
459+ toAddress,
460+ nativeAmount : inputNativeAmount
461+ } )
462+
463+ const fromMultiplier = getPrimaryMultiplier (
464+ fromWallet . currencyConfig ,
465+ fromTokenId
466+ )
467+ const toMultiplier = getPrimaryMultiplier (
468+ toWallet . currencyConfig ,
469+ toTokenId
470+ )
471+ const fromDisplay = div ( quote . fromNativeAmount , fromMultiplier , 8 )
472+ const toDisplay = div ( quote . toNativeAmount , toMultiplier , 8 )
473+
474+ const confirmed = await Airship . show < boolean > ( bridge => (
475+ < ConfirmContinueModal
476+ bridge = { bridge }
477+ title = { lstrings . houdini_ps_confirm_send }
478+ body = { `${ fromDisplay } ${ fromCurrencyCode } → ~${ toDisplay } ${ toCurrencyCode } \n\n${ lstrings . houdini_ps_confirm_body } ` }
479+ warning
480+ />
481+ ) )
482+ if ( ! confirmed ) return
483+
484+ const result = await quote . approve ( )
485+ resetState ( )
486+ navigation . push ( 'swapSuccess' , {
487+ edgeTransaction : result . transaction ,
488+ walletId : fromWallet . id
489+ } )
490+ } catch ( error : unknown ) {
491+ showError ( error )
492+ } finally {
493+ setPrivateSwapPending ( false )
494+ }
495+ } )
496+
384497 const handleMaxPress = useHandler ( ( ) => {
498+ if ( isPrivateSwap ) {
499+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
500+ return
501+ }
502+
385503 if ( toWallet == null ) {
386504 showWarning ( lstrings . exchange_select_receiving_wallet , {
387505 trackError : false
@@ -410,10 +528,21 @@ export const SwapCreateScene: React.FC<Props> = props => {
410528 getQuote ( request )
411529 } )
412530
531+ const handleTogglePrivateSwap = useHandler ( ( ) => {
532+ setIsPrivateSwap ( value => ! value )
533+ } )
534+
413535 const handleNext = useHandler ( ( ) => {
414536 // Should only happen if the user initiated the swap from the keyboard
415537 if ( fromWallet == null || toWallet == null ) return
416538
539+ if ( isPrivateSwap ) {
540+ executePrivateSwap ( ) . catch ( ( error : unknown ) => {
541+ showError ( error )
542+ } )
543+ return
544+ }
545+
417546 if ( zeroString ( inputNativeAmount ) ) {
418547 showToast (
419548 `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
@@ -530,7 +659,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
530659 primary = { {
531660 label : lstrings . string_next_capitalized ,
532661 onPress : handleNext ,
533- disabled : isNextHidden
662+ disabled : isNextHidden || privateSwapPending
534663 } }
535664 tertiary = { {
536665 label : lstrings . string_cancel_cap ,
@@ -614,12 +743,23 @@ export const SwapCreateScene: React.FC<Props> = props => {
614743 ) }
615744 </ EdgeAnim >
616745 < EdgeAnim enter = { fadeInDown60 } > { renderAlert ( ) } </ EdgeAnim >
746+ { isPrivateSwapSupported ? (
747+ < EdgeAnim enter = { fadeInDown90 } >
748+ < SettingsSwitchRow
749+ label = { lstrings . houdini_swap_private_label }
750+ value = { isPrivateSwap }
751+ onPress = { handleTogglePrivateSwap }
752+ />
753+ </ EdgeAnim >
754+ ) : null }
617755 < EdgeAnim enter = { fadeInDown90 } >
618756 { isNextHidden || isKeyboardOpen ? null : (
619757 < SceneButtons
620758 primary = { {
621759 label : lstrings . string_next_capitalized ,
622- onPress : handleNext
760+ onPress : handleNext ,
761+ disabled : privateSwapPending ,
762+ spinner : privateSwapPending
623763 } }
624764 />
625765 ) }
0 commit comments