diff --git a/apps/web/jest.setup.js b/apps/web/jest.setup.js index ec59ed0b33e..9e66fca9826 100644 --- a/apps/web/jest.setup.js +++ b/apps/web/jest.setup.js @@ -10,6 +10,14 @@ faker.seed(123) // Set timezone to UTC for consistent date formatting across environments process.env.TZ = 'UTC' +// jsdom doesn't implement the Compression Streams API (unlike every evergreen browser +// this app supports) - polyfill it from Node's own implementation for tests only. +if (typeof CompressionStream === 'undefined' || typeof DecompressionStream === 'undefined') { + const { CompressionStream, DecompressionStream } = require('node:stream/web') + global.CompressionStream = CompressionStream + global.DecompressionStream = DecompressionStream +} + jest.mock('@web3-onboard/coinbase', () => jest.fn()) jest.mock('@web3-onboard/injected-wallets', () => ({ ProviderLabel: { MetaMask: 'MetaMask' } })) jest.mock('@web3-onboard/walletconnect', () => jest.fn()) diff --git a/apps/web/src/components/common/DataMigrationBanner/index.test.tsx b/apps/web/src/components/common/DataMigrationBanner/index.test.tsx new file mode 100644 index 00000000000..f5d66f7346c --- /dev/null +++ b/apps/web/src/components/common/DataMigrationBanner/index.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, fireEvent, waitFor } from '@/tests/test-utils' +import DataMigrationBanner from './index' +import * as useChains from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { chainBuilder } from '@/tests/builders/chains' +import * as useSafeAddress from '@/hooks/useSafeAddress' +import * as encode from '@/services/dataMigration/encodeMigrationPayload' +import { AppRoutes } from '@/config/routes' +import * as nextRouter from 'next/router' +import { faker } from '@faker-js/faker' + +const mockRouter = (pathname: string) => + jest.spyOn(nextRouter, 'useRouter').mockReturnValue({ pathname, query: {} } as never) + +const safeAddress = faker.finance.ethereumAddress() + +const mockFeatures = (enabled: FEATURES[]) => + jest.spyOn(useChains, 'useHasFeature').mockImplementation((feature) => enabled.includes(feature)) + +describe('DataMigrationBanner', () => { + beforeEach(() => { + mockFeatures([FEATURES.DATA_MIGRATION_PROMO]) + mockRouter(AppRoutes.home) + jest.spyOn(useSafeAddress, 'default').mockReturnValue(safeAddress) + jest.spyOn(useChains, 'useCurrentChain').mockReturnValue(chainBuilder().with({ shortName: 'astr' }).build()) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('renders the banner when the feature is enabled', async () => { + render() + + expect(await screen.findByTestId('data-migration-cta')).toBeInTheDocument() + }) + + it('is not dismissible', async () => { + render() + + expect(await screen.findByTestId('data-migration-cta')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + }) + + it('renders nothing when the feature is disabled', () => { + mockFeatures([]) + + render() + + expect(screen.queryByTestId('data-migration-cta')).not.toBeInTheDocument() + }) + + it('renders nothing outside the dashboard', () => { + mockRouter(AppRoutes.welcome.accounts) + + render() + + expect(screen.queryByTestId('data-migration-cta')).not.toBeInTheDocument() + }) + + it('renders nothing on the dashboard when no safe is open', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue('') + + render() + + expect(screen.queryByTestId('data-migration-cta')).not.toBeInTheDocument() + }) + + it('opens the same safe on the shared UI when there is no data to migrate', async () => { + jest + .spyOn(encode, 'collectMigrationPayload') + .mockReturnValue({ addressBook: {}, addedSafes: {}, undeployedSafes: {}, visitedSafes: {}, safeApps: {} }) + const encodeSpy = jest.spyOn(encode, 'encodeMigrationPayload') + const location = { href: '' } + Object.defineProperty(window, 'location', { value: location, writable: true }) + + render() + fireEvent.click(await screen.findByTestId('data-migration-cta')) + + await waitFor(() => expect(location.href).toContain(`/home?safe=astr:${safeAddress}`)) + expect(location.href).not.toContain('/migrate') + expect(encodeSpy).not.toHaveBeenCalled() + }) +}) + +describe('hasMigrationData', () => { + it('ignores settings and empty slices', () => { + expect(encode.hasMigrationData({ settings: { a: 1 }, addressBook: {}, addedSafes: {} })).toBe(false) + }) + + it('detects any non-empty slice', () => { + expect(encode.hasMigrationData({ addedSafes: { '1': {} } })).toBe(true) + }) +}) diff --git a/apps/web/src/components/common/DataMigrationBanner/index.tsx b/apps/web/src/components/common/DataMigrationBanner/index.tsx new file mode 100644 index 00000000000..e645d9ababb --- /dev/null +++ b/apps/web/src/components/common/DataMigrationBanner/index.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react' +import { Typography, Button } from '@mui/material' +import classnames from 'classnames' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@safe-global/utils/utils/chains' +import { DATA_MIGRATION_BANNER, getSharedUiMigrateUrl, getSharedUiSafeUrl } from '@/config/dataMigration' +import { + collectMigrationPayload, + encodeMigrationPayload, + hasMigrationData, +} from '@/services/dataMigration/encodeMigrationPayload' +import { useRouter } from 'next/router' +import { AppRoutes } from '@/config/routes' +import useSafeAddress from '@/hooks/useSafeAddress' +import useSharedUiOrigin from '@/hooks/useSharedUiOrigin' +import css from './style.module.css' + +const DataMigrationBanner = ({ className }: { className?: string }): ReactElement | null => { + const chain = useCurrentChain() + const isDataMigrationPromoEnabled = useHasFeature(FEATURES.DATA_MIGRATION_PROMO) + const sharedUiOrigin = useSharedUiOrigin() + const router = useRouter() + const safeAddress = useSafeAddress() + + const isDashboardWithSafe = router.pathname === AppRoutes.home && !!safeAddress + + if (!isDashboardWithSafe || !isDataMigrationPromoEnabled || !chain?.shortName || !sharedUiOrigin) return null + + const handleMigrate = async () => { + const migrateUrl = getSharedUiMigrateUrl(sharedUiOrigin, chain.shortName) + const payload = collectMigrationPayload() + + if (!hasMigrationData(payload)) { + window.location.href = getSharedUiSafeUrl(sharedUiOrigin, chain.shortName, safeAddress) + return + } + + try { + // The payload travels as a URL fragment - never sent to the server, and readable by + // the shared UI regardless of origin, so no iframe/postMessage bridge is needed. + const encoded = await encodeMigrationPayload(payload) + window.location.href = `${migrateUrl}#${encoded}` + } catch (error) { + // Falls back to the shared UI's own "couldn't read your data" state, which points + // the user to a manual export/import instead. + console.error('Failed to encode migration payload', error) + window.location.href = migrateUrl + } + } + + return ( +
+ + + {DATA_MIGRATION_BANNER.description(sharedUiOrigin)} + + + +
+ ) +} + +export default DataMigrationBanner diff --git a/apps/web/src/components/common/DataMigrationBanner/style.module.css b/apps/web/src/components/common/DataMigrationBanner/style.module.css new file mode 100644 index 00000000000..8bec94eebe5 --- /dev/null +++ b/apps/web/src/components/common/DataMigrationBanner/style.module.css @@ -0,0 +1,9 @@ +.wrapper { + padding: var(--space-3) var(--space-3) 0; +} + +@media (max-width: 599.95px) { + .wrapper { + padding: var(--space-2) var(--space-2) 0; + } +} diff --git a/apps/web/src/components/common/PageLayout/index.tsx b/apps/web/src/components/common/PageLayout/index.tsx index 58a0d6ed0c6..7c045953fb4 100644 --- a/apps/web/src/components/common/PageLayout/index.tsx +++ b/apps/web/src/components/common/PageLayout/index.tsx @@ -11,6 +11,7 @@ import { TxModalContext } from '@/components/tx-flow' import BatchSidebar from '@/components/batch/BatchSidebar' import Breadcrumbs from '@/components/common/Breadcrumbs' import { AppRoutes } from '@/config/routes' +import DataMigrationBanner from '@/components/common/DataMigrationBanner' const PageLayout = ({ pathname, children }: { pathname: string; children: ReactElement }): ReactElement => { const [isSidebarRoute, isAnimated] = useIsSidebarRoute(pathname) @@ -46,6 +47,7 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE })} >
+ {!isWelcomePage && } {!hideHeader && } {children} diff --git a/apps/web/src/config/__tests__/dataMigration.test.ts b/apps/web/src/config/__tests__/dataMigration.test.ts new file mode 100644 index 00000000000..2ad9f265b95 --- /dev/null +++ b/apps/web/src/config/__tests__/dataMigration.test.ts @@ -0,0 +1,59 @@ +import { + getSharedUiMigrateUrl, + getSharedUiSafeUrl, + getSharedUiWelcomeUrl, + resolveSharedUiEnvironment, + resolveSharedUiOrigin, + SHARED_UI_ORIGINS, +} from '../dataMigration' + +describe('dataMigration config', () => { + describe('resolveSharedUiEnvironment', () => { + it.each([ + ['staging.astar-safe.protofire.io', 'stage'], + ['safe-stg.example.org', 'stage'], + ['stage-safe.example.org', 'stage'], + ['safe.astar.network', 'prod'], + ['app.safe.protofire.io', 'prod'], + ['localhost:3000', 'prod'], + ])('%s -> %s', (host, expected) => { + expect(resolveSharedUiEnvironment(host)).toBe(expected) + }) + + it('does not treat a marker inside another word as an environment', () => { + expect(resolveSharedUiEnvironment('backstage.example.org')).toBe('prod') + expect(resolveSharedUiEnvironment('stages.example.org')).toBe('prod') + }) + + it('is case-insensitive', () => { + expect(resolveSharedUiEnvironment('STAGING.EXAMPLE.ORG')).toBe('stage') + }) + }) + + describe('resolveSharedUiOrigin', () => { + it('maps each environment to its shared UI origin', () => { + expect(resolveSharedUiOrigin('staging.astar-safe.protofire.io')).toBe(SHARED_UI_ORIGINS.stage) + expect(resolveSharedUiOrigin('safe.astar.network')).toBe(SHARED_UI_ORIGINS.prod) + }) + }) + + describe('url builders', () => { + it('builds the migrate URL with an encoded source', () => { + expect(getSharedUiMigrateUrl(SHARED_UI_ORIGINS.prod, 'astr')).toBe( + 'https://app.safe.protofire.io/migrate?source=astr', + ) + }) + + it('builds the safe URL', () => { + expect(getSharedUiSafeUrl(SHARED_UI_ORIGINS.prod, 'astr', '0x1')).toBe( + 'https://app.safe.protofire.io/home?safe=astr:0x1', + ) + }) + + it('builds the welcome URL', () => { + expect(getSharedUiWelcomeUrl(SHARED_UI_ORIGINS.stage)).toBe( + 'https://app.stage.safe.protofire.io/welcome/data-migration', + ) + }) + }) +}) diff --git a/apps/web/src/config/dataMigration.tsx b/apps/web/src/config/dataMigration.tsx new file mode 100644 index 00000000000..975dc51ee50 --- /dev/null +++ b/apps/web/src/config/dataMigration.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react' +import ExternalLink from '@/components/common/ExternalLink' + +/** + * Shared UI origins, one per environment. This deployment picks the one matching its own + * host (see resolveSharedUiOrigin): a staging host points to the staging shared UI and + * anything else to production - no per-environment code change or env var needed. + */ +export const SHARED_UI_ORIGINS = { + stage: 'https://app.stage.safe.protofire.io', + prod: 'https://app.safe.protofire.io', +} as const + +export type SharedUiEnvironment = keyof typeof SHARED_UI_ORIGINS + +// Match the marker as its own host segment (`staging.astar-safe.protofire.io`, +// `safe-stg.example.org`) but not inside another word (`backstage.`). +const STAGE_HOST_RE = /(^|[.-])(stage|staging|stg)([.-]|$)/i + +/** + * Maps the current host of this (dedicated) deployment to the shared UI environment it + * should hand users over to. Pure, so it can be unit-tested without a DOM. + */ +export const resolveSharedUiEnvironment = (host: string): SharedUiEnvironment => + STAGE_HOST_RE.test(host) ? 'stage' : 'prod' + +export const resolveSharedUiOrigin = (host: string): string => SHARED_UI_ORIGINS[resolveSharedUiEnvironment(host)] + +/** + * Where the promo banner CTA redirects to, carrying the encoded payload as a URL fragment. + * `source` is the chain's `shortName` from the config service, which the shared UI + * validates against its own migration source allowlist - never a raw domain. + */ +export const getSharedUiMigrateUrl = (origin: string, source: string): string => + `${origin}/migrate?source=${encodeURIComponent(source)}` + +/** + * Opens the same Safe (`chain:address`) on the shared UI, used when there is no local data to migrate. + */ +export const getSharedUiSafeUrl = (origin: string, shortName: string, safeAddress: string): string => + `${origin}/home?safe=${encodeURIComponent(shortName)}:${safeAddress}` + +export const getSharedUiWelcomeUrl = (origin: string): string => `${origin}/welcome/data-migration` + +/** + * Promo banner copy. Which chains show the banner is decided purely by the + * DATA_MIGRATION_PROMO feature flag in the chain config. + */ +export const DATA_MIGRATION_BANNER = { + title: 'Try Protofire Multisig — your Safes are already there', + description: (sharedUiOrigin: string): ReactNode => ( + <> + Sync your address book, added Safes and settings to{' '} + Protofire Multisig in one click. Your + Safes and transaction history are already accessible there. + + ), +} diff --git a/apps/web/src/hooks/useSharedUiOrigin.ts b/apps/web/src/hooks/useSharedUiOrigin.ts new file mode 100644 index 00000000000..6080b762203 --- /dev/null +++ b/apps/web/src/hooks/useSharedUiOrigin.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react' +import { resolveSharedUiOrigin } from '@/config/dataMigration' + +/** + * Shared UI origin matching this deployment's environment (staging -> stage, else prod). + * `undefined` until mounted, since it depends on `window.location`. + */ +const useSharedUiOrigin = (): string | undefined => { + const [origin, setOrigin] = useState(undefined) + + useEffect(() => { + setOrigin(resolveSharedUiOrigin(window.location.host)) + }, []) + + return origin +} + +export default useSharedUiOrigin diff --git a/apps/web/src/services/dataMigration/__tests__/encodeMigrationPayload.test.ts b/apps/web/src/services/dataMigration/__tests__/encodeMigrationPayload.test.ts new file mode 100644 index 00000000000..334f7d5f20d --- /dev/null +++ b/apps/web/src/services/dataMigration/__tests__/encodeMigrationPayload.test.ts @@ -0,0 +1,53 @@ +import { collectMigrationPayload, encodeMigrationPayload, type MigrationPayload } from '../encodeMigrationPayload' +import * as store from '@/store' +import { addressBookSlice } from '@/store/addressBookSlice' +import { settingsSlice } from '@/store/settingsSlice' + +// Mirrors the shared UI's `decodeMigrationFragment` - kept local to this test so it stays +// an independent check of the wire format, not a reuse of the implementation under test. +const decodeMigration = async (hash: string): Promise => { + const [ver, b64url] = hash.replace(/^#/, '').split('.') + if (ver !== 'v1' || !b64url) throw new Error('bad payload') + const b64 = b64url + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(b64url.length / 4) * 4, '=') + const gz = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) + const json = await new Response(new Blob([gz]).stream().pipeThrough(new DecompressionStream('gzip'))).text() + return JSON.parse(json) +} + +describe('encodeMigrationPayload', () => { + it('round-trips a payload through the v1 fragment format', async () => { + const payload: MigrationPayload = { + addressBook: { '1': { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'Alice' } }, + settings: { currency: 'usd' }, + } + + const encoded = await encodeMigrationPayload(payload) + + expect(encoded.startsWith('v1.')).toBe(true) + await expect(decodeMigration(`#${encoded}`)).resolves.toEqual(payload) + }) + + it('produces a URL-safe fragment with no padding or reserved characters', async () => { + const encoded = await encodeMigrationPayload({ addressBook: { '1': { '0x1': 'A very long name '.repeat(50) } } }) + + expect(encoded).toMatch(/^v1\.[A-Za-z0-9_-]+$/) + }) +}) + +describe('collectMigrationPayload', () => { + it('reads the same six slices as the manual Settings -> Data export', () => { + jest.spyOn(store, 'getPersistedState').mockReturnValue({ + [addressBookSlice.name]: { '1': { '0x1': 'Alice' } }, + [settingsSlice.name]: { currency: 'usd' }, + } as never) + + const payload = collectMigrationPayload() + + expect(payload.addressBook).toEqual({ '1': { '0x1': 'Alice' } }) + expect(payload.settings).toEqual({ currency: 'usd' }) + expect(payload.addedSafes).toBeUndefined() + }) +}) diff --git a/apps/web/src/services/dataMigration/encodeMigrationPayload.ts b/apps/web/src/services/dataMigration/encodeMigrationPayload.ts new file mode 100644 index 00000000000..df7752ef8cb --- /dev/null +++ b/apps/web/src/services/dataMigration/encodeMigrationPayload.ts @@ -0,0 +1,62 @@ +import { getPersistedState } from '@/store' +import { addressBookSlice } from '@/store/addressBookSlice' +import { addedSafesSlice } from '@/store/addedSafesSlice' +import { settingsSlice } from '@/store/settingsSlice' +import { safeAppsSlice } from '@/store/safeAppsSlice' +import { undeployedSafesSlice } from '@/features/counterfactual/store' +import { visitedSafesSlice } from '@/store/visitedSafesSlice' + +export type MigrationPayload = { + addressBook?: unknown + addedSafes?: unknown + settings?: unknown + safeApps?: unknown + undeployedSafes?: unknown + visitedSafes?: unknown +} + +const MIGRATION_FRAGMENT_VERSION = 'v1' + +/** + * Collects the same six redux-persisted slices as the manual Settings -> Data JSON + * export (see exportAppData in components/settings/DataManagement/index.tsx), for + * encoding into a migration URL fragment instead of a downloaded file. + */ +export const collectMigrationPayload = (): MigrationPayload => { + const { + [addressBookSlice.name]: addressBook, + [addedSafesSlice.name]: addedSafes, + [settingsSlice.name]: settings, + [safeAppsSlice.name]: safeApps, + [undeployedSafesSlice.name]: undeployedSafes, + [visitedSafesSlice.name]: visitedSafes, + } = getPersistedState() + + return { addressBook, addedSafes, settings, safeApps, undeployedSafes, visitedSafes } +} + +const isNonEmpty = (value: unknown): boolean => + typeof value === 'object' && value !== null && Object.keys(value).length > 0 + +/** + * Whether the user has any locally stored data worth migrating. `settings` is excluded + * because it always holds defaults, so it can't tell a fresh user from an existing one. + */ +export const hasMigrationData = (payload: MigrationPayload): boolean => + Object.entries(payload).some(([key, value]) => key !== 'settings' && isNonEmpty(value)) + +const toBase64Url = (base64: string): string => base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + +/** + * Encodes a migration payload as `v1.` for the destination app to + * read straight off `location.hash` - no iframe/postMessage bridge needed, since URL + * fragments are never sent to the server and are readable regardless of origin. The exact + * format is the contract with the shared UI's `decodeMigrationFragment` - changing it + * requires updating both sides. + */ +export const encodeMigrationPayload = async (payload: MigrationPayload): Promise => { + const bytes = new TextEncoder().encode(JSON.stringify(payload)) + const gz = await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'))).arrayBuffer() + const b64 = btoa(String.fromCharCode(...new Uint8Array(gz))) + return `${MIGRATION_FRAGMENT_VERSION}.${toBase64Url(b64)}` +} diff --git a/packages/utils/src/utils/chains.ts b/packages/utils/src/utils/chains.ts index 524fd4c870a..2320c293eb3 100644 --- a/packages/utils/src/utils/chains.ts +++ b/packages/utils/src/utils/chains.ts @@ -55,7 +55,8 @@ export enum FEATURES { HYPERNATIVE_QUEUE_SCAN = 'HYPERNATIVE_QUEUE_SCAN', EURCV_BOOST = 'EURCV_BOOST', MY_ACCOUNTS = 'MY_ACCOUNTS', - NATIVE_SWAPS_LIFI = 'NATIVE_SWAPS_LIF' + NATIVE_SWAPS_LIFI = 'NATIVE_SWAPS_LIF', + DATA_MIGRATION_PROMO = 'DATA_MIGRATION_PROMO', // opt-in localStorage migration banner from dedicated deployments to the shared UI } const MIN_SAFE_VERSION = '1.3.0'