diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe47fb7f1..c9a1f5f24f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- added: Houdini private-send prototype scene (Proposal A) reachable from a wallet's Send button, with hard-coded linked amounts, a recipient-asset picker, a Private send toggle, and modal vs swap-success completion paths. - added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. ## 4.49.0 (staging) diff --git a/maestro/14-houdini/houdini-send-a.yaml b/maestro/14-houdini/houdini-send-a.yaml new file mode 100644 index 00000000000..1fe10a95157 --- /dev/null +++ b/maestro/14-houdini/houdini-send-a.yaml @@ -0,0 +1,85 @@ +# Houdini incognito-send prototype walk — Proposal A (From -> To grouping). +# +# Opens the Bitcoin wallet, taps Send (the wallet Send button is routed to the +# prototype scene), and walks every branch. Nothing talks to Houdini; every +# value is hard-coded by HoudiniSendScene. The reroute passes variant 'a1' (the +# "Recipient receives" row is visible before a selection); variant 'a2' hides +# that row until a selection is made. +# +# Screenshots captured (under ~/.maestro/tests// ): +# houdini-a-01-plain-initial same-asset start: add-recipient affordance, +# no amount / fee rows yet, "Recipient receives" +# visible (a1) +# houdini-a-02-after-address after a recipient is chosen: You send + Network +# Fee rows appear +# houdini-a-03-incognito incognito on: toggle tile expands with +# messaging, Recipient gets shows conversion % +# houdini-a-04-swap-xmr cross-asset XMR: conversion % + Destination Tag +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - houdini +--- +- launchApp +- runFlow: + when: + visible: 'Exit PIN' + commands: + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 1500 } +- runFlow: + when: + visible: 'Security is Our Priority' + commands: + - tapOn: 'Cancel' +# Dismiss the unrelated Stellar/Horizon plugin error toast if present. +- tapOn: + text: '.*Horizon.*' + optional: true + +# Open the Bitcoin wallet, then Send (routed to the prototype scene). Gate on +# the wallet tx-list "Receive" button so Send cannot misfire on the home Send. +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin' +- assertVisible: 'Receive' +- tapOn: 'Send' +- waitForAnimationToEnd: + timeout: 3000 + +# Plain same-asset start: add-recipient affordance, no amount/fee rows yet. +- assertVisible: 'Stealth Send' +- assertVisible: 'Send to Address' +- takeScreenshot: houdini-a-01-plain-initial + +# Choose a recipient via self-transfer -> reveals the amount + fee rows. +- tapOn: + text: '.*Myself' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin 3' +- waitForAnimationToEnd: + timeout: 2500 +- assertVisible: 'You send' +- assertVisible: 'Network Fee' +- takeScreenshot: houdini-a-02-after-address + +# Incognito on: toggle tile expands, Recipient gets shows the conversion percent. +- tapOn: 'Stealth Send' +- assertVisible: 'Uses a route that helps obfuscate.*' +- assertVisible: 'Recipient gets' +- takeScreenshot: houdini-a-03-incognito + +# Cross-asset: pick XMR (asset mismatch routes through Houdini). +- tapOn: 'Stealth Send' +- tapOn: 'Recipient receives' +- tapOn: + text: 'XMR Monero.*' +- assertVisible: 'Monero' +- assertVisible: 'Destination Tag' +- takeScreenshot: houdini-a-04-swap-xmr diff --git a/maestro/14-houdini/houdini-swap-a.yaml b/maestro/14-houdini/houdini-swap-a.yaml new file mode 100644 index 00000000000..c882464ae80 --- /dev/null +++ b/maestro/14-houdini/houdini-swap-a.yaml @@ -0,0 +1,54 @@ +# Houdini incognito-swap prototype walk — Proposal A. +# +# Opens the Bitcoin wallet, taps Trade -> "Swap BTC to/from another crypto" +# (the production swap flow), then on the swap-create scene shows the added +# incognito toggle in a card during amount entry. With incognito on, the +# resulting quote scene shows a fixed-provider "Powered by Houdini" (no +# chevron). Nothing talks to Houdini; the quote itself is a real provider +# quote re-skinned for the prototype. +# +# Screenshots captured (under ~/.maestro/tests// ): +# houdini-swap-01-entry from/to amount entry, incognito toggle in a card +# houdini-swap-02-incognito incognito on: toggle card expands with messaging +# houdini-swap-03-quote quote scene, "Powered by Houdini" (no chevron) +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - houdini +--- +- launchApp +- runFlow: + when: + visible: 'Exit PIN' + commands: + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 1500 } +- tapOn: + text: '.*Horizon.*' + optional: true + +# Open the Bitcoin wallet, then Trade -> Swap (production swap flow). +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin' +- assertVisible: 'Receive' +- tapOn: 'Trade' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Swap BTC to/from another crypto' +- waitForAnimationToEnd: + timeout: 3000 + +# Swap amount entry with the added incognito toggle in a card. +- assertVisible: 'Stealth Swap' +- takeScreenshot: houdini-swap-01-entry + +# Incognito on: the toggle card expands with messaging. +- tapOn: 'Stealth Swap' +- assertVisible: 'Routes your swap through multiple.*' +- takeScreenshot: houdini-swap-02-incognito diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 1d67d8131e3..f93c0d5b296 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -106,6 +106,7 @@ import { } from './scenes/GuiPluginListScene' import { GuiPluginViewScene as GuiPluginViewSceneComponent } from './scenes/GuiPluginViewScene' import { HomeScene as HomeSceneComponent } from './scenes/HomeScene' +import { HoudiniSendScene as HoudiniSendSceneComponent } from './scenes/HoudiniSendScene' import { LoanCloseScene as LoanCloseSceneComponent } from './scenes/Loans/LoanCloseScene' import { LoanCreateConfirmationScene as LoanCreateConfirmationSceneComponent } from './scenes/Loans/LoanCreateConfirmationScene' import { LoanCreateScene as LoanCreateSceneComponent } from './scenes/Loans/LoanCreateScene' @@ -242,6 +243,7 @@ const FioStakingChangeScene = ifLoggedIn(FioStakingChangeSceneComponent) const FioStakingOverviewScene = ifLoggedIn(FioStakingOverviewSceneComponent) const GuiPluginViewScene = ifLoggedIn(GuiPluginViewSceneComponent) const HomeScene = ifLoggedIn(HomeSceneComponent) +const HoudiniSendScene = ifLoggedIn(HoudiniSendSceneComponent) const GiftCardAccountInfoScene = ifLoggedIn(GiftCardAccountInfoSceneComponent) const GiftCardListScene = ifLoggedIn(GiftCardListSceneComponent) const GiftCardMarketScene = ifLoggedIn(GiftCardMarketSceneComponent) @@ -1087,6 +1089,7 @@ const EdgeAppStack: React.FC = () => { options={{ headerShown: false }} /> + Promise | void + // When omitted, the card is not tappable: no chevron and no + // "tap to change provider" hint are shown (e.g. a fixed-provider swap). + onPress?: () => Promise | void } /** * Small card that displays "Powered by {provider}" with an optional logo. - * Tapping the card triggers `onPress` to change the active provider. + * Tapping the card triggers `onPress` to change the active provider. When + * `onPress` is omitted the card is static (no chevron) to indicate the + * provider cannot be changed. */ export const PoweredByCard: React.FC = (props: Props) => { const { iconUri, poweredByText, onPress } = props const theme = useTheme() const styles = getStyles(theme) const iconSrc = iconUri == null ? {} : { uri: iconUri } + const tappable = onPress != null return ( @@ -40,13 +45,17 @@ export const PoweredByCard: React.FC = (props: Props) => { {poweredByText} - - - {lstrings.tap_to_change_provider} - - + {tappable ? ( + + + {lstrings.tap_to_change_provider} + + + ) : null} - + {tappable ? ( + + ) : null} diff --git a/src/components/scenes/HoudiniSendScene.tsx b/src/components/scenes/HoudiniSendScene.tsx new file mode 100644 index 00000000000..0eae9397124 --- /dev/null +++ b/src/components/scenes/HoudiniSendScene.tsx @@ -0,0 +1,529 @@ +import { div, mul } from 'biggystring' +import type { EdgeTokenId, EdgeTransaction } from 'edge-core-js' +import * as React from 'react' +import { View } from 'react-native' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { useSelector } from '../../types/reactRedux' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { EdgeCard } from '../cards/EdgeCard' +import { EdgeAnim } from '../common/EdgeAnim' +import { SceneWrapper } from '../common/SceneWrapper' +import { CryptoIcon } from '../icons/CryptoIcon' +import { SceneContainer } from '../layout/SceneContainer' +import { ButtonsModal } from '../modals/ButtonsModal' +import { RadioListModal } from '../modals/RadioListModal' +import { TextInputModal } from '../modals/TextInputModal' +import { EdgeRow } from '../rows/EdgeRow' +import { Airship, showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' +import { EdgeText } from '../themed/EdgeText' +import { SafeSlider } from '../themed/SafeSlider' +import { AddressTile2 } from '../tiles/AddressTile2' + +export interface HoudiniSendParams { + walletId: string + tokenId: EdgeTokenId + // Which card grouping to render. The two prototype proposals differ ONLY in + // this layout; all controls, values, and navigation are identical. + layout: 'a' | 'b' + // Initial-state variant for Proposal A: + // a1 - the "Recipient receives" row is visible before any selection + // a2 - the "Recipient receives" row is hidden until a selection is made + // The amount and network-fee rows appear only after an asset or address is + // chosen, in both variants. + variant?: 'a1' | 'a2' +} + +interface Props extends EdgeAppSceneProps<'houdiniSend'> {} + +// A single Houdini-supported recipient chain. Everything here is hard-coded for +// the prototype; nothing talks to Houdini. +interface HoudiniChain { + pluginId: string + currencyCode: string + displayName: string + // Hard-coded "1 BTC = ratePerBtc " exchange rate. + ratePerBtc: string + // Hard-coded conversion percent shown on the estimated side (swap-scene + // style, e.g. "-2.5"). Represents the incognito/exchange spread. + conversionPercent: string + // Whether this chain needs a destination tag / memo (drives the conditional row). + memoNeeded: boolean +} + +const SOURCE_CHAIN: HoudiniChain = { + pluginId: 'bitcoin', + currencyCode: 'BTC', + displayName: 'Bitcoin', + ratePerBtc: '1', + conversionPercent: '-1', + memoNeeded: false +} + +const RECIPIENT_CHAINS: HoudiniChain[] = [ + SOURCE_CHAIN, + { + pluginId: 'ethereum', + currencyCode: 'ETH', + displayName: 'Ethereum', + ratePerBtc: '36.5', + conversionPercent: '-1.8', + memoNeeded: false + }, + { + pluginId: 'monero', + currencyCode: 'XMR', + displayName: 'Monero', + ratePerBtc: '350', + conversionPercent: '-2.5', + memoNeeded: true + }, + { + pluginId: 'solana', + currencyCode: 'SOL', + displayName: 'Solana', + ratePerBtc: '620', + conversionPercent: '-2', + memoNeeded: false + } +] + +// Hard-coded prototype values: +const HARD_CODED_ADDRESS = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' +const HARD_CODED_NETWORK_FEE = '0.00002 BTC' +const HARD_CODED_DESTINATION_TAG = '8675309' +const ESTIMATE_PREFIX = '~ ' + +const amountRegex = /^\d*\.?\d*$/ + +export const HoudiniSendScene: React.FC = props => { + const { navigation, route } = props + const { walletId, layout, variant = 'a1' } = route.params + const theme = useTheme() + const styles = getStyles(theme) + + // State: + const [recipientChain, setRecipientChain] = + React.useState(SOURCE_CHAIN) + const [youSend, setYouSend] = React.useState('0.1') + const [recipientGets, setRecipientGets] = React.useState('0.1') + const [guaranteedSide, setGuaranteedSide] = React.useState< + 'send' | 'receive' + >('send') + const [incognito, setIncognito] = React.useState(false) + // A plain same-asset send reveals the amount + fee rows only once a recipient + // address has been entered. + const [addressEntered, setAddressEntered] = React.useState(false) + const [enteredAddress, setEnteredAddress] = React.useState('') + // Guards against a second slide firing another navigation before the scene + // transitions away. + const isSubmitting = React.useRef(false) + + // Selectors: + const sourceWallet = useSelector( + state => state.core.account.currencyWallets[walletId] + ) + + // Derived values: + const isCrossAsset = recipientChain.currencyCode !== SOURCE_CHAIN.currencyCode + // The send routes through Houdini (and so shows the estimated "recipient gets" + // amount with a conversion percent, and a locked recipient address) only when + // it is incognito OR converts between assets. A plain same-asset, non-incognito + // send is an ordinary on-chain send with the normal "add recipient" UI. + const isExchange = incognito || isCrossAsset + // Conversion percent shown on the estimated side (instead of a rate tile). + const conversionPercentText = `${recipientChain.conversionPercent}%` + // The amount + fee rows are revealed only after a selection has been made: + // an exchange (incognito or cross-asset) or an entered recipient address. + const revealDetails = isExchange || addressEntered + // Proposal A1 shows the "Recipient receives" row from the start; A2 reveals it + // only once details are shown. Both always show it once a selection is made. + const showRecipientReceives = revealDetails || variant === 'a1' + + // Handlers: + const handleEditYouSend = useHandler(async () => { + const result = await Airship.show(bridge => ( + + )) + if (result == null || !amountRegex.test(result) || result === '') return + setYouSend(result) + setGuaranteedSide('send') + setRecipientGets(mul(result, recipientChain.ratePerBtc)) + }) + + const handleEditRecipientGets = useHandler(async () => { + const result = await Airship.show(bridge => ( + + )) + if (result == null || !amountRegex.test(result) || result === '') return + setRecipientGets(result) + setGuaranteedSide('receive') + setYouSend(div(result, recipientChain.ratePerBtc, 8)) + }) + + const handlePickRecipientChain = useHandler(async () => { + const selectedCode = await Airship.show(bridge => ( + ({ + name: chain.currencyCode, + text: chain.displayName, + icon: ( + + ) + }))} + /> + )) + if (selectedCode == null) return + const nextChain = RECIPIENT_CHAINS.find( + chain => chain.currencyCode === selectedCode + ) + if (nextChain == null) return + setRecipientChain(nextChain) + // Keep the guaranteed side fixed and recompute the estimated side. + if (guaranteedSide === 'send') { + setRecipientGets(mul(youSend, nextChain.ratePerBtc)) + } else { + setYouSend(div(recipientGets, nextChain.ratePerBtc, 8)) + } + }) + + const handleToggleIncognito = useHandler(() => { + setIncognito(value => !value) + }) + + const handleSlidingComplete = useHandler(async (reset: () => void) => { + if (isSubmitting.current) return + isSubmitting.current = true + const edgeTransaction = buildPrototypeTransaction(walletId) + // Cross-asset or incognito sends celebrate with the swap success scene. + // The slider is intentionally NOT reset here: leaving it un-reset locks it + // after completion, so a second slide cannot fire another navigation while + // the scene transitions away. + if (isExchange) { + navigation.navigate('swapSuccess', { edgeTransaction, walletId }) + return + } + // A plain same-asset send stays on the scene, so reset the slider after the + // success modal to make it usable again. + const result = await Airship.show<'ok' | undefined>(bridge => ( + + )).catch((err: unknown) => { + showError(err) + return undefined + }) + reset() + isSubmitting.current = false + // Only continue to the details scene when the user acknowledges the + // success modal; dismissing it leaves them on the send scene. + if (result === 'ok') { + navigation.navigate('transactionDetails', { edgeTransaction, walletId }) + } + }) + + // --------------------------------------------------------------------------- + // Render helpers + // --------------------------------------------------------------------------- + + const renderFromWallet = (): React.ReactElement => ( + + + + {sourceWallet?.name ?? SOURCE_CHAIN.displayName} + + + ) + + const renderAmountRow = ( + title: string, + amount: string, + currencyCode: string, + isGuaranteed: boolean, + onPress: () => Promise + ): React.ReactElement => ( + + + + {`${isGuaranteed ? '' : ESTIMATE_PREFIX}${amount} ${currencyCode}`} + + {isGuaranteed ? ( + + {lstrings.houdini_guaranteed} + + ) : ( + + {conversionPercentText} + + )} + + + ) + + const renderYouSend = (): React.ReactElement => + renderAmountRow( + lstrings.houdini_you_send, + youSend, + SOURCE_CHAIN.currencyCode, + guaranteedSide === 'send', + handleEditYouSend + ) + + const renderRecipientGets = (): React.ReactElement => + renderAmountRow( + lstrings.houdini_recipient_gets, + recipientGets, + recipientChain.currencyCode, + guaranteedSide === 'receive', + handleEditRecipientGets + ) + + const renderRecipientReceives = (): React.ReactElement => ( + + + + {recipientChain.displayName} + + + ) + + const renderAddress = (): React.ReactElement | null => { + if (sourceWallet == null) return null + // A Houdini (incognito/cross-asset) send pre-fills and locks the recipient + // address; a plain on-chain send shows the standard "add recipient address" + // affordance (enter / scan / paste) so it matches a normal BTC send. + return ( + ['navigation'] + } + onChangeAddress={async result => { + // Entering a recipient reveals the amount + fee rows for a plain send. + setEnteredAddress( + result.parsedUri?.publicAddress ?? HARD_CODED_ADDRESS + ) + setAddressEntered(true) + }} + resetSendTransaction={() => { + setEnteredAddress('') + setAddressEntered(false) + }} + /> + ) + } + + const renderNetworkFee = (): React.ReactElement => ( + + {HARD_CODED_NETWORK_FEE} + + ) + + const renderDestinationTag = (): React.ReactElement | null => { + if (!recipientChain.memoNeeded) return null + return ( + + {HARD_CODED_DESTINATION_TAG} + + ) + } + + // The incognito toggle expands in-tile with explanatory messaging while it is + // enabled, mirroring the wording planned for the production tooltip. + const renderIncognitoToggle = (): React.ReactElement => ( + + ) + + const renderIncognitoInfo = (): React.ReactElement => ( + + + {lstrings.houdini_incognito_info}{' '} + + {lstrings.houdini_learn_more} + + + + ) + + // --------------------------------------------------------------------------- + // Layouts — only the card grouping differs between Proposal A and Proposal B. + // --------------------------------------------------------------------------- + + const renderLayoutA = (): React.ReactElement => ( + <> + + {renderFromWallet()} + {revealDetails ? renderYouSend() : null} + {revealDetails ? renderNetworkFee() : null} + + + {showRecipientReceives ? renderRecipientReceives() : null} + {renderAddress()} + {isExchange ? renderRecipientGets() : null} + {revealDetails ? renderDestinationTag() : null} + + + {renderIncognitoToggle()} + {incognito ? renderIncognitoInfo() : null} + + + ) + + const renderLayoutB = (): React.ReactElement => ( + <> + {renderFromWallet()} + + {renderAddress()} + {showRecipientReceives ? renderRecipientReceives() : null} + {revealDetails ? renderYouSend() : null} + {isExchange ? renderRecipientGets() : null} + + + {renderIncognitoToggle()} + {incognito ? renderIncognitoInfo() : null} + + {revealDetails ? ( + + {renderNetworkFee()} + {renderDestinationTag()} + + ) : null} + + ) + + return ( + + + + {layout === 'a' ? renderLayoutA() : renderLayoutB()} + + + + + + + ) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Builds a hard-coded, display-only transaction so the prototype's success + * scenes (SwapSuccessScene / Transaction Details) have something to render. + * It is never broadcast. + */ +function buildPrototypeTransaction(walletId: string): EdgeTransaction { + return { + tokenId: null, + nativeAmount: '-10000000', + networkFees: [], + blockHeight: 0, + date: 1700000000, + txid: 'houdini-prototype-transaction', + signedTx: '', + memos: [], + ourReceiveAddresses: [], + isSend: true, + walletId, + currencyCode: 'BTC', + networkFee: '2000' + } +} + +const getStyles = cacheStyles((theme: Theme) => ({ + assetRow: { + flexDirection: 'row', + alignItems: 'center' + }, + amountRow: { + flexDirection: 'row', + alignItems: 'center' + }, + amountText: { + marginLeft: theme.rem(0.25), + marginRight: theme.rem(0.5) + }, + percentHint: { + color: theme.negativeText, + fontSize: theme.rem(0.75) + }, + guaranteedHint: { + color: theme.positiveText, + fontSize: theme.rem(0.75) + }, + incognitoInfo: { + paddingHorizontal: theme.rem(0.5), + paddingBottom: theme.rem(0.25) + }, + incognitoInfoText: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + learnMoreLink: { + color: theme.textLink, + fontSize: theme.rem(0.75) + }, + sliderContainer: { + marginTop: theme.rem(1), + marginBottom: theme.rem(2), + alignItems: 'center' + } +})) diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 5aba519cc4b..103e8a120f4 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -60,6 +60,10 @@ export interface SwapConfirmationParams { selectedQuote: EdgeSwapQuote quotes: EdgeSwapQuote[] onApprove: () => void + + // Houdini incognito-swap prototype (Proposal A): when set, the quote is shown + // as a fixed-provider Houdini swap (non-tappable "Powered by Houdini"). + incognito?: boolean } interface Props extends SwapTabSceneProps<'swapConfirmation'> {} @@ -71,7 +75,7 @@ interface Section { export const SwapConfirmationScene: React.FC = (props: Props) => { const { route, navigation } = props - const { quotes, onApprove } = route.params + const { quotes, onApprove, incognito = false } = route.params const dispatch = useDispatch() const theme = useTheme() @@ -224,7 +228,8 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove + onApprove, + incognito }) } }) @@ -488,11 +493,17 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { /> - + {incognito ? ( + // Incognito swaps route through a fixed Houdini provider, so the + // card is not tappable (no chevron, no "tap to change provider"). + + ) : ( + + )} {selectedQuote.isEstimate && !showPriceImpact ? ( diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index f42ec8d90b1..c727a0dda6e 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -30,6 +30,7 @@ import { EdgeButton } from '../buttons/EdgeButton' import { KavButtons } from '../buttons/KavButtons' import { SceneButtons } from '../buttons/SceneButtons' import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeCard } from '../cards/EdgeCard' import { EdgeAnim, fadeInDown30, @@ -47,8 +48,10 @@ import { type WalletListResult } from '../modals/WalletListModal' import { Airship, showToast, showWarning } from '../services/AirshipInstance' -import { useTheme } from '../services/ThemeContext' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { UnscaledText } from '../text/UnscaledText' +import { EdgeText } from '../themed/EdgeText' import { LineTextDivider } from '../themed/LineTextDivider' import { SwapInput, @@ -86,6 +89,7 @@ export const SwapCreateScene: React.FC = props => { errorDisplayInfo } = route.params ?? {} const theme = useTheme() + const styles = getStyles(theme) const dispatch = useDispatch() // Input state is the state of the user input @@ -95,6 +99,11 @@ export const SwapCreateScene: React.FC = props => { 'from' | 'to' >('from') + // Houdini incognito-swap prototype (Proposal A): an additive toggle shown + // during amount entry. When enabled, the quote routes through Houdini as a + // fixed provider (see SwapConfirmationScene). + const [incognito, setIncognito] = useState(false) + const fromInputRef = React.useRef(null) const toInputRef = React.useRef(null) @@ -277,7 +286,8 @@ export const SwapCreateScene: React.FC = props => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove: resetState + onApprove: resetState, + incognito }) } }) @@ -441,6 +451,10 @@ export const SwapCreateScene: React.FC = props => { Keyboard.dismiss() }) + const handleToggleIncognito = useHandler(() => { + setIncognito(value => !value) + }) + const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => { navigation.setParams({ // Update the error state: @@ -607,6 +621,30 @@ export const SwapCreateScene: React.FC = props => { /> )} + {fromWallet != null && toWallet != null ? ( + + + + {incognito ? ( + + + {lstrings.houdini_incognito_swap_info}{' '} + + {lstrings.houdini_learn_more} + + + + ) : null} + + + ) : null} {renderAlert()} {isNextHidden || isKeyboardOpen ? null : ( @@ -624,6 +662,21 @@ export const SwapCreateScene: React.FC = props => { ) } +const getStyles = cacheStyles((theme: Theme) => ({ + incognitoInfo: { + paddingHorizontal: theme.rem(0.5), + paddingBottom: theme.rem(0.25) + }, + incognitoInfoText: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + learnMoreLink: { + color: theme.textLink, + fontSize: theme.rem(0.75) + } +})) + const MaxButtonContainerView = styled(View)(theme => ({ position: 'absolute', right: theme.rem(1), diff --git a/src/components/themed/TransactionListTop.tsx b/src/components/themed/TransactionListTop.tsx index d8332b3b2cf..66ec1c6665f 100644 --- a/src/components/themed/TransactionListTop.tsx +++ b/src/components/themed/TransactionListTop.tsx @@ -746,10 +746,13 @@ export const TransactionListTop: React.FC = props => { const handleSend = useHandler((): void => { triggerHaptic('impactLight') - navigation.push('send2', { + // Houdini incognito-send prototype (Proposal A): route the wallet Send button + // to the reorganized scene instead of the production send scene. + navigation.push('houdiniSend', { walletId: wallet.id, tokenId, - hiddenFeaturesMap: { scamWarning: false } + layout: 'a', + variant: 'a1' }) }) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 18137a8f612..262e78b79cf 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1907,6 +1907,24 @@ const strings = { bank_info_title: 'Bank Info', home_address_title: 'Home Address', + + // Houdini incognito send prototype + houdini_send_title: 'Send', + houdini_you_send: 'You send', + houdini_recipient_gets: 'Recipient gets', + houdini_recipient_receives: 'Recipient receives', + houdini_incognito_send: 'Stealth Send', + houdini_incognito_swap: 'Stealth Swap', + houdini_incognito_info: + 'Uses a route that helps obfuscate the on-chain link between source and destination wallets.', + houdini_guaranteed: 'Guaranteed', + houdini_slide_send: 'Slide to send', + houdini_slide_incognito: 'Slide to send stealthily', + houdini_provider_name: 'Houdini', + houdini_incognito_swap_info: + 'Routes your swap through multiple exchanges so your source and destination wallets are more obfuscated on-chain.', + houdini_learn_more: 'Learn more', + input_output_currency: 'Currency', n_a: 'N/A', payment_details: 'Payment Details', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 3c0b1513f56..196b4175aeb 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1490,6 +1490,19 @@ "form_field_error_invalid_ssn": "Please enter a valid SSN (XXX-XX-XXXX)", "bank_info_title": "Bank Info", "home_address_title": "Home Address", + "houdini_send_title": "Send", + "houdini_you_send": "You send", + "houdini_recipient_gets": "Recipient gets", + "houdini_recipient_receives": "Recipient receives", + "houdini_incognito_send": "Stealth Send", + "houdini_incognito_swap": "Stealth Swap", + "houdini_incognito_info": "Uses a route that helps obfuscate the on-chain link between source and destination wallets.", + "houdini_guaranteed": "Guaranteed", + "houdini_slide_send": "Slide to send", + "houdini_slide_incognito": "Slide to send stealthily", + "houdini_provider_name": "Houdini", + "houdini_incognito_swap_info": "Routes your swap through multiple exchanges so your source and destination wallets are more obfuscated on-chain.", + "houdini_learn_more": "Learn more", "input_output_currency": "Currency", "n_a": "N/A", "payment_details": "Payment Details", diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index d7d8ec55964..604be6a664f 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -37,6 +37,7 @@ import type { GiftCardAccountInfoParams } from '../components/scenes/GiftCardAcc import type { GiftCardPurchaseParams } from '../components/scenes/GiftCardPurchaseScene' import type { GuiPluginListParams } from '../components/scenes/GuiPluginListScene' import type { PluginViewParams } from '../components/scenes/GuiPluginViewScene' +import type { HoudiniSendParams } from '../components/scenes/HoudiniSendScene' import type { LoanCloseParams } from '../components/scenes/Loans/LoanCloseScene' import type { LoanCreateConfirmationParams } from '../components/scenes/Loans/LoanCreateConfirmationScene' import type { LoanCreateParams } from '../components/scenes/Loans/LoanCreateScene' @@ -208,6 +209,7 @@ export type EdgeAppStackParamList = {} & { giftCardList: undefined giftCardMarket: undefined giftCardPurchase: GiftCardPurchaseParams + houdiniSend: HoudiniSendParams loanClose: LoanCloseParams loanCreate: LoanCreateParams loanCreateConfirmation: LoanCreateConfirmationParams