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 */