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'