Skip to content

Commit c3b4aaf

Browse files
committed
Add a private swap toggle to the swap amount scene
When enabled, the swap routes privately through Houdini's swap-to-address path: the destination address is derived from the chosen receive wallet, a Houdini-only private quote is fetched, confirmed, and approved. The toggle only appears for destination chains Houdini can privately route to.
1 parent dd45ccd commit c3b4aaf

3 files changed

Lines changed: 191 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased (develop)
44

5+
- added: Private swap toggle on the swap amount scene that routes the exchange through Houdini's swap-to-address path to the chosen receive wallet. Depends on unpublished edge-core-js and edge-exchange-plugins changes.
56
- added: Houdini private send prototype (dev-only): a swap-to-address flow that gets a live HoudiniSwap private quote, creates the private exchange order, and broadcasts the on-chain deposit. Depends on unpublished edge-core-js and edge-exchange-plugins changes.
67
- added: Logbox disable option to env.json
78
- added: Reverse-resolve recipient addresses to ENS / Unstoppable Domains / ZNS names in the send flow, address modal, and transaction history.

src/components/scenes/SwapCreateScene.tsx

Lines changed: 185 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { gt, gte } from 'biggystring'
1+
import { div, gt, gte } from 'biggystring'
22
import {
33
asMaybeInsufficientFundsError,
44
asMaybeSwapAboveLimitError,
@@ -21,10 +21,16 @@ import { useSwapRequestOptions } from '../../hooks/swap/useSwapRequestOptions'
2121
import { useHandler } from '../../hooks/useHandler'
2222
import { useWatch } from '../../hooks/useWatch'
2323
import { lstrings } from '../../locales/strings'
24+
import { getExchangeDenom } from '../../selectors/DenominationSelectors'
2425
import { useDispatch, useSelector } from '../../types/reactRedux'
2526
import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes'
2627
import { getCurrencyCode } from '../../util/CurrencyInfoHelpers'
2728
import { getWalletName } from '../../util/CurrencyWalletHelpers'
29+
import {
30+
fetchHoudiniPrivateQuote,
31+
HOUDINI_DESTINATION_ASSETS,
32+
isPluginDisabled
33+
} from '../../util/houdiniPrivateSend'
2834
import { zeroString } from '../../util/utils'
2935
import { EdgeButton } from '../buttons/EdgeButton'
3036
import { KavButtons } from '../buttons/KavButtons'
@@ -42,12 +48,19 @@ import { SceneWrapper } from '../common/SceneWrapper'
4248
import { styled } from '../hoc/styled'
4349
import { SwapVerticalIcon } from '../icons/ThemedIcons'
4450
import { SceneContainer } from '../layout/SceneContainer'
51+
import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
4552
import {
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'
5062
import { useTheme } from '../services/ThemeContext'
63+
import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
5164
import { UnscaledText } from '../text/UnscaledText'
5265
import { LineTextDivider } from '../themed/LineTextDivider'
5366
import {
@@ -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
)}

src/locales/en_US.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,6 +1852,11 @@ const strings = {
18521852
'Select a source wallet, destination asset, address, and amount first',
18531853
houdini_ps_pick_dest_asset_first: 'Select a destination asset first',
18541854
houdini_ps_pick_source_first: 'Select a source wallet first',
1855+
houdini_swap_private_label: 'Private swap',
1856+
houdini_swap_from_amount_only:
1857+
'Private swaps quote from the send amount only',
1858+
houdini_swap_no_dest_address:
1859+
'Could not get a destination address for the selected wallet',
18551860
deposit_to_bank: 'Deposit to Bank',
18561861
your_wallets: 'Your Wallets',
18571862
pause_wallet_toast:

0 commit comments

Comments
 (0)