diff --git a/CHANGELOG.md b/CHANGELOG.md index 6057216a2c9..c6e9554afd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - changed: Upgrade react-native-worklets to 0.10.4, which stops the animation loop from running while the app is in the background on Android and fixes crashes when the JavaScript runtime shuts down. - changed: Upgrade react-native-reanimated to 4.5.5, fixing a crash when an event arrives for a view that has just unmounted, an iOS crash at launch, and scroll-driven animations on Android jumping back a frame. - changed: Rename the HoudiniSwap provider to Houdini and link its privacy pages on docs.houdini.com +- changed: Send scene tests cover the transparent Zcash payout warning on a pasted unified address - fixed: Swaps with no exact receive quote switch to a send amount of the same value with a toast, not a no-provider error - fixed: The side menu's dimming overlay works again on both platforms. React Native 0.86 removed the `StyleSheet.absoluteFillObject` the drawer's overlay spreads, which collapsed it to nothing, so tapping outside the menu no longer closed it. - fixed: Text inputs mounted in the disabled state (such as the spending-limit amount) no longer flash their enabled look before dimming when a scene appears. diff --git a/src/__tests__/scenes/SendScene2.recipientNetwork.ui.test.tsx b/src/__tests__/scenes/SendScene2.recipientNetwork.ui.test.tsx index 155cf156a96..7ccfabfeb28 100644 --- a/src/__tests__/scenes/SendScene2.recipientNetwork.ui.test.tsx +++ b/src/__tests__/scenes/SendScene2.recipientNetwork.ui.test.tsx @@ -34,6 +34,7 @@ import { EdgeRow } from '../../components/rows/EdgeRow' import { SendScene2 } from '../../components/scenes/SendScene2' import type * as AirshipInstance from '../../components/services/AirshipInstance' import { Airship, showToast } from '../../components/services/AirshipInstance' +import { SafeSlider } from '../../components/themed/SafeSlider' import { StealthInfoText } from '../../components/themed/StealthInfoText' import { AddressTile2 } from '../../components/tiles/AddressTile2' import { lstrings } from '../../locales/strings' @@ -61,6 +62,13 @@ jest.mock('../../components/services/AirshipInstance', () => { return { ...actual, showToast: jest.fn() } }) +// A same-chain Zcash pick falls through to a ZNS reverse lookup over the +// network: +jest.mock('../../util/nameServices', () => ({ + ...jest.requireActual('../../util/nameServices'), + reverseLookupName: async () => null +})) + const BTC_ADDRESS = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' const EVM_ADDRESS = '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e' const OTHER_EVM_ADDRESS = '0x52908400098527886E0F7030069857D2E4169EE7' @@ -117,6 +125,11 @@ const FIFTY_USD_OF_ETH = '37700000000000000' const SCAM_WARNING = 'Scam Warning' const SWAP_BEFORE_SEND = 'Swap before send' const ZCASH_TRANSPARENT_WARNING = 'Transparent Zcash payout' +// A ZIP-316 unified address with a P2PKH receiver, which the provider pays +// out to as a t1: +const ZCASH_UNIFIED_ADDRESS = + 'u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx' +const ZCASH_TRANSPARENT_ADDRESS = 't1Hsc1LR8yKnbbe3twRp88p6vFfC5t7DLbs' /** * A fake plugin whose parser accepts one address format, bare or inside a @@ -199,16 +212,37 @@ const ftmCurrencyInfo: EdgeCurrencyInfo = { /** * The swap provider every send-to-address quote goes to. It records the * destination addresses each request reached it with, then declines the pair - * as a provider with no route would. + * as a provider with no route would. With `fakeHoudiniQuotes.enabled` set it + * quotes the pair one to one instead, so the scene holds a live quote. */ const quotedDestinations: EdgeAddress[][] = [] +const fakeHoudiniQuotes = { enabled: false } const fakeHoudiniPlugin: EdgeSwapPlugin = { swapInfo: { pluginId: 'houdini', displayName: 'Houdini', supportEmail: '' }, async fetchSwapQuote(request) { quotedDestinations.push( await request.toWallet.getAddresses({ tokenId: null }) ) - throw new SwapCurrencyError(fakeHoudiniPlugin.swapInfo, request) + if (!fakeHoudiniQuotes.enabled) { + throw new SwapCurrencyError(fakeHoudiniPlugin.swapInfo, request) + } + return { + swapInfo: fakeHoudiniPlugin.swapInfo, + request, + isEstimate: false, + fromNativeAmount: request.nativeAmount, + toNativeAmount: request.nativeAmount, + networkFee: { + currencyCode: request.fromWallet.currencyInfo.currencyCode, + tokenId: null, + nativeAmount: '0' + }, + pluginId: fakeHoudiniPlugin.swapInfo.pluginId, + approve: async () => { + throw new Error('The fake provider sends nothing') + }, + close: async () => {} + } } } @@ -369,11 +403,27 @@ afterAll(async () => { await context?.close() }) +/** + * Waits in act scopes, for up to four seconds, until `done` holds. A quote or + * a wallet pick settles on its own promise chain, and only an act scope that + * closes over it delivers it. waitFor polls outside act, so an update landing + * between its polls never renders. + */ +async function settleInAct(done: () => boolean): Promise { + const deadline = Date.now() + 4000 + while (!done() && Date.now() < deadline) { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 20)) + }) + } +} + describe('SendScene2 recipient network', () => { const mockShowToast = jest.mocked(showToast) beforeEach(() => { mockShowToast.mockClear() + fakeHoudiniQuotes.enabled = false }) interface RenderedSend { @@ -422,6 +472,8 @@ describe('SendScene2 recipient network', () => { position: (text: string) => number /** Whether some text node on the scene reads exactly this. */ shows: (text: string) => boolean + /** Whether the slider is shown and ready to slide. */ + sliderLive: () => boolean /** The notes under the Stealth Send toggle, top to bottom. */ stealthNotes: () => string[] /** Taps the Stealth Send toggle. */ @@ -585,13 +637,7 @@ describe('SendScene2 recipient network', () => { .UNSAFE_getByProps({ testID: 'addressTileMyself' }) .props.onPress() }) - // The tile settles the pick on its own promise chain, whose updates - // only reach the scene once an act scope closes over them: - for (let i = 0; i < 50 && tileProps().recipientAddress === ''; ++i) { - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 20)) - }) - } + await settleInAct(() => tileProps().recipientAddress !== '') expect(tileProps().recipientAddress).not.toBe('') show.mockRestore() }, @@ -679,6 +725,10 @@ describe('SendScene2 recipient network', () => { 1, shows: text => JSON.stringify(rendered.toJSON()).includes(JSON.stringify([text])), + sliderLive: () => + rendered + .UNSAFE_queryAllByType(SafeSlider) + .some(slider => slider.props.disabled !== true), stealthNotes: () => rendered .UNSAFE_queryAllByType(StealthInfoText) @@ -1159,15 +1209,7 @@ describe('SendScene2 recipient network', () => { // The fake provider refuses every quote. The refusal settles on the // quote's own promise chain, so only a closing act scope delivers it: await scene.enterSendAmount(ONE_BTC) - for ( - let i = 0; - i < 50 && !scene.shows(lstrings.exchange_generic_error_title); - ++i - ) { - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 20)) - }) - } + await settleInAct(() => scene.shows(lstrings.exchange_generic_error_title)) expect(scene.shows(lstrings.exchange_generic_error_title)).toBe(true) expect(scene.shows(ZCASH_TRANSPARENT_WARNING)).toBe(false) scene.unmount() @@ -1188,6 +1230,55 @@ describe('SendScene2 recipient network', () => { scene.unmount() }) + describe('with a pasted Zcash address the provider quotes', () => { + /** + * Pastes a Zcash address on a Bitcoin send, enters an amount, and waits + * until the provider's quote for that address leaves the slider ready. + */ + const renderQuotedPaste = async ( + address: string, + stealth: boolean + ): Promise => { + fakeHoudiniQuotes.enabled = true + const scene = renderBitcoinSend() + await scene.pickRecipientAsset('Zcash') + expect(await scene.enterCrossChainAddress(address)).toBe(true) + if (stealth) await scene.toggleStealth() + expect(scene.stealthArmed()).toBe(stealth) + + quotedDestinations.length = 0 + await scene.enterSendAmount(ONE_BTC) + await settleInAct(scene.sliderLive) + expect(scene.sliderLive()).toBe(true) + expect(quotedDestinations.at(-1)).toEqual([ + { addressType: 'publicAddress', publicAddress: address } + ]) + expect(scene.shows(lstrings.exchange_generic_error_title)).toBe(false) + return scene + } + + it('warns before the slide that a Stealth u1 send pays a t1', async () => { + const scene = await renderQuotedPaste(ZCASH_UNIFIED_ADDRESS, true) + expect(scene.shows(ZCASH_TRANSPARENT_WARNING)).toBe(true) + expect(scene.shows(lstrings.stealth_zcash_transparent_payout_body)).toBe( + true + ) + scene.unmount() + }) + + it('shows no warning for a Stealth send to a pasted t1', async () => { + const scene = await renderQuotedPaste(ZCASH_TRANSPARENT_ADDRESS, true) + expect(scene.shows(ZCASH_TRANSPARENT_WARNING)).toBe(false) + scene.unmount() + }) + + it('shows no warning for a u1 send with Stealth off', async () => { + const scene = await renderQuotedPaste(ZCASH_UNIFIED_ADDRESS, false) + expect(scene.shows(ZCASH_TRANSPARENT_WARNING)).toBe(false) + scene.unmount() + }) + }) + it('shows the scam warning on a plain send with no address', async () => { const scene = renderBitcoinSend()