Skip to content

Commit 6161658

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 6161658

3 files changed

Lines changed: 211 additions & 80 deletions

File tree

src/components/scenes/HoudiniPrivateSendScene.tsx

Lines changed: 86 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,30 @@
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'
4+
import { sprintf } from 'sprintf-js'
105

116
import { useHandler } from '../../hooks/useHandler'
127
import { lstrings } from '../../locales/strings'
8+
import { getExchangeDenom } from '../../selectors/DenominationSelectors'
139
import { useState } from '../../types/reactHooks'
1410
import { useSelector } from '../../types/reactRedux'
1511
import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes'
12+
import { getCurrencyCode } from '../../util/CurrencyInfoHelpers'
1613
import { getWalletName } from '../../util/CurrencyWalletHelpers'
1714
import {
15+
fetchHoudiniPrivateQuote,
1816
HOUDINI_DESTINATION_ASSETS,
17+
HOUDINI_DESTINATION_EDGE_ASSETS,
1918
type HoudiniDestinationAsset,
19+
isAssetDisabled,
2020
isValidHoudiniDestination
2121
} from '../../util/houdiniPrivateSend'
22+
import { zeroString } from '../../util/utils'
2223
import { ButtonsView } from '../buttons/ButtonsView'
2324
import { EdgeCard } from '../cards/EdgeCard'
2425
import { SceneWrapper } from '../common/SceneWrapper'
2526
import { SectionHeader } from '../common/SectionHeader'
2627
import { ConfirmContinueModal } from '../modals/ConfirmContinueModal'
27-
import { RadioListModal } from '../modals/RadioListModal'
2828
import { TextInputModal } from '../modals/TextInputModal'
2929
import {
3030
WalletListModal,
@@ -37,26 +37,10 @@ import { EdgeText } from '../themed/EdgeText'
3737
interface Props extends EdgeAppSceneProps<'houdiniPrivateSend'> {}
3838

3939
/**
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.
40+
* A Houdini private send: pick a funded source wallet, pick a destination asset
41+
* (both via the shared `WalletListModal`), paste a destination address, get a
42+
* live private quote, then create the exchange order and broadcast the on-chain
43+
* deposit through core's swap-to-address path.
6044
*/
6145
export const HoudiniPrivateSendScene: React.FC<Props> = props => {
6246
const { navigation } = props
@@ -67,6 +51,12 @@ export const HoudiniPrivateSendScene: React.FC<Props> = props => {
6751
const currencyWallets = useSelector(
6852
state => state.core.account.currencyWallets
6953
)
54+
const disablePlugins = useSelector(
55+
state => state.ui.exchangeInfo.swap.disablePlugins
56+
)
57+
const disableAssets = useSelector(
58+
state => state.ui.exchangeInfo.swap.disableAssets
59+
)
7060

7161
const [fromWalletId, setFromWalletId] = useState<string | undefined>(
7262
undefined
@@ -103,25 +93,24 @@ export const HoudiniPrivateSendScene: React.FC<Props> = props => {
10393
})
10494

10595
const handlePickDestAsset = useHandler(async () => {
106-
const selected = await Airship.show<string | undefined>(bridge => (
107-
<RadioListModal
96+
// Reuse the shared wallet picker, filtered to the chains Houdini can
97+
// privately route to, so the destination chain is chosen with the same
98+
// control as the source rather than a bespoke picker.
99+
const result = await Airship.show<WalletListResult>(bridge => (
100+
<WalletListModal
108101
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-
}
102+
// eslint-disable-next-line @typescript-eslint/no-deprecated
103+
navigation={navigation as NavigationBase}
104+
headerTitle={lstrings.houdini_ps_select_dest_asset}
105+
allowedAssets={HOUDINI_DESTINATION_EDGE_ASSETS}
106+
showCreateWallet
119107
/>
120108
))
121-
if (selected == null) return
109+
if (result?.type !== 'wallet') return
110+
const selectedWallet = currencyWallets[result.walletId]
111+
if (selectedWallet == null) return
122112
const asset = HOUDINI_DESTINATION_ASSETS.find(
123-
candidate =>
124-
`${candidate.displayName} (${candidate.currencyCode})` === selected
113+
candidate => candidate.pluginId === selectedWallet.currencyInfo.pluginId
125114
)
126115
if (asset != null) {
127116
setDestAsset(asset)
@@ -183,56 +172,77 @@ export const HoudiniPrivateSendScene: React.FC<Props> = props => {
183172
fromWallet == null ||
184173
destAsset == null ||
185174
toAddress == null ||
186-
displayAmount == null
175+
displayAmount == null ||
176+
zeroString(displayAmount)
187177
) {
188178
showError(lstrings.houdini_ps_missing_fields)
189179
return
190180
}
181+
182+
// Honor the exchange-info asset disables, matching the swap flow.
183+
if (
184+
isAssetDisabled(
185+
disableAssets.source,
186+
fromWallet.currencyInfo.pluginId,
187+
fromTokenId
188+
)
189+
) {
190+
showError(
191+
sprintf(
192+
lstrings.swap_token_no_enabled_exchanges_2s,
193+
getCurrencyCode(fromWallet, fromTokenId),
194+
fromWallet.currencyInfo.displayName
195+
)
196+
)
197+
return
198+
}
199+
if (
200+
isAssetDisabled(
201+
disableAssets.destination,
202+
destAsset.pluginId,
203+
destAsset.tokenId
204+
)
205+
) {
206+
showError(
207+
sprintf(
208+
lstrings.swap_token_no_enabled_exchanges_2s,
209+
destAsset.currencyCode,
210+
destAsset.displayName
211+
)
212+
)
213+
return
214+
}
215+
191216
setPending(true)
192217
try {
193-
const fromMultiplier = getPrimaryMultiplier(
218+
const fromMultiplier = getExchangeDenom(
194219
fromWallet.currencyConfig,
195220
fromTokenId
196-
)
221+
).multiplier
197222
const nativeAmount = round(mul(displayAmount, fromMultiplier), 0)
198223

199-
const toAddressInfo: EdgeSwapToAddressInfo = {
200-
toPluginId: destAsset.pluginId,
201-
toAddress
224+
// Don't let the user reach confirm/approve with more than they hold.
225+
const balance = fromWallet.balanceMap.get(fromTokenId) ?? '0'
226+
if (gt(nativeAmount, balance)) {
227+
showError(lstrings.exchange_insufficient_funds_below_balance)
228+
return
202229
}
203-
const request: EdgeSwapRequest = {
230+
231+
const quote = await fetchHoudiniPrivateQuote(account, {
204232
fromWallet,
205233
fromTokenId,
234+
toPluginId: destAsset.pluginId,
206235
toTokenId: destAsset.tokenId,
207-
toAddressInfo,
236+
toAddress,
208237
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
238+
disablePlugins
223239
})
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-
}
233240

234241
const toConfig = account.currencyConfig[destAsset.pluginId]
235-
const toMultiplier = getPrimaryMultiplier(toConfig, destAsset.tokenId)
242+
const toMultiplier = getExchangeDenom(
243+
toConfig,
244+
destAsset.tokenId
245+
).multiplier
236246
const fromDisplay = div(quote.fromNativeAmount, fromMultiplier, 8)
237247
const toDisplay = div(quote.toNativeAmount, toMultiplier, 8)
238248

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: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
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 type { DisableAsset } from '../actions/ExchangeInfoActions'
11+
import { lstrings } from '../locales/strings'
12+
import type { EdgeAsset } from '../types/types'
213

314
/**
415
* A destination asset Houdini can privately route a swap to, paired with the
@@ -64,15 +75,16 @@ export const HOUDINI_DESTINATION_ASSETS: HoudiniDestinationAsset[] = [
6475
tokenId: null,
6576
currencyCode: 'DASH',
6677
displayName: 'Dash',
67-
addressValidation: /^[X|7][0-9A-Za-z]{33}$/
78+
addressValidation: /^[X7][0-9A-Za-z]{33}$/
6879
},
6980
{
7081
pluginId: 'solana',
7182
tokenId: null,
7283
currencyCode: 'SOL',
7384
displayName: 'Solana',
74-
addressValidation:
75-
/^[1-9A-HJ-NP-SU-Za-hj-np-su-z][1-9A-HJ-NP-Za-km-z]{31,43}$/
85+
// Base58, 32-44 chars; every position uses the full Base58 alphabet
86+
// (excludes 0, O, I, l).
87+
addressValidation: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
7688
},
7789
{
7890
pluginId: 'tron',
@@ -118,6 +130,16 @@ export const HOUDINI_DESTINATION_ASSETS: HoudiniDestinationAsset[] = [
118130
}
119131
]
120132

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

0 commit comments

Comments
 (0)