Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
94 changes: 94 additions & 0 deletions apps/web/src/components/common/DataMigrationBanner/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DataMigrationBanner />)

expect(await screen.findByTestId('data-migration-cta')).toBeInTheDocument()
})

it('is not dismissible', async () => {
render(<DataMigrationBanner />)

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(<DataMigrationBanner />)

expect(screen.queryByTestId('data-migration-cta')).not.toBeInTheDocument()
})

it('renders nothing outside the dashboard', () => {
mockRouter(AppRoutes.welcome.accounts)

render(<DataMigrationBanner />)

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(<DataMigrationBanner />)

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(<DataMigrationBanner />)
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)
})
})
66 changes: 66 additions & 0 deletions apps/web/src/components/common/DataMigrationBanner/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={classnames(css.wrapper, className)}>
<ErrorMessage level="info" title={DATA_MIGRATION_BANNER.title}>
<Typography variant="body2" mb={1}>
{DATA_MIGRATION_BANNER.description(sharedUiOrigin)}
</Typography>
<Button data-testid="data-migration-cta" variant="contained" size="small" onClick={handleMigrate}>
Sync my data & open Protofire Multisig
</Button>
</ErrorMessage>
</div>
)
}

export default DataMigrationBanner
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions apps/web/src/components/common/PageLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -46,6 +47,7 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE
})}
>
<div className={css.content}>
{!isWelcomePage && <DataMigrationBanner />}
<SafeLoadingError>
{!hideHeader && <Breadcrumbs />}
{children}
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/config/__tests__/dataMigration.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
})
58 changes: 58 additions & 0 deletions apps/web/src/config/dataMigration.tsx
Original file line number Diff line number Diff line change
@@ -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{' '}
<ExternalLink href={getSharedUiWelcomeUrl(sharedUiOrigin)}>Protofire Multisig</ExternalLink> in one click. Your
Safes and transaction history are already accessible there.
</>
),
}
18 changes: 18 additions & 0 deletions apps/web/src/hooks/useSharedUiOrigin.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(undefined)

useEffect(() => {
setOrigin(resolveSharedUiOrigin(window.location.host))
}, [])

return origin
}

export default useSharedUiOrigin
Original file line number Diff line number Diff line change
@@ -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<unknown> => {
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()
})
})
Loading
Loading