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