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/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')
+ }}
+ />
{}
+
+/**
+ * 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/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)
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())
+}