Skip to content

Commit dd45ccd

Browse files
committed
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.
1 parent 2205f69 commit dd45ccd

3 files changed

Lines changed: 148 additions & 79 deletions

File tree

src/components/scenes/HoudiniPrivateSendScene.tsx

Lines changed: 42 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
1-
import { div, mul, round } from 'biggystring'
2-
import type {
3-
EdgeCurrencyConfig,
4-
EdgeSwapQuote,
5-
EdgeSwapRequest,
6-
EdgeSwapToAddressInfo,
7-
EdgeTokenId
8-
} from 'edge-core-js'
1+
import { div, gt, mul, round } from 'biggystring'
2+
import type { EdgeTokenId } from 'edge-core-js'
93
import * as React from 'react'
104

115
import { useHandler } from '../../hooks/useHandler'
126
import { lstrings } from '../../locales/strings'
7+
import { getExchangeDenom } from '../../selectors/DenominationSelectors'
138
import { useState } from '../../types/reactHooks'
149
import { useSelector } from '../../types/reactRedux'
1510
import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes'
1611
import { getWalletName } from '../../util/CurrencyWalletHelpers'
1712
import {
13+
fetchHoudiniPrivateQuote,
1814
HOUDINI_DESTINATION_ASSETS,
15+
HOUDINI_DESTINATION_EDGE_ASSETS,
1916
type HoudiniDestinationAsset,
2017
isValidHoudiniDestination
2118
} from '../../util/houdiniPrivateSend'
@@ -24,7 +21,6 @@ import { EdgeCard } from '../cards/EdgeCard'
2421
import { SceneWrapper } from '../common/SceneWrapper'
2522
import { SectionHeader } from '../common/SectionHeader'
2623
import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
27-
import { RadioListModal } from '../modals/RadioListModal'
2824
import { TextInputModal } from '../modals/TextInputModal'
2925
import {
3026
WalletListModal,
@@ -37,26 +33,10 @@ import { EdgeText } from '../themed/EdgeText'
3733
interface Props extends EdgeAppSceneProps<'houdiniPrivateSend'> {}
3834

3935
/**
40-
* The primary-unit multiplier for an asset, used to convert a user-entered
41-
* display amount to and from a native (atomic) amount.
42-
*/
43-
function getPrimaryMultiplier(
44-
currencyConfig: EdgeCurrencyConfig,
45-
tokenId: EdgeTokenId
46-
): string {
47-
const { allTokens, currencyInfo } = currencyConfig
48-
const denominations =
49-
tokenId == null
50-
? currencyInfo.denominations
51-
: allTokens[tokenId]?.denominations ?? currencyInfo.denominations
52-
return denominations[0].multiplier
53-
}
54-
55-
/**
56-
* A minimal prototype flow for a Houdini private send: pick a funded source
57-
* wallet, pick a destination asset from the supported set, paste a destination
58-
* address, get a live private quote, then create the exchange order and
59-
* broadcast the on-chain deposit through core's swap-to-address path.
36+
* A Houdini private send: pick a funded source wallet, pick a destination asset
37+
* (both via the shared `WalletListModal`), paste a destination address, get a
38+
* live private quote, then create the exchange order and broadcast the on-chain
39+
* deposit through core's swap-to-address path.
6040
*/
6141
export const HoudiniPrivateSendScene: React.FC<Props> = props => {
6242
const { navigation } = props
@@ -67,6 +47,9 @@ export const HoudiniPrivateSendScene: React.FC<Props> = props => {
6747
const currencyWallets = useSelector(
6848
state => state.core.account.currencyWallets
6949
)
50+
const disablePlugins = useSelector(
51+
state => state.ui.exchangeInfo.swap.disablePlugins
52+
)
7053

7154
const [fromWalletId, setFromWalletId] = useState<string | undefined>(
7255
undefined
@@ -103,25 +86,24 @@ export const HoudiniPrivateSendScene: React.FC<Props> = props => {
10386
})
10487

10588
const handlePickDestAsset = useHandler(async () => {
106-
const selected = await Airship.show<string | undefined>(bridge => (
107-
<RadioListModal
89+
// Reuse the shared wallet picker, filtered to the chains Houdini can
90+
// privately route to, so the destination chain is chosen with the same
91+
// control as the source rather than a bespoke picker.
92+
const result = await Airship.show<WalletListResult>(bridge => (
93+
<WalletListModal
10894
bridge={bridge}
109-
title={lstrings.houdini_ps_select_dest_asset}
110-
items={HOUDINI_DESTINATION_ASSETS.map(asset => ({
111-
icon: '',
112-
name: `${asset.displayName} (${asset.currencyCode})`
113-
}))}
114-
selected={
115-
destAsset == null
116-
? undefined
117-
: `${destAsset.displayName} (${destAsset.currencyCode})`
118-
}
95+
// eslint-disable-next-line @typescript-eslint/no-deprecated
96+
navigation={navigation as NavigationBase}
97+
headerTitle={lstrings.houdini_ps_select_dest_asset}
98+
allowedAssets={HOUDINI_DESTINATION_EDGE_ASSETS}
99+
showCreateWallet
119100
/>
120101
))
121-
if (selected == null) return
102+
if (result?.type !== 'wallet') return
103+
const selectedWallet = currencyWallets[result.walletId]
104+
if (selectedWallet == null) return
122105
const asset = HOUDINI_DESTINATION_ASSETS.find(
123-
candidate =>
124-
`${candidate.displayName} (${candidate.currencyCode})` === selected
106+
candidate => candidate.pluginId === selectedWallet.currencyInfo.pluginId
125107
)
126108
if (asset != null) {
127109
setDestAsset(asset)
@@ -190,49 +172,34 @@ export const HoudiniPrivateSendScene: React.FC<Props> = props => {
190172
}
191173
setPending(true)
192174
try {
193-
const fromMultiplier = getPrimaryMultiplier(
175+
const fromMultiplier = getExchangeDenom(
194176
fromWallet.currencyConfig,
195177
fromTokenId
196-
)
178+
).multiplier
197179
const nativeAmount = round(mul(displayAmount, fromMultiplier), 0)
198180

199-
const toAddressInfo: EdgeSwapToAddressInfo = {
200-
toPluginId: destAsset.pluginId,
201-
toAddress
181+
// Don't let the user reach confirm/approve with more than they hold.
182+
const balance = fromWallet.balanceMap.get(fromTokenId) ?? '0'
183+
if (gt(nativeAmount, balance)) {
184+
showError(lstrings.exchange_insufficient_funds_below_balance)
185+
return
202186
}
203-
const request: EdgeSwapRequest = {
187+
188+
const quote = await fetchHoudiniPrivateQuote(account, {
204189
fromWallet,
205190
fromTokenId,
191+
toPluginId: destAsset.pluginId,
206192
toTokenId: destAsset.tokenId,
207-
toAddressInfo,
193+
toAddress,
208194
nativeAmount,
209-
quoteFor: 'from'
210-
}
211-
212-
// Restrict the prototype to Houdini: a swap-to-address request would
213-
// otherwise fan out to every central provider, creating junk orders and
214-
// burning their quotas.
215-
const disabled: Record<string, true> = {}
216-
for (const pluginId of Object.keys(account.swapConfig)) {
217-
if (pluginId !== 'houdini') disabled[pluginId] = true
218-
}
219-
220-
const quotes = await account.fetchSwapQuotes(request, {
221-
preferPluginId: 'houdini',
222-
disabled
195+
disablePlugins
223196
})
224-
// Houdini-only by design: never fall back to another provider's quote, or
225-
// the swap-to-address deposit could be routed through the wrong plugin.
226-
const quote: EdgeSwapQuote | undefined = quotes.find(
227-
candidate => candidate.pluginId === 'houdini'
228-
)
229-
if (quote == null) {
230-
showError(lstrings.houdini_ps_no_quote)
231-
return
232-
}
233197

234198
const toConfig = account.currencyConfig[destAsset.pluginId]
235-
const toMultiplier = getPrimaryMultiplier(toConfig, destAsset.tokenId)
199+
const toMultiplier = getExchangeDenom(
200+
toConfig,
201+
destAsset.tokenId
202+
).multiplier
236203
const fromDisplay = div(quote.fromNativeAmount, fromMultiplier, 8)
237204
const toDisplay = div(quote.toNativeAmount, toMultiplier, 8)
238205

src/locales/strings/enUS.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,9 @@
14461446
"houdini_ps_missing_fields": "Select a source wallet, destination asset, address, and amount first",
14471447
"houdini_ps_pick_dest_asset_first": "Select a destination asset first",
14481448
"houdini_ps_pick_source_first": "Select a source wallet first",
1449+
"houdini_swap_private_label": "Private swap",
1450+
"houdini_swap_from_amount_only": "Private swaps quote from the send amount only",
1451+
"houdini_swap_no_dest_address": "Could not get a destination address for the selected wallet",
14491452
"deposit_to_bank": "Deposit to Bank",
14501453
"your_wallets": "Your Wallets",
14511454
"pause_wallet_toast": "This wallet will no longer synchronize with the blockchain and will not detect new transactions or balance changes",

src/util/houdiniPrivateSend.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import type { EdgeTokenId } from 'edge-core-js'
1+
import type {
2+
EdgeAccount,
3+
EdgeCurrencyWallet,
4+
EdgeSwapQuote,
5+
EdgeSwapRequest,
6+
EdgeSwapToAddressInfo,
7+
EdgeTokenId
8+
} from 'edge-core-js'
9+
10+
import { lstrings } from '../locales/strings'
11+
import type { EdgeAsset } from '../types/types'
212

313
/**
414
* A destination asset Houdini can privately route a swap to, paired with the
@@ -64,15 +74,16 @@ export const HOUDINI_DESTINATION_ASSETS: HoudiniDestinationAsset[] = [
6474
tokenId: null,
6575
currencyCode: 'DASH',
6676
displayName: 'Dash',
67-
addressValidation: /^[X|7][0-9A-Za-z]{33}$/
77+
addressValidation: /^[X7][0-9A-Za-z]{33}$/
6878
},
6979
{
7080
pluginId: 'solana',
7181
tokenId: null,
7282
currencyCode: 'SOL',
7383
displayName: 'Solana',
74-
addressValidation:
75-
/^[1-9A-HJ-NP-SU-Za-hj-np-su-z][1-9A-HJ-NP-Za-km-z]{31,43}$/
84+
// Base58, 32-44 chars; every position uses the full Base58 alphabet
85+
// (excludes 0, O, I, l).
86+
addressValidation: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
7687
},
7788
{
7889
pluginId: 'tron',
@@ -118,6 +129,16 @@ export const HOUDINI_DESTINATION_ASSETS: HoudiniDestinationAsset[] = [
118129
}
119130
]
120131

132+
/**
133+
* The Houdini destination chains expressed as `EdgeAsset`s, for filtering the
134+
* shared `WalletListModal` down to the assets Houdini can privately route to.
135+
*/
136+
export const HOUDINI_DESTINATION_EDGE_ASSETS: EdgeAsset[] =
137+
HOUDINI_DESTINATION_ASSETS.map(asset => ({
138+
pluginId: asset.pluginId,
139+
tokenId: asset.tokenId
140+
}))
141+
121142
/**
122143
* Validate a pasted destination address against the asset's Houdini regex.
123144
*/
@@ -127,3 +148,81 @@ export function isValidHoudiniDestination(
127148
): boolean {
128149
return asset.addressValidation.test(address.trim())
129150
}
151+
152+
export interface HoudiniPrivateQuoteParams {
153+
fromWallet: EdgeCurrencyWallet
154+
fromTokenId: EdgeTokenId
155+
toPluginId: string
156+
toTokenId: EdgeTokenId
157+
toAddress: string
158+
nativeAmount: string
159+
/**
160+
* The `exchangeInfo.swap.disablePlugins` map. If Houdini is disabled
161+
* server-side the private path must not invoke it, so a disabled Houdini is
162+
* treated as "no private quote available".
163+
*/
164+
disablePlugins?: Readonly<Record<string, unknown>>
165+
}
166+
167+
/** Whether a plugin is turned off in the exchange-info disable map. */
168+
export function isPluginDisabled(
169+
disablePlugins: Readonly<Record<string, unknown>> | undefined,
170+
pluginId: string
171+
): boolean {
172+
return disablePlugins?.[pluginId] === true
173+
}
174+
175+
/**
176+
* Build a Houdini swap-to-address request and fetch a Houdini-only private
177+
* quote. Restricting the request to Houdini keeps a swap-to-address quote from
178+
* fanning out to every central provider (which would create junk orders and
179+
* burn their quotas) and guarantees the on-chain deposit is routed through the
180+
* private path rather than another provider's.
181+
*/
182+
export async function fetchHoudiniPrivateQuote(
183+
account: EdgeAccount,
184+
params: HoudiniPrivateQuoteParams
185+
): Promise<EdgeSwapQuote> {
186+
const {
187+
fromWallet,
188+
fromTokenId,
189+
toPluginId,
190+
toTokenId,
191+
toAddress,
192+
nativeAmount,
193+
disablePlugins
194+
} = params
195+
196+
// Respect a server-side Houdini disable: the private path is Houdini-only, so
197+
// a disabled Houdini means there is no private quote to offer.
198+
if (isPluginDisabled(disablePlugins, 'houdini')) {
199+
throw new Error(lstrings.houdini_ps_no_quote)
200+
}
201+
202+
// `toAddressInfo` carries only what the request does not already hold: the
203+
// address itself and the destination plugin (the token is on the request).
204+
const toAddressInfo: EdgeSwapToAddressInfo = { toPluginId, toAddress }
205+
const request: EdgeSwapRequest = {
206+
fromWallet,
207+
fromTokenId,
208+
toTokenId,
209+
toAddressInfo,
210+
nativeAmount,
211+
quoteFor: 'from'
212+
}
213+
214+
const disabled: Record<string, true> = {}
215+
for (const pluginId of Object.keys(account.swapConfig)) {
216+
if (pluginId !== 'houdini') disabled[pluginId] = true
217+
}
218+
219+
const quotes = await account.fetchSwapQuotes(request, {
220+
preferPluginId: 'houdini',
221+
disabled
222+
})
223+
const quote = quotes.find(candidate => candidate.pluginId === 'houdini')
224+
if (quote == null) {
225+
throw new Error(lstrings.houdini_ps_no_quote)
226+
}
227+
return quote
228+
}

0 commit comments

Comments
 (0)