diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe47fb7f1..2b76dbf6670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- added: Houdini private-send prototype scene (Proposal B) 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/maestro/14-houdini/houdini-send-b.yaml b/maestro/14-houdini/houdini-send-b.yaml new file mode 100644 index 00000000000..af7b5a09ebd --- /dev/null +++ b/maestro/14-houdini/houdini-send-b.yaml @@ -0,0 +1,93 @@ +# Houdini private-send prototype walk — Proposal B (today's live card order). +# +# 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. Proposal B differs from Proposal A only in +# card grouping: an address card grouping Send To Address + Recipient receives + +# the two amounts, with a separate lower card for the rate, network fee, and +# (conditional) destination tag. +# +# Screenshots captured (under ~/.maestro/tests// ): +# houdini-b-01-scene reorganized scene (Proposal B card order) +# houdini-b-02-picker recipient-asset picker (hard-coded BTC/ETH/XMR/SOL) +# houdini-b-03-destination-tag cross-asset XMR: Destination Tag row in the lower card +# houdini-b-04-private-on Private send on, slider in "send privately" state +# houdini-b-05-swap-success cross-asset / private success (SwapSuccessScene) +# houdini-b-06-success-modal same-asset, non-private success (Transaction Success modal) +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-b-01-scene + +# Recipient-asset picker (hard-coded chain list). +- tapOn: 'Recipient receives' +- takeScreenshot: houdini-b-02-picker +- tapOn: + text: 'XMR Monero.*' + +# Cross-asset XMR shows the conditional Destination Tag row (lower card in Proposal B). +- assertVisible: 'Destination Tag' +- swipe: { start: 50%, 70%, end: 50%, 35% } +- takeScreenshot: houdini-b-03-destination-tag + +# Turn on Private send; the slider switches to "send privately". +- tapOn: 'Private send' +- takeScreenshot: houdini-b-04-private-on + +# Slide -> SwapSuccessScene (cross-asset / private completion path). +- swipe: + start: 77%, 90% + end: 6%, 90% + duration: 1600 +- assertVisible: 'Congratulations!' +- takeScreenshot: houdini-b-05-swap-success + +# Same-asset, non-private success path: re-enter the scene and slide without +# changing the recipient asset or toggling Private send -> Transaction Success modal. +- tapOn: 'Done' +- tapOn: 'Assets' +- tapOn: 'My Bitcoin' +- tapOn: 'Send' +- assertVisible: 'Private Send' +- swipe: { start: 50%, 75%, end: 50%, 30% } +- swipe: + start: 77%, 90% + end: 6%, 90% + duration: 1600 +- assertVisible: 'Transaction Success' +- takeScreenshot: houdini-b-06-success-modal 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..4983798fc3a 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 B): 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: 'b' }) }) 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