From 10e363010287ec4690b7d37a0cd2eb0f54af4491 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 12 Jun 2026 00:04:25 -0700 Subject: [PATCH 1/4] Add Houdini private send prototype A dev-only swap-to-address flow reachable from the Developer test scene. Pick a funded source wallet, pick a destination asset from Houdini's supported set, paste a destination address (validated with Houdini's per-chain regex), get a live private quote, then create the private exchange order and broadcast the on-chain deposit via core's swap-to-address path. Registers the HoudiniSwap plugin and its initOptions. Restricts the prototype request to the houdini plugin so a swap-to-address quote does not fan out to other providers. --- CHANGELOG.md | 1 + src/components/Main.tsx | 6 + src/components/scenes/DevTestScene.tsx | 6 + .../scenes/HoudiniPrivateSendScene.tsx | 338 ++++++++++++++++++ src/envConfig.ts | 6 + src/locales/en_US.ts | 20 ++ src/locales/strings/enUS.json | 18 + src/types/routerTypes.tsx | 1 + src/util/corePlugins.ts | 1 + src/util/houdiniPrivateSend.ts | 129 +++++++ 10 files changed, 526 insertions(+) create mode 100644 src/components/scenes/HoudiniPrivateSendScene.tsx create mode 100644 src/util/houdiniPrivateSend.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a93be492443..91f21a99de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- 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/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 => { <> + { + navigation.navigate('houdiniPrivateSend') + }} + /> {} + +/** + * The primary-unit multiplier for an asset, used to convert a user-entered + * display amount to and from a native (atomic) amount. + */ +function getPrimaryMultiplier( + currencyConfig: EdgeCurrencyConfig, + tokenId: EdgeTokenId +): string { + const { allTokens, currencyInfo } = currencyConfig + const denominations = + tokenId == null + ? currencyInfo.denominations + : allTokens[tokenId]?.denominations ?? currencyInfo.denominations + return denominations[0].multiplier +} + +/** + * A minimal prototype flow for a Houdini private send: pick a funded source + * wallet, pick a destination asset from the supported set, 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 [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 () => { + const result = await Airship.show(bridge => ( + + )) + if (result?.type === 'wallet') { + setFromWalletId(result.walletId) + setFromTokenId(result.tokenId) + } + }) + + const handlePickDestAsset = useHandler(async () => { + const selected = await Airship.show(bridge => ( + ({ + icon: '', + name: `${asset.displayName} (${asset.currencyCode})` + }))} + selected={ + destAsset == null + ? undefined + : `${destAsset.displayName} (${destAsset.currencyCode})` + } + /> + )) + if (selected == null) return + const asset = HOUDINI_DESTINATION_ASSETS.find( + candidate => + `${candidate.displayName} (${candidate.currencyCode})` === selected + ) + if (asset != null) { + setDestAsset(asset) + // A new destination chain invalidates a previously entered address: + setToAddress(undefined) + } + }) + + const handleEnterAddress = useHandler(async () => { + 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 (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 + ) { + showError(lstrings.houdini_ps_missing_fields) + return + } + setPending(true) + try { + const fromMultiplier = getPrimaryMultiplier( + fromWallet.currencyConfig, + fromTokenId + ) + const nativeAmount = round(mul(displayAmount, fromMultiplier), 0) + + const toAddressInfo: EdgeSwapToAddressInfo = { + toPluginId: destAsset.pluginId, + toAddress + } + const request: EdgeSwapRequest = { + fromWallet, + fromTokenId, + toTokenId: destAsset.tokenId, + toAddressInfo, + nativeAmount, + quoteFor: 'from' + } + + // Restrict the prototype to Houdini: a swap-to-address request would + // otherwise fan out to every central provider, creating junk orders and + // burning their quotas. + 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 + }) + // Houdini-only by design: never fall back to another provider's quote, or + // the swap-to-address deposit could be routed through the wrong plugin. + const quote: EdgeSwapQuote | undefined = quotes.find( + candidate => candidate.pluginId === 'houdini' + ) + if (quote == null) { + showError(lstrings.houdini_ps_no_quote) + return + } + + const toConfig = account.currencyConfig[destAsset.pluginId] + const toMultiplier = getPrimaryMultiplier(toConfig, destAsset.tokenId) + 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/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..58fbdb39e7c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1832,6 +1832,26 @@ 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', 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..b03e7704cc4 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1428,6 +1428,24 @@ "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", "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..ba3472f3960 --- /dev/null +++ b/src/util/houdiniPrivateSend.ts @@ -0,0 +1,129 @@ +import type { EdgeTokenId } from 'edge-core-js' + +/** + * 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: /^[X|7][0-9A-Za-z]{33}$/ + }, + { + pluginId: 'solana', + tokenId: null, + currencyCode: 'SOL', + displayName: 'Solana', + addressValidation: + /^[1-9A-HJ-NP-SU-Za-hj-np-su-z][1-9A-HJ-NP-Za-km-z]{31,43}$/ + }, + { + 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}$/ + } +] + +/** + * 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()) +} From 2205f69d67eda728c5f740ee49a79a8645318fb8 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 12 Jun 2026 01:51:26 -0700 Subject: [PATCH 2/4] Handle optional swap destination wallet from swap-to-address core The swap-to-address change in edge-core-js makes EdgeSwapRequest.toWallet and EdgeTxActionSwap.payoutWalletId optional (a private send has no destination wallet). Narrow toWallet to its always-present value in the wallet-to-wallet swap scenes, and tolerate a missing payoutWalletId in the transaction-action mappers, so these consumers compile against the new core. --- eslint.config.mjs | 2 +- src/actions/CategoriesActions.ts | 5 +-- src/components/rows/SwapProviderRow.tsx | 8 ++++- .../scenes/SwapConfirmationScene.tsx | 18 ++++++++-- src/components/scenes/SwapCreateScene.tsx | 10 ++++-- src/components/scenes/SwapProcessingScene.tsx | 35 +++++++++++++------ .../scenes/TransactionDetailsScene.tsx | 4 ++- .../themed/ExchangeQuoteComponent.tsx | 8 ++++- 8 files changed, 69 insertions(+), 21 deletions(-) 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/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/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..2245a6a0116 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -228,6 +228,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 +253,7 @@ export const SwapCreateScene: React.FC = props => { const disableDest = checkDisableAsset( exchangeInfo.swap.disableAssets.destination, - swapRequest.toWallet.id, + toWallet.id, toTokenId ) if (disableDest) { @@ -255,7 +261,7 @@ export const SwapCreateScene: React.FC = props => { sprintf( lstrings.swap_token_no_enabled_exchanges_2s, toCurrencyCode, - swapRequest.toWallet.currencyInfo.displayName + toWallet.currencyInfo.displayName ) ) return 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) From 0b75c6fd5a04c18ca7a341e6b82174019d459675 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 17 Jun 2026 01:53:23 -0700 Subject: [PATCH 3/4] Reuse WalletListModal as the Houdini private send picker Extract the Houdini-only swap-to-address quote construction into a shared fetchHoudiniPrivateQuote helper and drive the private send destination selection through the shared WalletListModal (filtered to Houdini's supported chains) instead of a bespoke asset picker. --- .../scenes/HoudiniPrivateSendScene.tsx | 166 ++++++++++-------- src/locales/strings/enUS.json | 3 + src/util/houdiniPrivateSend.ts | 126 ++++++++++++- 3 files changed, 215 insertions(+), 80 deletions(-) diff --git a/src/components/scenes/HoudiniPrivateSendScene.tsx b/src/components/scenes/HoudiniPrivateSendScene.tsx index 65d1912e4a1..7b8d3483e10 100644 --- a/src/components/scenes/HoudiniPrivateSendScene.tsx +++ b/src/components/scenes/HoudiniPrivateSendScene.tsx @@ -1,30 +1,30 @@ -import { div, mul, round } from 'biggystring' -import type { - EdgeCurrencyConfig, - EdgeSwapQuote, - EdgeSwapRequest, - EdgeSwapToAddressInfo, - EdgeTokenId -} from 'edge-core-js' +import { div, gt, mul, round } from 'biggystring' +import type { EdgeTokenId } from 'edge-core-js' import * as React from 'react' +import { sprintf } from 'sprintf-js' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' +import { getExchangeDenom } from '../../selectors/DenominationSelectors' import { useState } from '../../types/reactHooks' import { useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' +import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' import { + fetchHoudiniPrivateQuote, HOUDINI_DESTINATION_ASSETS, + HOUDINI_DESTINATION_EDGE_ASSETS, type HoudiniDestinationAsset, + isAssetDisabled, isValidHoudiniDestination } from '../../util/houdiniPrivateSend' +import { zeroString } from '../../util/utils' import { ButtonsView } from '../buttons/ButtonsView' import { EdgeCard } from '../cards/EdgeCard' import { SceneWrapper } from '../common/SceneWrapper' import { SectionHeader } from '../common/SectionHeader' import { ConfirmContinueModal } from '../modals/ConfirmContinueModal' -import { RadioListModal } from '../modals/RadioListModal' import { TextInputModal } from '../modals/TextInputModal' import { WalletListModal, @@ -37,26 +37,10 @@ import { EdgeText } from '../themed/EdgeText' interface Props extends EdgeAppSceneProps<'houdiniPrivateSend'> {} /** - * The primary-unit multiplier for an asset, used to convert a user-entered - * display amount to and from a native (atomic) amount. - */ -function getPrimaryMultiplier( - currencyConfig: EdgeCurrencyConfig, - tokenId: EdgeTokenId -): string { - const { allTokens, currencyInfo } = currencyConfig - const denominations = - tokenId == null - ? currencyInfo.denominations - : allTokens[tokenId]?.denominations ?? currencyInfo.denominations - return denominations[0].multiplier -} - -/** - * A minimal prototype flow for a Houdini private send: pick a funded source - * wallet, pick a destination asset from the supported set, 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. + * 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 @@ -67,6 +51,12 @@ export const HoudiniPrivateSendScene: React.FC = props => { 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 @@ -85,6 +75,7 @@ export const HoudiniPrivateSendScene: React.FC = props => { fromWalletId != null ? currencyWallets[fromWalletId] : undefined const handlePickSource = useHandler(async () => { + if (pending) return const result = await Airship.show(bridge => ( = props => { }) const handlePickDestAsset = useHandler(async () => { - const selected = await Airship.show(bridge => ( - (bridge => ( + ({ - icon: '', - name: `${asset.displayName} (${asset.currencyCode})` - }))} - selected={ - destAsset == null - ? undefined - : `${destAsset.displayName} (${destAsset.currencyCode})` - } + // eslint-disable-next-line @typescript-eslint/no-deprecated + navigation={navigation as NavigationBase} + headerTitle={lstrings.houdini_ps_select_dest_asset} + allowedAssets={HOUDINI_DESTINATION_EDGE_ASSETS} + showCreateWallet /> )) - if (selected == null) return + if (result?.type !== 'wallet') return + const selectedWallet = currencyWallets[result.walletId] + if (selectedWallet == null) return const asset = HOUDINI_DESTINATION_ASSETS.find( - candidate => - `${candidate.displayName} (${candidate.currencyCode})` === selected + candidate => candidate.pluginId === selectedWallet.currencyInfo.pluginId ) if (asset != null) { setDestAsset(asset) @@ -131,6 +122,7 @@ export const HoudiniPrivateSendScene: React.FC = props => { }) const handleEnterAddress = useHandler(async () => { + if (pending) return if (destAsset == null) { showError(lstrings.houdini_ps_pick_dest_asset_first) return @@ -158,6 +150,7 @@ export const HoudiniPrivateSendScene: React.FC = props => { }) const handleEnterAmount = useHandler(async () => { + if (pending) return if (fromWallet == null) { showError(lstrings.houdini_ps_pick_source_first) return @@ -183,56 +176,77 @@ export const HoudiniPrivateSendScene: React.FC = props => { fromWallet == null || destAsset == null || toAddress == null || - displayAmount == 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 = getPrimaryMultiplier( + const fromMultiplier = getExchangeDenom( fromWallet.currencyConfig, fromTokenId - ) + ).multiplier const nativeAmount = round(mul(displayAmount, fromMultiplier), 0) - const toAddressInfo: EdgeSwapToAddressInfo = { - toPluginId: destAsset.pluginId, - toAddress + // 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 request: EdgeSwapRequest = { + + const quote = await fetchHoudiniPrivateQuote(account, { fromWallet, fromTokenId, + toPluginId: destAsset.pluginId, toTokenId: destAsset.tokenId, - toAddressInfo, + toAddress, nativeAmount, - quoteFor: 'from' - } - - // Restrict the prototype to Houdini: a swap-to-address request would - // otherwise fan out to every central provider, creating junk orders and - // burning their quotas. - 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 + disablePlugins }) - // Houdini-only by design: never fall back to another provider's quote, or - // the swap-to-address deposit could be routed through the wrong plugin. - const quote: EdgeSwapQuote | undefined = quotes.find( - candidate => candidate.pluginId === 'houdini' - ) - if (quote == null) { - showError(lstrings.houdini_ps_no_quote) - return - } const toConfig = account.currencyConfig[destAsset.pluginId] - const toMultiplier = getPrimaryMultiplier(toConfig, destAsset.tokenId) + const toMultiplier = getExchangeDenom( + toConfig, + destAsset.tokenId + ).multiplier const fromDisplay = div(quote.fromNativeAmount, fromMultiplier, 8) const toDisplay = div(quote.toNativeAmount, toMultiplier, 8) diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index b03e7704cc4..a8dd003316c 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1446,6 +1446,9 @@ "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/util/houdiniPrivateSend.ts b/src/util/houdiniPrivateSend.ts index ba3472f3960..349cf4a3e6d 100644 --- a/src/util/houdiniPrivateSend.ts +++ b/src/util/houdiniPrivateSend.ts @@ -1,4 +1,15 @@ -import type { EdgeTokenId } from 'edge-core-js' +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 @@ -64,15 +75,16 @@ export const HOUDINI_DESTINATION_ASSETS: HoudiniDestinationAsset[] = [ tokenId: null, currencyCode: 'DASH', displayName: 'Dash', - addressValidation: /^[X|7][0-9A-Za-z]{33}$/ + addressValidation: /^[X7][0-9A-Za-z]{33}$/ }, { pluginId: 'solana', tokenId: null, currencyCode: 'SOL', displayName: 'Solana', - addressValidation: - /^[1-9A-HJ-NP-SU-Za-hj-np-su-z][1-9A-HJ-NP-Za-km-z]{31,43}$/ + // 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', @@ -118,6 +130,16 @@ export const HOUDINI_DESTINATION_ASSETS: HoudiniDestinationAsset[] = [ } ] +/** + * 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. */ @@ -127,3 +149,99 @@ export function isValidHoudiniDestination( ): 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 +} From 69154ab899eeadb6c9cb28a3e772ace1992645f7 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 17 Jun 2026 01:54:12 -0700 Subject: [PATCH 4/4] Add a private swap toggle to the swap amount scene When enabled, the swap routes privately through Houdini's swap-to-address path: the destination address is derived from the chosen receive wallet, a Houdini-only private quote is fetched, confirmed, and approved. The toggle only appears for destination chains Houdini can privately route to. --- CHANGELOG.md | 1 + src/components/scenes/SwapCreateScene.tsx | 218 ++++++++++++++++++++-- src/locales/en_US.ts | 5 + 3 files changed, 209 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f21a99de3..e4e5aae36de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index 2245a6a0116..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 { @@ -298,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, @@ -381,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 @@ -410,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}.` @@ -448,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') @@ -461,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') @@ -530,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, @@ -614,12 +790,24 @@ export const SwapCreateScene: React.FC = props => { )} {renderAlert()} + {isPrivateSwapSupported ? ( + + + + ) : null} {isNextHidden || isKeyboardOpen ? null : ( )} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 58fbdb39e7c..c32e44d5471 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1852,6 +1852,11 @@ const strings = { '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: