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,87 @@ 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+ } finally {
491+ setPrivateSwapPending ( false )
492+ }
493+ } )
494+
384495 const handleMaxPress = useHandler ( ( ) => {
496+ if ( isPrivateSwap ) {
497+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
498+ return
499+ }
500+
385501 if ( toWallet == null ) {
386502 showWarning ( lstrings . exchange_select_receiving_wallet , {
387503 trackError : false
@@ -410,10 +526,21 @@ export const SwapCreateScene: React.FC<Props> = props => {
410526 getQuote ( request )
411527 } )
412528
529+ const handleTogglePrivateSwap = useHandler ( ( ) => {
530+ setIsPrivateSwap ( value => ! value )
531+ } )
532+
413533 const handleNext = useHandler ( ( ) => {
414534 // Should only happen if the user initiated the swap from the keyboard
415535 if ( fromWallet == null || toWallet == null ) return
416536
537+ if ( isPrivateSwap ) {
538+ executePrivateSwap ( ) . catch ( ( error : unknown ) => {
539+ showError ( error )
540+ } )
541+ return
542+ }
543+
417544 if ( zeroString ( inputNativeAmount ) ) {
418545 showToast (
419546 `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
@@ -530,7 +657,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
530657 primary = { {
531658 label : lstrings . string_next_capitalized ,
532659 onPress : handleNext ,
533- disabled : isNextHidden
660+ disabled : isNextHidden || privateSwapPending
534661 } }
535662 tertiary = { {
536663 label : lstrings . string_cancel_cap ,
@@ -614,12 +741,23 @@ export const SwapCreateScene: React.FC<Props> = props => {
614741 ) }
615742 </ EdgeAnim >
616743 < EdgeAnim enter = { fadeInDown60 } > { renderAlert ( ) } </ EdgeAnim >
744+ { isPrivateSwapSupported ? (
745+ < EdgeAnim enter = { fadeInDown90 } >
746+ < SettingsSwitchRow
747+ label = { lstrings . houdini_swap_private_label }
748+ value = { isPrivateSwap }
749+ onPress = { handleTogglePrivateSwap }
750+ />
751+ </ EdgeAnim >
752+ ) : null }
617753 < EdgeAnim enter = { fadeInDown90 } >
618754 { isNextHidden || isKeyboardOpen ? null : (
619755 < SceneButtons
620756 primary = { {
621757 label : lstrings . string_next_capitalized ,
622- onPress : handleNext
758+ onPress : handleNext ,
759+ disabled : privateSwapPending ,
760+ spinner : privateSwapPending
623761 } }
624762 />
625763 ) }
0 commit comments