Skip to content

Commit a321ebb

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 6161658 commit a321ebb

3 files changed

Lines changed: 195 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: 189 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 {
@@ -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
)}

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)