Skip to content

Commit 13e6d6b

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 c204165 commit 13e6d6b

3 files changed

Lines changed: 148 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: 142 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,
@@ -25,6 +25,11 @@ import { useDispatch, useSelector } from '../../types/reactRedux'
2525
import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes'
2626
import { getCurrencyCode } from '../../util/CurrencyInfoHelpers'
2727
import { getWalletName } from '../../util/CurrencyWalletHelpers'
28+
import {
29+
fetchHoudiniPrivateQuote,
30+
getPrimaryMultiplier,
31+
HOUDINI_DESTINATION_ASSETS
32+
} from '../../util/houdiniPrivateSend'
2833
import { zeroString } from '../../util/utils'
2934
import { EdgeButton } from '../buttons/EdgeButton'
3035
import { KavButtons } from '../buttons/KavButtons'
@@ -42,12 +47,19 @@ import { SceneWrapper } from '../common/SceneWrapper'
4247
import { styled } from '../hoc/styled'
4348
import { SwapVerticalIcon } from '../icons/ThemedIcons'
4449
import { SceneContainer } from '../layout/SceneContainer'
50+
import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
4551
import {
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'
5061
import { useTheme } from '../services/ThemeContext'
62+
import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
5163
import { UnscaledText } from '../text/UnscaledText'
5264
import { LineTextDivider } from '../themed/LineTextDivider'
5365
import {
@@ -95,6 +107,14 @@ 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+
// Guards the async private-swap flow so a double-tap cannot launch two
115+
// concurrent quote/approve/broadcast sequences.
116+
const [privateSwapPending, setPrivateSwapPending] = useState(false)
117+
98118
const fromInputRef = React.useRef<SwapInputCardInputRef>(null)
99119
const toInputRef = React.useRef<SwapInputCardInputRef>(null)
100120

@@ -130,6 +150,16 @@ export const SwapCreateScene: React.FC<Props> = props => {
130150
const hasMaxSpend =
131151
fromWallet != null && fromWalletSpecialCurrencyInfo.noMaxSpend !== true
132152

153+
// Houdini can only privately route to the NATIVE asset of the chains in its
154+
// destination set, so a token destination (toTokenId != null) is unsupported
155+
// even when its chain appears in the set.
156+
const isPrivateSwapSupported =
157+
toWallet != null &&
158+
toTokenId == null &&
159+
HOUDINI_DESTINATION_ASSETS.some(
160+
asset => asset.pluginId === toWallet.currencyInfo.pluginId
161+
)
162+
133163
const isNextHidden =
134164
// Don't show next button if the wallets haven't been selected:
135165
fromWallet == null ||
@@ -149,6 +179,12 @@ export const SwapCreateScene: React.FC<Props> = props => {
149179
})
150180
}, [dispatch, navigation])
151181

182+
// Keep the private toggle from getting stuck "on" for a destination Houdini
183+
// cannot privately route to (e.g. after the user changes the receive wallet).
184+
React.useEffect(() => {
185+
if (isPrivateSwap && !isPrivateSwapSupported) setIsPrivateSwap(false)
186+
}, [isPrivateSwap, isPrivateSwapSupported])
187+
152188
//
153189
// Callbacks
154190
//
@@ -381,7 +417,87 @@ export const SwapCreateScene: React.FC<Props> = props => {
381417
}
382418
)
383419

