diff --git a/CHANGELOG.md b/CHANGELOG.md
index a93be492443..e4e5aae36de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased (develop)
+- added: Private swap toggle on the swap amount scene that routes the exchange through Houdini's swap-to-address path to the chosen receive wallet. Depends on unpublished edge-core-js and edge-exchange-plugins changes.
+- added: Houdini private send prototype (dev-only): a swap-to-address flow that gets a live HoudiniSwap private quote, creates the private exchange order, and broadcasts the on-chain deposit. Depends on unpublished edge-core-js and edge-exchange-plugins changes.
- added: Logbox disable option to env.json
- added: Reverse-resolve recipient addresses to ENS / Unstoppable Domains / ZNS names in the send flow, address modal, and transaction history.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4ed016e51dd..9dc298326f5 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -229,7 +229,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/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts
index 494d875de17..a3e192b6cbe 100644
--- a/src/actions/CategoriesActions.ts
+++ b/src/actions/CategoriesActions.ts
@@ -382,8 +382,9 @@ export const getTxActionDisplayInfo = (
? lstrings.transaction_details_swap_to_subcat_1s
: lstrings.transaction_details_swap_from_subcat_1s
const walletName =
- account.currencyWallets[action.payoutWalletId]?.name ??
- displayName
+ (action.payoutWalletId != null
+ ? account.currencyWallets[action.payoutWalletId]?.name
+ : undefined) ?? displayName
edgeCategory = {
category: 'transfer',
subcategory: sprintf(toFromStr, walletName)
diff --git a/src/components/Main.tsx b/src/components/Main.tsx
index 1d67d8131e3..4744284ca37 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 { HoudiniPrivateSendScene as HoudiniPrivateSendSceneComponent } from './scenes/HoudiniPrivateSendScene'
import { LoanCloseScene as LoanCloseSceneComponent } from './scenes/Loans/LoanCloseScene'
import { LoanCreateConfirmationScene as LoanCreateConfirmationSceneComponent } from './scenes/Loans/LoanCreateConfirmationScene'
import { LoanCreateScene as LoanCreateSceneComponent } from './scenes/Loans/LoanCreateScene'
@@ -246,6 +247,7 @@ const GiftCardAccountInfoScene = ifLoggedIn(GiftCardAccountInfoSceneComponent)
const GiftCardListScene = ifLoggedIn(GiftCardListSceneComponent)
const GiftCardMarketScene = ifLoggedIn(GiftCardMarketSceneComponent)
const GiftCardPurchaseScene = ifLoggedIn(GiftCardPurchaseSceneComponent)
+const HoudiniPrivateSendScene = ifLoggedIn(HoudiniPrivateSendSceneComponent)
const LoanCloseScene = ifLoggedIn(LoanCloseSceneComponent)
const LoanCreateConfirmationScene = ifLoggedIn(
LoanCreateConfirmationSceneComponent
@@ -958,6 +960,10 @@ const EdgeAppStack: 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/DevTestScene.tsx b/src/components/scenes/DevTestScene.tsx
index 006550b5c22..19ba8235a6f 100644
--- a/src/components/scenes/DevTestScene.tsx
+++ b/src/components/scenes/DevTestScene.tsx
@@ -242,6 +242,12 @@ export const DevTestScene: React.FC = props => {
<>
+ {
+ navigation.navigate('houdiniPrivateSend')
+ }}
+ />
{}
+
+/**
+ * A Houdini private send: pick a funded source wallet, pick a destination asset
+ * (both via the shared `WalletListModal`), paste a destination address, get a
+ * live private quote, then create the exchange order and broadcast the on-chain
+ * deposit through core's swap-to-address path.
+ */
+export const HoudiniPrivateSendScene: React.FC = props => {
+ const { navigation } = props
+ const theme = useTheme()
+ const styles = getStyles(theme)
+
+ const account = useSelector(state => state.core.account)
+ const currencyWallets = useSelector(
+ state => state.core.account.currencyWallets
+ )
+ const disablePlugins = useSelector(
+ state => state.ui.exchangeInfo.swap.disablePlugins
+ )
+ const disableAssets = useSelector(
+ state => state.ui.exchangeInfo.swap.disableAssets
+ )
+
+ const [fromWalletId, setFromWalletId] = useState(
+ undefined
+ )
+ const [fromTokenId, setFromTokenId] = useState(null)
+ const [destAsset, setDestAsset] = useState<
+ HoudiniDestinationAsset | undefined
+ >(undefined)
+ const [toAddress, setToAddress] = useState(undefined)
+ const [displayAmount, setDisplayAmount] = useState(
+ undefined
+ )
+ const [pending, setPending] = useState(false)
+
+ const fromWallet =
+ fromWalletId != null ? currencyWallets[fromWalletId] : undefined
+
+ const handlePickSource = useHandler(async () => {
+ if (pending) return
+ const result = await Airship.show(bridge => (
+
+ ))
+ if (result?.type === 'wallet') {
+ setFromWalletId(result.walletId)
+ setFromTokenId(result.tokenId)
+ }
+ })
+
+ const handlePickDestAsset = useHandler(async () => {
+ if (pending) return
+ // Reuse the shared wallet picker, filtered to the chains Houdini can
+ // privately route to, so the destination chain is chosen with the same
+ // control as the source rather than a bespoke picker.
+ const result = await Airship.show(bridge => (
+
+ ))
+ if (result?.type !== 'wallet') return
+ const selectedWallet = currencyWallets[result.walletId]
+ if (selectedWallet == null) return
+ const asset = HOUDINI_DESTINATION_ASSETS.find(
+ candidate => candidate.pluginId === selectedWallet.currencyInfo.pluginId
+ )
+ if (asset != null) {
+ setDestAsset(asset)
+ // A new destination chain invalidates a previously entered address:
+ setToAddress(undefined)
+ }
+ })
+
+ const handleEnterAddress = useHandler(async () => {
+ if (pending) return
+ if (destAsset == null) {
+ showError(lstrings.houdini_ps_pick_dest_asset_first)
+ return
+ }
+ const asset = destAsset
+ const address = await Airship.show(bridge => (
+ {
+ if (!isValidHoudiniDestination(asset, text)) {
+ return lstrings.houdini_ps_invalid_address
+ }
+ return true
+ }}
+ />
+ ))
+ if (address != null && address.trim() !== '') {
+ setToAddress(address.trim())
+ }
+ })
+
+ const handleEnterAmount = useHandler(async () => {
+ if (pending) return
+ if (fromWallet == null) {
+ showError(lstrings.houdini_ps_pick_source_first)
+ return
+ }
+ const amount = await Airship.show(bridge => (
+
+ ))
+ if (amount != null && amount.trim() !== '') {
+ setDisplayAmount(amount.trim())
+ }
+ })
+
+ const handleGetQuote = useHandler(async () => {
+ if (pending) return
+ if (
+ fromWallet == null ||
+ destAsset == null ||
+ toAddress == null ||
+ displayAmount == null ||
+ zeroString(displayAmount)
+ ) {
+ showError(lstrings.houdini_ps_missing_fields)
+ return
+ }
+
+ // Honor the exchange-info asset disables, matching the swap flow.
+ if (
+ isAssetDisabled(
+ disableAssets.source,
+ fromWallet.currencyInfo.pluginId,
+ fromTokenId
+ )
+ ) {
+ showError(
+ sprintf(
+ lstrings.swap_token_no_enabled_exchanges_2s,
+ getCurrencyCode(fromWallet, fromTokenId),
+ fromWallet.currencyInfo.displayName
+ )
+ )
+ return
+ }
+ if (
+ isAssetDisabled(
+ disableAssets.destination,
+ destAsset.pluginId,
+ destAsset.tokenId
+ )
+ ) {
+ showError(
+ sprintf(
+ lstrings.swap_token_no_enabled_exchanges_2s,
+ destAsset.currencyCode,
+ destAsset.displayName
+ )
+ )
+ return
+ }
+
+ setPending(true)
+ try {
+ const fromMultiplier = getExchangeDenom(
+ fromWallet.currencyConfig,
+ fromTokenId
+ ).multiplier
+ const nativeAmount = round(mul(displayAmount, fromMultiplier), 0)
+
+ // Don't let the user reach confirm/approve with more than they hold.
+ const balance = fromWallet.balanceMap.get(fromTokenId) ?? '0'
+ if (gt(nativeAmount, balance)) {
+ showError(lstrings.exchange_insufficient_funds_below_balance)
+ return
+ }
+
+ const quote = await fetchHoudiniPrivateQuote(account, {
+ fromWallet,
+ fromTokenId,
+ toPluginId: destAsset.pluginId,
+ toTokenId: destAsset.tokenId,
+ toAddress,
+ nativeAmount,
+ disablePlugins
+ })
+
+ const toConfig = account.currencyConfig[destAsset.pluginId]
+ const toMultiplier = getExchangeDenom(
+ toConfig,
+ destAsset.tokenId
+ ).multiplier
+ const fromDisplay = div(quote.fromNativeAmount, fromMultiplier, 8)
+ const toDisplay = div(quote.toNativeAmount, toMultiplier, 8)
+
+ const fromCurrencyCode =
+ fromTokenId == null
+ ? fromWallet.currencyInfo.currencyCode
+ : fromWallet.currencyConfig.allTokens[fromTokenId]?.currencyCode ??
+ fromWallet.currencyInfo.currencyCode
+
+ const confirmed = await Airship.show(bridge => (
+
+ ))
+ if (!confirmed) return
+
+ const result = await quote.approve()
+ navigation.navigate('swapSuccess', {
+ edgeTransaction: result.transaction,
+ walletId: fromWallet.id
+ })
+ } catch (error: unknown) {
+ showError(error)
+ } finally {
+ setPending(false)
+ }
+ })
+
+ const sourceLabel =
+ fromWallet == null
+ ? lstrings.houdini_ps_select_source
+ : getWalletName(fromWallet)
+ const destLabel =
+ destAsset == null
+ ? lstrings.houdini_ps_select_dest_asset
+ : `${destAsset.displayName} (${destAsset.currencyCode})`
+ const addressLabel = toAddress ?? lstrings.houdini_ps_enter_dest_address
+ const amountLabel = displayAmount ?? lstrings.houdini_ps_enter_amount
+
+ return (
+
+
+
+
+
+ {lstrings.houdini_ps_source_wallet}
+
+
+ {sourceLabel}
+
+
+
+
+
+ {lstrings.houdini_ps_dest_asset}
+
+
+ {destLabel}
+
+
+
+
+
+ {lstrings.houdini_ps_dest_address}
+
+
+ {addressLabel}
+
+
+
+
+
+ {lstrings.houdini_ps_amount}
+
+
+ {amountLabel}
+
+
+
+
+
+ )
+}
+
+const getStyles = cacheStyles((theme: Theme) => ({
+ rowLabel: {
+ color: theme.secondaryText,
+ fontSize: theme.rem(0.75)
+ },
+ rowValue: {
+ marginTop: theme.rem(0.25)
+ }
+}))
diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx
index 5aba519cc4b..4eb0fe30126 100644
--- a/src/components/scenes/SwapConfirmationScene.tsx
+++ b/src/components/scenes/SwapConfirmationScene.tsx
@@ -127,7 +127,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => {
const { quoteFor } = request
const priceImpact = React.useMemo(() => {
- const { fromWallet, fromTokenId, toWallet, toTokenId } = request
+ const { fromWallet, fromTokenId, toTokenId } = request
+ const toWallet = request.toWallet
+ if (toWallet == null) {
+ throw new Error('Swap quote is missing a destination wallet')
+ }
const fromExchangeDenom = getExchangeDenom(
fromWallet.currencyConfig,
@@ -282,7 +286,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'))
@@ -556,7 +564,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..1cb86bd1420 100644
--- a/src/components/scenes/SwapCreateScene.tsx
+++ b/src/components/scenes/SwapCreateScene.tsx
@@ -1,4 +1,4 @@
-import { gt, gte } from 'biggystring'
+import { div, gt, gte } from 'biggystring'
import {
asMaybeInsufficientFundsError,
asMaybeSwapAboveLimitError,
@@ -21,10 +21,17 @@ import { useSwapRequestOptions } from '../../hooks/swap/useSwapRequestOptions'
import { useHandler } from '../../hooks/useHandler'
import { useWatch } from '../../hooks/useWatch'
import { lstrings } from '../../locales/strings'
+import { getExchangeDenom } from '../../selectors/DenominationSelectors'
import { useDispatch, useSelector } from '../../types/reactRedux'
import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes'
import { getCurrencyCode } from '../../util/CurrencyInfoHelpers'
import { getWalletName } from '../../util/CurrencyWalletHelpers'
+import {
+ fetchHoudiniPrivateQuote,
+ HOUDINI_DESTINATION_ASSETS,
+ isAssetDisabled,
+ isPluginDisabled
+} from '../../util/houdiniPrivateSend'
import { zeroString } from '../../util/utils'
import { EdgeButton } from '../buttons/EdgeButton'
import { KavButtons } from '../buttons/KavButtons'
@@ -42,12 +49,19 @@ import { SceneWrapper } from '../common/SceneWrapper'
import { styled } from '../hoc/styled'
import { SwapVerticalIcon } from '../icons/ThemedIcons'
import { SceneContainer } from '../layout/SceneContainer'
+import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
import {
WalletListModal,
type WalletListResult
} from '../modals/WalletListModal'
-import { Airship, showToast, showWarning } from '../services/AirshipInstance'
+import {
+ Airship,
+ showError,
+ showToast,
+ showWarning
+} from '../services/AirshipInstance'
import { useTheme } from '../services/ThemeContext'
+import { SettingsSwitchRow } from '../settings/SettingsSwitchRow'
import { UnscaledText } from '../text/UnscaledText'
import { LineTextDivider } from '../themed/LineTextDivider'
import {
@@ -95,6 +109,14 @@ export const SwapCreateScene: React.FC = props => {
'from' | 'to'
>('from')
+ // When enabled, the swap routes privately through Houdini's swap-to-address
+ // path (depositing to the destination wallet's address) instead of the normal
+ // multi-provider wallet-to-wallet flow.
+ const [isPrivateSwap, setIsPrivateSwap] = useState(false)
+ // Guards the async private-swap flow so a double-tap cannot launch two
+ // concurrent quote/approve/broadcast sequences.
+ const [privateSwapPending, setPrivateSwapPending] = useState(false)
+
const fromInputRef = React.useRef(null)
const toInputRef = React.useRef(null)
@@ -103,6 +125,9 @@ export const SwapCreateScene: React.FC = props => {
const account = useSelector(state => state.core.account)
const currencyWallets = useWatch(account, 'currencyWallets')
const exchangeInfo = useSelector(state => state.ui.exchangeInfo)
+ const disablePlugins = useSelector(
+ state => state.ui.exchangeInfo.swap.disablePlugins
+ )
const toWallet: EdgeCurrencyWallet | undefined =
toWalletId == null ? undefined : currencyWallets[toWalletId]
@@ -130,6 +155,18 @@ export const SwapCreateScene: React.FC = props => {
const hasMaxSpend =
fromWallet != null && fromWalletSpecialCurrencyInfo.noMaxSpend !== true
+ // Houdini can only privately route to the NATIVE asset of the chains in its
+ // destination set, so a token destination (toTokenId != null) is unsupported
+ // even when its chain appears in the set. A server-side Houdini disable also
+ // hides the toggle, since the private path is Houdini-only.
+ const isPrivateSwapSupported =
+ toWallet != null &&
+ toTokenId == null &&
+ !isPluginDisabled(disablePlugins, 'houdini') &&
+ HOUDINI_DESTINATION_ASSETS.some(
+ asset => asset.pluginId === toWallet.currencyInfo.pluginId
+ )
+
const isNextHidden =
// Don't show next button if the wallets haven't been selected:
fromWallet == null ||
@@ -149,6 +186,12 @@ export const SwapCreateScene: React.FC = props => {
})
}, [dispatch, navigation])
+ // Keep the private toggle from getting stuck "on" for a destination Houdini
+ // cannot privately route to (e.g. after the user changes the receive wallet).
+ React.useEffect(() => {
+ if (isPrivateSwap && !isPrivateSwapSupported) setIsPrivateSwap(false)
+ }, [isPrivateSwap, isPrivateSwapSupported])
+
//
// Callbacks
//
@@ -199,17 +242,9 @@ export const SwapCreateScene: React.FC = props => {
walletId: string,
tokenId: EdgeTokenId
): boolean => {
- const wallet = currencyWallets[walletId] ?? { currencyInfo: {} }
- const walletPluginId = wallet.currencyInfo.pluginId
- const walletTokenId = tokenId
- for (const disableAsset of disableAssets) {
- const { pluginId, tokenId } = disableAsset
- if (pluginId !== walletPluginId) continue
- if (tokenId === walletTokenId) return true
- if (tokenId === 'allCoins') return true
- if (tokenId === 'allTokens' && walletTokenId != null) return true
- }
- return false
+ const wallet = currencyWallets[walletId]
+ if (wallet == null) return false
+ return isAssetDisabled(disableAssets, wallet.currencyInfo.pluginId, tokenId)
}
function checkAmountExceedsBalance(): boolean {
@@ -228,6 +263,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 +288,7 @@ export const SwapCreateScene: React.FC = props => {
const disableDest = checkDisableAsset(
exchangeInfo.swap.disableAssets.destination,
- swapRequest.toWallet.id,
+ toWallet.id,
toTokenId
)
if (disableDest) {
@@ -255,7 +296,7 @@ export const SwapCreateScene: React.FC = props => {
sprintf(
lstrings.swap_token_no_enabled_exchanges_2s,
toCurrencyCode,
- swapRequest.toWallet.currencyInfo.displayName
+ toWallet.currencyInfo.displayName
)
)
return
@@ -292,6 +333,8 @@ export const SwapCreateScene: React.FC = props => {
const showWalletListModal = async (
whichWallet: 'from' | 'to'
): Promise => {
+ // Don't let a wallet change underneath an in-flight private quote.
+ if (privateSwapPending) return
const result = await Airship.show(bridge => (
= props => {
//
const handleFlipWalletPress = useHandler(() => {
+ // Don't let the pair change underneath an in-flight private quote.
+ if (privateSwapPending) return
// Flip params:
navigation.setParams({
fromWalletId: toWalletId,
@@ -375,7 +420,121 @@ export const SwapCreateScene: React.FC = props => {
}
)
+ /**
+ * Route the current amounts through Houdini's swap-to-address path: derive
+ * the destination address from the chosen receiving wallet, fetch a
+ * Houdini-only private quote, confirm, approve, then land on the success
+ * scene. The normal `swapProcessing`/`swapConfirmation` scenes assume a
+ * destination wallet, so the private path runs its own confirm + approve.
+ */
+ const executePrivateSwap = useHandler(async (): Promise => {
+ if (privateSwapPending) return
+ if (fromWallet == null || toWallet == null) return
+
+ if (zeroString(inputNativeAmount)) {
+ showToast(
+ `${lstrings.no_exchange_amount}. ${lstrings.select_exchange_amount}.`
+ )
+ return
+ }
+ if (checkAmountExceedsBalance()) return
+ // Houdini quotes only off the send amount, so a "to" amount cannot drive a
+ // private quote.
+ if (inputNativeAmountFor !== 'from') {
+ showWarning(lstrings.houdini_swap_from_amount_only, { trackError: false })
+ return
+ }
+
+ // Mirror getQuote: honor the exchange-info asset disables for the private
+ // path too, so a disabled source/destination asset cannot start a swap.
+ const disableSrc = checkDisableAsset(
+ exchangeInfo.swap.disableAssets.source,
+ fromWallet.id,
+ fromTokenId
+ )
+ if (disableSrc) {
+ showToast(
+ sprintf(
+ lstrings.swap_token_no_enabled_exchanges_2s,
+ fromCurrencyCode,
+ fromWallet.currencyInfo.displayName
+ )
+ )
+ return
+ }
+ const disableDest = checkDisableAsset(
+ exchangeInfo.swap.disableAssets.destination,
+ toWallet.id,
+ toTokenId
+ )
+ if (disableDest) {
+ showToast(
+ sprintf(
+ lstrings.swap_token_no_enabled_exchanges_2s,
+ toCurrencyCode,
+ toWallet.currencyInfo.displayName
+ )
+ )
+ return
+ }
+
+ setPrivateSwapPending(true)
+ try {
+ const toAddresses = await toWallet.getAddresses({ tokenId: null })
+ const toAddress = toAddresses[0]?.publicAddress
+ if (toAddress == null) {
+ showError(lstrings.houdini_swap_no_dest_address)
+ return
+ }
+
+ const quote = await fetchHoudiniPrivateQuote(account, {
+ fromWallet,
+ fromTokenId,
+ toPluginId: toWallet.currencyInfo.pluginId,
+ toTokenId,
+ toAddress,
+ nativeAmount: inputNativeAmount,
+ disablePlugins
+ })
+
+ const fromMultiplier = getExchangeDenom(
+ fromWallet.currencyConfig,
+ fromTokenId
+ ).multiplier
+ const toMultiplier = getExchangeDenom(
+ toWallet.currencyConfig,
+ toTokenId
+ ).multiplier
+ const fromDisplay = div(quote.fromNativeAmount, fromMultiplier, 8)
+ const toDisplay = div(quote.toNativeAmount, toMultiplier, 8)
+
+ const confirmed = await Airship.show(bridge => (
+
+ ))
+ if (!confirmed) return
+
+ const result = await quote.approve()
+ resetState()
+ navigation.push('swapSuccess', {
+ edgeTransaction: result.transaction,
+ walletId: fromWallet.id
+ })
+ } finally {
+ setPrivateSwapPending(false)
+ }
+ })
+
const handleMaxPress = useHandler(() => {
+ if (isPrivateSwap) {
+ showWarning(lstrings.houdini_swap_from_amount_only, { trackError: false })
+ return
+ }
+
if (toWallet == null) {
showWarning(lstrings.exchange_select_receiving_wallet, {
trackError: false
@@ -404,10 +563,29 @@ export const SwapCreateScene: React.FC = props => {
getQuote(request)
})
+ const handleTogglePrivateSwap = useHandler(() => {
+ // Don't let the routing change underneath an in-flight private quote.
+ if (privateSwapPending) return
+ setIsPrivateSwap(value => !value)
+ })
+
const handleNext = useHandler(() => {
// Should only happen if the user initiated the swap from the keyboard
if (fromWallet == null || toWallet == null) return
+ // Gate on isPrivateSwapSupported too: when the receive asset stops
+ // supporting private routing, isPrivateSwap can lag a render behind the
+ // effect that clears it, so this guard keeps Next on the normal flow.
+ if (isPrivateSwap && isPrivateSwapSupported) {
+ // handleNext feeds the void-typed button onPress, so it must stay
+ // synchronous; executePrivateSwap owns its own error display, and this
+ // .catch is the required floating-promise guard.
+ executePrivateSwap().catch((error: unknown) => {
+ showError(error)
+ })
+ return
+ }
+
if (zeroString(inputNativeAmount)) {
showToast(
`${lstrings.no_exchange_amount}. ${lstrings.select_exchange_amount}.`
@@ -442,6 +620,8 @@ export const SwapCreateScene: React.FC = props => {
})
const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => {
+ // Don't let the amount change underneath an in-flight private quote.
+ if (privateSwapPending) return
navigation.setParams({
// Update the error state:
...getNewErrorInfo('amount')
@@ -455,6 +635,8 @@ export const SwapCreateScene: React.FC = props => {
})
const handleToAmountChange = useHandler((amounts: SwapInputCardAmounts) => {
+ // Don't let the amount change underneath an in-flight private quote.
+ if (privateSwapPending) return
navigation.setParams({
// Update the error state:
...getNewErrorInfo('amount')
@@ -524,7 +706,7 @@ export const SwapCreateScene: React.FC = props => {
primary={{
label: lstrings.string_next_capitalized,
onPress: handleNext,
- disabled: isNextHidden
+ disabled: isNextHidden || privateSwapPending
}}
tertiary={{
label: lstrings.string_cancel_cap,
@@ -608,12 +790,24 @@ export const SwapCreateScene: React.FC = props => {
)}
{renderAlert()}
+ {isPrivateSwapSupported ? (
+
+
+
+ ) : null}
{isNextHidden || isKeyboardOpen ? null : (
)}
diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx
index 3305f8a2458..fa797e5959e 100644
--- a/src/components/scenes/SwapProcessingScene.tsx
+++ b/src/components/scenes/SwapProcessingScene.tsx
@@ -49,10 +49,22 @@ export const SwapProcessingScene: React.FC = (props: Props) => {
swapRequest.fromTokenId
)
const toDenomination = useDisplayDenom(
- swapRequest.toWallet.currencyConfig,
- swapRequest.toTokenId
+ // Wallet-to-wallet swaps always have a destination wallet here; fall back to
+ // the source config only so this hook stays unconditional. Pair that
+ // fallback with a null tokenId so the lookup never asks the source config
+ // for a token it does not have (the result is unused once the guard below
+ // throws on a missing destination wallet).
+ (swapRequest.toWallet ?? swapRequest.fromWallet).currencyConfig,
+ swapRequest.toWallet != null ? swapRequest.toTokenId : null
)
+ // 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 +82,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 +101,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 +177,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 +201,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 +325,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 +376,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..4f5cea8a262 100644
--- a/src/components/scenes/TransactionDetailsScene.tsx
+++ b/src/components/scenes/TransactionDetailsScene.tsx
@@ -771,7 +771,9 @@ const convertActionToSwapData = (
payoutCurrencyCode,
payoutTokenId: toAsset.tokenId,
payoutNativeAmount: action.toAsset.nativeAmount ?? '0',
- payoutWalletId,
+ // A swap-to-address (private send) has no payout wallet; EdgeTxSwap still
+ // types this as required, so fall back to an empty id.
+ payoutWalletId: payoutWalletId ?? '',
refundAddress
}
return out
diff --git a/src/components/themed/ExchangeQuoteComponent.tsx b/src/components/themed/ExchangeQuoteComponent.tsx
index 70b110a5db6..d287b602362 100644
--- a/src/components/themed/ExchangeQuoteComponent.tsx
+++ b/src/components/themed/ExchangeQuoteComponent.tsx
@@ -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)
diff --git a/src/envConfig.ts b/src/envConfig.ts
index 149d1383f28..d33ab4d1e5c 100644
--- a/src/envConfig.ts
+++ b/src/envConfig.ts
@@ -322,6 +322,12 @@ export const asEnvConfig = asObject({
),
HOLESKY_INIT: asCorePluginInit(asEvmApiKeys),
HEDERA_INIT: asOptional(asBoolean, true),
+ HOUDINI_INIT: asCorePluginInit(
+ asObject({
+ apiKey: asOptional(asString, ''),
+ apiSecret: asOptional(asString, '')
+ }).withRest
+ ),
HYPEREVM_INIT: asCorePluginInit(asEvmApiKeys),
LIBERLAND_INIT: asOptional(asBoolean, true),
LIFI_INIT: asCorePluginInit(
diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts
index 6b289adf405..c32e44d5471 100644
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -1832,6 +1832,31 @@ const strings = {
// Wallet List Modal
select_recv_wallet: 'Select Receiving Wallet',
select_src_wallet: 'Select Source Wallet',
+ houdini_private_send_title: 'Houdini Private Send',
+ houdini_ps_source_wallet: 'Source wallet',
+ houdini_ps_select_source: 'Select source wallet',
+ houdini_ps_dest_asset: 'Destination asset',
+ houdini_ps_select_dest_asset: 'Select destination asset',
+ houdini_ps_dest_address: 'Destination address',
+ houdini_ps_enter_dest_address: 'Enter destination address',
+ houdini_ps_paste_address_hint: 'Paste the recipient address',
+ houdini_ps_amount: 'Amount',
+ houdini_ps_enter_amount: 'Enter amount to send',
+ houdini_ps_get_quote: 'Get private quote',
+ houdini_ps_invalid_address: 'Invalid address for the selected asset',
+ houdini_ps_confirm_send: 'Confirm private send',
+ houdini_ps_confirm_body:
+ 'A private exchange order will be created and the on-chain deposit broadcast. This cannot be undone.',
+ houdini_ps_no_quote: 'No private quote available',
+ houdini_ps_missing_fields:
+ 'Select a source wallet, destination asset, address, and amount first',
+ houdini_ps_pick_dest_asset_first: 'Select a destination asset first',
+ houdini_ps_pick_source_first: 'Select a source wallet first',
+ houdini_swap_private_label: 'Private swap',
+ houdini_swap_from_amount_only:
+ 'Private swaps quote from the send amount only',
+ houdini_swap_no_dest_address:
+ 'Could not get a destination address for the selected wallet',
deposit_to_bank: 'Deposit to Bank',
your_wallets: 'Your Wallets',
pause_wallet_toast:
diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json
index deedd2840cd..a8dd003316c 100644
--- a/src/locales/strings/enUS.json
+++ b/src/locales/strings/enUS.json
@@ -1428,6 +1428,27 @@
"fiat_plugin_no_sell_providers": "Unable to find any available providers supporting sell of your current assets. This may be due to a poor network connection or lack of support in your region. Please try again later or select a different payment method.",
"select_recv_wallet": "Select Receiving Wallet",
"select_src_wallet": "Select Source Wallet",
+ "houdini_private_send_title": "Houdini Private Send",
+ "houdini_ps_source_wallet": "Source wallet",
+ "houdini_ps_select_source": "Select source wallet",
+ "houdini_ps_dest_asset": "Destination asset",
+ "houdini_ps_select_dest_asset": "Select destination asset",
+ "houdini_ps_dest_address": "Destination address",
+ "houdini_ps_enter_dest_address": "Enter destination address",
+ "houdini_ps_paste_address_hint": "Paste the recipient address",
+ "houdini_ps_amount": "Amount",
+ "houdini_ps_enter_amount": "Enter amount to send",
+ "houdini_ps_get_quote": "Get private quote",
+ "houdini_ps_invalid_address": "Invalid address for the selected asset",
+ "houdini_ps_confirm_send": "Confirm private send",
+ "houdini_ps_confirm_body": "A private exchange order will be created and the on-chain deposit broadcast. This cannot be undone.",
+ "houdini_ps_no_quote": "No private quote available",
+ "houdini_ps_missing_fields": "Select a source wallet, destination asset, address, and amount first",
+ "houdini_ps_pick_dest_asset_first": "Select a destination asset first",
+ "houdini_ps_pick_source_first": "Select a source wallet first",
+ "houdini_swap_private_label": "Private swap",
+ "houdini_swap_from_amount_only": "Private swaps quote from the send amount only",
+ "houdini_swap_no_dest_address": "Could not get a destination address for the selected wallet",
"deposit_to_bank": "Deposit to Bank",
"your_wallets": "Your Wallets",
"pause_wallet_toast": "This wallet will no longer synchronize with the blockchain and will not detect new transactions or balance changes",
diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx
index d7d8ec55964..95dc1ba368f 100644
--- a/src/types/routerTypes.tsx
+++ b/src/types/routerTypes.tsx
@@ -208,6 +208,7 @@ export type EdgeAppStackParamList = {} & {
giftCardList: undefined
giftCardMarket: undefined
giftCardPurchase: GiftCardPurchaseParams
+ houdiniPrivateSend: undefined
loanClose: LoanCloseParams
loanCreate: LoanCreateParams
loanCreateConfirmation: LoanCreateConfirmationParams
diff --git a/src/util/corePlugins.ts b/src/util/corePlugins.ts
index 719aebe1008..eb7aa52499e 100644
--- a/src/util/corePlugins.ts
+++ b/src/util/corePlugins.ts
@@ -95,6 +95,7 @@ export const swapPlugins = {
changenow: ENV.CHANGE_NOW_INIT,
exolix: ENV.EXOLIX_INIT,
godex: ENV.GODEX_INIT,
+ houdini: ENV.HOUDINI_INIT,
lifi: ENV.LIFI_INIT,
letsexchange: ENV.LETSEXCHANGE_INIT,
nexchange: ENV.NEXCHANGE_INIT,
diff --git a/src/util/houdiniPrivateSend.ts b/src/util/houdiniPrivateSend.ts
new file mode 100644
index 00000000000..349cf4a3e6d
--- /dev/null
+++ b/src/util/houdiniPrivateSend.ts
@@ -0,0 +1,247 @@
+import type {
+ EdgeAccount,
+ EdgeCurrencyWallet,
+ EdgeSwapQuote,
+ EdgeSwapRequest,
+ EdgeSwapToAddressInfo,
+ EdgeTokenId
+} from 'edge-core-js'
+
+import type { DisableAsset } from '../actions/ExchangeInfoActions'
+import { lstrings } from '../locales/strings'
+import type { EdgeAsset } from '../types/types'
+
+/**
+ * A destination asset Houdini can privately route a swap to, paired with the
+ * per-chain address-validation regex from Houdini's own `GET /chains`
+ * (the Phase 1 coverage matrix, Asana task 1215645061309285).
+ */
+export interface HoudiniDestinationAsset {
+ pluginId: string
+ tokenId: EdgeTokenId
+ currencyCode: string
+ displayName: string
+ addressValidation: RegExp
+}
+
+/**
+ * Prototype subset of Houdini's MVP destination chains (native assets only).
+ * A production flow would source this dynamically from Houdini's `GET /chains`
+ * (intersected with Edge's `edgeCurrencyPluginIds`) rather than hard-coding it;
+ * the full 32-chain matrix lives on Asana task 1215645061309285. The
+ * `addressValidation` regexes are Houdini's own, reused here to validate a
+ * pasted destination address before spending the user's funds.
+ */
+export const HOUDINI_DESTINATION_ASSETS: HoudiniDestinationAsset[] = [
+ {
+ pluginId: 'bitcoin',
+ tokenId: null,
+ currencyCode: 'BTC',
+ displayName: 'Bitcoin',
+ addressValidation:
+ /^([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39}|bc1[a-z0-9]{59})$/
+ },
+ {
+ pluginId: 'ethereum',
+ tokenId: null,
+ currencyCode: 'ETH',
+ displayName: 'Ethereum',
+ addressValidation: /^(0x)[0-9A-Za-z]{40}$/
+ },
+ {
+ pluginId: 'litecoin',
+ tokenId: null,
+ currencyCode: 'LTC',
+ displayName: 'Litecoin',
+ addressValidation: /^(L|M|3)[A-Za-z0-9]{33}$|^(ltc1)[0-9A-Za-z]{39}$/
+ },
+ {
+ pluginId: 'dogecoin',
+ tokenId: null,
+ currencyCode: 'DOGE',
+ displayName: 'Dogecoin',
+ addressValidation: /^(D|A|9)[a-km-zA-HJ-NP-Z1-9]{33,34}$/
+ },
+ {
+ pluginId: 'bitcoincash',
+ tokenId: null,
+ currencyCode: 'BCH',
+ displayName: 'Bitcoin Cash',
+ addressValidation:
+ /^([13][a-km-zA-HJ-NP-Z1-9]{25,34})$|^((bitcoincash:)?(q|p)[a-z0-9]{41})$|^((BITCOINCASH:)?(Q|P)[A-Z0-9]{41})$/
+ },
+ {
+ pluginId: 'dash',
+ tokenId: null,
+ currencyCode: 'DASH',
+ displayName: 'Dash',
+ addressValidation: /^[X7][0-9A-Za-z]{33}$/
+ },
+ {
+ pluginId: 'solana',
+ tokenId: null,
+ currencyCode: 'SOL',
+ displayName: 'Solana',
+ // Base58, 32-44 chars; every position uses the full Base58 alphabet
+ // (excludes 0, O, I, l).
+ addressValidation: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
+ },
+ {
+ pluginId: 'tron',
+ tokenId: null,
+ currencyCode: 'TRX',
+ displayName: 'Tron',
+ addressValidation: /^T[1-9A-HJ-NP-Za-km-z]{33}$/
+ },
+ {
+ pluginId: 'monero',
+ tokenId: null,
+ currencyCode: 'XMR',
+ displayName: 'Monero',
+ addressValidation: /^[48][a-zA-Z\d]{94}([a-zA-Z\d]{11})?$/
+ },
+ {
+ pluginId: 'polygon',
+ tokenId: null,
+ currencyCode: 'POL',
+ displayName: 'Polygon',
+ addressValidation: /^(0x)[0-9A-Za-z]{40}$/
+ },
+ {
+ pluginId: 'avalanche',
+ tokenId: null,
+ currencyCode: 'AVAX',
+ displayName: 'Avalanche (C-Chain)',
+ addressValidation: /^(0x)[0-9A-Za-z]{40}$/
+ },
+ {
+ pluginId: 'arbitrum',
+ tokenId: null,
+ currencyCode: 'ETH',
+ displayName: 'Arbitrum',
+ addressValidation: /^(0x)[0-9A-Za-z]{40}$/
+ },
+ {
+ pluginId: 'base',
+ tokenId: null,
+ currencyCode: 'ETH',
+ displayName: 'Base',
+ addressValidation: /^(0x)[0-9A-Za-z]{40}$/
+ }
+]
+
+/**
+ * The Houdini destination chains expressed as `EdgeAsset`s, for filtering the
+ * shared `WalletListModal` down to the assets Houdini can privately route to.
+ */
+export const HOUDINI_DESTINATION_EDGE_ASSETS: EdgeAsset[] =
+ HOUDINI_DESTINATION_ASSETS.map(asset => ({
+ pluginId: asset.pluginId,
+ tokenId: asset.tokenId
+ }))
+
+/**
+ * Validate a pasted destination address against the asset's Houdini regex.
+ */
+export function isValidHoudiniDestination(
+ asset: HoudiniDestinationAsset,
+ address: string
+): boolean {
+ return asset.addressValidation.test(address.trim())
+}
+
+export interface HoudiniPrivateQuoteParams {
+ fromWallet: EdgeCurrencyWallet
+ fromTokenId: EdgeTokenId
+ toPluginId: string
+ toTokenId: EdgeTokenId
+ toAddress: string
+ nativeAmount: string
+ /**
+ * The `exchangeInfo.swap.disablePlugins` map. If Houdini is disabled
+ * server-side the private path must not invoke it, so a disabled Houdini is
+ * treated as "no private quote available".
+ */
+ disablePlugins?: Readonly>
+}
+
+/** Whether a plugin is turned off in the exchange-info disable map. */
+export function isPluginDisabled(
+ disablePlugins: Readonly> | undefined,
+ pluginId: string
+): boolean {
+ return disablePlugins?.[pluginId] === true
+}
+
+/**
+ * Whether the exchange-info `disableAssets` list flags a given asset (by plugin
+ * and token), honoring the `allCoins` / `allTokens` wildcards.
+ */
+export function isAssetDisabled(
+ disableAssets: DisableAsset[],
+ pluginId: string,
+ tokenId: EdgeTokenId
+): boolean {
+ for (const disableAsset of disableAssets) {
+ if (disableAsset.pluginId !== pluginId) continue
+ if (disableAsset.tokenId === tokenId) return true
+ if (disableAsset.tokenId === 'allCoins') return true
+ if (disableAsset.tokenId === 'allTokens' && tokenId != null) return true
+ }
+ return false
+}
+
+/**
+ * Build a Houdini swap-to-address request and fetch a Houdini-only private
+ * quote. Restricting the request to Houdini keeps a swap-to-address quote from
+ * fanning out to every central provider (which would create junk orders and
+ * burn their quotas) and guarantees the on-chain deposit is routed through the
+ * private path rather than another provider's.
+ */
+export async function fetchHoudiniPrivateQuote(
+ account: EdgeAccount,
+ params: HoudiniPrivateQuoteParams
+): Promise {
+ const {
+ fromWallet,
+ fromTokenId,
+ toPluginId,
+ toTokenId,
+ toAddress,
+ nativeAmount,
+ disablePlugins
+ } = params
+
+ // Respect a server-side Houdini disable: the private path is Houdini-only, so
+ // a disabled Houdini means there is no private quote to offer.
+ if (isPluginDisabled(disablePlugins, 'houdini')) {
+ throw new Error(lstrings.houdini_ps_no_quote)
+ }
+
+ // `toAddressInfo` carries only what the request does not already hold: the
+ // address itself and the destination plugin (the token is on the request).
+ const toAddressInfo: EdgeSwapToAddressInfo = { toPluginId, toAddress }
+ const request: EdgeSwapRequest = {
+ fromWallet,
+ fromTokenId,
+ toTokenId,
+ toAddressInfo,
+ nativeAmount,
+ quoteFor: 'from'
+ }
+
+ const disabled: Record = {}
+ for (const pluginId of Object.keys(account.swapConfig)) {
+ if (pluginId !== 'houdini') disabled[pluginId] = true
+ }
+
+ const quotes = await account.fetchSwapQuotes(request, {
+ preferPluginId: 'houdini',
+ disabled
+ })
+ const quote = quotes.find(candidate => candidate.pluginId === 'houdini')
+ if (quote == null) {
+ throw new Error(lstrings.houdini_ps_no_quote)
+ }
+ return quote
+}