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,11 @@ 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+
98115 const fromInputRef = React . useRef < SwapInputCardInputRef > ( null )
99116 const toInputRef = React . useRef < SwapInputCardInputRef > ( null )
100117
@@ -130,6 +147,13 @@ export const SwapCreateScene: React.FC<Props> = props => {
130147 const hasMaxSpend =
131148 fromWallet != null && fromWalletSpecialCurrencyInfo . noMaxSpend !== true
132149
150+ // Houdini can only privately route to the chains in its destination set.
151+ const isPrivateSwapSupported =
152+ toWallet != null &&
153+ HOUDINI_DESTINATION_ASSETS . some (
154+ asset => asset . pluginId === toWallet . currencyInfo . pluginId
155+ )
156+
133157 const isNextHidden =
134158 // Don't show next button if the wallets haven't been selected:
135159 fromWallet == null ||
@@ -149,6 +173,12 @@ export const SwapCreateScene: React.FC<Props> = props => {
149173 } )
150174 } , [ dispatch , navigation ] )
151175
176+ // Keep the private toggle from getting stuck "on" for a destination Houdini
177+ // cannot privately route to (e.g. after the user changes the receive wallet).
178+ React . useEffect ( ( ) => {
179+ if ( isPrivateSwap && ! isPrivateSwapSupported ) setIsPrivateSwap ( false )
180+ } , [ isPrivateSwap , isPrivateSwapSupported ] )
181+
152182 //
153183 // Callbacks
154184 //
@@ -381,7 +411,85 @@ export const SwapCreateScene: React.FC<Props> = props => {
381411 }
382412 )
383413
414+ /**
415+ * Route the current amounts through Houdini's swap-to-address path: derive
416+ * the destination address from the chosen receiving wallet, fetch a
417+ * Houdini-only private quote, confirm, approve, then land on the success
418+ * scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
419+ * destination wallet, so the private path runs its own confirm + approve.
420+ */
421+ const executePrivateSwap = useHandler ( async ( ) : Promise < void > => {
422+ if ( fromWallet == null || toWallet == null ) return
423+
424+ if ( zeroString ( inputNativeAmount ) ) {
425+ showToast (
426+ `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
427+ )
428+ return
429+ }
430+ if ( checkAmountExceedsBalance ( ) ) return
431+ // Houdini quotes only off the send amount, so a "to" amount cannot drive a
432+ // private quote.
433+ if ( inputNativeAmountFor !== 'from' ) {
434+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
435+ return
436+ }
437+
438+ try {
439+ const toAddresses = await toWallet . getAddresses ( { tokenId : null } )
440+ const toAddress = toAddresses [ 0 ] ?. publicAddress
441+ if ( toAddress == null ) {
442+ showError ( lstrings . houdini_swap_no_dest_address )
443+ return
444+ }
445+
446+ const quote = await fetchHoudiniPrivateQuote ( account , {
447+ fromWallet,
448+ fromTokenId,
449+ toPluginId : toWallet . currencyInfo . pluginId ,
450+ toTokenId,
451+ toAddress,
452+ nativeAmount : inputNativeAmount
453+ } )
454+
455+ const fromMultiplier = getPrimaryMultiplier (
456+ fromWallet . currencyConfig ,
457+ fromTokenId
458+ )
459+ const toMultiplier = getPrimaryMultiplier (
460+ toWallet . currencyConfig ,
461+ toTokenId
462+ )
463+ const fromDisplay = div ( quote . fromNativeAmount , fromMultiplier , 8 )
464+ const toDisplay = div ( quote . toNativeAmount , toMultiplier , 8 )
465+
466+ const confirmed = await Airship . show < boolean > ( bridge => (
467+ < ConfirmContinueModal
468+ bridge = { bridge }
469+ title = { lstrings . houdini_ps_confirm_send }
470+ body = { `${ fromDisplay } ${ fromCurrencyCode } → ~${ toDisplay } ${ toCurrencyCode } \n\n${ lstrings . houdini_ps_confirm_body } ` }
471+ warning
472+ />
473+ ) )
474+ if ( ! confirmed ) return
475+
476+ const result = await quote . approve ( )
477+ resetState ( )
478+ navigation . push ( 'swapSuccess' , {
479+ edgeTransaction : result . transaction ,
480+ walletId : fromWallet . id
481+ } )
482+ } catch ( error : unknown ) {
483+ showError ( error )
484+ }
485+ } )
486+
384487 const handleMaxPress = useHandler ( ( ) => {
488+ if ( isPrivateSwap ) {
489+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
490+ return
491+ }
492+
385493 if ( toWallet == null ) {
386494 showWarning ( lstrings . exchange_select_receiving_wallet , {
387495 trackError : false
@@ -410,10 +518,21 @@ export const SwapCreateScene: React.FC<Props> = props => {
410518 getQuote ( request )
411519 } )
412520
521+ const handleTogglePrivateSwap = useHandler ( ( ) => {
522+ setIsPrivateSwap ( value => ! value )
523+ } )
524+
413525 const handleNext = useHandler ( ( ) => {
414526 // Should only happen if the user initiated the swap from the keyboard
415527 if ( fromWallet == null || toWallet == null ) return
416528
529+ if ( isPrivateSwap ) {
530+ executePrivateSwap ( ) . catch ( ( error : unknown ) => {
531+ showError ( error )
532+ } )
533+ return
534+ }
535+
417536 if ( zeroString ( inputNativeAmount ) ) {
418537 showToast (
419538 `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
@@ -614,6 +733,15 @@ export const SwapCreateScene: React.FC<Props> = props => {
614733 ) }
615734 </ EdgeAnim >
616735 < EdgeAnim enter = { fadeInDown60 } > { renderAlert ( ) } </ EdgeAnim >
736+ { isPrivateSwapSupported ? (
737+ < EdgeAnim enter = { fadeInDown90 } >
738+ < SettingsSwitchRow
739+ label = { lstrings . houdini_swap_private_label }
740+ value = { isPrivateSwap }
741+ onPress = { handleTogglePrivateSwap }
742+ />
743+ </ EdgeAnim >
744+ ) : null }
617745 < EdgeAnim enter = { fadeInDown90 } >
618746 { isNextHidden || isKeyboardOpen ? null : (
619747 < SceneButtons
0 commit comments