Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bridge-ui-v2): amount input validation #14213

Merged
merged 27 commits into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/bridge-ui-v2/src/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ export const processingFeeComponent = {
closingDelayOptionClick: 300,
intervalComputeRecommendedFee: 20000,
};

export const bridge = {
noOwnerGasLimit: BigInt(140000),
noTokenDeployedGasLimit: BigInt(3000000),
};
Original file line number Diff line number Diff line change
@@ -1,26 +1,157 @@
<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 might throw an error that contains the following message, indicating
// that the user won't have enough to pay the transaction
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}
id="{inputId}x"
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>
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

import { destNetwork, selectedToken } from '../state';

let tokenBalance: Maybe<FetchBalanceResult>;
export let value: Maybe<FetchBalanceResult>;

let computingTokenBalance = false;
let errorComputingTokenBalance = false;

Expand All @@ -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;
Expand All @@ -50,7 +51,7 @@
<LoadingText mask="0.0000" />
<LoadingText mask="XXX" />
{:else}
{renderTokenBalance(tokenBalance)}
{renderTokenBalance(value)}
{/if}
</span>
</div>
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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>
Expand Down
7 changes: 4 additions & 3 deletions packages/bridge-ui-v2/src/components/Bridge/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 13 additions & 17 deletions packages/bridge-ui-v2/src/components/Faucet/Faucet.svelte
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 {
Expand All @@ -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', {
Expand All @@ -70,8 +66,8 @@
);

// TODO: pending transaction logic
} catch (error) {
console.error(error);
} catch (err) {
console.error(err);

// const { cause } = error as Error;
} finally {
Expand All @@ -98,12 +94,12 @@
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:
Expand Down Expand Up @@ -140,7 +136,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>

Expand Down
14 changes: 9 additions & 5 deletions packages/bridge-ui-v2/src/components/InputBox/InputBox.svelte
Original file line number Diff line number Diff line change
@@ -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 />
Loading