From a62e82d4495793b1108ff8a678e44db2d3a83d22 Mon Sep 17 00:00:00 2001 From: Mikhala <122326421+imx-mikhala@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:19:10 +0800 Subject: [PATCH] WT-1935 Bridge Confirmation Screen (#1230) Co-authored-by: Sharon Sheah --- .../src/resources/text/textConfig.ts | 18 ++ .../src/widgets/x-bridge/XBridgeWidget.cy.tsx | 67 +++++- .../src/widgets/x-bridge/XBridgeWidget.tsx | 4 + .../x-bridge/components/BridgeForm.tsx | 99 +++++---- .../components/BridgeReviewSummary.tsx | 210 ++++++++++++++++++ .../components/BridgeReviewSummaryStyles.ts | 48 ++++ .../widgets/x-bridge/views/BridgeReview.tsx | 51 +++++ 7 files changed, 454 insertions(+), 43 deletions(-) create mode 100644 packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummary.tsx create mode 100644 packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummaryStyles.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/x-bridge/views/BridgeReview.tsx diff --git a/packages/checkout/widgets-lib/src/resources/text/textConfig.ts b/packages/checkout/widgets-lib/src/resources/text/textConfig.ts index 95d9436e10..bc0dedeac3 100644 --- a/packages/checkout/widgets-lib/src/resources/text/textConfig.ts +++ b/packages/checkout/widgets-lib/src/resources/text/textConfig.ts @@ -460,6 +460,24 @@ export const text = { text: 'Next', }, }, + [XBridgeWidgetViews.BRIDGE_REVIEW]: { + layoutHeading: 'Move', + heading: 'Ok, how does this look?', + fromLabel: { + amountHeading: 'Moving', + heading: 'From', + }, + toLabel: { + heading: 'To', + }, + fees: { + heading: 'Gas fee', + }, + footer: { + buttonText: 'Confirm move', + }, + fiatPricePrefix: '~ USD $', + }, }, footers: { quickswapFooter: { diff --git a/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.cy.tsx b/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.cy.tsx index bfa1bb9492..eca9ffba68 100644 --- a/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.cy.tsx +++ b/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.cy.tsx @@ -7,6 +7,7 @@ import { import { Environment } from '@imtbl/config'; import { StrongCheckoutWidgetsConfig } from 'lib/withDefaultWidgetConfig'; import { Passport } from '@imtbl/passport'; +import { BigNumber } from 'ethers'; import { XBridgeWidget } from './XBridgeWidget'; import { text } from '../../resources/text/textConfig'; @@ -19,6 +20,8 @@ describe('XBridgeWidget', () => { let checkIsWalletConnectedStub; let connectStub; let switchNetworkStub; + let getNetworkInfoStub; + let getAllBalancesStub; beforeEach(() => { cy.viewport('ipad-2'); @@ -28,11 +31,15 @@ describe('XBridgeWidget', () => { checkIsWalletConnectedStub = cy.stub().as('checkIsWalletConnectedStub'); connectStub = cy.stub().as('connectStub'); switchNetworkStub = cy.stub().as('switchNetworkStub'); + getNetworkInfoStub = cy.stub().as('getNetworkInfoStub'); + getAllBalancesStub = cy.stub().as('getAllBalancesStub'); Checkout.prototype.createProvider = createProviderStub; Checkout.prototype.checkIsWalletConnected = checkIsWalletConnectedStub; Checkout.prototype.connect = connectStub; Checkout.prototype.switchNetwork = switchNetworkStub; + Checkout.prototype.getNetworkInfo = getNetworkInfoStub; + Checkout.prototype.getAllBalances = getAllBalancesStub; getNetworkSepoliaStub = cy.stub().as('getNetworkSepoliaStub').resolves({ chainId: ChainId.SEPOLIA }); @@ -55,7 +62,7 @@ describe('XBridgeWidget', () => { }, getNetwork: getNetworkImmutableZkEVMStub, getSigner: () => ({ - getAddress: () => Promise.resolve('0x1234567890123456789012345678901234567890'), + getAddress: () => Promise.resolve('0x0987654321098765432109876543210987654321'), }), }; }); @@ -493,4 +500,62 @@ describe('XBridgeWidget', () => { cySmartGet('bridge-form').should('be.visible'); }); }); + + describe('Happy path', () => { + it('should complete the full move flow', () => { + createProviderStub + .onFirstCall() + .returns({ provider: mockPassportProvider }) + .onSecondCall() + .returns({ provider: mockMetaMaskProvider }); + checkIsWalletConnectedStub.resolves({ isConnected: false }); + connectStub + .onFirstCall() + .returns({ provider: mockPassportProvider }) + .onSecondCall() + .returns({ provider: mockMetaMaskProvider }); + getNetworkInfoStub.resolves({ chainId: ChainId.IMTBL_ZKEVM_TESTNET }); + getAllBalancesStub.resolves({ + balances: [ + { + balance: BigNumber.from('1000000000000000000'), + formattedBalance: '1.0', + token: { + name: 'IMX', + symbol: 'IMX', + decimals: 18, + }, + }, + ], + }); + + switchNetworkStub.resolves({ + provider: mockMetaMaskProvider, + network: { chainId: ChainId.IMTBL_ZKEVM_TESTNET }, + } as SwitchNetworkResult); + + mount(); + + // Wallet & Network Selector + cySmartGet('wallet-network-selector-from-wallet-select__target').click(); + cySmartGet('wallet-network-selector-from-wallet-list-passport').click(); + cySmartGet('wallet-network-selector-to-wallet-select__target').click(); + cySmartGet('wallet-network-selector-to-wallet-list-metamask').click(); + cySmartGet('wallet-network-selector-submit-button').click(); + + // Bridge form + cySmartGet('bridge-token-select__target').click(); + cySmartGet('bridge-token-coin-selector__option-imx').click(); + cySmartGet('bridge-amount-text__input').type('1'); + cySmartGet('bridge-form-button').click(); + + // Review screen + cySmartGet('bridge-review-summary-from-amount__priceDisplay__price').should('have.text', 'IMX 1'); + cySmartGet('bridge-review-summary-from-amount__priceDisplay__fiatAmount').should('have.text', '~ USD $1.50'); + cySmartGet('bridge-review-summary-gas-amount__priceDisplay__price').should('have.text', 'ETH 0.007984'); + cySmartGet('bridge-review-summary-gas-amount__priceDisplay__fiatAmount').should('have.text', '~ USD $15.00'); + cySmartGet('bridge-review-summary-from-address__label').should('include.text', '0x0987...4321'); + cySmartGet('bridge-review-summary-to-address__label').should('include.text', '0x1234...7890'); + }); + }); }); diff --git a/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.tsx b/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.tsx index 89d5d51201..c4c4696572 100644 --- a/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.tsx +++ b/packages/checkout/widgets-lib/src/widgets/x-bridge/XBridgeWidget.tsx @@ -23,6 +23,7 @@ import { import { widgetTheme } from '../../lib/theme'; import { WalletNetworkSelectionView } from './views/WalletNetworkSelectionView'; import { Bridge } from './views/Bridge'; +import { BridgeReview } from './views/BridgeReview'; export type BridgeWidgetInputs = BridgeWidgetParams & { config: StrongCheckoutWidgetsConfig, @@ -69,6 +70,9 @@ export function XBridgeWidget({ {viewState.view.type === XBridgeWidgetViews.BRIDGE_FORM && ( )} + {viewState.view.type === XBridgeWidgetViews.BRIDGE_REVIEW && ( + + )} diff --git a/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeForm.tsx b/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeForm.tsx index 5742db0571..3c04706660 100644 --- a/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeForm.tsx +++ b/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeForm.tsx @@ -57,6 +57,8 @@ export function BridgeForm(props: BridgeFormProps) { allowedTokens, checkout, web3Provider, + amount, + token, }, } = useContext(XBridgeContext); @@ -76,16 +78,16 @@ export function BridgeForm(props: BridgeFormProps) { } = text.views[BridgeWidgetViews.BRIDGE]; // Form state - const [amount, setAmount] = useState(defaultAmount || ''); + const [formAmount, setFormAmount] = useState(defaultAmount || ''); const [amountError, setAmountError] = useState(''); - const [token, setToken] = useState(); + const [formToken, setFormToken] = useState(); const [tokenError, setTokenError] = useState(''); const [amountFiatValue, setAmountFiatValue] = useState(''); const [editing, setEditing] = useState(false); const [loading, setLoading] = useState(false); const hasSetDefaultState = useRef(false); - const tokenBalanceSubtext = token - ? `${content.availableBalancePrefix} ${tokenValueFormat(token?.formattedBalance)}` + const tokenBalanceSubtext = formToken + ? `${content.availableBalancePrefix} ${tokenValueFormat(formToken?.formattedBalance)}` : ''; // Fee estimates & transactions @@ -135,7 +137,7 @@ export function BridgeForm(props: BridgeFormProps) { if (!hasSetDefaultState.current) { hasSetDefaultState.current = true; if (defaultFromContractAddress) { - setToken( + setFormToken( tokenBalances.find( (b) => (isNativeToken(b.token.address) && defaultFromContractAddress?.toLocaleUpperCase() === NATIVE) || (b.token.address?.toLowerCase() === defaultFromContractAddress?.toLowerCase()), @@ -148,23 +150,36 @@ export function BridgeForm(props: BridgeFormProps) { cryptoFiatState.conversions, defaultFromContractAddress, hasSetDefaultState.current, - setToken, - setTokensOptions, formatTokenOptionsId, formatZeroAmount, ]); + useEffect(() => { + // This useEffect is for populating the form + // with values from context when the user + // has selected the back button from the review screen + if (!amount || !token) return; + setFormAmount(amount); + for (let i = 0; i < tokenBalances.length; i++) { + const balance = tokenBalances[i]; + if (balance.token.address === token.address) { + setFormToken(balance); + break; + } + } + }, [amount, token, tokenBalances]); + const selectedOption = useMemo( - () => (token && token.token - ? formatTokenOptionsId(token.token.symbol, token.token.address) + () => (formToken && formToken.token + ? formatTokenOptionsId(formToken.token.symbol, formToken.token.address) : undefined), - [token, tokenBalances, cryptoFiatState.conversions, formatTokenOptionsId], + [formToken, tokenBalances, cryptoFiatState.conversions, formatTokenOptionsId], ); const canFetchEstimates = (): boolean => { - if (Number.isNaN(parseFloat(amount))) return false; - if (parseFloat(amount) <= 0) return false; - if (!token) return false; + if (Number.isNaN(parseFloat(formAmount))) return false; + if (parseFloat(formAmount) <= 0) return false; + if (!formToken) return false; if (isFetching) return false; return true; }; @@ -230,14 +245,14 @@ export function BridgeForm(props: BridgeFormProps) { return true; } - const tokenIsEth = isNativeToken(token?.token.address); + const tokenIsEth = isNativeToken(formToken?.token.address); const gasAmount = utils.parseEther(gasFee.length !== 0 ? gasFee : '0'); - const additionalAmount = tokenIsEth && !Number.isNaN(parseFloat(amount)) - ? utils.parseEther(amount) + const additionalAmount = tokenIsEth && !Number.isNaN(parseFloat(formAmount)) + ? utils.parseEther(formAmount) : BigNumber.from('0'); return gasAmount.add(additionalAmount).gt(ethBalance.balance); - }, [gasFee, tokenBalances, token, amount]); + }, [gasFee, tokenBalances, formToken, formAmount]); // Silently refresh the quote useInterval(() => fetchEstimates(true), DEFAULT_QUOTE_REFRESH_INTERVAL); @@ -245,39 +260,39 @@ export function BridgeForm(props: BridgeFormProps) { useEffect(() => { if (editing) return; (async () => await fetchEstimates())(); - }, [amount, token, editing]); + }, [formAmount, formToken, editing]); const onTextInputFocus = () => { setEditing(true); }; const handleBridgeAmountChange = (value: string) => { - setAmount(value); + setFormAmount(value); if (amountError) { - const validateAmountError = validateAmount(value, token?.formattedBalance); + const validateAmountError = validateAmount(value, formToken?.formattedBalance); setAmountError(validateAmountError); } - if (!token) return; + if (!formToken) return; setAmountFiatValue(calculateCryptoToFiat( value, - token.token.symbol, + formToken.token.symbol, cryptoFiatState.conversions, )); }; const handleAmountInputBlur = (value: string) => { setEditing(false); - setAmount(value); + setFormAmount(value); if (amountError) { - const validateAmountError = validateAmount(value, token?.formattedBalance); + const validateAmountError = validateAmount(value, formToken?.formattedBalance); setAmountError(validateAmountError); } - if (!token) return; + if (!formToken) return; setAmountFiatValue(calculateCryptoToFiat( value, - token.token.symbol, + formToken.token.symbol, cryptoFiatState.conversions, )); }; @@ -286,7 +301,7 @@ export function BridgeForm(props: BridgeFormProps) { const selected = tokenBalances.find((t) => value === formatTokenOptionsId(t.token.symbol, t.token.address)); if (!selected) return; - setToken(selected); + setFormToken(selected); setTokenError(''); }; @@ -300,15 +315,15 @@ export function BridgeForm(props: BridgeFormProps) { }, [cryptoFiatDispatch, allowedTokens]); useEffect(() => { - if (!amount) return; - if (!token) return; + if (!formAmount) return; + if (!formToken) return; setAmountFiatValue(calculateCryptoToFiat( - amount, - token.token.symbol, + formAmount, + formToken.token.symbol, cryptoFiatState.conversions, )); - }, [amount, token]); + }, [formAmount, formToken]); useEffect(() => { (async () => { @@ -316,7 +331,7 @@ export function BridgeForm(props: BridgeFormProps) { const address = await web3Provider.getSigner().getAddress(); setWalletAddress((previous) => { if (previous !== '' && previous !== address) { - setToken(undefined); + setFormToken(undefined); } return address; }); @@ -324,17 +339,17 @@ export function BridgeForm(props: BridgeFormProps) { }, [web3Provider, tokenBalances]); const bridgeFormValidator = useCallback((): boolean => { - const validateTokenError = validateToken(token); - const validateAmountError = validateAmount(amount, token?.formattedBalance); + const validateTokenError = validateToken(formToken); + const validateAmountError = validateAmount(formAmount, formToken?.formattedBalance); if (validateTokenError) setTokenError(validateTokenError); if (validateAmountError) setAmountError(validateAmountError); if (validateTokenError || validateAmountError) return false; return true; - }, [token, amount, setTokenError, setAmountError]); + }, [formToken, formAmount, setTokenError, setAmountError]); const submitBridge = useCallback(async () => { if (!bridgeFormValidator()) return; - if (!checkout || !web3Provider || !token) return; + if (!checkout || !web3Provider || !formToken) return; if (insufficientFundsForGas) { setShowNotEnoughGasDrawer(true); @@ -344,8 +359,8 @@ export function BridgeForm(props: BridgeFormProps) { bridgeDispatch({ payload: { type: BridgeActions.SET_TOKEN_AND_AMOUNT, - token: token.token, - amount, + token: formToken.token, + amount: formAmount, }, }); @@ -362,7 +377,7 @@ export function BridgeForm(props: BridgeFormProps) { web3Provider, bridgeFormValidator, insufficientFundsForGas, - token]); + formToken]); const retrySubmitBridge = async () => { setShowTxnRejectedState(false); @@ -401,7 +416,7 @@ export function BridgeForm(props: BridgeFormProps) { /> setShowNotEnoughGasDrawer(false)} walletAddress={walletAddress} - showAdjustAmount={isNativeToken(token?.token.address)} + showAdjustAmount={isNativeToken(formToken?.token.address)} /> diff --git a/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummary.tsx b/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummary.tsx new file mode 100644 index 0000000000..a990964f9a --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummary.tsx @@ -0,0 +1,210 @@ +import { text } from 'resources/text/textConfig'; +import { useContext, useMemo } from 'react'; +import { XBridgeWidgetViews } from 'context/view-context/XBridgeViewContextTypes'; +import { + Body, + Box, Heading, Icon, MenuItem, +} from '@biom3/react'; +import { ChainId, WalletProviderName } from '@imtbl/checkout-sdk'; +import { abbreviateAddress } from 'lib/addressUtils'; +import { CryptoFiatContext } from 'context/crypto-fiat-context/CryptoFiatContext'; +import { isPassportProvider } from 'lib/providerUtils'; +import { calculateCryptoToFiat } from 'lib/utils'; +import { Web3Provider } from '@ethersproject/providers'; +import { DEFAULT_QUOTE_REFRESH_INTERVAL } from 'lib'; +import { useInterval } from 'lib/hooks/useInterval'; +import { networkIconStyles } from './WalletNetworkButtonStyles'; +import { + arrowIconStyles, + arrowIconWrapperStyles, + bottomMenuItemStyles, + bridgeReviewHeadingStyles, + bridgeReviewWrapperStyles, + gasAmountHeadingStyles, + topMenuItemStyles, + walletLogoStyles, +} from './BridgeReviewSummaryStyles'; +import { XBridgeContext } from '../context/XBridgeContext'; + +const networkIcon = { + [ChainId.IMTBL_ZKEVM_DEVNET]: 'Immutable', + [ChainId.IMTBL_ZKEVM_MAINNET]: 'Immutable', + [ChainId.IMTBL_ZKEVM_TESTNET]: 'Immutable', + [ChainId.ETHEREUM]: 'EthToken', + [ChainId.SEPOLIA]: 'EthToken', +}; + +const logo = { + [WalletProviderName.PASSPORT]: 'PassportSymbolOutlined', + [WalletProviderName.METAMASK]: 'MetaMaskSymbol', +}; + +const testId = 'bridge-review-summary'; + +export function BridgeReviewSummary() { + const { + heading, fromLabel, toLabel, fees, fiatPricePrefix, + } = text.views[XBridgeWidgetViews.BRIDGE_REVIEW]; + + const { + bridgeState: { + from, + to, + token, + amount, + }, + } = useContext(XBridgeContext); + + const { cryptoFiatState } = useContext(CryptoFiatContext); + + const walletProviderName = (provider: Web3Provider | undefined) => (isPassportProvider(provider) + ? WalletProviderName.PASSPORT + : WalletProviderName.METAMASK); + + const fromAmount = useMemo(() => (token?.symbol ? `${token?.symbol} ${amount}` : `${amount}`), [token, amount]); + const fromFiatAmount = useMemo(() => { + if (!amount || !token) return ''; + return calculateCryptoToFiat(amount, token.symbol, cryptoFiatState.conversions); + }, [token, amount]); + const fromAddress = useMemo(() => { + if (!from) return '-'; + return from.walletAddress; + }, [from]); + + const fromWalletProviderName = useMemo(() => walletProviderName(from?.web3Provider), [from]); + const fromNetwork = useMemo(() => from && from.network, [from]); + + const toAddress = useMemo(() => { + if (!to) return '-'; + return to.walletAddress; + }, [to]); + const toWalletProviderName = useMemo(() => walletProviderName(to?.web3Provider), [to]); + const toNetwork = useMemo(() => to?.network, [to]); + + const fetchGasEstimate = () => { + // eslint-disable-next-line no-console + console.log('fetch gas estimate'); + }; + useInterval(() => fetchGasEstimate(), DEFAULT_QUOTE_REFRESH_INTERVAL); + + // Fetch on useInterval interval when available + const gasEstimate = 'ETH 0.007984'; + const gasFiatEstimate = '15.00'; + + return ( + + + {heading} + + + {/* From review */} + + + {fromLabel.amountHeading} + + + } + price={fromAmount ?? '-'} + fiatAmount={`${fiatPricePrefix}${fromFiatAmount}`} + /> + + + {fromWalletProviderName && ( + + )} + + {fromLabel.heading} + {' '} + + {abbreviateAddress(fromAddress ?? '')} + + + {fromNetwork && ( + + )} + + + + + + + {/* To review */} + + {toWalletProviderName && ( + + )} + + {toLabel.heading} + {' '} + + {abbreviateAddress(toAddress ?? '')} + + + {toNetwork && ( + + )} + + + + {fees.heading} + + } + price={gasEstimate ?? '-'} + fiatAmount={`${fiatPricePrefix}${gasFiatEstimate}`} + /> + + + ); +} diff --git a/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummaryStyles.ts b/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummaryStyles.ts new file mode 100644 index 0000000000..836f61e004 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/x-bridge/components/BridgeReviewSummaryStyles.ts @@ -0,0 +1,48 @@ +import { WalletProviderName } from '@imtbl/checkout-sdk'; + +export const topMenuItemStyles = { + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px', + marginBottom: '2px', +}; + +export const bottomMenuItemStyles = { + borderTopLeftRadius: '0px', + borderTopRightRadius: '0px', +}; + +export const bridgeReviewWrapperStyles = { + height: '100%', + display: 'flex', + flexDirection: 'column', + paddingX: 'base.spacing.x4', +}; + +export const bridgeReviewHeadingStyles = { + paddingTop: 'base.spacing.x10', + paddingBottom: 'base.spacing.x4', +}; + +export const arrowIconWrapperStyles = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + paddingY: 'base.spacing.x1', +}; + +export const arrowIconStyles = { + width: 'base.icon.size.300', + transform: 'rotate(270deg)', +}; + +export const walletLogoStyles = (walletName: WalletProviderName) => ({ + minWidth: 'base.icon.size.400', + padding: walletName === WalletProviderName.PASSPORT ? 'base.spacing.x1' : '', + backgroundColor: 'base.color.translucent.standard.200', + borderRadius: 'base.borderRadius.x2', +}); + +export const gasAmountHeadingStyles = { + marginBottom: 'base.spacing.x4', + color: 'base.color.text.secondary', +}; diff --git a/packages/checkout/widgets-lib/src/widgets/x-bridge/views/BridgeReview.tsx b/packages/checkout/widgets-lib/src/widgets/x-bridge/views/BridgeReview.tsx new file mode 100644 index 0000000000..8b48269d7b --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/x-bridge/views/BridgeReview.tsx @@ -0,0 +1,51 @@ +import { HeaderNavigation } from 'components/Header/HeaderNavigation'; +import { SimpleLayout } from 'components/SimpleLayout/SimpleLayout'; +import { FooterLogo } from 'components/Footer/FooterLogo'; +import { useContext } from 'react'; +import { EventTargetContext } from 'context/event-target-context/EventTargetContext'; +import { text } from 'resources/text/textConfig'; +import { XBridgeWidgetViews } from 'context/view-context/XBridgeViewContextTypes'; +import { + Box, Button, +} from '@biom3/react'; +import { sendBridgeWidgetCloseEvent } from '../BridgeWidgetEvents'; + +import { BridgeReviewSummary } from '../components/BridgeReviewSummary'; + +export function BridgeReview() { + const { eventTargetState: { eventTarget } } = useContext(EventTargetContext); + + const { layoutHeading, footer } = text.views[XBridgeWidgetViews.BRIDGE_REVIEW]; + + return ( + sendBridgeWidgetCloseEvent(eventTarget)} + /> + )} + footer={( + + + + + )} + > + + + ); +}