From 10e363010287ec4690b7d37a0cd2eb0f54af4491 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 12 Jun 2026 00:04:25 -0700 Subject: [PATCH 1/2] 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 b89674f0cae0cb8c54355472956ac017393f3dbb Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 12 Jun 2026 01:51:26 -0700 Subject: [PATCH 2/2] 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 | 30 +++++++++++++------ .../scenes/TransactionDetailsScene.tsx | 4 ++- .../themed/ExchangeQuoteComponent.tsx | 8 ++++- 8 files changed, 65 insertions(+), 20 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..bafcf4eba7e 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -49,10 +49,19 @@ export const SwapProcessingScene: React.FC = (props: Props) => { swapRequest.fromTokenId ) const toDenomination = useDisplayDenom( - swapRequest.toWallet.currencyConfig, + // Wallet-to-wallet swaps always have a destination wallet here; fall back to + // the source config only so this hook stays unconditional. + (swapRequest.toWallet ?? swapRequest.fromWallet).currencyConfig, swapRequest.toTokenId ) + // This scene only processes wallet-to-wallet swap requests, which always + // carry a destination wallet (swap-to-address has its own flow). + const toWallet = swapRequest.toWallet + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } + const doWork = async (isCancelled: () => boolean): Promise => { const quotes = await account.fetchSwapQuotes( swapRequest, @@ -70,7 +79,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { const fromWallet = swapRequest.fromWallet const fromAddresses = await fromWallet.getAddresses({ tokenId: null }) const fromAddress = fromAddresses[0]?.publicAddress - const targetPluginId = swapRequest.toWallet.currencyInfo.pluginId + const targetPluginId = toWallet.currencyInfo.pluginId let matchingWalletId: string | undefined for (const walletId of Object.keys(account.currencyWallets)) { @@ -89,8 +98,8 @@ export const SwapProcessingScene: React.FC = (props: Props) => { } } - let finalToWalletId: string = swapRequest.toWallet.id - let finalToWallet = swapRequest.toWallet + let finalToWalletId: string + let finalToWallet: typeof toWallet let isWalletCreated = false if (matchingWalletId == null) { // If not found, split from the source chain wallet to the destination @@ -165,7 +174,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId } }) @@ -189,7 +198,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId, errorDisplayInfo } @@ -313,7 +322,9 @@ function processSwapQuoteError({ swapRequest.fromTokenId ) const toCurrencyCode = getCurrencyCode( - swapRequest.toWallet, + // Wallet-to-wallet swaps always have a destination wallet here; the + // fallback only keeps the type honest for swap-to-address requests. + swapRequest.toWallet ?? swapRequest.fromWallet, swapRequest.toTokenId ) @@ -362,10 +373,11 @@ function trackSwapError(error: unknown, swapRequest: EdgeSwapRequest): void { swapRequest.fromTokenId ), swapToCurrency: getCurrencyCode( - swapRequest.toWallet, + swapRequest.toWallet ?? swapRequest.fromWallet, swapRequest.toTokenId ), - swapToWalletKind: swapRequest.toWallet.currencyInfo.pluginId, + swapToWalletKind: (swapRequest.toWallet ?? swapRequest.fromWallet) + .currencyInfo.pluginId, swapDirectionType: swapRequest.quoteFor }) // Unsearchable context data: diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 1d17b7a482c..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)