Skip to content

Commit 176a17c

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 6936d6e commit 176a17c

3 files changed

Lines changed: 136 additions & 2 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: 130 additions & 2 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,11 @@ 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+
98115
const fromInputRef = React.useRef<SwapInputCardInputRef>(null)
99116
const toInputRef = React.useRef<SwapInputCardInputRef>(null)
100117

@@ -130,6 +147,13 @@ export const SwapCreateScene: React.FC<Props> = props => {
130147
const hasMaxSpend =
131148
fromWallet != null && fromWalletSpecialCurrencyInfo.noMaxSpend !== true
132149

150+
// Houdini can only privately route to the chains in its destination set.
151+
const isPrivateSwapSupported =
152+
toWallet != null &&
153+
HOUDINI_DESTINATION_ASSETS.some(
154+
asset => asset.pluginId === toWallet.currencyInfo.pluginId
155+
)
156+
133157
const isNextHidden =
134158
// Don't show next button if the wallets haven't been selected:
135159
fromWallet == null ||
@@ -149,6 +173,12 @@ export const SwapCreateScene: React.FC<Props> = props => {
149173
})
150174
}, [dispatch, navigation])
151175

176+
// Keep the private toggle from getting stuck "on" for a destination Houdini
177+
// cannot privately route to (e.g. after the user changes the receive wallet).
178+
React.useEffect(() => {
179+
if (isPrivateSwap && !isPrivateSwapSupported) setIsPrivateSwap(false)
180+
}, [isPrivateSwap, isPrivateSwapSupported])
181+
152182
//
153183
// Callbacks
154184
//
@@ -381,7 +411,85 @@ export const SwapCreateScene: React.FC<Props> = props => {
381411
}
382412
)
383413

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

521+
const handleTogglePrivateSwap = useHandler(() => {
522+
setIsPrivateSwap(value => !value)
523+
})
524+
413525
const handleNext = useHandler(() => {
414526
// Should only happen if the user initiated the swap from the keyboard
415527
if (fromWallet == null || toWallet == null) return
416528

529+
if (isPrivateSwap) {
530+
executePrivateSwap().catch((error: unknown) => {
531+
showError(error)
532+
})
533+
return
534+
}
535+
417536
if (zeroString(inputNativeAmount)) {
418537
showToast(
419538
`${lstrings.no_exchange_amount}. ${lstrings.select_exchange_amount}.`
@@ -614,6 +733,15 @@ export const SwapCreateScene: React.FC<Props> = props => {
614733
)}
615734
</EdgeAnim>
616735
<EdgeAnim enter={fadeInDown60}>{renderAlert()}</EdgeAnim>
736+
{isPrivateSwapSupported ? (
737+
<EdgeAnim enter={fadeInDown90}>
738+
<SettingsSwitchRow
739+
label={lstrings.houdini_swap_private_label}
740+
value={isPrivateSwap}
741+
onPress={handleTogglePrivateSwap}
742+
/>
743+
</EdgeAnim>
744+
) : null}
617745
<EdgeAnim enter={fadeInDown90}>
618746
{isNextHidden || isKeyboardOpen ? null : (
619747
<SceneButtons

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)