Skip to content

Commit 72e1a0b

Browse files
committed
Add Stealth Swap to the existing swap flow
A Stealth Swap toggle card on the amount-entry scene restricts the quote request to the Houdini privacy provider (all other providers disabled for the request), with the final copy and a working "Learn more" link. The confirmation scene keeps the restriction on re-quotes and renders the powered-by card as a fixed provider: no chevron and no "tap to change provider" hint, via a now-optional PoweredByCard onPress.
1 parent ae3d183 commit 72e1a0b

4 files changed

Lines changed: 149 additions & 21 deletions

File tree

src/components/cards/PoweredByCard.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,23 @@ import { EdgeCard } from './EdgeCard'
1111
interface Props {
1212
poweredByText: string
1313
iconUri?: string
14-
onPress: () => Promise<void> | void
14+
// When omitted, the card is not tappable: no chevron and no
15+
// "tap to change provider" hint are shown (e.g. a fixed-provider swap).
16+
onPress?: () => Promise<void> | void
1517
}
1618

1719
/**
1820
* Small card that displays "Powered by {provider}" with an optional logo.
19-
* Tapping the card triggers `onPress` to change the active provider.
21+
* Tapping the card triggers `onPress` to change the active provider. When
22+
* `onPress` is omitted the card is static (no chevron) to indicate the
23+
* provider cannot be changed.
2024
*/
2125
export const PoweredByCard: React.FC<Props> = (props: Props) => {
2226
const { iconUri, poweredByText, onPress } = props
2327
const theme = useTheme()
2428
const styles = getStyles(theme)
2529
const iconSrc = iconUri == null ? {} : { uri: iconUri }
30+
const tappable = onPress != null
2631

2732
return (
2833
<View style={styles.cardContainer}>
@@ -40,13 +45,17 @@ export const PoweredByCard: React.FC<Props> = (props: Props) => {
4045
</EdgeText>
4146
<EdgeText style={styles.poweredByText}>{poweredByText}</EdgeText>
4247
</View>
43-
<View style={styles.poweredByContainerRow}>
44-
<EdgeText style={styles.tapToChangeText}>
45-
{lstrings.tap_to_change_provider}
46-
</EdgeText>
47-
</View>
48+
{tappable ? (
49+
<View style={styles.poweredByContainerRow}>
50+
<EdgeText style={styles.tapToChangeText}>
51+
{lstrings.tap_to_change_provider}
52+
</EdgeText>
53+
</View>
54+
) : null}
4855
</View>
49-
<ChevronRightIcon color={theme.iconTappable} size={theme.rem(1)} />
56+
{tappable ? (
57+
<ChevronRightIcon color={theme.iconTappable} size={theme.rem(1)} />
58+
) : null}
5059
</View>
5160
</EdgeCard>
5261
</View>

src/components/scenes/SwapConfirmationScene.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { GuiSwapInfo } from '../../types/types'
2525
import { getSwapPluginIconUri } from '../../util/CdnUris'
2626
import { CryptoAmount } from '../../util/CryptoAmount'
2727
import { logActivity } from '../../util/logger'
28+
import { makeStealthSwapRequestOptions } from '../../util/stealthSwap'
2829
import { logEvent } from '../../util/tracking'
2930
import { convertNativeToExchange, DECIMAL_PRECISION } from '../../util/utils'
3031
import { AlertCardUi4 } from '../cards/AlertCard'
@@ -62,6 +63,13 @@ export interface SwapConfirmationParams {
6263
selectedQuote: EdgeSwapQuote
6364
quotes: EdgeSwapQuote[]
6465
onApprove: () => void
66+
67+
/**
68+
* A Stealth Swap routes through the Houdini privacy provider as a fixed
69+
* provider: the powered-by card is not tappable and a re-quote keeps the
70+
* provider restriction.
71+
*/
72+
stealth?: boolean
6573
}
6674

6775
interface Props extends SwapTabSceneProps<'swapConfirmation'> {}
@@ -73,7 +81,7 @@ interface Section {
7381

7482
export const SwapConfirmationScene: React.FC<Props> = (props: Props) => {
7583
const { route, navigation } = props
76-
const { quotes, onApprove } = route.params
84+
const { quotes, onApprove, stealth = false } = route.params
7785

7886
const dispatch = useDispatch()
7987
const theme = useTheme()
@@ -185,15 +193,18 @@ export const SwapConfirmationScene: React.FC<Props> = (props: Props) => {
185193

186194
navigation.replace('swapProcessing', {
187195
swapRequest: selectedQuote.request,
188-
swapRequestOptions,
196+
swapRequestOptions: stealth
197+
? makeStealthSwapRequestOptions(account, swapRequestOptions)
198+
: swapRequestOptions,
189199
onCancel: () => {
190200
navigation.navigate('swapTab', { screen: 'swapCreate' })
191201
},
192202
onDone: quotes => {
193203
navigation.replace('swapConfirmation', {
194204
selectedQuote: quotes[0],
195205
quotes,
196-
onApprove
206+
onApprove,
207+
stealth
197208
})
198209
}
199210
})
@@ -461,11 +472,20 @@ export const SwapConfirmationScene: React.FC<Props> = (props: Props) => {
461472
/>
462473
</EdgeAnim>
463474
<EdgeAnim enter={fadeInDown60}>
464-
<PoweredByCard
465-
iconUri={getSwapPluginIconUri(selectedQuote.pluginId, theme)}
466-
poweredByText={exchangeName}
467-
onPress={handlePoweredByTap}
468-
/>
475+
{stealth ? (
476+
// A stealth swap's provider is fixed, so the card is not
477+
// tappable (no chevron, no "tap to change provider"):
478+
<PoweredByCard
479+
iconUri={getSwapPluginIconUri(selectedQuote.pluginId, theme)}
480+
poweredByText={exchangeName}
481+
/>
482+
) : (
483+
<PoweredByCard
484+
iconUri={getSwapPluginIconUri(selectedQuote.pluginId, theme)}
485+
poweredByText={exchangeName}
486+
onPress={handlePoweredByTap}
487+
/>
488+
)}
469489
</EdgeAnim>
470490
{selectedQuote.isEstimate && !showPriceImpact ? (
471491
<EdgeAnim enter={fadeInDown90}>

src/components/scenes/SwapCreateScene.tsx

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ 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 { makeStealthSwapRequestOptions } from '../../util/stealthSwap'
2829
import { zeroString } from '../../util/utils'
30+
import { openBrowserUri } from '../../util/WebUtils'
2931
import { EdgeButton } from '../buttons/EdgeButton'
3032
import { KavButtons } from '../buttons/KavButtons'
3133
import { SceneButtons } from '../buttons/SceneButtons'
3234
import { AlertCardUi4 } from '../cards/AlertCard'
35+
import { EdgeCard } from '../cards/EdgeCard'
3336
import {
3437
EdgeAnim,
3538
fadeInDown30,
@@ -46,9 +49,16 @@ import {
4649
WalletListModal,
4750
type WalletListResult
4851
} from '../modals/WalletListModal'
49-
import { Airship, showToast, showWarning } from '../services/AirshipInstance'
50-
import { useTheme } from '../services/ThemeContext'
52+
import {
53+
Airship,
54+
showError,
55+
showToast,
56+
showWarning
57+
} from '../services/AirshipInstance'
58+
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
59+
import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
5160
import { UnscaledText } from '../text/UnscaledText'
61+
import { EdgeText } from '../themed/EdgeText'
5262
import { LineTextDivider } from '../themed/LineTextDivider'
5363
import {
5464
SwapInput,
@@ -74,6 +84,10 @@ export interface SwapErrorDisplayInfo {
7484
error: unknown
7585
}
7686

87+
/** Placeholder pending the final marketing URL. */
88+
const STEALTH_LEARN_MORE_URI =
89+
'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b'
90+
7791
interface Props extends SwapTabSceneProps<'swapCreate'> {}
7892

7993
export const SwapCreateScene: React.FC<Props> = props => {
@@ -86,6 +100,7 @@ export const SwapCreateScene: React.FC<Props> = props => {
86100
errorDisplayInfo
87101
} = route.params ?? {}
88102
const theme = useTheme()
103+
const styles = getStyles(theme)
89104
const dispatch = useDispatch()
90105

91106
// Input state is the state of the user input
@@ -95,6 +110,10 @@ export const SwapCreateScene: React.FC<Props> = props => {
95110
'from' | 'to'
96111
>('from')
97112

113+
// Stealth Swap: when enabled, the quote routes through the Houdini privacy
114+
// provider as a fixed provider (see SwapConfirmationScene).
115+
const [stealth, setStealth] = useState(false)
116+
98117
const fromInputRef = React.useRef<SwapInputCardInputRef>(null)
99118
const toInputRef = React.useRef<SwapInputCardInputRef>(null)
100119

@@ -272,18 +291,22 @@ export const SwapCreateScene: React.FC<Props> = props => {
272291
errorDisplayInfo: undefined
273292
})
274293

275-
// Start request for quote:
294+
// Start request for quote. A stealth swap restricts the request to the
295+
// Houdini privacy provider:
276296
navigation.navigate('swapProcessing', {
277297
swapRequest,
278-
swapRequestOptions,
298+
swapRequestOptions: stealth
299+
? makeStealthSwapRequestOptions(account, swapRequestOptions)
300+
: swapRequestOptions,
279301
onCancel: () => {
280302
navigation.goBack()
281303
},
282304
onDone: quotes => {
283305
navigation.replace('swapConfirmation', {
284306
selectedQuote: quotes[0],
285307
quotes,
286-
onApprove: resetState
308+
onApprove: resetState,
309+
stealth
287310
})
288311
}
289312
})
@@ -447,6 +470,16 @@ export const SwapCreateScene: React.FC<Props> = props => {
447470
Keyboard.dismiss()
448471
})
449472

473+
const handleToggleStealth = useHandler(() => {
474+
setStealth(value => !value)
475+
})
476+
477+
const handleStealthLearnMore = useHandler(() => {
478+
openBrowserUri(STEALTH_LEARN_MORE_URI).catch((err: unknown) => {
479+
showError(err)
480+
})
481+
})
482+
450483
const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => {
451484
navigation.setParams({
452485
// Update the error state:
@@ -613,6 +646,30 @@ export const SwapCreateScene: React.FC<Props> = props => {
613646
/>
614647
)}
615648
</EdgeAnim>
649+
{fromWallet != null && toWallet != null ? (
650+
<EdgeAnim enter={fadeInDown60}>
651+
<EdgeCard sections>
652+
<SettingsSwitchRow
653+
label={lstrings.stealth_swap_toggle}
654+
value={stealth}
655+
onPress={handleToggleStealth}
656+
/>
657+
{stealth ? (
658+
<View style={styles.stealthInfo}>
659+
<EdgeText style={styles.stealthInfoText} numberOfLines={4}>
660+
{lstrings.stealth_swap_info}{' '}
661+
<EdgeText
662+
style={styles.stealthLearnMoreLink}
663+
onPress={handleStealthLearnMore}
664+
>
665+
{lstrings.stealth_learn_more}
666+
</EdgeText>
667+
</EdgeText>
668+
</View>
669+
) : null}
670+
</EdgeCard>
671+
</EdgeAnim>
672+
) : null}
616673
<EdgeAnim enter={fadeInDown60}>{renderAlert()}</EdgeAnim>
617674
<EdgeAnim enter={fadeInDown90}>
618675
{isNextHidden || isKeyboardOpen ? null : (
@@ -642,3 +699,18 @@ const MaxButtonText = styled(UnscaledText)(theme => ({
642699
fontSize: theme.rem(0.75),
643700
includeFontPadding: false
644701
}))
702+
703+
const getStyles = cacheStyles((theme: Theme) => ({
704+
stealthInfo: {
705+
paddingHorizontal: theme.rem(1),
706+
paddingBottom: theme.rem(0.75)
707+
},
708+
stealthInfoText: {
709+
color: theme.secondaryText,
710+
fontSize: theme.rem(0.75)
711+
},
712+
stealthLearnMoreLink: {
713+
color: theme.textLink,
714+
fontSize: theme.rem(0.75)
715+
}
716+
}))

src/util/stealthSwap.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type {
2+
EdgeAccount,
3+
EdgePluginMap,
4+
EdgeSwapRequestOptions
5+
} from 'edge-core-js'
6+
7+
/**
8+
* Restricts a swap request to the Houdini privacy provider, for Stealth Swap
9+
* and Stealth Send. Every other enabled swap provider is disabled for the
10+
* request, and any preferred-provider override is cleared so it cannot fight
11+
* the restriction.
12+
*/
13+
export function makeStealthSwapRequestOptions(
14+
account: EdgeAccount,
15+
opts: EdgeSwapRequestOptions = {}
16+
): EdgeSwapRequestOptions {
17+
const disabled: EdgePluginMap<true> = { ...opts.disabled }
18+
for (const swapPluginId of Object.keys(account.swapConfig)) {
19+
if (swapPluginId !== 'houdini') disabled[swapPluginId] = true
20+
}
21+
return {
22+
...opts,
23+
disabled,
24+
preferPluginId: undefined,
25+
preferType: undefined
26+
}
27+
}

0 commit comments

Comments
 (0)