diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cebfd37327..d75dccf7d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: Stealth Send: the send scene offers a "Recipient receives" asset selector and a Stealth Send toggle, turning the send into a live swap-to-address quote (Houdini privacy routing when stealth is on, all providers for a plain cross-asset send), with linked You send/Recipient gets amounts, quote expiry countdown, and a destination tag row on memo-required chains. +- added: Stealth Swap: a toggle on the swap amount-entry scene routes the swap through the Houdini privacy provider as a fixed, non-tappable provider. - 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. - changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings". - fixed: Prevent imported Monero wallets from using the Edge LWS backend. Choosing to import now prompts the user to continue with a full node or configure a custom LWS server, matching the wallet settings rule. diff --git a/eslint.config.mjs b/eslint.config.mjs index 7dc53841b8d..f92b3884e78 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -160,7 +160,7 @@ export default [ 'src/components/cards/StakingOptionCard.tsx', 'src/components/cards/StakingReturnsCard.tsx', 'src/components/cards/SupportCard.tsx', - 'src/components/cards/SwapDetailsCard.tsx', + 'src/components/cards/TappableAccountCard.tsx', 'src/components/cards/TappableCard.tsx', 'src/components/cards/UnderlinedNumInputCard.tsx', @@ -258,7 +258,7 @@ export default [ 'src/components/rows/EdgeRow.tsx', 'src/components/rows/PaymentMethodRow.tsx', - 'src/components/rows/SwapProviderRow.tsx', + 'src/components/rows/TxCryptoAmountRow.tsx', 'src/components/scenes/ChangeMiningFeeScene.tsx', diff --git a/maestro/14-stealth/stealth-send.yaml b/maestro/14-stealth/stealth-send.yaml new file mode 100644 index 00000000000..cec7fc28cc3 --- /dev/null +++ b/maestro/14-stealth/stealth-send.yaml @@ -0,0 +1,69 @@ +# Stealth Send UI walk. +# +# Opens the Bitcoin wallet's Send scene and walks the send-to-address swap UI +# without entering an address, so no live quote is requested: the "Recipient +# receives" selector (visible before address entry), the Stealth Send toggle +# card with its explainer copy and inline "Learn more" link, and the +# Destination Tag row on a memo-required destination chain (XRP). +# +# Screenshots captured (under ~/.maestro/tests// ): +# stealth-send-01-initial plain same-asset start: add-recipient +# affordance, "Recipient receives" visible +# stealth-send-02-stealth-on toggle expanded with the explainer copy +# stealth-send-03-xrp-tag XRP destination: Destination Tag row +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - stealth +--- +- 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. 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, +# "Recipient receives" visible before address entry. +- assertVisible: 'Stealth Send' +- assertVisible: 'Send to Address' +- assertVisible: 'Recipient receives' +- takeScreenshot: stealth-send-01-initial + +# Stealth on: the toggle card expands with the explainer + Learn more link. +- tapOn: 'Stealth Send' +- assertVisible: 'Uses a route that helps obfuscate.*Learn more.*' +- takeScreenshot: stealth-send-02-stealth-on +- tapOn: 'Stealth Send' + +# Cross-asset memo chain: an XRP destination shows the Destination Tag row. +- tapOn: 'Recipient receives' +- tapOn: + text: 'XRP.*' +- assertVisible: 'Destination Tag' +- takeScreenshot: stealth-send-03-xrp-tag diff --git a/maestro/14-stealth/stealth-swap.yaml b/maestro/14-stealth/stealth-swap.yaml new file mode 100644 index 00000000000..f5b6c8abadf --- /dev/null +++ b/maestro/14-stealth/stealth-swap.yaml @@ -0,0 +1,52 @@ +# Stealth Swap UI walk. +# +# Opens the swap flow from the Bitcoin wallet and checks the Stealth Swap +# toggle card during amount entry: the toggle, the explainer copy, and the +# inline "Learn more" link. No quote is requested. +# +# Screenshots captured (under ~/.maestro/tests// ): +# stealth-swap-01-start amount entry with the Stealth Swap card +# stealth-swap-02-stealth-on toggle expanded with the explainer copy +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - stealth +--- +- 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' +- tapOn: + text: '.*Horizon.*' + optional: true + +# Open the swap flow with a from + to pair so the toggle card renders. +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin' +- assertVisible: 'Receive' +- tapOn: 'Trade' +- tapOn: + text: 'Swap BTC to/from another crypto' +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: 'Stealth Swap' +- takeScreenshot: stealth-swap-01-start + +# Stealth on: the toggle card expands with the explainer + Learn more link. +- tapOn: 'Stealth Swap' +- assertVisible: 'Routes your swap through multiple.*Learn more.*' +- takeScreenshot: stealth-swap-02-stealth-on diff --git a/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap index 2c36f977410..e1503ee0bf6 100644 --- a/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/AccelerateTxModal.test.tsx.snap @@ -619,6 +619,7 @@ exports[`AccelerateTxModalComponent should render with loading props 1`] = ` }, ] } + testID="confirmSliderThumb" > @@ -1571,7 +1571,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="13" + nativeID="16" > @@ -3580,7 +3581,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="23" + nativeID="26" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -5589,7 +5703,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` "reduceMotionV": "system", } } - nativeID="34" + nativeID="37" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -7179,7 +7406,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="44" + nativeID="47" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -8749,7 +9089,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="54" + nativeID="57" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -10086,7 +10539,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="63" + nativeID="66" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -11836,7 +12402,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="75" + nativeID="78" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -13537,7 +14216,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="86" + nativeID="89" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -15165,7 +15957,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="97" + nativeID="100" > - Send to Address + Recipient receives - -  - - - Enter - + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + - + Bitcoin (BTC) + + + + + +  + + + + + + + + Send to Address + + + + +  + + + Enter + + + + + + + + + Stealth Send + + + + + + + + + + + + + + + + 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/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index a0df0c33f4a..6234887f097 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -58,7 +58,7 @@ const upgradeSwapData = ( return swapData } -export function SwapDetailsCard(props: Props) { +export const SwapDetailsCard: React.FC = props => { const { swapData, transaction, wallet } = props const theme = useTheme() const styles = getStyles(theme) @@ -72,10 +72,14 @@ export function SwapDetailsCard(props: Props) { : selectDisplayDenom(state, wallet.currencyConfig, tokenId) ) - // The wallet may have been deleted: + // A swap-to-address payout has no wallet, and the wallet may also have + // been deleted: const account = useSelector(state => state.core.account) const currencyWallets = useWatch(account, 'currencyWallets') - const destinationWallet = currencyWallets[swapData.payoutWalletId] + const destinationWallet = + swapData.payoutWalletId == null + ? undefined + : currencyWallets[swapData.payoutWalletId] const destinationWalletName = destinationWallet == null ? '' : getWalletName(destinationWallet) @@ -124,12 +128,12 @@ export function SwapDetailsCard(props: Props) { return } - if (error) showError(error) + if (error != null) showError(error) } ) }) - const handleLink = async () => { + const handleLink = async (): Promise => { if (formattedOrderUri == null) return // Replace {{TXID}} with actual transaction ID if present @@ -140,9 +144,9 @@ export function SwapDetailsCard(props: Props) { if (available) await SafariView.show({ url: formattedOrderUri }) else await Linking.openURL(formattedOrderUri) }) - .catch(error => { + .catch((error: unknown) => { showError(error) - Linking.openURL(formattedOrderUri).catch(err => { + Linking.openURL(formattedOrderUri).catch((err: unknown) => { showError(err) }) }) @@ -180,7 +184,7 @@ export function SwapDetailsCard(props: Props) { destinationDenomination.multiplier )(swapData.payoutNativeAmount) const destinationAssetName = - payoutTokenId == null + payoutTokenId == null || destinationWallet == null ? payoutCurrencyCode : `${payoutCurrencyCode} (${ getExchangeDenom(destinationWallet.currencyConfig, null).name @@ -192,7 +196,7 @@ export function SwapDetailsCard(props: Props) { ? walletDefaultDenom.symbol : transaction.currencyCode - const createExchangeDataString = (newline: string = '\n') => { + const createExchangeDataString = (newline: string = '\n'): string => { const uniqueIdentifier = memos .map( (memo, index) => diff --git a/src/components/rows/SwapProviderRow.tsx b/src/components/rows/SwapProviderRow.tsx index e94a212fc60..c7521d3ae7c 100644 --- a/src/components/rows/SwapProviderRow.tsx +++ b/src/components/rows/SwapProviderRow.tsx @@ -18,7 +18,13 @@ export const SwapProviderRow: React.FC = (props: Props) => { const { quote } = props const { request, toNativeAmount, fromNativeAmount } = quote const { quoteFor } = request - const { fromWallet, fromTokenId, toWallet, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + // A wallet-to-wallet swap quote always carries a destination wallet; only a + // swap-to-address request (its own flow) omits it. + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } const theme = useTheme() const styles = getStyles(theme) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 58aa9578ff5..25853161e7f 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -10,6 +10,7 @@ import { type EdgeMemoOption, type EdgeSpendInfo, type EdgeSpendTarget, + type EdgeSwapQuote, type EdgeTokenId, type EdgeTransaction, type InsufficientFundsError @@ -59,6 +60,11 @@ import { FioError, recordSend } from '../../util/FioAddressUtils' +import { + getHoudiniChain, + HOUDINI_CHAINS, + isValidHoudiniAddress +} from '../../util/houdiniChains' import { logActivity } from '../../util/logger' import { createEdgeMemo, @@ -67,18 +73,21 @@ import { getMemoLabel, getMemoTitle } from '../../util/memoUtils' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import { convertTransactionFeeToDisplayFee, darkenHexColor, DECIMAL_PRECISION, zeroString } from '../../util/utils' +import { openBrowserUri } from '../../util/WebUtils' import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeCard } from '../cards/EdgeCard' import { ErrorCard, I18nError } from '../cards/ErrorCard' import type { AccentColors } from '../common/DotsBackground' import { EdgeAnim } from '../common/EdgeAnim' import { SceneWrapper } from '../common/SceneWrapper' +import { CryptoIcon } from '../icons/CryptoIcon' import { ButtonsModal } from '../modals/ButtonsModal' import { FlipInputModal2, @@ -86,6 +95,7 @@ import { type FlipInputModalResult } from '../modals/FlipInputModal2' import { showInsufficientFeesModal } from '../modals/InsufficientFeesModal' +import { RadioListModal } from '../modals/RadioListModal' import { TextInputModal } from '../modals/TextInputModal' import { WalletListModal, @@ -94,6 +104,7 @@ import { import { EdgeRow } from '../rows/EdgeRow' import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { UnscaledTextInput } from '../text/UnscaledTextInput' import { EdgeText } from '../themed/EdgeText' import type { @@ -102,6 +113,10 @@ import type { } from '../themed/ExchangedFlipInput2' import { asPrivateNetworkingSetting } from '../themed/MaybePrivateNetworkingSetting' import { PinDots } from '../themed/PinDots' +import { + calculateQuotePriceImpact, + PriceImpactText +} from '../themed/PriceImpactText' import { SafeSlider } from '../themed/SafeSlider' import { SendFromFioRows } from '../themed/SendFromFioRows' import { @@ -172,6 +187,10 @@ interface FioSenderInfo { const ALLOW_MULTIPLE_TARGETS = true +/** Placeholder pending the final marketing URL. */ +const STEALTH_LEARN_MORE_URI = + 'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b' + /** * If the prior two spend targets of a multi-out payment have the same amount * within 0.5%, then use the same amount for the new spend target. @@ -261,6 +280,32 @@ const SendComponent: React.FC = props => { // -1 = no max spend, otherwise equal to the index the spendTarget that requested the max spend. const [maxSpendSetter, setMaxSpendSetter] = useState(-1) + // Send-to-address swap state (Stealth Send / cross-asset recipient). The + // recipient asset defaults to the source asset (undefined); picking another + // chain, or enabling stealth, turns the send into a swap-to-address quote. + const [recipientPluginId, setRecipientPluginId] = useState< + string | undefined + >(undefined) + const [stealth, setStealth] = useState(false) + const [destinationTag, setDestinationTag] = useState( + undefined + ) + const [swapQuote, setSwapQuote] = useState( + undefined + ) + const [fetchingSwapQuote, setFetchingSwapQuote] = useState(false) + const [guaranteedSide, setGuaranteedSide] = useState<'send' | 'receive'>( + 'send' + ) + // The fixed receive amount (destination-chain native units) when the user + // edits "Recipient gets"; otherwise the latest quote's estimate. + const [receiveNativeAmount, setReceiveNativeAmount] = useState< + string | undefined + >(undefined) + // Bumped when the quote expires, to force a re-quote: + const [swapQuoteNonce, setSwapQuoteNonce] = useState(0) + const isApprovingSwapRef = React.useRef(false) + const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) const exchangeRates = useSelector( @@ -320,6 +365,44 @@ const SendComponent: React.FC = props => { spendInfo.tokenId = tokenId + // --------------------------------------------------------------------- + // Send-to-address swap mode (Stealth Send / cross-asset recipient) + // --------------------------------------------------------------------- + + // The send-to-address swap UI is offered only when this scene is a plain, + // unconstrained send. Callers that pre-lock tiles, pre-fill an address + // (payment protocol, deep links), pay FIO requests, or take over the + // broadcast/completion flow keep today's behavior untouched. + const swapSendAllowed = + lockTilesMap.address !== true && + lockTilesMap.amount !== true && + lockTilesMap.wallet !== true && + hiddenFeaturesMap.address !== true && + hiddenFeaturesMap.amount !== true && + fioPendingRequest == null && + onDone == null && + alternateBroadcast == null && + beforeTransaction == null && + initSpendInfo?.spendTargets[0]?.publicAddress == null + + // Where the funds land. The recipient asset defaults to the source asset; + // `destChain` carries Houdini's metadata (address regex, memoNeeded) for + // the destination chain when it is served. + const destPluginId = recipientPluginId ?? pluginId + const sameAsset = destPluginId === pluginId && tokenId == null + const crossAsset = recipientPluginId != null && !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAsset) + const destChain = swapSendActive + ? getHoudiniChain(destPluginId, null) + : undefined + const destCurrencyConfig = account.currencyConfig[destPluginId] + const destCurrencyInfo = destCurrencyConfig?.currencyInfo + const destExchangeDenom = + destCurrencyConfig == null + ? undefined + : getExchangeDenom(destCurrencyConfig, null) + const multipleTargets = spendInfo.spendTargets.length > 1 + const updatePendingTxState = React.useCallback(async (): Promise => { if (coreWallet == null || !isEvmWallet(coreWallet)) { setHasPendingTx(false) @@ -558,6 +641,14 @@ const SendComponent: React.FC = props => { (publicAddress === '' && lastAddressEntryMethod === 'scan') if (openCameraRef.current) openCameraRef.current = false + // A cross-chain destination address cannot be parsed by the source + // wallet; validate it against the destination chain's own rules: + const crossChainAddressValidation = + swapSendActive && destPluginId !== pluginId + ? (address: string) => + destChain != null && isValidHoudiniAddress(destChain, address) + : undefined + return ( = props => { isCameraOpen={doOpenCamera} recipientName={recipientName} recipientNameService={recipientNameService} + crossChainAddressValidation={crossChainAddressValidation} navigation={navigation as NavigationBase} /> ) @@ -648,6 +740,8 @@ const SendComponent: React.FC = props => { index: number, spendTarget: EdgeSpendTarget ): React.ReactElement | null => { + // A send-to-address swap renders its own linked amount rows: + if (swapSendActive) return null const { publicAddress, nativeAmount } = spendTarget if (publicAddress != null && hiddenFeaturesMap.amount !== true) { const title = @@ -708,6 +802,12 @@ const SendComponent: React.FC = props => { if (pluginId !== newPluginId || tokenId !== result.tokenId) { setTokenId(result.tokenId) setSpendInfo({ tokenId: result.tokenId, spendTargets: [{}] }) + // A new source asset invalidates the swap-send destination state: + setRecipientPluginId(undefined) + setDestinationTag(undefined) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') } }) .catch((error: unknown) => { @@ -735,7 +835,431 @@ const SendComponent: React.FC = props => { needsScrollToEnd.current = true }) + // --------------------------------------------------------------------- + // Send-to-address swap handlers + rows + // --------------------------------------------------------------------- + + const handleToggleStealth = useHandler((): void => { + if (multipleTargets) return + setStealth(value => !value) + setPinValue(undefined) + }) + + const handleStealthLearnMore = useHandler((): void => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((err: unknown) => { + showError(err) + }) + }) + + const handlePickRecipientAsset = useHandler((): void => { + if (multipleTargets) return + // The radio row's `name` is BOTH the label and the selection key, so it + // must be unique. Several chains share a currency code (ETH on Base / + // Arbitrum / Ethereum), so the label is the unique network display name + // with the currency code as subtext, and a map resolves it back to the + // destination pluginId. + const sourceDisplayName = + tokenId == null + ? coreWallet.currencyInfo.displayName + : coreWallet.currencyConfig.allTokens[tokenId]?.displayName ?? + currencyCode + const displayNameToPluginId = new Map() + displayNameToPluginId.set(sourceDisplayName, undefined) + + const items = [ + { + name: sourceDisplayName, + text: currencyCode, + icon: + }, + ...HOUDINI_CHAINS.filter( + chain => + account.currencyConfig[chain.pluginId] != null && + !(chain.pluginId === pluginId && tokenId == null) + ).map(chain => { + const { currencyCode: chainCode, displayName } = + account.currencyConfig[chain.pluginId].currencyInfo + displayNameToPluginId.set(displayName, chain.pluginId) + return { + name: displayName, + text: chainCode, + icon: ( + + ) + } + }) + ] + const selectedName = + recipientPluginId == null + ? sourceDisplayName + : account.currencyConfig[recipientPluginId]?.currencyInfo.displayName ?? + sourceDisplayName + + Airship.show(bridge => ( + + )) + .then(selected => { + if (selected == null || selected === selectedName) return + if (!displayNameToPluginId.has(selected)) return + setRecipientPluginId(displayNameToPluginId.get(selected)) + // A new destination chain invalidates the entered address and tag: + setDestinationTag(undefined) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + handleResetSendTransaction(spendInfo.spendTargets[0])() + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditYouSend = useHandler((): void => { + const startAmount = zeroString(spendInfo.spendTargets[0].nativeAmount) + ? '' + : div( + spendInfo.spendTargets[0].nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + Airship.show(bridge => ( + + )) + .then(amount => { + if (amount == null || amount === '') return + spendInfo.spendTargets[0].nativeAmount = mul( + amount, + cryptoDisplayDenomination.multiplier + ) + setGuaranteedSide('send') + setSpendInfo({ ...spendInfo }) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditRecipientGets = useHandler((): void => { + if (destExchangeDenom == null) return + const startAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + Airship.show(bridge => ( + + )) + .then(amount => { + if (amount == null || amount === '') return + setReceiveNativeAmount(mul(amount, destExchangeDenom.multiplier)) + setGuaranteedSide('receive') + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditDestinationTag = useHandler((): void => { + Airship.show(bridge => ( + + )) + .then(tag => { + if (tag == null) return + setDestinationTag(tag === '' ? undefined : tag.trim()) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleSwapQuoteExpired = useHandler((): void => { + setSwapQuoteNonce(nonce => nonce + 1) + }) + + /** + * One side of the linked flip inputs. The edited side is the guaranteed + * amount; the other tracks the live quote as an estimate. + */ + const renderSwapAmountRow = ( + title: string, + displayAmount: string, + displayCode: string, + isGuaranteed: boolean, + onPress: () => void + ): React.ReactElement => ( + + + + {`${isGuaranteed ? '' : '~ '}${displayAmount} ${displayCode}`} + + + {isGuaranteed + ? lstrings.stealth_guaranteed + : lstrings.stealth_estimated} + + + + ) + + const renderYouSendRow = (): React.ReactElement => { + const nativeAmount = spendInfo.spendTargets[0].nativeAmount + const displayAmount = zeroString(nativeAmount) + ? '0' + : div( + nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_you_send, + displayAmount, + currencyCode, + guaranteedSide === 'send', + handleEditYouSend + ) + } + + const renderRecipientGetsRow = (): React.ReactElement | null => { + if (destExchangeDenom == null) return null + const displayAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '0' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_recipient_gets, + displayAmount, + destCurrencyInfo?.currencyCode ?? '', + guaranteedSide === 'receive', + handleEditRecipientGets + ) + } + + const renderRecipientReceives = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + const recipientCurrencyCode = + recipientPluginId == null + ? currencyCode + : destCurrencyInfo?.currencyCode ?? currencyCode + const recipientDisplayName = + recipientPluginId == null + ? tokenId == null + ? coreWallet.currencyInfo.displayName + : coreWallet.currencyConfig.allTokens[tokenId]?.displayName ?? + currencyCode + : destCurrencyInfo?.displayName ?? recipientCurrencyCode + return ( + + + + {`${recipientDisplayName} (${recipientCurrencyCode})`} + + + ) + } + + const renderDestinationTagRow = (): React.ReactElement | null => { + if (destChain?.memoNeeded !== true) return null + return ( + + {destinationTag ?? ''} + + ) + } + + const renderSwapQuoteRow = (): React.ReactElement | null => { + if (spendInfo.spendTargets[0].publicAddress == null) return null + if (fetchingSwapQuote) { + return ( + + + {lstrings.stealth_getting_quote} + + + + ) + } + if (swapQuote == null) return null + + // Rate in exchange (standard) units, plus the provider that quoted and + // the shared price-delta indicator: + const fromExchangeAmount = div( + swapQuote.fromNativeAmount, + cryptoExchangeDenomination.multiplier, + DECIMAL_PRECISION + ) + const toExchangeAmount = + destExchangeDenom == null + ? '0' + : div( + swapQuote.toNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + const rate = zeroString(fromExchangeAmount) + ? '0' + : div(toExchangeAmount, fromExchangeAmount, 8) + const providerName = + account.swapConfig[swapQuote.pluginId]?.swapInfo.displayName ?? + swapQuote.pluginId + const priceImpact = calculateQuotePriceImpact( + swapQuote, + exchangeRates, + defaultIsoFiat + ) + + return ( + <> + + + + {`1 ${currencyCode} = ${rate} ${ + destCurrencyInfo?.currencyCode ?? '' + }`} + + + {providerName} + + + {swapQuote.expirationDate == null ? null : ( + + )} + + ) + } + + const renderSwapFeeRow = (): React.ReactElement | null => { + if (swapQuote == null) return null + const { networkFee } = swapQuote + const feeDenom = getExchangeDenom( + coreWallet.currencyConfig, + networkFee.tokenId + ) + const feeDisplayAmount = div( + networkFee.nativeAmount, + feeDenom.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${feeDisplayAmount} ${feeDenom.name}`} + + ) + } + + const renderStealthToggle = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + return ( + + + + {multipleTargets ? ( + + + {lstrings.stealth_multi_recipient_unsupported} + + + ) : stealth ? ( + + + {lstrings.stealth_send_info}{' '} + + {lstrings.stealth_learn_more} + + + + ) : null} + + + ) + } + + /** + * With multiple recipients, show the aggregate on one row instead of making + * the reviewer sum the per-recipient amounts. + */ + const renderMultiRecipientTotal = (): React.ReactElement | null => { + if (!multipleTargets) return null + const totalNativeAmount = spendInfo.spendTargets.reduce( + (prev, target) => add(target.nativeAmount ?? '0', prev), + '0' + ) + const totalDisplayAmount = div( + totalNativeAmount, + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${totalDisplayAmount} ${currencyCode}`} + + ) + } + const renderAddAddress = (): React.ReactElement | null => { + // Stealth and cross-asset sends support exactly one recipient: + if (swapSendActive) return null const { pluginId } = coreWallet.currencyInfo const maxSpendTargets = getSpecialCurrencyInfo(pluginId)?.maxSpendTargets ?? 1 @@ -804,6 +1328,7 @@ const SendComponent: React.FC = props => { } const renderFees = (): React.ReactElement | null => { + if (swapSendActive) return null if ( spendInfo.spendTargets[0].publicAddress != null && spendInfo.spendTargets[0].nativeAmount != null @@ -956,6 +1481,9 @@ const SendComponent: React.FC = props => { } const renderMemoOptions = (): Array => { + // A send-to-address swap's deposit memo comes from the provider, and the + // recipient's tag is entered on the destination-tag row instead: + if (swapSendActive) return [null] const spendTarget: EdgeSpendTarget | undefined = spendInfo.spendTargets[0] if (spendTarget?.publicAddress == null) return [null] @@ -1276,6 +1804,32 @@ const SendComponent: React.FC = props => { const handleSliderComplete = useHandler( async (resetSlider: () => void): Promise => { + // A send-to-address swap submits by approving the live quote. The + // slider is intentionally not reset on success, so a second slide + // cannot fire while the scene transitions to the success scene. + if (swapSendActive) { + if (swapQuote == null || isApprovingSwapRef.current) return + isApprovingSwapRef.current = true + isSendingRef.current = true + try { + const result = await swapQuote.approve() + playSendSound().catch((error: unknown) => { + console.log(error) // Fail quietly + }) + navigation.replace('swapSuccess', { + edgeTransaction: result.transaction, + walletId: coreWallet.id + }) + } catch (err: unknown) { + setError(err) + resetSlider() + } finally { + isApprovingSwapRef.current = false + isSendingRef.current = false + } + return + } + if (edgeTransaction == null) return if (pinSpendingLimitsEnabled && spendingLimitExceeded) { const isAuthorized = await account.checkPin(pinValue ?? '') @@ -1574,6 +2128,13 @@ const SendComponent: React.FC = props => { // Calculate the transaction useAsyncEffect( async () => { + // A send-to-address swap builds its transaction through the swap quote, + // not through makeSpend: + if (swapSendActive) { + setEdgeTransaction(null) + setProcessingAmountChanged(false) + return + } pendingInsufficientFees.current = undefined try { setProcessingAmountChanged(true) @@ -1737,15 +2298,111 @@ const SendComponent: React.FC = props => { } setProcessingAmountChanged(false) }, - [spendInfo, maxSpendSetter, walletId, pinSpendingLimitsEnabled, pinValue], + [ + spendInfo, + maxSpendSetter, + walletId, + pinSpendingLimitsEnabled, + pinValue, + swapSendActive + ], 'SendComponent' ) + // Fetch the send-to-address swap quote. Quotes are requested when the + // guaranteed-side amount commits (not per keystroke), and re-requested when + // the destination, stealth mode, tag, or expiry nonce changes. + useAsyncEffect( + async () => { + if (!swapSendActive) { + setSwapQuote(undefined) + setFetchingSwapQuote(false) + return + } + const toAddress = spendInfo.spendTargets[0].publicAddress + const sendNativeAmount = spendInfo.spendTargets[0].nativeAmount + const quoteNativeAmount = + guaranteedSide === 'send' ? sendNativeAmount : receiveNativeAmount + if ( + toAddress == null || + toAddress === '' || + quoteNativeAmount == null || + zeroString(quoteNativeAmount) + ) { + setSwapQuote(undefined) + return + } + + setFetchingSwapQuote(true) + try { + const toMemos: EdgeMemo[] = + destinationTag == null || destinationTag === '' + ? [] + : [ + { + type: destCurrencyInfo?.memoOptions?.[0]?.type ?? 'text', + value: destinationTag + } + ] + + // Send-to-address flows (Stealth Send and plain cross-asset + // send-to-any) route through the Houdini privacy provider only. + const quotes = await account.fetchSwapQuotes( + { + fromWallet: coreWallet, + fromTokenId: tokenId, + toTokenId: null, + toAddressInfo: { + toPluginId: destPluginId, + toAddress, + toMemos + }, + nativeAmount: quoteNativeAmount, + quoteFor: guaranteedSide === 'send' ? 'from' : 'to' + }, + makeStealthSwapRequestOptions(account) + ) + const quote = quotes[0] + + setSwapQuote(quote) + setError(undefined) + // Update the estimated side from the live quote: + if (guaranteedSide === 'send') { + setReceiveNativeAmount(quote.toNativeAmount) + } else { + spendInfo.spendTargets[0].nativeAmount = quote.fromNativeAmount + setSpendInfo({ ...spendInfo }) + } + needsScrollToEnd.current = true + } catch (err: unknown) { + setSwapQuote(undefined) + setError(err) + } finally { + setFetchingSwapQuote(false) + } + }, + [ + swapSendActive, + spendInfo.spendTargets[0].publicAddress, + guaranteedSide, + guaranteedSide === 'send' + ? spendInfo.spendTargets[0].nativeAmount + : receiveNativeAmount, + destPluginId, + destinationTag, + swapQuoteNonce + ], + 'SendComponent:swapQuote' + ) + const showSlider = spendInfo.spendTargets[0].publicAddress != null let disableSlider = false let disabledText: string | undefined - if ( + if (swapSendActive) { + // A send-to-address swap submits its live quote: + disableSlider = swapQuote == null || fetchingSwapQuote + } else if ( edgeTransaction == null || processingAmountChanged || (zeroString(spendInfo.spendTargets[0].nativeAmount) && @@ -1833,19 +2490,33 @@ const SendComponent: React.FC = props => { {renderSelectedWallet()} {renderSelectFioAddress()} + {swapSendActive && + spendInfo.spendTargets[0].publicAddress != null + ? renderYouSendRow() + : null} + {swapSendActive ? renderSwapFeeRow() : null} + {renderRecipientReceives()} {renderAddressAmountPairs()} + {swapSendActive && + spendInfo.spendTargets[0].publicAddress != null + ? renderRecipientGetsRow() + : null} + {swapSendActive ? renderDestinationTagRow() : null} + {swapSendActive ? renderSwapQuoteRow() : null} {renderTimeout()} {renderAddAddress()} + {renderStealthToggle()} + {renderMultiRecipientTotal()} {renderFees()} {renderMetadataNotes()} {renderMemoOptions()} @@ -1866,6 +2537,11 @@ const SendComponent: React.FC = props => { @@ -1895,6 +2571,36 @@ const getStyles = cacheStyles((theme: Theme) => ({ calcFeeView: { flexDirection: 'row' }, + swapAmountRow: { + alignItems: 'flex-start' + }, + swapAmountText: { + fontSize: theme.rem(1) + }, + swapAssetRow: { + flexDirection: 'row', + alignItems: 'center' + }, + guaranteedHint: { + fontSize: theme.rem(0.75), + color: theme.positiveText + }, + estimatedHint: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, + stealthInfo: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + stealthInfoText: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, + stealthLearnMoreLink: { + fontSize: theme.rem(0.75), + color: theme.textLink + }, calcFeeSpinner: { marginLeft: theme.rem(1) }, diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 5aba519cc4b..4ce947cd384 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -1,5 +1,5 @@ import { useIsFocused } from '@react-navigation/native' -import { add, div, gt, gte, lte, sub, toFixed } from 'biggystring' +import { add, div, gt, gte, toFixed } from 'biggystring' import type { EdgeSwapQuote, EdgeSwapResult } from 'edge-core-js' import React, { useState } from 'react' import { SectionList, type ViewStyle } from 'react-native' @@ -25,6 +25,7 @@ import type { GuiSwapInfo } from '../../types/types' import { getSwapPluginIconUri } from '../../util/CdnUris' import { CryptoAmount } from '../../util/CryptoAmount' import { logActivity } from '../../util/logger' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import { logEvent } from '../../util/tracking' import { convertNativeToExchange, DECIMAL_PRECISION } from '../../util/utils' import { AlertCardUi4 } from '../cards/AlertCard' @@ -51,15 +52,24 @@ import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { ExchangeQuote } from '../themed/ExchangeQuoteComponent' import { LineTextDivider } from '../themed/LineTextDivider' import { ModalFooter } from '../themed/ModalParts' +import { + calculateQuotePriceImpact, + PRICE_IMPACT_WARNING_THRESHOLD +} from '../themed/PriceImpactText' import { SafeSlider } from '../themed/SafeSlider' import { WalletListSectionHeader } from '../themed/WalletListSectionHeader' -const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 - export interface SwapConfirmationParams { selectedQuote: EdgeSwapQuote quotes: EdgeSwapQuote[] onApprove: () => void + + /** + * A Stealth Swap routes through the Houdini privacy provider as a fixed + * provider: the powered-by card is not tappable and a re-quote keeps the + * provider restriction. + */ + stealth?: boolean } interface Props extends SwapTabSceneProps<'swapConfirmation'> {} @@ -71,7 +81,7 @@ interface Section { export const SwapConfirmationScene: React.FC = (props: Props) => { const { route, navigation } = props - const { quotes, onApprove } = route.params + const { quotes, onApprove, stealth = false } = route.params const dispatch = useDispatch() const theme = useTheme() @@ -126,44 +136,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const { request } = selectedQuote const { quoteFor } = request - const priceImpact = React.useMemo(() => { - const { fromWallet, fromTokenId, toWallet, toTokenId } = request - - const fromExchangeDenom = getExchangeDenom( - fromWallet.currencyConfig, - fromTokenId - ) - const toExchangeDenom = getExchangeDenom(toWallet.currencyConfig, toTokenId) - - const fromExchangeAmount = convertNativeToExchange( - fromExchangeDenom.multiplier - )(selectedQuote.fromNativeAmount) - const toExchangeAmount = convertNativeToExchange( - toExchangeDenom.multiplier - )(selectedQuote.toNativeAmount) - - const fromFiatValue = convertCurrency( - exchangeRates, - fromWallet.currencyInfo.pluginId, - fromTokenId, - defaultIsoFiat, - fromExchangeAmount - ) - const toFiatValue = convertCurrency( - exchangeRates, - toWallet.currencyInfo.pluginId, - toTokenId, - defaultIsoFiat, - toExchangeAmount - ) - - if (lte(fromFiatValue, '0')) return undefined - - const impact = parseFloat( - div(sub(fromFiatValue, toFiatValue), fromFiatValue, 8) - ) - return impact > 0 ? impact : undefined - }, [selectedQuote, exchangeRates, defaultIsoFiat, request]) + const priceImpact = React.useMemo( + () => + calculateQuotePriceImpact(selectedQuote, exchangeRates, defaultIsoFiat), + [selectedQuote, exchangeRates, defaultIsoFiat] + ) const showPriceImpact = priceImpact != null && priceImpact >= PRICE_IMPACT_WARNING_THRESHOLD @@ -216,7 +193,9 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { navigation.replace('swapProcessing', { swapRequest: selectedQuote.request, - swapRequestOptions, + swapRequestOptions: stealth + ? makeStealthSwapRequestOptions(account, swapRequestOptions) + : swapRequestOptions, onCancel: () => { navigation.navigate('swapTab', { screen: 'swapCreate' }) }, @@ -224,7 +203,8 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove + onApprove, + stealth }) } }) @@ -282,7 +262,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { request } = selectedQuote // Both fromCurrencyCode and toCurrencyCode will exist, since we set them: - const { toWallet, toTokenId, fromWallet, fromTokenId } = request + const { toTokenId, fromWallet, fromTokenId } = request + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } try { dispatch(logEvent('Exchange_Shift_Start')) @@ -488,11 +472,20 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { /> - + {stealth ? ( + // A stealth swap's provider is fixed, so the card is not + // tappable (no chevron, no "tap to change provider"): + + ) : ( + + )} {selectedQuote.isEstimate && !showPriceImpact ? ( @@ -556,7 +549,11 @@ const getSwapInfo = ( // Currency conversion tools: // Both fromCurrencyCode and toCurrencyCode will exist, since we set them: const { request } = quote - const { fromWallet, toWallet, fromTokenId, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } // Format from amount: const fromDisplayDenomination = selectDisplayDenom( diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index f42ec8d90b1..4b243910511 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -25,11 +25,14 @@ import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import { zeroString } from '../../util/utils' +import { openBrowserUri } from '../../util/WebUtils' 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, @@ -46,9 +49,16 @@ import { WalletListModal, type WalletListResult } from '../modals/WalletListModal' -import { Airship, showToast, showWarning } from '../services/AirshipInstance' -import { useTheme } from '../services/ThemeContext' +import { + Airship, + showError, + showToast, + showWarning +} from '../services/AirshipInstance' +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, @@ -74,6 +84,10 @@ export interface SwapErrorDisplayInfo { error: unknown } +/** Placeholder pending the final marketing URL. */ +const STEALTH_LEARN_MORE_URI = + 'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b' + interface Props extends SwapTabSceneProps<'swapCreate'> {} export const SwapCreateScene: React.FC = props => { @@ -86,6 +100,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 +110,10 @@ export const SwapCreateScene: React.FC = props => { 'from' | 'to' >('from') + // Stealth Swap: when enabled, the quote routes through the Houdini privacy + // provider as a fixed provider (see SwapConfirmationScene). + const [stealth, setStealth] = useState(false) + const fromInputRef = React.useRef(null) const toInputRef = React.useRef(null) @@ -228,6 +247,12 @@ export const SwapCreateScene: React.FC = props => { } const getQuote = (swapRequest: EdgeSwapRequest): void => { + // This scene only builds wallet-to-wallet swap requests, which always carry + // a destination wallet (swap-to-address has its own flow). + const toWallet = swapRequest.toWallet + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } if (exchangeInfo != null) { const disableSrc = checkDisableAsset( exchangeInfo.swap.disableAssets.source, @@ -247,7 +272,7 @@ export const SwapCreateScene: React.FC = props => { const disableDest = checkDisableAsset( exchangeInfo.swap.disableAssets.destination, - swapRequest.toWallet.id, + toWallet.id, toTokenId ) if (disableDest) { @@ -255,7 +280,7 @@ export const SwapCreateScene: React.FC = props => { sprintf( lstrings.swap_token_no_enabled_exchanges_2s, toCurrencyCode, - swapRequest.toWallet.currencyInfo.displayName + toWallet.currencyInfo.displayName ) ) return @@ -266,10 +291,13 @@ export const SwapCreateScene: React.FC = props => { errorDisplayInfo: undefined }) - // Start request for quote: + // Start request for quote. A stealth swap restricts the request to the + // Houdini privacy provider: navigation.navigate('swapProcessing', { swapRequest, - swapRequestOptions, + swapRequestOptions: stealth + ? makeStealthSwapRequestOptions(account, swapRequestOptions) + : swapRequestOptions, onCancel: () => { navigation.goBack() }, @@ -277,7 +305,8 @@ export const SwapCreateScene: React.FC = props => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove: resetState + onApprove: resetState, + stealth }) } }) @@ -441,6 +470,16 @@ export const SwapCreateScene: React.FC = props => { Keyboard.dismiss() }) + const handleToggleStealth = useHandler(() => { + setStealth(value => !value) + }) + + const handleStealthLearnMore = useHandler(() => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((err: unknown) => { + showError(err) + }) + }) + const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => { navigation.setParams({ // Update the error state: @@ -607,6 +646,30 @@ export const SwapCreateScene: React.FC = props => { /> )} + {fromWallet != null && toWallet != null ? ( + + + + {stealth ? ( + + + {lstrings.stealth_swap_info}{' '} + + {lstrings.stealth_learn_more} + + + + ) : null} + + + ) : null} {renderAlert()} {isNextHidden || isKeyboardOpen ? null : ( @@ -636,3 +699,18 @@ const MaxButtonText = styled(UnscaledText)(theme => ({ fontSize: theme.rem(0.75), includeFontPadding: false })) + +const getStyles = cacheStyles((theme: Theme) => ({ + stealthInfo: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + stealthInfoText: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + stealthLearnMoreLink: { + color: theme.textLink, + fontSize: theme.rem(0.75) + } +})) diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 3305f8a2458..bafcf4eba7e 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -49,10 +49,19 @@ export const SwapProcessingScene: React.FC = (props: Props) => { swapRequest.fromTokenId ) const toDenomination = useDisplayDenom( - swapRequest.toWallet.currencyConfig, + // Wallet-to-wallet swaps always have a destination wallet here; fall back to + // the source config only so this hook stays unconditional. + (swapRequest.toWallet ?? swapRequest.fromWallet).currencyConfig, swapRequest.toTokenId ) + // This scene only processes wallet-to-wallet swap requests, which always + // carry a destination wallet (swap-to-address has its own flow). + const toWallet = swapRequest.toWallet + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } + const doWork = async (isCancelled: () => boolean): Promise => { const quotes = await account.fetchSwapQuotes( swapRequest, @@ -70,7 +79,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { const fromWallet = swapRequest.fromWallet const fromAddresses = await fromWallet.getAddresses({ tokenId: null }) const fromAddress = fromAddresses[0]?.publicAddress - const targetPluginId = swapRequest.toWallet.currencyInfo.pluginId + const targetPluginId = toWallet.currencyInfo.pluginId let matchingWalletId: string | undefined for (const walletId of Object.keys(account.currencyWallets)) { @@ -89,8 +98,8 @@ export const SwapProcessingScene: React.FC = (props: Props) => { } } - let finalToWalletId: string = swapRequest.toWallet.id - let finalToWallet = swapRequest.toWallet + let finalToWalletId: string + let finalToWallet: typeof toWallet let isWalletCreated = false if (matchingWalletId == null) { // If not found, split from the source chain wallet to the destination @@ -165,7 +174,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId } }) @@ -189,7 +198,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId, errorDisplayInfo } @@ -313,7 +322,9 @@ function processSwapQuoteError({ swapRequest.fromTokenId ) const toCurrencyCode = getCurrencyCode( - swapRequest.toWallet, + // Wallet-to-wallet swaps always have a destination wallet here; the + // fallback only keeps the type honest for swap-to-address requests. + swapRequest.toWallet ?? swapRequest.fromWallet, swapRequest.toTokenId ) @@ -362,10 +373,11 @@ function trackSwapError(error: unknown, swapRequest: EdgeSwapRequest): void { swapRequest.fromTokenId ), swapToCurrency: getCurrencyCode( - swapRequest.toWallet, + swapRequest.toWallet ?? swapRequest.fromWallet, swapRequest.toTokenId ), - swapToWalletKind: swapRequest.toWallet.currencyInfo.pluginId, + swapToWalletKind: (swapRequest.toWallet ?? swapRequest.fromWallet) + .currencyInfo.pluginId, swapDirectionType: swapRequest.quoteFor }) // Unsearchable context data: diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 1d17b7a482c..2e50d9ee3d1 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -771,6 +771,7 @@ const convertActionToSwapData = ( payoutCurrencyCode, payoutTokenId: toAsset.tokenId, payoutNativeAmount: action.toAsset.nativeAmount ?? '0', + // A swap-to-address (private send) has no payout wallet: payoutWalletId, refundAddress } diff --git a/src/components/themed/ExchangeQuoteComponent.tsx b/src/components/themed/ExchangeQuoteComponent.tsx index 70b110a5db6..86bd3b68eee 100644 --- a/src/components/themed/ExchangeQuoteComponent.tsx +++ b/src/components/themed/ExchangeQuoteComponent.tsx @@ -6,7 +6,6 @@ import { View } from 'react-native' import { useCryptoText } from '../../hooks/useCryptoText' import { formatFiatString, useFiatText } from '../../hooks/useFiatText' import { useTokenDisplayData } from '../../hooks/useTokenDisplayData' -import { formatNumber } from '../../locales/intl' import { lstrings } from '../../locales/strings' import { convertCurrency } from '../../selectors/WalletSelectors' import { useSelector } from '../../types/reactRedux' @@ -16,6 +15,7 @@ import { EdgeCard } from '../cards/EdgeCard' import { CurrencyRow } from '../rows/CurrencyRow' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from './EdgeText' +import { PriceImpactText } from './PriceImpactText' interface Props { fromTo: 'from' | 'to' @@ -27,7 +27,13 @@ interface Props { export const ExchangeQuote: React.FC = props => { const { fromTo, priceImpact, quote, showFeeWarning } = props const { request, fromNativeAmount, toNativeAmount, networkFee } = quote - const { fromWallet, fromTokenId, toWallet, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + // A wallet-to-wallet swap quote always carries a destination wallet; only a + // swap-to-address request (its own flow) omits it. + const toWallet = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } const theme = useTheme() const styles = getStyles(theme) @@ -188,17 +194,7 @@ export const ExchangeQuote: React.FC = props => { const priceImpactNode = !isFrom && priceImpact != null && priceImpact > 0 ? ( - = 0.15 - ? styles.priceImpactHigh - : priceImpact >= 0.05 - ? styles.priceImpactMedium - : styles.priceImpactLow - } - > - {` (${formatNumber(priceImpact * 100, { toFixed: 2 })}%)`} - + ) : undefined return ( @@ -241,17 +237,5 @@ const getStyles = cacheStyles((theme: Theme) => ({ bottomWarningText: { fontSize: theme.rem(0.75), color: theme.warningText - }, - priceImpactLow: { - fontSize: theme.rem(0.75), - color: theme.deactivatedText - }, - priceImpactMedium: { - fontSize: theme.rem(0.75), - color: theme.warningText - }, - priceImpactHigh: { - fontSize: theme.rem(0.75), - color: theme.dangerText } })) diff --git a/src/components/themed/PriceImpactText.tsx b/src/components/themed/PriceImpactText.tsx new file mode 100644 index 00000000000..027fc532907 --- /dev/null +++ b/src/components/themed/PriceImpactText.tsx @@ -0,0 +1,111 @@ +import { div, lte, sub } from 'biggystring' +import type { EdgeSwapQuote } from 'edge-core-js' +import * as React from 'react' + +import type { GuiExchangeRates } from '../../actions/ExchangeRateActions' +import { formatNumber } from '../../locales/intl' +import { getExchangeDenom } from '../../selectors/DenominationSelectors' +import { convertCurrency } from '../../selectors/WalletSelectors' +import { convertNativeToExchange } from '../../util/utils' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from './EdgeText' + +/** Price impacts at or above this warrant a warning. */ +export const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 + +/** + * The fiat-value fraction a swap quote loses between its from and to sides + * (0.05 = 5%), or undefined when it cannot be computed or is not a loss. + * Works for wallet-to-wallet and swap-to-address quotes alike: the quote's + * destination wallet (synthetic for swap-to-address) carries the real + * `currencyConfig`. + */ +export function calculateQuotePriceImpact( + quote: EdgeSwapQuote, + exchangeRates: GuiExchangeRates, + defaultIsoFiat: string +): number | undefined { + const { request, fromNativeAmount, toNativeAmount } = quote + const { fromWallet, fromTokenId, toWallet, toTokenId } = request + if (toWallet == null) return undefined + + const fromExchangeDenom = getExchangeDenom( + fromWallet.currencyConfig, + fromTokenId + ) + const toExchangeDenom = getExchangeDenom(toWallet.currencyConfig, toTokenId) + + const fromExchangeAmount = convertNativeToExchange( + fromExchangeDenom.multiplier + )(fromNativeAmount) + const toExchangeAmount = convertNativeToExchange(toExchangeDenom.multiplier)( + toNativeAmount + ) + + const fromFiatValue = convertCurrency( + exchangeRates, + fromWallet.currencyInfo.pluginId, + fromTokenId, + defaultIsoFiat, + fromExchangeAmount + ) + const toFiatValue = convertCurrency( + exchangeRates, + toWallet.currencyInfo.pluginId, + toTokenId, + defaultIsoFiat, + toExchangeAmount + ) + + if (lte(fromFiatValue, '0')) return undefined + + const impact = parseFloat( + div(sub(fromFiatValue, toFiatValue), fromFiatValue, 8) + ) + return impact > 0 ? impact : undefined +} + +interface Props { + priceImpact: number | undefined +} + +/** + * The colored ` (x.xx%)` price-delta suffix shared by the swap confirmation + * quote card and the send scene's quote row. + */ +export const PriceImpactText: React.FC = props => { + const { priceImpact } = props + const theme = useTheme() + const styles = getStyles(theme) + + if (priceImpact == null || priceImpact <= 0) return null + + return ( + = 0.15 + ? styles.priceImpactHigh + : priceImpact >= PRICE_IMPACT_WARNING_THRESHOLD + ? styles.priceImpactMedium + : styles.priceImpactLow + } + > + {` (${formatNumber(priceImpact * 100, { toFixed: 2 })}%)`} + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + priceImpactLow: { + color: theme.deactivatedText, + fontSize: theme.rem(0.75) + }, + priceImpactMedium: { + color: theme.warningText, + fontSize: theme.rem(0.75) + }, + priceImpactHigh: { + color: theme.dangerText, + fontSize: theme.rem(0.75) + } +})) diff --git a/src/components/themed/SafeSlider.tsx b/src/components/themed/SafeSlider.tsx index 323a936858c..ae6626f5351 100644 --- a/src/components/themed/SafeSlider.tsx +++ b/src/components/themed/SafeSlider.tsx @@ -125,6 +125,7 @@ export const SafeSlider: React.FC = props => { sliderDisabled ? styles.disabledThumb : null, scrollTranslationStyle ]} + testID="confirmSliderThumb" > boolean navigation: NavigationBase } @@ -102,6 +109,7 @@ export const AddressTile2 = React.forwardRef( onChangeAddress, recipientAddress, resetSendTransaction, + crossChainAddressValidation, title } = props @@ -170,6 +178,24 @@ export const AddressTile2 = React.forwardRef( async (address: string, addressEntryMethod: AddressEntryMethod) => { if (address == null || address.trim() === '') return + // A cross-chain destination cannot go through this wallet's URI + // parsing or name services; validate it against the destination + // chain's own rules and pass it through verbatim. + if (crossChainAddressValidation != null) { + const crossChainAddress = address.trim() + if (!crossChainAddressValidation(crossChainAddress)) { + showToast( + `${lstrings.scan_invalid_address_error_title} ${lstrings.scan_invalid_address_error_description}` + ) + return + } + await onChangeAddress({ + parsedUri: { publicAddress: crossChainAddress }, + addressEntryMethod + }) + return + } + setLoading(true) const enteredInput = address.trim() address = enteredInput @@ -518,6 +544,7 @@ export const AddressTile2 = React.forwardRef( chain.pluginId === pluginId) +} + +/** Validate a pasted destination address against the chain's own regex. */ +export function isValidHoudiniAddress( + chain: HoudiniChain, + address: string +): boolean { + return chain.addressValidation.test(address.trim()) +} diff --git a/src/util/stealthSwap.ts b/src/util/stealthSwap.ts new file mode 100644 index 00000000000..a0abd83f143 --- /dev/null +++ b/src/util/stealthSwap.ts @@ -0,0 +1,27 @@ +import type { + EdgeAccount, + EdgePluginMap, + EdgeSwapRequestOptions +} from 'edge-core-js' + +/** + * Restricts a swap request to the Houdini privacy provider, for Stealth Swap + * and Stealth Send. Every other enabled swap provider is disabled for the + * request, and any preferred-provider override is cleared so it cannot fight + * the restriction. + */ +export function makeStealthSwapRequestOptions( + account: EdgeAccount, + opts: EdgeSwapRequestOptions = {} +): EdgeSwapRequestOptions { + const disabled: EdgePluginMap = { ...opts.disabled } + for (const swapPluginId of Object.keys(account.swapConfig)) { + if (swapPluginId !== 'houdini') disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + preferPluginId: undefined, + preferType: undefined + } +}