From 03e28410299f4c6d4b20a0fce5e61b3d15456f77 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 24 Jun 2026 15:05:10 -0700 Subject: [PATCH 1/6] Add Houdini private-send prototype scene (Proposal A) Adds a review-only, fully navigable prototype of the redesigned Houdini private send flow, reachable from a wallet's Send button. The scene reorganizes the send-to-address experience with two linked amounts (You send / Recipient gets) computed from a hard-coded rate, a recipient-asset picker over a hard-coded chain list, a Private send toggle, an inline quote row, a network fee row, and a conditional destination-tag row. Cross-asset or private sends complete on SwapSuccessScene; same-asset sends show the transaction success modal. Nothing talks to Houdini; all values are hard-coded. The card grouping uses Proposal A (From -> To grouping). --- CHANGELOG.md | 1 + src/components/Main.tsx | 3 + src/components/scenes/HoudiniSendScene.tsx | 475 +++++++++++++++++++ src/components/themed/TransactionListTop.tsx | 6 +- src/locales/en_US.ts | 13 + src/locales/strings/enUS.json | 10 + src/types/routerTypes.tsx | 2 + 7 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 src/components/scenes/HoudiniSendScene.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe47fb7f1..c9a1f5f24f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- added: Houdini private-send prototype scene (Proposal A) reachable from a wallet's Send button, with hard-coded linked amounts, a recipient-asset picker, a Private send toggle, and modal vs swap-success completion paths. - added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. ## 4.49.0 (staging) diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 1d67d8131e3..f93c0d5b296 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 { HoudiniSendScene as HoudiniSendSceneComponent } from './scenes/HoudiniSendScene' import { LoanCloseScene as LoanCloseSceneComponent } from './scenes/Loans/LoanCloseScene' import { LoanCreateConfirmationScene as LoanCreateConfirmationSceneComponent } from './scenes/Loans/LoanCreateConfirmationScene' import { LoanCreateScene as LoanCreateSceneComponent } from './scenes/Loans/LoanCreateScene' @@ -242,6 +243,7 @@ const FioStakingChangeScene = ifLoggedIn(FioStakingChangeSceneComponent) const FioStakingOverviewScene = ifLoggedIn(FioStakingOverviewSceneComponent) const GuiPluginViewScene = ifLoggedIn(GuiPluginViewSceneComponent) const HomeScene = ifLoggedIn(HomeSceneComponent) +const HoudiniSendScene = ifLoggedIn(HoudiniSendSceneComponent) const GiftCardAccountInfoScene = ifLoggedIn(GiftCardAccountInfoSceneComponent) const GiftCardListScene = ifLoggedIn(GiftCardListSceneComponent) const GiftCardMarketScene = ifLoggedIn(GiftCardMarketSceneComponent) @@ -1087,6 +1089,7 @@ const EdgeAppStack: React.FC = () => { options={{ headerShown: false }} /> + {} + +// A single Houdini-supported recipient chain. Everything here is hard-coded for +// the prototype; nothing talks to Houdini. +interface HoudiniChain { + pluginId: string + currencyCode: string + displayName: string + // Hard-coded "1 BTC = ratePerBtc " exchange rate. + ratePerBtc: string + // Whether this chain needs a destination tag / memo (drives the conditional row). + memoNeeded: boolean +} + +const SOURCE_CHAIN: HoudiniChain = { + pluginId: 'bitcoin', + currencyCode: 'BTC', + displayName: 'Bitcoin', + ratePerBtc: '1', + memoNeeded: false +} + +const RECIPIENT_CHAINS: HoudiniChain[] = [ + SOURCE_CHAIN, + { + pluginId: 'ethereum', + currencyCode: 'ETH', + displayName: 'Ethereum', + ratePerBtc: '36.5', + memoNeeded: false + }, + { + pluginId: 'monero', + currencyCode: 'XMR', + displayName: 'Monero', + ratePerBtc: '350', + memoNeeded: true + }, + { + pluginId: 'solana', + currencyCode: 'SOL', + displayName: 'Solana', + ratePerBtc: '620', + memoNeeded: false + } +] + +// Hard-coded prototype values: +const HARD_CODED_ADDRESS = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' +const HARD_CODED_NETWORK_FEE = '0.00002 BTC' +const HARD_CODED_DESTINATION_TAG = '8675309' +const QUOTE_EXPIRY_SECONDS = 60 +const ESTIMATE_PREFIX = '~ ' + +const amountRegex = /^\d*\.?\d*$/ + +export const HoudiniSendScene: React.FC = props => { + const { navigation, route } = props + const { walletId, layout } = route.params + const theme = useTheme() + const styles = getStyles(theme) + + // State: + const [recipientChain, setRecipientChain] = + React.useState(SOURCE_CHAIN) + const [youSend, setYouSend] = React.useState('0.1') + const [recipientGets, setRecipientGets] = React.useState('0.1') + const [guaranteedSide, setGuaranteedSide] = React.useState< + 'send' | 'receive' + >('send') + const [privateSend, setPrivateSend] = React.useState(false) + const [secondsLeft, setSecondsLeft] = React.useState(QUOTE_EXPIRY_SECONDS) + + // Selectors: + const sourceWallet = useSelector( + state => state.core.account.currencyWallets[walletId] + ) + + // Derived values: + const isCrossAsset = recipientChain.currencyCode !== SOURCE_CHAIN.currencyCode + const rateText = `1 ${SOURCE_CHAIN.currencyCode} = ${recipientChain.ratePerBtc} ${recipientChain.currencyCode}` + + // Handlers: + const handleEditYouSend = useHandler(async () => { + const result = await Airship.show(bridge => ( + + )) + if (result == null || !amountRegex.test(result) || result === '') return + setYouSend(result) + setGuaranteedSide('send') + setRecipientGets(mul(result, recipientChain.ratePerBtc)) + }) + + const handleEditRecipientGets = useHandler(async () => { + const result = await Airship.show(bridge => ( + + )) + if (result == null || !amountRegex.test(result) || result === '') return + setRecipientGets(result) + setGuaranteedSide('receive') + setYouSend(div(result, recipientChain.ratePerBtc, 8)) + }) + + const handlePickRecipientChain = useHandler(async () => { + const selectedCode = await Airship.show(bridge => ( + ({ + name: chain.currencyCode, + text: chain.displayName, + icon: ( + + ) + }))} + /> + )) + if (selectedCode == null) return + const nextChain = RECIPIENT_CHAINS.find( + chain => chain.currencyCode === selectedCode + ) + if (nextChain == null) return + setRecipientChain(nextChain) + // Keep the guaranteed side fixed and recompute the estimated side. + if (guaranteedSide === 'send') { + setRecipientGets(mul(youSend, nextChain.ratePerBtc)) + } else { + setYouSend(div(recipientGets, nextChain.ratePerBtc, 8)) + } + }) + + const handleTogglePrivate = useHandler(() => { + setPrivateSend(value => !value) + }) + + const handleSlidingComplete = useHandler(async (reset: () => void) => { + const edgeTransaction = buildPrototypeTransaction(walletId) + // Cross-asset or private sends celebrate with the swap success scene; + // a plain same-asset send shows the standard transaction success modal. + if (isCrossAsset || privateSend) { + reset() + navigation.navigate('swapSuccess', { edgeTransaction, walletId }) + return + } + const result = await Airship.show<'ok' | undefined>(bridge => ( + + )).catch((err: unknown) => { + showError(err) + return undefined + }) + reset() + // Only continue to the details scene when the user acknowledges the + // success modal; dismissing it leaves them on the send scene. + if (result === 'ok') { + navigation.navigate('transactionDetails', { edgeTransaction, walletId }) + } + }) + + // Effects: + React.useEffect(() => { + const interval = setInterval(() => { + setSecondsLeft(prev => (prev <= 1 ? QUOTE_EXPIRY_SECONDS : prev - 1)) + }, 1000) + return () => { + clearInterval(interval) + } + }, []) + + // --------------------------------------------------------------------------- + // Render helpers + // --------------------------------------------------------------------------- + + const renderFromWallet = (): React.ReactElement => ( + + + + {sourceWallet?.name ?? SOURCE_CHAIN.displayName} + + + ) + + const renderAmountRow = ( + title: string, + amount: string, + currencyCode: string, + isGuaranteed: boolean, + onPress: () => Promise + ): React.ReactElement => ( + + + + {`${isGuaranteed ? '' : ESTIMATE_PREFIX}${amount} ${currencyCode}`} + + + {isGuaranteed + ? lstrings.houdini_guaranteed + : lstrings.houdini_estimated} + + + + ) + + const renderYouSend = (): React.ReactElement => + renderAmountRow( + lstrings.houdini_you_send, + youSend, + SOURCE_CHAIN.currencyCode, + guaranteedSide === 'send', + handleEditYouSend + ) + + const renderRecipientGets = (): React.ReactElement => + renderAmountRow( + lstrings.houdini_recipient_gets, + recipientGets, + recipientChain.currencyCode, + guaranteedSide === 'receive', + handleEditRecipientGets + ) + + const renderRecipientReceives = (): React.ReactElement => ( + + + + {recipientChain.displayName} + + + ) + + const renderAddress = (): React.ReactElement | null => { + if (sourceWallet == null) return null + return ( + ['navigation'] + } + onChangeAddress={async () => {}} + resetSendTransaction={() => {}} + /> + ) + } + + const renderQuote = (): React.ReactElement => ( + + + {rateText} + {`${secondsLeft}s`} + + + ) + + const renderNetworkFee = (): React.ReactElement => ( + + {HARD_CODED_NETWORK_FEE} + + ) + + const renderDestinationTag = (): React.ReactElement | null => { + if (!recipientChain.memoNeeded) return null + return ( + + {HARD_CODED_DESTINATION_TAG} + + ) + } + + const renderPrivateToggle = (): React.ReactElement => ( + + ) + + // --------------------------------------------------------------------------- + // Layouts — only the card grouping differs between Proposal A and Proposal B. + // --------------------------------------------------------------------------- + + const renderLayoutA = (): React.ReactElement => ( + <> + + {renderFromWallet()} + {renderYouSend()} + {renderNetworkFee()} + + {renderQuote()} + + {renderRecipientReceives()} + {renderAddress()} + {renderRecipientGets()} + {renderDestinationTag()} + + {renderPrivateToggle()} + + ) + + const renderLayoutB = (): React.ReactElement => ( + <> + {renderFromWallet()} + + {renderAddress()} + {renderRecipientReceives()} + {renderYouSend()} + {renderRecipientGets()} + + {renderPrivateToggle()} + + {renderQuote()} + {renderNetworkFee()} + {renderDestinationTag()} + + + ) + + return ( + + + + {layout === 'a' ? renderLayoutA() : renderLayoutB()} + + + + + + + ) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Builds a hard-coded, display-only transaction so the prototype's success + * scenes (SwapSuccessScene / Transaction Details) have something to render. + * It is never broadcast. + */ +function buildPrototypeTransaction(walletId: string): EdgeTransaction { + return { + tokenId: null, + nativeAmount: '-10000000', + networkFees: [], + blockHeight: 0, + date: 1700000000, + txid: 'houdini-prototype-transaction', + signedTx: '', + memos: [], + ourReceiveAddresses: [], + isSend: true, + walletId, + currencyCode: 'BTC', + networkFee: '2000' + } +} + +const getStyles = cacheStyles((theme: Theme) => ({ + assetRow: { + flexDirection: 'row', + alignItems: 'center' + }, + amountRow: { + flexDirection: 'row', + alignItems: 'center' + }, + amountText: { + marginLeft: theme.rem(0.25), + marginRight: theme.rem(0.5) + }, + amountHint: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + guaranteedHint: { + color: theme.positiveText, + fontSize: theme.rem(0.75) + }, + quoteRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, + countdownText: { + color: theme.secondaryText + }, + sliderContainer: { + marginTop: theme.rem(1), + marginBottom: theme.rem(2), + alignItems: 'center' + } +})) diff --git a/src/components/themed/TransactionListTop.tsx b/src/components/themed/TransactionListTop.tsx index d8332b3b2cf..d20ecdb10d6 100644 --- a/src/components/themed/TransactionListTop.tsx +++ b/src/components/themed/TransactionListTop.tsx @@ -746,10 +746,12 @@ export const TransactionListTop: React.FC = props => { const handleSend = useHandler((): void => { triggerHaptic('impactLight') - navigation.push('send2', { + // Houdini private-send prototype (Proposal A): route the wallet Send button + // to the reorganized scene instead of the production send scene. + navigation.push('houdiniSend', { walletId: wallet.id, tokenId, - hiddenFeaturesMap: { scamWarning: false } + layout: 'a' }) }) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 18137a8f612..b0f505d5a54 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1907,6 +1907,19 @@ const strings = { bank_info_title: 'Bank Info', home_address_title: 'Home Address', + + // Houdini private send prototype + houdini_send_title: 'Private Send', + houdini_you_send: 'You send', + houdini_recipient_gets: 'Recipient gets', + houdini_recipient_receives: 'Recipient receives', + houdini_private_send: 'Private send', + houdini_provider_label: 'Houdini private', + houdini_guaranteed: 'Guaranteed', + houdini_estimated: 'Estimated', + houdini_slide_send: 'Slide to send', + houdini_slide_private: 'Slide to send privately', + input_output_currency: 'Currency', n_a: 'N/A', payment_details: 'Payment Details', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 3c0b1513f56..69caeb3f07c 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1490,6 +1490,16 @@ "form_field_error_invalid_ssn": "Please enter a valid SSN (XXX-XX-XXXX)", "bank_info_title": "Bank Info", "home_address_title": "Home Address", + "houdini_send_title": "Private Send", + "houdini_you_send": "You send", + "houdini_recipient_gets": "Recipient gets", + "houdini_recipient_receives": "Recipient receives", + "houdini_private_send": "Private send", + "houdini_provider_label": "Houdini private", + "houdini_guaranteed": "Guaranteed", + "houdini_estimated": "Estimated", + "houdini_slide_send": "Slide to send", + "houdini_slide_private": "Slide to send privately", "input_output_currency": "Currency", "n_a": "N/A", "payment_details": "Payment Details", diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index d7d8ec55964..604be6a664f 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -37,6 +37,7 @@ import type { GiftCardAccountInfoParams } from '../components/scenes/GiftCardAcc import type { GiftCardPurchaseParams } from '../components/scenes/GiftCardPurchaseScene' import type { GuiPluginListParams } from '../components/scenes/GuiPluginListScene' import type { PluginViewParams } from '../components/scenes/GuiPluginViewScene' +import type { HoudiniSendParams } from '../components/scenes/HoudiniSendScene' import type { LoanCloseParams } from '../components/scenes/Loans/LoanCloseScene' import type { LoanCreateConfirmationParams } from '../components/scenes/Loans/LoanCreateConfirmationScene' import type { LoanCreateParams } from '../components/scenes/Loans/LoanCreateScene' @@ -208,6 +209,7 @@ export type EdgeAppStackParamList = {} & { giftCardList: undefined giftCardMarket: undefined giftCardPurchase: GiftCardPurchaseParams + houdiniSend: HoudiniSendParams loanClose: LoanCloseParams loanCreate: LoanCreateParams loanCreateConfirmation: LoanCreateConfirmationParams From ecd9a884bf24b99dc65e25a0127457929ff24755 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 24 Jun 2026 15:20:20 -0700 Subject: [PATCH 2/6] Add maestro UI flow for Houdini send prototype (Proposal A) Walks the prototype from the wallet Send button through the reorganized scene, recipient-asset picker, cross-asset XMR destination tag, Private send toggle, and both success paths (Transaction Success modal and SwapSuccessScene), capturing review screenshots along the way. --- maestro/14-houdini/houdini-send-a.yaml | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 maestro/14-houdini/houdini-send-a.yaml diff --git a/maestro/14-houdini/houdini-send-a.yaml b/maestro/14-houdini/houdini-send-a.yaml new file mode 100644 index 00000000000..690310bb984 --- /dev/null +++ b/maestro/14-houdini/houdini-send-a.yaml @@ -0,0 +1,93 @@ +# Houdini private-send prototype walk — Proposal A (From -> To grouping). +# +# Logs into the funded test account, opens the Bitcoin wallet, taps Send (the +# wallet Send button is routed to the prototype scene), and walks the full flow, +# capturing the review screenshots. Nothing here talks to Houdini; every value +# is hard-coded by HoudiniSendScene. +# +# Screenshots captured (under ~/.maestro/tests// ): +# houdini-a-01-scene reorganized scene: You send / Recipient gets with +# Guaranteed / Estimated indicators, Recipient +# receives, Private send toggle +# houdini-a-02-success-modal same-asset, non-private success (Transaction +# Success modal) +# houdini-a-03-picker recipient-asset picker (hard-coded BTC/ETH/XMR/SOL) +# houdini-a-04-destination-tag cross-asset XMR: Destination Tag row (memoNeeded) +# houdini-a-05-private-on Private send on, slider in "send privately" state +# houdini-a-06-swap-success cross-asset / private success (SwapSuccessScene) +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - houdini +--- +- launchApp +- runFlow: + when: + visible: 'Exit PIN' + commands: + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 1500 } +- runFlow: + when: + visible: 'Security is Our Priority' + commands: + - tapOn: 'Cancel' +- runFlow: + when: + visible: 'How Did You Discover Edge?' + commands: + - tapOn: 'Dismiss' +- runFlow: + when: + visible: 'Claim Your Web3 Handle' + commands: + - tapOn: 'Not Now' + +# Open the Bitcoin wallet, then Send (routed to the prototype scene). +- tapOn: 'Assets' +- tapOn: 'My Bitcoin' +- tapOn: 'Send' +- assertVisible: 'Private Send' +- takeScreenshot: houdini-a-01-scene + +# Same-asset, non-private success: slide the SafeSlider -> Transaction Success modal. +- swipe: + start: 77%, 90% + end: 6%, 90% + duration: 1600 +- assertVisible: 'Transaction Success' +- takeScreenshot: houdini-a-02-success-modal +- tapOn: 'OK' + +# Re-enter a fresh scene for the cross-asset / private path. +- swipe: { start: 1%, 50%, end: 95%, 50%, duration: 400 } +- swipe: { start: 1%, 50%, end: 95%, 50%, duration: 400 } +- tapOn: 'Send' +- assertVisible: 'Private Send' + +# Recipient-asset picker (hard-coded chain list). +- tapOn: 'Recipient receives' +- takeScreenshot: houdini-a-03-picker +- tapOn: + text: 'XMR Monero.*' + +# Cross-asset XMR shows the conditional Destination Tag row. +- assertVisible: 'Destination Tag' +- takeScreenshot: houdini-a-04-destination-tag + +# Turn on Private send; the slider switches to "send privately". +- swipe: { start: 50%, 75%, end: 50%, 30% } +- tapOn: 'Private send' +- takeScreenshot: houdini-a-05-private-on + +# Slide -> SwapSuccessScene (cross-asset / private completion path). +- swipe: + start: 77%, 90% + end: 6%, 90% + duration: 1600 +- assertVisible: 'Congratulations!' +- takeScreenshot: houdini-a-06-swap-success From 7706ea6c6cdf047235d55c5c350d0a74a0f3a717 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 26 Jun 2026 14:17:31 -0700 Subject: [PATCH 3/6] Show Houdini exchange tile only for private or cross-asset sends Address Proposal A review feedback: the Houdini provider tile no longer renders on a plain same-asset, non-private send. It appears only when the send is private or converts between assets, with the title 'Houdini Private Exchange' (private) or 'Houdini Exchange' (cross-asset). A plain BTC send now shows the standard add-recipient-address UI and hides the estimated recipient amount. --- src/components/scenes/HoudiniSendScene.tsx | 28 ++++++++++++++++------ src/locales/en_US.ts | 3 ++- src/locales/strings/enUS.json | 3 ++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/components/scenes/HoudiniSendScene.tsx b/src/components/scenes/HoudiniSendScene.tsx index 53e34cee101..d467ec3f7e8 100644 --- a/src/components/scenes/HoudiniSendScene.tsx +++ b/src/components/scenes/HoudiniSendScene.tsx @@ -111,6 +111,11 @@ export const HoudiniSendScene: React.FC = props => { // Derived values: const isCrossAsset = recipientChain.currencyCode !== SOURCE_CHAIN.currencyCode + // The send routes through Houdini (and so shows the exchange quote tile, the + // estimated "recipient gets" amount, and a locked recipient address) only when + // it is private OR converts between assets. A plain same-asset, non-private + // send is an ordinary on-chain send with the normal "add recipient" UI. + const isExchange = privateSend || isCrossAsset const rateText = `1 ${SOURCE_CHAIN.currencyCode} = ${recipientChain.ratePerBtc} ${recipientChain.currencyCode}` // Handlers: @@ -300,13 +305,16 @@ export const HoudiniSendScene: React.FC = props => { const renderAddress = (): React.ReactElement | null => { if (sourceWallet == null) return null + // A Houdini (private/cross-asset) send pre-fills and locks the recipient + // address; a plain on-chain send shows the standard "add recipient address" + // affordance (enter / scan / paste) so it matches a normal BTC send. return ( ['navigation'] @@ -318,7 +326,13 @@ export const HoudiniSendScene: React.FC = props => { } const renderQuote = (): React.ReactElement => ( - + {rateText} {`${secondsLeft}s`} @@ -360,11 +374,11 @@ export const HoudiniSendScene: React.FC = props => { {renderYouSend()} {renderNetworkFee()} - {renderQuote()} + {isExchange ? {renderQuote()} : null} {renderRecipientReceives()} {renderAddress()} - {renderRecipientGets()} + {isExchange ? renderRecipientGets() : null} {renderDestinationTag()} {renderPrivateToggle()} @@ -378,11 +392,11 @@ export const HoudiniSendScene: React.FC = props => { {renderAddress()} {renderRecipientReceives()} {renderYouSend()} - {renderRecipientGets()} + {isExchange ? renderRecipientGets() : null} {renderPrivateToggle()} - {renderQuote()} + {isExchange ? renderQuote() : null} {renderNetworkFee()} {renderDestinationTag()} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index b0f505d5a54..06b1b1baff7 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1914,7 +1914,8 @@ const strings = { houdini_recipient_gets: 'Recipient gets', houdini_recipient_receives: 'Recipient receives', houdini_private_send: 'Private send', - houdini_provider_label: 'Houdini private', + houdini_exchange_private: 'Houdini Private Exchange', + houdini_exchange: 'Houdini Exchange', houdini_guaranteed: 'Guaranteed', houdini_estimated: 'Estimated', houdini_slide_send: 'Slide to send', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 69caeb3f07c..c53ccfd4385 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1495,7 +1495,8 @@ "houdini_recipient_gets": "Recipient gets", "houdini_recipient_receives": "Recipient receives", "houdini_private_send": "Private send", - "houdini_provider_label": "Houdini private", + "houdini_exchange_private": "Houdini Private Exchange", + "houdini_exchange": "Houdini Exchange", "houdini_guaranteed": "Guaranteed", "houdini_estimated": "Estimated", "houdini_slide_send": "Slide to send", From b032355f82687714ded8ee34a5895d9ae46af2ba Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 26 Jun 2026 14:57:59 -0700 Subject: [PATCH 4/6] Revise Houdini send prototype per design review (Proposal A) Apply review feedback on the redesigned send flow: - Drop the conversion-rate tile. Show a conversion percent on the estimated side instead (swap-scene style), saving a tile. - Rename 'Private send' to 'Incognito send' throughout. The incognito toggle tile now expands with explanatory messaging while enabled. - A plain same-asset, non-incognito send is treated as an ordinary on-chain send: standard add-recipient-address UI, no exchange rows. Routing through Houdini (incognito or cross-asset) shows the estimated amount, conversion percent, and a locked recipient address. - Update the maestro walk to cover all branches (plain, incognito, swap) and both success paths. --- maestro/14-houdini/houdini-send-a.yaml | 117 +++++++++++++------- src/components/scenes/HoudiniSendScene.tsx | 123 ++++++++++----------- src/locales/en_US.ts | 13 +-- src/locales/strings/enUS.json | 10 +- 4 files changed, 144 insertions(+), 119 deletions(-) diff --git a/maestro/14-houdini/houdini-send-a.yaml b/maestro/14-houdini/houdini-send-a.yaml index 690310bb984..0ee8dd45ca4 100644 --- a/maestro/14-houdini/houdini-send-a.yaml +++ b/maestro/14-houdini/houdini-send-a.yaml @@ -1,20 +1,24 @@ -# Houdini private-send prototype walk — Proposal A (From -> To grouping). +# Houdini incognito-send prototype walk — Proposal A (From -> To grouping). # # Logs into the funded test account, opens the Bitcoin wallet, taps Send (the -# wallet Send button is routed to the prototype scene), and walks the full flow, -# capturing the review screenshots. Nothing here talks to Houdini; every value -# is hard-coded by HoudiniSendScene. +# wallet Send button is routed to the prototype scene), and walks every branch +# of the flow, capturing the review screenshots. Nothing here talks to Houdini; +# every value is hard-coded by HoudiniSendScene. # # Screenshots captured (under ~/.maestro/tests// ): -# houdini-a-01-scene reorganized scene: You send / Recipient gets with -# Guaranteed / Estimated indicators, Recipient -# receives, Private send toggle -# houdini-a-02-success-modal same-asset, non-private success (Transaction +# houdini-a-01-plain-initial same-asset, non-incognito start: standard +# "add recipient address" affordance, no quote +# tile, no estimated "recipient gets" row +# houdini-a-02-incognito same-asset incognito on: toggle tile expands +# with messaging, "recipient gets" shows the +# conversion percent, address locks +# houdini-a-03-swap-xmr cross-asset XMR: conversion percent (-2.5%) and +# the conditional Destination Tag row +# houdini-a-04-swap-success cross-asset success (SwapSuccessScene) +# houdini-a-05-success-modal same-asset, non-incognito success (Transaction # Success modal) -# houdini-a-03-picker recipient-asset picker (hard-coded BTC/ETH/XMR/SOL) -# houdini-a-04-destination-tag cross-asset XMR: Destination Tag row (memoNeeded) -# houdini-a-05-private-on Private send on, slider in "send privately" state -# houdini-a-06-swap-success cross-asset / private success (SwapSuccessScene) +# houdini-a-06-tx-details same-asset success continues to Transaction +# Details for the prototype transaction appId: ${APP_ID} env: APP_ID: co.edgesecure.app @@ -47,47 +51,74 @@ tags: commands: - tapOn: 'Not Now' -# Open the Bitcoin wallet, then Send (routed to the prototype scene). +# Open the Bitcoin wallet, then Send (routed to the prototype scene). Gate on +# the wallet tx-list scene (its "Receive" button) so Send cannot misfire on the +# home balance-card Send button (which would open the production send flow). - tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 - tapOn: 'My Bitcoin' +- assertVisible: 'Receive' - tapOn: 'Send' -- assertVisible: 'Private Send' -- takeScreenshot: houdini-a-01-scene +- waitForAnimationToEnd: + timeout: 3000 -# Same-asset, non-private success: slide the SafeSlider -> Transaction Success modal. -- swipe: - start: 77%, 90% - end: 6%, 90% - duration: 1600 -- assertVisible: 'Transaction Success' -- takeScreenshot: houdini-a-02-success-modal -- tapOn: 'OK' +# Same-asset, non-incognito start: the initial "add recipient address" state. +# No quote tile and no estimated "recipient gets" row exist yet. +- assertVisible: 'Incognito send' +- assertVisible: 'Send to Address' +- takeScreenshot: houdini-a-01-plain-initial -# Re-enter a fresh scene for the cross-asset / private path. -- swipe: { start: 1%, 50%, end: 95%, 50%, duration: 400 } -- swipe: { start: 1%, 50%, end: 95%, 50%, duration: 400 } -- tapOn: 'Send' -- assertVisible: 'Private Send' +# Turn incognito on: the toggle tile expands with messaging, the estimated +# "recipient gets" row appears with a conversion percent, address locks. +- tapOn: 'Incognito send' +- assertVisible: 'Incognito routes your send.*' +- assertVisible: 'Recipient gets' +- takeScreenshot: houdini-a-02-incognito -# Recipient-asset picker (hard-coded chain list). +# Back to non-incognito, then pick a mismatching recipient asset (XMR). The +# asset mismatch alone routes through Houdini (conversion percent + locked +# address + destination tag). +- tapOn: 'Incognito send' - tapOn: 'Recipient receives' -- takeScreenshot: houdini-a-03-picker - tapOn: text: 'XMR Monero.*' - -# Cross-asset XMR shows the conditional Destination Tag row. - assertVisible: 'Destination Tag' -- takeScreenshot: houdini-a-04-destination-tag - -# Turn on Private send; the slider switches to "send privately". -- swipe: { start: 50%, 75%, end: 50%, 30% } -- tapOn: 'Private send' -- takeScreenshot: houdini-a-05-private-on +- scrollUntilVisible: + element: + text: 'Slide to send.*' + direction: DOWN +- takeScreenshot: houdini-a-03-swap-xmr -# Slide -> SwapSuccessScene (cross-asset / private completion path). +# Slide -> SwapSuccessScene (cross-asset completion path). - swipe: - start: 77%, 90% - end: 6%, 90% - duration: 1600 + start: 77%, 88% + end: 18%, 88% + duration: 1800 - assertVisible: 'Congratulations!' -- takeScreenshot: houdini-a-06-swap-success +- takeScreenshot: houdini-a-04-swap-success +- tapOn: 'Done' +- waitForAnimationToEnd: + timeout: 2500 + +# Same-asset, non-incognito success path: re-enter a fresh send and slide. +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin' +- assertVisible: 'Receive' +- tapOn: 'Send' +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: 'Send to Address' +- swipe: + start: 77%, 88% + end: 18%, 88% + duration: 1800 +- assertVisible: 'Transaction Success' +- takeScreenshot: houdini-a-05-success-modal + +# Acknowledge -> Transaction Details for the prototype transaction. +- tapOn: 'OK' +- assertVisible: 'Transaction ID' +- takeScreenshot: houdini-a-06-tx-details diff --git a/src/components/scenes/HoudiniSendScene.tsx b/src/components/scenes/HoudiniSendScene.tsx index d467ec3f7e8..82601dc651c 100644 --- a/src/components/scenes/HoudiniSendScene.tsx +++ b/src/components/scenes/HoudiniSendScene.tsx @@ -41,6 +41,9 @@ interface HoudiniChain { displayName: string // Hard-coded "1 BTC = ratePerBtc " exchange rate. ratePerBtc: string + // Hard-coded conversion percent shown on the estimated side (swap-scene + // style, e.g. "-2.5"). Represents the incognito/exchange spread. + conversionPercent: string // Whether this chain needs a destination tag / memo (drives the conditional row). memoNeeded: boolean } @@ -50,6 +53,7 @@ const SOURCE_CHAIN: HoudiniChain = { currencyCode: 'BTC', displayName: 'Bitcoin', ratePerBtc: '1', + conversionPercent: '-1', memoNeeded: false } @@ -60,6 +64,7 @@ const RECIPIENT_CHAINS: HoudiniChain[] = [ currencyCode: 'ETH', displayName: 'Ethereum', ratePerBtc: '36.5', + conversionPercent: '-1.8', memoNeeded: false }, { @@ -67,6 +72,7 @@ const RECIPIENT_CHAINS: HoudiniChain[] = [ currencyCode: 'XMR', displayName: 'Monero', ratePerBtc: '350', + conversionPercent: '-2.5', memoNeeded: true }, { @@ -74,6 +80,7 @@ const RECIPIENT_CHAINS: HoudiniChain[] = [ currencyCode: 'SOL', displayName: 'Solana', ratePerBtc: '620', + conversionPercent: '-2', memoNeeded: false } ] @@ -82,7 +89,6 @@ const RECIPIENT_CHAINS: HoudiniChain[] = [ const HARD_CODED_ADDRESS = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' const HARD_CODED_NETWORK_FEE = '0.00002 BTC' const HARD_CODED_DESTINATION_TAG = '8675309' -const QUOTE_EXPIRY_SECONDS = 60 const ESTIMATE_PREFIX = '~ ' const amountRegex = /^\d*\.?\d*$/ @@ -101,8 +107,7 @@ export const HoudiniSendScene: React.FC = props => { const [guaranteedSide, setGuaranteedSide] = React.useState< 'send' | 'receive' >('send') - const [privateSend, setPrivateSend] = React.useState(false) - const [secondsLeft, setSecondsLeft] = React.useState(QUOTE_EXPIRY_SECONDS) + const [incognito, setIncognito] = React.useState(false) // Selectors: const sourceWallet = useSelector( @@ -111,12 +116,13 @@ export const HoudiniSendScene: React.FC = props => { // Derived values: const isCrossAsset = recipientChain.currencyCode !== SOURCE_CHAIN.currencyCode - // The send routes through Houdini (and so shows the exchange quote tile, the - // estimated "recipient gets" amount, and a locked recipient address) only when - // it is private OR converts between assets. A plain same-asset, non-private + // The send routes through Houdini (and so shows the estimated "recipient gets" + // amount with a conversion percent, and a locked recipient address) only when + // it is incognito OR converts between assets. A plain same-asset, non-incognito // send is an ordinary on-chain send with the normal "add recipient" UI. - const isExchange = privateSend || isCrossAsset - const rateText = `1 ${SOURCE_CHAIN.currencyCode} = ${recipientChain.ratePerBtc} ${recipientChain.currencyCode}` + const isExchange = incognito || isCrossAsset + // Conversion percent shown on the estimated side (instead of a rate tile). + const conversionPercentText = `${recipientChain.conversionPercent}%` // Handlers: const handleEditYouSend = useHandler(async () => { @@ -184,15 +190,15 @@ export const HoudiniSendScene: React.FC = props => { } }) - const handleTogglePrivate = useHandler(() => { - setPrivateSend(value => !value) + const handleToggleIncognito = useHandler(() => { + setIncognito(value => !value) }) const handleSlidingComplete = useHandler(async (reset: () => void) => { const edgeTransaction = buildPrototypeTransaction(walletId) - // Cross-asset or private sends celebrate with the swap success scene; + // Cross-asset or incognito sends celebrate with the swap success scene; // a plain same-asset send shows the standard transaction success modal. - if (isCrossAsset || privateSend) { + if (isExchange) { reset() navigation.navigate('swapSuccess', { edgeTransaction, walletId }) return @@ -216,16 +222,6 @@ export const HoudiniSendScene: React.FC = props => { } }) - // Effects: - React.useEffect(() => { - const interval = setInterval(() => { - setSecondsLeft(prev => (prev <= 1 ? QUOTE_EXPIRY_SECONDS : prev - 1)) - }, 1000) - return () => { - clearInterval(interval) - } - }, []) - // --------------------------------------------------------------------------- // Render helpers // --------------------------------------------------------------------------- @@ -256,13 +252,15 @@ export const HoudiniSendScene: React.FC = props => { {`${isGuaranteed ? '' : ESTIMATE_PREFIX}${amount} ${currencyCode}`} - - {isGuaranteed - ? lstrings.houdini_guaranteed - : lstrings.houdini_estimated} - + {isGuaranteed ? ( + + {lstrings.houdini_guaranteed} + + ) : ( + + {conversionPercentText} + + )} ) @@ -305,7 +303,7 @@ export const HoudiniSendScene: React.FC = props => { const renderAddress = (): React.ReactElement | null => { if (sourceWallet == null) return null - // A Houdini (private/cross-asset) send pre-fills and locks the recipient + // A Houdini (incognito/cross-asset) send pre-fills and locks the recipient // address; a plain on-chain send shows the standard "add recipient address" // affordance (enter / scan / paste) so it matches a normal BTC send. return ( @@ -325,21 +323,6 @@ export const HoudiniSendScene: React.FC = props => { ) } - const renderQuote = (): React.ReactElement => ( - - - {rateText} - {`${secondsLeft}s`} - - - ) - const renderNetworkFee = (): React.ReactElement => ( {HARD_CODED_NETWORK_FEE} @@ -355,14 +338,24 @@ export const HoudiniSendScene: React.FC = props => { ) } - const renderPrivateToggle = (): React.ReactElement => ( + // The incognito toggle expands in-tile with explanatory messaging while it is + // enabled, mirroring the wording planned for the production tooltip. + const renderIncognitoToggle = (): React.ReactElement => ( ) + const renderIncognitoInfo = (): React.ReactElement => ( + + + {lstrings.houdini_incognito_info} + + + ) + // --------------------------------------------------------------------------- // Layouts — only the card grouping differs between Proposal A and Proposal B. // --------------------------------------------------------------------------- @@ -374,14 +367,16 @@ export const HoudiniSendScene: React.FC = props => { {renderYouSend()} {renderNetworkFee()} - {isExchange ? {renderQuote()} : null} {renderRecipientReceives()} {renderAddress()} {isExchange ? renderRecipientGets() : null} {renderDestinationTag()} - {renderPrivateToggle()} + + {renderIncognitoToggle()} + {incognito ? renderIncognitoInfo() : null} + ) @@ -394,9 +389,11 @@ export const HoudiniSendScene: React.FC = props => { {renderYouSend()} {isExchange ? renderRecipientGets() : null} - {renderPrivateToggle()} - {isExchange ? renderQuote() : null} + {renderIncognitoToggle()} + {incognito ? renderIncognitoInfo() : null} + + {renderNetworkFee()} {renderDestinationTag()} @@ -413,8 +410,8 @@ export const HoudiniSendScene: React.FC = props => { ({ marginLeft: theme.rem(0.25), marginRight: theme.rem(0.5) }, - amountHint: { - color: theme.secondaryText, + percentHint: { + color: theme.negativeText, fontSize: theme.rem(0.75) }, guaranteedHint: { color: theme.positiveText, fontSize: theme.rem(0.75) }, - quoteRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between' + incognitoInfo: { + paddingHorizontal: theme.rem(0.5), + paddingBottom: theme.rem(0.25) }, - countdownText: { - color: theme.secondaryText + incognitoInfoText: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) }, sliderContainer: { marginTop: theme.rem(1), diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 06b1b1baff7..502898857ef 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1908,18 +1908,17 @@ const strings = { bank_info_title: 'Bank Info', home_address_title: 'Home Address', - // Houdini private send prototype - houdini_send_title: 'Private Send', + // Houdini incognito send prototype + houdini_send_title: 'Send', houdini_you_send: 'You send', houdini_recipient_gets: 'Recipient gets', houdini_recipient_receives: 'Recipient receives', - houdini_private_send: 'Private send', - houdini_exchange_private: 'Houdini Private Exchange', - houdini_exchange: 'Houdini Exchange', + houdini_incognito_send: 'Incognito send', + houdini_incognito_info: + "Incognito routes your send through Houdini so the amount and recipient aren't linked to your wallet on-chain. Network fees still apply.", houdini_guaranteed: 'Guaranteed', - houdini_estimated: 'Estimated', houdini_slide_send: 'Slide to send', - houdini_slide_private: 'Slide to send privately', + houdini_slide_incognito: 'Slide to send incognito', input_output_currency: 'Currency', n_a: 'N/A', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index c53ccfd4385..ed2e1b75284 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1490,17 +1490,15 @@ "form_field_error_invalid_ssn": "Please enter a valid SSN (XXX-XX-XXXX)", "bank_info_title": "Bank Info", "home_address_title": "Home Address", - "houdini_send_title": "Private Send", + "houdini_send_title": "Send", "houdini_you_send": "You send", "houdini_recipient_gets": "Recipient gets", "houdini_recipient_receives": "Recipient receives", - "houdini_private_send": "Private send", - "houdini_exchange_private": "Houdini Private Exchange", - "houdini_exchange": "Houdini Exchange", + "houdini_incognito_send": "Incognito send", + "houdini_incognito_info": "Incognito routes your send through Houdini so the amount and recipient aren't linked to your wallet on-chain. Network fees still apply.", "houdini_guaranteed": "Guaranteed", - "houdini_estimated": "Estimated", "houdini_slide_send": "Slide to send", - "houdini_slide_private": "Slide to send privately", + "houdini_slide_incognito": "Slide to send incognito", "input_output_currency": "Currency", "n_a": "N/A", "payment_details": "Payment Details", From 28bc5d727c5de1995421674d96aaa7f45876e28b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 26 Jun 2026 15:18:47 -0700 Subject: [PATCH 5/6] Lock Houdini prototype slider after an exchange send Bugbot: on the cross-asset / incognito completion path the slider reset was called synchronously before navigating, which left the control active and let a second slide fire another navigation. Drop the early reset so SafeSlider locks after completion, and add a submission guard so the handler runs once. --- src/components/scenes/HoudiniSendScene.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/scenes/HoudiniSendScene.tsx b/src/components/scenes/HoudiniSendScene.tsx index 82601dc651c..a5520e632cb 100644 --- a/src/components/scenes/HoudiniSendScene.tsx +++ b/src/components/scenes/HoudiniSendScene.tsx @@ -108,6 +108,9 @@ export const HoudiniSendScene: React.FC = props => { 'send' | 'receive' >('send') const [incognito, setIncognito] = React.useState(false) + // Guards against a second slide firing another navigation before the scene + // transitions away. + const isSubmitting = React.useRef(false) // Selectors: const sourceWallet = useSelector( @@ -195,14 +198,19 @@ export const HoudiniSendScene: React.FC = props => { }) const handleSlidingComplete = useHandler(async (reset: () => void) => { + if (isSubmitting.current) return + isSubmitting.current = true const edgeTransaction = buildPrototypeTransaction(walletId) - // Cross-asset or incognito sends celebrate with the swap success scene; - // a plain same-asset send shows the standard transaction success modal. + // Cross-asset or incognito sends celebrate with the swap success scene. + // The slider is intentionally NOT reset here: leaving it un-reset locks it + // after completion, so a second slide cannot fire another navigation while + // the scene transitions away. if (isExchange) { - reset() navigation.navigate('swapSuccess', { edgeTransaction, walletId }) return } + // A plain same-asset send stays on the scene, so reset the slider after the + // success modal to make it usable again. const result = await Airship.show<'ok' | undefined>(bridge => ( = props => { return undefined }) reset() + isSubmitting.current = false // Only continue to the details scene when the user acknowledges the // success modal; dismissing it leaves them on the send scene. if (result === 'ok') { From e3eacffebcde65eb47ab8fb1fa3c3053552144e9 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 26 Jun 2026 16:13:52 -0700 Subject: [PATCH 6/6] Add Houdini swap prototype and refine send scene reveal (Proposal A) Apply the next round of design-review feedback: - The send scene reveals the amount and network-fee rows only after a recipient address or asset is selected. Add an a1/a2 variant for the initial state: a1 shows the Recipient receives row up front, a2 hides it until a selection. - Add a Houdini swap prototype reachable from a wallet's Trade -> Swap action: an amount-entry scene with the incognito toggle in an expanding card, then a mock quote scene that shows 'Powered by Houdini' with no tappable chevron. - Add maestro walks for both flows. --- maestro/14-houdini/houdini-send-a.yaml | 111 +++---- maestro/14-houdini/houdini-swap-a.yaml | 66 +++++ src/components/Main.tsx | 9 + src/components/scenes/HoudiniSendScene.tsx | 55 +++- .../scenes/HoudiniSwapQuoteScene.tsx | 167 +++++++++++ src/components/scenes/HoudiniSwapScene.tsx | 276 ++++++++++++++++++ src/components/themed/TransactionListTop.tsx | 167 +---------- src/locales/en_US.ts | 10 + src/locales/strings/enUS.json | 10 + src/types/routerTypes.tsx | 4 + 10 files changed, 628 insertions(+), 247 deletions(-) create mode 100644 maestro/14-houdini/houdini-swap-a.yaml create mode 100644 src/components/scenes/HoudiniSwapQuoteScene.tsx create mode 100644 src/components/scenes/HoudiniSwapScene.tsx diff --git a/maestro/14-houdini/houdini-send-a.yaml b/maestro/14-houdini/houdini-send-a.yaml index 0ee8dd45ca4..1de5d2c0502 100644 --- a/maestro/14-houdini/houdini-send-a.yaml +++ b/maestro/14-houdini/houdini-send-a.yaml @@ -1,24 +1,20 @@ # Houdini incognito-send prototype walk — Proposal A (From -> To grouping). # -# Logs into the funded test account, opens the Bitcoin wallet, taps Send (the -# wallet Send button is routed to the prototype scene), and walks every branch -# of the flow, capturing the review screenshots. Nothing here talks to Houdini; -# every value is hard-coded by HoudiniSendScene. +# Opens the Bitcoin wallet, taps Send (the wallet Send button is routed to the +# prototype scene), and walks every branch. Nothing talks to Houdini; every +# value is hard-coded by HoudiniSendScene. The reroute passes variant 'a1' (the +# "Recipient receives" row is visible before a selection); variant 'a2' hides +# that row until a selection is made. # # Screenshots captured (under ~/.maestro/tests// ): -# houdini-a-01-plain-initial same-asset, non-incognito start: standard -# "add recipient address" affordance, no quote -# tile, no estimated "recipient gets" row -# houdini-a-02-incognito same-asset incognito on: toggle tile expands -# with messaging, "recipient gets" shows the -# conversion percent, address locks -# houdini-a-03-swap-xmr cross-asset XMR: conversion percent (-2.5%) and -# the conditional Destination Tag row -# houdini-a-04-swap-success cross-asset success (SwapSuccessScene) -# houdini-a-05-success-modal same-asset, non-incognito success (Transaction -# Success modal) -# houdini-a-06-tx-details same-asset success continues to Transaction -# Details for the prototype transaction +# houdini-a-01-plain-initial same-asset start: add-recipient affordance, +# no amount / fee rows yet, "Recipient receives" +# visible (a1) +# houdini-a-02-after-address after a recipient is chosen: You send + Network +# Fee rows appear +# houdini-a-03-incognito incognito on: toggle tile expands with +# messaging, Recipient gets shows conversion % +# houdini-a-04-swap-xmr cross-asset XMR: conversion % + Destination Tag appId: ${APP_ID} env: APP_ID: co.edgesecure.app @@ -40,20 +36,13 @@ tags: visible: 'Security is Our Priority' commands: - tapOn: 'Cancel' -- runFlow: - when: - visible: 'How Did You Discover Edge?' - commands: - - tapOn: 'Dismiss' -- runFlow: - when: - visible: 'Claim Your Web3 Handle' - commands: - - tapOn: 'Not Now' +# Dismiss the unrelated Stellar/Horizon plugin error toast if present. +- tapOn: + text: '.*Horizon.*' + optional: true # Open the Bitcoin wallet, then Send (routed to the prototype scene). Gate on -# the wallet tx-list scene (its "Receive" button) so Send cannot misfire on the -# home balance-card Send button (which would open the production send flow). +# the wallet tx-list "Receive" button so Send cannot misfire on the home Send. - tapOn: 'Assets' - waitForAnimationToEnd: timeout: 2000 @@ -63,62 +52,34 @@ tags: - waitForAnimationToEnd: timeout: 3000 -# Same-asset, non-incognito start: the initial "add recipient address" state. -# No quote tile and no estimated "recipient gets" row exist yet. +# Plain same-asset start: add-recipient affordance, no amount/fee rows yet. - assertVisible: 'Incognito send' - assertVisible: 'Send to Address' - takeScreenshot: houdini-a-01-plain-initial -# Turn incognito on: the toggle tile expands with messaging, the estimated -# "recipient gets" row appears with a conversion percent, address locks. +# Choose a recipient via self-transfer -> reveals the amount + fee rows. +- tapOn: + text: '.*Myself' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin 3' +- waitForAnimationToEnd: + timeout: 2500 +- assertVisible: 'You send' +- assertVisible: 'Network Fee' +- takeScreenshot: houdini-a-02-after-address + +# Incognito on: toggle tile expands, Recipient gets shows the conversion percent. - tapOn: 'Incognito send' - assertVisible: 'Incognito routes your send.*' - assertVisible: 'Recipient gets' -- takeScreenshot: houdini-a-02-incognito +- takeScreenshot: houdini-a-03-incognito -# Back to non-incognito, then pick a mismatching recipient asset (XMR). The -# asset mismatch alone routes through Houdini (conversion percent + locked -# address + destination tag). +# Cross-asset: pick XMR (asset mismatch routes through Houdini). - tapOn: 'Incognito send' - tapOn: 'Recipient receives' - tapOn: text: 'XMR Monero.*' +- assertVisible: 'Monero' - assertVisible: 'Destination Tag' -- scrollUntilVisible: - element: - text: 'Slide to send.*' - direction: DOWN -- takeScreenshot: houdini-a-03-swap-xmr - -# Slide -> SwapSuccessScene (cross-asset completion path). -- swipe: - start: 77%, 88% - end: 18%, 88% - duration: 1800 -- assertVisible: 'Congratulations!' -- takeScreenshot: houdini-a-04-swap-success -- tapOn: 'Done' -- waitForAnimationToEnd: - timeout: 2500 - -# Same-asset, non-incognito success path: re-enter a fresh send and slide. -- tapOn: 'Assets' -- waitForAnimationToEnd: - timeout: 2000 -- tapOn: 'My Bitcoin' -- assertVisible: 'Receive' -- tapOn: 'Send' -- waitForAnimationToEnd: - timeout: 3000 -- assertVisible: 'Send to Address' -- swipe: - start: 77%, 88% - end: 18%, 88% - duration: 1800 -- assertVisible: 'Transaction Success' -- takeScreenshot: houdini-a-05-success-modal - -# Acknowledge -> Transaction Details for the prototype transaction. -- tapOn: 'OK' -- assertVisible: 'Transaction ID' -- takeScreenshot: houdini-a-06-tx-details +- takeScreenshot: houdini-a-04-swap-xmr diff --git a/maestro/14-houdini/houdini-swap-a.yaml b/maestro/14-houdini/houdini-swap-a.yaml new file mode 100644 index 00000000000..6f04422ed93 --- /dev/null +++ b/maestro/14-houdini/houdini-swap-a.yaml @@ -0,0 +1,66 @@ +# Houdini incognito-swap prototype walk — Proposal A. +# +# Opens the Bitcoin wallet, taps Trade -> "Swap BTC to/from another crypto" (the +# wallet swap action is routed to the prototype HoudiniSwapScene), enters +# amounts with the incognito toggle in a card, then gets a mock quote +# ("Powered by Houdini", no chevron). Nothing talks to Houdini. +# +# Screenshots captured (under ~/.maestro/tests// ): +# houdini-swap-01-entry from/to amount entry, incognito toggle in a card +# houdini-swap-02-incognito incognito on: toggle card expands with messaging +# houdini-swap-03-quote mock quote scene, "Powered by Houdini" (no chevron) +appId: ${APP_ID} +env: + APP_ID: co.edgesecure.app + PIN_DIGIT: '0' +tags: + - houdini +--- +- launchApp +- runFlow: + when: + visible: 'Exit PIN' + commands: + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 900 } + - tapOn: { text: '${PIN_DIGIT}', waitToSettleTimeoutMs: 1500 } +- tapOn: + text: '.*Horizon.*' + optional: true + +# Open the Bitcoin wallet, then Trade -> Swap (routed to the prototype scene). +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'My Bitcoin' +- assertVisible: 'Receive' +- tapOn: 'Trade' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Swap BTC to/from another crypto' +- waitForAnimationToEnd: + timeout: 3000 + +# Swap amount entry, incognito toggle in a card. +- assertVisible: 'You send' +- assertVisible: 'You receive' +- assertVisible: 'Incognito send' +- takeScreenshot: houdini-swap-01-entry + +# Incognito on: the toggle card expands with messaging. +- tapOn: 'Incognito send' +- assertVisible: 'Incognito routes your send.*' +- takeScreenshot: houdini-swap-02-incognito + +# Slide to get the mock quote -> "Powered by Houdini" (no chevron). +- scrollUntilVisible: + element: + text: 'Slide to get quote' + direction: DOWN +- swipe: + start: 77%, 80% + end: 16%, 80% + duration: 1900 +- assertVisible: 'Powered by Houdini' +- takeScreenshot: houdini-swap-03-quote diff --git a/src/components/Main.tsx b/src/components/Main.tsx index f93c0d5b296..626301512c6 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -107,6 +107,8 @@ import { import { GuiPluginViewScene as GuiPluginViewSceneComponent } from './scenes/GuiPluginViewScene' import { HomeScene as HomeSceneComponent } from './scenes/HomeScene' import { HoudiniSendScene as HoudiniSendSceneComponent } from './scenes/HoudiniSendScene' +import { HoudiniSwapQuoteScene as HoudiniSwapQuoteSceneComponent } from './scenes/HoudiniSwapQuoteScene' +import { HoudiniSwapScene as HoudiniSwapSceneComponent } from './scenes/HoudiniSwapScene' import { LoanCloseScene as LoanCloseSceneComponent } from './scenes/Loans/LoanCloseScene' import { LoanCreateConfirmationScene as LoanCreateConfirmationSceneComponent } from './scenes/Loans/LoanCreateConfirmationScene' import { LoanCreateScene as LoanCreateSceneComponent } from './scenes/Loans/LoanCreateScene' @@ -244,6 +246,8 @@ const FioStakingOverviewScene = ifLoggedIn(FioStakingOverviewSceneComponent) const GuiPluginViewScene = ifLoggedIn(GuiPluginViewSceneComponent) const HomeScene = ifLoggedIn(HomeSceneComponent) const HoudiniSendScene = ifLoggedIn(HoudiniSendSceneComponent) +const HoudiniSwapScene = ifLoggedIn(HoudiniSwapSceneComponent) +const HoudiniSwapQuoteScene = ifLoggedIn(HoudiniSwapQuoteSceneComponent) const GiftCardAccountInfoScene = ifLoggedIn(GiftCardAccountInfoSceneComponent) const GiftCardListScene = ifLoggedIn(GiftCardListSceneComponent) const GiftCardMarketScene = ifLoggedIn(GiftCardMarketSceneComponent) @@ -1090,6 +1094,11 @@ const EdgeAppStack: React.FC = () => { /> + +