diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 66e2acc4..281c8d2c 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -16,6 +16,7 @@ "@goodwidget/goodreserve-widget": "workspace:*", "@goodwidget/governance-widget": "workspace:*", "@goodwidget/streaming-widget": "workspace:*", + "@goodwidget/bridging-widget": "workspace:*", "@goodwidget/staking-migration-widget": "workspace:*", "@goodwidget/ai-credits-widget": "workspace:*", "@goodwidget/gooddata-widget": "workspace:*", diff --git a/examples/storybook/src/stories/bridging-widget/BridgingWidgetQA.stories.tsx b/examples/storybook/src/stories/bridging-widget/BridgingWidgetQA.stories.tsx new file mode 100644 index 00000000..9ddea3fc --- /dev/null +++ b/examples/storybook/src/stories/bridging-widget/BridgingWidgetQA.stories.tsx @@ -0,0 +1,232 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { BridgingWidget } from '@goodwidget/bridging-widget' +import { + BelowMinAmountStory, + ConfigErrorStory, + ConfigLoadingStory, + CustodialLocalFixtureStory, + FaqTabStory, + HistoryDetailModalOpenStory, + HistoryEmptyStory, + HistoryErrorStory, + HistoryLoadingStory, + HistoryPopulatedStory, + InjectedWalletStory, + InsufficientBalanceStory, + LightThemePopulatedStory, + NeedsApprovalStory, + NoWalletStory, + QuoteErrorStory, + QuoteLoadingStory, + ReadyToBridgeStory, + ReviewOpenStory, + RouteNotSupportedStory, + SubmissionApprovingStory, + SubmissionBridgingStory, + SubmissionCompletedStory, + SubmissionFailedApprovingStory, + SubmissionFailedBridgingStory, + WrongChainStory, +} from '../helpers/bridgingWidgetStories' +import { BRAND_PRESET_OPTIONS, brandPresetOverrides, type BrandPreset } from '../helpers/themeOverridePresets' + +interface BridgingWidgetQAArgs { + defaultTheme: 'light' | 'dark' + brandPreset: BrandPreset +} + +const meta: Meta = { + title: 'QA/BridgingWidget/Runtime Fixtures', + component: BridgingWidget, + tags: ['autodocs', 'qa'], + parameters: { layout: 'padded' }, + argTypes: { + defaultTheme: { + control: 'radio', + options: ['dark', 'light'], + description: 'Base theme applied via the widget’s own defaultTheme prop.', + }, + brandPreset: { + control: 'select', + options: BRAND_PRESET_OPTIONS, + description: 'Sample host-branding themeOverrides preset.', + }, + }, + args: { + defaultTheme: 'dark', + brandPreset: 'None', + }, +} + +export default meta +type Story = StoryObj + +export const CustodialLocalFixture: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const InjectedWallet: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const NoWallet: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const WrongChain: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const RouteNotSupported: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const ConfigLoading: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const ConfigError: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const QuoteLoading: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const QuoteError: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const BelowMinAmount: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const InsufficientBalance: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const NeedsApproval: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const ReadyToBridge: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const ReviewOpen: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const SubmissionApproving: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const SubmissionBridging: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const SubmissionCompleted: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const SubmissionFailedApproving: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const SubmissionFailedBridging: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const HistoryLoading: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const HistoryEmpty: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const HistoryError: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const HistoryPopulated: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const HistoryDetailModalOpen: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const FaqTab: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +/** Always mounts with defaultTheme="light" regardless of the control, to demonstrate the + * explicit light-theme branch; brandPreset still applies on top. */ +export const LightThemePopulated: Story = { + render: ({ brandPreset }) => ( + + ), +} diff --git a/examples/storybook/src/stories/helpers/bridgingWidgetStories.tsx b/examples/storybook/src/stories/helpers/bridgingWidgetStories.tsx new file mode 100644 index 00000000..c3bd97d3 --- /dev/null +++ b/examples/storybook/src/stories/helpers/bridgingWidgetStories.tsx @@ -0,0 +1,666 @@ +import React from 'react' +import { + BRIDGING_CHAINS, + BridgingWidget, + BridgingWidgetPreview, + type BridgeConfig, + type BridgeFeeQuote, + type BridgeTransaction, + type BridgingWidgetAdapterActions, + type BridgingWidgetAdapterResult, + type BridgingWidgetAdapterState, + type BridgingWidgetProps, + type BridgingWidgetTab, +} from '@goodwidget/bridging-widget' +import { MiniAppShell, YStack } from '@goodwidget/ui' +import { GoodWidgetProvider } from '@goodwidget/core' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' + +const DEMO_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + +const SAMPLE_COMPLETED_SRC_HASH = + '0x1111111111111111111111111111111111111111111111111111111111111111' +const SAMPLE_COMPLETED_DST_HASH = + '0x2222222222222222222222222222222222222222222222222222222222222222' +const SAMPLE_PENDING_HASH = + '0x3333333333333333333333333333333333333333333333333333333333333333' +const SAMPLE_FAILED_HASH = + '0x4444444444444444444444444444444444444444444444444444444444444444' + +// G$ uses 18 decimals on every supported chain, so all amount fixtures below +// are plain base-unit bigints (e.g. 50 G$ == 50n * 10n ** 18n). +const G$ = (whole: number) => BigInt(whole) * 10n ** 18n + +const sampleQuote: BridgeFeeQuote = { + fee: 120000000000000n, + feeInNative: '0.00012', + protocol: 'LAYERZERO', + target: DEMO_ADDRESS, + targetChainId: BRIDGING_CHAINS.CELO, + amount: G$(50), + protocolFeeBps: 50n, + protocolFee: G$(50) / 400n, + amountReceived: G$(50) - G$(50) / 400n, +} + +const sampleBridgeConfig: BridgeConfig = { + // Unused by every current UI surface (see widgetRuntimeContract.ts) — left + // empty here rather than pulling in @goodsdks/bridging-sdk's chain map. + supportedChains: {}, + currentChainId: BRIDGING_CHAINS.FUSE, + tokenBalance: G$(500), + nativeBalance: 2000000000000000000n, + allowance: 0n, + fees: null, + routeLimits: { + minAmount: G$(1), + dailyLimit: G$(1000000), + txLimit: G$(300000000), + accountDailyLimit: G$(50000), + onlyWhitelisted: false, + }, + routeLimitsChainId: BRIDGING_CHAINS.FUSE, + protocolFeeBps: 50n, + protocolFeeChainId: BRIDGING_CHAINS.CELO, +} + +const sampleHistory: BridgeTransaction[] = [ + { + hash: SAMPLE_COMPLETED_SRC_HASH, + fromChainId: BRIDGING_CHAINS.FUSE, + toChainId: BRIDGING_CHAINS.CELO, + amount: G$(120), + protocol: 'LAYERZERO', + status: { + status: 'completed', + srcTxHash: SAMPLE_COMPLETED_SRC_HASH, + dstTxHash: SAMPLE_COMPLETED_DST_HASH, + timestamp: 1767225600, + }, + timestamp: 1767225600, + }, + { + hash: SAMPLE_PENDING_HASH, + fromChainId: BRIDGING_CHAINS.CELO, + toChainId: BRIDGING_CHAINS.ETHEREUM, + amount: G$(35), + protocol: 'AXELAR', + status: { + status: 'pending', + srcTxHash: SAMPLE_PENDING_HASH, + timestamp: 1767312000, + }, + timestamp: 1767312000, + }, + { + hash: SAMPLE_FAILED_HASH, + fromChainId: BRIDGING_CHAINS.FUSE, + toChainId: BRIDGING_CHAINS.XDC, + amount: G$(8), + protocol: 'LAYERZERO', + status: { + status: 'failed', + srcTxHash: SAMPLE_FAILED_HASH, + timestamp: 1767139200, + error: 'Relay timed out before delivery confirmation.', + }, + timestamp: 1767139200, + }, +] + +function createAdapter( + stateOverrides: Partial = {}, + actionOverrides: Partial = {}, +): BridgingWidgetAdapterResult { + // Default to a fully connected, route-selected, ready-to-bridge state so + // most fixtures only need to override the one field their state is about. + const baseState: BridgingWidgetAdapterState = { + isConnected: true, + address: DEMO_ADDRESS, + chainId: BRIDGING_CHAINS.FUSE, + isWrongChain: false, + targetChainId: BRIDGING_CHAINS.CELO, + availableProtocols: ['LAYERZERO', 'AXELAR'], + selectedProtocol: 'LAYERZERO', + isRouteSupported: true, + inputAmount: '50', + bridgeConfig: sampleBridgeConfig, + isBridgeConfigLoading: false, + bridgeConfigError: null, + quoteStatus: 'ready', + quote: sampleQuote, + needsApproval: false, + canBridge: true, + requirements: [], + quoteError: null, + isReviewOpen: false, + submissionStep: 'idle', + submissionFailedAtStep: null, + submissionError: null, + submissionTxHash: null, + historyTransactions: sampleHistory, + isHistoryLoading: false, + historyError: null, + isHistoryPartial: false, + selectedHistoryTransaction: null, + selectedHistoryExplorerLink: null, + } + + const actions: BridgingWidgetAdapterActions = { + connect: async () => {}, + switchChain: async () => {}, + selectTargetChain: () => {}, + selectProtocol: () => {}, + setInputAmount: () => {}, + setMaxAmount: () => {}, + openReview: () => {}, + closeReview: () => {}, + confirmBridge: () => {}, + retrySubmission: () => {}, + closeSubmission: () => {}, + refreshHistory: () => {}, + viewHistoryTransaction: () => {}, + closeHistoryTransaction: () => {}, + ...actionOverrides, + } + + return { + state: { ...baseState, ...stateOverrides }, + actions, + } +} + +// MiniAppShell must sit inside a provider: without one it first paints before any +// Tamagui theme is registered, and the theme that the widget's own provider registers +// then flips its internal Theme hook path on the next re-render ("Should have a queue"). +function StoryShell({ children, dataTestId }: { children: React.ReactNode; dataTestId: string }) { + return ( + + + + {children} + + + + ) +} + +function PreviewStoryShell({ + adapter, + dataTestId, + initialTab = 'bridge', + defaultTheme, + themeOverrides, +}: { + adapter: BridgingWidgetAdapterResult + dataTestId: string + initialTab?: BridgingWidgetTab + defaultTheme?: 'light' | 'dark' + themeOverrides?: BridgingWidgetProps['themeOverrides'] +}) { + return ( + + + + ) +} + +function BridgingWidgetStoryShell({ + provider, + dataTestId, + defaultTheme, + themeOverrides, +}: { + provider: unknown + dataTestId: string + defaultTheme?: 'light' | 'dark' + themeOverrides?: BridgingWidgetProps['themeOverrides'] +}) { + return ( + + + + ) +} + +export function InjectedWalletStory({ + defaultTheme, + themeOverrides, +}: Pick) { + const injectedProvider = getInjectedEip1193Provider() + const usableProvider = isInjectedProviderUsable(injectedProvider) + + if (!usableProvider) { + return ( + + ) + } + + return ( + + ) +} + +export function CustodialLocalFixtureStory({ + defaultTheme, + themeOverrides, +}: Pick) { + try { + const provider = createCustodialEip1193Provider() + return ( + + ) + } catch (error: unknown) { + return ( + + Custodial fixture not configured + + {error instanceof Error + ? error.message + : 'Set a local private key in custodialEip1193.ts'} + + + ) + } +} + +type ThemeArgs = { + defaultTheme?: 'light' | 'dark' + themeOverrides?: BridgingWidgetProps['themeOverrides'] +} + +export function NoWalletStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function WrongChainStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function RouteNotSupportedStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function ConfigLoadingStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function ConfigErrorStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function QuoteLoadingStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function QuoteErrorStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function BelowMinAmountStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function InsufficientBalanceStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function NeedsApprovalStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function ReadyToBridgeStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function ReviewOpenStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function SubmissionApprovingStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function SubmissionBridgingStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function SubmissionCompletedStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function SubmissionFailedApprovingStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function SubmissionFailedBridgingStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function HistoryLoadingStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function HistoryEmptyStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function HistoryErrorStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function HistoryPopulatedStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function HistoryDetailModalOpenStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function FaqTabStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + return ( + + ) +} + +export function LightThemePopulatedStory({ themeOverrides }: Pick = {}) { + return ( + + + + ) +} diff --git a/packages/bridging-widget/package.json b/packages/bridging-widget/package.json new file mode 100644 index 00000000..b7f9d2f0 --- /dev/null +++ b/packages/bridging-widget/package.json @@ -0,0 +1,56 @@ +{ + "name": "@goodwidget/bridging-widget", + "version": "0.1.0-beta", + "description": "GoodWidget for cross-chain G$ bridging between Celo, Ethereum, Fuse, and XDC via Axelar/LayerZero", + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/bridging-widget" + }, + "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": { + "@goodsdks/bridging-sdk": "1.0.6", + "@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/bridging-widget/src/BridgingWidget.tsx b/packages/bridging-widget/src/BridgingWidget.tsx new file mode 100644 index 00000000..4529c2de --- /dev/null +++ b/packages/bridging-widget/src/BridgingWidget.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import type { EIP1193Provider } from '@goodwidget/core' +import { ToastContainer } from '@goodwidget/ui' +import { useBridgingAdapter } from './adapter' +import { BridgingWidgetView } from './components/BridgingWidgetView' +import type { + BridgingWidgetAdapterResult, + BridgingWidgetProps, + BridgingWidgetTab, +} from './widgetRuntimeContract' + +function BridgingWidgetRuntime() { + const adapter = useBridgingAdapter() + + return +} + +export interface BridgingWidgetPreviewProps + extends Pick { + adapter: BridgingWidgetAdapterResult + initialTab?: BridgingWidgetTab +} + +// Deterministic render path for Storybook/test fixtures that provide a fixed adapter. +export function BridgingWidgetPreview({ + adapter, + initialTab, + themeOverrides, + config, + defaultTheme = 'dark', +}: BridgingWidgetPreviewProps) { + return ( + + + + + ) +} + +export function BridgingWidget({ + provider, + themeOverrides, + config, + defaultTheme = 'dark', +}: BridgingWidgetProps) { + return ( + + + + + ) +} diff --git a/packages/bridging-widget/src/adapter.ts b/packages/bridging-widget/src/adapter.ts new file mode 100644 index 00000000..7a717928 --- /dev/null +++ b/packages/bridging-widget/src/adapter.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { formatUnits } from 'viem' +import { useWallet } from '@goodwidget/core' +import type { BridgeProtocol, BridgeTransaction } from '@goodsdks/bridging-sdk' +import type { Address } from 'viem' +import type { + BridgingWidgetAdapterActions, + BridgingWidgetAdapterResult, + BridgingWidgetAdapterState, +} from './widgetRuntimeContract' +import { getRouteProtocols } from './adapter/bridgeRoutes' +import { useBridgingPublicClients, useBridgingSdk } from './adapter/bridgingClient' +import { isBridgingChain } from './adapter/chains' +import { sanitizeAmount } from './amount' +import { useBridgeConfig } from './adapter/useBridgeConfig' +import { useBridgeHistory } from './adapter/useBridgeHistory' +import { useBridgeQuote } from './adapter/useBridgeQuote' +import { useBridgeSubmission } from './adapter/useBridgeSubmission' + +export function useBridgingAdapter(): BridgingWidgetAdapterResult { + const { address, chainId, isConnected, provider, connect, switchChain } = useWallet() + const walletAddress = (address as Address | null) ?? null + const activeChainId = chainId ?? null + const isWrongChain = isConnected && !isBridgingChain(activeChainId) + const isReady = isConnected && !!walletAddress && !isWrongChain + + const [targetChainId, setTargetChainId] = useState(null) + const [selectedProtocol, setSelectedProtocol] = useState(null) + const [inputAmount, setInputAmountRaw] = useState('') + const [isReviewOpen, setIsReviewOpen] = useState(false) + const [selectedHistoryTransaction, setSelectedHistoryTransaction] = useState(null) + + const publicClients = useBridgingPublicClients() + const sdk = useBridgingSdk({ provider, chainId: activeChainId }) + const submission = useBridgeSubmission({ sdk }) + const history = useBridgeHistory({ sdk, address: walletAddress, publicClients }) + + // Reset the selected route whenever the source chain stops being usable, + // so a stale target/protocol from a previous chain is never shown as valid. + useEffect(() => { + if (isReady) return + + setTargetChainId(null) + setSelectedProtocol(null) + setInputAmountRaw('') + setIsReviewOpen(false) + setSelectedHistoryTransaction(null) + submission.reset() + }, [isReady]) + + // Clear a typed amount when the destination chain changes: a route's + // limits/balance are route-specific, so an amount valid for the old route + // could silently violate the new one if left in place. + useEffect(() => { + setInputAmountRaw('') + setIsReviewOpen(false) + submission.reset() + }, [targetChainId]) + + const availableProtocols = useMemo(() => { + if (!isReady || activeChainId === null || targetChainId === null) return [] + return getRouteProtocols(activeChainId, targetChainId) + }, [isReady, activeChainId, targetChainId]) + + // Keep the selected protocol in sync with the route's actual options: default + // to the first one when a route becomes available, and drop a selection that + // the current route no longer supports (e.g. target changed to a Fuse/XDC leg). + useEffect(() => { + if (availableProtocols.length === 0) { + setSelectedProtocol(null) + return + } + + setSelectedProtocol((current) => + current && availableProtocols.includes(current) ? current : availableProtocols[0], + ) + }, [availableProtocols]) + + const selectTargetChain = useCallback((nextChainId: number) => { + setTargetChainId(nextChainId) + }, []) + + const selectProtocol = useCallback((protocol: BridgeProtocol) => { + setSelectedProtocol(protocol) + }, []) + + const bridgeConfigState = useBridgeConfig({ + sdk, + address: walletAddress, + fromChain: activeChainId, + toChain: targetChainId, + protocol: selectedProtocol, + }) + + const quoteState = useBridgeQuote({ + sdk, + sender: walletAddress, + fromChain: activeChainId, + toChain: targetChainId, + protocol: selectedProtocol, + inputAmount, + }) + + const setInputAmount = useCallback((amount: string) => { + setInputAmountRaw(sanitizeAmount(amount)) + }, []) + + const setMaxAmount = useCallback(() => { + const balance = bridgeConfigState.config?.tokenBalance + if (balance === undefined) return + setInputAmountRaw(formatUnits(balance, 18)) + }, [bridgeConfigState.config]) + + const openReview = useCallback(() => { + if (!quoteState.canBridge || !quoteState.quote) return + setIsReviewOpen(true) + }, [quoteState.canBridge, quoteState.quote]) + + const closeReview = useCallback(() => setIsReviewOpen(false), []) + + const confirmBridge = useCallback(() => { + if (!quoteState.quote) return + setIsReviewOpen(false) + void submission.submit(quoteState.quote) + }, [quoteState.quote, submission.submit]) + + const retrySubmission = useCallback(() => { + if (!quoteState.quote) return + void submission.submit(quoteState.quote) + }, [quoteState.quote, submission.submit]) + + const closeSubmission = useCallback(() => { + const wasCompleted = submission.step === 'completed' + submission.reset() + // A completed bridge spent the typed amount, so clear it rather than + // leaving a stale value that would re-trigger a quote for funds already sent. + if (wasCompleted) setInputAmountRaw('') + // The newly-submitted bridge is now on-chain; refresh so it shows up in + // the History tab (as pending) without waiting for the next natural refetch. + if (wasCompleted) history.refresh() + }, [submission.step, submission.reset, history.refresh]) + + const viewHistoryTransaction = useCallback((transaction: BridgeTransaction) => { + setSelectedHistoryTransaction(transaction) + }, []) + + const closeHistoryTransaction = useCallback(() => setSelectedHistoryTransaction(null), []) + + // Explorer links are protocol/chain-specific URL construction that only the + // SDK instance knows how to build; the UI layer gets a ready-to-use string. + const selectedHistoryExplorerLink = useMemo(() => { + if (!sdk || !selectedHistoryTransaction) return null + return sdk.getExplorerLink(selectedHistoryTransaction.hash, selectedHistoryTransaction.protocol) + }, [sdk, selectedHistoryTransaction]) + + const state: BridgingWidgetAdapterState = { + isConnected, + address: walletAddress, + chainId: activeChainId, + isWrongChain, + targetChainId, + availableProtocols, + selectedProtocol, + isRouteSupported: availableProtocols.length > 0, + inputAmount, + bridgeConfig: bridgeConfigState.config, + isBridgeConfigLoading: bridgeConfigState.isLoading, + bridgeConfigError: bridgeConfigState.error, + quoteStatus: quoteState.status, + quote: quoteState.quote, + needsApproval: quoteState.needsApproval, + canBridge: quoteState.canBridge, + requirements: quoteState.requirements, + quoteError: quoteState.error, + isReviewOpen, + submissionStep: submission.step, + submissionFailedAtStep: submission.failedAtStep, + submissionError: submission.error, + submissionTxHash: submission.receipt?.transactionHash ?? null, + historyTransactions: history.transactions, + isHistoryLoading: history.isLoading, + historyError: history.error, + isHistoryPartial: history.isPartial, + selectedHistoryTransaction, + selectedHistoryExplorerLink, + } + + const actions: BridgingWidgetAdapterActions = { + connect, + switchChain, + selectTargetChain, + selectProtocol, + setInputAmount, + setMaxAmount, + openReview, + closeReview, + confirmBridge, + retrySubmission, + closeSubmission, + refreshHistory: history.refresh, + viewHistoryTransaction, + closeHistoryTransaction, + } + + return { state, actions } +} diff --git a/packages/bridging-widget/src/adapter/bridgeRoutes.ts b/packages/bridging-widget/src/adapter/bridgeRoutes.ts new file mode 100644 index 00000000..9314d043 --- /dev/null +++ b/packages/bridging-widget/src/adapter/bridgeRoutes.ts @@ -0,0 +1,28 @@ +import type { BridgeProtocol } from '@goodsdks/bridging-sdk' +import { BRIDGING_CHAINS, type BridgingChainId } from '../widgetRuntimeContract' + +// bridging-sdk exposes no provider↔chain-pair matrix (tracked as a gap in +// GoodSDKs#67). This mirrors the only rule the SDK actually enforces, read +// out of BridgingSDK.estimateFee() in packages/bridging-sdk/src/viem-sdk.ts: +// Axelar rejects any route touching Fuse or XDC, so it only ever carries +// Celo<->Ethereum. LayerZero has no such restriction and carries every pair +// among the chains in SUPPORTED_CHAINS. +const AXELAR_CHAINS = new Set([BRIDGING_CHAINS.CELO, BRIDGING_CHAINS.ETHEREUM]) + +export function getBridgeTargetChains(fromChainId: number): BridgingChainId[] { + return (Object.values(BRIDGING_CHAINS) as BridgingChainId[]).filter( + (chainId) => chainId !== fromChainId, + ) +} + +export function getRouteProtocols(fromChainId: number, toChainId: number): BridgeProtocol[] { + if (fromChainId === toChainId) return [] + + const protocols: BridgeProtocol[] = [] + if (AXELAR_CHAINS.has(fromChainId) && AXELAR_CHAINS.has(toChainId)) { + protocols.push('AXELAR') + } + protocols.push('LAYERZERO') + + return protocols +} diff --git a/packages/bridging-widget/src/adapter/bridgingClient.ts b/packages/bridging-widget/src/adapter/bridgingClient.ts new file mode 100644 index 00000000..36f78945 --- /dev/null +++ b/packages/bridging-widget/src/adapter/bridgingClient.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import { createWalletClient, custom, type PublicClient, type WalletClient } from 'viem' +import { BridgingSDK } from '@goodsdks/bridging-sdk' +import type { EIP1193Provider } from '@goodwidget/core' +import { createBridgingPublicClient, VIEM_CHAINS } from './chains' + +// One read-only public client per supported chain, built once, so the SDK can +// read source + destination balances/limits/fees without requiring the +// connected wallet to switch chains just to answer a read. +function buildPublicClients(): Record { + const clients: Record = {} + for (const chainId of Object.keys(VIEM_CHAINS).map(Number)) { + const client = createBridgingPublicClient(chainId) + if (client) clients[chainId] = client + } + return clients +} + +interface UseBridgingSdkArgs { + provider: EIP1193Provider | null + chainId: number | null +} + +// Exposed separately from useBridgingSdk so callers that need the raw +// per-chain clients (e.g. useBridgeHistory's getCombinedHistory, which takes +// a clients map directly rather than reading the SDK's private copy of it) +// share the exact same memoized map instead of building a second one. +export function useBridgingPublicClients(): Record { + return useMemo(buildPublicClients, []) +} + +// Builds the BridgingSDK instance used by the Bridge tab. Returns null until +// the wallet is on a supported chain, mirroring streaming-widget's +// useStreamingClients pattern (memoized clients, memoized SDK on top). +export function useBridgingSdk({ provider, chainId }: UseBridgingSdkArgs): BridgingSDK | null { + const publicClients = useBridgingPublicClients() + + const walletClient = useMemo(() => { + if (!provider || chainId === null) return undefined + const chain = VIEM_CHAINS[chainId] + if (!chain) return undefined + return createWalletClient({ chain, transport: custom(provider as Parameters[0]) }) + }, [provider, chainId]) + + return useMemo(() => { + if (chainId === null || !publicClients[chainId]) return null + return new BridgingSDK(publicClients[chainId], walletClient, chainId, publicClients) + }, [publicClients, walletClient, chainId]) +} diff --git a/packages/bridging-widget/src/adapter/chains.ts b/packages/bridging-widget/src/adapter/chains.ts new file mode 100644 index 00000000..d24b0727 --- /dev/null +++ b/packages/bridging-widget/src/adapter/chains.ts @@ -0,0 +1,43 @@ +import { createPublicClient, http, type Chain } from 'viem' +import { BRIDGING_CHAINS } from '../widgetRuntimeContract' + +// RPC endpoints matching GoodSDKs' own demo-bridging-app wiring +// (apps/demo-bridging-app/src/config.tsx), kept consistent with the SDK's +// reference integration rather than picked independently. +export const VIEM_CHAINS: Record = { + [BRIDGING_CHAINS.CELO]: { + id: BRIDGING_CHAINS.CELO, + name: 'Celo', + nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 }, + rpcUrls: { default: { http: ['https://forno.celo.org'] } }, + } as Chain, + [BRIDGING_CHAINS.ETHEREUM]: { + id: BRIDGING_CHAINS.ETHEREUM, + name: 'Ethereum', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://cloudflare-eth.com'] } }, + } as Chain, + [BRIDGING_CHAINS.FUSE]: { + id: BRIDGING_CHAINS.FUSE, + name: 'Fuse', + nativeCurrency: { name: 'Fuse', symbol: 'FUSE', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.fuse.io'] } }, + } as Chain, + [BRIDGING_CHAINS.XDC]: { + id: BRIDGING_CHAINS.XDC, + name: 'XDC Network', + nativeCurrency: { name: 'XDC', symbol: 'XDC', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.xdcrpc.com'] } }, + } as Chain, +} + +export function isBridgingChain(chainId: number | null | undefined): boolean { + return !!chainId && chainId in VIEM_CHAINS +} + +export function createBridgingPublicClient(chainId: number) { + const chain = VIEM_CHAINS[chainId] + if (!chain) return null + + return createPublicClient({ chain, transport: http(chain.rpcUrls.default.http[0]) }) +} diff --git a/packages/bridging-widget/src/adapter/errors.ts b/packages/bridging-widget/src/adapter/errors.ts new file mode 100644 index 00000000..666bb8f1 --- /dev/null +++ b/packages/bridging-widget/src/adapter/errors.ts @@ -0,0 +1,82 @@ +import type { BridgeRequirement, BridgeRequirementType } from '@goodsdks/bridging-sdk' + +// Converts low-level bridge/viem errors into concise user-facing messages. +// Unmatched errors return a generic fallback (and are logged) so raw viem +// output — which can leak RPC URLs, contract addresses, or revert hex — is +// never surfaced directly in the UI. Mirrors goodreserve-widget's errors.ts; +// duplicated rather than shared per that file's own note on a future +// @goodwidget/core consolidation being a maintainer decision. + +type ErrorMatcher = { + match: readonly string[] + message: string +} + +const BRIDGE_ERROR_RULES: readonly ErrorMatcher[] = [ + { + match: ['user rejected', 'user denied', '4001', 'action_rejected'], + message: 'Transaction canceled in wallet.', + }, + { + match: [ + 'failed to fetch', + 'http request failed', + 'fetch failed', + 'networkerror', + 'net::err_', + 'econnrefused', + 'econnreset', + 'etimedout', + ], + message: 'Unable to reach the network. Check your connection and try again.', + }, + { + match: ['timeout', 'timed out'], + message: 'The request timed out. Please try again.', + }, + { + match: ['revert'], + message: 'Bridge transaction reverted on-chain. Try a smaller amount.', + }, + { match: ['insufficient funds'], message: 'Insufficient funds for the transfer or gas.' }, + { match: ['allowance'], message: 'Insufficient allowance. Approve and try again.' }, + { match: ['unsupported chain', 'wrong chain'], message: 'Switch to a supported chain to continue.' }, +] + +export function mapBridgeError(err: unknown, fallback: string): string { + console.error('[BridgingWidget]', err) + + const message = err instanceof Error ? err.message : String(err ?? fallback) + const lower = message.toLowerCase() + + for (const rule of BRIDGE_ERROR_RULES) { + if (rule.match.some((needle) => lower.includes(needle))) return rule.message + } + + return fallback +} + +// A quote can surface several unmet requirements at once (e.g. both below the +// minimum and over the daily limit); this priority order picks the one worth +// showing first — a blocked route or wrong chain masks every other reason, +// since fixing the amount wouldn't help until the route itself is valid. +const REQUIREMENT_PRIORITY: readonly BridgeRequirementType[] = [ + 'wrong_chain', + 'route_unavailable', + 'below_min_amount', + 'exceeds_limit', + 'insufficient_token_balance', + 'insufficient_native_balance', + 'insufficient_allowance', +] + +export function primaryRequirementMessage(requirements: BridgeRequirement[]): string | null { + if (requirements.length === 0) return null + + for (const type of REQUIREMENT_PRIORITY) { + const match = requirements.find((requirement) => requirement.type === type) + if (match) return match.message + } + + return requirements[0].message +} diff --git a/packages/bridging-widget/src/adapter/submissionSteps.ts b/packages/bridging-widget/src/adapter/submissionSteps.ts new file mode 100644 index 00000000..e94c4b8d --- /dev/null +++ b/packages/bridging-widget/src/adapter/submissionSteps.ts @@ -0,0 +1,54 @@ +import type { StepperStepItem } from '@goodwidget/ui' +import type { BridgeSubmissionStep } from './useBridgeSubmission' + +// Builds the Confirm modal's Stepper rows directly from the SDK's own +// approving/bridging/completed/failed vocabulary (see useBridgeSubmission) — +// there is no separate step-numbering scheme to keep in sync with it. +// Mirrors governance-widget's createTransactionSteps. +export function buildSubmissionSteps( + step: BridgeSubmissionStep, + needsApproval: boolean, + failedAtStep: 'approving' | 'bridging' | null, + errorMessage: string | null, +): StepperStepItem[] { + const approveStatus = (): StepperStepItem['status'] => { + if (failedAtStep === 'approving') return 'failed' + if (step === 'approving') return 'active' + if (step === 'idle') return 'pending' + return 'completed' + } + + const bridgeStatus = (): StepperStepItem['status'] => { + if (failedAtStep === 'bridging') return 'failed' + if (step === 'bridging') return 'active' + if (step === 'completed') return 'completed' + return 'pending' + } + + const steps: StepperStepItem[] = [] + + if (needsApproval) { + steps.push({ + id: 'approving', + title: 'Approve G$', + description: failedAtStep === 'approving' ? errorMessage ?? undefined : 'Confirm the approval in your wallet.', + status: approveStatus(), + }) + } + + steps.push({ + id: 'bridging', + title: 'Send bridge transaction', + description: failedAtStep === 'bridging' ? errorMessage ?? undefined : 'Confirm the bridge transaction in your wallet.', + status: bridgeStatus(), + }) + + steps.push({ + id: 'completed', + title: 'Bridge submitted', + description: 'The source-chain transaction is confirmed. Delivery on the destination chain follows shortly after.', + status: step === 'completed' ? 'completed' : 'pending', + }) + + return steps +} diff --git a/packages/bridging-widget/src/adapter/useBridgeConfig.ts b/packages/bridging-widget/src/adapter/useBridgeConfig.ts new file mode 100644 index 00000000..eb87d09f --- /dev/null +++ b/packages/bridging-widget/src/adapter/useBridgeConfig.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from 'react' +import type { Address } from 'viem' +import type { BridgeConfig, BridgeProtocol, BridgingSDK } from '@goodsdks/bridging-sdk' +import { mapBridgeError } from './errors' + +export interface BridgeConfigState { + config: BridgeConfig | null + isLoading: boolean + error: string | null +} + +const EMPTY_STATE: BridgeConfigState = { config: null, isLoading: false, error: null } + +interface UseBridgeConfigArgs { + sdk: BridgingSDK | null + address: Address | null + fromChain: number | null + toChain: number | null + protocol: BridgeProtocol | null +} + +// Loads the source balance, G$ protocol fee, and route limits for the +// selected route. Unlike useBridgeQuote this is not debounced on the amount +// field — it only needs to refetch when the route itself changes, so the +// Bridge tab can show a balance/limit hint before any amount is typed. +export function useBridgeConfig({ + sdk, + address, + fromChain, + toChain, + protocol, +}: UseBridgeConfigArgs): BridgeConfigState { + const [state, setState] = useState(EMPTY_STATE) + const requestIdRef = useRef(0) + + useEffect(() => { + if (!sdk || !address) { + requestIdRef.current += 1 + setState(EMPTY_STATE) + return + } + + const requestId = ++requestIdRef.current + setState({ config: null, isLoading: true, error: null }) + + const route = + fromChain !== null && toChain !== null && protocol + ? { fromChain, toChain, protocol } + : undefined + + sdk + .getBridgeConfig(address, route) + .then((config) => { + if (requestIdRef.current !== requestId) return + setState({ config, isLoading: false, error: null }) + }) + .catch((err: unknown) => { + if (requestIdRef.current !== requestId) return + setState({ + config: null, + isLoading: false, + error: mapBridgeError(err, 'Unable to load balance and limits.'), + }) + }) + }, [sdk, address, fromChain, toChain, protocol]) + + return state +} diff --git a/packages/bridging-widget/src/adapter/useBridgeHistory.ts b/packages/bridging-widget/src/adapter/useBridgeHistory.ts new file mode 100644 index 00000000..a34d03b2 --- /dev/null +++ b/packages/bridging-widget/src/adapter/useBridgeHistory.ts @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { PublicClient } from 'viem' +import type { Address } from 'viem' +import type { + BridgeRequestEvent, + BridgeTransaction, + BridgingSDK, + ExecutedTransferEvent, +} from '@goodsdks/bridging-sdk' +import { mapBridgeError } from './errors' + +const HISTORY_EVENT_LIMIT = 50 +// While any transaction is still 'pending', poll for delivery updates instead +// of waiting for the user to tab away and back. +const HISTORY_POLL_INTERVAL_MS = 15_000 + +export interface BridgeHistoryState { + transactions: BridgeTransaction[] + isLoading: boolean + error: string | null + /** True when at least one event's delivery-status lookup failed and was + * dropped from `transactions` rather than blanking the whole list. */ + isPartial: boolean +} + +const EMPTY_STATE: BridgeHistoryState = { transactions: [], isLoading: false, error: null, isPartial: false } + +function isRequestEvent( + event: BridgeRequestEvent | ExecutedTransferEvent, +): event is BridgeRequestEvent { + return 'targetChainId' in event.args +} + +interface BuildTransactionsResult { + transactions: BridgeTransaction[] + isPartial: boolean +} + +// getCombinedHistory returns the raw on-chain events for both legs of a +// bridge (the source-chain request and, once delivered, the destination-chain +// transfer) deduplicated across chains, but not paired up or resolved to a +// delivery status. Groups them by bridge + source chain + request id (the id +// alone is only unique per source chain, not globally) so each bridge appears +// once, then normalizes into the SDK's own BridgeTransaction shape. +async function buildTransactions( + sdk: BridgingSDK, + events: (BridgeRequestEvent | ExecutedTransferEvent)[], +): Promise { + const requestById = new Map() + const executedById = new Map() + + for (const event of events) { + const sourceChainId = isRequestEvent(event) ? event.chainId : event.args.sourceChainId + const key = `${event.args.bridge}:${sourceChainId}:${event.args.id.toString()}` + if (isRequestEvent(event)) { + requestById.set(key, event) + } else { + executedById.set(key, event) + } + } + + const keys = new Set([...requestById.keys(), ...executedById.keys()]) + + // Each entry's delivery-status lookup hits an external explorer + // (LayerZeroScan/Axelarscan) independently, so one slow/unavailable + // explorer must not blank every other already-resolved entry. allSettled + // keeps the entries that succeeded and flags the batch as partial instead. + const settled = await Promise.allSettled( + Array.from(keys, async (key): Promise => { + const request = requestById.get(key) + const executed = executedById.get(key) + + // Both legs landed: the transfer is complete and there is nothing left + // to poll an external explorer for. + if (request && executed) { + return { + hash: request.transactionHash, + fromChainId: request.chainId, + toChainId: request.args.targetChainId, + amount: request.args.amount, + protocol: request.args.bridge, + status: { status: 'completed', srcTxHash: request.transactionHash, dstTxHash: executed.transactionHash }, + timestamp: request.timestamp, + } + } + + // Only the source-chain request has landed so far: ask the protocol's + // explorer (LayerZeroScan/Axelarscan, via the SDK) whether delivery is + // still pending, completed, or failed. + if (request) { + const status = await sdk.getTransactionStatus(request.transactionHash, request.args.bridge) + return { + hash: request.transactionHash, + fromChainId: request.chainId, + toChainId: request.args.targetChainId, + amount: request.args.amount, + protocol: request.args.bridge, + status, + timestamp: request.timestamp, + } + } + + // Only the destination-chain transfer is visible (the request event + // fell outside the queried block range) — still enough to show a + // completed entry. executed is guaranteed here: `keys` only contains + // ids present in at least one of the two maps, and the request branch + // above already covered every id with a request event. + const transfer = executed as ExecutedTransferEvent + return { + hash: transfer.transactionHash, + fromChainId: transfer.args.sourceChainId, + toChainId: transfer.chainId, + amount: transfer.args.amount, + protocol: transfer.args.bridge, + status: { status: 'completed', dstTxHash: transfer.transactionHash }, + timestamp: transfer.timestamp, + } + }), + ) + + const transactions: BridgeTransaction[] = [] + let isPartial = false + for (const result of settled) { + if (result.status === 'fulfilled') { + transactions.push(result.value) + } else { + isPartial = true + } + } + + return { transactions, isPartial } +} + +interface UseBridgeHistoryArgs { + sdk: BridgingSDK | null + address: Address | null + publicClients: Record +} + +// Loads and normalizes the connected wallet's bridge history across all +// supported chains. Mirrors useBridgeConfig's request-id guard so a stale +// response from a superseded address/sdk never overwrites a newer one. +export function useBridgeHistory({ sdk, address, publicClients }: UseBridgeHistoryArgs): BridgeHistoryState & { + refresh: () => void +} { + const [state, setState] = useState(EMPTY_STATE) + const requestIdRef = useRef(0) + const [refreshToken, setRefreshToken] = useState(0) + + useEffect(() => { + if (!sdk || !address) { + requestIdRef.current += 1 + setState(EMPTY_STATE) + return + } + + const requestId = ++requestIdRef.current + setState({ transactions: [], isLoading: true, error: null, isPartial: false }) + + sdk + .getCombinedHistory(address, publicClients, { limit: HISTORY_EVENT_LIMIT }) + .then((events) => buildTransactions(sdk, events)) + .then(({ transactions, isPartial }) => { + if (requestIdRef.current !== requestId) return + transactions.sort((a, b) => b.timestamp - a.timestamp) + setState({ transactions, isLoading: false, error: null, isPartial }) + }) + .catch((err: unknown) => { + if (requestIdRef.current !== requestId) return + setState({ + transactions: [], + isLoading: false, + error: mapBridgeError(err, 'Unable to load bridge history.'), + isPartial: false, + }) + }) + }, [sdk, address, publicClients, refreshToken]) + + const refresh = useCallback(() => setRefreshToken((token) => token + 1), []) + + // Keep pending entries live without requiring the user to manually refresh: + // poll on an interval for as long as at least one transaction is pending, + // and stop automatically once every entry has settled. + useEffect(() => { + const hasPendingTransaction = state.transactions.some((transaction) => transaction.status.status === 'pending') + if (!hasPendingTransaction) return + + const intervalId = setInterval(refresh, HISTORY_POLL_INTERVAL_MS) + return () => clearInterval(intervalId) + }, [state.transactions, refresh]) + + return { ...state, refresh } +} diff --git a/packages/bridging-widget/src/adapter/useBridgeQuote.ts b/packages/bridging-widget/src/adapter/useBridgeQuote.ts new file mode 100644 index 00000000..69c2d27b --- /dev/null +++ b/packages/bridging-widget/src/adapter/useBridgeQuote.ts @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react' +import { parseUnits, type Address } from 'viem' +import type { BridgeFeeQuote, BridgeProtocol, BridgeRequirement, BridgingSDK } from '@goodsdks/bridging-sdk' +import { mapBridgeError } from './errors' + +const QUOTE_DEBOUNCE_MS = 400 + +// G$ is an 18-decimal token on every bridged chain (BridgeRouteLimits docs +// its own amounts as "normalized G$ base units (18 decimals)"). +const G_DOLLAR_DECIMALS = 18 + +export type BridgeQuoteStatus = 'idle' | 'loading' | 'ready' | 'error' + +export interface BridgeQuoteState { + status: BridgeQuoteStatus + quote: BridgeFeeQuote | null + needsApproval: boolean + canBridge: boolean + requirements: BridgeRequirement[] + error: string | null +} + +const IDLE_STATE: BridgeQuoteState = { + status: 'idle', + quote: null, + needsApproval: false, + canBridge: false, + requirements: [], + error: null, +} + +interface UseBridgeQuoteArgs { + sdk: BridgingSDK | null + sender: Address | null + fromChain: number | null + toChain: number | null + protocol: BridgeProtocol | null + inputAmount: string +} + +// Debounced wrapper around BridgingSDK.getQuote: re-fetches whenever the +// amount or route changes, and discards any in-flight response that's no +// longer current so a fast retype can't let a stale quote win the race. +export function useBridgeQuote({ + sdk, + sender, + fromChain, + toChain, + protocol, + inputAmount, +}: UseBridgeQuoteArgs): BridgeQuoteState { + const [state, setState] = useState(IDLE_STATE) + const requestIdRef = useRef(0) + + useEffect(() => { + if (!sdk || !sender || fromChain === null || toChain === null || !protocol || !inputAmount) { + requestIdRef.current += 1 + setState(IDLE_STATE) + return + } + + let amount: bigint + try { + amount = parseUnits(inputAmount, G_DOLLAR_DECIMALS) + } catch { + requestIdRef.current += 1 + setState(IDLE_STATE) + return + } + if (amount <= 0n) { + requestIdRef.current += 1 + setState(IDLE_STATE) + return + } + + const requestId = ++requestIdRef.current + setState((current) => ({ ...current, status: 'loading', error: null })) + + const timeoutId = setTimeout(async () => { + try { + const result = await sdk.getQuote({ sender, amount, fromChain, toChain, protocol }) + if (requestIdRef.current !== requestId) return + setState({ + status: 'ready', + quote: result.quote, + needsApproval: result.needsApproval, + canBridge: result.canBridge, + requirements: result.requirements, + error: null, + }) + } catch (err: unknown) { + if (requestIdRef.current !== requestId) return + setState({ + ...IDLE_STATE, + status: 'error', + error: mapBridgeError(err, 'Failed to fetch bridge quote.'), + }) + } + }, QUOTE_DEBOUNCE_MS) + + return () => clearTimeout(timeoutId) + }, [sdk, sender, fromChain, toChain, protocol, inputAmount]) + + return state +} diff --git a/packages/bridging-widget/src/adapter/useBridgeSubmission.ts b/packages/bridging-widget/src/adapter/useBridgeSubmission.ts new file mode 100644 index 00000000..008b7fdc --- /dev/null +++ b/packages/bridging-widget/src/adapter/useBridgeSubmission.ts @@ -0,0 +1,70 @@ +import { useCallback, useRef, useState } from 'react' +import type { TransactionReceipt } from 'viem' +import type { BridgeFeeQuote, BridgeStatus, BridgingSDK } from '@goodsdks/bridging-sdk' +import { mapBridgeError } from './errors' + +export type BridgeSubmissionStep = 'idle' | 'approving' | 'bridging' | 'completed' | 'failed' + +export interface BridgeSubmissionState { + step: BridgeSubmissionStep + /** Which in-flight step doBridge was on when it rejected, so the Confirm + * modal's Stepper can mark that row failed instead of defaulting to the first one. */ + failedAtStep: 'approving' | 'bridging' | null + error: string | null + receipt: TransactionReceipt | null +} + +const IDLE_STATE: BridgeSubmissionState = { step: 'idle', failedAtStep: null, error: null, receipt: null } + +interface UseBridgeSubmissionArgs { + sdk: BridgingSDK | null +} + +export interface BridgeSubmissionResult extends BridgeSubmissionState { + submit: (quote: BridgeFeeQuote) => Promise + reset: () => void +} + +// Drives the Confirm modal's Stepper from BridgingSDK.doBridge's onStatus +// callback. BridgeStatus.step ('approving' | 'bridging' | 'completed' | +// 'failed') is used directly as the submission step, so there is no +// parallel state machine to keep in sync with the SDK's own. +export function useBridgeSubmission({ sdk }: UseBridgeSubmissionArgs): BridgeSubmissionResult { + const [state, setState] = useState(IDLE_STATE) + // doBridge rejects without saying which step it was on; this ref keeps the + // last step reported by onStatus so the catch block can attribute the failure. + const lastInFlightStepRef = useRef<'approving' | 'bridging' | null>(null) + + const submit = useCallback( + async (quote: BridgeFeeQuote) => { + if (!sdk) return + + lastInFlightStepRef.current = null + setState({ step: 'approving', failedAtStep: null, error: null, receipt: null }) + + try { + const receipt = await sdk.doBridge(quote, (status: BridgeStatus) => { + if (status.step === 'approving' || status.step === 'bridging') { + lastInFlightStepRef.current = status.step + } + if (status.step !== 'failed') { + setState((current) => ({ ...current, step: status.step, error: null })) + } + }) + setState({ step: 'completed', failedAtStep: null, error: null, receipt }) + } catch (err: unknown) { + setState({ + step: 'failed', + failedAtStep: lastInFlightStepRef.current, + error: mapBridgeError(err, 'Bridge transaction failed.'), + receipt: null, + }) + } + }, + [sdk], + ) + + const reset = useCallback(() => setState(IDLE_STATE), []) + + return { ...state, submit, reset } +} diff --git a/packages/bridging-widget/src/amount.ts b/packages/bridging-widget/src/amount.ts new file mode 100644 index 00000000..0846cce9 --- /dev/null +++ b/packages/bridging-widget/src/amount.ts @@ -0,0 +1,64 @@ +// Amount-entry helpers for the Bridge tab's G$ input, following the pattern +// already established in goodreserve-widget/src/amount.ts. Not promoted to +// a shared package yet — per the execution plan, a generic version only +// gets extracted to packages/ui once a second widget needs it. + +// Keeps only digits and a single decimal point so the value is always safe to +// pass to viem's parseUnits (which throws on "1.2.3", "1e6", separators, etc.). +export function sanitizeAmount(raw: string): string { + const cleaned = raw.replace(/[^0-9.]/g, '') + const firstDot = cleaned.indexOf('.') + if (firstDot === -1) return cleaned + return cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '') +} + +// Smallest magnitude rendered exactly; anything nonzero below this collapses to +// a "<0.000001" marker rather than displaying as a misleading "0.00". +const MIN_DISPLAY_VALUE = 0.000001 + +// Above this magnitude the leading digits dominate and six decimals is just +// noise, so large G$ amounts collapse to two decimals. +const HIGH_PRECISION_THRESHOLD = 1000 + +// Longest fractional tail kept in the editable amount field while unfocused. +const MAX_INPUT_DECIMALS = 6 + +/** + * Formats a G$/native-currency amount for display, magnitude-aware: small + * values keep up to 6 decimals, large values collapse to 2, with thousands + * separators throughout. Display only — never feed the result back into + * parseUnits; the raw formatUnits string stays the source of truth. + */ +export function formatTokenAmount(value: string | number | null | undefined): string { + if (value === null || value === undefined) return '0.00' + if (typeof value === 'string' && value.trim() === '') return '0.00' + + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num)) return String(value) + if (num === 0) return '0.00' + + const abs = Math.abs(num) + if (abs < MIN_DISPLAY_VALUE) return num < 0 ? '>-0.000001' : '<0.000001' + + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: abs < HIGH_PRECISION_THRESHOLD ? 6 : 2, + }).format(num) +} + +/** + * Shortens a raw amount for display inside the editable amount input while + * unfocused. Truncates rather than rounds so the displayed value never + * exceeds the value it came from (a MAX fill must still spend the full + * balance even though only 6 decimals are shown). + */ +export function formatInputAmount(raw: string): string { + const dot = raw.indexOf('.') + if (dot === -1) return raw + + const truncated = raw.slice(0, dot + 1 + MAX_INPUT_DECIMALS) + const trimmed = truncated.replace(/\.?0+$/, '') + + if (trimmed === '' || Number(trimmed) === 0) return raw + return trimmed +} diff --git a/packages/bridging-widget/src/components/AmountEntry.tsx b/packages/bridging-widget/src/components/AmountEntry.tsx new file mode 100644 index 00000000..f51ff2c9 --- /dev/null +++ b/packages/bridging-widget/src/components/AmountEntry.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react' +import { Input, Text, XStack } from '@goodwidget/ui' +import { formatUnits } from 'viem' +import type { BridgeConfig } from '@goodsdks/bridging-sdk' +import { formatInputAmount, formatTokenAmount } from '../amount' +import { RouteSelectionCard } from './shared' + +const G_DOLLAR_DECIMALS = 18 + +interface AmountEntryProps { + inputAmount: string + bridgeConfig: BridgeConfig | null + isBridgeConfigLoading: boolean + onChangeAmount: (raw: string) => void + onPressMax: () => void +} + +// G$ amount field for the Bridge tab: balance + MAX on top, a right-aligned +// numeric input below. Mirrors goodreserve-widget's "Swap from" card so the +// two widgets read as the same family. +export function AmountEntry({ + inputAmount, + bridgeConfig, + isBridgeConfigLoading, + onChangeAmount, + onPressMax, +}: AmountEntryProps) { + // Full precision while typing, shortened once the field loses focus — same + // split as goodreserve-widget, so MAX still spends the exact balance. + const [isFocused, setIsFocused] = useState(false) + const displayValue = isFocused ? inputAmount : formatInputAmount(inputAmount) + + const balanceLabel = bridgeConfig + ? formatTokenAmount(formatUnits(bridgeConfig.tokenBalance, G_DOLLAR_DECIMALS)) + : isBridgeConfigLoading + ? '…' + : '0.00' + + return ( + + + + Amount + + + + Balance: {balanceLabel} G$ + + + MAX + + + + + + G$ + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onChange={(event: React.ChangeEvent) => + onChangeAmount(event.target.value) + } + /> + + + ) +} diff --git a/packages/bridging-widget/src/components/BridgeConfirmModal.tsx b/packages/bridging-widget/src/components/BridgeConfirmModal.tsx new file mode 100644 index 00000000..551d333d --- /dev/null +++ b/packages/bridging-widget/src/components/BridgeConfirmModal.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import { Button, ButtonText, Drawer, Heading, Icon, Stepper, Text, XStack, YStack } from '@goodwidget/ui' +import { buildSubmissionSteps } from '../adapter/submissionSteps' +import type { BridgeSubmissionStep } from '../adapter/useBridgeSubmission' + +interface BridgeConfirmModalProps { + submissionStep: BridgeSubmissionStep + submissionFailedAtStep: 'approving' | 'bridging' | null + submissionError: string | null + needsApproval: boolean + onRetry: () => void + onClose: () => void +} + +const HEADER_COPY: Record = { + idle: '', + approving: 'Approving G$…', + bridging: 'Bridging…', + completed: 'Bridge Submitted', + failed: 'Bridge Failed', +} + +// Confirm/Submit modal: reuses the shared Stepper (loader icon on the active +// row, no bespoke progress indicator) to track doBridge()'s approve → bridge +// → complete lifecycle, per issue #244's "Transaction-confirmed state is a +// drawer/modal using the shared Stepper" requirement. +export function BridgeConfirmModal({ + submissionStep, + submissionFailedAtStep, + submissionError, + needsApproval, + onRetry, + onClose, +}: BridgeConfirmModalProps) { + const isOpen = submissionStep !== 'idle' + const isSettled = submissionStep === 'completed' || submissionStep === 'failed' + + // Dismissing the drawer mid-submission would reset local state while + // doBridge() keeps running in the background; a later onStatus/resolve call + // would then silently reopen the modal. Only allow dismissal once settled. + const handleClose = () => { + if (isSettled) onClose() + } + + const steps = buildSubmissionSteps(submissionStep, needsApproval, submissionFailedAtStep, submissionError) + + return ( + + + + + {HEADER_COPY[submissionStep]} + + {isSettled && ( + + + + )} + + + + + {submissionStep === 'failed' && ( + + {submissionError ?? 'Bridge transaction failed.'} + + + )} + + {submissionStep === 'completed' && ( + + )} + + + ) +} diff --git a/packages/bridging-widget/src/components/BridgeHistoryDetailModal.tsx b/packages/bridging-widget/src/components/BridgeHistoryDetailModal.tsx new file mode 100644 index 00000000..b664b9c6 --- /dev/null +++ b/packages/bridging-widget/src/components/BridgeHistoryDetailModal.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { formatUnits } from 'viem' +import { + Anchor, + Button, + ButtonText, + Drawer, + Heading, + Icon, + Stepper, + type StepperStepItem, + Text, + XStack, + YStack, + getChainDisplayName, +} from '@goodwidget/ui' +import type { BridgeTransaction } from '@goodsdks/bridging-sdk' +import { formatTokenAmount } from '../amount' +import { PROTOCOL_LABELS } from './shared' + +const G_DOLLAR_DECIMALS = 18 + +interface BridgeHistoryDetailModalProps { + transaction: BridgeTransaction | null + explorerLink: string | null + onClose: () => void +} + +// Read-only progress view for a tapped History row. Unlike BridgeConfirmModal +// (which drives a live doBridge() call with approve/retry actions) this +// transfer already happened on-chain — there is nothing to resubmit, so it +// only ever shows where the transfer currently stands. Still built on the +// same shared Stepper, satisfying issue #244's "tapping a pending entry +// reopens the confirm modal" as the same visual/status language, not a +// literal re-render of the submit flow. +export function BridgeHistoryDetailModal({ transaction, explorerLink, onClose }: BridgeHistoryDetailModalProps) { + if (!transaction) return null + + const { status } = transaction.status + const steps: StepperStepItem[] = [ + { + id: 'source', + title: `Sent on ${getChainDisplayName(transaction.fromChainId)}`, + status: 'completed', + }, + { + id: 'destination', + title: `Delivered on ${getChainDisplayName(transaction.toChainId)}`, + description: status === 'failed' ? transaction.status.error ?? 'Delivery failed.' : undefined, + status: status === 'completed' ? 'completed' : status === 'failed' ? 'failed' : 'active', + }, + ] + + return ( + + + + + {PROTOCOL_LABELS[transaction.protocol]} Bridge + + + + + + + + {formatTokenAmount(formatUnits(transaction.amount, G_DOLLAR_DECIMALS))} G$ + + + + + {explorerLink && ( + + + + )} + + + ) +} diff --git a/packages/bridging-widget/src/components/BridgeTab.tsx b/packages/bridging-widget/src/components/BridgeTab.tsx new file mode 100644 index 00000000..99cdb2a1 --- /dev/null +++ b/packages/bridging-widget/src/components/BridgeTab.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import { BadgeText, ChainBadge, Heading, Text, XStack } from '@goodwidget/ui' +import type { BridgeConfig, BridgeFeeQuote, BridgeProtocol, BridgeRequirement } from '@goodsdks/bridging-sdk' +import { getBridgeTargetChains } from '../adapter/bridgeRoutes' +import { AmountEntry } from './AmountEntry' +import { FeeBreakdown } from './FeeBreakdown' +import { ActionButton, BridgingTabContent, PROTOCOL_LABELS, RouteSelectionCard } from './shared' + +interface BridgeTabProps { + chainId: number + targetChainId: number | null + availableProtocols: BridgeProtocol[] + selectedProtocol: BridgeProtocol | null + onSelectTargetChain: (chainId: number) => void + onSelectProtocol: (protocol: BridgeProtocol) => void + inputAmount: string + bridgeConfig: BridgeConfig | null + isBridgeConfigLoading: boolean + bridgeConfigError: string | null + quoteStatus: 'idle' | 'loading' | 'ready' | 'error' + quote: BridgeFeeQuote | null + needsApproval: boolean + canBridge: boolean + requirements: BridgeRequirement[] + quoteError: string | null + onChangeAmount: (raw: string) => void + onPressMax: () => void + onReview: () => void +} + +export function BridgeTab({ + chainId, + targetChainId, + availableProtocols, + selectedProtocol, + onSelectTargetChain, + onSelectProtocol, + inputAmount, + bridgeConfig, + isBridgeConfigLoading, + bridgeConfigError, + quoteStatus, + quote, + needsApproval, + canBridge, + requirements, + quoteError, + onChangeAmount, + onPressMax, + onReview, +}: BridgeTabProps) { + const targetOptions = getBridgeTargetChains(chainId) + const hasTarget = targetChainId !== null + const isRouteSupported = availableProtocols.length > 0 + + return ( + + + From + + + To + + {targetOptions.map((option) => ( + onSelectTargetChain(option)} + variant={targetChainId === option ? 'primary' : 'secondary'} + > + {option} + + ))} + + + + {/* Balance/limits fetch failed independently of route selection — surfaced as + its own card so it isn't masked by the "Route not supported" message below. */} + {bridgeConfigError && ( + + {bridgeConfigError} + + )} + + {hasTarget && !isRouteSupported && ( + + Route not supported + + No bridge protocol connects these two chains yet. Pick a different destination + chain. + + + )} + + {hasTarget && isRouteSupported && ( + + Bridge via + + {availableProtocols.map((protocol) => ( + onSelectProtocol(protocol)} + variant={selectedProtocol === protocol ? 'primary' : 'secondary'} + > + {PROTOCOL_LABELS[protocol]} + + ))} + + + )} + + {hasTarget && isRouteSupported && selectedProtocol && ( + <> + + + {canBridge && ( + + Review + + )} + + )} + + ) +} diff --git a/packages/bridging-widget/src/components/BridgingWidgetView.tsx b/packages/bridging-widget/src/components/BridgingWidgetView.tsx new file mode 100644 index 00000000..7f29afd2 --- /dev/null +++ b/packages/bridging-widget/src/components/BridgingWidgetView.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react' +import { Badge, BadgeText, Heading, WidgetTabs, XStack, YStack } from '@goodwidget/ui' +import type { + BridgingWidgetAdapterResult, + BridgingWidgetTab, +} from '../widgetRuntimeContract' +import { BridgeConfirmModal } from './BridgeConfirmModal' +import { BridgeHistoryDetailModal } from './BridgeHistoryDetailModal' +import { BridgeTab } from './BridgeTab' +import { FaqTab } from './FaqTab' +import { HistoryTab } from './HistoryTab' +import { ReviewScreen } from './ReviewScreen' +import { WalletGate } from './WalletGate' + +interface BridgingWidgetViewProps { + adapter: BridgingWidgetAdapterResult + initialTab?: BridgingWidgetTab +} + +export function BridgingWidgetView({ adapter, initialTab = 'bridge' }: BridgingWidgetViewProps) { + const { state, actions } = adapter + const [activeTab, setActiveTab] = useState(initialTab) + + const tabContent = !state.isConnected || state.isWrongChain ? ( + + ) : ( + <> + {activeTab === 'bridge' && state.chainId !== null && ( + + )} + {activeTab === 'history' && ( + + )} + {activeTab === 'faq' && } + + ) + + // Review and Confirm render as drawers above the tab content rather than + // inline, so they stay mounted (and keep doBridge()'s submission running) + // even if the user flips tabs mid-flow. + const reviewAndConfirm = state.chainId !== null && state.targetChainId !== null && + state.selectedProtocol !== null && state.quote !== null && ( + <> + + + + ) + + return ( + + + GoodDollar Bridge + {state.chainId && ( + + Chain {state.chainId} + + )} + + setActiveTab(id as BridgingWidgetTab)} + withConnectionStatus={false} + /> + {tabContent} + {reviewAndConfirm} + + + ) +} diff --git a/packages/bridging-widget/src/components/FaqTab.tsx b/packages/bridging-widget/src/components/FaqTab.tsx new file mode 100644 index 00000000..91da3829 --- /dev/null +++ b/packages/bridging-widget/src/components/FaqTab.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Accordion, Anchor, Text, YStack } from '@goodwidget/ui' +import type { AccordionItem } from '@goodwidget/ui' +import { BRIDGE_FAQ_ITEMS } from '../faqContent' +import { BridgingTabContent } from './shared' + +function FaqAnswer({ item }: { item: (typeof BRIDGE_FAQ_ITEMS)[number] }) { + return ( + + {item.answer.map((paragraph, index) => ( + + {paragraph} + + ))} + {item.links?.map((link) => ( + + {link.text ? `${link.text} ` : null} + {link.linkText} + + ))} + + ) +} + +// One Accordion row per question, collapsed by default (Accordion's own +// single-open behavior keeps the tab short). Content is static reference +// copy from GoodProtocolUI's Main Bridge FAQ — see faqContent.ts. +export function FaqTab() { + const items: AccordionItem[] = BRIDGE_FAQ_ITEMS.map((item) => ({ + id: item.id, + title: item.question, + content: , + })) + + return ( + + + + ) +} diff --git a/packages/bridging-widget/src/components/FeeBreakdown.tsx b/packages/bridging-widget/src/components/FeeBreakdown.tsx new file mode 100644 index 00000000..da709b78 --- /dev/null +++ b/packages/bridging-widget/src/components/FeeBreakdown.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { formatUnits } from 'viem' +import { Spinner, Text, XStack, getChainDisplayName } from '@goodwidget/ui' +import type { BridgeFeeQuote, BridgeProtocol, BridgeRequirement } from '@goodsdks/bridging-sdk' +import { formatTokenAmount } from '../amount' +import { primaryRequirementMessage } from '../adapter/errors' +import { DetailRow, PROTOCOL_LABELS, RouteSelectionCard } from './shared' + +const G_DOLLAR_DECIMALS = 18 +const BPS_DENOMINATOR = 10_000 + +interface FeeBreakdownProps { + targetChainId: number | null + protocol: BridgeProtocol + quoteStatus: 'idle' | 'loading' | 'ready' | 'error' + quote: BridgeFeeQuote | null + needsApproval: boolean + requirements: BridgeRequirement[] + quoteError: string | null +} + +// Route summary + fee quote for the amount currently entered. Stays quiet +// (renders nothing) until the user has typed an amount, since there is +// nothing to break down yet. +export function FeeBreakdown({ + targetChainId, + protocol, + quoteStatus, + quote, + needsApproval, + requirements, + quoteError, +}: FeeBreakdownProps) { + if (quoteStatus === 'idle' || targetChainId === null) return null + + if (quoteStatus === 'loading') { + return ( + + + + Fetching quote… + + + ) + } + + if (quoteStatus === 'error') { + return ( + + {quoteError ?? 'Unable to fetch a quote for this amount.'} + + ) + } + + const requirementMessage = primaryRequirementMessage(requirements) + + return ( + + + {quote && ( + <> + + + + + )} + {needsApproval && ( + + This amount needs a one-time G$ approval before it can bridge. + + )} + {requirementMessage && ( + + {requirementMessage} + + )} + + ) +} diff --git a/packages/bridging-widget/src/components/HistoryTab.tsx b/packages/bridging-widget/src/components/HistoryTab.tsx new file mode 100644 index 00000000..428cb77b --- /dev/null +++ b/packages/bridging-widget/src/components/HistoryTab.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { formatUnits } from 'viem' +import { + Badge, + BadgeText, + Button, + ButtonText, + Heading, + Icon, + Spinner, + Text, + XStack, + YStack, + getChainDisplayName, +} from '@goodwidget/ui' +import type { BridgeTransaction } from '@goodsdks/bridging-sdk' +import { formatTokenAmount } from '../amount' +import { BridgingTabContent, EmptyStateCard, PROTOCOL_LABELS, RouteSelectionCard } from './shared' + +const G_DOLLAR_DECIMALS = 18 + +interface HistoryTabProps { + transactions: BridgeTransaction[] + isLoading: boolean + error: string | null + /** True when the underlying fetch dropped one or more entries (e.g. a + * delivery-status lookup failed) rather than returning the full set. */ + isPartial: boolean + onRefresh: () => void + onSelectTransaction: (transaction: BridgeTransaction) => void +} + +const STATUS_BADGE_TYPE: Record = { + completed: 'success', + pending: 'warning', + failed: 'error', +} + +// One tappable row per bridge transfer. Every row is tappable (not just +// pending ones) so a user can re-check a failed transfer's detail too; the +// detail modal itself decides what to show for each status. +function HistoryRow({ transaction, onPress }: { transaction: BridgeTransaction; onPress: () => void }) { + return ( + + + + {getChainDisplayName(transaction.fromChainId)} → {getChainDisplayName(transaction.toChainId)} + + + {transaction.status.status} + + + + + {PROTOCOL_LABELS[transaction.protocol]} · {new Date(transaction.timestamp * 1000).toLocaleString()} + + {formatTokenAmount(formatUnits(transaction.amount, G_DOLLAR_DECIMALS))} G$ + + + ) +} + +// Loads the connected address's bridge history (useBridgeHistory, driven via +// the adapter) across all supported chains. Tapping any row reopens +// BridgeHistoryDetailModal so a pending transfer's delivery can be tracked. +// +// The underlying SDK call (getCombinedHistory) can silently drop a chain +// it failed to query internally — a known upstream limitation tracked +// separately in GoodSDKs, not something this widget can detect or fix. The +// copy below is deliberately non-committal ("no transactions found" rather +// than "you have no history") so it never asserts a blank history that may +// actually just be an SDK-side gap, and always offers a manual refresh. +export function HistoryTab({ transactions, isLoading, error, isPartial, onRefresh, onSelectTransaction }: HistoryTabProps) { + if (isLoading) { + return ( + + + + Loading history… + + + ) + } + + if (error) { + return ( + + + {error} + + + + ) + } + + if (transactions.length === 0) { + return ( + + + + Bridge history + + + No transactions found. Recently bridged funds can take a few minutes to appear — try refreshing. + + + + + ) + } + + return ( + + + Bridge history + + + {isPartial && ( + + Some transactions couldn't be loaded. Tap refresh to try again. + + )} + + {transactions.map((transaction) => ( + onSelectTransaction(transaction)} + /> + ))} + + + ) +} diff --git a/packages/bridging-widget/src/components/ReviewScreen.tsx b/packages/bridging-widget/src/components/ReviewScreen.tsx new file mode 100644 index 00000000..38e6228a --- /dev/null +++ b/packages/bridging-widget/src/components/ReviewScreen.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { formatUnits } from 'viem' +import { + Button, + ButtonText, + ChainBadge, + Drawer, + Heading, + Icon, + Separator, + Text, + XStack, + YStack, + getChainDisplayName, +} from '@goodwidget/ui' +import type { BridgeFeeQuote, BridgeProtocol } from '@goodsdks/bridging-sdk' +import { formatTokenAmount } from '../amount' +import { DetailRow, PROTOCOL_LABELS, RouteSelectionCard } from './shared' + +const G_DOLLAR_DECIMALS = 18 +const BPS_DENOMINATOR = 10_000 + +interface ReviewScreenProps { + isOpen: boolean + fromChainId: number + targetChainId: number + protocol: BridgeProtocol + quote: BridgeFeeQuote + onClose: () => void + onConfirm: () => void +} + +// Pre-submit summary drawer: mirrors the main Bridge tab's amount + fee +// fields (FeeBreakdown) plus a From/To hero and the provider-fee-paid- +// separately note, per issue #244's "Review/Confirm screen" requirement. +export function ReviewScreen({ + isOpen, + fromChainId, + targetChainId, + protocol, + quote, + onClose, + onConfirm, +}: ReviewScreenProps) { + const amountSent = formatTokenAmount(formatUnits(quote.amount, G_DOLLAR_DECIMALS)) + const amountReceived = formatTokenAmount(formatUnits(quote.amountReceived, G_DOLLAR_DECIMALS)) + const protocolFeePercent = (Number(quote.protocolFeeBps) / BPS_DENOMINATOR) * 100 + + return ( + + + + + Review Bridge + + + + + + + + + + + + + + + G$ Sent + + + {amountSent} + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/bridging-widget/src/components/WalletGate.tsx b/packages/bridging-widget/src/components/WalletGate.tsx new file mode 100644 index 00000000..4eda9c6d --- /dev/null +++ b/packages/bridging-widget/src/components/WalletGate.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { ButtonText, Heading, Text, XStack } from '@goodwidget/ui' +import { BRIDGING_CHAINS } from '../widgetRuntimeContract' +import { ActionButton, EmptyStateCard } from './shared' + +interface WalletGateProps { + isConnected: boolean + isWrongChain: boolean + onConnect: () => void + onSwitchChain: (chainId: number) => void +} + +export function WalletGate({ + isConnected, + isWrongChain, + onConnect, + onSwitchChain, +}: WalletGateProps) { + if (!isConnected) { + return ( + + + Wallet not connected + + + Connect your wallet to bridge G$ between chains. + + + Connect Wallet + + + ) + } + + if (isWrongChain) { + return ( + + + Unsupported network + + + Switch to a supported chain to use the bridging widget. + + + onSwitchChain(BRIDGING_CHAINS.CELO)}> + Celo + + onSwitchChain(BRIDGING_CHAINS.ETHEREUM)}> + Ethereum + + onSwitchChain(BRIDGING_CHAINS.FUSE)}> + Fuse + + onSwitchChain(BRIDGING_CHAINS.XDC)}> + XDC + + + + ) + } + + return null +} diff --git a/packages/bridging-widget/src/components/shared.tsx b/packages/bridging-widget/src/components/shared.tsx new file mode 100644 index 00000000..5a778586 --- /dev/null +++ b/packages/bridging-widget/src/components/shared.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { + Button, + type ButtonProps, + Card, + Text, + XStack, + YStack, + createComponent, +} from '@goodwidget/ui' +import type { BridgeProtocol } from '@goodsdks/bridging-sdk' + +// Shared across BridgeTab (protocol picker) and FeeBreakdown (route summary) +// so the two screens never label the same protocol differently. +export const PROTOCOL_LABELS: Record = { + AXELAR: 'Axelar', + LAYERZERO: 'LayerZero', +} + +export const BridgingTabContent = createComponent(YStack, { + name: 'BridgingTabContent', + flex: 1, + gap: '$3', + paddingVertical: '$3', +}) + +export const EmptyStateCard = createComponent(Card, { + name: 'EmptyStateCard', + padding: '$6', + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: '$3', +}) + +export const RouteSelectionCard = createComponent(Card, { + name: 'RouteSelectionCard', + padding: '$4', + gap: '$3', +}) + +// A single right-aligned key/value row, shared by FeeBreakdown and ReviewScreen +// so the main-screen and Confirm-screen fee breakdowns always read identically. +export function DetailRow({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) { + return ( + + + {label} + + + {value} + + + ) +} + +export function ActionButton({ + children, + minWidth = 100, + size = 'sm', + ...props +}: ButtonProps) { + return ( + + ) +} diff --git a/packages/bridging-widget/src/element.ts b/packages/bridging-widget/src/element.ts new file mode 100644 index 00000000..0ff06954 --- /dev/null +++ b/packages/bridging-widget/src/element.ts @@ -0,0 +1,8 @@ +import { createMiniAppElement } from '@goodwidget/embed' +import { BridgingWidget } from './BridgingWidget' +import type React from 'react' + +export const BridgingWidgetElement = createMiniAppElement( + BridgingWidget as React.ComponentType>, + { shadow: true, defaultTheme: 'dark', events: [] }, +) diff --git a/packages/bridging-widget/src/faqContent.ts b/packages/bridging-widget/src/faqContent.ts new file mode 100644 index 00000000..67a73843 --- /dev/null +++ b/packages/bridging-widget/src/faqContent.ts @@ -0,0 +1,122 @@ +export interface FaqLink { + href: string + linkText: string + /** Optional lead-in text shown before the link, e.g. "More technical information:" */ + text?: string +} + +export interface FaqEntry { + id: string + question: string + /** Paragraphs, split on blank lines so each renders as its own block */ + answer: string[] + links?: FaqLink[] +} + +// Content ported from GoodProtocolUI's `faqMpbBridgeCopy` +// (src/components/Faq/copies.tsx) — the "Main Bridge" FAQ set, which covers +// the multi-provider (LayerZero/Axelar) Celo/Ethereum/Fuse/XDC routes this +// widget implements. GoodProtocolUI's sibling `faqBridgeCopy` set describes +// the older Fuse<->Celo-only MicroBridge, a different product this widget +// does not use, so it is intentionally not ported. This is a content +// reference only — no runtime dependency on GoodProtocolUI. +export const BRIDGE_FAQ_ITEMS: FaqEntry[] = [ + { + id: 'whatismainbridge', + question: 'What is the Main Bridge?', + answer: [ + 'A bridge is a tool built to transfer assets from one network to another. It’s also a verb, used to describe that action: “I bridged my G$ from Fuse to Celo.”', + 'GoodDollar operates across Fuse, Celo, Ethereum and XDC networks, and G$ tokens can be bridged across those networks.', + 'Thanks to the Main Bridge you can easily move G$ between supported GoodDollar networks directly from GoodDapp.', + 'Depending on the selected source and destination networks, different bridge providers and routes may be available.', + ], + }, + { + id: 'supportednetworksroutes', + question: 'Which networks and routes are supported?', + answer: [ + 'GoodDollar currently supports bridging across Fuse, Celo, Ethereum and XDC.', + 'Available bridge providers and routes depend on the selected source and destination networks. Currently, Axelar supports Ethereum ↔ Celo, and LayerZero supports all chains and routes.', + 'The available routes will update automatically depending on the selected route.', + ], + }, + { + id: 'whocanusemainbridge', + question: 'Who can use it?', + answer: [ + 'Anyone can use the Main Bridge, but there are a few requirements to ensure a smooth transfer of your G$ tokens:', + '1. Wallet Compatibility — verify that your wallet is compatible with both the source and destination networks. Sending G$ to incompatible wallets may result in loss of funds.', + '2. Gas Fees — using the Main Bridge requires native tokens to cover gas fees on the selected network (CELO on Celo, ETH on Ethereum, FUSE on Fuse, XDC on XDC). Make sure you have sufficient balances before bridging.', + ], + }, + { + id: 'howdoesmainbridgework', + question: 'How does it work?', + answer: [ + 'The Main Bridge uses bridge smart contracts and cross-chain messaging infrastructure to transfer G$ between supported networks, via providers including Axelar and LayerZero depending on the selected route.', + 'When you bridge G$: your G$ is sent through bridge contracts on the source network, the selected bridge provider verifies and relays the transaction across networks, and G$ is then released or minted on the destination network.', + ], + links: [ + { + href: 'https://docs.gooddollar.org/user-guides/bridge-gooddollars', + text: 'More technical information:', + linkText: 'GoodDollar Documentation', + }, + ], + }, + { + id: 'whichprovider', + question: 'Which bridge provider should I use?', + answer: [ + 'In most cases, simply select the source network, select the destination network, and enter the amount you want to transfer.', + 'Currently, Axelar supports Ethereum ↔ Celo, and LayerZero supports all chains that G$ is active on (Celo, Fuse, XDC, Ethereum). For Ethereum ↔ Celo transfers, Axelar may provide lower fees depending on network conditions.', + ], + }, + { + id: 'whatfeesmainbridge', + question: 'What are the fees for using the Main Bridge?', + answer: [ + 'The fees for using the Main Bridge depend on the selected route and bridge provider, and may include protocol fees, native network gas fees, and bridge fees charged in native tokens.', + 'All bridge transactions require native gas tokens on the source network (CELO on Celo, ETH on Ethereum, FUSE on Fuse, XDC on XDC). Fees vary depending on the source/destination networks, selected provider, network congestion, and current gas costs.', + 'Estimated fees are shown below the bridge button. Ethereum-related transactions may incur higher gas fees depending on network activity.', + ], + }, + { + id: 'whatlimitsmainbridge', + question: 'What are the limits?', + answer: ['You may bridge up to 300M G$ per transaction depending on route and provider availability.'], + }, + { + id: 'howlongbridging', + question: 'How long does bridging take?', + answer: [ + 'Transfer times vary depending on the source network, destination network, selected provider, and network congestion. Ethereum-related transfers may take longer due to confirmation times.', + 'The Main Bridge interface provides transaction status updates during the bridging process.', + ], + }, + { + id: 'trackmainbridge', + question: 'Can I track the status of my bridge transaction?', + answer: [ + 'The Main Bridge interface provides information about your bridge transaction in the History tab.', + 'Some providers may also provide additional transaction tracking through their own explorers.', + ], + }, + { + id: 'whybridgefailed', + question: 'Why did my bridge transaction fail?', + answer: [ + 'Common reasons include: insufficient native gas tokens, an unsupported route, bridge limits exceeded, a wallet/network mismatch, or temporary provider or relay delays.', + 'If a transaction appears delayed, funds may still be processing through the bridge infrastructure.', + ], + }, + { + id: 'arefundssafe', + question: 'Are my funds safe during bridging?', + answer: [ + 'Bridge transactions go through multiple verification and relay steps before completion.', + 'Before bridging: verify the selected source and destination networks, ensure your wallet supports the destination network, make sure you have enough native gas tokens, and review the estimated fees before confirming the transaction.', + 'Always double-check the selected route and destination network before submitting a transaction.', + ], + }, +] diff --git a/packages/bridging-widget/src/index.ts b/packages/bridging-widget/src/index.ts new file mode 100644 index 00000000..18661558 --- /dev/null +++ b/packages/bridging-widget/src/index.ts @@ -0,0 +1,28 @@ +// Runtime contract types +export type { + BridgingWidgetProps, + BridgingWidgetTab, + BridgingChainId, + BridgingWidgetAdapterState, + BridgingWidgetAdapterActions, + BridgingWidgetAdapterResult, +} from './widgetRuntimeContract' +export { BRIDGING_CHAINS } from './widgetRuntimeContract' + +// Bridging SDK domain types used in the adapter state, re-exported so +// consumers (e.g. Storybook fixtures) can build full state objects without +// taking a direct dependency on @goodsdks/bridging-sdk themselves. +export type { + BridgeConfig, + BridgeFeeQuote, + BridgeProtocol, + BridgeRequirement, + BridgeTransaction, +} from '@goodsdks/bridging-sdk' + +// Adapter hook +export { useBridgingAdapter } from './adapter' + +// Widget component +export { BridgingWidget, BridgingWidgetPreview } from './BridgingWidget' +export type { BridgingWidgetPreviewProps } from './BridgingWidget' diff --git a/packages/bridging-widget/src/register.ts b/packages/bridging-widget/src/register.ts new file mode 100644 index 00000000..1a08e23b --- /dev/null +++ b/packages/bridging-widget/src/register.ts @@ -0,0 +1,27 @@ +import { BridgingWidgetElement } from './element' + +const DEFAULT_TAG_NAME = 'gw-bridging' + +/** + * Register the custom element. + * + * Call once at the top of your app or in a