From 4b639d7a5315c20a6766fb2b59d0ce5d3b973453 Mon Sep 17 00:00:00 2001 From: Francisco Ramos <jscriptcoder@gmail.com> Date: Thu, 20 Jul 2023 08:22:05 +0200 Subject: [PATCH] feat(bridge-ui-v2): amount input validation (#14213) --- packages/bridge-ui-v2/src/app.config.ts | 5 + .../Bridge/AmountInput/AmountInput.svelte | 136 +++++++++++++++++- .../Bridge/AmountInput/Balance.svelte | 11 +- .../Bridge/ProcessingFee/NoneOption.svelte | 2 +- .../Bridge/ProcessingFee/ProcessingFee.svelte | 20 +-- .../src/components/Bridge/state.ts | 7 +- .../ChainSelector/ChainSelector.svelte | 6 +- .../src/components/Faucet/Faucet.svelte | 33 ++--- .../src/components/InputBox/InputBox.svelte | 14 +- packages/bridge-ui-v2/src/i18n/en.json | 6 +- .../src/libs/bridge/ERC1155Bridge.ts | 7 + .../src/libs/bridge/ERC20Bridge.ts | 67 +++++++++ .../src/libs/bridge/ERC721Bridge.ts | 7 + .../bridge-ui-v2/src/libs/bridge/ETHBridge.ts | 69 +++++++++ .../bridge-ui-v2/src/libs/bridge/bridges.ts | 12 ++ .../src/libs/bridge/estimateCostOfBridging.ts | 12 ++ .../src/libs/bridge/getMaxToBridge.ts | 60 ++++++++ .../bridge-ui-v2/src/libs/bridge/index.ts | 3 + .../bridge-ui-v2/src/libs/bridge/types.ts | 97 +++++++++++++ .../bridge-ui-v2/src/libs/chain/chains.ts | 3 +- .../src/libs/token/checkMintable.test.ts | 21 +-- .../src/libs/token/checkMintable.ts | 25 ++-- .../src/libs/token/getBalance.test.ts | 1 - .../bridge-ui-v2/src/libs/token/getBalance.ts | 6 +- .../bridge-ui-v2/src/libs/token/mint.test.ts | 2 +- packages/bridge-ui-v2/src/libs/token/mint.ts | 7 +- packages/bridge-ui-v2/src/libs/token/types.ts | 3 +- .../bridge-ui-v2/src/libs/util/debounce.ts | 14 ++ .../bridge-ui-v2/src/libs/util/getWallet.ts | 11 ++ .../src/libs/util/truncateDecimal.ts | 4 + .../bridge-ui-v2/src/styles/components.css | 6 +- 31 files changed, 584 insertions(+), 93 deletions(-) create mode 100644 packages/bridge-ui-v2/src/libs/bridge/ERC1155Bridge.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/ERC20Bridge.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/ERC721Bridge.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/ETHBridge.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/bridges.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/estimateCostOfBridging.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/getMaxToBridge.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/index.ts create mode 100644 packages/bridge-ui-v2/src/libs/bridge/types.ts create mode 100644 packages/bridge-ui-v2/src/libs/util/debounce.ts create mode 100644 packages/bridge-ui-v2/src/libs/util/getWallet.ts create mode 100644 packages/bridge-ui-v2/src/libs/util/truncateDecimal.ts diff --git a/packages/bridge-ui-v2/src/app.config.ts b/packages/bridge-ui-v2/src/app.config.ts index 5584560c844..0c49606a95c 100644 --- a/packages/bridge-ui-v2/src/app.config.ts +++ b/packages/bridge-ui-v2/src/app.config.ts @@ -8,3 +8,8 @@ export const processingFeeComponent = { closingDelayOptionClick: 300, intervalComputeRecommendedFee: 20000, }; + +export const bridge = { + noOwnerGasLimit: BigInt(140000), + noTokenDeployedGasLimit: BigInt(3000000), +}; diff --git a/packages/bridge-ui-v2/src/components/Bridge/AmountInput/AmountInput.svelte b/packages/bridge-ui-v2/src/components/Bridge/AmountInput/AmountInput.svelte index d2cd50a02cc..02e08753648 100644 --- a/packages/bridge-ui-v2/src/components/Bridge/AmountInput/AmountInput.svelte +++ b/packages/bridge-ui-v2/src/components/Bridge/AmountInput/AmountInput.svelte @@ -1,26 +1,158 @@ <script lang="ts"> + import type { FetchBalanceResult } from '@wagmi/core'; import { t } from 'svelte-i18n'; + import { formatEther, parseUnits } from 'viem'; + import Icon from '$components/Icon/Icon.svelte'; import { InputBox } from '$components/InputBox'; + import { warningToast } from '$components/NotificationToast'; + import { getMaxToBridge } from '$libs/bridge/getMaxToBridge'; + import { debounce } from '$libs/util/debounce'; import { uid } from '$libs/util/uid'; + import { account } from '$stores/account'; + import { network } from '$stores/network'; + import { destNetwork, enteredAmount, processingFee, selectedToken } from '../state'; import Balance from './Balance.svelte'; let inputId = `input-${uid()}`; + let tokenBalance: FetchBalanceResult; + let inputBox: InputBox; + + let computingMaxAmount = false; + let errorAmount = false; + + // Let's get the max amount to bridge and see if it's less + // than what the user has entered. For ETH, will actually get an error + // when trying to get that max amount, if the user has entered too much ETH + async function checkEnteredAmount() { + if ( + !$selectedToken || + !$network || + !$account?.address || + $enteredAmount === BigInt(0) // why to even bother, right? + ) { + errorAmount = false; + return; + } + + try { + const maxAmount = await getMaxToBridge({ + token: $selectedToken, + balance: tokenBalance.value, + processingFee: $processingFee, + srcChainId: $network.id, + destChainId: $destNetwork?.id, + userAddress: $account.address, + amount: $enteredAmount, + }); + + if ($enteredAmount > maxAmount) { + errorAmount = true; + } + } catch (err) { + console.error(err); + + // Viem will throw an error that contains the following message, indicating + // that the user won't have enough to pay the transaction + // TODO: better way to handle this. Error codes? + if (`${err}`.toLocaleLowerCase().match('transaction exceeds the balance')) { + errorAmount = true; + } + } + } + + // We want to debounce this function for input events + const debouncedCheckEnteredAmount = debounce(checkEnteredAmount, 300); + + // Will trigger on input events. We update the entered amount + // and check it's validity + function updateAmount(event: Event) { + errorAmount = false; + + if (!$selectedToken) return; + + const target = event.target as HTMLInputElement; + + try { + $enteredAmount = parseUnits(target.value, $selectedToken?.decimals); + + debouncedCheckEnteredAmount(); + } catch (err) { + $enteredAmount = BigInt(0); + } + } + + function setETHAmount(amount: bigint) { + inputBox.setValue(formatEther(amount)); + $enteredAmount = amount; + } + + // Will trigger when the user clicks on the "Max" button + async function useMaxAmount() { + errorAmount = false; + + if (!$selectedToken || !$network || !$account?.address) return; + + computingMaxAmount = true; + + try { + const maxAmount = await getMaxToBridge({ + token: $selectedToken, + balance: tokenBalance.value, + processingFee: $processingFee, + srcChainId: $network.id, + destChainId: $destNetwork?.id, + userAddress: $account.address, + }); + + setETHAmount(maxAmount); + } catch (err) { + console.error(err); + warningToast($t('amount_input.button.failed_max')); + } finally { + computingMaxAmount = false; + } + } + + // Let's also trigger the check when either the processingFee or + // the selectedToken change and debounce it, just in case + // TODO: better way? maybe store.subscribe(), or different component + $: $processingFee && $selectedToken && debouncedCheckEnteredAmount(); </script> <div class="AmountInput f-col space-y-2"> <div class="f-between-center text-secondary-content"> <label class="body-regular" for={inputId}>{$t('amount_input.label')}</label> - <Balance /> + <Balance bind:value={tokenBalance} /> </div> + <div class="relative f-items-center"> <InputBox id={inputId} type="number" placeholder="0.01" min="0" + loading={computingMaxAmount} + error={errorAmount} + on:input={updateAmount} + bind:this={inputBox} class="w-full input-box outline-none py-6 pr-16 px-[26px] title-subsection-bold placeholder:text-tertiary-content" /> - <button class="absolute right-6 uppercase">{$t('amount_input.button.max')}</button> + <button + class="absolute right-6 uppercase" + disabled={!$selectedToken || !$network || computingMaxAmount} + on:click={useMaxAmount}> + {$t('amount_input.button.max')} + </button> </div> + + {#if errorAmount} + <!-- TODO: should we make another component for flat error messages? --> + <div class="f-items-center space-x-1 mt-3"> + <Icon type="exclamation-circle" fillClass="fill-negative-sentiment" /> + <div class="body-small-regular text-negative-sentiment"> + {$t('amount_input.error.insufficient_balance')} + </div> + </div> + {/if} </div> diff --git a/packages/bridge-ui-v2/src/components/Bridge/AmountInput/Balance.svelte b/packages/bridge-ui-v2/src/components/Bridge/AmountInput/Balance.svelte index 18000188fa6..a82bc0d8fa2 100644 --- a/packages/bridge-ui-v2/src/components/Bridge/AmountInput/Balance.svelte +++ b/packages/bridge-ui-v2/src/components/Bridge/AmountInput/Balance.svelte @@ -10,7 +10,8 @@ import { destNetwork, selectedToken } from '../state'; - let tokenBalance: Maybe<FetchBalanceResult>; + export let value: Maybe<FetchBalanceResult>; + let computingTokenBalance = false; let errorComputingTokenBalance = false; @@ -21,14 +22,14 @@ errorComputingTokenBalance = false; try { - tokenBalance = await getTokenBalance({ + value = await getTokenBalance({ token, destChainId, userAddress: account.address, chainId: srcChainId, }); - } catch (error) { - console.error(error); + } catch (err) { + console.error(err); errorComputingTokenBalance = true; } finally { computingTokenBalance = false; @@ -50,7 +51,7 @@ <LoadingText mask="0.0000" /> <LoadingText mask="XXX" /> {:else} - {renderTokenBalance(tokenBalance)} + {renderTokenBalance(value)} {/if} </span> </div> diff --git a/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/NoneOption.svelte b/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/NoneOption.svelte index fb5dbd614f8..99af71c167a 100644 --- a/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/NoneOption.svelte +++ b/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/NoneOption.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import type { Address } from 'abitype'; + import type { Address } from 'viem'; import { recommendProcessingFee } from '$libs/fee'; import { getBalance, type Token } from '$libs/token'; diff --git a/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/ProcessingFee.svelte b/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/ProcessingFee.svelte index 18b8fcb0994..47dc3807f93 100644 --- a/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/ProcessingFee.svelte +++ b/packages/bridge-ui-v2/src/components/Bridge/ProcessingFee/ProcessingFee.svelte @@ -29,7 +29,7 @@ let errorCalculatingEnoughEth = false; let modalOpen = false; - let customInput: InputBox; + let inputBox: InputBox; function closeModal() { // Let's check if we are closing with CUSTOM method selected and zero amount entered @@ -51,11 +51,11 @@ setTimeout(closeModal, processingFeeComponent.closingDelayOptionClick); } - function focusCustomInput() { - customInput?.focus(); + function focusInputBox() { + inputBox.focus(); } - function onCustomInputChange(event: Event) { + function onInputBoxChange(event: Event) { if (selectedFeeMethod !== ProcessingFeeMethod.CUSTOM) return; const input = event.target as HTMLInputElement; @@ -66,20 +66,20 @@ switch (method) { case ProcessingFeeMethod.RECOMMENDED: $processingFee = recommendedAmount; - customInput?.clear(); + inputBox?.clear(); break; case ProcessingFeeMethod.CUSTOM: // Get a previous value entered if exists, otherwise default to 0 - $processingFee = parseToWei(customInput?.value()); + $processingFee = parseToWei(inputBox?.getValue()); // We need to wait for Svelte to set the attribute `disabled` on the input // to false to be able to focus it - tick().then(focusCustomInput); + tick().then(focusInputBox); break; case ProcessingFeeMethod.NONE: $processingFee = BigInt(0); - customInput?.clear(); + inputBox?.clear(); break; } @@ -214,8 +214,8 @@ placeholder="0.01" disabled={selectedFeeMethod !== ProcessingFeeMethod.CUSTOM} class="w-full input-box outline-none p-6 pr-16 title-subsection-bold placeholder:text-tertiary-content" - on:input={onCustomInputChange} - bind:this={customInput} /> + on:input={onInputBoxChange} + bind:this={inputBox} /> <span class="absolute right-6 uppercase body-bold text-secondary-content">ETH</span> </div> </div> diff --git a/packages/bridge-ui-v2/src/components/Bridge/state.ts b/packages/bridge-ui-v2/src/components/Bridge/state.ts index 834e1045738..63435098002 100644 --- a/packages/bridge-ui-v2/src/components/Bridge/state.ts +++ b/packages/bridge-ui-v2/src/components/Bridge/state.ts @@ -12,6 +12,7 @@ import type { Token } from '$libs/token'; // but once again, we don't need such level of security that we have to // prevent other components outside the Bridge from accessing this store. -export const selectedToken = writable<Maybe<Token>>(); -export const destNetwork = writable<Maybe<Chain>>(); -export const processingFee = writable<bigint>(); +export const selectedToken = writable<Maybe<Token>>(null); +export const enteredAmount = writable<bigint>(BigInt(0)); +export const destNetwork = writable<Maybe<Chain>>(null); +export const processingFee = writable<bigint>(BigInt(0)); diff --git a/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte b/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte index a95f2fcb20f..ae34f6184bd 100644 --- a/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte +++ b/packages/bridge-ui-v2/src/components/ChainSelector/ChainSelector.svelte @@ -56,10 +56,10 @@ try { await switchNetwork({ chainId: chain.id }); closeModal(); - } catch (error) { - console.error(error); + } catch (err) { + console.error(err); - if (error instanceof UserRejectedRequestError) { + if (err instanceof UserRejectedRequestError) { warningToast($t('messages.network.rejected')); } } finally { diff --git a/packages/bridge-ui-v2/src/components/Faucet/Faucet.svelte b/packages/bridge-ui-v2/src/components/Faucet/Faucet.svelte index c1348c80eb3..2b7e621a568 100644 --- a/packages/bridge-ui-v2/src/components/Faucet/Faucet.svelte +++ b/packages/bridge-ui-v2/src/components/Faucet/Faucet.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { type Chain, getWalletClient, switchNetwork } from '@wagmi/core'; + import { type Chain, switchNetwork } from '@wagmi/core'; import { t } from 'svelte-i18n'; import { UserRejectedRequestError } from 'viem'; @@ -30,11 +30,11 @@ switchingNetwork = true; try { - await switchNetwork({ chainId: +PUBLIC_L1_CHAIN_ID }); - } catch (error) { - console.error(error); + await switchNetwork({ chainId: Number(PUBLIC_L1_CHAIN_ID) }); + } catch (err) { + console.error(err); - if (error instanceof UserRejectedRequestError) { + if (err instanceof UserRejectedRequestError) { warningToast($t('messages.network.rejected')); } } finally { @@ -49,15 +49,11 @@ // A token and a source chain must be selected in order to be able to mint if (!selectedToken || !$network) return; - // ... and of course, our wallet must be connected - const walletClient = await getWalletClient({ chainId: $network.id }); - if (!walletClient) return; - // Let's begin the minting process minting = true; try { - const txHash = await mint(selectedToken, walletClient); + const txHash = await mint(selectedToken); successToast( $t('faucet.minting_tx', { @@ -70,8 +66,8 @@ ); // TODO: pending transaction logic - } catch (error) { - console.error(error); + } catch (err) { + console.error(err); // const { cause } = error as Error; } finally { @@ -98,17 +94,14 @@ reasonNotMintable = ''; try { - await checkMintable(token, network); + await checkMintable(token, network.id); mintButtonEnabled = true; - } catch (error) { - console.error(error); + } catch (err) { + console.error(err); - const { cause } = error as Error; + const { cause } = err as Error; switch (cause) { - case MintableError.NOT_CONNECTED: - reasonNotMintable = $t('faucet.warning.no_connected'); - break; case MintableError.INSUFFICIENT_BALANCE: reasonNotMintable = $t('faucet.warning.insufficient_balance'); break; @@ -140,7 +133,7 @@ <Card class="md:w-[524px]" title={$t('faucet.title')} text={$t('faucet.subtitle')}> <div class="space-y-[35px]"> <div class="space-y-2"> - <ChainSelector label={$t('chain_selector.currently_on')} value={$network} /> + <ChainSelector label={$t('chain_selector.currently_on')} value={$network} switchWallet /> <TokenDropdown tokens={testERC20Tokens} bind:value={selectedToken} /> </div> diff --git a/packages/bridge-ui-v2/src/components/InputBox/InputBox.svelte b/packages/bridge-ui-v2/src/components/InputBox/InputBox.svelte index 3ecbb46e85f..757aaa698bd 100644 --- a/packages/bridge-ui-v2/src/components/InputBox/InputBox.svelte +++ b/packages/bridge-ui-v2/src/components/InputBox/InputBox.svelte @@ -1,13 +1,17 @@ <script lang="ts"> import { classNames } from '$libs/util/classNames'; - let input: HTMLInputElement; + export let loading = false; + export let error = false; - export let classes = classNames('w-full input-box placeholder:text-tertiary-content', $$props.class); + let input: HTMLInputElement; + let classes = classNames('w-full input-box placeholder:text-tertiary-content', $$props.class); - export const clear = () => (input.value = ''); + // Public API + export const getValue = () => input.value; + export const setValue = (value: string) => (input.value = value); + export const clear = () => setValue(''); export const focus = () => input.focus(); - export const value = () => input.value; </script> -<input class={classes} {...$$restProps} on:input bind:this={input} /> +<input class={classes} class:error disabled={loading} {...$$restProps} bind:this={input} on:input on:blur /> diff --git a/packages/bridge-ui-v2/src/i18n/en.json b/packages/bridge-ui-v2/src/i18n/en.json index cc9e3ca9999..56ab85e6681 100644 --- a/packages/bridge-ui-v2/src/i18n/en.json +++ b/packages/bridge-ui-v2/src/i18n/en.json @@ -84,7 +84,11 @@ "amount_input": { "label": "Amount", "balance": "Balance", - "button.max": "Max" + "button": { + "max": "Max", + "failed_max": "Coult not estimate max amount to bridge." + }, + "error.insufficient_balance": "Insufficient balance" }, "chain_selector": { diff --git a/packages/bridge-ui-v2/src/libs/bridge/ERC1155Bridge.ts b/packages/bridge-ui-v2/src/libs/bridge/ERC1155Bridge.ts new file mode 100644 index 00000000000..ddad81ecab1 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/ERC1155Bridge.ts @@ -0,0 +1,7 @@ +import type { Bridge } from './types'; + +export class ERC1155Bridge implements Bridge { + async estimateGas(): Promise<bigint> { + return Promise.resolve(BigInt(0)); + } +} diff --git a/packages/bridge-ui-v2/src/libs/bridge/ERC20Bridge.ts b/packages/bridge-ui-v2/src/libs/bridge/ERC20Bridge.ts new file mode 100644 index 00000000000..074bc062a27 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/ERC20Bridge.ts @@ -0,0 +1,67 @@ +import { getContract } from '@wagmi/core'; + +import { tokenVaultABI } from '$abi'; +import { bridge } from '$config'; +import { getConnectedWallet } from '$libs/util/getWallet'; +import { getLogger } from '$libs/util/logger'; + +import type { Bridge, ERC20BridgeArgs, SendERC20Args } from './types'; + +const log = getLogger('ERC20Bridge'); + +export class ERC20Bridge implements Bridge { + private static async _prepareTransaction(args: ERC20BridgeArgs) { + const walletClient = await getConnectedWallet(); + + const { + to, + memo = '', + amount, + destChainId, + tokenAddress, + processingFee, + tokenVaultAddress, + isTokenAlreadyDeployed, + } = args; + + const tokenVaultContract = getContract({ + walletClient, + abi: tokenVaultABI, + address: tokenVaultAddress, + }); + + const refundAddress = walletClient.account.address; + + const gasLimit = !isTokenAlreadyDeployed + ? BigInt(bridge.noTokenDeployedGasLimit) + : processingFee > 0 + ? bridge.noOwnerGasLimit + : BigInt(0); + + const sendERC20Args: SendERC20Args = [ + BigInt(destChainId), + to, + tokenAddress, + amount, + gasLimit, + processingFee, + refundAddress, + memo, + ]; + + log('Preparing transaction with args', sendERC20Args); + + return { tokenVaultContract, sendERC20Args }; + } + + async estimateGas(args: ERC20BridgeArgs) { + const { tokenVaultContract, sendERC20Args } = await ERC20Bridge._prepareTransaction(args); + const [, , , , , processingFee] = sendERC20Args; + + const value = processingFee; + + log('Estimating gas for sendERC20 call. Sending value', value); + + return tokenVaultContract.estimateGas.sendERC20([...sendERC20Args], { value }); + } +} diff --git a/packages/bridge-ui-v2/src/libs/bridge/ERC721Bridge.ts b/packages/bridge-ui-v2/src/libs/bridge/ERC721Bridge.ts new file mode 100644 index 00000000000..a9d03418e55 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/ERC721Bridge.ts @@ -0,0 +1,7 @@ +import type { Bridge } from './types'; + +export class ERC721Bridge implements Bridge { + async estimateGas(): Promise<bigint> { + return Promise.resolve(BigInt(0)); + } +} diff --git a/packages/bridge-ui-v2/src/libs/bridge/ETHBridge.ts b/packages/bridge-ui-v2/src/libs/bridge/ETHBridge.ts new file mode 100644 index 00000000000..45c54f813a9 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/ETHBridge.ts @@ -0,0 +1,69 @@ +import { getContract } from '@wagmi/core'; + +import { bridgeABI } from '$abi'; +import { bridge } from '$config'; +import { getConnectedWallet } from '$libs/util/getWallet'; +import { getLogger } from '$libs/util/logger'; + +import type { Bridge, ETHBridgeArgs, Message } from './types'; + +const log = getLogger('ETHBridge'); + +export class ETHBridge implements Bridge { + private static async _prepareTransaction(args: ETHBridgeArgs) { + const walletClient = await getConnectedWallet(); + + const { to, memo = '', amount, srcChainId, destChainId, bridgeAddress, processingFee } = args; + + const bridgeContract = getContract({ + walletClient, + abi: bridgeABI, + address: bridgeAddress, + }); + + const owner = walletClient.account.address; + + // TODO: contract actually supports bridging to ourselves as well as + // to another address at the same time + const [depositValue, callValue] = + to.toLowerCase() === owner.toLowerCase() ? [amount, BigInt(0)] : [BigInt(0), amount]; + + // If there is a processing fee, use the specified message gas limit + // if not called by the owner + const gasLimit = processingFee > 0 ? bridge.noOwnerGasLimit : BigInt(0); + + const message: Message = { + to, + owner, + sender: owner, + refundAddress: owner, + + srcChainId: BigInt(srcChainId), + destChainId: BigInt(destChainId), + + gasLimit, + callValue, + depositValue, + processingFee, + + memo, + data: '0x', + id: BigInt(0), // will be set in contract + }; + + log('Preparing transaction with message', message); + + return { bridgeContract, message }; + } + + async estimateGas(args: ETHBridgeArgs) { + const { bridgeContract, message } = await ETHBridge._prepareTransaction(args); + const { depositValue, callValue, processingFee } = message; + + const value = depositValue + callValue + processingFee; + + log('Estimating gas for sendMessage call. Sending value', value); + + return bridgeContract.estimateGas.sendMessage([message], { value }); + } +} diff --git a/packages/bridge-ui-v2/src/libs/bridge/bridges.ts b/packages/bridge-ui-v2/src/libs/bridge/bridges.ts new file mode 100644 index 00000000000..8eefde0a6dd --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/bridges.ts @@ -0,0 +1,12 @@ +import { ERC20Bridge } from './ERC20Bridge'; +import { ERC721Bridge } from './ERC721Bridge'; +import { ERC1155Bridge } from './ERC1155Bridge'; +import { ETHBridge } from './ETHBridge'; +import type { Bridge, BridgeType } from './types'; + +export const bridges: Record<BridgeType, Bridge> = { + ETH: new ETHBridge(), + ERC20: new ERC20Bridge(), + ERC721: new ERC721Bridge(), + ERC1155: new ERC1155Bridge(), +}; diff --git a/packages/bridge-ui-v2/src/libs/bridge/estimateCostOfBridging.ts b/packages/bridge-ui-v2/src/libs/bridge/estimateCostOfBridging.ts new file mode 100644 index 00000000000..adcb8c81d74 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/estimateCostOfBridging.ts @@ -0,0 +1,12 @@ +import { getPublicClient } from '@wagmi/core'; + +import type { Bridge, BridgeArgs } from './types'; + +export async function estimateCostOfBridging(bridge: Bridge, bridgeArgs: BridgeArgs) { + const publicClient = getPublicClient(); + + // Calculate the estimated cost of bridging + const estimatedGas = await bridge.estimateGas(bridgeArgs); + const gasPrice = await publicClient.getGasPrice(); + return estimatedGas * gasPrice; +} diff --git a/packages/bridge-ui-v2/src/libs/bridge/getMaxToBridge.ts b/packages/bridge-ui-v2/src/libs/bridge/getMaxToBridge.ts new file mode 100644 index 00000000000..041567139a4 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/getMaxToBridge.ts @@ -0,0 +1,60 @@ +import type { Address } from 'viem'; + +import { chainContractsMap, chains } from '$libs/chain'; +import { isETH, type Token } from '$libs/token'; +import { getLogger } from '$libs/util/logger'; + +import { bridges } from './bridges'; +import { estimateCostOfBridging } from './estimateCostOfBridging'; +import type { ETHBridgeArgs } from './types'; + +type GetMaxToBridgeArgs = { + token: Token; + balance: bigint; + srcChainId: number; + userAddress: Address; + processingFee: bigint; + destChainId?: number; + amount?: bigint; +}; + +const log = getLogger('getMaxToBridge'); + +export async function getMaxToBridge({ + token, + balance, + srcChainId, + userAddress, + processingFee, + destChainId, + amount, +}: GetMaxToBridgeArgs) { + if (isETH(token)) { + const to = userAddress; + const { bridgeAddress } = chainContractsMap[srcChainId.toString()]; + + const bridgeArgs = { + to, + srcChainId, + bridgeAddress, + processingFee, + + // If no amount passed in, use whatever just to get an estimation + amount: amount ?? BigInt(1), + + // If no destination chain is selected, find another chain to estimate + // TODO: we might want to really find a compatible chain to bridge to + // if we have multiple layers + destChainId: destChainId ?? chains.find((chain) => chain.id !== srcChainId)?.id, + } as ETHBridgeArgs; + + const estimatedCost = await estimateCostOfBridging(bridges.ETH, bridgeArgs); + + log('Estimated cost of bridging', estimatedCost, 'with argument', bridgeArgs); + + return balance - processingFee - estimatedCost; + } + + // For ERC20 tokens, we can bridge the whole balance + return balance; +} diff --git a/packages/bridge-ui-v2/src/libs/bridge/index.ts b/packages/bridge-ui-v2/src/libs/bridge/index.ts new file mode 100644 index 00000000000..5187917dc4b --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/index.ts @@ -0,0 +1,3 @@ +export { bridges } from './bridges'; +export { estimateCostOfBridging } from './estimateCostOfBridging'; +export * from './types'; diff --git a/packages/bridge-ui-v2/src/libs/bridge/types.ts b/packages/bridge-ui-v2/src/libs/bridge/types.ts new file mode 100644 index 00000000000..7dd0f52172c --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/bridge/types.ts @@ -0,0 +1,97 @@ +import type { Address, Hex } from 'viem'; + +export enum BridgeType { + ETH = 'ETH', + + // https://ethereum.org/en/developers/docs/standards/tokens/erc-20/ + ERC20 = 'ERC20', + + // https://ethereum.org/en/developers/docs/standards/tokens/erc-721/ + ERC721 = 'ERC721', + + // https://ethereum.org/en/developers/docs/standards/tokens/erc-1155/ + ERC1155 = 'ERC1155', +} + +// Bridge sendMessage(message: Message) +export type Message = { + // Message ID. Will be set in contract + id: bigint; + // Message sender address (auto filled) + sender: Address; + // Source chain ID (auto filled) + srcChainId: bigint; + // Destination chain ID where the `to` address lives (auto filled) + destChainId: bigint; + // Owner address of the bridged asset. + owner: Address; + // Destination owner address + to: Address; + // Alternate address to send any refund. If blank, defaults to owner. + refundAddress: Address; + // Deposited Ether minus the processingFee. + depositValue: bigint; + // callValue to invoke on the destination chain, for ERC20 transfers. + callValue: bigint; + // Processing fee for the relayer. Zero if owner will process themself. + processingFee: bigint; + // gasLimit to invoke on the destination chain, for ERC20 transfers. + gasLimit: bigint; + // callData to invoke on the destination chain, for ERC20 transfers. + data: Hex; + // Optional memo. + memo: string; +}; + +// TokenVault sendERC20(...args) +export type SendERC20Args = [ + bigint, // destChainId + Address, // to + Address, // token + bigint, // amount + bigint, // gasLimit + bigint, // processingFee + Address, // refundAddress + string, // memo +]; + +// TODO: future sendToken(op: BridgeTransferOp) +export type BridgeTransferOp = { + destChainId: bigint; + to: Address; + token: Address; + amount: bigint; + gasLimit: bigint; + processingFee: bigint; + refundAddress: Address; + memo: string; +}; + +export type ApproveArgs = { + amount: bigint; + tokenAddress: Address; + spenderAddress: Address; +}; + +export type BridgeArgs = { + to: Address; + srcChainId: number; + destChainId: number; + amount: bigint; + processingFee: bigint; + memo?: string; +}; + +export type ETHBridgeArgs = BridgeArgs & { + bridgeAddress: Address; +}; + +export type ERC20BridgeArgs = BridgeArgs & { + tokenAddress: Address; + tokenVaultAddress: Address; + isTokenAlreadyDeployed?: boolean; +}; + +export interface Bridge { + estimateGas(args: BridgeArgs): Promise<bigint>; +} diff --git a/packages/bridge-ui-v2/src/libs/chain/chains.ts b/packages/bridge-ui-v2/src/libs/chain/chains.ts index 2779bff7880..496137b14ba 100644 --- a/packages/bridge-ui-v2/src/libs/chain/chains.ts +++ b/packages/bridge-ui-v2/src/libs/chain/chains.ts @@ -1,5 +1,4 @@ -import type { Chain } from '@wagmi/core'; -import type { Address } from 'abitype'; +import type { Address, Chain } from '@wagmi/core'; import { PUBLIC_L1_BRIDGE_ADDRESS, diff --git a/packages/bridge-ui-v2/src/libs/token/checkMintable.test.ts b/packages/bridge-ui-v2/src/libs/token/checkMintable.test.ts index 3066604fa6c..503542c036e 100644 --- a/packages/bridge-ui-v2/src/libs/token/checkMintable.test.ts +++ b/packages/bridge-ui-v2/src/libs/token/checkMintable.test.ts @@ -45,24 +45,15 @@ describe('checkMintable', () => { vi.mocked(getPublicClient).mockReturnValue(mockPublicClient); }); - it('should throw when wallet is not connected', async () => { - vi.mocked(getWalletClient).mockResolvedValueOnce(null); - - try { - await checkMintable(BLLToken, mainnetChain); - expect.fail('should have thrown'); - } catch (error) { - const { cause } = error as Error; - expect(cause).toBe(MintableError.NOT_CONNECTED); - expect(getWalletClient).toHaveBeenCalledWith({ chainId: mainnetChain.id }); - } + beforeEach(() => { + vi.clearAllMocks(); }); it('should throw when user has already minted', async () => { vi.mocked(mockTokenContract.read.minters).mockResolvedValueOnce(true); try { - await checkMintable(BLLToken, mainnetChain); + await checkMintable(BLLToken, mainnetChain.id); expect.fail('should have thrown'); } catch (error) { const { cause } = error as Error; @@ -91,12 +82,12 @@ describe('checkMintable', () => { vi.mocked(mockPublicClient.getBalance).mockResolvedValueOnce(BigInt(100)); try { - await checkMintable(BLLToken, mainnetChain); + await checkMintable(BLLToken, mainnetChain.id); expect.fail('should have thrown'); } catch (error) { const { cause } = error as Error; expect(cause).toBe(MintableError.INSUFFICIENT_BALANCE); - expect(getPublicClient).toHaveBeenCalledWith({ chainId: mainnetChain.id }); + expect(getPublicClient).toHaveBeenCalled(); expect(mockTokenContract.estimateGas.mint).toHaveBeenCalledWith([mockWalletClient.account.address]); expect(mockPublicClient.getBalance).toHaveBeenCalledWith({ address: mockWalletClient.account.address }); } @@ -117,7 +108,7 @@ describe('checkMintable', () => { vi.mocked(mockPublicClient.getBalance).mockResolvedValueOnce(BigInt(300)); try { - await checkMintable(BLLToken, mainnetChain); + await checkMintable(BLLToken, mainnetChain.id); } catch (error) { expect.fail('should not have thrown'); } diff --git a/packages/bridge-ui-v2/src/libs/token/checkMintable.ts b/packages/bridge-ui-v2/src/libs/token/checkMintable.ts index 848de485316..2c91d47a4eb 100644 --- a/packages/bridge-ui-v2/src/libs/token/checkMintable.ts +++ b/packages/bridge-ui-v2/src/libs/token/checkMintable.ts @@ -1,21 +1,15 @@ -import { type Chain, getContract, getPublicClient, getWalletClient } from '@wagmi/core'; -import { formatEther } from 'viem'; +import { getContract, getPublicClient } from '@wagmi/core'; import { freeMintErc20ABI } from '$abi'; +import { getConnectedWallet } from '$libs/util/getWallet'; import { MintableError, type Token } from './types'; // Throws an error if: -// 1. User is not connected to the network -// 2. User has already minted this token -// 3. User has insufficient balance to mint this token -export async function checkMintable(token: Token, network: Chain) { - const chainId = network.id; - const walletClient = await getWalletClient({ chainId }); - - if (!walletClient) { - throw Error(`user is not connected to ${network.name}`, { cause: MintableError.NOT_CONNECTED }); - } +// 1. User has already minted this token +// 2. User has insufficient balance to mint this token +export async function checkMintable(token: Token, chainId: number) { + const walletClient = await getConnectedWallet(); const tokenContract = getContract({ walletClient, @@ -28,12 +22,13 @@ export async function checkMintable(token: Token, network: Chain) { const hasMinted = await tokenContract.read.minters([userAddress]); if (hasMinted) { - throw Error(`token ${token.symbol} has already been minted`, { cause: MintableError.TOKEN_MINTED }); + throw Error(`token already minted`, { cause: MintableError.TOKEN_MINTED }); } // Check whether the user has enough balance to mint. // Compute the cost of the transaction: - const publicClient = getPublicClient({ chainId }); + const publicClient = getPublicClient(); + const estimatedGas = await tokenContract.estimateGas.mint([userAddress]); const gasPrice = await publicClient.getGasPrice(); const estimatedCost = estimatedGas * gasPrice; @@ -41,7 +36,7 @@ export async function checkMintable(token: Token, network: Chain) { const userBalance = await publicClient.getBalance({ address: userAddress }); if (estimatedCost > userBalance) { - throw Error(`user has insufficient balance to mint ${token.symbol}: ${formatEther(userBalance)}`, { + throw Error('user has insufficient balance', { cause: MintableError.INSUFFICIENT_BALANCE, }); } diff --git a/packages/bridge-ui-v2/src/libs/token/getBalance.test.ts b/packages/bridge-ui-v2/src/libs/token/getBalance.test.ts index a6846bb2be6..7b9d00d2e70 100644 --- a/packages/bridge-ui-v2/src/libs/token/getBalance.test.ts +++ b/packages/bridge-ui-v2/src/libs/token/getBalance.test.ts @@ -68,7 +68,6 @@ describe('getBalance', () => { }); expect(fetchBalance).toHaveBeenCalledWith({ address: mockWalletClient.account.address, - chainId: Number(PUBLIC_L1_CHAIN_ID), token: BLLToken.addresses[PUBLIC_L1_CHAIN_ID], }); }); diff --git a/packages/bridge-ui-v2/src/libs/token/getBalance.ts b/packages/bridge-ui-v2/src/libs/token/getBalance.ts index ebb974455e1..f4715362bd5 100644 --- a/packages/bridge-ui-v2/src/libs/token/getBalance.ts +++ b/packages/bridge-ui-v2/src/libs/token/getBalance.ts @@ -1,6 +1,5 @@ import { fetchBalance, type FetchBalanceResult } from '@wagmi/core'; -import type { Address } from 'abitype'; -import { zeroAddress } from 'viem'; +import { type Address, zeroAddress } from 'viem'; import { getLogger } from '$libs/util/logger'; @@ -21,7 +20,7 @@ export async function getBalance({ token, userAddress, chainId, destChainId }: G let tokenBalance: FetchBalanceResult | null = null; if (isETH(token)) { - tokenBalance = await fetchBalance({ address: userAddress, chainId }); + tokenBalance = await fetchBalance({ address: userAddress }); } else { // We are dealing with an ERC20 token. We need to first find out its address // on the current chain in order to fetch the balance. @@ -32,7 +31,6 @@ export async function getBalance({ token, userAddress, chainId, destChainId }: G // Wagmi is such an amazing library. We had to do this // more manually before. tokenBalance = await fetchBalance({ - chainId, address: userAddress, token: tokenAddress, }); diff --git a/packages/bridge-ui-v2/src/libs/token/mint.test.ts b/packages/bridge-ui-v2/src/libs/token/mint.test.ts index 379c5798d88..2be21d8334d 100644 --- a/packages/bridge-ui-v2/src/libs/token/mint.test.ts +++ b/packages/bridge-ui-v2/src/libs/token/mint.test.ts @@ -28,7 +28,7 @@ describe('mint', () => { vi.mocked(getContract).mockReturnValue(mockTokenContract); vi.mocked(mockTokenContract.write.mint).mockResolvedValue('0x123456'); - await expect(mint(BLLToken, mockWalletClient)).resolves.toEqual('0x123456'); + await expect(mint(BLLToken)).resolves.toEqual('0x123456'); expect(mockTokenContract.write.mint).toHaveBeenCalledWith([mockWalletClient.account.address]); }); }); diff --git a/packages/bridge-ui-v2/src/libs/token/mint.ts b/packages/bridge-ui-v2/src/libs/token/mint.ts index cfd35b3b331..c9585583711 100644 --- a/packages/bridge-ui-v2/src/libs/token/mint.ts +++ b/packages/bridge-ui-v2/src/libs/token/mint.ts @@ -1,13 +1,16 @@ -import { getContract, type WalletClient } from '@wagmi/core'; +import { getContract } from '@wagmi/core'; import { freeMintErc20ABI } from '$abi'; +import { getConnectedWallet } from '$libs/util/getWallet'; import { getLogger } from '../util/logger'; import type { Token } from './types'; const log = getLogger('token:mint'); -export async function mint(token: Token, walletClient: WalletClient) { +export async function mint(token: Token) { + const walletClient = await getConnectedWallet(); + const tokenSymbol = token.symbol; const userAddress = walletClient.account.address; const chainId = walletClient.chain.id; diff --git a/packages/bridge-ui-v2/src/libs/token/types.ts b/packages/bridge-ui-v2/src/libs/token/types.ts index c47b27f7fa3..230e5814d1d 100644 --- a/packages/bridge-ui-v2/src/libs/token/types.ts +++ b/packages/bridge-ui-v2/src/libs/token/types.ts @@ -1,4 +1,4 @@ -import type { Address } from 'abitype'; +import type { Address } from 'viem'; export type Token = { name: string; @@ -14,7 +14,6 @@ export type TokenEnv = { }; export enum MintableError { - NOT_CONNECTED = 'NOT_CONNECTED', TOKEN_MINTED = 'TOKEN_MINTED', INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', } diff --git a/packages/bridge-ui-v2/src/libs/util/debounce.ts b/packages/bridge-ui-v2/src/libs/util/debounce.ts new file mode 100644 index 00000000000..75cdc862732 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/util/debounce.ts @@ -0,0 +1,14 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function debounce<T extends (...args: any[]) => ReturnType<T>>( + callback: T, + timeout: number, +): (...args: Parameters<T>) => void { + let timer: ReturnType<typeof setTimeout>; + + return (...args: Parameters<T>) => { + clearTimeout(timer); + timer = setTimeout(() => { + callback(...args); + }, timeout); + }; +} diff --git a/packages/bridge-ui-v2/src/libs/util/getWallet.ts b/packages/bridge-ui-v2/src/libs/util/getWallet.ts new file mode 100644 index 00000000000..d609ef62344 --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/util/getWallet.ts @@ -0,0 +1,11 @@ +import { getWalletClient } from '@wagmi/core'; + +export async function getConnectedWallet() { + const walletClient = await getWalletClient(); + + if (!walletClient) { + throw Error('wallet is not connected'); + } + + return walletClient; +} diff --git a/packages/bridge-ui-v2/src/libs/util/truncateDecimal.ts b/packages/bridge-ui-v2/src/libs/util/truncateDecimal.ts new file mode 100644 index 00000000000..bd3391864cb --- /dev/null +++ b/packages/bridge-ui-v2/src/libs/util/truncateDecimal.ts @@ -0,0 +1,4 @@ +export function truncateDecimal(num: number, decimalPlaces: number) { + const factor = 10 ** decimalPlaces; + return Math.floor(num * factor) / factor; +} diff --git a/packages/bridge-ui-v2/src/styles/components.css b/packages/bridge-ui-v2/src/styles/components.css index 7b82bbfa2c6..839066bb4d9 100644 --- a/packages/bridge-ui-v2/src/styles/components.css +++ b/packages/bridge-ui-v2/src/styles/components.css @@ -83,7 +83,11 @@ /* focus:border-[3px] */ /* focus:border-primary-border-accent */ - focus:shadow-[0_0_0_3px_#E81899]; + focus:!shadow-[0_0_0_3px_#E81899]; + } + + .input-box.error { + @apply !shadow-[0_0_0_3px_#F15C5D]; } /* Separatos */