420+
/**
421+
* Route the current amounts through Houdini's swap-to-address path: derive
422+
* the destination address from the chosen receiving wallet, fetch a
423+
* Houdini-only private quote, confirm, approve, then land on the success
424+
* scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
425+
* destination wallet, so the private path runs its own confirm + approve.
426+
*/
427+
const executePrivateSwap = useHandler(async (): Promise<void> => {
428+
if (privateSwapPending) return
429+
if (fromWallet == null || toWallet == null) return
430+
431+
if (zeroString(inputNativeAmount)) {
432+
showToast(
433+
`${lstrings.no_exchange_amount}. ${lstrings.select_exchange_amount}.`
434+
)
435+
return
436+
}
437+
if (checkAmountExceedsBalance()) return
438+
// Houdini quotes only off the send amount, so a "to" amount cannot drive a
439+
// private quote.
440+
if (inputNativeAmountFor !== 'from') {
441+
showWarning(lstrings.houdini_swap_from_amount_only, { trackError: false })
442+
return
443+
}
444+
445+
setPrivateSwapPending(true)
446+
try {
447+
const toAddresses = await toWallet.getAddresses({ tokenId: null })
448+
const toAddress = toAddresses[0]?.publicAddress
449+
if (toAddress == null) {
450+
showError(lstrings.houdini_swap_no_dest_address)
451+
return
452+
}
453+
454+
const quote = await fetchHoudiniPrivateQuote(account, {
455+
fromWallet,
456+
fromTokenId,
457+
toPluginId: toWallet.currencyInfo.pluginId,
458+
toTokenId,
459+
toAddress,
460+
nativeAmount: inputNativeAmount
461+
})
462+
463+
const fromMultiplier = getPrimaryMultiplier(
464+
fromWallet.currencyConfig,
465+
fromTokenId
466+
)
467+
const toMultiplier = getPrimaryMultiplier(
468+
toWallet.currencyConfig,
469+
toTokenId
470+
)
471+
const fromDisplay = div(quote.fromNativeAmount, fromMultiplier, 8)
472+
const toDisplay = div(quote.toNativeAmount, toMultiplier, 8)
473+
474+
const confirmed = await Airship.show<boolean>(bridge => (
475+
<ConfirmContinueModal
476+
bridge={bridge}
477+
title={lstrings.houdini_ps_confirm_send}
478+
body={`${fromDisplay} ${fromCurrencyCode} → ~${toDisplay} ${toCurrencyCode}\n\n${lstrings.houdini_ps_confirm_body}`}
479+
warning
480+
/>
481+
))
482+
if (!confirmed) return
483+
484+
const result = await quote.approve()
485+
resetState()
486+
navigation.push('swapSuccess', {
487+
edgeTransaction: result.transaction,
488+
walletId: fromWallet.id
489+
})
490+
} finally {
491+
setPrivateSwapPending(false)
492+
}
493+
})
494+
384495
const handleMaxPress = useHandler(() => {
496+
if (isPrivateSwap) {
497+
showWarning(lstrings.houdini_swap_from_amount_only, { trackError: false })
498+
return
499+
}
500+
385501
if (toWallet == null) {
386502
showWarning(lstrings.exchange_select_receiving_wallet, {
387503
trackError: false
@@ -410,10 +526,21 @@ export const SwapCreateScene: React.FC<Props> = props => {
410526
getQuote(request)
411527
})
412528

529+
const handleTogglePrivateSwap = useHandler(() => {
530+
setIsPrivateSwap(value => !value)
531+
})
532+
413533
const handleNext = useHandler(() => {
414534
// Should only happen if the user initiated the swap from the keyboard
415535
if (fromWallet == null || toWallet == null) return
416536

537+
if (isPrivateSwap) {
538+
executePrivateSwap().catch((error: unknown) => {
539+
showError(error)
540+
})
541+
return
542+
}
543+
417544
if (zeroString(inputNativeAmount)) {
418545
showToast(
419546
`${lstrings.no_exchange_amount}. ${lstrings.select_exchange_amount}.`
@@ -530,7 +657,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
530657
primary={{
531658
label: lstrings.string_next_capitalized,
532659
onPress: handleNext,
533-
disabled: isNextHidden
660+
disabled: isNextHidden || privateSwapPending
534661
}}
535662
tertiary={{
536663
label: lstrings.string_cancel_cap,
@@ -614,12 +741,23 @@ export const SwapCreateScene: React.FC<Props> = props => {
614741
)}
615742
</EdgeAnim>
616743
<EdgeAnim enter={fadeInDown60}>{renderAlert()}</EdgeAnim>
744+
{isPrivateSwapSupported ? (
745+
<EdgeAnim enter={fadeInDown90}>
746+
<SettingsSwitchRow
747+
label={lstrings.houdini_swap_private_label}
748+
value={isPrivateSwap}
749+
onPress={handleTogglePrivateSwap}
750+
/>
751+
</EdgeAnim>
752+
) : null}
617753
<EdgeAnim enter={fadeInDown90}>
618754
{isNextHidden || isKeyboardOpen ? null : (
619755
<SceneButtons
620756
primary={{
621757
label: lstrings.string_next_capitalized,
622-
onPress: handleNext
758+
onPress: handleNext,
759+
disabled: privateSwapPending,
760+
spinner: privateSwapPending
623761
}}
624762
/>
625763
)}

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)