diff --git a/examples/react-web/package.json b/examples/react-web/package.json
index 47fde76..7b9f02d 100644
--- a/examples/react-web/package.json
+++ b/examples/react-web/package.json
@@ -15,6 +15,7 @@
"@goodwidget/ui": "workspace:*",
"@goodwidget/embed": "workspace:*",
"@goodwidget/claim-widget-theme-demo": "workspace:*",
+ "@goodwidget/staking-migration-widget": "workspace:*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-native-web": "^0.19.13"
diff --git a/examples/react-web/src/App.tsx b/examples/react-web/src/App.tsx
index 39d93fa..da15a93 100644
--- a/examples/react-web/src/App.tsx
+++ b/examples/react-web/src/App.tsx
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { GoodWidgetProvider, useWallet, useHost } from '@goodwidget/core'
import { ClaimWidget } from '@goodwidget/claim-widget-theme-demo'
+import { StakingMigrationWidget } from '@goodwidget/staking-migration-widget'
import {
getThemeManifest,
MiniAppShell,
@@ -162,6 +163,13 @@ function OverrideShowcase() {
+
+ StakingMigrationWidget:
+
+
+
Form Controls
diff --git a/examples/react-web/src/globals.d.ts b/examples/react-web/src/globals.d.ts
index a0269a7..7eb9fa0 100644
--- a/examples/react-web/src/globals.d.ts
+++ b/examples/react-web/src/globals.d.ts
@@ -2,4 +2,12 @@ declare const process: {
env: Record
}
+interface ImportMetaEnv {
+ readonly VITE_MIGRATION_API_BASE_URL?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
+
declare function setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): number
diff --git a/examples/storybook/package.json b/examples/storybook/package.json
index 25373c9..23571e4 100644
--- a/examples/storybook/package.json
+++ b/examples/storybook/package.json
@@ -13,6 +13,7 @@
"@goodwidget/ui": "workspace:*",
"@goodwidget/claim-widget-theme-demo": "workspace:*",
"@goodwidget/citizen-claim-widget": "workspace:*",
+ "@goodwidget/staking-migration-widget": "workspace:*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-native-web": "^0.19.13",
diff --git a/examples/storybook/src/stories/design-system/Stepper.stories.tsx b/examples/storybook/src/stories/design-system/Stepper.stories.tsx
new file mode 100644
index 0000000..41fcc67
--- /dev/null
+++ b/examples/storybook/src/stories/design-system/Stepper.stories.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+import type { Meta, StoryObj } from '@storybook/react'
+import { Stepper, Text, YStack, type StepperStepItem } from '@goodwidget/ui'
+
+const meta: Meta = {
+ title: 'Design System/Stepper',
+ component: Stepper,
+ tags: ['autodocs'],
+ parameters: { layout: 'padded' },
+}
+
+export default meta
+type Story = StoryObj
+
+const STEPS: StepperStepItem[] = [
+ { id: 'connect', title: 'Connect wallet', status: 'completed' },
+ { id: 'approve', title: 'Approve transaction', status: 'completed' },
+ { id: 'submit', title: 'Submit migration', status: 'active', description: 'Waiting for wallet confirmation.' },
+ { id: 'bridge', title: 'Bridge to Celo', status: 'pending' },
+ { id: 'stake', title: 'Stake on Celo', status: 'pending' },
+ { id: 'confirm', title: 'Confirm receipt', status: 'pending' },
+]
+
+export const TransactionFlow: Story = {
+ render: () => (
+
+ Transaction steps}
+ maxHeight={280}
+ />
+
+ ),
+}
diff --git a/examples/storybook/src/stories/staking-migration-widget/StakingMigrationWidget.stories.tsx b/examples/storybook/src/stories/staking-migration-widget/StakingMigrationWidget.stories.tsx
new file mode 100644
index 0000000..2869940
--- /dev/null
+++ b/examples/storybook/src/stories/staking-migration-widget/StakingMigrationWidget.stories.tsx
@@ -0,0 +1,160 @@
+import React from 'react'
+import type { Meta, StoryObj } from '@storybook/react'
+import { YStack } from '@goodwidget/ui'
+import {
+ StakingMigrationWidget,
+ derivePrimaryAction,
+ derivePrimaryLabel,
+ type MigrationStep,
+ type StakingMigrationWidgetAdapterFactory,
+ type StakingMigrationWidgetState,
+ type StakingMigrationWidgetStatus,
+} from '@goodwidget/staking-migration-widget'
+import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193'
+
+function createMockState(
+ status: StakingMigrationWidgetStatus,
+ overrides: {
+ stakedAmount?: string
+ stakedAmountRaw?: bigint
+ completedSteps?: MigrationStep[]
+ activeStep?: MigrationStep | null
+ failedStep?: MigrationStep | null
+ error?: string | null
+ hasRequiredConfig?: boolean
+ isWrongNetwork?: boolean
+ } = {},
+): StakingMigrationWidgetState {
+ const stakedAmountRaw = overrides.stakedAmountRaw ?? 250000n
+ const state: StakingMigrationWidgetState = {
+ status,
+ address: '0x329377cbeeF39f01b0Ea04B80465c9eB47D3ED1',
+ chainId: 122,
+ stakedAmount: overrides.stakedAmount ?? '2500',
+ stakedAmountRaw,
+ stakedTokenSymbol: 'sG$',
+ hasRequiredConfig: overrides.hasRequiredConfig ?? true,
+ isWrongNetwork: overrides.isWrongNetwork ?? false,
+ isBalanceLoading: false,
+ completedSteps: overrides.completedSteps ?? [],
+ activeStep: overrides.activeStep ?? null,
+ failedStep: overrides.failedStep ?? null,
+ approvalTxHash: '0xapprovalhash',
+ migrationId: 'migration-1',
+ error: overrides.error ?? null,
+ primaryAction: 'none',
+ primaryLabel: '',
+ }
+ const primaryAction = derivePrimaryAction(state)
+ return {
+ ...state,
+ primaryAction,
+ primaryLabel: derivePrimaryLabel(state, primaryAction),
+ }
+}
+
+function createAdapterFactory(
+ status: StakingMigrationWidgetStatus,
+ overrides: Parameters[1] = {},
+): StakingMigrationWidgetAdapterFactory {
+ return () => ({
+ state: createMockState(status, overrides),
+ actions: {
+ connect: async () => {},
+ switchToFuse: async () => {},
+ refresh: async () => {},
+ approveAndMigrate: async () => {},
+ retryMigration: async () => {},
+ },
+ })
+}
+
+function StoryShell({ adapterFactory }: { adapterFactory: StakingMigrationWidgetAdapterFactory }) {
+ try {
+ const provider = createCustodialEip1193Provider()
+ return (
+
+
+
+ )
+ } catch (error: unknown) {
+ return (
+
+ {error instanceof Error ? error.message : 'Custodial fixture not configured'}
+
+ )
+ }
+}
+
+const meta: Meta = {
+ title: 'Widgets/StakingMigrationWidget',
+ component: StakingMigrationWidget,
+ tags: ['autodocs'],
+ parameters: { layout: 'padded' },
+}
+
+export default meta
+type Story = StoryObj
+
+export const EmptyBalance: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Ready: Story = {
+ render: () => ,
+}
+
+export const WrongNetwork: Story = {
+ render: () => (
+
+ ),
+}
+
+export const ApprovalPending: Story = {
+ render: () => ,
+}
+
+export const Migrating: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Success: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Error: Story = {
+ render: () => (
+
+ ),
+}
diff --git a/packages/staking-migration-widget/package.json b/packages/staking-migration-widget/package.json
new file mode 100644
index 0000000..a1899eb
--- /dev/null
+++ b/packages/staking-migration-widget/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@goodwidget/staking-migration-widget",
+ "version": "0.1.0",
+ "description": "Fuse staking migration widget for moving sG$ positions to Celo savings",
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./element": {
+ "types": "./dist/element.d.ts",
+ "import": "./dist/element.js",
+ "require": "./dist/element.cjs"
+ },
+ "./register": {
+ "types": "./dist/register.d.ts",
+ "import": "./dist/register.js",
+ "require": "./dist/register.cjs"
+ }
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "lint": "eslint src/",
+ "clean": "rm -rf dist .turbo"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ },
+ "dependencies": {
+ "@goodwidget/core": "workspace:*",
+ "@goodwidget/embed": "workspace:*",
+ "@goodwidget/ui": "workspace:*",
+ "viem": "^2.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "tsup": "^8.4.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/packages/staking-migration-widget/src/MigrationProgressTimeline.tsx b/packages/staking-migration-widget/src/MigrationProgressTimeline.tsx
new file mode 100644
index 0000000..8c46983
--- /dev/null
+++ b/packages/staking-migration-widget/src/MigrationProgressTimeline.tsx
@@ -0,0 +1,186 @@
+import React, { useMemo } from 'react'
+import { Heading, Stepper, Text, YStack, type StepperStepItem } from '@goodwidget/ui'
+import type { MigrationStep, StakingMigrationWidgetStatus } from './widgetRuntimeContract'
+
+const STEP_ORDER: MigrationStep[] = ['unstake', 'bridge sent', 'bridge received', 'stake']
+
+interface MigrationProgressTimelineProps {
+ status: StakingMigrationWidgetStatus
+ completedSteps: MigrationStep[]
+ activeStep: MigrationStep | null
+ failedStep: MigrationStep | null
+ error: string | null
+ hasAvailableBalance: boolean
+}
+
+function formatStepLabel(step: MigrationStep): string {
+ if (step === 'bridge sent') return 'Bridge to Celo'
+ if (step === 'stake') return 'Stake on Celo'
+ return step
+ .split(' ')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ')
+}
+
+function getApproveDescription(status: StakingMigrationWidgetStatus, error: string | null): string {
+ if (status === 'wrong-network') {
+ return 'Switch to the Fuse network to approve the migration.'
+ }
+
+ if (status === 'approval-pending') {
+ return 'Confirm the approval transaction in your wallet.'
+ }
+
+ if (status === 'approval-failed') {
+ return error ?? 'Approval did not complete. Retry to continue.'
+ }
+
+ if (status === 'migrating' || status === 'success' || status === 'error') {
+ return 'Approval confirmed on Fuse.'
+ }
+
+ return 'Approve the migration from your Fuse wallet.'
+}
+
+function getStepDescription(
+ step: MigrationStep,
+ status: StakingMigrationWidgetStatus,
+ activeStep: MigrationStep | null,
+ failedStep: MigrationStep | null,
+ error: string | null,
+): string {
+ if (failedStep === step) {
+ return error ?? 'This step failed. Retry the migration to continue.'
+ }
+
+ if (activeStep === step) {
+ return 'Currently in progress.'
+ }
+
+ if (status === 'success' || status === 'error' || status === 'migrating') {
+ if (step === 'unstake') return 'Release the staked position on Fuse.'
+ if (step === 'bridge sent') return 'Send the migrated assets from Fuse.'
+ if (step === 'bridge received') return 'Finalize the bridge transfer on Celo.'
+ return 'Deposit the migrated assets into Celo savings.'
+ }
+
+ return 'Pending'
+}
+
+function getStepStatusLabel(status: StakingMigrationWidgetStatus): string | null {
+ if (status === 'success') return 'Completed'
+ if (status === 'error' || status === 'approval-failed') return 'Failed'
+ if (status === 'missing-config') return 'Configuration required'
+ return null
+}
+
+function resolveMigrationStepStatus(
+ isCompleted: boolean,
+ isActive: boolean,
+ isFailed: boolean,
+ needsAttention: boolean,
+): StepperStepItem['status'] {
+ if (isCompleted) return 'completed'
+ if (isFailed) return 'failed'
+ if (isActive && needsAttention) return 'attention'
+ if (isActive) return 'active'
+ return 'pending'
+}
+
+export function MigrationProgressTimeline({
+ status,
+ completedSteps,
+ activeStep,
+ failedStep,
+ error,
+ hasAvailableBalance,
+}: MigrationProgressTimelineProps) {
+ const approvalCompleted = status === 'migrating' || status === 'success' || status === 'error'
+ const approvalActive =
+ ((status === 'summary' || status === 'wrong-network') && hasAvailableBalance) ||
+ status === 'approval-pending' ||
+ status === 'approval-failed'
+ const approvalFailed = status === 'approval-failed'
+ const approveNeedsAttention = status === 'wrong-network' || status === 'approval-failed'
+
+ const statusColor =
+ status === 'success'
+ ? '$success'
+ : status === 'error' || status === 'approval-failed'
+ ? '$error'
+ : status === 'wrong-network' || status === 'missing-config'
+ ? '$warning'
+ : '$primary'
+
+ const statusLabel = getStepStatusLabel(status)
+
+ const steps = useMemo(() => {
+ const approveStatus = resolveMigrationStepStatus(
+ approvalCompleted,
+ approvalActive,
+ approvalFailed,
+ approveNeedsAttention,
+ )
+
+ const migrationSteps = STEP_ORDER.map((step) => {
+ const isCompleted = completedSteps.includes(step)
+ const isActive = activeStep === step
+ const isFailed = failedStep === step
+ const needsAttention = failedStep === step
+
+ return {
+ id: step,
+ title: formatStepLabel(step),
+ description: getStepDescription(step, status, activeStep, failedStep, error),
+ status: resolveMigrationStepStatus(isCompleted, isActive, isFailed, needsAttention),
+ }
+ })
+
+ return [
+ {
+ id: 'approve-on-fuse',
+ title: 'Approve on Fuse',
+ description: getApproveDescription(status, error),
+ status: approveStatus,
+ },
+ ...migrationSteps,
+ ]
+ }, [
+ activeStep,
+ approvalActive,
+ approvalCompleted,
+ approvalFailed,
+ approveNeedsAttention,
+ completedSteps,
+ error,
+ failedStep,
+ status,
+ ])
+
+ const activeStepId = useMemo(() => {
+ if (approvalActive || approvalFailed) return 'approve-on-fuse'
+ if (failedStep) return failedStep
+ if (activeStep) return activeStep
+ return null
+ }, [activeStep, approvalActive, approvalFailed, failedStep])
+
+ return (
+
+ Migration journey
+ {statusLabel && (
+
+ {statusLabel}
+
+ )}
+ {!hasAvailableBalance && status === 'summary' && (
+ No migration available for this wallet yet.
+ )}
+
+ }
+ />
+ )
+}
diff --git a/packages/staking-migration-widget/src/MigrationSummaryCard.tsx b/packages/staking-migration-widget/src/MigrationSummaryCard.tsx
new file mode 100644
index 0000000..6625c9b
--- /dev/null
+++ b/packages/staking-migration-widget/src/MigrationSummaryCard.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import { YStack, Heading, Text, TokenAmount, CircularActionButton } from '@goodwidget/ui'
+
+export interface MigrationSummaryAction {
+ label: string
+ disabled: boolean
+ pending?: boolean
+ onPress?: () => void
+}
+
+interface MigrationSummaryCardProps {
+ stakedAmount: string
+ statusMessage?: string
+ action: MigrationSummaryAction
+}
+
+export function MigrationSummaryCard({
+ stakedAmount,
+ statusMessage,
+ action,
+}: MigrationSummaryCardProps) {
+ return (
+
+
+ Migrate Fuse staking to Celo savings
+
+
+ Move your assets to the new network to continue earning rewards.
+
+
+
+
+ Amount to migrate
+
+
+
+
+
+ {statusMessage && (
+
+ {statusMessage}
+
+ )}
+
+
+ )
+}
diff --git a/packages/staking-migration-widget/src/StakingMigrationWidget.tsx b/packages/staking-migration-widget/src/StakingMigrationWidget.tsx
new file mode 100644
index 0000000..2b16f6e
--- /dev/null
+++ b/packages/staking-migration-widget/src/StakingMigrationWidget.tsx
@@ -0,0 +1,159 @@
+import React, { useCallback, useMemo } from 'react'
+import { GoodWidgetProvider } from '@goodwidget/core'
+import type { EIP1193Provider } from '@goodwidget/core'
+import { Card, Text, ToastContainer, YStack } from '@goodwidget/ui'
+import { MigrationProgressTimeline } from './MigrationProgressTimeline'
+import { MigrationSummaryCard } from './MigrationSummaryCard'
+import { useStakingMigrationAdapter } from './adapter'
+import type { StakingMigrationWidgetProps } from './widgetRuntimeContract'
+
+type StakingMigrationInnerProps = Pick<
+ StakingMigrationWidgetProps,
+ | 'migrationApiBaseUrl'
+ | 'adapterFactory'
+ | 'onMigrationSuccess'
+ | 'onMigrationError'
+>
+
+function formatJourneyLabel(label: string | null): string | null {
+ if (!label) return null
+ return label
+ .split(' ')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ')
+}
+
+function StakingMigrationInner({
+ migrationApiBaseUrl,
+ adapterFactory,
+ onMigrationSuccess,
+ onMigrationError,
+}: StakingMigrationInnerProps) {
+ const defaultAdapter = useStakingMigrationAdapter({
+ migrationApiBaseUrl,
+ onMigrationSuccess,
+ onMigrationError,
+ })
+
+ const activeAdapter = useMemo(
+ () =>
+ adapterFactory
+ ? adapterFactory({
+ migrationApiBaseUrl,
+ })
+ : defaultAdapter,
+ [adapterFactory, defaultAdapter, migrationApiBaseUrl],
+ )
+
+ const { state, actions } = activeAdapter
+ const isZeroBalance = state.stakedAmountRaw <= 0n
+ const isPrimaryPending =
+ state.isBalanceLoading ||
+ state.status === 'approval-pending' ||
+ state.status === 'migrating'
+
+ const handlePrimaryAction = useCallback(async () => {
+ switch (state.primaryAction) {
+ case 'connect':
+ await actions.connect()
+ break
+ case 'switch_chain':
+ await actions.switchToFuse()
+ break
+ case 'migrate':
+ await actions.approveAndMigrate()
+ break
+ case 'retry':
+ await actions.retryMigration()
+ break
+ case 'refresh':
+ await actions.refresh()
+ break
+ default:
+ break
+ }
+ }, [actions, state.primaryAction])
+
+ const summaryAction = useMemo(
+ () => ({
+ label: state.primaryLabel,
+ disabled: state.primaryAction === 'none' || isPrimaryPending,
+ pending: isPrimaryPending,
+ onPress:
+ state.primaryAction === 'none' || isPrimaryPending
+ ? undefined
+ : () => {
+ void handlePrimaryAction()
+ },
+ }),
+ [handlePrimaryAction, isPrimaryPending, state.primaryAction, state.primaryLabel],
+ )
+
+ const summaryStatusMessage = useMemo(() => {
+ if (state.status === 'migrating') {
+ return state.activeStep
+ ? `${formatJourneyLabel(state.activeStep)} is in progress.`
+ : 'Migration is in progress.'
+ }
+
+ return undefined
+ }, [state.activeStep, state.status])
+
+ return (
+
+
+
+
+
+
+
+ {state.status === 'missing-config' && (
+
+ Missing migration configuration: Provide a migration API base
+ URL before enabling migration.
+
+ )}
+
+
+
+ )
+}
+
+export function StakingMigrationWidget({
+ provider,
+ config,
+ defaultTheme = 'light',
+ themeOverrides,
+ migrationApiBaseUrl,
+ onMigrationSuccess,
+ onMigrationError,
+ adapterFactory,
+}: StakingMigrationWidgetProps) {
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/staking-migration-widget/src/adapter.ts b/packages/staking-migration-widget/src/adapter.ts
new file mode 100644
index 0000000..1a7ae86
--- /dev/null
+++ b/packages/staking-migration-widget/src/adapter.ts
@@ -0,0 +1,873 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useWallet } from '@goodwidget/core'
+import type { EIP1193Provider } from '@goodwidget/core'
+import {
+ createPublicClient,
+ createWalletClient,
+ custom,
+ formatUnits,
+ http,
+ parseAbi,
+ type Address,
+ type Chain,
+} from 'viem'
+import { resolveMigrationConfig, type ResolvedStakingMigrationConfig } from './migrationEnvironments'
+import {
+ FUSE_CHAIN_ID,
+ FUSE_STAKING_CONTRACT_ADDRESS,
+ type MigrationStep,
+ type StakingMigrationErrorDetail,
+ type StakingMigrationPrimaryAction,
+ type StakingMigrationSuccessDetail,
+ type StakingMigrationWidgetAdapterResult,
+ type StakingMigrationWidgetState,
+} from './widgetRuntimeContract'
+
+const MIGRATION_STEPS: MigrationStep[] = ['unstake', 'bridge sent', 'bridge received', 'stake']
+
+const MIGRATION_SUBMIT_PATH = '/migrate-stake-from-approval'
+const MIGRATION_STATUS_PATH = '/migrate-stake-status'
+const MIGRATION_STATUS_STREAM_PATH = '/migrate-stake-status/stream'
+const MIGRATION_STATUS_POLL_INTERVAL_MS = 2500
+
+const fuseStakingAbi = parseAbi([
+ 'function balanceOf(address account) view returns (uint256)',
+ 'function decimals() view returns (uint8)',
+ 'function approve(address spender, uint256 amount) returns (bool)',
+])
+
+const FUSE_CHAIN: Chain = {
+ id: FUSE_CHAIN_ID,
+ name: 'Fuse',
+ nativeCurrency: {
+ name: 'Fuse',
+ symbol: 'FUSE',
+ decimals: 18,
+ },
+ rpcUrls: {
+ default: { http: ['https://rpc.fuse.io'] },
+ public: { http: ['https://rpc.fuse.io'] },
+ },
+}
+
+const WORKER_STEP_CHECKPOINTS: Record = {
+ unstake: ['fuse_transfer', 'fuse_withdraw', 'fuse_bridge_sent', 'celo_bridge_received', 'celo_staked', 'completed'],
+ 'bridge sent': ['fuse_bridge_sent', 'celo_bridge_received', 'celo_staked', 'completed'],
+ 'bridge received': ['celo_bridge_received', 'celo_staked', 'completed'],
+ stake: ['celo_staked', 'completed'],
+}
+
+interface WorkerMigrationState {
+ approvalTxHash: string
+ user?: string
+ status: 'pending' | 'completed' | 'failed'
+ lastSuccessfulStep?: string
+ lastError?: {
+ stage: string
+ message: string
+ at: string
+ }
+ updatedAt?: string
+}
+
+interface ApiProgressPayload {
+ migrationId: string | null
+ status: 'migrating' | 'success' | 'error'
+ completedSteps: MigrationStep[]
+ activeStep: MigrationStep | null
+ failedStep: MigrationStep | null
+ reason: string | null
+}
+
+export interface UseStakingMigrationAdapterOptions {
+ migrationApiBaseUrl?: string
+ onMigrationSuccess?: (detail: StakingMigrationSuccessDetail) => void
+ onMigrationError?: (detail: StakingMigrationErrorDetail) => void
+}
+
+const DEFAULT_STATE: StakingMigrationWidgetState = {
+ status: 'summary',
+ address: null,
+ chainId: null,
+ stakedAmount: '0',
+ stakedAmountRaw: 0n,
+ stakedTokenSymbol: 'sG$',
+ hasRequiredConfig: false,
+ isWrongNetwork: false,
+ isBalanceLoading: false,
+ completedSteps: [],
+ activeStep: null,
+ failedStep: null,
+ approvalTxHash: null,
+ migrationId: null,
+ error: null,
+ primaryAction: 'none',
+ primaryLabel: '',
+}
+
+function formatErrorMessage(error: unknown): string {
+ if (!(error instanceof Error)) return 'Unexpected migration error'
+ const lowerMessage = error.message.toLowerCase()
+ if (lowerMessage.includes('user rejected') || lowerMessage.includes('rejected the request')) {
+ return 'Approval rejected in wallet'
+ }
+ return error.message
+}
+
+function hasRequiredConfig(migrationConfig: ResolvedStakingMigrationConfig): boolean {
+ return Boolean(migrationConfig.migrationApiBaseUrl && migrationConfig.migrationOperator)
+}
+
+function buildApiHeaders(): Record {
+ return {
+ 'Content-Type': 'application/json',
+ }
+}
+
+function buildMigrationApiUrl(baseUrl: string, path: string): string {
+ return `${baseUrl.replace(/\/$/, '')}${path}`
+}
+
+function workerStepCompletesWidgetStep(
+ lastSuccessfulStep: string | undefined,
+ widgetStep: MigrationStep,
+): boolean {
+ if (!lastSuccessfulStep) return false
+ return WORKER_STEP_CHECKPOINTS[widgetStep].includes(lastSuccessfulStep)
+}
+
+function mapWorkerStageToFailedStep(stage: string): MigrationStep | null {
+ const normalizedStage = stage.toLowerCase()
+ if (
+ normalizedStage.includes('fuse_transfer') ||
+ normalizedStage.includes('fuse_withdraw') ||
+ normalizedStage.includes('unstake')
+ ) {
+ return 'unstake'
+ }
+ if (normalizedStage.includes('fuse_bridge_sent') || normalizedStage.includes('bridge_sent')) {
+ return 'bridge sent'
+ }
+ if (normalizedStage.includes('celo_bridge_received') || normalizedStage.includes('bridge_received')) {
+ return 'bridge received'
+ }
+ if (normalizedStage.includes('celo_staked') || normalizedStage.includes('stake')) {
+ return 'stake'
+ }
+ return null
+}
+
+function mapWorkerStateToProgress(state: WorkerMigrationState): ApiProgressPayload {
+ const completedSteps = MIGRATION_STEPS.filter((step) =>
+ workerStepCompletesWidgetStep(state.lastSuccessfulStep, step),
+ )
+ const activeStep = MIGRATION_STEPS.find((step) => !completedSteps.includes(step)) ?? null
+ const failedStep =
+ state.status === 'failed' && state.lastError?.stage
+ ? mapWorkerStageToFailedStep(state.lastError.stage)
+ : null
+
+ const status: ApiProgressPayload['status'] =
+ state.status === 'completed' ? 'success' : state.status === 'failed' ? 'error' : 'migrating'
+
+ return {
+ migrationId: state.approvalTxHash,
+ status,
+ completedSteps,
+ activeStep: status === 'success' ? null : failedStep ?? activeStep,
+ failedStep,
+ reason: state.lastError?.message ?? null,
+ }
+}
+
+function createCompletedProgress(approvalTxHash: string): ApiProgressPayload {
+ return {
+ migrationId: approvalTxHash,
+ status: 'success',
+ completedSteps: [...MIGRATION_STEPS],
+ activeStep: null,
+ failedStep: null,
+ reason: null,
+ }
+}
+
+function parseSseFrame(frame: string): { event: string; data: unknown } | null {
+ let event = 'message'
+ let dataStr = ''
+
+ for (const line of frame.split('\n')) {
+ if (line.startsWith('event:')) {
+ event = line.slice(6).trim()
+ }
+ if (line.startsWith('data:')) {
+ dataStr += line.slice(5).trim()
+ }
+ }
+
+ if (!dataStr) return null
+
+ try {
+ return { event, data: JSON.parse(dataStr) as unknown }
+ } catch {
+ return null
+ }
+}
+
+function sleep(ms: number, signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(new DOMException('Aborted', 'AbortError'))
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ signal?.removeEventListener('abort', onAbort)
+ resolve()
+ }, ms)
+
+ const onAbort = () => {
+ clearTimeout(timeout)
+ reject(new DOMException('Aborted', 'AbortError'))
+ }
+
+ signal?.addEventListener('abort', onAbort, { once: true })
+ })
+}
+
+async function fetchWorkerMigrationState(
+ approvalTxHash: string,
+ migrationConfig: ResolvedStakingMigrationConfig,
+): Promise {
+ const endpoint = `${buildMigrationApiUrl(migrationConfig.migrationApiBaseUrl!, MIGRATION_STATUS_PATH)}?approvalTxHash=${encodeURIComponent(approvalTxHash)}`
+ const response = await fetch(endpoint, {
+ method: 'GET',
+ headers: buildApiHeaders(),
+ })
+
+ const responsePayload = (await response.json().catch(() => ({}))) as unknown
+
+ if (response.status === 404) {
+ throw new Error('Migration state not found')
+ }
+
+ if (!response.ok) {
+ const payload =
+ responsePayload && typeof responsePayload === 'object'
+ ? (responsePayload as Record)
+ : {}
+ throw new Error(
+ typeof payload.error === 'string'
+ ? payload.error
+ : `Migration status request failed (${response.status})`,
+ )
+ }
+
+ return responsePayload as WorkerMigrationState
+}
+
+async function submitMigrationStart(
+ approvalTxHash: string,
+ migrationConfig: ResolvedStakingMigrationConfig,
+): Promise<'started' | 'completed'> {
+ const endpoint = buildMigrationApiUrl(migrationConfig.migrationApiBaseUrl!, MIGRATION_SUBMIT_PATH)
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: buildApiHeaders(),
+ body: JSON.stringify({ approvalTxHash }),
+ })
+
+ const responsePayload = (await response.json().catch(() => ({}))) as Record
+
+ if (response.status === 409) {
+ return 'started'
+ }
+
+ if (!response.ok) {
+ throw new Error(
+ typeof responsePayload.error === 'string'
+ ? responsePayload.error
+ : `Migration API request failed (${response.status})`,
+ )
+ }
+
+ if (responsePayload.skipped === true) {
+ throw new Error(
+ typeof responsePayload.skipReason === 'string'
+ ? responsePayload.skipReason
+ : 'Migration was skipped by backend',
+ )
+ }
+
+ return 'completed'
+}
+
+async function watchMigrationViaPolling(
+ approvalTxHash: string,
+ migrationConfig: ResolvedStakingMigrationConfig,
+ signal: AbortSignal,
+ onProgress: (progress: ApiProgressPayload) => boolean,
+): Promise {
+ while (!signal.aborted) {
+ try {
+ const workerState = await fetchWorkerMigrationState(approvalTxHash, migrationConfig)
+ if (onProgress(mapWorkerStateToProgress(workerState))) {
+ return
+ }
+ } catch (error: unknown) {
+ if (!(error instanceof Error) || error.message !== 'Migration state not found') {
+ throw error
+ }
+ }
+
+ await sleep(MIGRATION_STATUS_POLL_INTERVAL_MS, signal)
+ }
+}
+
+async function watchMigrationViaSse(
+ approvalTxHash: string,
+ migrationConfig: ResolvedStakingMigrationConfig,
+ signal: AbortSignal,
+ onProgress: (progress: ApiProgressPayload) => boolean,
+): Promise {
+ const endpoint = `${buildMigrationApiUrl(migrationConfig.migrationApiBaseUrl!, MIGRATION_STATUS_STREAM_PATH)}?approvalTxHash=${encodeURIComponent(approvalTxHash)}`
+ const response = await fetch(endpoint, {
+ method: 'GET',
+ headers: buildApiHeaders(),
+ signal,
+ })
+
+ if (!response.ok || !response.body) {
+ throw new Error(`Migration SSE request failed (${response.status})`)
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+
+ while (!signal.aborted) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ buffer += decoder.decode(value, { stream: true })
+
+ let frameEnd = buffer.indexOf('\n\n')
+ while (frameEnd >= 0) {
+ const frame = buffer.slice(0, frameEnd)
+ buffer = buffer.slice(frameEnd + 2)
+ const parsedFrame = parseSseFrame(frame)
+
+ if (parsedFrame?.event === 'state' && parsedFrame.data && typeof parsedFrame.data === 'object') {
+ const payload = parsedFrame.data as Record
+ if (payload.status === 'not_found') {
+ frameEnd = buffer.indexOf('\n\n')
+ continue
+ }
+
+ if (onProgress(mapWorkerStateToProgress(parsedFrame.data as WorkerMigrationState))) {
+ return
+ }
+ }
+
+ if (parsedFrame?.event === 'done') {
+ return
+ }
+
+ frameEnd = buffer.indexOf('\n\n')
+ }
+ }
+}
+
+async function watchMigrationProgress(
+ approvalTxHash: string,
+ migrationConfig: ResolvedStakingMigrationConfig,
+ signal: AbortSignal,
+ onProgress: (progress: ApiProgressPayload) => boolean,
+): Promise {
+ try {
+ await watchMigrationViaSse(approvalTxHash, migrationConfig, signal, onProgress)
+ } catch {
+ await watchMigrationViaPolling(approvalTxHash, migrationConfig, signal, onProgress)
+ }
+}
+
+export function derivePrimaryAction(state: StakingMigrationWidgetState): StakingMigrationPrimaryAction {
+ if (state.isBalanceLoading) return 'none'
+ if (!state.hasRequiredConfig || state.status === 'missing-config') return 'none'
+ if (state.stakedAmountRaw <= 0n) return 'none'
+ if (!state.address) return 'connect'
+ if (state.status === 'wrong-network') return 'switch_chain'
+ if (state.status === 'approval-pending' || state.status === 'migrating') return 'none'
+ if (state.status === 'success') return 'refresh'
+ if (state.status === 'approval-failed' || state.status === 'error') return 'retry'
+ return 'migrate'
+}
+
+export function derivePrimaryLabel(
+ state: StakingMigrationWidgetState,
+ primaryAction: StakingMigrationPrimaryAction,
+): string {
+ if (state.isBalanceLoading) return 'Loading...'
+ if (!state.hasRequiredConfig || state.status === 'missing-config') return 'Setup required'
+ if (state.stakedAmountRaw <= 0n) return 'No balance'
+ if (state.status === 'approval-pending') return 'Approval pending'
+ if (state.status === 'migrating') return 'Migrating'
+
+ switch (primaryAction) {
+ case 'connect':
+ return 'Connect wallet'
+ case 'switch_chain':
+ return 'Switch to Fuse'
+ case 'migrate':
+ return 'Approve & Migrate'
+ case 'retry':
+ return state.status === 'approval-failed' ? 'Retry approval' : 'Retry migration'
+ case 'refresh':
+ return 'Refresh balance'
+ default:
+ return ''
+ }
+}
+
+export function useStakingMigrationAdapter({
+ migrationApiBaseUrl,
+ onMigrationSuccess,
+ onMigrationError,
+}: UseStakingMigrationAdapterOptions = {}): StakingMigrationWidgetAdapterResult {
+ const { address, chainId, isConnected, provider, connect } = useWallet()
+
+ const resolvedConfig = useMemo(
+ () => resolveMigrationConfig({ migrationApiBaseUrl }),
+ [migrationApiBaseUrl],
+ )
+
+ const [state, setState] = useState(() => ({
+ ...DEFAULT_STATE,
+ hasRequiredConfig: hasRequiredConfig(resolvedConfig),
+ }))
+
+ const actionInFlightRef = useRef(false)
+ const unmountedRef = useRef(false)
+ const migrationWatchAbortRef = useRef(null)
+
+ const publicClient = useMemo(
+ () =>
+ createPublicClient({
+ chain: FUSE_CHAIN,
+ transport: http(FUSE_CHAIN.rpcUrls.default.http[0]),
+ }),
+ [],
+ )
+
+ const walletClient = useMemo(() => {
+ if (!provider || !address) return null
+
+ return createWalletClient({
+ account: address as Address,
+ chain: FUSE_CHAIN,
+ transport: custom(provider as EIP1193Provider),
+ })
+ }, [provider, address])
+
+ const stopMigrationWatch = useCallback(() => {
+ migrationWatchAbortRef.current?.abort()
+ migrationWatchAbortRef.current = null
+ }, [])
+
+ const applyMigrationProgress = useCallback(
+ (progress: ApiProgressPayload, approvalTxHash: string) => {
+ if (progress.status === 'success') {
+ setState((previousState) => ({
+ ...previousState,
+ status: 'success',
+ approvalTxHash,
+ migrationId: progress.migrationId ?? approvalTxHash,
+ completedSteps: progress.completedSteps.length > 0 ? progress.completedSteps : MIGRATION_STEPS,
+ activeStep: null,
+ failedStep: null,
+ error: null,
+ }))
+
+ onMigrationSuccess?.({
+ address: address!,
+ approvalTxHash,
+ migrationId: progress.migrationId ?? approvalTxHash,
+ completedSteps:
+ progress.completedSteps.length > 0 ? progress.completedSteps : [...MIGRATION_STEPS],
+ })
+ return true
+ }
+
+ if (progress.status === 'error') {
+ const errorMessage = progress.reason ?? 'Migration failed during backend processing'
+
+ setState((previousState) => ({
+ ...previousState,
+ status: 'error',
+ approvalTxHash,
+ migrationId: progress.migrationId ?? approvalTxHash,
+ completedSteps: progress.completedSteps,
+ activeStep: progress.activeStep,
+ failedStep: progress.failedStep,
+ error: errorMessage,
+ }))
+
+ onMigrationError?.({
+ address: address ?? null,
+ reason: errorMessage,
+ failedStep: progress.failedStep,
+ })
+ return true
+ }
+
+ setState((previousState) => ({
+ ...previousState,
+ status: 'migrating',
+ approvalTxHash,
+ migrationId: progress.migrationId ?? approvalTxHash,
+ completedSteps: progress.completedSteps,
+ activeStep:
+ progress.activeStep ??
+ MIGRATION_STEPS.find((step) => !progress.completedSteps.includes(step)) ??
+ null,
+ failedStep: null,
+ error: null,
+ }))
+ return false
+ },
+ [address, onMigrationError, onMigrationSuccess],
+ )
+
+ const runMigrationJob = useCallback(
+ async (approvalTxHash: string) => {
+ stopMigrationWatch()
+
+ const abortController = new AbortController()
+ migrationWatchAbortRef.current = abortController
+ let reachedTerminalState = false
+
+ const watchPromise = watchMigrationProgress(
+ approvalTxHash,
+ resolvedConfig,
+ abortController.signal,
+ (progress) => {
+ const terminal = applyMigrationProgress(progress, approvalTxHash)
+ if (terminal) {
+ reachedTerminalState = true
+ stopMigrationWatch()
+ }
+ return terminal
+ },
+ ).catch((error: unknown) => {
+ if (reachedTerminalState || unmountedRef.current) return
+ if (error instanceof DOMException && error.name === 'AbortError') return
+ throw error
+ })
+
+ try {
+ const postResult = await submitMigrationStart(approvalTxHash, resolvedConfig)
+ if (postResult === 'completed' && !reachedTerminalState) {
+ reachedTerminalState = applyMigrationProgress(
+ createCompletedProgress(approvalTxHash),
+ approvalTxHash,
+ )
+ }
+ } catch (error: unknown) {
+ if (!reachedTerminalState) {
+ throw error
+ }
+ }
+
+ await watchPromise
+ },
+ [applyMigrationProgress, resolvedConfig, stopMigrationWatch],
+ )
+
+ const refreshStakeState = useCallback(async () => {
+ if (!isConnected || !address) {
+ setState((previousState) => ({
+ ...previousState,
+ status: hasRequiredConfig(resolvedConfig) ? 'summary' : 'missing-config',
+ address: null,
+ chainId: chainId ?? null,
+ stakedAmount: '0',
+ stakedAmountRaw: 0n,
+ isBalanceLoading: false,
+ hasRequiredConfig: hasRequiredConfig(resolvedConfig),
+ isWrongNetwork: false,
+ error: null,
+ }))
+ return
+ }
+
+ const configReady = hasRequiredConfig(resolvedConfig)
+ const wrongNetwork = chainId !== FUSE_CHAIN_ID
+
+ if (!configReady || wrongNetwork) {
+ setState((previousState) => ({
+ ...previousState,
+ status: !configReady ? 'missing-config' : 'wrong-network',
+ address,
+ chainId: chainId ?? null,
+ hasRequiredConfig: configReady,
+ isWrongNetwork: wrongNetwork,
+ isBalanceLoading: false,
+ error: null,
+ }))
+ return
+ }
+
+ setState((previousState) => ({
+ ...previousState,
+ status: previousState.status === 'success' ? 'success' : 'summary',
+ address,
+ chainId: chainId ?? null,
+ hasRequiredConfig: true,
+ isWrongNetwork: false,
+ isBalanceLoading: true,
+ error: null,
+ }))
+
+ try {
+ const [stakedAmountRaw, stakingTokenDecimals] = await Promise.all([
+ publicClient.readContract({
+ address: FUSE_STAKING_CONTRACT_ADDRESS,
+ abi: fuseStakingAbi,
+ functionName: 'balanceOf',
+ args: [address as Address],
+ }),
+ publicClient.readContract({
+ address: FUSE_STAKING_CONTRACT_ADDRESS,
+ abi: fuseStakingAbi,
+ functionName: 'decimals',
+ }),
+ ])
+
+ const stakedAmount = formatUnits(stakedAmountRaw, stakingTokenDecimals)
+
+ if (unmountedRef.current) return
+
+ setState((previousState) => ({
+ ...previousState,
+ status: previousState.status === 'success' ? 'success' : 'summary',
+ address,
+ chainId: chainId ?? null,
+ stakedAmount,
+ stakedAmountRaw,
+ isBalanceLoading: false,
+ error: null,
+ }))
+ } catch (error: unknown) {
+ if (unmountedRef.current) return
+ setState((previousState) => ({
+ ...previousState,
+ status: 'error',
+ address,
+ chainId: chainId ?? null,
+ isBalanceLoading: false,
+ error: formatErrorMessage(error),
+ }))
+ }
+ }, [address, chainId, isConnected, publicClient, resolvedConfig])
+
+ useEffect(() => {
+ unmountedRef.current = false
+ return () => {
+ unmountedRef.current = true
+ stopMigrationWatch()
+ }
+ }, [stopMigrationWatch])
+
+ useEffect(() => {
+ void refreshStakeState()
+ }, [refreshStakeState])
+
+ const startApprovalAndMigration = useCallback(async () => {
+ if (actionInFlightRef.current) return
+
+ const configReady = hasRequiredConfig(resolvedConfig)
+ if (!configReady) {
+ setState((previousState) => ({
+ ...previousState,
+ status: 'missing-config',
+ hasRequiredConfig: false,
+ error: 'Migration backend configuration is unavailable',
+ }))
+ return
+ }
+
+ if (!isConnected || !address) {
+ await connect()
+ return
+ }
+
+ if (chainId !== FUSE_CHAIN_ID) {
+ setState((previousState) => ({
+ ...previousState,
+ status: 'wrong-network',
+ isWrongNetwork: true,
+ error: 'Switch wallet network to Fuse before approving migration',
+ }))
+ return
+ }
+
+ if (!walletClient) {
+ setState((previousState) => ({
+ ...previousState,
+ status: 'approval-failed',
+ error: 'Wallet client is not available for Fuse approval',
+ }))
+ return
+ }
+
+ if (state.stakedAmountRaw <= 0n) {
+ setState((previousState) => ({
+ ...previousState,
+ status: 'summary',
+ error: null,
+ }))
+ return
+ }
+
+ actionInFlightRef.current = true
+ let approvalConfirmed = false
+
+ setState((previousState) => ({
+ ...previousState,
+ status: 'approval-pending',
+ error: null,
+ failedStep: null,
+ }))
+
+ try {
+ const approvalTxHash = await walletClient.writeContract({
+ address: FUSE_STAKING_CONTRACT_ADDRESS,
+ abi: fuseStakingAbi,
+ functionName: 'approve',
+ args: [resolvedConfig.migrationOperator!, state.stakedAmountRaw],
+ })
+
+ const approvalReceipt = await publicClient.waitForTransactionReceipt({
+ hash: approvalTxHash,
+ })
+
+ if (approvalReceipt.status !== 'success') {
+ throw new Error('Approval transaction did not confirm successfully')
+ }
+
+ approvalConfirmed = true
+
+ setState((previousState) => ({
+ ...previousState,
+ status: 'migrating',
+ approvalTxHash,
+ migrationId: approvalTxHash,
+ }))
+
+ await runMigrationJob(approvalTxHash)
+ } catch (error: unknown) {
+ const errorMessage = formatErrorMessage(error)
+ const isApprovalFailure = !approvalConfirmed
+
+ setState((previousState) => ({
+ ...previousState,
+ status: isApprovalFailure ? 'approval-failed' : 'error',
+ error: errorMessage,
+ }))
+
+ onMigrationError?.({
+ address: address ?? null,
+ reason: errorMessage,
+ failedStep: state.activeStep,
+ })
+ } finally {
+ actionInFlightRef.current = false
+ stopMigrationWatch()
+ }
+ }, [
+ address,
+ chainId,
+ connect,
+ isConnected,
+ onMigrationError,
+ publicClient,
+ resolvedConfig,
+ runMigrationJob,
+ state.activeStep,
+ state.stakedAmountRaw,
+ stopMigrationWatch,
+ walletClient,
+ ])
+
+ const retryMigration = useCallback(async () => {
+ if (!state.approvalTxHash) {
+ await startApprovalAndMigration()
+ return
+ }
+
+ if (!hasRequiredConfig(resolvedConfig)) {
+ setState((previousState) => ({
+ ...previousState,
+ status: 'missing-config',
+ error: 'Migration backend configuration is unavailable',
+ }))
+ return
+ }
+
+ actionInFlightRef.current = true
+
+ setState((previousState) => ({
+ ...previousState,
+ status: 'migrating',
+ error: null,
+ }))
+
+ try {
+ await runMigrationJob(state.approvalTxHash)
+ } catch (error: unknown) {
+ const errorMessage = formatErrorMessage(error)
+ setState((previousState) => ({
+ ...previousState,
+ status: 'error',
+ error: errorMessage,
+ }))
+ } finally {
+ actionInFlightRef.current = false
+ stopMigrationWatch()
+ }
+ }, [resolvedConfig, runMigrationJob, startApprovalAndMigration, state.approvalTxHash, stopMigrationWatch])
+
+ const switchToFuse = useCallback(async () => {
+ if (!provider) return
+
+ try {
+ await (provider as EIP1193Provider).request({
+ method: 'wallet_switchEthereumChain',
+ params: [{ chainId: `0x${FUSE_CHAIN_ID.toString(16)}` }],
+ })
+ } catch {
+ } finally {
+ await refreshStakeState()
+ }
+ }, [provider, refreshStakeState])
+
+ const derivedState = useMemo(() => {
+ const primaryAction = derivePrimaryAction(state)
+ const primaryLabel = derivePrimaryLabel(state, primaryAction)
+ return {
+ ...state,
+ primaryAction,
+ primaryLabel,
+ }
+ }, [state])
+
+ return {
+ state: derivedState,
+ actions: {
+ connect,
+ switchToFuse,
+ refresh: refreshStakeState,
+ approveAndMigrate: startApprovalAndMigration,
+ retryMigration,
+ },
+ }
+}
diff --git a/packages/staking-migration-widget/src/element.ts b/packages/staking-migration-widget/src/element.ts
new file mode 100644
index 0000000..1532cb6
--- /dev/null
+++ b/packages/staking-migration-widget/src/element.ts
@@ -0,0 +1,13 @@
+import { createMiniAppElement } from '@goodwidget/embed'
+import { StakingMigrationWidget } from './StakingMigrationWidget'
+import type React from 'react'
+
+// This custom element wraps the staking migration widget for HTML integrators.
+export const StakingMigrationWidgetElement = createMiniAppElement(
+ StakingMigrationWidget as React.ComponentType>,
+ {
+ shadow: true,
+ defaultTheme: 'light',
+ events: ['migration-success', 'migration-error'],
+ },
+)
diff --git a/packages/staking-migration-widget/src/index.ts b/packages/staking-migration-widget/src/index.ts
new file mode 100644
index 0000000..3cc38e0
--- /dev/null
+++ b/packages/staking-migration-widget/src/index.ts
@@ -0,0 +1,29 @@
+export { stakingMigrationIntegration } from './integration'
+export type { StakingMigrationIntegration } from './integration'
+
+export { StakingMigrationWidget } from './StakingMigrationWidget'
+export {
+ derivePrimaryAction,
+ derivePrimaryLabel,
+ useStakingMigrationAdapter,
+} from './adapter'
+export type { UseStakingMigrationAdapterOptions } from './adapter'
+
+export type {
+ MigrationStep,
+ StakingMigrationErrorDetail,
+ StakingMigrationSuccessDetail,
+ StakingMigrationWidgetAdapterFactory,
+ StakingMigrationWidgetAdapterFactoryInput,
+ StakingMigrationWidgetAdapterResult,
+ StakingMigrationWidgetActions,
+ StakingMigrationWidgetProps,
+ StakingMigrationWidgetState,
+ StakingMigrationWidgetStatus,
+ StakingMigrationPrimaryAction,
+} from './widgetRuntimeContract'
+
+export {
+ FUSE_CHAIN_ID,
+ FUSE_STAKING_CONTRACT_ADDRESS,
+} from './widgetRuntimeContract'
diff --git a/packages/staking-migration-widget/src/integration.ts b/packages/staking-migration-widget/src/integration.ts
new file mode 100644
index 0000000..c4dd117
--- /dev/null
+++ b/packages/staking-migration-widget/src/integration.ts
@@ -0,0 +1,19 @@
+export const stakingMigrationIntegration = {
+ id: 'staking-migration',
+ capabilitySource: 'stakingMigrationCapabilities',
+ uses: ['approve', 'migrate', 'status', 'statusStream'],
+ chains: [122],
+ states: [
+ 'summary',
+ 'approval-pending',
+ 'approval-failed',
+ 'migrating',
+ 'success',
+ 'error',
+ 'wrong-network',
+ 'missing-config',
+ ],
+ events: ['migration-success', 'migration-error'],
+} as const
+
+export type StakingMigrationIntegration = typeof stakingMigrationIntegration
diff --git a/packages/staking-migration-widget/src/migrationEnvironments.ts b/packages/staking-migration-widget/src/migrationEnvironments.ts
new file mode 100644
index 0000000..aad604b
--- /dev/null
+++ b/packages/staking-migration-widget/src/migrationEnvironments.ts
@@ -0,0 +1,27 @@
+import type { Address } from 'viem'
+
+export const stakingMigrationCapabilities = {
+ environments: ['production', 'staging', 'development'] as const,
+ chains: [122],
+ events: ['migration-success', 'migration-error'],
+} as const
+
+export const MIGRATION_OPERATOR_ADDRESS: Address = '0xE3441bA0863AEFBf28eca5F6fAAFb4A2B608F3A1'
+
+export interface ResolvedStakingMigrationConfig {
+ migrationApiBaseUrl?: string
+ migrationOperator: Address
+}
+
+export interface ResolveMigrationConfigInput {
+ migrationApiBaseUrl?: string
+}
+
+export function resolveMigrationConfig(
+ input: ResolveMigrationConfigInput = {},
+): ResolvedStakingMigrationConfig {
+ return {
+ migrationApiBaseUrl: input.migrationApiBaseUrl,
+ migrationOperator: MIGRATION_OPERATOR_ADDRESS,
+ }
+}
diff --git a/packages/staking-migration-widget/src/register.ts b/packages/staking-migration-widget/src/register.ts
new file mode 100644
index 0000000..e788b1e
--- /dev/null
+++ b/packages/staking-migration-widget/src/register.ts
@@ -0,0 +1,13 @@
+import { StakingMigrationWidgetElement } from './element'
+
+const TAG_NAME = 'gw-staking-migration-widget'
+
+// This helper registers the default staking migration custom element tag.
+export function register(tagName: string = TAG_NAME): string {
+ if (!customElements.get(tagName)) {
+ customElements.define(tagName, StakingMigrationWidgetElement)
+ }
+ return tagName
+}
+
+register()
diff --git a/packages/staking-migration-widget/src/widgetRuntimeContract.ts b/packages/staking-migration-widget/src/widgetRuntimeContract.ts
new file mode 100644
index 0000000..b044984
--- /dev/null
+++ b/packages/staking-migration-widget/src/widgetRuntimeContract.ts
@@ -0,0 +1,90 @@
+import type { Address } from 'viem'
+import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui'
+export const FUSE_CHAIN_ID = 122
+
+export const FUSE_STAKING_CONTRACT_ADDRESS: Address = '0xB7C3e738224625289C573c54d402E9Be46205546'
+
+export type MigrationStep = 'unstake' | 'bridge sent' | 'bridge received' | 'stake'
+
+export type StakingMigrationWidgetStatus =
+ | 'summary'
+ | 'approval-pending'
+ | 'approval-failed'
+ | 'migrating'
+ | 'success'
+ | 'error'
+ | 'wrong-network'
+ | 'missing-config'
+
+export type StakingMigrationPrimaryAction =
+ | 'connect'
+ | 'switch_chain'
+ | 'migrate'
+ | 'retry'
+ | 'refresh'
+ | 'none'
+
+export interface StakingMigrationSuccessDetail {
+ address: string
+ approvalTxHash: string
+ migrationId: string
+ completedSteps: MigrationStep[]
+}
+
+export interface StakingMigrationErrorDetail {
+ address: string | null
+ reason: string
+ failedStep: MigrationStep | null
+}
+
+export interface StakingMigrationWidgetState {
+ status: StakingMigrationWidgetStatus
+ address: string | null
+ chainId: number | null
+ stakedAmount: string
+ stakedAmountRaw: bigint
+ stakedTokenSymbol: 'sG$'
+ hasRequiredConfig: boolean
+ isWrongNetwork: boolean
+ isBalanceLoading: boolean
+ completedSteps: MigrationStep[]
+ activeStep: MigrationStep | null
+ failedStep: MigrationStep | null
+ approvalTxHash: string | null
+ migrationId: string | null
+ error: string | null
+ primaryAction: StakingMigrationPrimaryAction
+ primaryLabel: string
+}
+
+export interface StakingMigrationWidgetActions {
+ connect: () => Promise
+ switchToFuse: () => Promise
+ refresh: () => Promise
+ approveAndMigrate: () => Promise
+ retryMigration: () => Promise
+}
+
+export interface StakingMigrationWidgetAdapterResult {
+ state: StakingMigrationWidgetState
+ actions: StakingMigrationWidgetActions
+}
+
+export interface StakingMigrationWidgetAdapterFactoryInput {
+ migrationApiBaseUrl?: string
+}
+
+export type StakingMigrationWidgetAdapterFactory = (
+ input: StakingMigrationWidgetAdapterFactoryInput,
+) => StakingMigrationWidgetAdapterResult
+
+export interface StakingMigrationWidgetProps {
+ provider?: unknown
+ config?: GoodWidgetConfig
+ defaultTheme?: 'light' | 'dark'
+ themeOverrides?: GoodWidgetThemeOverrides
+ migrationApiBaseUrl?: string
+ onMigrationSuccess?: (detail: StakingMigrationSuccessDetail) => void
+ onMigrationError?: (detail: StakingMigrationErrorDetail) => void
+ adapterFactory?: StakingMigrationWidgetAdapterFactory
+}
diff --git a/packages/staking-migration-widget/tsconfig.build.json b/packages/staking-migration-widget/tsconfig.build.json
new file mode 100644
index 0000000..54871c4
--- /dev/null
+++ b/packages/staking-migration-widget/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "paths": {
+ "react-native": ["./node_modules/react-native-web"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/packages/staking-migration-widget/tsconfig.json b/packages/staking-migration-widget/tsconfig.json
new file mode 100644
index 0000000..006b6f4
--- /dev/null
+++ b/packages/staking-migration-widget/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "paths": {
+ "@goodwidget/core": ["../core/src/index.ts"],
+ "@goodwidget/core/*": ["../core/src/*"],
+ "@goodwidget/ui": ["../ui/src/index.ts"],
+ "react-native": ["./node_modules/react-native-web"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/packages/staking-migration-widget/tsup.config.ts b/packages/staking-migration-widget/tsup.config.ts
new file mode 100644
index 0000000..95c2de0
--- /dev/null
+++ b/packages/staking-migration-widget/tsup.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+ entry: {
+ index: 'src/index.ts',
+ element: 'src/element.ts',
+ register: 'src/register.ts',
+ },
+ format: ['esm', 'cjs'],
+ dts: true,
+ sourcemap: true,
+ clean: true,
+ tsconfig: 'tsconfig.build.json',
+ external: ['react', 'react-dom', 'react-native', 'react-native-web'],
+})
diff --git a/packages/ui/src/components/CircularActionButton.tsx b/packages/ui/src/components/CircularActionButton.tsx
new file mode 100644
index 0000000..9efb302
--- /dev/null
+++ b/packages/ui/src/components/CircularActionButton.tsx
@@ -0,0 +1,106 @@
+import React from 'react'
+import { createComponent } from '../createComponent'
+import { ButtonFrame, ButtonText } from './Button'
+import { Spinner } from '../components-test/Spinner'
+import { XStack, YStack } from '../components-test/Stacks'
+
+const ClaimActionButton = createComponent(ButtonFrame, {
+ name: 'ClaimActionButton',
+ extends: 'Button',
+ width: 160,
+ height: 160,
+ borderRadius: 9999,
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 0,
+ shadowOpacity: 0,
+ overflow: 'visible' as const,
+ position: 'relative' as const,
+ paddingHorizontal: 0,
+ hoverStyle: { backgroundColor: '$backgroundTransparent' },
+ pressStyle: { backgroundColor: '$backgroundTransparent', opacity: 0.95 },
+ focusStyle: { backgroundColor: '$backgroundTransparent', outlineStyle: 'none' },
+})
+
+const ClaimActionGlow = createComponent(YStack, {
+ name: 'ClaimActionGlow',
+ position: 'absolute' as const,
+ top: -16,
+ right: -16,
+ bottom: -16,
+ left: -16,
+ borderRadius: 9999,
+ backgroundColor: '$primary',
+ opacity: 0.45,
+})
+
+const ClaimActionRing = createComponent(YStack, {
+ name: 'ClaimActionRing',
+ position: 'absolute' as const,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ borderRadius: 9999,
+ backgroundColor: '$primary',
+})
+
+const ClaimActionInner = createComponent(YStack, {
+ name: 'ClaimActionInner',
+ position: 'absolute' as const,
+ top: 2,
+ right: 2,
+ bottom: 2,
+ left: 2,
+ borderRadius: 9999,
+ backgroundColor: '$backgroundDark',
+})
+
+export interface CircularActionButtonProps {
+ label: string
+ disabled?: boolean
+ pending?: boolean
+ onPress?: () => void
+}
+
+export function CircularActionButton({
+ label,
+ disabled = false,
+ pending = false,
+ onPress,
+}: CircularActionButtonProps) {
+ const labelColor = pending || disabled ? '$grey600' : '$primary'
+
+ return (
+
+
+
+
+
+
+ {pending ? (
+
+
+ {label}
+
+
+
+ ) : (
+
+ {label}
+
+ )}
+
+
+ )
+}
diff --git a/packages/ui/src/components/Stepper.tsx b/packages/ui/src/components/Stepper.tsx
new file mode 100644
index 0000000..8f21fc7
--- /dev/null
+++ b/packages/ui/src/components/Stepper.tsx
@@ -0,0 +1,281 @@
+import React, { useEffect, useRef } from 'react'
+import type { ReactNode } from 'react'
+import { Icon } from './Icon'
+import { Text } from './Text'
+import { XStack, YStack } from '../components-test/Stacks'
+import { createComponent } from '../createComponent'
+
+const MARKER_SIZE = 28
+const ROW_GAP_PX = 8
+
+export type StepperStepStatus = 'pending' | 'active' | 'completed' | 'failed' | 'attention'
+
+export interface StepperStepItem {
+ id: string
+ title: string
+ description?: string
+ status: StepperStepStatus
+}
+
+export interface StepperProps {
+ steps: StepperStepItem[]
+ activeStepId?: string | null
+ header?: ReactNode
+ maxHeight?: number
+}
+
+function connectorColor(status: StepperStepStatus): string {
+ if (status === 'completed') return '$borderColorFocus'
+ return '$borderColor'
+}
+
+function statusLabel(status: StepperStepStatus): string {
+ if (status === 'failed') return 'Needs attention'
+ if (status === 'completed') return 'Completed'
+ if (status === 'active' || status === 'attention') return 'In progress'
+ return 'Pending'
+}
+
+function statusColor(status: StepperStepStatus): string | undefined {
+ if (status === 'failed' || status === 'attention') return '$warning'
+ if (status === 'completed') return '$success'
+ if (status === 'active') return '$primary'
+ return undefined
+}
+
+type MarkerVariant = 'completed' | 'active' | 'failed' | 'pending' | 'attention'
+
+function markerVariant(status: StepperStepStatus): MarkerVariant {
+ if (status === 'completed') return 'completed'
+ if (status === 'failed') return 'failed'
+ if (status === 'attention') return 'attention'
+ if (status === 'active') return 'active'
+ return 'pending'
+}
+
+function StepperMarker({ variant }: { variant: MarkerVariant }) {
+ if (variant === 'pending') {
+ return (
+
+ )
+ }
+
+ if (variant === 'attention') {
+ return (
+
+ )
+ }
+
+ const fillColor = variant === 'failed' ? '$warning' : '$borderColorFocus'
+ const glyph =
+ variant === 'completed' ? (
+
+ ) : variant === 'failed' ? (
+
+ ) : (
+
+ )
+
+ return (
+
+ {glyph}
+
+ )
+}
+
+interface StepperStepRowProps {
+ step: StepperStepItem
+ isFirst: boolean
+ isLast: boolean
+ connectorAboveColor?: string
+ stepRef: (node: HTMLElement | null) => void
+}
+
+function StepperStepRow({
+ step,
+ isFirst,
+ isLast,
+ connectorAboveColor,
+ stepRef,
+}: StepperStepRowProps) {
+ const isActive = step.status === 'active' || step.status === 'attention'
+ const isFailed = step.status === 'failed'
+ const isAttention = step.status === 'attention'
+ const connectorBelowColor = connectorColor(step.status)
+ const titleColor = isAttention
+ ? '$warning'
+ : isFailed
+ ? '$warning'
+ : step.status === 'completed' || isActive
+ ? '$color'
+ : '$placeholderColor'
+ const contentBackgroundColor = isActive ? '$backgroundHover' : undefined
+ const contentBorderColor = isAttention
+ ? '$warning'
+ : isFailed
+ ? '$warning'
+ : isActive
+ ? '$borderColorFocus'
+ : 'transparent'
+ const showDescription = Boolean(step.description) && (isActive || isFailed)
+ const railOffset = isActive || isFailed ? '$2' : '$1'
+
+ return (
+
+
+
+ {!isFirst && connectorAboveColor && (
+
+ )}
+
+ {!isLast && (
+
+ )}
+
+
+
+
+
+
+ {step.title}
+
+ {isAttention && }
+
+
+ {statusLabel(step.status)}
+
+
+ {showDescription && (
+
+ {step.description}
+
+ )}
+
+
+
+ )
+}
+
+const SCROLL_HIDE_CLASS = 'gw-stepper-scroll-hide'
+
+let scrollbarStyleInjected = false
+
+function ensureScrollbarHidden() {
+ if (scrollbarStyleInjected || typeof document === 'undefined') return
+ scrollbarStyleInjected = true
+ const style = document.createElement('style')
+ style.id = 'gw-stepper-scroll-hide'
+ style.textContent = `.${SCROLL_HIDE_CLASS}::-webkit-scrollbar { display: none; width: 0; height: 0; }`
+ document.head.appendChild(style)
+}
+
+const StepperScrollFrame = createComponent(YStack, {
+ name: 'Stepper',
+ width: '100%',
+ overflow: 'auto' as const,
+})
+
+function resolveActiveStepId(steps: StepperStepItem[], activeStepId?: string | null): string | null {
+ if (activeStepId) return activeStepId
+ const prioritized = steps.find(
+ (step) => step.status === 'active' || step.status === 'failed' || step.status === 'attention',
+ )
+ return prioritized?.id ?? null
+}
+
+export function Stepper({ steps, activeStepId, header, maxHeight = 360 }: StepperProps) {
+ const stepRefs = useRef(new Map())
+ const resolvedActiveStepId = resolveActiveStepId(steps, activeStepId)
+
+ ensureScrollbarHidden()
+
+ useEffect(() => {
+ if (!resolvedActiveStepId) return undefined
+
+ const frame = requestAnimationFrame(() => {
+ const node = stepRefs.current.get(resolvedActiveStepId)
+ node?.scrollIntoView({ block: 'center', behavior: 'smooth' })
+ })
+
+ return () => cancelAnimationFrame(frame)
+ }, [resolvedActiveStepId, steps])
+
+ return (
+
+ {header}
+
+
+ {steps.map((step, index) => (
+ {
+ if (node) {
+ stepRefs.current.set(step.id, node)
+ return
+ }
+ stepRefs.current.delete(step.id)
+ }}
+ />
+ ))}
+
+
+
+ )
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 27a33dc..17ad19b 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -93,6 +93,10 @@ export { WalletInfo } from './components-test/WalletInfo'
// Patterns / Composites
export { MiniAppShell } from './components/MiniAppShell'
+export { CircularActionButton } from './components/CircularActionButton'
+export type { CircularActionButtonProps } from './components/CircularActionButton'
+export { Stepper } from './components/Stepper'
+export type { StepperProps, StepperStepItem, StepperStepStatus } from './components/Stepper'
export { WidgetTabs } from './components/WidgetTabs'
export { ActionSheet } from './components-test/ActionSheet'
export { TokenInput } from './components-test/TokenInput'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 04afa8b..4276ed7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -130,6 +130,9 @@ importers:
'@goodwidget/embed':
specifier: workspace:*
version: link:../../packages/embed
+ '@goodwidget/staking-migration-widget':
+ specifier: workspace:*
+ version: link:../../packages/staking-migration-widget
'@goodwidget/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -170,6 +173,9 @@ importers:
'@goodwidget/core':
specifier: workspace:*
version: link:../../packages/core
+ '@goodwidget/staking-migration-widget':
+ specifier: workspace:*
+ version: link:../../packages/staking-migration-widget
'@goodwidget/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -344,6 +350,40 @@ importers:
specifier: ^5.7.0
version: 5.9.3
+ packages/staking-migration-widget:
+ dependencies:
+ '@goodwidget/core':
+ specifier: workspace:*
+ version: link:../core
+ '@goodwidget/embed':
+ specifier: workspace:*
+ version: link:../embed
+ '@goodwidget/ui':
+ specifier: workspace:*
+ version: link:../ui
+ viem:
+ specifier: ^2.0.0
+ version: 2.48.4(typescript@5.9.3)
+ devDependencies:
+ '@types/react':
+ specifier: ^18.3.0
+ version: 18.3.28
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.7(@types/react@18.3.28)
+ react:
+ specifier: ^18.3.0
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.0
+ version: 18.3.1(react@18.3.1)
+ tsup:
+ specifier: ^8.4.0
+ version: 8.5.1(@swc/core@1.15.30)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3)
+ typescript:
+ specifier: ^5.7.0
+ version: 5.9.3
+
packages/ui:
dependencies:
'@tamagui/animations-react-native':
diff --git a/tests/widgets/staking-migration-widget/states.spec.ts b/tests/widgets/staking-migration-widget/states.spec.ts
new file mode 100644
index 0000000..64c0f79
--- /dev/null
+++ b/tests/widgets/staking-migration-widget/states.spec.ts
@@ -0,0 +1,90 @@
+import { expect, test, type Page } from '@playwright/test'
+
+// This map keeps each test state tied to one Storybook story for visual smoke coverage.
+const STORY_IDS = {
+ empty: '/iframe.html?id=widgets-stakingmigrationwidget--empty-balance&viewMode=story',
+ ready: '/iframe.html?id=widgets-stakingmigrationwidget--ready&viewMode=story',
+ wrongNetwork: '/iframe.html?id=widgets-stakingmigrationwidget--wrong-network&viewMode=story',
+ approvalPending: '/iframe.html?id=widgets-stakingmigrationwidget--approval-pending&viewMode=story',
+ migrating: '/iframe.html?id=widgets-stakingmigrationwidget--migrating&viewMode=story',
+ success: '/iframe.html?id=widgets-stakingmigrationwidget--success&viewMode=story',
+ error: '/iframe.html?id=widgets-stakingmigrationwidget--error&viewMode=story',
+} as const
+
+async function gotoStory(page: Page, storyUrl: string): Promise {
+ await page.goto(storyUrl)
+ await page.waitForLoadState('domcontentloaded')
+}
+
+test('StakingMigrationWidget empty balance summary', async ({ page }) => {
+ await gotoStory(page, STORY_IDS.empty)
+ await expect(page.getByText('No migration available for this wallet yet.')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'No balance' })).toBeDisabled()
+ await page.screenshot({
+ path: 'tests/widgets/staking-migration-widget/test-results/smw-01-empty-balance.png',
+ fullPage: true,
+ })
+})
+
+test('StakingMigrationWidget ready summary', async ({ page }) => {
+ await gotoStory(page, STORY_IDS.ready)
+ await expect(page.getByText('Amount to migrate')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Approve & Migrate' })).toBeEnabled()
+ await page.screenshot({
+ path: 'tests/widgets/staking-migration-widget/test-results/smw-02-ready.png',
+ fullPage: true,
+ })
+})
+
+test('StakingMigrationWidget wrong network notice', async ({ page }) => {
+ await gotoStory(page, STORY_IDS.wrongNetwork)
+ await expect(page.getByText('Approve on Fuse')).toBeVisible()
+ await expect(page.getByText('In progress')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Switch to Fuse' })).toHaveCount(1)
+ await page.screenshot({
+ path: 'tests/widgets/staking-migration-widget/test-results/smw-03-wrong-network.png',
+ fullPage: true,
+ })
+})
+
+test('StakingMigrationWidget approval pending notice', async ({ page }) => {
+ await gotoStory(page, STORY_IDS.approvalPending)
+ await expect(page.getByText('Confirm the approval transaction in your wallet.')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Approval pending' })).toBeDisabled()
+ await page.screenshot({
+ path: 'tests/widgets/staking-migration-widget/test-results/smw-04-approval-pending.png',
+ fullPage: true,
+ })
+})
+
+test('StakingMigrationWidget migrating timeline', async ({ page }) => {
+ await gotoStory(page, STORY_IDS.migrating)
+ await expect(page.getByText('Migration journey')).toBeVisible()
+ await expect(page.getByText('Bridge Received', { exact: true })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Migrating' })).toBeDisabled()
+ await page.screenshot({
+ path: 'tests/widgets/staking-migration-widget/test-results/smw-05-migrating.png',
+ fullPage: true,
+ })
+})
+
+test('StakingMigrationWidget success state', async ({ page }) => {
+ await gotoStory(page, STORY_IDS.success)
+ await expect(page.getByText('Completed').first()).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Refresh balance' })).toHaveCount(1)
+ await page.screenshot({
+ path: 'tests/widgets/staking-migration-widget/test-results/smw-06-success.png',
+ fullPage: true,
+ })
+})
+
+test('StakingMigrationWidget error state', async ({ page }) => {
+ await gotoStory(page, STORY_IDS.error)
+ await expect(page.getByText('Failed', { exact: true })).toBeVisible()
+ await expect(page.getByText('Bridge finalization timeout')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Retry migration' })).toHaveCount(1)
+ await page.screenshot({
+ path: 'tests/widgets/staking-migration-widget/test-results/smw-07-error.png',
+ fullPage: true,
+ })
+})
diff --git a/tests/widgets/staking-migration-widget/test-results/smw-01-empty-balance.png b/tests/widgets/staking-migration-widget/test-results/smw-01-empty-balance.png
new file mode 100644
index 0000000..0d0e65a
Binary files /dev/null and b/tests/widgets/staking-migration-widget/test-results/smw-01-empty-balance.png differ
diff --git a/tests/widgets/staking-migration-widget/test-results/smw-02-ready.png b/tests/widgets/staking-migration-widget/test-results/smw-02-ready.png
new file mode 100644
index 0000000..3e113e7
Binary files /dev/null and b/tests/widgets/staking-migration-widget/test-results/smw-02-ready.png differ
diff --git a/tests/widgets/staking-migration-widget/test-results/smw-03-wrong-network.png b/tests/widgets/staking-migration-widget/test-results/smw-03-wrong-network.png
new file mode 100644
index 0000000..a27ddd1
Binary files /dev/null and b/tests/widgets/staking-migration-widget/test-results/smw-03-wrong-network.png differ
diff --git a/tests/widgets/staking-migration-widget/test-results/smw-04-approval-pending.png b/tests/widgets/staking-migration-widget/test-results/smw-04-approval-pending.png
new file mode 100644
index 0000000..0d8e281
Binary files /dev/null and b/tests/widgets/staking-migration-widget/test-results/smw-04-approval-pending.png differ
diff --git a/tests/widgets/staking-migration-widget/test-results/smw-05-migrating.png b/tests/widgets/staking-migration-widget/test-results/smw-05-migrating.png
new file mode 100644
index 0000000..32ddadd
Binary files /dev/null and b/tests/widgets/staking-migration-widget/test-results/smw-05-migrating.png differ
diff --git a/tests/widgets/staking-migration-widget/test-results/smw-06-success.png b/tests/widgets/staking-migration-widget/test-results/smw-06-success.png
new file mode 100644
index 0000000..1502450
Binary files /dev/null and b/tests/widgets/staking-migration-widget/test-results/smw-06-success.png differ
diff --git a/tests/widgets/staking-migration-widget/test-results/smw-07-error.png b/tests/widgets/staking-migration-widget/test-results/smw-07-error.png
new file mode 100644
index 0000000..decd05d
Binary files /dev/null and b/tests/widgets/staking-migration-widget/test-results/smw-07-error.png differ