Skip to content

Commit

Permalink
WT-1935 Bridge Confirmation Screen (#1230)
Browse files Browse the repository at this point in the history
Co-authored-by: Sharon Sheah <[email protected]>
  • Loading branch information
imx-mikhala and sharonsheah authored Dec 4, 2023
1 parent 47366ee commit a62e82d
Show file tree
Hide file tree
Showing 7 changed files with 454 additions and 43 deletions.
18 changes: 18 additions & 0 deletions packages/checkout/widgets-lib/src/resources/text/textConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -19,6 +20,8 @@ describe('XBridgeWidget', () => {
let checkIsWalletConnectedStub;
let connectStub;
let switchNetworkStub;
let getNetworkInfoStub;
let getAllBalancesStub;

beforeEach(() => {
cy.viewport('ipad-2');
Expand All @@ -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 });

Expand All @@ -55,7 +62,7 @@ describe('XBridgeWidget', () => {
},
getNetwork: getNetworkImmutableZkEVMStub,
getSigner: () => ({
getAddress: () => Promise.resolve('0x1234567890123456789012345678901234567890'),
getAddress: () => Promise.resolve('0x0987654321098765432109876543210987654321'),
}),
};
});
Expand Down Expand Up @@ -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(<XBridgeWidget checkout={checkout} config={widgetConfig} />);

// 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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +70,9 @@ export function XBridgeWidget({
{viewState.view.type === XBridgeWidgetViews.BRIDGE_FORM && (
<Bridge />
)}
{viewState.view.type === XBridgeWidgetViews.BRIDGE_REVIEW && (
<BridgeReview />
)}
</CryptoFiatProvider>
</XBridgeContext.Provider>
</ViewContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export function BridgeForm(props: BridgeFormProps) {
allowedTokens,
checkout,
web3Provider,
amount,
token,
},
} = useContext(XBridgeContext);

Expand All @@ -76,16 +78,16 @@ export function BridgeForm(props: BridgeFormProps) {
} = text.views[BridgeWidgetViews.BRIDGE];

// Form state
const [amount, setAmount] = useState<string>(defaultAmount || '');
const [formAmount, setFormAmount] = useState<string>(defaultAmount || '');
const [amountError, setAmountError] = useState<string>('');
const [token, setToken] = useState<GetBalanceResult | undefined>();
const [formToken, setFormToken] = useState<GetBalanceResult | undefined>();
const [tokenError, setTokenError] = useState<string>('');
const [amountFiatValue, setAmountFiatValue] = useState<string>('');
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
Expand Down Expand Up @@ -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()),
Expand All @@ -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;
};
Expand Down Expand Up @@ -230,54 +245,54 @@ 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);

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,
));
};
Expand All @@ -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('');
};

Expand All @@ -300,41 +315,41 @@ 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 () => {
if (!web3Provider) return;
const address = await web3Provider.getSigner().getAddress();
setWalletAddress((previous) => {
if (previous !== '' && previous !== address) {
setToken(undefined);
setFormToken(undefined);
}
return address;
});
})();
}, [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);
Expand All @@ -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,
},
});

Expand All @@ -362,7 +377,7 @@ export function BridgeForm(props: BridgeFormProps) {
web3Provider,
bridgeFormValidator,
insufficientFundsForGas,
token]);
formToken]);

const retrySubmitBridge = async () => {
setShowTxnRejectedState(false);
Expand Down Expand Up @@ -401,7 +416,7 @@ export function BridgeForm(props: BridgeFormProps) {
/>
<TextInputForm
testId="bridge-amount"
value={amount}
value={formAmount}
placeholder={bridgeForm.from.inputPlaceholder}
subtext={`${content.fiatPricePrefix} $${formatZeroAmount(amountFiatValue, true)}`}
validator={amountInputValidation}
Expand Down Expand Up @@ -451,7 +466,7 @@ export function BridgeForm(props: BridgeFormProps) {
showHeaderBar={false}
onCloseBottomSheet={() => setShowNotEnoughGasDrawer(false)}
walletAddress={walletAddress}
showAdjustAmount={isNativeToken(token?.token.address)}
showAdjustAmount={isNativeToken(formToken?.token.address)}
/>
</Box>
</Box>
Expand Down
Loading

0 comments on commit a62e82d

Please sign in to comment.