Skip to content

Commit af4e902

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 0b75c6f commit af4e902

3 files changed

Lines changed: 206 additions & 15 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: 200 additions & 15 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,17 @@ 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+
isAssetDisabled,
33+
isPluginDisabled
34+
} from '../../util/houdiniPrivateSend'
2835
import { zeroString } from '../../util/utils'
2936
import { EdgeButton } from '../buttons/EdgeButton'
3037
import { KavButtons } from '../buttons/KavButtons'
@@ -42,12 +49,19 @@ import { SceneWrapper } from '../common/SceneWrapper'
4249
import { styled } from '../hoc/styled'
4350
import { SwapVerticalIcon } from '../icons/ThemedIcons'
4451
import { SceneContainer } from '../layout/SceneContainer'
52+
import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
4553
import {
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'
5063
import { useTheme } from '../services/ThemeContext'
64+
import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
5165
import { UnscaledText } from '../text/UnscaledText'
5266
import { LineTextDivider } from '../themed/LineTextDivider'
5367
import {
@@ -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 {
@@ -298,6 +333,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
298333
const showWalletListModal = async (
299334
whichWallet: 'from' | 'to'
300335
): Promise<void> => {
336+
// Don't let a wallet change underneath an in-flight private quote.
337+
if (privateSwapPending) return
301338
const result = await Airship.show<WalletListResult>(bridge => (
302339
<WalletListModal
303340
bridge={bridge}
@@ -323,6 +360,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
323360
//
324361

325362
const handleFlipWalletPress = useHandler(() => {
363+
// Don't let the pair change underneath an in-flight private quote.
364+
if (privateSwapPending) return
326365
// Flip params:
327366
navigation.setParams({
328367
fromWalletId: toWalletId,
@@ -381,7 +420,121 @@ export const SwapCreateScene: React.FC<Props> = props => {
381420
}
382421
)
383422

423+
/**
424+
* Route the current amounts through Houdini's swap-to-address path: derive
425+
* the destination address from the chosen receiving wallet, fetch a
426+
* Houdini-only private quote, confirm, approve, then land on the success
427+
* scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
428+
* destination wallet, so the private path runs its own confirm + approve.
429+
*/
430+
const executePrivateSwap = useHandler(async (): Promise<void> => {
431+
if (privateSwapPending) return
432+
if (fromWallet == null || toWallet == null) return
433+
434+
if (zeroString(inputNativeAmount)) {
435+
showToast(
436+
`${lstrings.no_exchange_amount}. ${lstrings.select_exchange_amount}.`
437+
)
438+
return
439+
}
440+
if (checkAmountExceedsBalance()) return
441+
// Houdini quotes only off the send amount, so a "to" amount cannot drive a
442+
// private quote.
443+
if (inputNativeAmountFor !== 'from') {
444+
showWarning(lstrings.houdini_swap_from_amount_only, { trackError: false })
445+
return
446+
}
447+
448+
// Mirror getQuote: honor the exchange-info asset disables for the private
449+
// path too, so a disabled source/destination asset cannot start a swap.
450+
const disableSrc = checkDisableAsset(
451+
exchangeInfo.swap.disableAssets.source,
452+
fromWallet.id,
453+
fromTokenId
454+
)
455+
if (disableSrc) {
456+
showToast(
457+
sprintf(
458+
lstrings.swap_token_no_enabled_exchanges_2s,
459+
fromCurrencyCode,
460+
fromWallet.currencyInfo.displayName
461+
)
462+
)
463+
return
464+
}
465+
const disableDest = checkDisableAsset(
466+
exchangeInfo.swap.disableAssets.destination,
467+
toWallet.id,
468+
toTokenId
469+
)
470+
if (disableDest) {
471+
showToast(
472+
sprintf(
473+
lstrings.swap_token_no_enabled_exchanges_2s,
474+
toCurrencyCode,
475+
toWallet.currencyInfo.displayName
476+
)
477+
)
478+
return
479+
}
480+
481+
setPrivateSwapPending(true)
482+
try {
483+
const toAddresses = await toWallet.getAddresses({ tokenId: null })
484+
const toAddress = toAddresses[0]?.publicAddress
485+
if (toAddress == null) {
486+
showError(lstrings.houdini_swap_no_dest_address)
487+
return
488+
}
489+
490+
const quote = await fetchHoudiniPrivateQuote(account, {
491+
fromWallet,
492+
fromTokenId,
493+
toPluginId: toWallet.currencyInfo.pluginId,
494+
toTokenId,
495+
toAddress,
496+
nativeAmount: inputNativeAmount,
497+
disablePlugins
498+
})
499+
500+
const fromMultiplier = getExchangeDenom(
501+
fromWallet.currencyConfig,
502+
fromTokenId
503+
).multiplier
504+
const toMultiplier = getExchangeDenom(
505+
toWallet.currencyConfig,
506+
toTokenId
507+
).multiplier
508+
const fromDisplay = div(quote.fromNativeAmount, fromMultiplier, 8)
509+
const toDisplay = div(quote.toNativeAmount, toMultiplier, 8)
510+
511+
const confirmed = await Airship.show<boolean>(bridge => (
512+
<ConfirmContinueModal
513+
bridge={bridge}
514+
title={lstrings.houdini_ps_confirm_send}
515+
body={`${fromDisplay} ${fromCurrencyCode} → ~${toDisplay} ${toCurrencyCode}\n\n${lstrings.houdini_ps_confirm_body}`}
516+
warning
517+
/>
518+
))
519+
if (!confirmed) return
520+
521+
const result = await quote.approve()
522+
resetState()
523+
navigation.push('swapSuccess', {
524+
edgeTransaction: result.transaction,
525+
walletId: fromWallet.id
526+
})
527+
} finally {
528+
setPrivateSwapPending(false)
529+
}
530+
})
531+
384532
const handleMaxPress = useHandler(() => {
533+
if (isPrivateSwap) {
534+
showWarning(lstrings.houdini_swap_from_amount_only, { trackError: false })
535+
return
536+
}
537+
385538
if (toWallet == null) {
386539
showWarning(lstrings.exchange_select_receiving_wallet, {
387540
trackError: false
@@ -410,10 +563,26 @@ export const SwapCreateScene: React.FC<Props> = props => {
410563
getQuote(request)
411564
})
412565

566+
const handleTogglePrivateSwap = useHandler(() => {
567+
// Don't let the routing change underneath an in-flight private quote.
568+
if (privateSwapPending) return
569+
setIsPrivateSwap(value => !value)
570+
})
571+
413572
const handleNext = useHandler(() => {
414573
// Should only happen if the user initiated the swap from the keyboard
415574
if (fromWallet == null || toWallet == null) return
416575

576+
if (isPrivateSwap) {
577+
// handleNext feeds the void-typed button onPress, so it must stay
578+
// synchronous; executePrivateSwap owns its own error display, and this
579+
// .catch is the required floating-promise guard.
580+
executePrivateSwap().catch((error: unknown) => {
581+
showError(error)
582+
})
583+
return
584+
}
585+
417586
if (zeroString(inputNativeAmount)) {
418587
showToast(
419588
`${lstrings.no_exchange_amount}. ${lstrings.select_exchange_amount}.`
@@ -448,6 +617,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
448617
})
449618

450619
const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => {
620+
// Don't let the amount change underneath an in-flight private quote.
621+
if (privateSwapPending) return
451622
navigation.setParams({
452623
// Update the error state:
453624
...getNewErrorInfo('amount')
@@ -461,6 +632,8 @@ export const SwapCreateScene: React.FC<Props> = props => {
461632
})
462633

463634
const handleToAmountChange = useHandler((amounts: SwapInputCardAmounts) => {
635+
// Don't let the amount change underneath an in-flight private quote.
636+
if (privateSwapPending) return
464637
navigation.setParams({
465638
// Update the error state:
466639
...getNewErrorInfo('amount')
@@ -530,7 +703,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
530703
primary={{
531704
label: lstrings.string_next_capitalized,
532705
onPress: handleNext,
533-
disabled: isNextHidden
706+
disabled: isNextHidden || privateSwapPending
534707
}}
535708
tertiary={{
536709
label: lstrings.string_cancel_cap,
@@ -614,12 +787,24 @@ export const SwapCreateScene: React.FC<Props> = props => {
614787
)}
615788
</EdgeAnim>
616789
<EdgeAnim enter={fadeInDown60}>{renderAlert()}</EdgeAnim>
790+
{isPrivateSwapSupported ? (
791+
<EdgeAnim enter={fadeInDown90}>
792+
<SettingsSwitchRow
793+
disabled={privateSwapPending}
794+
label={lstrings.houdini_swap_private_label}
795+
value={isPrivateSwap}
796+
onPress={handleTogglePrivateSwap}
797+
/>
798+
</EdgeAnim>
799+
) : null}
617800
<EdgeAnim enter={fadeInDown90}>
618801
{isNextHidden || isKeyboardOpen ? null : (
619802
<SceneButtons
620803
primary={{
621804
label: lstrings.string_next_capitalized,
622-
onPress: handleNext
805+
onPress: handleNext,
806+
disabled: privateSwapPending,
807+
spinner: privateSwapPending
623808
}}
624809
/>
625810
)}

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)