From f09d3164e27c9f2e8feeaca43fab01170ac3b8f6 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 13 Oct 2026 16:24:09 -0700 Subject: [PATCH 1/6] Always show the Stealth Swap toggle The toggle used to appear only once both swap assets were picked. It now renders as soon as the swap scene opens, so the choice is visible before any wallet is selected. --- src/__tests__/scenes/SwapCreateScene.test.tsx | 15 + .../SwapCreateScene.test.tsx.snap | 264 +++++++++++++++++- src/components/scenes/SwapCreateScene.tsx | 30 +- src/docs/exchange-deep-links.md | 2 +- 4 files changed, 292 insertions(+), 19 deletions(-) diff --git a/src/__tests__/scenes/SwapCreateScene.test.tsx b/src/__tests__/scenes/SwapCreateScene.test.tsx index b16d4de0c7f..345f473d53f 100644 --- a/src/__tests__/scenes/SwapCreateScene.test.tsx +++ b/src/__tests__/scenes/SwapCreateScene.test.tsx @@ -156,7 +156,22 @@ describe('SwapCreateScene', () => { expect(nextParams).not.toBe(stealthParams) expect(nextParams.swapRequest.privacy).toBeUndefined() expect(nextParams.swapRequestOptions.forceEnabled).toBeUndefined() + rendered.unmount() + }) + + it('offers the Stealth Swap toggle before any wallet is picked', () => { + const rootState: FakeState = { ...fakeRootState } + + const rendered = render( + + + + ) + expect(rendered.getByText(lstrings.stealth_swap_toggle)).toBeTruthy() + expect( + rendered.queryByText(lstrings.stealth_swap_info, { exact: false }) + ).toBeNull() rendered.unmount() }) }) diff --git a/src/__tests__/scenes/__snapshots__/SwapCreateScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SwapCreateScene.test.tsx.snap index dd3939bfc20..d384fd2dcd9 100644 --- a/src/__tests__/scenes/__snapshots__/SwapCreateScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SwapCreateScene.test.tsx.snap @@ -804,6 +804,266 @@ exports[`SwapCreateScene should render with loading props 1`] = ` } } nativeID="4" + > + + + + + + Stealth Swap + + + + + + + + + + + + + + + + @@ -881,7 +1141,7 @@ exports[`SwapCreateScene should render with loading props 1`] = ` }, ] } - nativeID="6" + nativeID="9" onLayout={[Function]} stackHeight={0} style={ diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index bc7f4ba6d1a..d31f07d149c 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -625,23 +625,21 @@ export const SwapCreateScene: React.FC = props => { /> )} - {fromWallet != null && toWallet != null ? ( - - - + + + {stealth ? ( + - {stealth ? ( - - ) : null} - - - ) : null} + ) : null} + + {renderAlert()} {isNextHidden || isKeyboardOpen ? null : ( diff --git a/src/docs/exchange-deep-links.md b/src/docs/exchange-deep-links.md index b2dcd1859a3..718dcf52ad4 100644 --- a/src/docs/exchange-deep-links.md +++ b/src/docs/exchange-deep-links.md @@ -11,7 +11,7 @@ | Supersedes | - | | Related | [Asana: Deeplink - New format](https://app.asana.com/0/1215088146871429/1210180856778864) | - + Code blocks cite their file on the `jon/deeplink-new-format` branch rather than a commit sha: the doc is folded into the branch's first commit, so every push re-slots it ahead of the code it quotes and orphans any sha pinned before that push. The branch ref resolves for the life of the review, and these links move to `develop` when the branch merges. Direction came from the task description (growth's campaign-tracking request) and the operator's ruling on it. From dcc4a62eee45bf4925c8422dcfbdc11eb07686bd Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 13 Oct 2026 16:24:33 -0700 Subject: [PATCH 2/6] Add a stealth param to the swap scene Callers can now open the swap scene with Stealth Swap already on or off by passing `stealth` in the navigation params. The scene keeps the toggle in local state and consumes the param once. The swap tab stays mounted and its other callers navigate with params that carry no stealth value, so a toggle read straight from the params would switch off on every return from a failed quote. --- src/__tests__/scenes/SwapCreateScene.test.tsx | 21 +++++++++++++++++++ src/components/scenes/SwapCreateScene.tsx | 19 +++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/__tests__/scenes/SwapCreateScene.test.tsx b/src/__tests__/scenes/SwapCreateScene.test.tsx index 345f473d53f..2cefe8324f6 100644 --- a/src/__tests__/scenes/SwapCreateScene.test.tsx +++ b/src/__tests__/scenes/SwapCreateScene.test.tsx @@ -174,4 +174,25 @@ describe('SwapCreateScene', () => { ).toBeNull() rendered.unmount() }) + + it('turns Stealth Swap on from the stealth param', () => { + const rootState: FakeState = { ...fakeRootState } + const sceneProps = fakeSwapTabSceneProps('swapCreate', { stealth: true }) + const setParams = jest.spyOn(sceneProps.navigation, 'setParams') + + const rendered = render( + + + + ) + + expect( + rendered.getByText(lstrings.stealth_swap_info, { exact: false }) + ).toBeTruthy() + // The param is consumed, so later param merges cannot re-apply it: + expect(setParams).toHaveBeenCalledWith({ stealth: undefined }) + rendered.unmount() + // The fake navigation is shared by every test in the file: + setParams.mockRestore() + }) }) diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index d31f07d149c..e86b3fc130f 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -72,6 +72,10 @@ export interface SwapCreateParams { // Display error message in an alert card errorDisplayInfo?: SwapErrorDisplayInfo + + // Sets the Stealth Swap toggle on entry. The scene applies it once and + // clears it, so the toggle then stays where the user leaves it. + stealth?: boolean } interface Props extends SwapTabSceneProps<'swapCreate'> {} @@ -83,7 +87,8 @@ export const SwapCreateScene: React.FC = props => { fromTokenId = null, toWalletId, toTokenId = null, - errorDisplayInfo + errorDisplayInfo, + stealth: stealthParam } = route.params ?? {} const theme = useTheme() const dispatch = useDispatch() @@ -101,7 +106,7 @@ export const SwapCreateScene: React.FC = props => { // Stealth Swap: when enabled, the quote routes through the Houdini privacy // provider as a fixed provider (see SwapConfirmationScene). - const [stealth, setStealth] = useState(false) + const [stealth, setStealth] = useState(stealthParam === true) const fromInputRef = React.useRef(null) const toInputRef = React.useRef(null) @@ -157,6 +162,16 @@ export const SwapCreateScene: React.FC = props => { }) }, [dispatch, navigation]) + // This tab stays mounted, so a caller can navigate here with `stealth` long + // after the first render. The param is consumed rather than kept: the other + // `setParams` calls on this scene merge into the route params, and a + // lingering value would put the toggle back every time one of them ran. + React.useEffect(() => { + if (stealthParam == null) return + setStealth(stealthParam) + navigation.setParams({ stealth: undefined }) + }, [navigation, stealthParam]) + // // Callbacks // From a8864ac6c7427bcc26f24f63f0a9a647e4d854c7 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 13 Oct 2026 16:25:04 -0700 Subject: [PATCH 3/6] Add a stealth param to the send scene Callers can now open the send scene with Stealth Send already armed by passing `stealth` in the navigation params. The param arms through the same handler as a tap on the toggle, and only where a tap would succeed. A send that hides the toggle (locked tiles, a pre-filled address, a FIO request) or that cannot go private opens with Stealth off. --- src/__tests__/scenes/SendScene2.ui.test.tsx | 77 +++++++++++++++++++++ src/components/scenes/SendScene2.tsx | 14 +++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/__tests__/scenes/SendScene2.ui.test.tsx b/src/__tests__/scenes/SendScene2.ui.test.tsx index 797123444ce..104c5362902 100644 --- a/src/__tests__/scenes/SendScene2.ui.test.tsx +++ b/src/__tests__/scenes/SendScene2.ui.test.tsx @@ -366,6 +366,83 @@ describe('SendScene2', () => { ).toBeNull() rendered2.unmount() }) + it('arms Stealth Send from the stealth param', () => { + if (btcWallet == null) return + + const rootState: FakeState = { ...fakeRootState, core: { account } } + + const rendered = render( + + + + ) + + expect( + rendered.getByText(lstrings.stealth_send_info, { exact: false }) + ).toBeTruthy() + rendered.unmount() + }) + it('leaves Stealth Send off when the stealth param is absent', () => { + if (btcWallet == null) return + + const rootState: FakeState = { ...fakeRootState, core: { account } } + + const rendered = render( + + + + ) + + expect(rendered.getByText(lstrings.stealth_send_toggle)).toBeTruthy() + expect( + rendered.queryByText(lstrings.stealth_send_info, { exact: false }) + ).toBeNull() + rendered.unmount() + }) + it('arms nothing from the stealth param on an address-prefilled send', () => { + if (btcWallet == null) return + + const rootState: FakeState = { ...fakeRootState, core: { account } } + + // The toggle is not offered for a send that arrives with an address: + const rendered = render( + + + + ) + + expect(rendered.queryByText(lstrings.stealth_send_toggle)).toBeNull() + expect( + rendered.queryByText(lstrings.stealth_send_info, { exact: false }) + ).toBeNull() + rendered.unmount() + }) }) afterAll(async () => { diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index f2c945efd4d..f7793eac44f 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -235,6 +235,11 @@ export interface SendScene2Params { ) => Promise // Useful to disable during for test runtime doCheckAndShowGetCryptoModal?: boolean + /** + * Arms the Stealth Send toggle on entry. Ignored wherever a tap on the + * toggle would be refused, or where the toggle is not offered at all. + */ + stealth?: boolean } interface FioSenderInfo { @@ -297,7 +302,8 @@ const SendComponent: React.FC = props => { onExpired, beforeTransaction, alternateBroadcast, - doCheckAndShowGetCryptoModal = true + doCheckAndShowGetCryptoModal = true, + stealth: initStealth = false } = route.params const openCameraRef = React.useRef(openCamera) @@ -3450,6 +3456,12 @@ const SendComponent: React.FC = props => { showError(err) }) } + // Arm through the tap handler so an entry request gets the same quote and + // memo handling as a tap. A send that cannot go private opens with the + // toggle off, and the card already states why. + if (initStealth && swapSendAllowed && stealthBlockedReason == null) { + handleToggleStealth() + } }) useUnmount(() => { // Retire any quote request still in flight, so its answer is closed From 87085bd4aebd09b0a06f8a016ce734537714a04d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 14 Oct 2026 10:38:55 -0700 Subject: [PATCH 4/6] Add an edge://send deep link edge://send?stealth=true[&asset=[_]] lets the user pick a wallet to send from and opens the Send scene with Stealth Send on. A named asset narrows the picker, auto-picking a lone match, and falls back to every wallet when none holds it. The picker never offers wallet creation, since a new wallet has nothing to send. The link waits for every wallet and joins the in-progress guard so a second link cannot stack a second picker. --- src/__tests__/DeepLink.test.ts | 40 ++++ src/__tests__/actions/SendLinkActions.test.ts | 226 ++++++++++++++++++ src/actions/DeepLinkingActions.tsx | 70 +++++- src/docs/exchange-deep-links.md | 38 ++- src/types/DeepLinkTypes.ts | 17 ++ src/util/DeepLinkParser.ts | 9 + 6 files changed, 394 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/actions/SendLinkActions.test.ts diff --git a/src/__tests__/DeepLink.test.ts b/src/__tests__/DeepLink.test.ts index 4471b7cc8a5..087ebd6ce6d 100644 --- a/src/__tests__/DeepLink.test.ts +++ b/src/__tests__/DeepLink.test.ts @@ -544,6 +544,45 @@ describe('parseDeepLink', function () { }) }) + describe('send', () => { + makeLinkTests({ + 'edge://send?stealth=true': { + type: 'send', + asset: undefined, + stealth: true + }, + 'edge://send?stealth=true&asset=bitcoin': { + type: 'send', + asset: { pluginId: 'bitcoin', tokenId: null }, + stealth: true + }, + 'https://deep.edge.app/send?asset=ethereum_0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48&stealth=true': + { + type: 'send', + asset: { + pluginId: 'ethereum', + tokenId: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + }, + stealth: true + }, + 'edge://send': { + type: 'send', + asset: undefined, + stealth: false + }, + 'edge://send?stealth=1&asset=monero': { + type: 'send', + asset: { pluginId: 'monero', tokenId: null }, + stealth: false + }, + 'edge://send?stealth=true&asset=ethereum_': { + type: 'send', + asset: undefined, + stealth: true + } + }) + }) + describe('swap', () => { makeLinkTests({ 'edge://swap': { @@ -876,6 +915,7 @@ describe('getDeepLinkReadiness', function () { 'wallets' ], [{ type: 'rewards', pluginId: 'bitcoin', tokenId: null }, 'wallets'], + [{ type: 'send', stealth: true }, 'wallets'], [{ type: 'walletConnect', uri: 'wc:topic@2' }, 'wallets'], // An `exchange` link that names an asset opens the wallet picker, so it diff --git a/src/__tests__/actions/SendLinkActions.test.ts b/src/__tests__/actions/SendLinkActions.test.ts new file mode 100644 index 00000000000..3bfae8d1e43 --- /dev/null +++ b/src/__tests__/actions/SendLinkActions.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +import { launchDeepLink } from '../../actions/DeepLinkingActions' +import { Airship } from '../../components/services/AirshipInstance' +import { lstrings } from '../../locales/strings' +import type { DeepLink } from '../../types/DeepLinkTypes' + +const showSpy = jest.spyOn(Airship, 'show') + +/** An account whose wallets have all loaded, one per entry. */ +function makeState(wallets: Record): any { + const currencyWallets: Record = {} + for (const walletId of Object.keys(wallets)) { + currencyWallets[walletId] = { + currencyInfo: { pluginId: wallets[walletId] }, + enabledTokenIds: [] + } + } + return { + core: { + account: { + loggedIn: true, + activeWalletIds: Object.keys(wallets), + currencyWallets, + currencyWalletErrors: {}, + currencyConfig: {}, + watch: () => () => {} + }, + context: { clientId: '1111111111111111' } + }, + ui: { settings: { defaultIsoFiat: 'iso:USD' } } + } +} + +/** + * Follow a send link, answering any picker it raises with `pick`, and return + * the props each picker was shown with. + */ +async function follow( + link: DeepLink, + state: any, + pick: unknown = undefined +): Promise<{ followed: boolean; pickers: any[]; navigation: any }> { + const pickers: any[] = [] + showSpy.mockImplementation(async (render: any) => { + pickers.push(render({}).props) + return pick + }) + const navigation: any = { navigate: jest.fn(), push: jest.fn() } + const followed = await launchDeepLink(navigation, link)( + jest.fn() as any, + () => state + ) + return { followed, pickers, navigation } +} + +describe('launchDeepLink send', () => { + beforeEach(() => { + showSpy.mockReset() + }) + + it('auto-picks the one wallet holding the named asset', async () => { + const { followed, pickers, navigation } = await follow( + { + type: 'send', + asset: { pluginId: 'bitcoin', tokenId: null }, + stealth: true + }, + makeState({ a: 'bitcoin', b: 'ethereum' }) + ) + + expect(followed).toBe(true) + expect(pickers).toEqual([]) + expect(navigation.push).toHaveBeenCalledWith('send2', { + walletId: 'a', + tokenId: null, + hiddenFeaturesMap: { scamWarning: false }, + stealth: true + }) + }) + + it('narrows the picker to the named asset when several wallets hold it', async () => { + const { followed, pickers, navigation } = await follow( + { + type: 'send', + asset: { pluginId: 'bitcoin', tokenId: null }, + stealth: true + }, + makeState({ a: 'bitcoin', b: 'bitcoin', c: 'ethereum' }), + { type: 'wallet', walletId: 'b', tokenId: null } + ) + + expect(followed).toBe(true) + expect(pickers).toHaveLength(1) + expect(pickers[0].allowedAssets).toEqual([ + { pluginId: 'bitcoin', tokenId: null } + ]) + expect(pickers[0].headerTitle).toBe(lstrings.select_wallet_to_send_from) + expect(pickers[0].showCreateWallet).toBeUndefined() + expect(navigation.push).toHaveBeenCalledWith('send2', { + walletId: 'b', + tokenId: null, + hiddenFeaturesMap: { scamWarning: false }, + stealth: true + }) + }) + + it('falls back to every wallet when none holds the named asset', async () => { + const { followed, pickers, navigation } = await follow( + { + type: 'send', + asset: { pluginId: 'monero', tokenId: null }, + stealth: true + }, + makeState({ a: 'bitcoin', c: 'ethereum' }), + { type: 'wallet', walletId: 'c', tokenId: null } + ) + + expect(followed).toBe(true) + expect(pickers).toHaveLength(1) + expect(pickers[0].allowedAssets).toBeUndefined() + expect(pickers[0].showCreateWallet).toBeUndefined() + expect(pickers[0].headerTitle).toBe(lstrings.select_wallet_to_send_from) + expect(navigation.push).toHaveBeenCalledWith('send2', { + walletId: 'c', + tokenId: null, + hiddenFeaturesMap: { scamWarning: false }, + stealth: true + }) + }) + + it('opens the balance card picker when the link names no asset', async () => { + const { followed, pickers, navigation } = await follow( + { type: 'send', stealth: true }, + makeState({ a: 'bitcoin' }), + { type: 'wallet', walletId: 'a', tokenId: null } + ) + + // Even a lone wallet is offered rather than auto-picked, as the balance + // card's Send button does: + expect(followed).toBe(true) + expect(pickers).toHaveLength(1) + expect(pickers[0].allowedAssets).toBeUndefined() + expect(pickers[0].showCreateWallet).toBeUndefined() + expect(pickers[0].headerTitle).toBe(lstrings.select_wallet_to_send_from) + expect(navigation.push).toHaveBeenCalledTimes(1) + }) + + it('does not navigate when the picker is dismissed', async () => { + const { followed, navigation } = await follow( + { type: 'send', stealth: true }, + makeState({ a: 'bitcoin' }), + undefined + ) + + expect(followed).toBe(false) + expect(navigation.push).not.toHaveBeenCalled() + }) + + it('does not navigate when the narrowed picker is dismissed', async () => { + const { followed, pickers, navigation } = await follow( + { + type: 'send', + asset: { pluginId: 'bitcoin', tokenId: null }, + stealth: true + }, + makeState({ a: 'bitcoin', b: 'bitcoin' }), + undefined + ) + + expect(followed).toBe(false) + expect(pickers).toHaveLength(1) + expect(navigation.push).not.toHaveBeenCalled() + }) + + it('drops a send link that arrives while its picker is open', async () => { + let pick: (result: unknown) => void = () => {} + showSpy.mockImplementation( + async () => + await new Promise(resolve => { + pick = resolve + }) + ) + const state = makeState({ a: 'bitcoin', b: 'bitcoin' }) + const dispatch: any = jest.fn() + const navigation: any = { navigate: jest.fn(), push: jest.fn() } + const btcLink: DeepLink = { + type: 'send', + asset: { pluginId: 'bitcoin', tokenId: null }, + stealth: true + } + + const first = launchDeepLink(navigation, btcLink)(dispatch, () => state) + for (let i = 0; i < 20; i++) await Promise.resolve() + const second = await launchDeepLink(navigation, btcLink)( + dispatch, + () => state + ) + + // A second picker would stack on the first, then push a second scene: + expect(second).toBe(false) + expect(showSpy).toHaveBeenCalledTimes(1) + + pick({ type: 'wallet', walletId: 'b', tokenId: null }) + expect(await first).toBe(true) + expect(navigation.push).toHaveBeenCalledTimes(1) + }) + + it('leaves the stealth param off for a plain send link', async () => { + const { navigation } = await follow( + { + type: 'send', + asset: { pluginId: 'bitcoin', tokenId: null }, + stealth: false + }, + makeState({ a: 'bitcoin' }) + ) + + expect(navigation.push).toHaveBeenCalledWith('send2', { + walletId: 'a', + tokenId: null, + hiddenFeaturesMap: { scamWarning: false }, + stealth: undefined + }) + }) +}) diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index 674a6014c78..1618bd5fb7f 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -8,7 +8,10 @@ import { ButtonsModal } from '../components/modals/ButtonsModal' import { ConfirmContinueModal } from '../components/modals/ConfirmContinueModal' import { FundAccountModal } from '../components/modals/FundAccountModal' import { + ErrorNoMatchingWallets, pickWallet, + WalletListModal, + type WalletListResult, type WalletListWalletResult } from '../components/modals/WalletListModal' import { @@ -117,6 +120,7 @@ export function getDeepLinkReadiness(link: DeepLink): DeepLinkReadiness { case 'paymentRedirect': case 'requestAddress': case 'rewards': + case 'send': case 'walletConnect': return 'wallets' @@ -174,15 +178,17 @@ export function launchDeepLink( link: DeepLink ): ThunkAction> { return async (dispatch, getState) => { - // Only the exchange links raise the back-to-back pickers the drops below - // protect, and they raise them from inside an `affiliate` or `marketing` - // wrapper too, since that hands its inner link to the same `handleLink`. + // Only the exchange links and the send link raise the pickers the drops + // below protect, and they raise them from inside an `affiliate` or + // `marketing` wrapper too, since that hands its inner link to the same + // `handleLink`. // Dropping every link would lose a WalletConnect or payment link that // arrives from another app while one is up, since `DeepLinkingManager` // clears its pending slot before launching and ignores the result, and // those were handled before this guard existed. const innerType = unwrapDeepLink(link).type - const isExchangeLink = innerType === 'rampCreate' || innerType === 'swap' + const isExchangeLink = + innerType === 'rampCreate' || innerType === 'send' || innerType === 'swap' // DeepLinkingManager only launches a link once the app has the state it // needs, but promo card taps call in directly. A link that opens a wallet @@ -429,6 +435,24 @@ async function handleLink( await doRequestAddress(navigation, state.core.account, dispatch, link) break + case 'send': { + const { asset, stealth } = link + const result = await pickSendWallet(account, navigation, asset) + if (result == null) return false + + // The same entry as the balance card's Send button, so the scene shows + // the same first-send scam warning. A link without `stealth=true` leaves + // the param off rather than false, so the scene starts the toggle the + // way it does for any other plain entry. + navigation.push('send2', { + walletId: result.walletId, + tokenId: result.tokenId, + hiddenFeaturesMap: { scamWarning: false }, + stealth: stealth ? true : undefined + }) + break + } + case 'swap': { const { buyAsset, sellAsset, promoId } = link @@ -898,6 +922,44 @@ async function pickLinkedWallet( return result?.type === 'wallet' ? result : null } +/** + * Pick the wallet a send link sends from. A named asset narrows the picker to + * that asset, auto-picking a lone match. With no asset, or none of the user's + * wallets holding it, the user picks from every wallet, the same picker the + * balance card's Send button opens. There are no create-wallet rows: a new + * wallet is empty, so there is nothing to send from it. Returns undefined when + * the user dismissed the picker. + */ +async function pickSendWallet( + account: EdgeAccount, + navigation: Parameters[0]['navigation'], + asset: EdgeAsset | undefined +): Promise { + if (asset != null) { + try { + const result = await pickWallet({ + account, + assets: [asset], + headerTitle: lstrings.select_wallet_to_send_from, + navigation + }) + return result?.type === 'wallet' ? result : undefined + } catch (error: unknown) { + if (!(error instanceof Error) || error.message !== ErrorNoMatchingWallets) + throw error + } + } + + const result = await Airship.show(bridge => ( + + )) + return result?.type === 'wallet' ? result : undefined +} + /** * Release the promo attribution a deep link or promo card set, because the * user left the tab that entry opened without converting. diff --git a/src/docs/exchange-deep-links.md b/src/docs/exchange-deep-links.md index 718dcf52ad4..574af880c90 100644 --- a/src/docs/exchange-deep-links.md +++ b/src/docs/exchange-deep-links.md @@ -26,6 +26,7 @@ Code blocks cite their file on the `jon/deeplink-new-format` branch rather than - [4.3 The link-scoped promo id](#43-the-link-scoped-promo-id) - [4.4 Promo cards as link sources](#44-promo-cards-as-link-sources) - [4.5 Outcomes](#45-outcomes) + - [4.6 The send link](#46-the-send-link) 5. [Testing](#5-testing) 6. [Phase history](#6-phase-history) 7. [Decisions](#7-decisions) @@ -373,9 +374,42 @@ Reachable entry configurations and what each produces. Rows follow from [4.1](#4 | Legacy `edge://swap`, `edge://buy//` | none | unchanged behavior | unchanged | unchanged | | A buy-tab promo held, then an `exchange/swap` link arrives | swap's pickers | swap scene | `{ new, swapTab }` | the swap link's promo | +### 4.6 The send link + +The `send` host opens the Send scene from a wallet the user picks, optionally with Stealth Send already on: + +``` +edge://send[?] +https://deep.edge.app/send[?] +``` + +Both parameters are optional and may appear in either order: + +| Parameter | Meaning | +|---|---| +| `stealth` | `true` turns on the Send scene's Stealth Send toggle for this visit. Any other value, or none, opens a plain send. | +| `asset` | A `[_]` spec, parsed like the `exchange` assets in [4.1](#41-link-grammar-and-parsing). It narrows the wallet picker. A malformed spec is ignored. | + +`handleLink` resolves the wallet in `pickSendWallet`, then pushes `send2` with the wallet, `stealth: true` when the link asked for it, and the same `scamWarning: false` the balance card's Send button passes: + +| Entry | Wallet picker | Lands on | +|---|---|---| +| `send?stealth=true&asset=bitcoin`, exactly one bitcoin wallet | auto-picked, no modal | Send from that wallet, Stealth Send on | +| `send?stealth=true&asset=bitcoin`, several bitcoin wallets | modal narrowed to bitcoin | Send from the picked wallet, Stealth Send on | +| `send?stealth=true&asset=bitcoin`, no bitcoin wallet | every wallet, no create-wallet rows | Send from the picked wallet, Stealth Send on | +| `send?stealth=true` (no asset) | every wallet, the balance card's Send picker | Send from the picked wallet, Stealth Send on | +| `send` or `send?stealth=` | as above | Send, no stealth param | +| Any of the above, picker dismissed | modal, dismissed | nothing, caller unchanged | + +There are no create-wallet rows because a new wallet is empty and has nothing to send. Auto-pick ignores balance: a lone empty wallet opens Send, which shows its Wallet Empty modal. The picker is not narrowed to chains Stealth Send serves. A coin on an unserved chain opens Send with the toggle off and its reason shown. A token on an unserved chain is not refused up front, since a token source always swaps to the chain's coin: the toggle arms, and the first quote after an amount is entered fails and turns it off with a toast. The link takes no `promoId`, since conversion attribution is per tab and Send is not one of the exchange tabs. + +**Name native assets in marketing links.** Stealth Send always pays the recipient the destination chain's native coin, because the Send scene asks every swap-send quote for `toTokenId: null`. A token source therefore pays out the chain's coin rather than the token: a "send USDT privately" link built on `asset=ethereum_` delivers ETH to the recipient. Token payouts arrive with [EdgeApp/edge-react-gui#6253](https://github.com/EdgeApp/edge-react-gui/pull/6253), which changes this paragraph. + +The `send` link raises a picker of its own, so `getDeepLinkReadiness` waits for every wallet before following it, and the in-progress guard of [7.11](#711-only-exchange-links-are-dropped-while-a-picker-is-open) drops a second picker link while its picker is up. + ## 5. Testing -Automated coverage is 128 cases in `src/__tests__/DeepLink.test.ts` (17 of them new for `exchange`), 5 `linkPromo` cases in `src/__tests__/reducers/RootReducer.test.ts`, 19 in `src/__tests__/actions/LinkPromoActions.test.ts`, 7 in `src/__tests__/util/trackingLinkPromo.test.ts`, and 5 in `src/__tests__/hooks/useTabBlur.test.tsx`. +Automated coverage is 135 cases in `src/__tests__/DeepLink.test.ts` (17 of them new for `exchange`, 7 for `send`), 5 `linkPromo` cases in `src/__tests__/reducers/RootReducer.test.ts`, 22 in `src/__tests__/actions/LinkPromoActions.test.ts`, 8 in `src/__tests__/actions/SendLinkActions.test.ts`, 7 in `src/__tests__/util/trackingLinkPromo.test.ts`, and 5 in `src/__tests__/hooks/useTabBlur.test.tsx`. 1. Both URL forms parse to the same link. `https://deep.edge.app/exchange/buy?buyAsset=bitcoin` and `edge://exchange/buy?buyAsset=bitcoin` each produce `rampCreate` with `asset: { pluginId: 'bitcoin', tokenId: null }`. 2. Mainnet assets on token chains. `exchange/sell?sellAsset=ethereum` and `?sellAsset=arbitrum` give [`tokenId`](#tokenid)`: null` on their own plugin id. @@ -563,7 +597,7 @@ Reopen if: a flow gains a step that leaves the tab navigator and still belongs t ### 7.11 Only exchange links are dropped while a picker is open -Chosen: the in-progress guard in `launchDeepLink` applies to `rampCreate` and `swap` links only. It reads the type through `unwrapDeepLink`, so it sees the one an `affiliate` or `marketing` wrapper carries, and it runs on both paths out of the readiness branch, so an `exchange` link that names no asset takes it too. That asset-less link also counts as the latest link when it navigates, so an `exchange` link still waiting for wallets drops rather than opening its picker afterwards. Narrowing the drop by link type only works if the type it reads is the one that reaches `handleLink`, and neither a wrapper's own type nor an asset-less link's lighter readiness changes what that link writes. +Chosen: the in-progress guard in `launchDeepLink` applies to `rampCreate`, `send` and `swap` links only. The `send` link ([4.6](#46-the-send-link)) joined later, since it raises its own picker. It reads the type through `unwrapDeepLink`, so it sees the one an `affiliate` or `marketing` wrapper carries, and it runs on both paths out of the readiness branch, so an `exchange` link that names no asset takes it too. That asset-less link also counts as the latest link when it navigates, so an `exchange` link still waiting for wallets drops rather than opening its picker afterwards. Narrowing the drop by link type only works if the type it reads is the one that reaches `handleLink`, and neither a wrapper's own type nor an asset-less link's lighter readiness changes what that link writes. Rejected: dropping every `'wallets'` link. `walletConnect` and `paymentRedirect` share that readiness, raise no picker of their own, and arrive from another app while the user is mid-flow, which is exactly when the guard is armed. `DeepLinkingManager` clears its pending slot before it launches and ignores the return value, so a dropped link is gone, and both types were handled before this guard existed. diff --git a/src/types/DeepLinkTypes.ts b/src/types/DeepLinkTypes.ts index 26813db179c..a394abdd547 100644 --- a/src/types/DeepLinkTypes.ts +++ b/src/types/DeepLinkTypes.ts @@ -17,6 +17,7 @@ * - promotion: Activate a promotion code * - recovery: Password recovery * - sell: Sell flow entry, optionally pinning a ramp provider / payment type + * - send: Send flow entry, optionally pre-selecting an asset or Stealth Send * - swap: Crypto-to-crypto swap * - x-callback-url: Address request * @@ -186,6 +187,21 @@ export interface RequestAddressLink { payer?: string } +/** + * An entry into the send flow: + * + * edge://send[?stealth=true][&asset=] + * + * The user picks the wallet to send from, narrowed to `asset` when the link + * names one. `stealth` arms the Send scene's Stealth Send toggle for this visit + * only; any value other than "true" leaves it off. + */ +export interface SendLink { + type: 'send' + asset?: EdgeAsset + stealth: boolean +} + /** * An entry into the swap flow: * @@ -248,6 +264,7 @@ export type DeepLink = | PriceChangeLink | PromotionLink | RequestAddressLink + | SendLink | SwapLink | WalletConnectLink | RampCreateLink diff --git a/src/util/DeepLinkParser.ts b/src/util/DeepLinkParser.ts index 9077fc6d00b..8ab01f77300 100644 --- a/src/util/DeepLinkParser.ts +++ b/src/util/DeepLinkParser.ts @@ -322,6 +322,15 @@ function parseEdgeProtocol(url: URL): DeepLink { } } + case 'send': { + const query = parseQuery(url.query) + return { + type: 'send', + asset: parseOptionalAsset(query.asset), + stealth: query.stealth === 'true' + } + } + case 'swap': { return { type: 'swap' } } From 942de9f9b3c9bc4a79d6eafda26115b6694c03e5 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 14 Oct 2026 10:52:39 -0700 Subject: [PATCH 5/6] Add synced settings for the Stealth toggles Two synced account settings, stealthSendEnabled and stealthSwapEnabled, default off, loaded on login, with writers the scenes call on a user tap. --- .../__snapshots__/RootReducer.test.ts.snap | 2 + .../reducers/settingsReducer.test.ts | 68 ++++++++++++++++++- src/actions/SettingsActions.tsx | 43 ++++++++++++ src/reducers/scenes/SettingsReducer.ts | 4 ++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap index dbbc7db1d70..64acdc41b46 100644 --- a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap +++ b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap @@ -156,6 +156,8 @@ exports[`initialState 1`] = ` }, }, "stateProvinceCode": undefined, + "stealthSendEnabled": false, + "stealthSwapEnabled": false, "tokenWarningsShown": [], "userPausedWallets": [], "userPausedWalletsSet": null, diff --git a/src/__tests__/reducers/settingsReducer.test.ts b/src/__tests__/reducers/settingsReducer.test.ts index 8e96d54d32f..3d4fe0450b5 100644 --- a/src/__tests__/reducers/settingsReducer.test.ts +++ b/src/__tests__/reducers/settingsReducer.test.ts @@ -1,9 +1,19 @@ -import { expect, test } from '@jest/globals' +import { describe, expect, jest, test } from '@jest/globals' +import { makeMemoryDisklet } from 'disklet' +import type { EdgeAccount } from 'edge-core-js' +import { + asSyncedAccountSettings, + readSyncedSettings, + setStealthSendEnabled, + setStealthSwapEnabled +} from '../../actions/SettingsActions' import { initialState, settings as settingsReducer } from '../../reducers/scenes/SettingsReducer' +import type { Dispatch, GetState, ThunkAction } from '../../types/reduxTypes' +import { asLocalAccountSettings } from '../../types/types' test('initialState', () => { const expected = initialState @@ -13,3 +23,59 @@ test('initialState', () => { expect(actual).toEqual(expected) }) + +describe('saved Stealth toggles', () => { + test('default to off, including over a malformed value', () => { + const blank = asSyncedAccountSettings({}) + expect(blank.stealthSendEnabled).toBe(false) + expect(blank.stealthSwapEnabled).toBe(false) + + const malformed = asSyncedAccountSettings({ + stealthSendEnabled: 'yes', + stealthSwapEnabled: 1 + }) + expect(malformed.stealthSendEnabled).toBe(false) + expect(malformed.stealthSwapEnabled).toBe(false) + }) + + test('load on LOGIN', () => { + const state = settingsReducer(initialState, { + type: 'LOGIN', + data: { + account: {} as unknown as EdgeAccount, + syncedSettings: asSyncedAccountSettings({ + stealthSendEnabled: true, + stealthSwapEnabled: false + }), + localSettings: asLocalAccountSettings({}) + } + }) + + expect(state.stealthSendEnabled).toBe(true) + expect(state.stealthSwapEnabled).toBe(false) + }) + + test('round trip through the synced settings file, each on its own', async () => { + const account = { + disklet: makeMemoryDisklet() + } as unknown as EdgeAccount + const dispatch = jest.fn() + const run = async (thunk: ThunkAction>): Promise => { + await thunk(dispatch as unknown as Dispatch, jest.fn()) + } + + await run(setStealthSendEnabled(account, true)) + let saved = await readSyncedSettings(account) + expect(saved.stealthSendEnabled).toBe(true) + expect(saved.stealthSwapEnabled).toBe(false) + + await run(setStealthSwapEnabled(account, true)) + await run(setStealthSendEnabled(account, false)) + saved = await readSyncedSettings(account) + expect(saved.stealthSendEnabled).toBe(false) + expect(saved.stealthSwapEnabled).toBe(true) + + // Each write also lands in the store through `updateOneSetting`: + expect(dispatch).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/actions/SettingsActions.tsx b/src/actions/SettingsActions.tsx index 9234f62e20a..547352cf048 100644 --- a/src/actions/SettingsActions.tsx +++ b/src/actions/SettingsActions.tsx @@ -321,6 +321,47 @@ export const setRampCryptoSelection = dispatch(updateOneSetting({ rampLastCryptoSelection })) } +/** + * Save the Send scene's Stealth Send toggle. Only a user tap calls this: a + * navigation param or deep link arms the toggle for one visit and leaves the + * saved value alone. + */ +export const setStealthSendEnabled = + ( + account: EdgeAccount, + stealthSendEnabled: boolean + ): ThunkAction> => + async dispatch => { + const settings = await readSyncedSettings(account) + const updatedSettings: SyncedAccountSettings = { + ...settings, + stealthSendEnabled + } + + await writeSyncedSettings(account, updatedSettings) + dispatch(updateOneSetting({ stealthSendEnabled })) + } + +/** + * Save the swap scene's Stealth Swap toggle, on a user tap only, like + * `setStealthSendEnabled`. + */ +export const setStealthSwapEnabled = + ( + account: EdgeAccount, + stealthSwapEnabled: boolean + ): ThunkAction> => + async dispatch => { + const settings = await readSyncedSettings(account) + const updatedSettings: SyncedAccountSettings = { + ...settings, + stealthSwapEnabled + } + + await writeSyncedSettings(account, updatedSettings) + dispatch(updateOneSetting({ stealthSwapEnabled })) + } + export const asPasswordReminderLevels = asObject({ '20': asMaybe(asBoolean, false), '200': asMaybe(asBoolean, false), @@ -375,6 +416,8 @@ export const asSyncedAccountSettings = asObject({ stateProvinceCode: asMaybe(asString), rampLastFiatCurrencyCode: asMaybe(asString), rampLastCryptoSelection: asMaybe(asRampLastCryptoSelection), + stealthSendEnabled: asMaybe(asBoolean, false), + stealthSwapEnabled: asMaybe(asBoolean, false), mostRecentWallets: asMaybe(asArray(asMostRecentWallet), () => []), passwordRecoveryRemindersShown: asMaybe(asPasswordReminderLevels, () => asPasswordReminderLevels({}) diff --git a/src/reducers/scenes/SettingsReducer.ts b/src/reducers/scenes/SettingsReducer.ts index f75ea3af39d..569c23b6a68 100644 --- a/src/reducers/scenes/SettingsReducer.ts +++ b/src/reducers/scenes/SettingsReducer.ts @@ -56,6 +56,8 @@ export const settingsLegacy = ( preferredSwapPluginType, securityCheckedWallets, stateProvinceCode, + stealthSendEnabled, + stealthSwapEnabled, userPausedWallets, walletsSort, rampLastFiatCurrencyCode, @@ -89,6 +91,8 @@ export const settingsLegacy = ( settingsLoaded: true, stateProvinceCode, spamFilterOn, + stealthSendEnabled, + stealthSwapEnabled, walletsSort, rampLastFiatCurrencyCode, rampLastCryptoSelection From bb268211a6c3c4948d44b58b46c756f7797a3343 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 14 Oct 2026 10:54:24 -0700 Subject: [PATCH 6/6] Remember the Stealth toggle choices The Send and Swap scenes start their Stealth toggles from the saved per-account choice and save each accepted tap. The stealth navigation param still sets the toggle for one visit and never writes the saved value; Send re-applies the visit choice after a wallet switch. --- CHANGELOG.md | 4 + maestro/14-stealth/README.md | 10 +- .../stealth-below-private-minimum.yaml | 8 + .../14-stealth/stealth-cross-asset-quote.yaml | 5 + maestro/14-stealth/stealth-execute-send.yaml | 2 + maestro/14-stealth/stealth-execute-swap.yaml | 1 + maestro/14-stealth/stealth-myself-picker.yaml | 5 + maestro/14-stealth/stealth-private-quote.yaml | 7 + .../14-stealth/stealth-qr-payment-uri.yaml | 5 + maestro/14-stealth/stealth-send.yaml | 9 + maestro/14-stealth/stealth-swap.yaml | 7 + maestro/common/stealth-toggle.yaml | 39 ++- src/__tests__/scenes/SendScene2.ui.test.tsx | 286 +++++++++++++++++- src/__tests__/scenes/SwapCreateScene.test.tsx | 137 +++++++++ src/components/scenes/SendScene2.tsx | 75 ++++- src/components/scenes/SwapCreateScene.tsx | 42 ++- src/docs/exchange-deep-links.md | 16 +- src/docs/stealth-send-swap.md | 6 + 18 files changed, 630 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59077a1396a..dffb8462fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ - added: MoonPay Trade swap provider - added: Per-provider swap asset disables from the info server - added: Copy button for the master private key or seed, cleared from the clipboard after one minute +- added: `stealth` navigation param that presets the Stealth Swap and Stealth Send toggles +- added: `edge://send` deep link that opens Send, optionally with Stealth Send on - changed: Re-enable Pirate Chain on Android, using the smaller binary that downloads its Sapling parameters - changed: Standardize wallet list automation test IDs to use period separators. - changed: Lock the send confirmation slider for the rest of the scene once a broadcast has been attempted, whether the broadcast reported success or failure, and replace the generic failure card with a message that the transaction may have gone through, pointing at the block explorer or confirmation email before trying again. @@ -44,6 +46,8 @@ - changed: WalletConnect Smart Contract Call warning restated in the solid UI4 card - changed: WalletConnect Connect button pinned to the bottom of Confirm Connection - changed: Hide the Stealth Send card and Recipient receives row on sends with multiple recipients +- changed: Stealth Swap toggle shows on the swap scene before assets are selected +- changed: Stealth Send and Stealth Swap toggles remember the last setting per account - fixed: Paybis and Simplex going missing from buy and sell on the launch after a slow or retried device attestation - fixed: Missing wrong-device-clock warning on a launch that runs no device attestation - fixed: Missing exchange rates when the device clock is set ahead diff --git a/maestro/14-stealth/README.md b/maestro/14-stealth/README.md index 663500c2810..9343eb455d5 100644 --- a/maestro/14-stealth/README.md +++ b/maestro/14-stealth/README.md @@ -113,11 +113,19 @@ private quote on a pair the suite does not cover is five `runFlow` steps: AMOUNT: '30' - runFlow: file: ../common/stealth-toggle.yaml + env: + STATE: 'on' - runFlow: file: ../common/stealth-await-quote.yaml ``` -Two things bite when writing these by hand: +Three things bite when writing these by hand: + +- **The Stealth toggles are remembered per account** (synced settings), so a + scene can open with Stealth already on from an earlier run or another + device. `stealth-toggle.yaml` takes `STATE: on|off` and taps only when the + card is in the other state, so call it with an explicit state rather than + assuming the scene opens off. - **The confirm slider is a pan gesture.** A coordinate swipe across the track does nothing at all. `stealth-slide-to-confirm.yaml` swipes from the thumb by diff --git a/maestro/14-stealth/stealth-below-private-minimum.yaml b/maestro/14-stealth/stealth-below-private-minimum.yaml index 52cf9f8b560..b740cfbcf3e 100644 --- a/maestro/14-stealth/stealth-below-private-minimum.yaml +++ b/maestro/14-stealth/stealth-below-private-minimum.yaml @@ -24,6 +24,11 @@ tags: env: WALLET: ${SRC_WALLET} WALLET_CODE: ${SRC_CODE} +# The toggle is remembered per account; this walk needs it off. +- runFlow: + file: ../common/stealth-toggle.yaml + env: + STATE: 'off' - runFlow: file: ../common/stealth-pick-myself.yaml env: @@ -37,7 +42,10 @@ tags: - runFlow: file: ../common/stealth-await-quote.yaml +# Asking for on taps, since the explainer is hidden, and the tap is refused. - runFlow: file: ../common/stealth-toggle.yaml + env: + STATE: 'on' - assertVisible: 'Stealth routing needs at least.*' - takeScreenshot: maestro/screenshots/stealth-below-min-01-toggle-refused diff --git a/maestro/14-stealth/stealth-cross-asset-quote.yaml b/maestro/14-stealth/stealth-cross-asset-quote.yaml index 86f62557b28..529980249d2 100644 --- a/maestro/14-stealth/stealth-cross-asset-quote.yaml +++ b/maestro/14-stealth/stealth-cross-asset-quote.yaml @@ -21,6 +21,11 @@ tags: env: WALLET: ${SRC_WALLET} WALLET_CODE: ${SRC_CODE} +# The toggle is remembered per account; this walk needs it off. +- runFlow: + file: ../common/stealth-toggle.yaml + env: + STATE: 'off' - runFlow: file: ../common/stealth-pick-myself.yaml env: diff --git a/maestro/14-stealth/stealth-execute-send.yaml b/maestro/14-stealth/stealth-execute-send.yaml index 97deed4fdb4..310e5963b73 100644 --- a/maestro/14-stealth/stealth-execute-send.yaml +++ b/maestro/14-stealth/stealth-execute-send.yaml @@ -37,6 +37,8 @@ tags: AMOUNT: ${AMOUNT} - runFlow: file: ../common/stealth-toggle.yaml + env: + STATE: 'on' - runFlow: file: ../common/stealth-await-quote.yaml env: diff --git a/maestro/14-stealth/stealth-execute-swap.yaml b/maestro/14-stealth/stealth-execute-swap.yaml index 67378d89f2e..5a02b0e9766 100644 --- a/maestro/14-stealth/stealth-execute-swap.yaml +++ b/maestro/14-stealth/stealth-execute-swap.yaml @@ -60,6 +60,7 @@ tags: file: ../common/stealth-toggle.yaml env: TOGGLE: 'Stealth Swap' + STATE: 'on' - tapOn: text: 'Tap to edit' index: 0 diff --git a/maestro/14-stealth/stealth-myself-picker.yaml b/maestro/14-stealth/stealth-myself-picker.yaml index 194da4877c0..3c4253c7ad4 100644 --- a/maestro/14-stealth/stealth-myself-picker.yaml +++ b/maestro/14-stealth/stealth-myself-picker.yaml @@ -20,6 +20,11 @@ tags: env: WALLET: ${SRC_WALLET} WALLET_CODE: ${SRC_CODE} +# The toggle is remembered per account; this walk needs it off. +- runFlow: + file: ../common/stealth-toggle.yaml + env: + STATE: 'off' - tapOn: id: 'addressTileMyself' - waitForAnimationToEnd: diff --git a/maestro/14-stealth/stealth-private-quote.yaml b/maestro/14-stealth/stealth-private-quote.yaml index 99c4a36d09d..e1ecb98ab7f 100644 --- a/maestro/14-stealth/stealth-private-quote.yaml +++ b/maestro/14-stealth/stealth-private-quote.yaml @@ -25,6 +25,11 @@ tags: env: WALLET: ${SRC_WALLET} WALLET_CODE: ${SRC_CODE} +# The toggle is remembered per account; this walk needs it off. +- runFlow: + file: ../common/stealth-toggle.yaml + env: + STATE: 'off' - runFlow: file: ../common/stealth-pick-myself.yaml env: @@ -40,6 +45,8 @@ tags: - runFlow: file: ../common/stealth-toggle.yaml + env: + STATE: 'on' - takeScreenshot: maestro/screenshots/stealth-private-01-requoting - runFlow: file: ../common/stealth-await-quote.yaml diff --git a/maestro/14-stealth/stealth-qr-payment-uri.yaml b/maestro/14-stealth/stealth-qr-payment-uri.yaml index 02ad566ca5b..8b201a95dab 100644 --- a/maestro/14-stealth/stealth-qr-payment-uri.yaml +++ b/maestro/14-stealth/stealth-qr-payment-uri.yaml @@ -22,6 +22,11 @@ tags: env: WALLET: ${SRC_WALLET} WALLET_CODE: ${SRC_CODE} +# The toggle is remembered per account; this walk needs it off. +- runFlow: + file: ../common/stealth-toggle.yaml + env: + STATE: 'off' # Cross-asset destination: the source wallet cannot parse this chain's URI. - runFlow: diff --git a/maestro/14-stealth/stealth-send.yaml b/maestro/14-stealth/stealth-send.yaml index 55f8ec8cf1f..374388b1ca2 100644 --- a/maestro/14-stealth/stealth-send.yaml +++ b/maestro/14-stealth/stealth-send.yaml @@ -22,6 +22,11 @@ tags: WALLET_CODE: ${SRC_CODE} # Plain same-asset start: the recipient selector is available before an address. +# The toggle is remembered per account, so it is forced off first. +- runFlow: + file: ../common/stealth-toggle.yaml + env: + STATE: 'off' - assertVisible: 'Stealth Send' - assertVisible: 'Recipient receives' - takeScreenshot: maestro/screenshots/stealth-send-01-initial @@ -29,10 +34,14 @@ tags: # Stealth on: the toggle card expands with the explainer and Learn more link. - runFlow: file: ../common/stealth-toggle.yaml + env: + STATE: 'on' - assertVisible: 'Uses a route that helps obfuscate.*Learn more.*' - takeScreenshot: maestro/screenshots/stealth-send-02-stealth-on - runFlow: file: ../common/stealth-toggle.yaml + env: + STATE: 'off' # A memo-required destination chain adds its tag row. - runFlow: diff --git a/maestro/14-stealth/stealth-swap.yaml b/maestro/14-stealth/stealth-swap.yaml index 363fde15f1a..354c0e8852e 100644 --- a/maestro/14-stealth/stealth-swap.yaml +++ b/maestro/14-stealth/stealth-swap.yaml @@ -15,6 +15,12 @@ tags: env: WALLET: ${SRC_WALLET} WALLET_CODE: ${SRC_CODE} +# The toggle is remembered per account, so it is forced off for the start. +- runFlow: + file: ../common/stealth-toggle.yaml + env: + TOGGLE: 'Stealth Swap' + STATE: 'off' - assertVisible: 'Stealth Swap' - takeScreenshot: maestro/screenshots/stealth-swap-01-start @@ -22,5 +28,6 @@ tags: file: ../common/stealth-toggle.yaml env: TOGGLE: 'Stealth Swap' + STATE: 'on' - assertVisible: 'Routes your swap through multiple.*Learn more.*' - takeScreenshot: maestro/screenshots/stealth-swap-02-stealth-on diff --git a/maestro/common/stealth-toggle.yaml b/maestro/common/stealth-toggle.yaml index 4377beb004f..c975be4fd17 100644 --- a/maestro/common/stealth-toggle.yaml +++ b/maestro/common/stealth-toggle.yaml @@ -1,7 +1,15 @@ -# Flips the Stealth toggle. +# Sets the Stealth toggle to a requested state. # # env TOGGLE "Stealth Send" on the Send scene, "Stealth Swap" on the Exchange # scene. Defaults to the Send scene's label. +# env STATE "on" or "off". Defaults to "on". +# +# The toggles are remembered per account, so a scene can open with Stealth +# already on from an earlier run. The helper reads the current state from the +# explainer text the card shows only while armed, and taps only when that state +# differs from STATE. A tap the scene refuses (below the private floor, an +# asset with no private route) leaves the explainer hidden and shows the reason +# instead, which callers can assert. # # Toggling always invalidates any held quote and refetches, because the private # and transparent routes are different requests, so callers that need a live @@ -9,15 +17,19 @@ appId: ${APP_ID} env: APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} - # Declared under a different name than the one callers pass: a subflow env + # Declared under different names than the ones callers pass: a subflow env # entry that reuses the caller's key overwrites what the caller passed, so # `TOGGLE: ${TOGGLE || default}` would silently ignore the argument. TOGGLE_LABEL: ${TOGGLE || 'Stealth Send'} + TARGET: ${STATE || 'on'} + # The explainer text each card shows only while armed: + INFO: "${(TOGGLE || 'Stealth Send') == 'Stealth Swap' ? 'Routes your swap through multiple.*' : 'Uses a route that helps obfuscate.*'}" --- # Rows can sit above or below the fold depending on where the previous step # left the scroll position, so the row is brought into view from either side -# before it is tapped. A `scrollUntilVisible` that is already satisfied is a -# no-op, and one that scrolls the wrong way is optional and warns. +# before it is read. A `scrollUntilVisible` that is already satisfied is a +# no-op, and one that scrolls the wrong way is optional and warns. Centering +# the row also brings the explainer below it into view. - scrollUntilVisible: element: text: '${TOGGLE_LABEL}' @@ -32,6 +44,19 @@ env: timeout: 4000 centerElement: true optional: true -- tapOn: '${TOGGLE_LABEL}' -- waitForAnimationToEnd: - timeout: 2500 +- runFlow: + when: + notVisible: '${INFO}' + true: ${TARGET == 'on'} + commands: + - tapOn: '${TOGGLE_LABEL}' + - waitForAnimationToEnd: + timeout: 2500 +- runFlow: + when: + visible: '${INFO}' + true: ${TARGET == 'off'} + commands: + - tapOn: '${TOGGLE_LABEL}' + - waitForAnimationToEnd: + timeout: 2500 diff --git a/src/__tests__/scenes/SendScene2.ui.test.tsx b/src/__tests__/scenes/SendScene2.ui.test.tsx index 104c5362902..038526b337a 100644 --- a/src/__tests__/scenes/SendScene2.ui.test.tsx +++ b/src/__tests__/scenes/SendScene2.ui.test.tsx @@ -1,5 +1,13 @@ -import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals' -import { render } from '@testing-library/react-native' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + jest +} from '@jest/globals' +import { act, fireEvent, render } from '@testing-library/react-native' import { asDate, asObject, asOptional, asString, asUnknown } from 'cleaners' import { addEdgeCorePlugins, @@ -10,11 +18,13 @@ import { makeFakeEdgeWorld } from 'edge-core-js' import * as React from 'react' +import { sprintf } from 'sprintf-js' import { SendScene2, type SendScene2Params } from '../../components/scenes/SendScene2' +import { Airship } from '../../components/services/AirshipInstance' import { lstrings } from '../../locales/strings' import { avaxCurrencyInfo } from '../../util/fake/fakeAvaxInfo' import { btcCurrencyInfo } from '../../util/fake/fakeBtcInfo' @@ -24,13 +34,44 @@ import { FakeProviders, type FakeState } from '../../util/fake/FakeProviders' import { fakeRootState } from '../../util/fake/fakeRootState' import { fakeEdgeAppSceneProps } from '../../util/fake/fakeSceneProps' import fakeUser from '../../util/fake/fakeUserDump.json' +import type * as HoudiniChains from '../../util/houdiniChains' jest.useRealTimers() +const mockSetStealthSendEnabled = jest.fn() +jest.mock('../../actions/SettingsActions', () => ({ + ...jest.requireActual('../../actions/SettingsActions'), + setStealthSendEnabled: (...args: unknown[]) => { + mockSetStealthSendEnabled(...args) + return async () => {} + } +})) + +/** + * Chains whose `getHoudiniChain` entry reports no private route to itself, so + * a test can make a wallet ineligible for a same-asset Stealth Send. + */ +const mockNoSelfPrivate = new Set() +jest.mock('../../util/houdiniChains', () => { + const actual = jest.requireActual( + '../../util/houdiniChains' + ) + return { + ...actual, + getHoudiniChain: (...args: Parameters) => { + const chain = actual.getHoudiniChain(...args) + return chain != null && mockNoSelfPrivate.has(chain.pluginId) + ? { ...chain, hasSelfPrivate: false } + : chain + } + } +}) + let context: EdgeContext | undefined let account: EdgeAccount | undefined let btcWallet: EdgeCurrencyWallet | undefined +let ethWallet: EdgeCurrencyWallet | undefined // For use later when we need tests that use EVM currencies // let ethWallet: EdgeCurrencyWallet | undefined @@ -87,6 +128,11 @@ beforeAll(async () => { } btcWallet = await account.waitForCurrencyWallet(btcInfo.id) + const ethInfo = account.getFirstWalletInfo('wallet:ethereum') + if (ethInfo != null) { + ethWallet = await account.waitForCurrencyWallet(ethInfo.id) + } + // For use later when we need tests that use EVM currencies // ethWallet = await account.waitForCurrencyWallet(ethInfo.id) // avaxWallet = await account.waitForCurrencyWallet(avaxInfo.id) @@ -445,6 +491,242 @@ describe('SendScene2', () => { }) }) +describe('SendScene2 saved Stealth Send choice', () => { + beforeEach(() => { + mockSetStealthSendEnabled.mockReset() + mockNoSelfPrivate.clear() + }) + + /** A root state whose account saved `stealthSendEnabled`. */ + const savedState = (stealthSendEnabled: boolean): FakeState => ({ + ...fakeRootState, + core: { account }, + ui: { + ...fakeRootState.ui, + settings: { ...fakeRootState.ui?.settings, stealthSendEnabled } + } + }) + + const renderSend = ( + state: FakeState, + params: Partial = {} + ): ReturnType => + render( + + + + ) + + it('arms Stealth Send from the saved choice', () => { + if (btcWallet == null) return + + const rendered = renderSend(savedState(true)) + + expect( + rendered.getByText(lstrings.stealth_send_info, { exact: false }) + ).toBeTruthy() + // Seeding is not a tap, so it saves nothing: + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + rendered.unmount() + }) + + it('arms nothing from the saved choice on an address-prefilled send', () => { + if (btcWallet == null) return + + const rendered = renderSend(savedState(true), { + spendInfo: { + tokenId: null, + spendTargets: [{ publicAddress: 'some pub address' }] + } + }) + + expect(rendered.queryByText(lstrings.stealth_send_toggle)).toBeNull() + expect( + rendered.queryByText(lstrings.stealth_send_info, { exact: false }) + ).toBeNull() + rendered.unmount() + }) + + it('lets the stealth param override the saved choice without saving it', () => { + if (btcWallet == null) return + + const armed = renderSend(savedState(false), { stealth: true }) + expect( + armed.getByText(lstrings.stealth_send_info, { exact: false }) + ).toBeTruthy() + armed.unmount() + + const off = renderSend(savedState(true), { stealth: false }) + expect( + off.queryByText(lstrings.stealth_send_info, { exact: false }) + ).toBeNull() + off.unmount() + + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + }) + + it('saves the choice a tap makes', () => { + if (btcWallet == null) return + + const rendered = renderSend(savedState(false)) + + fireEvent.press(rendered.getByText(lstrings.stealth_send_toggle)) + expect( + rendered.getByText(lstrings.stealth_send_info, { exact: false }) + ).toBeTruthy() + expect(mockSetStealthSendEnabled).toHaveBeenLastCalledWith(account, true) + + fireEvent.press(rendered.getByText(lstrings.stealth_send_toggle)) + expect( + rendered.queryByText(lstrings.stealth_send_info, { exact: false }) + ).toBeNull() + expect(mockSetStealthSendEnabled).toHaveBeenLastCalledWith(account, false) + expect(mockSetStealthSendEnabled).toHaveBeenCalledTimes(2) + rendered.unmount() + }) + + /** Switch the scene's source wallet, or its asset, through its picker. */ + const switchWallet = async ( + rendered: ReturnType, + walletId: string, + tokenId: string | null = null + ): Promise => { + const showSpy = jest + .spyOn(Airship, 'show') + .mockResolvedValue({ type: 'wallet', walletId, tokenId }) + await act(async () => { + fireEvent.press(rendered.getByText(lstrings.send_scene_send_from_wallet)) + }) + showSpy.mockRestore() + } + + it('re-seeds from the saved choice after a wallet change', async () => { + if (btcWallet == null || ethWallet == null) return + + const rendered = renderSend(savedState(true)) + await switchWallet(rendered, ethWallet.id) + + // The switch resets the toggle, then the saved choice arms it again on + // the new wallet: + expect(rendered.getByText(/^My Ethereum \(ETH\)$/)).toBeTruthy() + expect( + rendered.getByText(lstrings.stealth_send_info, { exact: false }) + ).toBeTruthy() + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + rendered.unmount() + }) + + it('keeps a tap made on this visit across a wallet change', async () => { + if (btcWallet == null || ethWallet == null) return + + // The mocked writer leaves the store's saved value at true, so only the + // visit's own record of the tap can keep the toggle off: + const rendered = renderSend(savedState(true)) + fireEvent.press(rendered.getByText(lstrings.stealth_send_toggle)) + await switchWallet(rendered, ethWallet.id) + + expect(rendered.getByText(/^My Ethereum \(ETH\)$/)).toBeTruthy() + expect( + rendered.queryByText(lstrings.stealth_send_info, { exact: false }) + ).toBeNull() + rendered.unmount() + }) + describe('on a wallet that cannot go private', () => { + const USDT_TOKEN_ID = 'dac17f958d2ee523a2206206994597c13d831ec7' + + /** The reason the card shows in place of the info text. */ + const reasonFor = (currencyCode: string): string => + sprintf(lstrings.stealth_self_private_unsupported_1s, currencyCode) + + const infoShown = (rendered: ReturnType): boolean => + rendered.queryByText(lstrings.stealth_send_info, { exact: false }) != null + + it('opens off with its reason, without a toast or a save', () => { + if (btcWallet == null) return + mockNoSelfPrivate.add('bitcoin') + const showSpy = jest.spyOn(Airship, 'show') + + const rendered = renderSend(savedState(true)) + + expect(rendered.getByText(lstrings.stealth_send_toggle)).toBeTruthy() + expect(infoShown(rendered)).toBe(false) + expect(rendered.getByText(reasonFor('BTC'))).toBeTruthy() + // Seeding skips a wallet it cannot arm, rather than tapping and being + // refused: + expect(showSpy).not.toHaveBeenCalled() + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + showSpy.mockRestore() + rendered.unmount() + }) + + it('toasts a refused tap and saves nothing', () => { + if (btcWallet == null) return + mockNoSelfPrivate.add('bitcoin') + const showSpy = jest.spyOn(Airship, 'show').mockResolvedValue(undefined) + + const rendered = renderSend(savedState(false)) + fireEvent.press(rendered.getByText(lstrings.stealth_send_toggle)) + + expect(showSpy).toHaveBeenCalledTimes(1) + expect(infoShown(rendered)).toBe(false) + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + showSpy.mockRestore() + rendered.unmount() + }) + + it('arms from the saved choice on the next wallet that can', async () => { + if (btcWallet == null || ethWallet == null) return + mockNoSelfPrivate.add('bitcoin') + + const rendered = renderSend(savedState(true)) + expect(infoShown(rendered)).toBe(false) + await switchWallet(rendered, ethWallet.id) + + expect(rendered.getByText(/^My Ethereum \(ETH\)$/)).toBeTruthy() + expect(infoShown(rendered)).toBe(true) + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + rendered.unmount() + }) + + it('arms from the stealth param on the next wallet that can', async () => { + if (btcWallet == null || ethWallet == null) return + mockNoSelfPrivate.add('bitcoin') + + const rendered = renderSend(savedState(false), { stealth: true }) + expect(infoShown(rendered)).toBe(false) + expect(rendered.getByText(reasonFor('BTC'))).toBeTruthy() + await switchWallet(rendered, ethWallet.id) + + expect(infoShown(rendered)).toBe(true) + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + rendered.unmount() + }) + + it('re-seeds on a token switch within one wallet', async () => { + if (ethWallet == null) return + // ETH to itself is refused, while a token source swaps to the chain's + // coin and can go private: + mockNoSelfPrivate.add('ethereum') + + const rendered = renderSend(savedState(true), { walletId: ethWallet.id }) + expect(infoShown(rendered)).toBe(false) + expect(rendered.getByText(reasonFor('ETH'))).toBeTruthy() + await switchWallet(rendered, ethWallet.id, USDT_TOKEN_ID) + + expect(infoShown(rendered)).toBe(true) + expect(mockSetStealthSendEnabled).not.toHaveBeenCalled() + rendered.unmount() + }) + }) +}) + afterAll(async () => { await context?.close() }) diff --git a/src/__tests__/scenes/SwapCreateScene.test.tsx b/src/__tests__/scenes/SwapCreateScene.test.tsx index 2cefe8324f6..e1c0d6e874c 100644 --- a/src/__tests__/scenes/SwapCreateScene.test.tsx +++ b/src/__tests__/scenes/SwapCreateScene.test.tsx @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -81,6 +82,24 @@ afterAll(async () => { await context?.close() }) +const mockSetStealthSwapEnabled = jest.fn() +jest.mock('../../actions/SettingsActions', () => ({ + ...jest.requireActual('../../actions/SettingsActions'), + setStealthSwapEnabled: (...args: unknown[]) => { + mockSetStealthSwapEnabled(...args) + return async () => {} + } +})) + +/** A root state whose account saved `stealthSwapEnabled`. */ +const savedState = (stealthSwapEnabled: boolean): FakeState => ({ + ...fakeRootState, + ui: { + ...fakeRootState.ui, + settings: { ...fakeRootState.ui?.settings, stealthSwapEnabled } + } +}) + describe('SwapCreateScene', () => { afterEach(() => { jest.restoreAllMocks() @@ -101,6 +120,7 @@ describe('SwapCreateScene', () => { it('asks for an ordinary swap after the Stealth terms are declined', async () => { if (btcWallet == null || ethWallet == null) throw new Error('No wallets') + mockSetStealthSwapEnabled.mockReset() const rootState: FakeState = { ...fakeRootState, core: { account } } const navigate = jest .spyOn(fakeNavigation, 'navigate') @@ -156,6 +176,9 @@ describe('SwapCreateScene', () => { expect(nextParams).not.toBe(stealthParams) expect(nextParams.swapRequest.privacy).toBeUndefined() expect(nextParams.swapRequestOptions.forceEnabled).toBeUndefined() + // Only the tap saves. The decline turns the toggle off for this visit: + expect(mockSetStealthSwapEnabled).toHaveBeenCalledTimes(1) + expect(mockSetStealthSwapEnabled.mock.calls[0][1]).toBe(true) rendered.unmount() }) @@ -195,4 +218,118 @@ describe('SwapCreateScene', () => { // The fake navigation is shared by every test in the file: setParams.mockRestore() }) + + describe('saved Stealth Swap choice', () => { + beforeEach(() => { + mockSetStealthSwapEnabled.mockReset() + }) + + const infoShown = (rendered: ReturnType): boolean => + rendered.queryByText(lstrings.stealth_swap_info, { exact: false }) != null + + it('starts from the saved choice', () => { + const on = render( + + + + ) + expect(infoShown(on)).toBe(true) + on.unmount() + + const off = render( + + + + ) + expect(infoShown(off)).toBe(false) + off.unmount() + }) + + it('lets the stealth param override the saved choice without saving it', () => { + const rendered = render( + + + + ) + + expect(infoShown(rendered)).toBe(false) + expect(mockSetStealthSwapEnabled).not.toHaveBeenCalled() + rendered.unmount() + }) + + it('saves the choice a tap makes', () => { + const rendered = render( + + + + ) + + fireEvent.press(rendered.getByText(lstrings.stealth_swap_toggle)) + expect(infoShown(rendered)).toBe(true) + expect(mockSetStealthSwapEnabled).toHaveBeenCalledTimes(1) + expect(mockSetStealthSwapEnabled.mock.calls[0][1]).toBe(true) + rendered.unmount() + }) + + it('drops the param override when the user leaves the swap tab', () => { + // A tab navigator that has moved to another tab, with its blur handler + // captured so the test can fire it: + let leaveTab: () => void = () => {} + const sceneProps = fakeSwapTabSceneProps('swapCreate', { stealth: true }) + const getParent = jest + .spyOn(sceneProps.navigation, 'getParent') + .mockReturnValue({ + addListener: (event: string, callback: () => void) => { + leaveTab = callback + return () => {} + }, + getState: () => ({ index: 0, routes: [{ name: 'walletsTab' }] }) + }) + + const rendered = render( + + + + ) + expect(infoShown(rendered)).toBe(true) + + act(() => { + leaveTab() + }) + expect(infoShown(rendered)).toBe(false) + expect(mockSetStealthSwapEnabled).not.toHaveBeenCalled() + rendered.unmount() + // The fake navigation is shared by every test in the file: + getParent.mockRestore() + }) + + it('applies a stealth param that arrives after the tab mounted', () => { + // The swap tab stays mounted, so a link reaches it as new params on a + // scene that already rendered with none: + const sceneProps = fakeSwapTabSceneProps('swapCreate', {}) + const setParams = jest.spyOn(sceneProps.navigation, 'setParams') + + const rendered = render( + + + + ) + expect(infoShown(rendered)).toBe(false) + + rendered.rerender( + + + + ) + expect(infoShown(rendered)).toBe(true) + expect(setParams).toHaveBeenCalledWith({ stealth: undefined }) + expect(mockSetStealthSwapEnabled).not.toHaveBeenCalled() + rendered.unmount() + setParams.mockRestore() + }) + }) }) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index f7793eac44f..f1ebdc3e40f 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -36,6 +36,7 @@ import { addressWarnings, checkAndShowGetCryptoModal } from '../../actions/ScanActions' +import { setStealthSendEnabled } from '../../actions/SettingsActions' import { playSendSound } from '../../actions/SoundActions' import { showSwapSendWarningModal } from '../../actions/SwapSendWarningActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' @@ -236,7 +237,8 @@ export interface SendScene2Params { // Useful to disable during for test runtime doCheckAndShowGetCryptoModal?: boolean /** - * Arms the Stealth Send toggle on entry. Ignored wherever a tap on the + * Sets the Stealth Send toggle for this visit, in place of the account's + * saved choice. Never saved itself. Arming is ignored wherever a tap on the * toggle would be refused, or where the toggle is not offered at all. */ stealth?: boolean @@ -303,7 +305,7 @@ const SendComponent: React.FC = props => { beforeTransaction, alternateBroadcast, doCheckAndShowGetCryptoModal = true, - stealth: initStealth = false + stealth: stealthParam } = route.params const openCameraRef = React.useRef(openCamera) @@ -414,6 +416,9 @@ const SendComponent: React.FC = props => { const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) + const stealthSendEnabled = useSelector( + state => state.ui.settings.stealthSendEnabled + ) const exchangeRates = useSelector( state => state.exchangeRates ) @@ -1722,7 +1727,8 @@ const SendComponent: React.FC = props => { // Stealth toggle and the learned // route capabilities go too: all describe the pair and wallet the user // just left, so carrying them over produces auto-disables and errors - // the user cannot connect to anything they did. + // the user cannot connect to anything they did. The toggle is then + // seeded afresh for the new pair, once a render describes it. if (walletChanged || assetChanged) { setRecipientPluginId(undefined) setDestinationTag(undefined) @@ -1732,6 +1738,7 @@ const SendComponent: React.FC = props => { setFixedToFallback(false) setRateStarvedFallback(false) setStealth(false) + reseedStealthRef.current = true setRouteCaps({}) // The message describes the pair and wallet the user just left, the // same reason the toggle and the route caps go. Through the owners, @@ -1790,14 +1797,19 @@ const SendComponent: React.FC = props => { // Send-to-address swap handlers + rows // --------------------------------------------------------------------- - const handleToggleStealth = useHandler((): void => { - if (multipleTargets) return + /** + * Flips the Stealth toggle, or refuses to, returning whether it moved. + * Shared by a tap and by seeding, so an entry request or a saved choice gets + * the same quote and memo handling as a tap. + */ + const flipStealth = useHandler((): boolean => { + if (multipleTargets) return false // No private route is possible for this asset, pair, or amount: refuse to // arm and say why, instead of arming a toggle whose quote is guaranteed to // fail. Turning it back OFF is always allowed. if (!stealth && stealthBlockedReason != null) { showToast(stealthBlockedReason) - return + return false } // The standing quote was priced under the OTHER privacy setting, so it is // dead the moment the toggle moves. Drop it here rather than relying on @@ -1821,8 +1833,50 @@ const SendComponent: React.FC = props => { } setStealth(value => !value) setPinValue(undefined) + return true }) + /** + * The toggle setting this visit asks for: the entry's `stealth` param while + * there is one, then the user's last tap. `undefined` defers to the saved + * choice. + */ + const visitStealthRef = React.useRef(stealthParam) + + /** + * Only a tap the scene accepted becomes the account's saved choice. Seeding + * goes through `flipStealth` directly and saves nothing, so an entry param + * or deep link never changes what the user picked. The row stays locked + * until the save lands, so a second tap cannot race it. + */ + const handleToggleStealth = useHandler(async (): Promise => { + if (!flipStealth()) return + visitStealthRef.current = !stealth + await dispatch(setStealthSendEnabled(account, !stealth)) + }) + + /** + * Arms the toggle when the visit or the saved choice asks for it and a tap + * would be accepted. Anywhere else it stays off with its reason, and the + * saved choice is left for the next eligible wallet. Address-prefilled and + * other constrained entries never offer the toggle, so nothing arms there. + */ + const seedStealth = useHandler((): void => { + const wanted = visitStealthRef.current ?? stealthSendEnabled + if (!wanted || stealth) return + if (!swapSendAllowed || stealthBlockedReason != null) return + flipStealth() + }) + + // Set by a wallet or asset switch, which resets the toggle. Eligibility + // depends on the new pair, so the seed waits for the render that has it. + const reseedStealthRef = React.useRef(false) + React.useEffect(() => { + if (!reseedStealthRef.current) return + reseedStealthRef.current = false + seedStealth() + }, [walletId, tokenId, seedStealth]) + const handlePickRecipientAsset = useHandler((): void => { if (multipleTargets) return // Rows are keyed on the ASSET, never on its label: several chains share a @@ -3456,12 +3510,9 @@ const SendComponent: React.FC = props => { showError(err) }) } - // Arm through the tap handler so an entry request gets the same quote and - // memo handling as a tap. A send that cannot go private opens with the - // toggle off, and the card already states why. - if (initStealth && swapSendAllowed && stealthBlockedReason == null) { - handleToggleStealth() - } + // A send that cannot go private opens with the toggle off, and the card + // already states why. + seedStealth() }) useUnmount(() => { // Retire any quote request still in flight, so its answer is closed diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index e86b3fc130f..4852b5a7afd 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -15,11 +15,15 @@ import { Keyboard, View } from 'react-native' import { sprintf } from 'sprintf-js' import { isAssetDisabled } from '../../actions/ExchangeInfoActions' -import { checkEnabledExchanges } from '../../actions/SettingsActions' +import { + checkEnabledExchanges, + setStealthSwapEnabled +} from '../../actions/SettingsActions' import { getSpecialCurrencyInfo } from '../../constants/WalletAndCurrencyConstants' import { useSwapRequestOptions } from '../../hooks/swap/useSwapRequestOptions' import { useHandler } from '../../hooks/useHandler' import { useLinkPromoRelease } from '../../hooks/useLinkPromoRelease' +import { useTabBlur } from '../../hooks/useTabBlur' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' import { useDispatch, useSelector } from '../../types/reactRedux' @@ -73,8 +77,8 @@ export interface SwapCreateParams { // Display error message in an alert card errorDisplayInfo?: SwapErrorDisplayInfo - // Sets the Stealth Swap toggle on entry. The scene applies it once and - // clears it, so the toggle then stays where the user leaves it. + // Sets the Stealth Swap toggle for this visit, in place of the account's + // saved choice. The scene applies it once and clears it, and never saves it. stealth?: boolean } @@ -105,8 +109,15 @@ export const SwapCreateScene: React.FC = props => { >('from') // Stealth Swap: when enabled, the quote routes through the Houdini privacy - // provider as a fixed provider (see SwapConfirmationScene). - const [stealth, setStealth] = useState(stealthParam === true) + // provider as a fixed provider (see SwapConfirmationScene). The toggle shows + // the account's saved choice, unless a `stealth` param set it for this visit. + const stealthSwapEnabled = useSelector( + state => state.ui.settings.stealthSwapEnabled + ) + const [stealthOverride, setStealthOverride] = useState( + stealthParam + ) + const stealth = stealthOverride ?? stealthSwapEnabled const fromInputRef = React.useRef(null) const toInputRef = React.useRef(null) @@ -168,10 +179,16 @@ export const SwapCreateScene: React.FC = props => { // lingering value would put the toggle back every time one of them ran. React.useEffect(() => { if (stealthParam == null) return - setStealth(stealthParam) + setStealthOverride(stealthParam) navigation.setParams({ stealth: undefined }) }, [navigation, stealthParam]) + // The visit ends when the user switches tabs, and the saved choice is back + // the next time they open Swap. + useTabBlur(navigation, 'swapTab', () => { + setStealthOverride(undefined) + }) + // // Callbacks // @@ -460,16 +477,21 @@ export const SwapCreateScene: React.FC = props => { Keyboard.dismiss() }) - const handleToggleStealth = useHandler(() => { - setStealth(value => !value) + // A tap is the user's choice, so it is saved, and it replaces any param + // override at once rather than waiting on the settings write. The row stays + // locked until the save lands, so a second tap cannot race it. + const handleToggleStealth = useHandler(async (): Promise => { + setStealthOverride(!stealth) + await dispatch(setStealthSwapEnabled(account, !stealth)) }) // Declining the stealth provider's terms backs out to this scene with the // toggle off, so the next tap on the button asks for an ordinary swap // instead of walking straight back into the same terms modal. Turning the - // toggle back on brings the modal back, until the terms are accepted. + // toggle back on brings the modal back, until the terms are accepted. The + // decline only lasts this visit, so the saved choice is left alone. const handleStealthTermsDeclined = useHandler(() => { - setStealth(false) + setStealthOverride(false) }) const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => { diff --git a/src/docs/exchange-deep-links.md b/src/docs/exchange-deep-links.md index 574af880c90..f28fe4a60a7 100644 --- a/src/docs/exchange-deep-links.md +++ b/src/docs/exchange-deep-links.md @@ -387,7 +387,7 @@ Both parameters are optional and may appear in either order: | Parameter | Meaning | |---|---| -| `stealth` | `true` turns on the Send scene's Stealth Send toggle for this visit. Any other value, or none, opens a plain send. | +| `stealth` | `true` turns on the Send scene's Stealth Send toggle for this visit, without changing the user's saved choice. Any other value, or none, opens a plain send. | | `asset` | A `[_]` spec, parsed like the `exchange` assets in [4.1](#41-link-grammar-and-parsing). It narrows the wallet picker. A malformed spec is ignored. | `handleLink` resolves the wallet in `pickSendWallet`, then pushes `send2` with the wallet, `stealth: true` when the link asked for it, and the same `scamWarning: false` the balance card's Send button passes: @@ -398,9 +398,11 @@ Both parameters are optional and may appear in either order: | `send?stealth=true&asset=bitcoin`, several bitcoin wallets | modal narrowed to bitcoin | Send from the picked wallet, Stealth Send on | | `send?stealth=true&asset=bitcoin`, no bitcoin wallet | every wallet, no create-wallet rows | Send from the picked wallet, Stealth Send on | | `send?stealth=true` (no asset) | every wallet, the balance card's Send picker | Send from the picked wallet, Stealth Send on | -| `send` or `send?stealth=` | as above | Send, no stealth param | +| `send` or `send?stealth=` | as above | Send, no stealth param: the toggle follows the user's saved choice | | Any of the above, picker dismissed | modal, dismissed | nothing, caller unchanged | +The saved choice changes only on a tap of the toggle, which writes it to the account's synced settings (`setStealthSendEnabled`, and `setStealthSwapEnabled` on the swap scene). The tap handler returns that write, so the switch row stays locked until it lands and a second tap cannot persist a stale value. A param or link never writes it. + There are no create-wallet rows because a new wallet is empty and has nothing to send. Auto-pick ignores balance: a lone empty wallet opens Send, which shows its Wallet Empty modal. The picker is not narrowed to chains Stealth Send serves. A coin on an unserved chain opens Send with the toggle off and its reason shown. A token on an unserved chain is not refused up front, since a token source always swaps to the chain's coin: the toggle arms, and the first quote after an amount is entered fails and turns it off with a toast. The link takes no `promoId`, since conversion attribution is per tab and Send is not one of the exchange tabs. **Name native assets in marketing links.** Stealth Send always pays the recipient the destination chain's native coin, because the Send scene asks every swap-send quote for `toTokenId: null`. A token source therefore pays out the chain's coin rather than the token: a "send USDT privately" link built on `asset=ethereum_` delivers ETH to the recipient. Token payouts arrive with [EdgeApp/edge-react-gui#6253](https://github.com/EdgeApp/edge-react-gui/pull/6253), which changes this paragraph. @@ -497,6 +499,16 @@ The review found that nothing released the promo for a flow the user abandoned, The first two are the same mistake in two places: a guard written for the case in front of it, applied to a category wider than the case. The third is the cost of a design document that outlives its first draft, and the reason [10.2](#102-where-this-document-was-wrong-or-silent) exists. +### Phase 6: third review round + +| Found | Shipped | +|---|---| +| The ineligible side of the send seeding rules had no test | A suite on a wallet that cannot go private: no seed, no toast, no save, and the saved choice or param arms on the switch to an eligible wallet | +| Saving the tap made the stealth maestro flows depend on what the previous run left on the account | `maestro/common/stealth-toggle.yaml` takes a target state and taps only on a mismatch | +| No test covered `send` joining the open-picker guard, or a swap param arriving after the tab mounted | One case for each, both failing with the guarded line removed | +| The send grammar read as if `asset` needed `stealth`, and section 4.6 would go stale when #6253 lands | `edge://send[?]` with both optional; 4.6 splits the unserved case by coin and token | +| The toggle tap did not await its save, so a quick second tap could persist a stale choice (Bugbot) | Both tap handlers return the write and the switch row pending-locks until it lands | + ## 7. Decisions ### 7.1 A new `exchange` host rather than extending the existing ones diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md index 38bad2d9746..03fd3d2a312 100644 --- a/src/docs/stealth-send-swap.md +++ b/src/docs/stealth-send-swap.md @@ -663,6 +663,12 @@ The request reaches Houdini whatever the provider's switch in Exchange Settings A Stealth Swap whose pair has no private route shows the error and leaves the toggle on. The request asked for privacy, and quietly re-quoting a transparent swap is the substitution the plugin is told never to make; the user turns the toggle off if a transparent swap is acceptable. +### Remembered toggle settings + +Each toggle remembers the user's last choice per account, Send and Swap separately, in the synced account settings (`stealthSendEnabled` and `stealthSwapEnabled`), so the choice follows the account to other devices. Only a tap the scene accepts writes it. A refused tap, the toggle turning itself off on a missing private route, and the `stealth` navigation param (including the `edge://send` link) write nothing, so a marketing link cannot change what the user picked. + +The Send scene seeds its toggle on entry and again after every wallet or asset switch, through the same eligibility test a tap uses. A send that cannot go private opens with the toggle off and its reason shown, and the saved choice waits for the next eligible wallet. Address-prefilled entries never offer the toggle, so a saved choice arms nothing there. Within one visit the entry's `stealth` param, then the user's own taps, take precedence over the saved value. The swap tab stays mounted, so its param override lasts until the user switches to another tab. + ### Saying that a swap is running A send routed through Houdini looks like a send and behaves like a swap: the wallet pays the provider's deposit address, and the recipient is paid later by a second transaction the provider broadcasts. The table below lists what tells the user so, and each row answers a different question.