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 {
@@ -381,7 +416,121 @@ export const SwapCreateScene: React.FC<Props> = props => {
381416 }
382417 )
383418
419+ /**
420+ * Route the current amounts through Houdini's swap-to-address path: derive
421+ * the destination address from the chosen receiving wallet, fetch a
422+ * Houdini-only private quote, confirm, approve, then land on the success
423+ * scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
424+ * destination wallet, so the private path runs its own confirm + approve.
425+ */
426+ const executePrivateSwap = useHandler ( async ( ) : Promise < void > => {
427+ if ( privateSwapPending ) return
428+ if ( fromWallet == null || toWallet == null ) return
429+
430+ if ( zeroString ( inputNativeAmount ) ) {
431+ showToast (
432+ `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
433+ )
434+ return
435+ }
436+ if ( checkAmountExceedsBalance ( ) ) return
437+ // Houdini quotes only off the send amount, so a "to" amount cannot drive a
438+ // private quote.
439+ if ( inputNativeAmountFor !== 'from' ) {
440+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
441+ return
442+ }
443+
444+ // Mirror getQuote: honor the exchange-info asset disables for the private
445+ // path too, so a disabled source/destination asset cannot start a swap.
446+ const disableSrc = checkDisableAsset (
447+ exchangeInfo . swap . disableAssets . source ,
448+ fromWallet . id ,
449+ fromTokenId
450+ )
451+ if ( disableSrc ) {
452+ showToast (
453+ sprintf (
454+ lstrings . swap_token_no_enabled_exchanges_2s ,
455+ fromCurrencyCode ,
456+ fromWallet . currencyInfo . displayName
457+ )
458+ )
459+ return
460+ }
461+ const disableDest = checkDisableAsset (
462+ exchangeInfo . swap . disableAssets . destination ,
463+ toWallet . id ,
464+ toTokenId
465+ )
466+ if ( disableDest ) {
467+ showToast (
468+ sprintf (
469+ lstrings . swap_token_no_enabled_exchanges_2s ,
470+ toCurrencyCode ,
471+ toWallet . currencyInfo . displayName
472+ )
473+ )
474+ return
475+ }
476+
477+ setPrivateSwapPending ( true )
478+ try {
479+ const toAddresses = await toWallet . getAddresses ( { tokenId : null } )
480+ const toAddress = toAddresses [ 0 ] ?. publicAddress
481+ if ( toAddress == null ) {
482+ showError ( lstrings . houdini_swap_no_dest_address )
483+ return
484+ }
485+
486+ const quote = await fetchHoudiniPrivateQuote ( account , {
487+ fromWallet,
488+ fromTokenId,
489+ toPluginId : toWallet . currencyInfo . pluginId ,
490+ toTokenId,
491+ toAddress,
492+ nativeAmount : inputNativeAmount ,
493+ disablePlugins
494+ } )
495+
496+ const fromMultiplier = getExchangeDenom (
497+ fromWallet . currencyConfig ,
498+ fromTokenId
499+ ) . multiplier
500+ const toMultiplier = getExchangeDenom (
501+ toWallet . currencyConfig ,
502+ toTokenId
503+ ) . multiplier
504+ const fromDisplay = div ( quote . fromNativeAmount , fromMultiplier , 8 )
505+ const toDisplay = div ( quote . toNativeAmount , toMultiplier , 8 )
506+
507+ const confirmed = await Airship . show < boolean > ( bridge => (
508+ < ConfirmContinueModal
509+ bridge = { bridge }
510+ title = { lstrings . houdini_ps_confirm_send }
511+ body = { `${ fromDisplay } ${ fromCurrencyCode } → ~${ toDisplay } ${ toCurrencyCode } \n\n${ lstrings . houdini_ps_confirm_body } ` }
512+ warning
513+ />
514+ ) )
515+ if ( ! confirmed ) return
516+
517+ const result = await quote . approve ( )
518+ resetState ( )
519+ navigation . push ( 'swapSuccess' , {
520+ edgeTransaction : result . transaction ,
521+ walletId : fromWallet . id
522+ } )
523+ } finally {
524+ setPrivateSwapPending ( false )
525+ }
526+ } )
527+
384528 const handleMaxPress = useHandler ( ( ) => {
529+ if ( isPrivateSwap ) {
530+ showWarning ( lstrings . houdini_swap_from_amount_only , { trackError : false } )
531+ return
532+ }
533+
385534 if ( toWallet == null ) {
386535 showWarning ( lstrings . exchange_select_receiving_wallet , {
387536 trackError : false
@@ -410,10 +559,24 @@ export const SwapCreateScene: React.FC<Props> = props => {
410559 getQuote ( request )
411560 } )
412561
562+ const handleTogglePrivateSwap = useHandler ( ( ) => {
563+ setIsPrivateSwap ( value => ! value )
564+ } )
565+
413566 const handleNext = useHandler ( ( ) => {
414567 // Should only happen if the user initiated the swap from the keyboard
415568 if ( fromWallet == null || toWallet == null ) return
416569
570+ if ( isPrivateSwap ) {
571+ // handleNext feeds the void-typed button onPress, so it must stay
572+ // synchronous; executePrivateSwap owns its own error display, and this
573+ // .catch is the required floating-promise guard.
574+ executePrivateSwap ( ) . catch ( ( error : unknown ) => {
575+ showError ( error )
576+ } )
577+ return
578+ }
579+
417580 if ( zeroString ( inputNativeAmount ) ) {
418581 showToast (
419582 `${ lstrings . no_exchange_amount } . ${ lstrings . select_exchange_amount } .`
@@ -530,7 +693,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
530693 primary = { {
531694 label : lstrings . string_next_capitalized ,
532695 onPress : handleNext ,
533- disabled : isNextHidden
696+ disabled : isNextHidden || privateSwapPending
534697 } }
535698 tertiary = { {
536699 label : lstrings . string_cancel_cap ,
@@ -614,12 +777,23 @@ export const SwapCreateScene: React.FC<Props> = props => {
614777 ) }
615778 </ EdgeAnim >
616779 < EdgeAnim enter = { fadeInDown60 } > { renderAlert ( ) } </ EdgeAnim >
780+ { isPrivateSwapSupported ? (
781+ < EdgeAnim enter = { fadeInDown90 } >
782+ < SettingsSwitchRow
783+ label = { lstrings . houdini_swap_private_label }
784+ value = { isPrivateSwap }
785+ onPress = { handleTogglePrivateSwap }
786+ />
787+ </ EdgeAnim >
788+ ) : null }
617789 < EdgeAnim enter = { fadeInDown90 } >
618790 { isNextHidden || isKeyboardOpen ? null : (
619791 < SceneButtons
620792 primary = { {
621793 label : lstrings . string_next_capitalized ,
622- onPress : handleNext
794+ onPress : handleNext ,
795+ disabled : privateSwapPending ,
796+ spinner : privateSwapPending
623797 } }
624798 />
625799 ) }
0 commit comments