diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 281c8d2c..8a2ab12e 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -29,6 +29,7 @@ "viem": "^2.0.0" }, "devDependencies": { + "@goodsdks/bridging-sdk": "1.0.6", "@storybook/addon-essentials": "^8.6.17", "@storybook/addon-interactions": "^8.6.17", "@storybook/react": "^8.6.17", diff --git a/examples/storybook/src/stories/bridging-widget/BridgingWidgetQA.stories.tsx b/examples/storybook/src/stories/bridging-widget/BridgingWidgetQA.stories.tsx index 9ddea3fc..a92cf66c 100644 --- a/examples/storybook/src/stories/bridging-widget/BridgingWidgetQA.stories.tsx +++ b/examples/storybook/src/stories/bridging-widget/BridgingWidgetQA.stories.tsx @@ -6,12 +6,15 @@ import { ConfigLoadingStory, CustodialLocalFixtureStory, FaqTabStory, + FuseDestinationStory, + FuseSourceStory, HistoryDetailModalOpenStory, HistoryEmptyStory, HistoryErrorStory, HistoryLoadingStory, HistoryPopulatedStory, InjectedWalletStory, + InteractiveSubmitStory, InsufficientBalanceStory, LightThemePopulatedStory, NeedsApprovalStory, @@ -29,6 +32,7 @@ import { WrongChainStory, } from '../helpers/bridgingWidgetStories' import { BRAND_PRESET_OPTIONS, brandPresetOverrides, type BrandPreset } from '../helpers/themeOverridePresets' +import { BridgingMockedAdapterFixture } from '../helpers/bridgingRealAdapterFixture' interface BridgingWidgetQAArgs { defaultTheme: 'light' | 'dark' @@ -39,7 +43,14 @@ const meta: Meta = { title: 'QA/BridgingWidget/Runtime Fixtures', component: BridgingWidget, tags: ['autodocs', 'qa'], - parameters: { layout: 'padded' }, + parameters: { + layout: 'padded', + docs: { + description: { + component: 'Runtime fixture stories use deterministic mocked state and quote values. They are not evidence of live fees or delivery.', + }, + }, + }, argTypes: { defaultTheme: { control: 'radio', @@ -230,3 +241,41 @@ export const LightThemePopulated: Story = { ), } + +export const FuseSource: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const FuseDestination: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const InteractiveSubmit: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const MockedSdkSubmit: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} + +export const MockedSdkHistory: Story = { + render: ({ defaultTheme, brandPreset }) => ( + + ), +} diff --git a/examples/storybook/src/stories/helpers/bridgingRealAdapterFixture.tsx b/examples/storybook/src/stories/helpers/bridgingRealAdapterFixture.tsx new file mode 100644 index 00000000..40edc8d3 --- /dev/null +++ b/examples/storybook/src/stories/helpers/bridgingRealAdapterFixture.tsx @@ -0,0 +1,283 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { formatUnits, parseUnits, type Address, type TransactionReceipt } from 'viem' +import { BridgingSDK, type BridgeRequestEvent } from '@goodsdks/bridging-sdk' +// The published widget bundle includes its own SDK copy. Importing the widget +// source here lets this QA-only mock patch the same SDK class the real adapter uses. +import { BridgingWidget } from '../../../../../packages/bridging-widget/src/BridgingWidget' +import { BRIDGING_CHAINS, type BridgingWidgetProps } from '../../../../../packages/bridging-widget/src/widgetRuntimeContract' + +const ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Address +const HASH = '0x3333333333333333333333333333333333333333333333333333333333333333' as const +const DESTINATION_HASH = '0x4444444444444444444444444444444444444444444444444444444444444444' as const + +type FixtureMode = 'submit' | 'history' +type FixtureEvent = { kind: string; [key: string]: string | number | boolean } + +interface FixtureControls { + events: FixtureEvent[] + holdNextSwitch: () => void + releaseSwitch: () => void + holdNextFreshQuote: () => void + releaseFreshQuote: () => void + failNextFreshQuoteWithRevert: () => void + holdBridge: () => void + releaseBridge: () => void + failNextBridgeWithRevert: () => void + setAccount: (address: Address | null) => void + setWalletChain: (chainId: number) => void + failNextHistoryRefresh: () => void + markDelivered: () => void +} + +declare global { + interface Window { + __bridgingWidgetFixture?: FixtureControls + } +} + +function decimals(chainId: number): number { + return chainId === BRIDGING_CHAINS.FUSE || chainId === BRIDGING_CHAINS.ETHEREUM ? 2 : 18 +} + +function createFixture(mode: FixtureMode) { + const events: FixtureEvent[] = [] + const listeners = new Map void>>() + let account: Address | null = ADDRESS + let walletChainId: number = BRIDGING_CHAINS.CELO + let blockSwitch = false + let releaseSwitch: (() => void) | null = null + let blockFreshQuote = false + let releaseFreshQuote: (() => void) | null = null + let failFreshQuoteWithRevert = false + let blockBridge = false + let releaseBridge: (() => void) | null = null + let failBridgeWithRevert = false + let failNextScan = false + let delivered = false + let freshQuote: Awaited>['quote'] = null + + const emit = (event: string, value: unknown) => { + for (const listener of listeners.get(event) ?? []) listener(value) + } + + const controls: FixtureControls = { + events, + holdNextSwitch: () => { blockSwitch = true }, + releaseSwitch: () => { releaseSwitch?.() }, + holdNextFreshQuote: () => { blockFreshQuote = true }, + releaseFreshQuote: () => { releaseFreshQuote?.() }, + failNextFreshQuoteWithRevert: () => { failFreshQuoteWithRevert = true }, + holdBridge: () => { blockBridge = true }, + releaseBridge: () => { releaseBridge?.() }, + failNextBridgeWithRevert: () => { failBridgeWithRevert = true }, + setAccount: (next) => { + account = next + events.push({ kind: 'account', address: next ?? '' }) + emit('accountsChanged', next ? [next] : []) + }, + setWalletChain: (nextChainId) => { + walletChainId = nextChainId + events.push({ kind: 'wallet-chain', chainId: nextChainId }) + emit('chainChanged', `0x${nextChainId.toString(16)}`) + }, + failNextHistoryRefresh: () => { failNextScan = true }, + markDelivered: () => { delivered = true }, + } + + const provider = { + on(event: string, listener: (value: unknown) => void) { + const bucket = listeners.get(event) ?? new Set<(value: unknown) => void>() + bucket.add(listener) + listeners.set(event, bucket) + }, + removeListener(event: string, listener: (value: unknown) => void) { + listeners.get(event)?.delete(listener) + }, + async request({ method, params }: { method: string; params?: [{ chainId: string }] }): Promise { + if (method === 'eth_accounts' || method === 'eth_requestAccounts') return account ? [account] : [] + if (method === 'eth_chainId') return `0x${walletChainId.toString(16)}` + if (method === 'wallet_switchEthereumChain') { + const nextChainId = parseInt(params?.[0].chainId ?? '0x0', 16) + events.push({ kind: 'switch', chainId: nextChainId }) + if (blockSwitch) { + await new Promise((resolve) => { releaseSwitch = resolve }) + releaseSwitch = null + blockSwitch = false + } + walletChainId = nextChainId + emit('chainChanged', `0x${walletChainId.toString(16)}`) + events.push({ kind: 'switch-resolved', chainId: nextChainId }) + return null + } + throw new Error(`Mock wallet does not support ${method}`) + }, + } + + const pendingEvent: BridgeRequestEvent = { + transactionHash: HASH, + blockNumber: 1n, + timestamp: 1767225600, + address: ADDRESS, + chainId: BRIDGING_CHAINS.FUSE, + args: { + from: ADDRESS, + to: ADDRESS, + amount: 10000n, + targetChainId: BRIDGING_CHAINS.CELO, + timestamp: 1767225600n, + bridge: 'LAYERZERO', + id: 1n, + }, + } + + const installSdkMock = () => { + const originals = { + getBridgeConfig: BridgingSDK.prototype.getBridgeConfig, + getQuote: BridgingSDK.prototype.getQuote, + doBridge: BridgingSDK.prototype.doBridge, + getCombinedHistory: BridgingSDK.prototype.getCombinedHistory, + getTransactionStatus: BridgingSDK.prototype.getTransactionStatus, + } + + BridgingSDK.prototype.getBridgeConfig = async (_address, route) => { + const source = route?.fromChain ?? BRIDGING_CHAINS.CELO + const unit = 10n ** BigInt(decimals(source)) + const normalizedUnit = 10n ** 18n + return { + supportedChains: {}, + currentChainId: source, + tokenBalance: 500n * unit, + nativeBalance: 10n ** 18n, + allowance: 500n * unit, + fees: null, + routeLimits: { + minAmount: normalizedUnit, + dailyLimit: 1000000n * normalizedUnit, + txLimit: 300000000n * normalizedUnit, + accountDailyLimit: 50000n * normalizedUnit, + onlyWhitelisted: false, + }, + routeLimitsChainId: source, + protocolFeeBps: 25n, + protocolFeeChainId: route?.toChain ?? null, + } + } + + BridgingSDK.prototype.getQuote = async (params) => { + events.push({ + kind: 'quote', + refresh: params.refresh === true, + sender: params.sender, + fromChain: params.fromChain, + toChain: params.toChain, + amount: params.amount.toString(), + }) + if (params.refresh && blockFreshQuote) { + await new Promise((resolve) => { releaseFreshQuote = resolve }) + releaseFreshQuote = null + blockFreshQuote = false + } + if (params.refresh && failFreshQuoteWithRevert) { + failFreshQuoteWithRevert = false + throw new Error('The contract function "bridgeLimits" reverted: Internal error') + } + const destinationAmount = parseUnits( + formatUnits(params.amount, decimals(params.fromChain)), + decimals(params.toChain), + ) + const protocolFee = destinationAmount / 400n + const quote = { + fee: 132000000000000n, + feeInNative: '0.00012 FUSE', + protocol: params.protocol, + target: params.recipient ?? params.sender, + targetChainId: params.toChain, + amount: params.amount, + protocolFeeBps: 25n, + protocolFee, + amountReceived: destinationAmount - protocolFee, + } + if (params.refresh) freshQuote = quote + events.push({ kind: 'quote-returned', refresh: params.refresh === true }) + return { quote, needsApproval: false, canBridge: true, requirements: [] } + } + + BridgingSDK.prototype.doBridge = async (quote, onStatus) => { + events.push({ + kind: 'doBridge', + receivedFreshQuote: quote === freshQuote, + amount: quote.amount.toString(), + targetChainId: quote.targetChainId, + }) + onStatus?.({ step: 'bridging' }) + if (failBridgeWithRevert) { + failBridgeWithRevert = false + throw new Error('Execution reverted: sensitive RPC details') + } + if (blockBridge) { + await new Promise((resolve) => { releaseBridge = resolve }) + releaseBridge = null + blockBridge = false + } + // The adapter only reads transactionHash; no chain receipt is produced by this fixture. + const receipt = { transactionHash: HASH } as unknown as TransactionReceipt + onStatus?.({ step: 'completed', bridgeTxHash: HASH, receipt }) + events.push({ kind: 'bridge-complete' }) + return receipt + } + + BridgingSDK.prototype.getCombinedHistory = async () => { + events.push({ kind: 'history-scan' }) + if (failNextScan) { + failNextScan = false + throw new Error('Mock refresh failure') + } + return mode === 'history' ? [pendingEvent] : [] + } + + BridgingSDK.prototype.getTransactionStatus = async () => { + events.push({ kind: 'history-status', delivered }) + return delivered + ? { status: 'completed' as const, srcTxHash: HASH, dstTxHash: DESTINATION_HASH } + : { status: 'pending' as const, srcTxHash: HASH } + } + + return () => { + BridgingSDK.prototype.getBridgeConfig = originals.getBridgeConfig + BridgingSDK.prototype.getQuote = originals.getQuote + BridgingSDK.prototype.doBridge = originals.doBridge + BridgingSDK.prototype.getCombinedHistory = originals.getCombinedHistory + BridgingSDK.prototype.getTransactionStatus = originals.getTransactionStatus + } + } + + return { controls, provider, installSdkMock } +} + +export function BridgingMockedAdapterFixture({ + mode, + defaultTheme, + themeOverrides, +}: Pick & { mode: FixtureMode }) { + const fixture = useMemo(() => createFixture(mode), [mode]) + const [ready, setReady] = useState(false) + + useEffect(() => { + const restore = fixture.installSdkMock() + window.__bridgingWidgetFixture = fixture.controls + setReady(true) + return () => { + restore() + delete window.__bridgingWidgetFixture + } + }, [fixture]) + + return ( +
+

+ Mocked wallet and SDK responses. Fees and delivery shown here are test data, not live network results. +

+ {ready && } +
+ ) +} diff --git a/examples/storybook/src/stories/helpers/bridgingWidgetStories.tsx b/examples/storybook/src/stories/helpers/bridgingWidgetStories.tsx index c3bd97d3..ae537cc9 100644 --- a/examples/storybook/src/stories/helpers/bridgingWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/bridgingWidgetStories.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' +import { parseUnits } from 'viem' import { BRIDGING_CHAINS, BridgingWidget, @@ -31,20 +32,19 @@ const SAMPLE_PENDING_HASH = 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 G$Fuse = (whole: number) => BigInt(whole) * 100n const sampleQuote: BridgeFeeQuote = { - fee: 120000000000000n, - feeInNative: '0.00012', + fee: 132000000000000n, + feeInNative: '0.00012 FUSE', protocol: 'LAYERZERO', target: DEMO_ADDRESS, targetChainId: BRIDGING_CHAINS.CELO, - amount: G$(50), + amount: G$Fuse(50), protocolFeeBps: 50n, - protocolFee: G$(50) / 400n, - amountReceived: G$(50) - G$(50) / 400n, + protocolFee: G$(50) / 200n, + amountReceived: G$(50) - G$(50) / 200n, } const sampleBridgeConfig: BridgeConfig = { @@ -52,7 +52,7 @@ const sampleBridgeConfig: BridgeConfig = { // empty here rather than pulling in @goodsdks/bridging-sdk's chain map. supportedChains: {}, currentChainId: BRIDGING_CHAINS.FUSE, - tokenBalance: G$(500), + tokenBalance: G$Fuse(500), nativeBalance: 2000000000000000000n, allowance: 0n, fees: null, @@ -121,10 +121,11 @@ function createAdapter( const baseState: BridgingWidgetAdapterState = { isConnected: true, address: DEMO_ADDRESS, + walletChainId: BRIDGING_CHAINS.FUSE, chainId: BRIDGING_CHAINS.FUSE, isWrongChain: false, targetChainId: BRIDGING_CHAINS.CELO, - availableProtocols: ['LAYERZERO', 'AXELAR'], + availableProtocols: ['LAYERZERO'], selectedProtocol: 'LAYERZERO', isRouteSupported: true, inputAmount: '50', @@ -142,6 +143,15 @@ function createAdapter( submissionFailedAtStep: null, submissionError: null, submissionTxHash: null, + submissionSourceChainId: null, + submissionTargetChainId: null, + submissionProtocol: null, + submissionAmount: null, + submissionDeliveryStatus: null, + submissionStatusError: null, + submissionSourceExplorerLink: null, + submissionProviderExplorerLink: null, + submissionDestinationExplorerLink: null, historyTransactions: sampleHistory, isHistoryLoading: false, historyError: null, @@ -153,6 +163,7 @@ function createAdapter( const actions: BridgingWidgetAdapterActions = { connect: async () => {}, switchChain: async () => {}, + selectSourceChain: () => {}, selectTargetChain: () => {}, selectProtocol: () => {}, setInputAmount: () => {}, @@ -282,7 +293,6 @@ export function InjectedWalletStory({ /> ) } - export function CustodialLocalFixtureStory({ defaultTheme, themeOverrides, @@ -325,7 +335,8 @@ export function NoWalletStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) adapter={createAdapter({ isConnected: false, address: null, - chainId: null, + walletChainId: null, + chainId: BRIDGING_CHAINS.CELO, targetChainId: null, availableProtocols: [], selectedProtocol: null, @@ -345,7 +356,8 @@ export function WrongChainStory({ defaultTheme, themeOverrides }: ThemeArgs = {} return ( ) } + +export function FuseSourceStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + // Fuse (122) has 2 decimals: 500 G$ = 50000n, input 100 G$ = 10000n + + const fuseBridgeConfig: BridgeConfig = { + ...sampleBridgeConfig, + currentChainId: BRIDGING_CHAINS.FUSE, + tokenBalance: G$Fuse(500), // 50000n (500 G$) + routeLimits: { + minAmount: G$(1), + dailyLimit: G$(1000000), + txLimit: G$(300000000), + accountDailyLimit: G$(50000), + onlyWhitelisted: false, + }, + routeLimitsChainId: BRIDGING_CHAINS.FUSE, + } + + // 100 G$ transferred from Fuse (2 decimals) to Celo (18 decimals) + // Protocol fee: 0.25 G$ (in Celo 18 decimals) = 250000000000000000n + // Amount received: 99.75 G$ (in Celo 18 decimals) = 99750000000000000000n + const fuseSourceQuote: BridgeFeeQuote = { + fee: 132000000000000n, + feeInNative: '0.00012 FUSE', + protocol: 'LAYERZERO', + target: DEMO_ADDRESS, + targetChainId: BRIDGING_CHAINS.CELO, + amount: G$Fuse(100), // 10000n + protocolFeeBps: 25n, + protocolFee: 250000000000000000n, + amountReceived: 99750000000000000000n, + } + + return ( + + ) +} + +export function FuseDestinationStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + // Celo (42220) has 18 decimals -> Fuse (122) has 2 decimals + const celoDecimals = 18 + const G$Celo = (whole: number) => BigInt(whole) * 10n ** BigInt(celoDecimals) + + const celoBridgeConfig: BridgeConfig = { + ...sampleBridgeConfig, + currentChainId: BRIDGING_CHAINS.CELO, + tokenBalance: G$Celo(500), + routeLimits: { + minAmount: G$Celo(1), + dailyLimit: G$Celo(1000000), + txLimit: G$Celo(300000000), + accountDailyLimit: G$Celo(50000), + onlyWhitelisted: false, + }, + routeLimitsChainId: BRIDGING_CHAINS.CELO, + } + + // 100 G$ from Celo (18 decimals) to Fuse (2 decimals) + // Protocol fee: 0.25 G$ (in Fuse 2 decimals) = 25n + // Amount received: 99.75 G$ (in Fuse 2 decimals) = 9975n + const fuseDestinationQuote: BridgeFeeQuote = { + fee: 132000000000000n, + feeInNative: '0.00012 CELO', + protocol: 'LAYERZERO', + target: DEMO_ADDRESS, + targetChainId: BRIDGING_CHAINS.FUSE, + amount: G$Celo(100), + protocolFeeBps: 25n, + protocolFee: 25n, + amountReceived: 9975n, + } + + return ( + + ) +} + +// Clickable adapter fixture: exercises route, amount, Review, Confirm and +// submit callbacks in one mounted widget, rather than pre-opening each state. +export function InteractiveSubmitStory({ defaultTheme, themeOverrides }: ThemeArgs = {}) { + const [source, setSource] = useState(BRIDGING_CHAINS.CELO) + const [target, setTarget] = useState(null) + const [protocol, setProtocol] = useState<'LAYERZERO' | 'AXELAR'>('LAYERZERO') + const [amount, setAmount] = useState('') + const [quotedAmount, setQuotedAmount] = useState('') + const [reviewOpen, setReviewOpen] = useState(false) + const [submissionStep, setSubmissionStep] = useState('idle') + + useEffect(() => { + if (submissionStep !== 'bridging') return + const timer = setTimeout(() => setSubmissionStep('completed'), 700) + return () => clearTimeout(timer) + }, [submissionStep]) + + useEffect(() => { + setQuotedAmount('') + if (!amount || target === null) return + const timer = setTimeout(() => setQuotedAmount(amount), 200) + return () => clearTimeout(timer) + }, [source, target, protocol, amount]) + + let quote: BridgeFeeQuote | null = null + try { + if (target !== null && amount === quotedAmount && Number(amount) > 0) { + const sourceDecimals = source === BRIDGING_CHAINS.FUSE || source === BRIDGING_CHAINS.ETHEREUM ? 2 : 18 + const targetDecimals = target === BRIDGING_CHAINS.FUSE || target === BRIDGING_CHAINS.ETHEREUM ? 2 : 18 + const receivedBase = parseUnits(amount, targetDecimals) + const protocolFee = receivedBase / 200n + quote = { + ...sampleQuote, + protocol, + targetChainId: target, + amount: parseUnits(amount, sourceDecimals), + protocolFee, + amountReceived: receivedBase - protocolFee, + } + } + } catch { + // An incomplete decimal entry has no actionable quote. + } + + const adapter = createAdapter({ + chainId: source, + walletChainId: BRIDGING_CHAINS.CELO, + targetChainId: target, + availableProtocols: target === null ? [] : source === BRIDGING_CHAINS.CELO && target === BRIDGING_CHAINS.ETHEREUM ? ['AXELAR', 'LAYERZERO'] : ['LAYERZERO'], + selectedProtocol: target === null ? null : protocol, + inputAmount: amount, + bridgeConfig: { ...sampleBridgeConfig, currentChainId: source, tokenBalance: source === BRIDGING_CHAINS.FUSE || source === BRIDGING_CHAINS.ETHEREUM ? G$Fuse(500) : G$(500) }, + quoteStatus: quote ? 'ready' : amount ? 'loading' : 'idle', + quote, + canBridge: !!quote, + isReviewOpen: reviewOpen, + submissionStep, + }, { + selectSourceChain: (next) => { + setSource(next) + setTarget(null) + setAmount('') + setReviewOpen(false) + }, + selectTargetChain: (next) => { + setTarget(next) + setProtocol('LAYERZERO') + setAmount('') + setReviewOpen(false) + }, + selectProtocol: setProtocol, + setInputAmount: setAmount, + openReview: () => setReviewOpen(true), + closeReview: () => setReviewOpen(false), + confirmBridge: () => { + setReviewOpen(false) + setSubmissionStep('bridging') + }, + retrySubmission: () => setSubmissionStep('bridging'), + closeSubmission: () => setSubmissionStep('idle'), + }) + + return ( + + ) +} diff --git a/packages/bridging-widget/src/BridgingWidget.tsx b/packages/bridging-widget/src/BridgingWidget.tsx index 4529c2de..2be3310c 100644 --- a/packages/bridging-widget/src/BridgingWidget.tsx +++ b/packages/bridging-widget/src/BridgingWidget.tsx @@ -31,7 +31,13 @@ export function BridgingWidgetPreview({ defaultTheme = 'dark', }: BridgingWidgetPreviewProps) { return ( - + diff --git a/packages/bridging-widget/src/adapter.ts b/packages/bridging-widget/src/adapter.ts index 7a717928..f72f0339 100644 --- a/packages/bridging-widget/src/adapter.ts +++ b/packages/bridging-widget/src/adapter.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { formatUnits } from 'viem' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { formatUnits, parseUnits } from 'viem' import { useWallet } from '@goodwidget/core' import type { BridgeProtocol, BridgeTransaction } from '@goodsdks/bridging-sdk' import type { Address } from 'viem' @@ -10,7 +10,9 @@ import type { } from './widgetRuntimeContract' import { getRouteProtocols } from './adapter/bridgeRoutes' import { useBridgingPublicClients, useBridgingSdk } from './adapter/bridgingClient' -import { isBridgingChain } from './adapter/chains' +import { isBridgingChain, getChainDecimals, getChainTransactionExplorerLink } from './adapter/chains' +import { primaryRequirementMessage } from './adapter/errors' +import { BRIDGING_CHAINS } from './widgetRuntimeContract' import { sanitizeAmount } from './amount' import { useBridgeConfig } from './adapter/useBridgeConfig' import { useBridgeHistory } from './adapter/useBridgeHistory' @@ -20,9 +22,12 @@ 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 [selectedSourceChainId, setSelectedSourceChainId] = useState(null) + const activeChainId = selectedSourceChainId ?? (isBridgingChain(chainId) ? chainId! : BRIDGING_CHAINS.CELO) + const previousWalletChainIdRef = useRef(chainId) + const isWrongChain = isConnected && !isBridgingChain(chainId ?? null) + const submissionIdentityRef = useRef({ walletAddress, activeChainId }) + submissionIdentityRef.current = { walletAddress, activeChainId } const [targetChainId, setTargetChainId] = useState(null) const [selectedProtocol, setSelectedProtocol] = useState(null) @@ -31,22 +36,37 @@ export function useBridgingAdapter(): BridgingWidgetAdapterResult { const [selectedHistoryTransaction, setSelectedHistoryTransaction] = useState(null) const publicClients = useBridgingPublicClients() - const sdk = useBridgingSdk({ provider, chainId: activeChainId }) + const sdk = useBridgingSdk({ provider, chainId: activeChainId, publicClients }) + const historySdk = useBridgingSdk({ provider: null, chainId: BRIDGING_CHAINS.CELO, publicClients }) const submission = useBridgeSubmission({ sdk }) - const history = useBridgeHistory({ sdk, address: walletAddress, publicClients }) + const history = useBridgeHistory({ sdk: historySdk, 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 + if (previousWalletChainIdRef.current === chainId) return + previousWalletChainIdRef.current = chainId + if (isBridgingChain(chainId) && selectedSourceChainId !== null && chainId !== selectedSourceChainId) { + submission.cancelPreparation() + setSelectedSourceChainId(null) + } + }, [chainId, selectedSourceChainId, submission.cancelPreparation]) + + const selectSourceChain = useCallback((nextChainId: number) => { + if (!isBridgingChain(nextChainId)) return + if (nextChainId !== activeChainId) submission.cancelPreparation() + setSelectedSourceChainId(nextChainId) + }, [activeChainId, submission.cancelPreparation]) + // Route and amount belong to the selected account and source chain. Keep + // an in-flight transaction visible if either changes while it settles. + useEffect(() => { setTargetChainId(null) setSelectedProtocol(null) setInputAmountRaw('') setIsReviewOpen(false) setSelectedHistoryTransaction(null) - submission.reset() - }, [isReady]) + submission.cancelPreparation() + if (submission.step === 'failed') submission.reset() + }, [walletAddress, activeChainId]) // 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 @@ -54,13 +74,14 @@ export function useBridgingAdapter(): BridgingWidgetAdapterResult { useEffect(() => { setInputAmountRaw('') setIsReviewOpen(false) - submission.reset() + submission.cancelPreparation() + if (submission.step === 'failed') submission.reset() }, [targetChainId]) const availableProtocols = useMemo(() => { - if (!isReady || activeChainId === null || targetChainId === null) return [] + if (activeChainId === null || targetChainId === null) return [] return getRouteProtocols(activeChainId, targetChainId) - }, [isReady, activeChainId, targetChainId]) + }, [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 @@ -82,6 +103,7 @@ export function useBridgingAdapter(): BridgingWidgetAdapterResult { const selectProtocol = useCallback((protocol: BridgeProtocol) => { setSelectedProtocol(protocol) + setIsReviewOpen(false) }, []) const bridgeConfigState = useBridgeConfig({ @@ -103,31 +125,63 @@ export function useBridgingAdapter(): BridgingWidgetAdapterResult { const setInputAmount = useCallback((amount: string) => { setInputAmountRaw(sanitizeAmount(amount)) + setIsReviewOpen(false) }, []) const setMaxAmount = useCallback(() => { const balance = bridgeConfigState.config?.tokenBalance - if (balance === undefined) return - setInputAmountRaw(formatUnits(balance, 18)) - }, [bridgeConfigState.config]) + if (balance === undefined || activeChainId === null) return + const decimals = getChainDecimals(activeChainId) + setInputAmountRaw(formatUnits(balance, decimals)) + }, [bridgeConfigState.config, activeChainId]) const openReview = useCallback(() => { - if (!quoteState.canBridge || !quoteState.quote) return + if (quoteState.status !== 'ready' || !quoteState.canBridge || !quoteState.quote) return setIsReviewOpen(true) - }, [quoteState.canBridge, quoteState.quote]) + }, [quoteState.status, quoteState.canBridge, quoteState.quote]) const closeReview = useCallback(() => setIsReviewOpen(false), []) + const isSubmissionIdentityCurrent = useCallback( + () => submissionIdentityRef.current.walletAddress === walletAddress && + submissionIdentityRef.current.activeChainId === activeChainId, + [walletAddress, activeChainId], + ) + + const loadLatestQuote = useCallback(async () => { + if (!sdk || !walletAddress || targetChainId === null || !selectedProtocol || !inputAmount) { + throw new Error('Choose a route and amount before bridging.') + } + if (chainId !== activeChainId) { + await switchChain(activeChainId) + if (!isSubmissionIdentityCurrent()) throw new Error('Bridge preparation cancelled.') + } + const amount = parseUnits(inputAmount, getChainDecimals(activeChainId)) + const result = await sdk.getQuote({ + sender: walletAddress, + amount, + fromChain: activeChainId, + toChain: targetChainId, + protocol: selectedProtocol, + refresh: true, + }) + if (!isSubmissionIdentityCurrent()) throw new Error('Bridge preparation cancelled.') + if (!result.canBridge || !result.quote) { + throw new Error(primaryRequirementMessage(result.requirements) ?? 'Bridge quote is no longer valid.') + } + return result.quote + }, [sdk, walletAddress, targetChainId, selectedProtocol, inputAmount, chainId, activeChainId, switchChain, isSubmissionIdentityCurrent]) + const confirmBridge = useCallback(() => { - if (!quoteState.quote) return + if (!isReviewOpen || quoteState.status !== 'ready' || !quoteState.canBridge) return setIsReviewOpen(false) - void submission.submit(quoteState.quote) - }, [quoteState.quote, submission.submit]) + void submission.submit(loadLatestQuote, isSubmissionIdentityCurrent, activeChainId) + }, [isReviewOpen, quoteState.status, quoteState.canBridge, submission.submit, loadLatestQuote, isSubmissionIdentityCurrent, activeChainId]) const retrySubmission = useCallback(() => { - if (!quoteState.quote) return - void submission.submit(quoteState.quote) - }, [quoteState.quote, submission.submit]) + if (submission.step !== 'failed') return + void submission.submit(loadLatestQuote, isSubmissionIdentityCurrent, activeChainId) + }, [submission.step, submission.submit, loadLatestQuote, isSubmissionIdentityCurrent, activeChainId]) const closeSubmission = useCallback(() => { const wasCompleted = submission.step === 'completed' @@ -156,6 +210,7 @@ export function useBridgingAdapter(): BridgingWidgetAdapterResult { const state: BridgingWidgetAdapterState = { isConnected, address: walletAddress, + walletChainId: chainId ?? null, chainId: activeChainId, isWrongChain, targetChainId, @@ -177,6 +232,23 @@ export function useBridgingAdapter(): BridgingWidgetAdapterResult { submissionFailedAtStep: submission.failedAtStep, submissionError: submission.error, submissionTxHash: submission.receipt?.transactionHash ?? null, + submissionSourceChainId: submission.sourceChainId, + submissionTargetChainId: submission.targetChainId, + submissionProtocol: submission.protocol, + submissionAmount: submission.amount, + submissionDeliveryStatus: submission.deliveryStatus, + submissionStatusError: submission.deliveryStatusError, + submissionSourceExplorerLink: getChainTransactionExplorerLink( + submission.sourceChainId, + submission.receipt?.transactionHash ?? null, + ), + submissionProviderExplorerLink: submission.receipt && submission.protocol && sdk + ? sdk.getExplorerLink(submission.receipt.transactionHash, submission.protocol) + : null, + submissionDestinationExplorerLink: getChainTransactionExplorerLink( + submission.targetChainId, + submission.deliveryStatus?.dstTxHash ?? null, + ), historyTransactions: history.transactions, isHistoryLoading: history.isLoading, historyError: history.error, @@ -188,6 +260,7 @@ export function useBridgingAdapter(): BridgingWidgetAdapterResult { const actions: BridgingWidgetAdapterActions = { connect, switchChain, + selectSourceChain, selectTargetChain, selectProtocol, setInputAmount, diff --git a/packages/bridging-widget/src/adapter/bridgeRoutes.ts b/packages/bridging-widget/src/adapter/bridgeRoutes.ts index 9314d043..26046837 100644 --- a/packages/bridging-widget/src/adapter/bridgeRoutes.ts +++ b/packages/bridging-widget/src/adapter/bridgeRoutes.ts @@ -26,3 +26,11 @@ export function getRouteProtocols(fromChainId: number, toChainId: number): Bridg return protocols } + +export function getProtocolSupportedChains(protocol: BridgeProtocol): BridgingChainId[] { + return (Object.values(BRIDGING_CHAINS) as BridgingChainId[]).filter((fromChainId) => + getBridgeTargetChains(fromChainId).some((toChainId) => + getRouteProtocols(fromChainId, toChainId).includes(protocol), + ), + ) +} diff --git a/packages/bridging-widget/src/adapter/bridgingClient.ts b/packages/bridging-widget/src/adapter/bridgingClient.ts index 36f78945..8dbe1bac 100644 --- a/packages/bridging-widget/src/adapter/bridgingClient.ts +++ b/packages/bridging-widget/src/adapter/bridgingClient.ts @@ -19,6 +19,7 @@ function buildPublicClients(): Record { interface UseBridgingSdkArgs { provider: EIP1193Provider | null chainId: number | null + publicClients: Record } // Exposed separately from useBridgingSdk so callers that need the raw @@ -32,9 +33,7 @@ export function useBridgingPublicClients(): Record { // 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() - +export function useBridgingSdk({ provider, chainId, publicClients }: UseBridgingSdkArgs): BridgingSDK | null { const walletClient = useMemo(() => { if (!provider || chainId === null) return undefined const chain = VIEM_CHAINS[chainId] diff --git a/packages/bridging-widget/src/adapter/chains.ts b/packages/bridging-widget/src/adapter/chains.ts index d24b0727..28279cd9 100644 --- a/packages/bridging-widget/src/adapter/chains.ts +++ b/packages/bridging-widget/src/adapter/chains.ts @@ -1,9 +1,10 @@ -import { createPublicClient, http, type Chain } from 'viem' +import { createPublicClient, formatUnits, http, type Chain } from 'viem' +import { SUPPORTED_CHAINS } from '@goodsdks/bridging-sdk' +import type { Hash } 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. +// RPC endpoints follow GoodSDKs' demo wiring except Ethereum, where its +// Cloudflare endpoint fails contract reads used by bridge configuration. export const VIEM_CHAINS: Record = { [BRIDGING_CHAINS.CELO]: { id: BRIDGING_CHAINS.CELO, @@ -15,7 +16,7 @@ export const VIEM_CHAINS: Record = { id: BRIDGING_CHAINS.ETHEREUM, name: 'Ethereum', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - rpcUrls: { default: { http: ['https://cloudflare-eth.com'] } }, + rpcUrls: { default: { http: ['https://ethereum-rpc.publicnode.com'] } }, } as Chain, [BRIDGING_CHAINS.FUSE]: { id: BRIDGING_CHAINS.FUSE, @@ -35,6 +36,31 @@ export function isBridgingChain(chainId: number | null | undefined): boolean { return !!chainId && chainId in VIEM_CHAINS } +export function getChainDecimals(chainId: number | null | undefined): number { + if (!chainId) return 18 + return SUPPORTED_CHAINS[chainId]?.decimals ?? 18 +} + +// The SDK's feeInNative is the unbuffered provider estimate. The transaction +// sends fee (which includes its buffer), so show that exact native-token value. +export function formatChargedProviderFee(fee: bigint, sourceChainId: number): string { + const native = SUPPORTED_CHAINS[sourceChainId]?.nativeCurrency + if (!native) return formatUnits(fee, 18) + return `${formatUnits(fee, native.decimals)} ${native.symbol}` +} + +const TRANSACTION_EXPLORERS: Record = { + [BRIDGING_CHAINS.CELO]: 'https://celoscan.io/tx', + [BRIDGING_CHAINS.ETHEREUM]: 'https://etherscan.io/tx', + [BRIDGING_CHAINS.FUSE]: 'https://explorer.fuse.io/tx', + [BRIDGING_CHAINS.XDC]: 'https://xdcscan.com/tx', +} + +export function getChainTransactionExplorerLink(chainId: number | null, hash: Hash | null): string | null { + const explorer = chainId === null ? undefined : TRANSACTION_EXPLORERS[chainId] + return explorer && hash ? `${explorer}/${hash}` : null +} + export function createBridgingPublicClient(chainId: number) { const chain = VIEM_CHAINS[chainId] if (!chain) return null diff --git a/packages/bridging-widget/src/adapter/errors.ts b/packages/bridging-widget/src/adapter/errors.ts index 666bb8f1..0497db23 100644 --- a/packages/bridging-widget/src/adapter/errors.ts +++ b/packages/bridging-widget/src/adapter/errors.ts @@ -34,16 +34,12 @@ const BRIDGE_ERROR_RULES: readonly ErrorMatcher[] = [ 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 { +export function mapBridgeError(err: unknown, fallback: string, context: 'other' | 'transaction' = 'other'): string { console.error('[BridgingWidget]', err) const message = err instanceof Error ? err.message : String(err ?? fallback) @@ -53,6 +49,10 @@ export function mapBridgeError(err: unknown, fallback: string): string { if (rule.match.some((needle) => lower.includes(needle))) return rule.message } + if (context === 'transaction' && lower.includes('revert')) { + return 'Bridge transaction reverted on-chain. Try a smaller amount.' + } + return fallback } diff --git a/packages/bridging-widget/src/adapter/historyTransactions.ts b/packages/bridging-widget/src/adapter/historyTransactions.ts new file mode 100644 index 00000000..33cb74a9 --- /dev/null +++ b/packages/bridging-widget/src/adapter/historyTransactions.ts @@ -0,0 +1,107 @@ +import type { + BridgeRequestEvent, + BridgeTransaction, + BridgingSDK, + ExecutedTransferEvent, +} from '@goodsdks/bridging-sdk' + +type StatusClient = Pick + +function isRequestEvent( + event: BridgeRequestEvent | ExecutedTransferEvent, +): event is BridgeRequestEvent { + return 'targetChainId' in event.args +} + +export interface BuildTransactionsResult { + transactions: BridgeTransaction[] + isPartial: boolean +} + +// Request IDs are scoped to the source chain and bridge protocol. Preserve +// requests with unavailable explorer statuses as pending so polling can retry. +export async function buildTransactions( + sdk: StatusClient, + events: (BridgeRequestEvent | ExecutedTransferEvent)[], +): Promise { + const requests = new Map() + const transfers = 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)) requests.set(key, event) + else transfers.set(key, event) + } + + let isPartial = false + const transactions = await Promise.all( + Array.from(new Set([...requests.keys(), ...transfers.keys()]), async (key): Promise => { + const request = requests.get(key) + const transfer = transfers.get(key) + + if (request && transfer) { + 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: transfer.transactionHash }, + timestamp: request.timestamp, + } + } + + if (request) { + let status: BridgeTransaction['status'] + try { + status = await sdk.getTransactionStatus(request.transactionHash, request.args.bridge) + } catch { + isPartial = true + status = { status: 'pending', srcTxHash: request.transactionHash } + } + return { + hash: request.transactionHash, + fromChainId: request.chainId, + toChainId: request.args.targetChainId, + amount: request.args.amount, + protocol: request.args.bridge, + status, + timestamp: request.timestamp, + } + } + + // An executed event can be visible even when the source request lies + // outside the queried block range. + const executed = transfer as ExecutedTransferEvent + return { + hash: executed.transactionHash, + fromChainId: executed.args.sourceChainId, + toChainId: executed.chainId, + amount: executed.args.amount, + protocol: executed.args.bridge, + status: { status: 'completed', dstTxHash: executed.transactionHash }, + timestamp: executed.timestamp, + } + }), + ) + return { transactions, isPartial } +} + +export async function resolvePendingStatuses( + sdk: StatusClient, + transactions: BridgeTransaction[], +): Promise { + let isPartial = false + const updated = await Promise.all(transactions.map(async (transaction) => { + if (transaction.status.status !== 'pending') return transaction + try { + const status = await sdk.getTransactionStatus(transaction.hash, transaction.protocol) + return { ...transaction, status } + } catch { + isPartial = true + return transaction + } + })) + return { transactions: updated, isPartial } +} diff --git a/packages/bridging-widget/src/adapter/useBridgeConfig.ts b/packages/bridging-widget/src/adapter/useBridgeConfig.ts index eb87d09f..c97f8e3a 100644 --- a/packages/bridging-widget/src/adapter/useBridgeConfig.ts +++ b/packages/bridging-widget/src/adapter/useBridgeConfig.ts @@ -30,18 +30,23 @@ export function useBridgeConfig({ toChain, protocol, }: UseBridgeConfigArgs): BridgeConfigState { - const [state, setState] = useState(EMPTY_STATE) + const routeKey = `${address}:${fromChain}:${toChain}:${protocol}` + const [state, setState] = useState<{ routeKey: string; sdk: BridgingSDK | null; result: BridgeConfigState }>({ + routeKey: '', + sdk: null, + result: EMPTY_STATE, + }) const requestIdRef = useRef(0) useEffect(() => { if (!sdk || !address) { requestIdRef.current += 1 - setState(EMPTY_STATE) + setState({ routeKey, sdk, result: EMPTY_STATE }) return } const requestId = ++requestIdRef.current - setState({ config: null, isLoading: true, error: null }) + setState({ routeKey, sdk, result: { config: null, isLoading: true, error: null } }) const route = fromChain !== null && toChain !== null && protocol @@ -52,17 +57,26 @@ export function useBridgeConfig({ .getBridgeConfig(address, route) .then((config) => { if (requestIdRef.current !== requestId) return - setState({ config, isLoading: false, error: null }) + setState({ routeKey, sdk, result: { 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.'), + routeKey, + sdk, + result: { + config: null, + isLoading: false, + error: mapBridgeError(err, 'Unable to load balance and limits.'), + }, }) }) - }, [sdk, address, fromChain, toChain, protocol]) + }, [sdk, address, fromChain, toChain, protocol, routeKey]) - return state + // Effects reset asynchronously; never paint a balance or route limit that + // belongs to the previous account/source chain in the meantime. + if (state.routeKey !== routeKey || state.sdk !== sdk) { + return sdk && address ? { config: null, isLoading: true, error: null } : EMPTY_STATE + } + return state.result } diff --git a/packages/bridging-widget/src/adapter/useBridgeHistory.ts b/packages/bridging-widget/src/adapter/useBridgeHistory.ts index a34d03b2..7f6b60af 100644 --- a/packages/bridging-widget/src/adapter/useBridgeHistory.ts +++ b/packages/bridging-widget/src/adapter/useBridgeHistory.ts @@ -1,164 +1,54 @@ 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 type { Address, PublicClient } from 'viem' +import type { BridgeTransaction, BridgingSDK } from '@goodsdks/bridging-sdk' import { mapBridgeError } from './errors' +import { buildTransactions, resolvePendingStatuses } from './historyTransactions' 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. */ + /** True when a status lookup failed and its entry remains pending. */ 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. +// Scan all chains on initial load, account change, or explicit refresh. A +// 15-second pending poll checks explorer statuses only and leaves the list up. export function useBridgeHistory({ sdk, address, publicClients }: UseBridgeHistoryArgs): BridgeHistoryState & { refresh: () => void } { const [state, setState] = useState(EMPTY_STATE) const requestIdRef = useRef(0) + const identityRef = useRef<{ sdk: BridgingSDK | null; address: Address | null }>({ sdk: null, address: null }) const [refreshToken, setRefreshToken] = useState(0) useEffect(() => { if (!sdk || !address) { requestIdRef.current += 1 + identityRef.current = { sdk, address } setState(EMPTY_STATE) return } const requestId = ++requestIdRef.current - setState({ transactions: [], isLoading: true, error: null, isPartial: false }) + const sameIdentity = identityRef.current.sdk === sdk && identityRef.current.address === address + identityRef.current = { sdk, address } + setState((current) => sameIdentity && current.transactions.length > 0 + ? { ...current, error: null } + : { transactions: [], isLoading: true, error: null, isPartial: false }) - sdk - .getCombinedHistory(address, publicClients, { limit: HISTORY_EVENT_LIMIT }) + sdk.getCombinedHistory(address, publicClients, { limit: HISTORY_EVENT_LIMIT }) .then((events) => buildTransactions(sdk, events)) .then(({ transactions, isPartial }) => { if (requestIdRef.current !== requestId) return @@ -167,27 +57,44 @@ export function useBridgeHistory({ sdk, address, publicClients }: UseBridgeHisto }) .catch((err: unknown) => { if (requestIdRef.current !== requestId) return - setState({ - transactions: [], + setState((current) => ({ + transactions: sameIdentity ? current.transactions : [], isLoading: false, error: mapBridgeError(err, 'Unable to load bridge history.'), - isPartial: false, - }) + isPartial: sameIdentity && current.transactions.length > 0, + })) }) }, [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. + // A failed explicit scan preserves the same transactions array but still + // advances requestIdRef. Restart polling with that new request ID. 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]) + if (!sdk || !address) return + const pending = state.transactions.filter((transaction) => transaction.status.status === 'pending') + if (pending.length === 0) return + const requestId = requestIdRef.current + let polling = false + let active = true + const intervalId = setInterval(async () => { + if (polling) return + polling = true + const result = await resolvePendingStatuses(sdk, pending) + polling = false + if (!active || requestIdRef.current !== requestId) return + const updated = new Map(result.transactions.map((transaction) => [transaction.hash, transaction])) + setState((current) => ({ + ...current, + transactions: current.transactions.map((transaction) => updated.get(transaction.hash) ?? transaction), + isPartial: result.isPartial, + })) + }, HISTORY_POLL_INTERVAL_MS) + return () => { + active = false + clearInterval(intervalId) + } + }, [sdk, address, publicClients, refreshToken, state.transactions]) return { ...state, refresh } } diff --git a/packages/bridging-widget/src/adapter/useBridgeQuote.ts b/packages/bridging-widget/src/adapter/useBridgeQuote.ts index 69c2d27b..1081a756 100644 --- a/packages/bridging-widget/src/adapter/useBridgeQuote.ts +++ b/packages/bridging-widget/src/adapter/useBridgeQuote.ts @@ -1,14 +1,11 @@ import { useEffect, useRef, useState } from 'react' import { parseUnits, type Address } from 'viem' import type { BridgeFeeQuote, BridgeProtocol, BridgeRequirement, BridgingSDK } from '@goodsdks/bridging-sdk' +import { getChainDecimals } from './chains' 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 { @@ -49,57 +46,67 @@ export function useBridgeQuote({ protocol, inputAmount, }: UseBridgeQuoteArgs): BridgeQuoteState { - const [state, setState] = useState(IDLE_STATE) + const queryKey = `${sender}:${fromChain}:${toChain}:${protocol}:${inputAmount}` + const [state, setState] = useState<{ queryKey: string; sdk: BridgingSDK | null; result: BridgeQuoteState }>({ + queryKey: '', sdk: null, result: IDLE_STATE, + }) const requestIdRef = useRef(0) useEffect(() => { if (!sdk || !sender || fromChain === null || toChain === null || !protocol || !inputAmount) { requestIdRef.current += 1 - setState(IDLE_STATE) + setState({ queryKey, sdk, result: IDLE_STATE }) return } let amount: bigint try { - amount = parseUnits(inputAmount, G_DOLLAR_DECIMALS) + amount = parseUnits(inputAmount, getChainDecimals(fromChain)) } catch { requestIdRef.current += 1 - setState(IDLE_STATE) + setState({ queryKey, sdk, result: IDLE_STATE }) return } if (amount <= 0n) { requestIdRef.current += 1 - setState(IDLE_STATE) + setState({ queryKey, sdk, result: IDLE_STATE }) return } const requestId = ++requestIdRef.current - setState((current) => ({ ...current, status: 'loading', error: null })) + setState({ queryKey, sdk, result: { ...IDLE_STATE, status: 'loading' } }) const timeoutId = setTimeout(async () => { try { const result = await sdk.getQuote({ sender, amount, fromChain, toChain, protocol }) if (requestIdRef.current !== requestId) return - setState({ + setState({ queryKey, sdk, result: { 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({ + setState({ queryKey, sdk, result: { ...IDLE_STATE, status: 'error', error: mapBridgeError(err, 'Failed to fetch bridge quote.'), - }) + } }) } }, QUOTE_DEBOUNCE_MS) return () => clearTimeout(timeoutId) - }, [sdk, sender, fromChain, toChain, protocol, inputAmount]) + }, [sdk, sender, fromChain, toChain, protocol, inputAmount, queryKey]) - return state + // Effects run after render. Hide the old route's quote immediately on the + // first render with changed inputs, before a user could click Review. + if (state.queryKey !== queryKey || state.sdk !== sdk) { + return sdk && sender && fromChain !== null && toChain !== null && protocol && inputAmount + ? { ...IDLE_STATE, status: 'loading' } + : IDLE_STATE + } + return state.result } diff --git a/packages/bridging-widget/src/adapter/useBridgeSubmission.ts b/packages/bridging-widget/src/adapter/useBridgeSubmission.ts index 008b7fdc..03533179 100644 --- a/packages/bridging-widget/src/adapter/useBridgeSubmission.ts +++ b/packages/bridging-widget/src/adapter/useBridgeSubmission.ts @@ -1,6 +1,12 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { TransactionReceipt } from 'viem' -import type { BridgeFeeQuote, BridgeStatus, BridgingSDK } from '@goodsdks/bridging-sdk' +import type { + BridgeFeeQuote, + BridgeProtocol, + BridgeStatus, + BridgingSDK, + TransactionStatus, +} from '@goodsdks/bridging-sdk' import { mapBridgeError } from './errors' export type BridgeSubmissionStep = 'idle' | 'approving' | 'bridging' | 'completed' | 'failed' @@ -12,16 +18,34 @@ export interface BridgeSubmissionState { failedAtStep: 'approving' | 'bridging' | null error: string | null receipt: TransactionReceipt | null + sourceChainId: number | null + targetChainId: number | null + protocol: BridgeProtocol | null + amount: bigint | null + deliveryStatus: TransactionStatus | null + deliveryStatusError: string | null } -const IDLE_STATE: BridgeSubmissionState = { step: 'idle', failedAtStep: null, error: null, receipt: null } +const IDLE_STATE: BridgeSubmissionState = { + step: 'idle', + failedAtStep: null, + error: null, + receipt: null, + sourceChainId: null, + targetChainId: null, + protocol: null, + amount: null, + deliveryStatus: null, + deliveryStatusError: null, +} interface UseBridgeSubmissionArgs { sdk: BridgingSDK | null } export interface BridgeSubmissionResult extends BridgeSubmissionState { - submit: (quote: BridgeFeeQuote) => Promise + submit: (loadQuote: () => Promise, isCurrent: () => boolean, sourceChainId: number) => Promise + cancelPreparation: () => void reset: () => void } @@ -34,16 +58,39 @@ export function useBridgeSubmission({ sdk }: UseBridgeSubmissionArgs): BridgeSub // 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 requestIdRef = useRef(0) + // A quote/network switch is cancellable. Once doBridge starts, preserve its + // status callbacks even if the wallet account or selected source changes. + const preparingRequestRef = useRef(null) const submit = useCallback( - async (quote: BridgeFeeQuote) => { + async (loadQuote: () => Promise, isCurrent: () => boolean, sourceChainId: number) => { if (!sdk) return + const requestId = ++requestIdRef.current + preparingRequestRef.current = requestId lastInFlightStepRef.current = null - setState({ step: 'approving', failedAtStep: null, error: null, receipt: null }) + setState({ ...IDLE_STATE, step: 'approving', sourceChainId }) + let doBridgeStarted = false try { + const quote = await loadQuote() + if (requestIdRef.current !== requestId) return + if (preparingRequestRef.current === requestId && !isCurrent()) { + preparingRequestRef.current = null + setState(IDLE_STATE) + return + } + setState((current) => ({ + ...current, + targetChainId: quote.targetChainId, + protocol: quote.protocol, + amount: quote.amount, + })) + preparingRequestRef.current = null + doBridgeStarted = true const receipt = await sdk.doBridge(quote, (status: BridgeStatus) => { + if (requestIdRef.current !== requestId) return if (status.step === 'approving' || status.step === 'bridging') { lastInFlightStepRef.current = status.step } @@ -51,12 +98,34 @@ export function useBridgeSubmission({ sdk }: UseBridgeSubmissionArgs): BridgeSub setState((current) => ({ ...current, step: status.step, error: null })) } }) - setState({ step: 'completed', failedAtStep: null, error: null, receipt }) + if (requestIdRef.current !== requestId) return + setState((current) => ({ + ...current, + step: 'completed', + failedAtStep: null, + error: null, + receipt, + deliveryStatus: { status: 'pending', srcTxHash: receipt.transactionHash }, + deliveryStatusError: null, + })) } catch (err: unknown) { + if (requestIdRef.current !== requestId) return + if (preparingRequestRef.current === requestId && !isCurrent()) { + preparingRequestRef.current = null + setState(IDLE_STATE) + return + } + preparingRequestRef.current = null setState({ + ...IDLE_STATE, step: 'failed', + sourceChainId, failedAtStep: lastInFlightStepRef.current, - error: mapBridgeError(err, 'Bridge transaction failed.'), + error: mapBridgeError( + err, + doBridgeStarted ? 'Bridge transaction failed.' : 'Unable to prepare bridge. Please try again.', + doBridgeStarted ? 'transaction' : 'other', + ), receipt: null, }) } @@ -64,7 +133,51 @@ export function useBridgeSubmission({ sdk }: UseBridgeSubmissionArgs): BridgeSub [sdk], ) - const reset = useCallback(() => setState(IDLE_STATE), []) + useEffect(() => { + const hash = state.receipt?.transactionHash + const protocol = state.protocol + if (!sdk || state.step !== 'completed' || !hash || !protocol) return + + let active = true + let timeout: ReturnType | undefined + const poll = async () => { + try { + const deliveryStatus = await sdk.getTransactionStatus(hash, protocol) + if (!active) return + setState((current) => current.receipt?.transactionHash === hash + ? { ...current, deliveryStatus, deliveryStatusError: null } + : current) + if (deliveryStatus.status === 'pending') timeout = setTimeout(poll, 15_000) + } catch { + if (!active) return + setState((current) => current.receipt?.transactionHash === hash + ? { ...current, deliveryStatusError: 'Delivery status is temporarily unavailable. Retrying…' } + : current) + timeout = setTimeout(poll, 15_000) + } + } + + void poll() + return () => { + active = false + if (timeout) clearTimeout(timeout) + } + }, [sdk, state.step, state.receipt, state.protocol]) + + const cancelPreparation = useCallback(() => { + if (preparingRequestRef.current === null) return + preparingRequestRef.current = null + requestIdRef.current += 1 + lastInFlightStepRef.current = null + setState(IDLE_STATE) + }, []) + + const reset = useCallback(() => { + requestIdRef.current += 1 + preparingRequestRef.current = null + lastInFlightStepRef.current = null + setState(IDLE_STATE) + }, []) - return { ...state, submit, reset } + return { ...state, submit, cancelPreparation, reset } } diff --git a/packages/bridging-widget/src/amount.ts b/packages/bridging-widget/src/amount.ts index 0846cce9..b2a4a52f 100644 --- a/packages/bridging-widget/src/amount.ts +++ b/packages/bridging-widget/src/amount.ts @@ -1,3 +1,5 @@ +import { formatUnits } from 'viem' + // 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 @@ -46,6 +48,12 @@ export function formatTokenAmount(value: string | number | null | undefined): st }).format(num) } +// SDK history events expose normalizedAmount at 18 decimals, regardless of +// the source token's decimals (Fuse G$ uses 2 decimals before bridging). +export function formatHistoryAmount(normalizedAmount: bigint): string { + return formatTokenAmount(formatUnits(normalizedAmount, 18)) +} + /** * Shortens a raw amount for display inside the editable amount input while * unfocused. Truncates rather than rounds so the displayed value never diff --git a/packages/bridging-widget/src/components/AmountEntry.tsx b/packages/bridging-widget/src/components/AmountEntry.tsx index f51ff2c9..e4518f5f 100644 --- a/packages/bridging-widget/src/components/AmountEntry.tsx +++ b/packages/bridging-widget/src/components/AmountEntry.tsx @@ -1,14 +1,15 @@ import React, { useState } from 'react' -import { Input, Text, XStack } from '@goodwidget/ui' +import { InputFrame, Text, XStack } from '@goodwidget/ui' import { formatUnits } from 'viem' import type { BridgeConfig } from '@goodsdks/bridging-sdk' +import { getChainDecimals } from '../adapter/chains' import { formatInputAmount, formatTokenAmount } from '../amount' import { RouteSelectionCard } from './shared' -const G_DOLLAR_DECIMALS = 18 - interface AmountEntryProps { inputAmount: string + sourceChainId?: number | null + isConnected?: boolean bridgeConfig: BridgeConfig | null isBridgeConfigLoading: boolean onChangeAmount: (raw: string) => void @@ -20,6 +21,8 @@ interface AmountEntryProps { // two widgets read as the same family. export function AmountEntry({ inputAmount, + sourceChainId, + isConnected = true, bridgeConfig, isBridgeConfigLoading, onChangeAmount, @@ -30,11 +33,14 @@ export function AmountEntry({ const [isFocused, setIsFocused] = useState(false) const displayValue = isFocused ? inputAmount : formatInputAmount(inputAmount) - const balanceLabel = bridgeConfig - ? formatTokenAmount(formatUnits(bridgeConfig.tokenBalance, G_DOLLAR_DECIMALS)) - : isBridgeConfigLoading - ? '…' - : '0.00' + const sourceDecimals = getChainDecimals(sourceChainId) + const balanceLabel = !isConnected + ? '—' + : bridgeConfig + ? formatTokenAmount(formatUnits(bridgeConfig.tokenBalance, sourceDecimals)) + : isBridgeConfigLoading + ? '…' + : '0.00' return ( @@ -62,10 +68,14 @@ export function AmountEntry({ G$ - void onClose: () => void } @@ -29,6 +45,16 @@ export function BridgeConfirmModal({ submissionFailedAtStep, submissionError, needsApproval, + sourceChainId, + targetChainId, + protocol, + amount, + sourceTxHash, + deliveryStatus, + deliveryStatusError, + sourceExplorerLink, + providerExplorerLink, + destinationExplorerLink, onRetry, onClose, }: BridgeConfirmModalProps) { @@ -43,43 +69,70 @@ export function BridgeConfirmModal({ } const steps = buildSubmissionSteps(submissionStep, needsApproval, submissionFailedAtStep, submissionError) + const progressDetails = submissionStep === 'completed' && sourceChainId !== null && + targetChainId !== null && protocol !== null && amount !== null + ? { sourceChainId, targetChainId, protocol, amount } + : null return ( - - - - - {HEADER_COPY[submissionStep]} - - {isSettled && ( - - - - )} - + + + + + + {progressDetails ? 'Bridge status' : HEADER_COPY[submissionStep]} + + {isSettled && ( + + )} + - + {progressDetails ? ( + + ) : } - {submissionStep === 'failed' && ( - - {submissionError ?? 'Bridge transaction failed.'} - - - )} + {submissionStep === 'failed' && ( + + {submissionError ?? 'Bridge transaction failed.'} + + + )} - {submissionStep === 'completed' && ( - - )} - + {submissionStep === 'completed' && ( + + )} + + ) } diff --git a/packages/bridging-widget/src/components/BridgeHistoryDetailModal.tsx b/packages/bridging-widget/src/components/BridgeHistoryDetailModal.tsx index b664b9c6..17220daa 100644 --- a/packages/bridging-widget/src/components/BridgeHistoryDetailModal.tsx +++ b/packages/bridging-widget/src/components/BridgeHistoryDetailModal.tsx @@ -1,24 +1,17 @@ import React from 'react' -import { formatUnits } from 'viem' import { - Anchor, Button, - ButtonText, Drawer, Heading, Icon, - Stepper, - type StepperStepItem, - Text, + ScrollArea, 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 +import { getChainTransactionExplorerLink } from '../adapter/chains' +import { formatHistoryAmount } from '../amount' +import { BridgeProgress } from './BridgeProgress' interface BridgeHistoryDetailModalProps { transaction: BridgeTransaction | null @@ -26,57 +19,44 @@ interface BridgeHistoryDetailModalProps { 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. +// A History entry reuses the same end-to-end progress view as a just-submitted bridge. 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', - }, - ] + const sourceTxHash = transaction.status.srcTxHash ?? transaction.hash + const destinationTxHash = transaction.status.dstTxHash ?? null return ( - - - - - {PROTOCOL_LABELS[transaction.protocol]} Bridge - - - + + + + + + Bridge status + + - - - - {formatTokenAmount(formatUnits(transaction.amount, G_DOLLAR_DECIMALS))} G$ - - - - {explorerLink && ( - - - - )} - + + + ) } diff --git a/packages/bridging-widget/src/components/BridgeProgress.tsx b/packages/bridging-widget/src/components/BridgeProgress.tsx new file mode 100644 index 00000000..4f95d019 --- /dev/null +++ b/packages/bridging-widget/src/components/BridgeProgress.tsx @@ -0,0 +1,169 @@ +import React from 'react' +import { + Anchor, + ChainBadge, + Icon, + Stepper, + type StepperStepItem, + Text, + XStack, + YStack, + getChainDisplayName, +} from '@goodwidget/ui' +import type { BridgeProtocol, TransactionStatus } from '@goodsdks/bridging-sdk' +import type { Hash } from 'viem' +import type { BridgeSubmissionStep } from '../adapter/useBridgeSubmission' +import { DetailRow, PROTOCOL_LABELS, RouteSelectionCard } from './shared' + +interface BridgeProgressProps { + fromChainId: number + targetChainId: number + protocol: BridgeProtocol + amountLabel: string + submissionStep: BridgeSubmissionStep + submissionFailedAtStep: 'approving' | 'bridging' | null + submissionError: string | null + needsApproval: boolean + sourceTxHash: Hash | null + deliveryStatus: TransactionStatus | null + deliveryStatusError: string | null + sourceExplorerLink: string | null + providerExplorerLink: string | null + destinationExplorerLink: string | null +} + +function ExplorerLink({ href, children }: { href: string | null; children: string }) { + if (!href) return null + return ( + + + {children} + + + + ) +} + +export function BridgeProgress({ + fromChainId, + targetChainId, + protocol, + amountLabel, + submissionStep, + submissionFailedAtStep, + submissionError, + needsApproval, + sourceTxHash, + deliveryStatus, + deliveryStatusError, + sourceExplorerLink, + providerExplorerLink, + destinationExplorerLink, +}: BridgeProgressProps) { + const sourceFailed = submissionStep === 'failed' && submissionFailedAtStep === 'bridging' + const approvalFailed = submissionStep === 'failed' && submissionFailedAtStep === 'approving' + const sourceStatus: StepperStepItem['status'] = sourceFailed + ? 'failed' + : submissionStep === 'bridging' + ? 'active' + : submissionStep === 'completed' + ? 'completed' + : 'pending' + const deliveryStepStatus: StepperStepItem['status'] = deliveryStatus?.status === 'completed' + ? 'completed' + : deliveryStatus?.status === 'failed' + ? 'failed' + : submissionStep === 'completed' + ? 'active' + : 'pending' + const steps: StepperStepItem[] = [] + + if (needsApproval) { + steps.push({ + id: 'approval', + title: 'Approve G$', + description: approvalFailed ? submissionError ?? 'Approval failed.' : 'Confirm the token approval in your wallet.', + status: approvalFailed + ? 'failed' + : submissionStep === 'approving' + ? 'active' + : submissionStep === 'bridging' || submissionStep === 'completed' + ? 'completed' + : 'pending', + }) + } + + steps.push( + { + id: 'source', + title: `Submit on ${getChainDisplayName(fromChainId)}`, + description: sourceFailed + ? submissionError ?? 'The source-chain transaction failed.' + : submissionStep === 'bridging' + ? 'Confirm the bridge transaction in your wallet.' + : undefined, + status: sourceStatus, + }, + { + id: 'destination', + title: `Deliver on ${getChainDisplayName(targetChainId)}`, + description: deliveryStatus?.status === 'failed' + ? deliveryStatus.error ?? 'The provider reported a delivery failure.' + : deliveryStatusError ?? (submissionStep === 'completed' + ? `Waiting for ${PROTOCOL_LABELS[protocol]} to deliver the transfer.` + : undefined), + status: deliveryStepStatus, + }, + ) + + const title = deliveryStatus?.status === 'completed' + ? 'Bridge complete' + : deliveryStatus?.status === 'failed' + ? 'Delivery failed' + : sourceFailed || approvalFailed + ? 'Bridge failed' + : submissionStep === 'completed' + ? 'Bridging in progress' + : submissionStep === 'bridging' + ? 'Confirm bridge transaction' + : 'Preparing bridge' + return ( + + + {title} + + + + + + + + G$ sent + {amountLabel} + + + + + {submissionStep === 'completed' && !deliveryStatusError && deliveryStatus?.status === 'pending' && ( + + The source transaction is confirmed. Destination delivery can take several minutes. + + )} + {deliveryStatusError && ( + {deliveryStatusError} + )} + {sourceFailed && submissionError && ( + {submissionError} + )} + + + {`Source transaction${sourceTxHash ? ` ${sourceTxHash.slice(0, 8)}…` : ''}`} + + {`Track on ${PROTOCOL_LABELS[protocol]}`} + + {`Destination transaction${deliveryStatus?.dstTxHash ? ` ${deliveryStatus.dstTxHash.slice(0, 8)}…` : ''}`} + + + + ) +} diff --git a/packages/bridging-widget/src/components/BridgeTab.tsx b/packages/bridging-widget/src/components/BridgeTab.tsx index 99cdb2a1..0ddd46e8 100644 --- a/packages/bridging-widget/src/components/BridgeTab.tsx +++ b/packages/bridging-widget/src/components/BridgeTab.tsx @@ -1,16 +1,28 @@ import React from 'react' -import { BadgeText, ChainBadge, Heading, Text, XStack } from '@goodwidget/ui' +import { ButtonText, Heading, Icon, Text, XStack, YStack, getChainDisplayName } from '@goodwidget/ui' import type { BridgeConfig, BridgeFeeQuote, BridgeProtocol, BridgeRequirement } from '@goodsdks/bridging-sdk' import { getBridgeTargetChains } from '../adapter/bridgeRoutes' +import { BRIDGING_CHAINS } from '../widgetRuntimeContract' import { AmountEntry } from './AmountEntry' import { FeeBreakdown } from './FeeBreakdown' -import { ActionButton, BridgingTabContent, PROTOCOL_LABELS, RouteSelectionCard } from './shared' +import { NetworkSelect } from './NetworkSelect' +import { + ActionButton, + BridgingTabContent, + PROTOCOL_LABELS, + PROTOCOL_SUPPORTED_NETWORKS, + RouteSelectionCard, +} from './shared' interface BridgeTabProps { chainId: number targetChainId: number | null availableProtocols: BridgeProtocol[] selectedProtocol: BridgeProtocol | null + isConnected?: boolean + isWrongChain?: boolean + onConnect?: () => Promise | void + onSelectSourceChain: (chainId: number) => void onSelectTargetChain: (chainId: number) => void onSelectProtocol: (protocol: BridgeProtocol) => void inputAmount: string @@ -33,6 +45,10 @@ export function BridgeTab({ targetChainId, availableProtocols, selectedProtocol, + isConnected = true, + isWrongChain = false, + onConnect, + onSelectSourceChain, onSelectTargetChain, onSelectProtocol, inputAmount, @@ -49,30 +65,47 @@ export function BridgeTab({ onPressMax, onReview, }: BridgeTabProps) { + const sourceOptions = Object.values(BRIDGING_CHAINS) as number[] const targetOptions = getBridgeTargetChains(chainId) const hasTarget = targetChainId !== null const isRouteSupported = availableProtocols.length > 0 + const chainOptions = (chains: number[]) => chains.map((id) => ({ + value: String(id), + label: getChainDisplayName(id), + })) return ( - From - - - To - - {targetOptions.map((option) => ( - onSelectTargetChain(option)} - variant={targetChainId === option ? 'primary' : 'secondary'} - > - {option} - - ))} - + + From network + onSelectSourceChain(Number(value))} + /> + + + To network + onSelectTargetChain(Number(value))} + /> + + {isWrongChain && ( + + Unsupported network. Select a source chain; your wallet will switch when you bridge. + + )} + {/* 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 && ( @@ -93,51 +126,87 @@ export function BridgeTab({ {hasTarget && isRouteSupported && ( - Bridge via - + + Bridge provider + Available for this route + + {availableProtocols.map((protocol) => ( onSelectProtocol(protocol)} variant={selectedProtocol === protocol ? 'primary' : 'secondary'} + width="100%" + height="auto" + minWidth={0} + minHeight="$12" + paddingVertical="$3" + aria-pressed={selectedProtocol === protocol} > - {PROTOCOL_LABELS[protocol]} + + + + {PROTOCOL_LABELS[protocol]} + + + Supports {PROTOCOL_SUPPORTED_NETWORKS[protocol]} + + + {selectedProtocol === protocol && } + ))} - + )} - {hasTarget && isRouteSupported && selectedProtocol && ( + {(hasTarget && isRouteSupported && selectedProtocol) || !isConnected ? ( <> - - {canBridge && ( + {hasTarget && selectedProtocol && ( + + )} + {!isConnected ? ( + + Connect Wallet + + ) : canBridge && quoteStatus === 'ready' ? ( - Review + Review - )} + ) : null} - )} + ) : null} ) } diff --git a/packages/bridging-widget/src/components/BridgingWidgetView.tsx b/packages/bridging-widget/src/components/BridgingWidgetView.tsx index 7f29afd2..2ef092c6 100644 --- a/packages/bridging-widget/src/components/BridgingWidgetView.tsx +++ b/packages/bridging-widget/src/components/BridgingWidgetView.tsx @@ -1,8 +1,10 @@ import React, { useState } from 'react' -import { Badge, BadgeText, Heading, WidgetTabs, XStack, YStack } from '@goodwidget/ui' -import type { - BridgingWidgetAdapterResult, - BridgingWidgetTab, +import { Badge, BadgeText, Heading, WidgetTabs, XStack, YStack, getChainDisplayName } from '@goodwidget/ui' +import { WalletControls } from '@goodwidget/core' +import { + BRIDGING_CHAINS, + type BridgingWidgetAdapterResult, + type BridgingWidgetTab, } from '../widgetRuntimeContract' import { BridgeConfirmModal } from './BridgeConfirmModal' import { BridgeHistoryDetailModal } from './BridgeHistoryDetailModal' @@ -10,7 +12,6 @@ import { BridgeTab } from './BridgeTab' import { FaqTab } from './FaqTab' import { HistoryTab } from './HistoryTab' import { ReviewScreen } from './ReviewScreen' -import { WalletGate } from './WalletGate' interface BridgingWidgetViewProps { adapter: BridgingWidgetAdapterResult @@ -21,21 +22,18 @@ export function BridgingWidgetView({ adapter, initialTab = 'bridge' }: BridgingW const { state, actions } = adapter const [activeTab, setActiveTab] = useState(initialTab) - const tabContent = !state.isConnected || state.isWrongChain ? ( - - ) : ( + const tabContent = ( <> - {activeTab === 'bridge' && state.chainId !== null && ( + {activeTab === 'bridge' && ( ) - // 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 && + // Confirm stays mounted while doBridge() runs, even if the user changes tabs. + // Unmount Review on close so its Sheet cannot cover the submission drawer. + const review = state.isReviewOpen && state.chainId !== null && state.targetChainId !== null && state.selectedProtocol !== null && state.quote !== null && ( - <> - - ) return ( - + GoodDollar Bridge - {state.chainId && ( - - Chain {state.chainId} - + {state.chainId !== null && ( + {getChainDisplayName(state.chainId)} )} + {tabContent} - {reviewAndConfirm} + {review} + - + + + {quote && ( <> - + )} diff --git a/packages/bridging-widget/src/components/HistoryTab.tsx b/packages/bridging-widget/src/components/HistoryTab.tsx index 428cb77b..9d9c5397 100644 --- a/packages/bridging-widget/src/components/HistoryTab.tsx +++ b/packages/bridging-widget/src/components/HistoryTab.tsx @@ -1,5 +1,4 @@ import React from 'react' -import { formatUnits } from 'viem' import { Badge, BadgeText, @@ -14,17 +13,14 @@ import { getChainDisplayName, } from '@goodwidget/ui' import type { BridgeTransaction } from '@goodsdks/bridging-sdk' -import { formatTokenAmount } from '../amount' +import { formatHistoryAmount } 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. */ + /** True when a delivery-status lookup failed and the entry remains pending. */ isPartial: boolean onRefresh: () => void onSelectTransaction: (transaction: BridgeTransaction) => void @@ -54,7 +50,7 @@ function HistoryRow({ transaction, onPress }: { transaction: BridgeTransaction; {PROTOCOL_LABELS[transaction.protocol]} · {new Date(transaction.timestamp * 1000).toLocaleString()} - {formatTokenAmount(formatUnits(transaction.amount, G_DOLLAR_DECIMALS))} G$ + {formatHistoryAmount(transaction.amount)} G$ ) @@ -82,7 +78,7 @@ export function HistoryTab({ transactions, isLoading, error, isPartial, onRefres ) } - if (error) { + if (error && transactions.length === 0) { return ( @@ -121,9 +117,14 @@ export function HistoryTab({ transactions, isLoading, error, isPartial, onRefres - {isPartial && ( + {error && ( + + History could not be refreshed. Showing the last loaded entries; tap refresh to try again. + + )} + {!error && isPartial && ( - Some transactions couldn't be loaded. Tap refresh to try again. + Some delivery statuses could not be checked. Pending entries remain visible; tap refresh to try again. )} diff --git a/packages/bridging-widget/src/components/NetworkSelect.tsx b/packages/bridging-widget/src/components/NetworkSelect.tsx new file mode 100644 index 00000000..187c5475 --- /dev/null +++ b/packages/bridging-widget/src/components/NetworkSelect.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, useEffect, useId, useRef, useState } from 'react' +import { Stack, Text } from '@goodwidget/ui' + +interface NetworkSelectProps { + accessibilityLabel: string + testID: string + options: Array<{ label: string; value: string }> + value?: string + placeholder?: string + onValueChange: (value: string) => void +} + +export function NetworkSelect({ + accessibilityLabel, + testID, + options, + value, + placeholder = 'Select...', + onValueChange, +}: NetworkSelectProps) { + const [open, setOpen] = useState(false) + const listboxId = useId() + const containerRef = useRef(null) + const triggerRef = useRef(null) + const optionRefs = useRef>([]) + const optionsRef = useRef(options) + optionsRef.current = options + const valueRef = useRef(value) + valueRef.current = value + const selected = options.find((option) => option.value === value) + const handleSelect = useCallback((optionValue: string) => { + onValueChange(optionValue) + setOpen(false) + triggerRef.current?.focus() + }, [onValueChange]) + + useEffect(() => { + if (!open || typeof document === 'undefined') return + + optionRefs.current[Math.max(0, optionsRef.current.findIndex((option) => option.value === valueRef.current))]?.focus() + const onPointerDown = (event: PointerEvent) => { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } else if (optionsRef.current.length > 0 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + const index = optionRefs.current.indexOf(document.activeElement as HTMLElement) + if (index < 0) return + event.preventDefault() + const offset = event.key === 'ArrowDown' ? 1 : -1 + optionRefs.current[(index + offset + optionsRef.current.length) % optionsRef.current.length]?.focus() + } + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + return ( + { containerRef.current = node as HTMLElement | null }} position="relative"> + { triggerRef.current = node as HTMLElement | null }} + tag="button" + role="button" + aria-label={accessibilityLabel} + aria-expanded={open} + aria-haspopup="listbox" + aria-controls={open ? listboxId : undefined} + data-testid={testID} + onPress={() => setOpen(!open)} + flexDirection="row" + alignItems="center" + justifyContent="space-between" + backgroundColor="$background" + borderRadius="$2" + borderWidth={1} + borderColor="$borderColor" + paddingHorizontal="$3" + height="$10" + cursor="pointer" + hoverStyle={{ borderColor: '$borderColorHover' }} + focusStyle={{ borderColor: '$borderColorFocus' }} + > + + {selected?.label ?? placeholder} + + + {open ? '▲' : '▼'} + + + + {open && ( + + {options.map((option, index) => ( + { optionRefs.current[index] = node as HTMLElement | null }} + tag="button" + role="option" + aria-selected={option.value === value} + onPress={() => handleSelect(option.value)} + paddingHorizontal="$3" + paddingVertical="$2" + cursor="pointer" + backgroundColor="$background" + hoverStyle={{ backgroundColor: '$backgroundHover' }} + > + + {option.label} + + + ))} + + )} + + ) +} diff --git a/packages/bridging-widget/src/components/ReviewScreen.tsx b/packages/bridging-widget/src/components/ReviewScreen.tsx index 38e6228a..8c927ae4 100644 --- a/packages/bridging-widget/src/components/ReviewScreen.tsx +++ b/packages/bridging-widget/src/components/ReviewScreen.tsx @@ -7,6 +7,7 @@ import { Drawer, Heading, Icon, + ScrollArea, Separator, Text, XStack, @@ -14,10 +15,10 @@ import { getChainDisplayName, } from '@goodwidget/ui' import type { BridgeFeeQuote, BridgeProtocol } from '@goodsdks/bridging-sdk' +import { formatChargedProviderFee, getChainDecimals } from '../adapter/chains' import { formatTokenAmount } from '../amount' import { DetailRow, PROTOCOL_LABELS, RouteSelectionCard } from './shared' -const G_DOLLAR_DECIMALS = 18 const BPS_DENOMINATOR = 10_000 interface ReviewScreenProps { @@ -42,67 +43,77 @@ export function ReviewScreen({ onClose, onConfirm, }: ReviewScreenProps) { - const amountSent = formatTokenAmount(formatUnits(quote.amount, G_DOLLAR_DECIMALS)) - const amountReceived = formatTokenAmount(formatUnits(quote.amountReceived, G_DOLLAR_DECIMALS)) + const amountSent = formatTokenAmount(formatUnits(quote.amount, getChainDecimals(fromChainId))) + const amountReceived = formatTokenAmount( + formatUnits(quote.amountReceived, getChainDecimals(targetChainId)), + ) const protocolFeePercent = (Number(quote.protocolFeeBps) / BPS_DENOMINATOR) * 100 return ( - - - - Review Bridge - - - + + + + + Review Bridge + + - - - - - - + + + + + - - - G$ Sent - - - {amountSent} - - + + + G$ Sent + + + {amountSent} + + - - - - - - - + + + + + + + - + - - - - - + + + + + + ) } diff --git a/packages/bridging-widget/src/components/WalletGate.tsx b/packages/bridging-widget/src/components/WalletGate.tsx deleted file mode 100644 index 4eda9c6d..00000000 --- a/packages/bridging-widget/src/components/WalletGate.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 index 5a778586..5975630c 100644 --- a/packages/bridging-widget/src/components/shared.tsx +++ b/packages/bridging-widget/src/components/shared.tsx @@ -7,8 +7,10 @@ import { XStack, YStack, createComponent, + getChainDisplayName, } from '@goodwidget/ui' import type { BridgeProtocol } from '@goodsdks/bridging-sdk' +import { getProtocolSupportedChains } from '../adapter/bridgeRoutes' // Shared across BridgeTab (protocol picker) and FeeBreakdown (route summary) // so the two screens never label the same protocol differently. @@ -17,6 +19,17 @@ export const PROTOCOL_LABELS: Record = { LAYERZERO: 'LayerZero', } +function supportedNetworksLabel(protocol: BridgeProtocol): string { + const names = getProtocolSupportedChains(protocol).map(getChainDisplayName) + if (names.length < 2) return names[0] ?? '' + return `${names.slice(0, -1).join(', ')}${names.length > 2 ? ',' : ''} and ${names[names.length - 1]}` +} + +export const PROTOCOL_SUPPORTED_NETWORKS: Record = { + AXELAR: supportedNetworksLabel('AXELAR'), + LAYERZERO: supportedNetworksLabel('LAYERZERO'), +} + export const BridgingTabContent = createComponent(YStack, { name: 'BridgingTabContent', flex: 1, @@ -34,6 +47,7 @@ export const EmptyStateCard = createComponent(Card, { export const RouteSelectionCard = createComponent(Card, { name: 'RouteSelectionCard', + backgroundColor: '$backgroundSurface', padding: '$4', gap: '$3', }) @@ -42,11 +56,11 @@ export const RouteSelectionCard = createComponent(Card, { // 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} diff --git a/packages/bridging-widget/src/widgetRuntimeContract.ts b/packages/bridging-widget/src/widgetRuntimeContract.ts index 3b480a07..2e734639 100644 --- a/packages/bridging-widget/src/widgetRuntimeContract.ts +++ b/packages/bridging-widget/src/widgetRuntimeContract.ts @@ -5,6 +5,7 @@ import type { BridgeProtocol, BridgeRequirement, BridgeTransaction, + TransactionStatus, } from '@goodsdks/bridging-sdk' import type { Address, Hash } from 'viem' @@ -35,6 +36,9 @@ export interface BridgingWidgetAdapterState { /** Wallet/connection readiness */ isConnected: boolean address: Address | null + /** Network currently reported by the wallet, which may differ from the selected source. */ + walletChainId: number | null + /** Source chain selected in the widget; independent of the wallet network. */ chainId: number | null /** True when the connected chain is not one of BRIDGING_CHAINS */ isWrongChain: boolean @@ -76,15 +80,21 @@ export interface BridgingWidgetAdapterState { submissionFailedAtStep: 'approving' | 'bridging' | null submissionError: string | null submissionTxHash: Hash | null + submissionSourceChainId: number | null + submissionTargetChainId: number | null + submissionProtocol: BridgeProtocol | null + submissionAmount: bigint | null + submissionDeliveryStatus: TransactionStatus | null + submissionStatusError: string | null + submissionSourceExplorerLink: string | null + submissionProviderExplorerLink: string | null + submissionDestinationExplorerLink: string | null /** Combined source+destination history for the connected address, newest first */ historyTransactions: BridgeTransaction[] isHistoryLoading: boolean historyError: string | null - /** True when one or more history entries could not be resolved (e.g. a - * delivery-status lookup failed) and were dropped rather than blanking - * the whole list; distinct from historyError, which means the whole - * fetch failed. */ + /** True when a delivery-status lookup failed and the entry remains pending. */ isHistoryPartial: boolean /** Transaction tapped in the History tab; its detail modal is open while non-null */ selectedHistoryTransaction: BridgeTransaction | null @@ -98,6 +108,8 @@ export interface BridgingWidgetAdapterState { export interface BridgingWidgetAdapterActions { connect: () => Promise switchChain: (chainId: number) => Promise + /** Choose a source without changing the wallet network. */ + selectSourceChain: (chainId: number) => void /** Select the destination chain on the Bridge tab */ selectTargetChain: (chainId: number) => void /** Select the bridge protocol for the active route */ @@ -112,7 +124,7 @@ export interface BridgingWidgetAdapterActions { closeReview: () => void /** Close the Review drawer and start doBridge() for the current quote */ confirmBridge: () => void - /** Re-run doBridge() for the same quote after a failed submission */ + /** Fetch a fresh quote and retry after a failed submission */ retrySubmission: () => void /** Close the Confirm modal; only meaningful once submissionStep is 'completed' or 'failed' */ closeSubmission: () => void diff --git a/packages/bridging-widget/tests/amount.test.mjs b/packages/bridging-widget/tests/amount.test.mjs new file mode 100644 index 00000000..3e915357 --- /dev/null +++ b/packages/bridging-widget/tests/amount.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { formatHistoryAmount } from '../src/amount.ts' + +test('history amounts use the SDK normalized 18-decimal value for Fuse and other sources', () => { + assert.equal(formatHistoryAmount(120n * 10n ** 18n), '120.00') + assert.equal(formatHistoryAmount(8n * 10n ** 18n), '8.00') + assert.equal(formatHistoryAmount(35n * 10n ** 18n), '35.00') +}) diff --git a/packages/bridging-widget/tests/errors.test.mjs b/packages/bridging-widget/tests/errors.test.mjs new file mode 100644 index 00000000..393099d8 --- /dev/null +++ b/packages/bridging-widget/tests/errors.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mapBridgeError } from '../src/adapter/errors.ts' + +test('read-only contract reverts use the operation fallback, not a transaction message', () => { + const originalError = console.error + console.error = () => {} + + try { + const error = new Error('The contract function "bridgeLimits" reverted: Internal error') + assert.equal(mapBridgeError(error, 'Unable to load balance and limits.'), 'Unable to load balance and limits.') + assert.equal(mapBridgeError(error, 'Failed to fetch bridge quote.'), 'Failed to fetch bridge quote.') + assert.equal( + mapBridgeError(error, 'Unable to prepare bridge. Please try again.'), + 'Unable to prepare bridge. Please try again.', + ) + } finally { + console.error = originalError + } +}) + +test('transaction reverts retain the bridge hint without leaking raw errors', () => { + const originalError = console.error + console.error = () => {} + + try { + const error = new Error('Execution reverted: sensitive RPC details') + assert.equal( + mapBridgeError(error, 'Bridge transaction failed.', 'transaction'), + 'Bridge transaction reverted on-chain. Try a smaller amount.', + ) + } finally { + console.error = originalError + } +}) diff --git a/packages/bridging-widget/tests/historyTransactions.test.mjs b/packages/bridging-widget/tests/historyTransactions.test.mjs new file mode 100644 index 00000000..9c7e4bcb --- /dev/null +++ b/packages/bridging-widget/tests/historyTransactions.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildTransactions, resolvePendingStatuses } from '../src/adapter/historyTransactions.ts' + +const hash = (digit) => `0x${digit.repeat(64)}` +const request = (chainId, digit) => ({ + chainId, + transactionHash: hash(digit), + timestamp: 100, + args: { bridge: 'LAYERZERO', id: 1n, targetChainId: 42220, amount: 100n }, +}) + +test('status failure retains the request as pending and does not erase other chains', async () => { + const sdk = { + async getTransactionStatus(txHash) { + if (txHash === hash('1')) throw new Error('explorer unavailable') + return { status: 'completed', srcTxHash: txHash } + }, + } + const result = await buildTransactions(sdk, [request(122, '1'), request(1, '2')]) + assert.equal(result.transactions.length, 2) + assert.equal(result.isPartial, true) + assert.equal(result.transactions.find((tx) => tx.fromChainId === 122).status.status, 'pending') + assert.equal(result.transactions.find((tx) => tx.fromChainId === 1).status.status, 'completed') +}) + +test('pending polling checks only unresolved entries and retains failures', async () => { + const pending = { hash: hash('3'), protocol: 'LAYERZERO', status: { status: 'pending' } } + const completed = { hash: hash('4'), protocol: 'AXELAR', status: { status: 'completed' } } + const calls = [] + const failing = await resolvePendingStatuses({ + async getTransactionStatus(txHash) { + calls.push(txHash) + throw new Error('temporary failure') + }, + }, [pending, completed]) + assert.deepEqual(calls, [pending.hash]) + assert.equal(failing.isPartial, true) + assert.equal(failing.transactions[0].status.status, 'pending') + assert.equal(failing.transactions[1].status.status, 'completed') + + const settled = await resolvePendingStatuses({ + async getTransactionStatus(txHash) { + return { status: 'completed', srcTxHash: txHash } + }, + }, failing.transactions) + assert.equal(settled.isPartial, false) + assert.equal(settled.transactions[0].status.status, 'completed') +}) + +test('request and executed legs on the same source chain form one completed entry', async () => { + const source = request(122, '5') + const executed = { + chainId: 42220, + transactionHash: hash('6'), + timestamp: 101, + args: { bridge: 'LAYERZERO', id: 1n, sourceChainId: 122, amount: 100n }, + } + const result = await buildTransactions({ + async getTransactionStatus() { + throw new Error('completed transfers need no explorer lookup') + }, + }, [source, executed]) + assert.equal(result.isPartial, false) + assert.equal(result.transactions.length, 1) + assert.equal(result.transactions[0].status.status, 'completed') + assert.equal(result.transactions[0].status.dstTxHash, executed.transactionHash) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d98e5352..f3b0d374 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -366,6 +366,9 @@ importers: specifier: ^2.0.0 version: 2.48.4(typescript@5.9.3)(zod@4.1.11) devDependencies: + '@goodsdks/bridging-sdk': + specifier: 1.0.6 + version: 1.0.6(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) '@storybook/addon-essentials': specifier: ^8.6.17 version: 8.6.18(@types/react@18.3.28)(storybook@8.6.18(prettier@3.8.1)) diff --git a/tests/widgets/bridging-widget/realAdapter.spec.ts b/tests/widgets/bridging-widget/realAdapter.spec.ts new file mode 100644 index 00000000..ecea8ff0 --- /dev/null +++ b/tests/widgets/bridging-widget/realAdapter.spec.ts @@ -0,0 +1,222 @@ +import { test, expect, type Page } from '@playwright/test' + +test.use({ video: 'on' }) +const SCREENSHOT_DIR = process.env.GOODWIDGET_QA_SCREENSHOT_DIR ?? 'tests/widgets/bridging-widget/test-results' + +type FixtureEvent = { kind: string; [key: string]: string | number | boolean } +type FixtureWindow = Window & { + __bridgingWidgetFixture?: { + events: FixtureEvent[] + holdNextSwitch: () => void + releaseSwitch: () => void + holdNextFreshQuote: () => void + releaseFreshQuote: () => void + failNextFreshQuoteWithRevert: () => void + holdBridge: () => void + releaseBridge: () => void + failNextBridgeWithRevert: () => void + setAccount: (address: string | null) => void + setWalletChain: (chainId: number) => void + failNextHistoryRefresh: () => void + markDelivered: () => void + } +} + +async function gotoFixture(page: Page, story: 'submit' | 'history') { + await page.goto(`/iframe.html?id=qa-bridgingwidget-runtime-fixtures--mocked-sdk-${story}&viewMode=story`) + await page.waitForFunction(() => Boolean((window as FixtureWindow).__bridgingWidgetFixture)) + await expect(page.getByTestId('BridgingWidget-mocked-sdk-fixture')).toBeVisible() + await expect(page.getByText(/Mocked wallet and SDK responses/)).toBeVisible() +} + +async function events(page: Page): Promise { + return page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.events ?? []) +} + +async function waitForEvent(page: Page, kind: string, count = 1, timeout = 5_000) { + await expect.poll( + async () => (await events(page)).filter((event) => event.kind === kind).length, + { timeout }, + ).toBe(count) +} + +async function prepareRoute(page: Page) { + await gotoFixture(page, 'submit') + await page.getByTestId('BridgingWidget-source-select').click() + await page.getByRole('option', { name: 'Fuse' }).click() + await page.getByTestId('BridgingWidget-target-select').click() + await page.getByRole('option', { name: 'Celo' }).click() + await page.getByPlaceholder('0.00').fill('100') + await expect(page.getByTestId('BridgingWidget-review-cta')).toBeVisible() + await page.getByTestId('BridgingWidget-review-cta').click() + await expect(page.getByTestId('BridgingWidget-review-screen')).toBeVisible() +} + +test.describe('SDK-mocked adapter submit flow', () => { + test('switches source chain, refreshes the quote, and gives that quote to doBridge', async ({ page }) => { + test.setTimeout(60_000) + await prepareRoute(page) + expect((await events(page)).filter((event) => event.kind === 'switch')).toHaveLength(0) + await page.getByTestId('BridgingWidget-review-confirm').click() + await expect(page.getByTestId('BridgingWidget-confirm-done')).toContainText('Continue in background') + await expect(page.getByTestId('BridgingWidget-bridge-progress')).toContainText('Bridging in progress') + await expect(page.getByRole('link', { name: /Track on LayerZero/ })).toBeVisible() + await expect(page.getByTestId('BridgingWidget-review-screen')).toBeHidden() + await page.getByText('Bridge status').scrollIntoViewIfNeeded() + await expect(page.getByText('Bridge status')).toBeInViewport({ ratio: 1 }) + + const trace = await events(page) + const switchIndex = trace.findIndex((event) => event.kind === 'switch') + const freshQuoteIndex = trace.findIndex((event) => event.kind === 'quote' && event.refresh) + const bridgeIndex = trace.findIndex((event) => event.kind === 'doBridge') + expect(switchIndex).toBeGreaterThanOrEqual(0) + expect(freshQuoteIndex).toBeGreaterThan(switchIndex) + expect(bridgeIndex).toBeGreaterThan(freshQuoteIndex) + expect(trace[bridgeIndex]).toMatchObject({ + receivedFreshQuote: true, + amount: '10000', + targetChainId: 42220, + }) + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/bw-30-mocked-sdk-result.png`, + }) + const video = page.video() + await page.close() + await video?.saveAs(`${SCREENSHOT_DIR}/bw-30-mocked-sdk-flow.webm`) + }) + + test('submission progress updates from pending to delivered with destination explorer link', async ({ page }) => { + test.setTimeout(60_000) + await prepareRoute(page) + await page.getByTestId('BridgingWidget-review-confirm').click() + await expect(page.getByTestId('BridgingWidget-delivery-status')).toContainText('Bridging in progress') + + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.markDelivered()) + await waitForEvent(page, 'history-status', 2, 20_000) + await expect(page.getByTestId('BridgingWidget-delivery-status')).toContainText('Bridge complete') + await expect(page.getByRole('link', { name: /Destination transaction/ })).toHaveAttribute( + 'href', + /celoscan\.io\/tx\/0x4444444444444444/, + ) + await expect(page.getByTestId('BridgingWidget-review-screen')).toBeHidden() + await page.getByText('Bridge status').scrollIntoViewIfNeeded() + await expect(page.getByText('Bridge status')).toBeInViewport({ ratio: 1 }) + await page.screenshot({ + path: `${SCREENSHOT_DIR}/bw-32-delivered-progress.png`, + }) + }) +}) + +test('account change during network switch cancels preparation before a fresh quote', async ({ page }) => { + await prepareRoute(page) + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.holdNextSwitch()) + await page.getByTestId('BridgingWidget-review-confirm').click() + await waitForEvent(page, 'switch') + + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.setAccount(null)) + await expect(page.getByTestId('BridgingWidget-connect-cta')).toBeVisible() + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.releaseSwitch()) + await waitForEvent(page, 'switch-resolved') + await page.waitForTimeout(50) + + const trace = await events(page) + expect(trace.filter((event) => event.kind === 'quote' && event.refresh)).toHaveLength(0) + expect(trace.filter((event) => event.kind === 'doBridge')).toHaveLength(0) +}) + +test('source change during a fresh quote cancels preparation before doBridge', async ({ page }) => { + await prepareRoute(page) + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.holdNextFreshQuote()) + await page.getByTestId('BridgingWidget-review-confirm').click() + await expect.poll(async () => (await events(page)).filter((event) => event.kind === 'quote' && event.refresh).length).toBe(1) + + // The preparation drawer intentionally blocks pointer input to the bridge + // form. Trigger the underlying source control programmatically to model a + // host-driven route update while the fresh quote is still pending. + await page.getByTestId('BridgingWidget-source-select').evaluate((element) => { + (element as HTMLButtonElement).click() + }) + await page.getByRole('option', { name: 'Celo' }).evaluate((element) => { + (element as HTMLButtonElement).click() + }) + await expect(page.getByTestId('BridgingWidget-review-cta')).toHaveCount(0) + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.releaseFreshQuote()) + await expect.poll(async () => (await events(page)).filter((event) => event.kind === 'quote-returned' && event.refresh).length).toBe(1) + await page.waitForTimeout(50) + expect((await events(page)).filter((event) => event.kind === 'doBridge')).toHaveLength(0) +}) + +test('external wallet network change updates an explicitly selected source', async ({ page }) => { + await prepareRoute(page) + await page.getByRole('button', { name: 'Cancel' }).click() + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.setWalletChain(1)) + + await expect(page.getByTestId('BridgingWidget-source-select')).toContainText('Ethereum') + await expect(page.getByTestId('BridgingWidget-target-select')).toContainText('Choose a destination') + expect((await events(page)).filter((event) => event.kind === 'doBridge')).toHaveLength(0) +}) + +test('changing source does not rescan account-wide history', async ({ page }) => { + await gotoFixture(page, 'history') + await waitForEvent(page, 'history-scan') + await page.getByTestId('BridgingWidget-source-select').click() + await page.getByRole('option', { name: 'Fuse' }).click() + await expect(page.getByTestId('BridgingWidget-source-select')).toContainText('Fuse') + await page.waitForTimeout(100) + expect((await events(page)).filter((event) => event.kind === 'history-scan')).toHaveLength(1) +}) + +test('account change after doBridge starts keeps transaction progress visible', async ({ page }) => { + await prepareRoute(page) + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.holdBridge()) + await page.getByTestId('BridgingWidget-review-confirm').click() + await waitForEvent(page, 'doBridge') + + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.setAccount(null)) + await expect(page.getByTestId('BridgingWidget-confirm-modal')).toBeVisible() + await expect(page.getByText('Bridging…')).toBeVisible() + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.releaseBridge()) + await expect(page.getByTestId('BridgingWidget-confirm-done')).toContainText('Continue in background') + expect((await events(page)).filter((event) => event.kind === 'doBridge')).toHaveLength(1) +}) + +test('fresh-quote revert does not claim the bridge transaction reverted', async ({ page }) => { + await prepareRoute(page) + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.failNextFreshQuoteWithRevert()) + await page.getByTestId('BridgingWidget-review-confirm').click() + + await expect(page.getByTestId('BridgingWidget-confirm-modal')).toContainText( + 'Unable to prepare bridge. Please try again.', + ) + expect((await events(page)).filter((event) => event.kind === 'doBridge')).toHaveLength(0) +}) + +test('doBridge revert retains the transaction-specific hint', async ({ page }) => { + await prepareRoute(page) + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.failNextBridgeWithRevert()) + await page.getByTestId('BridgingWidget-review-confirm').click() + + await expect(page.getByTestId('BridgingWidget-confirm-modal')).toContainText( + 'Bridge transaction reverted on-chain. Try a smaller amount.', + ) + expect((await events(page)).filter((event) => event.kind === 'doBridge')).toHaveLength(1) +}) + +test('failed history refresh still lets the pending-status poll complete delivery', async ({ page }) => { + test.setTimeout(45_000) + await page.clock.install() + await gotoFixture(page, 'history') + await page.getByText('History', { exact: true }).first().click() + await expect(page.getByTestId('BridgingWidget-history-row')).toContainText('pending') + + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.failNextHistoryRefresh()) + await page.getByRole('button', { name: 'Refresh history' }).click() + await expect(page.getByText(/History could not be refreshed/)).toBeVisible() + const scansBeforePoll = (await events(page)).filter((event) => event.kind === 'history-scan').length + await page.evaluate(() => (window as FixtureWindow).__bridgingWidgetFixture?.markDelivered()) + await page.clock.fastForward(15_000) + + await expect(page.getByTestId('BridgingWidget-history-row')).toContainText('completed') + expect((await events(page)).filter((event) => event.kind === 'history-scan')).toHaveLength(scansBeforePoll) +}) diff --git a/tests/widgets/bridging-widget/review-evidence/882534a/bw-01-no-wallet.png b/tests/widgets/bridging-widget/review-evidence/882534a/bw-01-no-wallet.png new file mode 100644 index 00000000..4520280f Binary files /dev/null and b/tests/widgets/bridging-widget/review-evidence/882534a/bw-01-no-wallet.png differ diff --git a/tests/widgets/bridging-widget/review-evidence/882534a/bw-12-ready-to-bridge.png b/tests/widgets/bridging-widget/review-evidence/882534a/bw-12-ready-to-bridge.png new file mode 100644 index 00000000..a9728b2d Binary files /dev/null and b/tests/widgets/bridging-widget/review-evidence/882534a/bw-12-ready-to-bridge.png differ diff --git a/tests/widgets/bridging-widget/review-evidence/882534a/bw-13-review-open.png b/tests/widgets/bridging-widget/review-evidence/882534a/bw-13-review-open.png new file mode 100644 index 00000000..6d37246e Binary files /dev/null and b/tests/widgets/bridging-widget/review-evidence/882534a/bw-13-review-open.png differ diff --git a/tests/widgets/bridging-widget/review-evidence/882534a/bw-31-mobile-review-footer.png b/tests/widgets/bridging-widget/review-evidence/882534a/bw-31-mobile-review-footer.png new file mode 100644 index 00000000..388a053d Binary files /dev/null and b/tests/widgets/bridging-widget/review-evidence/882534a/bw-31-mobile-review-footer.png differ diff --git a/tests/widgets/bridging-widget/review-evidence/882534a/bw-32-delivered-progress.png b/tests/widgets/bridging-widget/review-evidence/882534a/bw-32-delivered-progress.png new file mode 100644 index 00000000..daaa13d9 Binary files /dev/null and b/tests/widgets/bridging-widget/review-evidence/882534a/bw-32-delivered-progress.png differ diff --git a/tests/widgets/bridging-widget/review-evidence/882534a/bw-33-mobile-progress-footer.png b/tests/widgets/bridging-widget/review-evidence/882534a/bw-33-mobile-progress-footer.png new file mode 100644 index 00000000..0422a11c Binary files /dev/null and b/tests/widgets/bridging-widget/review-evidence/882534a/bw-33-mobile-progress-footer.png differ diff --git a/tests/widgets/bridging-widget/states.spec.ts b/tests/widgets/bridging-widget/states.spec.ts index 7bde4baf..ecd4642d 100644 --- a/tests/widgets/bridging-widget/states.spec.ts +++ b/tests/widgets/bridging-widget/states.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Page } from '@playwright/test' const STORY_PREFIX = '/iframe.html?id=qa-bridgingwidget-runtime-fixtures--' +const SCREENSHOT_DIR = process.env.GOODWIDGET_QA_SCREENSHOT_DIR ?? 'tests/widgets/bridging-widget/test-results' function storyUrl(storyId: string): string { return `${STORY_PREFIX}${storyId}&viewMode=story` @@ -27,26 +28,49 @@ async function expectBodyToContain(page: Page, patterns: Array) } } -async function saveScreenshot(page: Page, name: string) { +async function saveScreenshot(page: Page, name: string, modalTestId?: string) { + const path = `${SCREENSHOT_DIR}/${name}.png` + if (modalTestId) { + await page.getByTestId(modalTestId).screenshot({ path }) + return + } await page.screenshot({ - path: `tests/widgets/bridging-widget/test-results/${name}.png`, + path, fullPage: true, }) } -test('BridgingWidget shows the disconnected wallet gate', async ({ page }) => { +test('BridgingWidget shows the disconnected wallet state with connect action', async ({ page }) => { await gotoStory(page, 'no-wallet') - await expectBodyToContain(page, ['Wallet not connected', 'Connect Wallet']) + await expectBodyToContain(page, ['GoodDollar Bridge', 'Connect Wallet', 'Balance: — G$']) await saveScreenshot(page, 'bw-01-no-wallet') }) +test('network picker supports listbox navigation and dismissal', async ({ page }) => { + await gotoStory(page, 'no-wallet') + const trigger = page.getByTestId('BridgingWidget-source-select') + await trigger.click() + const listbox = page.getByRole('listbox', { name: 'Source network' }) + await expect(listbox).toBeVisible() + await page.keyboard.press('ArrowDown') + await expect(page.getByRole('option', { name: 'Ethereum' })).toBeFocused() + await page.keyboard.press('Escape') + await expect(listbox).toBeHidden() + await expect(trigger).toBeFocused() + + await trigger.click() + await expect(listbox).toBeVisible() + await page.getByText('GoodDollar Bridge').click() + await expect(listbox).toBeHidden() +}) + test('BridgingWidget shows the unsupported network prompt', async ({ page }) => { await gotoStory(page, 'wrong-chain') await expectBodyToContain(page, [ 'Unsupported network', - 'Switch to a supported chain to use the bridging widget.', + 'your wallet will switch when you bridge.', 'Celo', 'Ethereum', 'Fuse', @@ -58,7 +82,7 @@ test('BridgingWidget shows the unsupported network prompt', async ({ page }) => test('BridgingWidget renders tab navigation and switches views', async ({ page }) => { await gotoStory(page, 'ready-to-bridge') - await expectBodyToContain(page, ['GoodDollar Bridge', 'Bridge', 'History', 'FAQ', 'Chain 122']) + await expectBodyToContain(page, ['GoodDollar Bridge', 'Bridge', 'History', 'FAQ', 'Fuse']) await page.getByText('History', { exact: true }).first().click() await expectBodyToContain(page, ['Fuse', 'Celo', 'Ethereum']) @@ -92,6 +116,7 @@ test('BridgingWidget shows bridge-config loading and error states', async ({ pag test('BridgingWidget shows fee-quote loading and error states', async ({ page }) => { await gotoStory(page, 'quote-loading') await expectBodyToContain(page, ['Fetching quote…']) + await expect(page.getByTestId('BridgingWidget-review-cta')).toHaveCount(0) await saveScreenshot(page, 'bw-07-quote-loading') await gotoStory(page, 'quote-error') @@ -122,7 +147,7 @@ test('BridgingWidget flags a one-time approval requirement while still allowing test('BridgingWidget shows the fee breakdown and Review CTA when ready to bridge', async ({ page }) => { await gotoStory(page, 'ready-to-bridge') - await expectBodyToContain(page, ['Celo via LayerZero', 'Estimated G$ received']) + await expectBodyToContain(page, ['Fuse → Celo', 'Provider', 'LayerZero', 'Estimated provider fee', '0.000132 FUSE']) await expect(page.getByTestId('BridgingWidget-review-cta')).toBeVisible() await saveScreenshot(page, 'bw-12-ready-to-bridge') }) @@ -133,38 +158,74 @@ test('BridgingWidget opens the Review Bridge drawer with route and fee details', await expectBodyToContain(page, [ 'Review Bridge', 'G$ Sent', - 'Celo via LayerZero', + 'Fuse → Celo', + 'Provider', + 'LayerZero', 'Estimated G$ received', + '0.000132 FUSE', ]) await expect(page.getByTestId('BridgingWidget-review-confirm')).toBeVisible() - await saveScreenshot(page, 'bw-13-review-open') + await saveScreenshot(page, 'bw-13-review-open', 'BridgingWidget-review-screen') +}) + +test('BridgingWidget keeps Review confirmation reachable on a short mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 504 }) + await gotoStory(page, 'review-open') + + const scrollArea = page.getByTestId('BridgingWidget-review-screen').locator('..') + const confirm = page.getByTestId('BridgingWidget-review-confirm') + await expect.poll(() => scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + await expect.poll(() => scrollArea.evaluate((element) => element.getBoundingClientRect().top)).toBeLessThan(200) + await page.mouse.move(195, 350) + await page.mouse.wheel(0, 800) + await expect.poll(() => scrollArea.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + await expect(confirm).toBeInViewport() + await page.screenshot({ path: `${SCREENSHOT_DIR}/bw-31-mobile-review-footer.png` }) }) test('BridgingWidget confirm modal tracks the approve/bridge submission steps', async ({ page }) => { await gotoStory(page, 'submission-approving') await expectBodyToContain(page, ['Approving G$…']) - await saveScreenshot(page, 'bw-14-submission-approving') + await saveScreenshot(page, 'bw-14-submission-approving', 'BridgingWidget-confirm-modal') await gotoStory(page, 'submission-bridging') await expectBodyToContain(page, ['Bridging…']) - await saveScreenshot(page, 'bw-15-submission-bridging') + await saveScreenshot(page, 'bw-15-submission-bridging', 'BridgingWidget-confirm-modal') await gotoStory(page, 'submission-completed') - await expectBodyToContain(page, ['Bridge Submitted']) - await expect(page.getByTestId('BridgingWidget-confirm-done')).toBeVisible() + await expectBodyToContain(page, ['Bridge status', 'Bridging in progress', 'Deliver on Celo', 'Track on LayerZero']) + await expect(page.getByTestId('BridgingWidget-confirm-done')).toContainText('Continue in background') + await page.getByText('Bridge status').scrollIntoViewIfNeeded() + await expect(page.getByText('Bridge status')).toBeInViewport({ ratio: 1 }) await saveScreenshot(page, 'bw-16-submission-completed') }) +test('BridgingWidget keeps progress and its action reachable on a short mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 504 }) + await gotoStory(page, 'submission-completed') + + const scrollArea = page.getByTestId('BridgingWidget-confirm-modal').locator('..') + const done = page.getByTestId('BridgingWidget-confirm-done') + await expect(page.getByText('Bridge status')).toBeInViewport({ ratio: 1 }) + await page.screenshot({ path: `${SCREENSHOT_DIR}/bw-34-mobile-progress-top.png` }) + await expect.poll(() => scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + await page.mouse.move(195, 350) + await page.mouse.wheel(0, 800) + await expect.poll(() => scrollArea.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + await expect(done).toBeInViewport({ ratio: 1 }) + await page.screenshot({ path: `${SCREENSHOT_DIR}/bw-33-mobile-progress-footer.png` }) +}) + test('BridgingWidget confirm modal shows retryable failure states per step', async ({ page }) => { await gotoStory(page, 'submission-failed-approving') await expectBodyToContain(page, ['Bridge Failed', 'You rejected the approval request.']) await expect(page.getByTestId('BridgingWidget-confirm-retry')).toBeVisible() - await saveScreenshot(page, 'bw-17-submission-failed-approving') + await saveScreenshot(page, 'bw-17-submission-failed-approving', 'BridgingWidget-confirm-modal') await gotoStory(page, 'submission-failed-bridging') await expectBodyToContain(page, ['Bridge Failed', 'The bridge transaction reverted on-chain.']) await expect(page.getByTestId('BridgingWidget-confirm-retry')).toBeVisible() - await saveScreenshot(page, 'bw-18-submission-failed-bridging') + await saveScreenshot(page, 'bw-18-submission-failed-bridging', 'BridgingWidget-confirm-modal') }) test('BridgingWidget shows history loading, empty and error states', async ({ page }) => { @@ -194,6 +255,8 @@ test('BridgingWidget lists populated bridge history across statuses', async ({ p 'failed', ]) await expect(page.getByTestId('BridgingWidget-history-row')).toHaveCount(3) + await expect(page.getByTestId('BridgingWidget-history-row').first()).toContainText('120.00 G$') + await expect(page.getByTestId('BridgingWidget-history-row').last()).toContainText('8.00 G$') await saveScreenshot(page, 'bw-22-history-populated') }) @@ -201,12 +264,28 @@ test('BridgingWidget opens the history detail modal with delivery progress', asy await gotoStory(page, 'history-detail-modal-open') await expectBodyToContain(page, [ - 'LayerZero Bridge', - 'Sent on Fuse', - 'Delivered on Celo', - 'View on explorer', + 'Bridge status', + '120.00 G$', + 'Submit on Fuse', + 'Deliver on Celo', + 'Track on LayerZero', ]) - await saveScreenshot(page, 'bw-23-history-detail-modal-open') + await saveScreenshot(page, 'bw-23-history-detail-modal-open', 'BridgingWidget-history-detail') +}) + +test('BridgingWidget keeps History progress links reachable on a short mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 504 }) + await gotoStory(page, 'history-detail-modal-open') + + const scrollArea = page.getByTestId('BridgingWidget-history-detail').locator('..') + const providerLink = page.getByRole('link', { name: /Track on LayerZero/ }) + await expect(page.getByText('Bridge status')).toBeInViewport({ ratio: 1 }) + await expect.poll(() => scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + await page.mouse.move(195, 350) + await page.mouse.wheel(0, 800) + await expect.poll(() => scrollArea.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + await expect(providerLink).toBeInViewport({ ratio: 1 }) + await page.screenshot({ path: `${SCREENSHOT_DIR}/bw-35-mobile-history-progress.png` }) }) test('BridgingWidget FAQ tab renders reference content', async ({ page }) => { @@ -219,7 +298,11 @@ test('BridgingWidget FAQ tab renders reference content', async ({ page }) => { test('BridgingWidget renders correctly in the light theme', async ({ page }) => { await gotoStory(page, 'light-theme-populated') - await expectBodyToContain(page, ['GoodDollar Bridge', 'Celo via LayerZero']) + await expectBodyToContain(page, ['GoodDollar Bridge', 'Fuse → Celo']) + const sourceSelect = page.getByTestId('BridgingWidget-source-select') + await expect(sourceSelect).toContainText('Fuse') + const sourceColor = await sourceSelect.evaluate((element) => getComputedStyle(element).color) + expect(sourceColor).not.toBe('rgb(255, 255, 255)') await saveScreenshot(page, 'bw-25-light-theme-populated') }) @@ -229,3 +312,75 @@ test('BridgingWidget renders a usable mobile layout', async ({ page }) => { await expectBodyToContain(page, ['GoodDollar Bridge', 'Bridge', 'History', 'FAQ']) await saveScreenshot(page, 'bw-26-mobile-ready-to-bridge') }) + +test('BridgingWidget correctly formats 2-decimal Fuse amounts as source', async ({ page }) => { + await gotoStory(page, 'fuse-source') + + await expectBodyToContain(page, [ + 'GoodDollar Bridge', + 'Balance: 500.00 G$', + 'Estimated G$ received', + '99.75 G$', + '0.000132 FUSE', + ]) + await expect(page.getByTestId('BridgingWidget-review-cta')).toBeVisible() + await saveScreenshot(page, 'bw-27-fuse-source') +}) + +test('BridgingWidget correctly formats 2-decimal Fuse amounts as destination', async ({ page }) => { + await gotoStory(page, 'fuse-destination') + + await expectBodyToContain(page, [ + 'GoodDollar Bridge', + 'Balance: 500.00 G$', + 'Estimated G$ received', + '99.75 G$', + '0.25% (0.25 G$)', + '0.000132 CELO', + ]) + await expect(page.getByTestId('BridgingWidget-review-cta')).toBeVisible() + await saveScreenshot(page, 'bw-28-fuse-destination') +}) + +test('BridgingWidget completes the interactive route-to-submit flow', async ({ page }) => { + await gotoStory(page, 'interactive-submit') + + await page.getByTestId('BridgingWidget-source-select').click() + await page.getByRole('option', { name: 'Fuse' }).click() + await page.getByTestId('BridgingWidget-target-select').click() + await page.getByRole('option', { name: 'Celo' }).click() + await page.getByPlaceholder('0.00').fill('100') + await expect(page.getByTestId('BridgingWidget-review-cta')).toBeVisible() + await page.getByPlaceholder('0.00').fill('101') + await expect(page.getByTestId('BridgingWidget-review-cta')).toHaveCount(0) + await expect(page.getByTestId('BridgingWidget-review-cta')).toBeVisible() + await page.getByPlaceholder('0.00').fill('100') + await expect(page.getByTestId('BridgingWidget-review-cta')).toBeVisible() + await page.getByTestId('BridgingWidget-review-cta').click() + await expect(page.getByTestId('BridgingWidget-review-screen')).toBeVisible() + await expectBodyToContain(page, ['G$ Sent', '100.00', 'Fuse → Celo', 'Provider']) + await page.getByTestId('BridgingWidget-review-confirm').click() + await expect(page.getByTestId('BridgingWidget-confirm-modal')).toBeVisible() + await expectBodyToContain(page, ['Bridging…']) + await expect(page.getByTestId('BridgingWidget-confirm-done')).toContainText('Continue in background') + await saveScreenshot(page, 'bw-29-interactive-submit', 'BridgingWidget-confirm-modal') +}) + +test('BridgingWidget keeps the chosen provider separate from the route', async ({ page }) => { + await gotoStory(page, 'interactive-submit') + await page.getByTestId('BridgingWidget-target-select').click() + await page.getByRole('option', { name: 'Ethereum' }).click() + + const axelar = page.getByRole('button', { name: /Axelar/ }) + const layerZero = page.getByRole('button', { name: /LayerZero/ }) + await expect(axelar).toBeVisible() + await expect(layerZero).toBeVisible() + await axelar.click() + await expect(axelar).toHaveAttribute('aria-pressed', 'true') + await expect(layerZero).toHaveAttribute('aria-pressed', 'false') + await page.getByPlaceholder('0.00').fill('25') + await expect(page.getByTestId('BridgingWidget-fee-breakdown')).toContainText('Celo → Ethereum') + await expect(page.getByTestId('BridgingWidget-fee-breakdown')).toContainText('Provider') + await expect(page.getByTestId('BridgingWidget-fee-breakdown')).toContainText('Axelar') + await expect(page.getByTestId('BridgingWidget-fee-breakdown')).not.toContainText('via Axelar') +}) diff --git a/tests/widgets/bridging-widget/test-results/bw-01-no-wallet.png b/tests/widgets/bridging-widget/test-results/bw-01-no-wallet.png index cac143e5..35de6a19 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-01-no-wallet.png and b/tests/widgets/bridging-widget/test-results/bw-01-no-wallet.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-02-wrong-chain.png b/tests/widgets/bridging-widget/test-results/bw-02-wrong-chain.png index 80db6161..e6a72e65 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-02-wrong-chain.png and b/tests/widgets/bridging-widget/test-results/bw-02-wrong-chain.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-03-tab-navigation.png b/tests/widgets/bridging-widget/test-results/bw-03-tab-navigation.png index 85edcefb..7bf19799 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-03-tab-navigation.png and b/tests/widgets/bridging-widget/test-results/bw-03-tab-navigation.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-04-route-not-supported.png b/tests/widgets/bridging-widget/test-results/bw-04-route-not-supported.png index cfc2136a..34e8c338 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-04-route-not-supported.png and b/tests/widgets/bridging-widget/test-results/bw-04-route-not-supported.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-05-config-loading.png b/tests/widgets/bridging-widget/test-results/bw-05-config-loading.png index df295e5b..d9013b68 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-05-config-loading.png and b/tests/widgets/bridging-widget/test-results/bw-05-config-loading.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-06-config-error.png b/tests/widgets/bridging-widget/test-results/bw-06-config-error.png index 5af9558b..608af535 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-06-config-error.png and b/tests/widgets/bridging-widget/test-results/bw-06-config-error.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-07-quote-loading.png b/tests/widgets/bridging-widget/test-results/bw-07-quote-loading.png index 69c47efc..13385817 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-07-quote-loading.png and b/tests/widgets/bridging-widget/test-results/bw-07-quote-loading.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-08-quote-error.png b/tests/widgets/bridging-widget/test-results/bw-08-quote-error.png index 70218db1..f97d645f 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-08-quote-error.png and b/tests/widgets/bridging-widget/test-results/bw-08-quote-error.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-09-below-min-amount.png b/tests/widgets/bridging-widget/test-results/bw-09-below-min-amount.png index bdc9d172..bf7ad4b1 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-09-below-min-amount.png and b/tests/widgets/bridging-widget/test-results/bw-09-below-min-amount.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-10-insufficient-balance.png b/tests/widgets/bridging-widget/test-results/bw-10-insufficient-balance.png index 13d6f768..e7d477ab 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-10-insufficient-balance.png and b/tests/widgets/bridging-widget/test-results/bw-10-insufficient-balance.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-11-needs-approval.png b/tests/widgets/bridging-widget/test-results/bw-11-needs-approval.png index 90ab8b81..c3d385d0 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-11-needs-approval.png and b/tests/widgets/bridging-widget/test-results/bw-11-needs-approval.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-12-ready-to-bridge.png b/tests/widgets/bridging-widget/test-results/bw-12-ready-to-bridge.png index b660976f..f642ed15 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-12-ready-to-bridge.png and b/tests/widgets/bridging-widget/test-results/bw-12-ready-to-bridge.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-13-review-open.png b/tests/widgets/bridging-widget/test-results/bw-13-review-open.png index a4a871ca..52580dc7 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-13-review-open.png and b/tests/widgets/bridging-widget/test-results/bw-13-review-open.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-14-submission-approving.png b/tests/widgets/bridging-widget/test-results/bw-14-submission-approving.png index 5c5a8a2e..a933cc2c 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-14-submission-approving.png and b/tests/widgets/bridging-widget/test-results/bw-14-submission-approving.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-15-submission-bridging.png b/tests/widgets/bridging-widget/test-results/bw-15-submission-bridging.png index 2f73f804..3000be72 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-15-submission-bridging.png and b/tests/widgets/bridging-widget/test-results/bw-15-submission-bridging.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-16-submission-completed.png b/tests/widgets/bridging-widget/test-results/bw-16-submission-completed.png index 0f0ac183..7abe6921 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-16-submission-completed.png and b/tests/widgets/bridging-widget/test-results/bw-16-submission-completed.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-17-submission-failed-approving.png b/tests/widgets/bridging-widget/test-results/bw-17-submission-failed-approving.png index 3d3c68cb..f4d9a5f0 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-17-submission-failed-approving.png and b/tests/widgets/bridging-widget/test-results/bw-17-submission-failed-approving.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-18-submission-failed-bridging.png b/tests/widgets/bridging-widget/test-results/bw-18-submission-failed-bridging.png index 17b91ee4..a128c16b 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-18-submission-failed-bridging.png and b/tests/widgets/bridging-widget/test-results/bw-18-submission-failed-bridging.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-19-history-loading.png b/tests/widgets/bridging-widget/test-results/bw-19-history-loading.png index ceaeeb4e..63c8628a 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-19-history-loading.png and b/tests/widgets/bridging-widget/test-results/bw-19-history-loading.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-20-history-empty.png b/tests/widgets/bridging-widget/test-results/bw-20-history-empty.png index 67f7eb86..da0bb700 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-20-history-empty.png and b/tests/widgets/bridging-widget/test-results/bw-20-history-empty.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-21-history-error.png b/tests/widgets/bridging-widget/test-results/bw-21-history-error.png index 987d1ce1..8f8969d6 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-21-history-error.png and b/tests/widgets/bridging-widget/test-results/bw-21-history-error.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-22-history-populated.png b/tests/widgets/bridging-widget/test-results/bw-22-history-populated.png index 7ed3eae1..bdd9f13d 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-22-history-populated.png and b/tests/widgets/bridging-widget/test-results/bw-22-history-populated.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-23-history-detail-modal-open.png b/tests/widgets/bridging-widget/test-results/bw-23-history-detail-modal-open.png index cfa2266c..d4220aa9 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-23-history-detail-modal-open.png and b/tests/widgets/bridging-widget/test-results/bw-23-history-detail-modal-open.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-24-faq-tab.png b/tests/widgets/bridging-widget/test-results/bw-24-faq-tab.png index 85edcefb..7bf19799 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-24-faq-tab.png and b/tests/widgets/bridging-widget/test-results/bw-24-faq-tab.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-25-light-theme-populated.png b/tests/widgets/bridging-widget/test-results/bw-25-light-theme-populated.png index fbd08cff..e71ddbdf 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-25-light-theme-populated.png and b/tests/widgets/bridging-widget/test-results/bw-25-light-theme-populated.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-26-mobile-ready-to-bridge.png b/tests/widgets/bridging-widget/test-results/bw-26-mobile-ready-to-bridge.png index 4b155600..681e7661 100644 Binary files a/tests/widgets/bridging-widget/test-results/bw-26-mobile-ready-to-bridge.png and b/tests/widgets/bridging-widget/test-results/bw-26-mobile-ready-to-bridge.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-27-fuse-source.png b/tests/widgets/bridging-widget/test-results/bw-27-fuse-source.png new file mode 100644 index 00000000..002b9845 Binary files /dev/null and b/tests/widgets/bridging-widget/test-results/bw-27-fuse-source.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-28-fuse-destination.png b/tests/widgets/bridging-widget/test-results/bw-28-fuse-destination.png new file mode 100644 index 00000000..bc1ac095 Binary files /dev/null and b/tests/widgets/bridging-widget/test-results/bw-28-fuse-destination.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-29-interactive-submit.png b/tests/widgets/bridging-widget/test-results/bw-29-interactive-submit.png new file mode 100644 index 00000000..7abe6921 Binary files /dev/null and b/tests/widgets/bridging-widget/test-results/bw-29-interactive-submit.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-30-real-adapter-happy-path.webm b/tests/widgets/bridging-widget/test-results/bw-30-real-adapter-happy-path.webm new file mode 100644 index 00000000..f1fd26fc Binary files /dev/null and b/tests/widgets/bridging-widget/test-results/bw-30-real-adapter-happy-path.webm differ diff --git a/tests/widgets/bridging-widget/test-results/bw-30-real-adapter-result.png b/tests/widgets/bridging-widget/test-results/bw-30-real-adapter-result.png new file mode 100644 index 00000000..5cc1b65f Binary files /dev/null and b/tests/widgets/bridging-widget/test-results/bw-30-real-adapter-result.png differ diff --git a/tests/widgets/bridging-widget/test-results/bw-31-mobile-review-footer.png b/tests/widgets/bridging-widget/test-results/bw-31-mobile-review-footer.png new file mode 100644 index 00000000..49c2a65b Binary files /dev/null and b/tests/widgets/bridging-widget/test-results/bw-31-mobile-review-footer.png differ