diff --git a/src/GlobalStateProvider.tsx b/src/GlobalStateProvider.tsx index 403324e8..9791e6da 100644 --- a/src/GlobalStateProvider.tsx +++ b/src/GlobalStateProvider.tsx @@ -1,5 +1,5 @@ import { WalletAccount, getWalletBySource } from '@talismn/connect-wallets'; -import { createContext } from 'preact'; +import { ComponentChildren, createContext } from 'preact'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/compat'; import { useLocation } from 'react-router-dom'; import { config } from './config'; @@ -41,7 +41,7 @@ const initWalletConnect = async (chainId: string) => { return await walletConnectService.init(provider?.session, chainId); }; -const GlobalStateProvider = ({ children }: { children: ReactNode }) => { +const GlobalStateProvider = ({ children }: { children: ComponentChildren }) => { const tenantRef = useRef(); const [walletAccount, setWallet] = useState(undefined); const { pathname } = useLocation(); diff --git a/src/SharedProvider.tsx b/src/SharedProvider.tsx new file mode 100644 index 00000000..4e4a7aa4 --- /dev/null +++ b/src/SharedProvider.tsx @@ -0,0 +1,17 @@ +import { ComponentChildren } from 'preact'; +import { useGlobalState } from './GlobalStateProvider'; +import { useNodeInfoState } from './NodeInfoProvider'; +import { SharedStateProvider } from './shared/Provider'; + +const SharedProvider = ({ children }: { children: ComponentChildren }) => { + const { api } = useNodeInfoState().state; + const { signer, address } = useGlobalState().walletAccount || {}; + + return ( + + {children} + + ); +}; + +export default SharedProvider; diff --git a/src/app.tsx b/src/app.tsx index 7917b869..6145ce3f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import TermsAndConditions from './TermsAndConditions'; +import AppsProvider from './components/Apps/provider'; import Layout from './components/Layout'; import { defaultPageLoader } from './components/Loader/Page'; import { NotFound } from './components/NotFound'; @@ -21,6 +22,7 @@ const TransfersPage = import('./pages/bridge/Trans const BackstopPoolsPage = ( import('./pages/nabla/backstop-pools')} fallback={defaultPageLoader} /> ); +const DevPage = import('./pages/nabla/dev')} fallback={defaultPageLoader} />; const Bridge = import('./pages/bridge')} fallback={defaultPageLoader} />; const Staking = import('./pages/collators/Collators')} fallback={defaultPageLoader} />; @@ -38,11 +40,12 @@ export function App() { - + }> + {config.isDev && } } /> diff --git a/src/assets/amplitude-icon.svg b/src/assets/amplitude-icon.svg deleted file mode 100644 index 3577b085..00000000 --- a/src/assets/amplitude-icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/components/Apps/Unsupported/index.tsx b/src/components/Apps/Unsupported/index.tsx new file mode 100644 index 00000000..cef47e87 --- /dev/null +++ b/src/components/Apps/Unsupported/index.tsx @@ -0,0 +1,41 @@ +import { Button } from 'react-daisyui'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { Apps } from '../../../config/apps'; +import { buildTenantPath } from '../../../helpers/url'; +import { TenantName } from '../../../models/Tenant'; + +export interface UnsupportedProps { + app: Apps; + tenant: TenantName; + supportedTenants: TenantName[]; +} + +const Unsupported = ({ app, tenant, supportedTenants }: UnsupportedProps): JSX.Element | null => { + const navigateTo = useNavigate(); + const location = useLocation().pathname; + return ( +
+

+ {app} is not supported on {tenant}. + Switch to: +

+
+ {supportedTenants.map((st) => ( + + ))} +
+
+ ); +}; + +export default Unsupported; diff --git a/src/components/Apps/provider.tsx b/src/components/Apps/provider.tsx new file mode 100644 index 00000000..ad840d41 --- /dev/null +++ b/src/components/Apps/provider.tsx @@ -0,0 +1,22 @@ +import { Outlet } from 'react-router-dom'; +import { useGlobalState } from '../../GlobalStateProvider'; +import { Apps, appsConfigs } from '../../config/apps'; +import { TenantName } from '../../models/Tenant'; +import Unsupported from './Unsupported'; + +export type AppsProviderProps = { + app: Apps; +}; + +const AppsProvider = ({ app }: AppsProviderProps): JSX.Element | null => { + const tenant = useGlobalState().tenantName; + const supportedTenants = appsConfigs[app].tenants; + if (!(supportedTenants as TenantName[]).includes(tenant)) { + return ; + } + return ; +}; + +AppsProvider.displayName = 'AppsProvider'; + +export default AppsProvider; diff --git a/src/components/Asset/Approval/index.tsx b/src/components/Asset/Approval/index.tsx new file mode 100644 index 00000000..5173f367 --- /dev/null +++ b/src/components/Asset/Approval/index.tsx @@ -0,0 +1,45 @@ +import { Button, ButtonProps } from 'react-daisyui'; +import { ApprovalState, useTokenApproval } from '../../../shared/useTokenApproval'; + +export type TokenApprovalProps = ButtonProps & { + token: string | undefined; + amount: number; + /** contract address (eg. router address) */ + spender?: string; + enabled?: boolean; + children: ReactNode; +}; + +const TokenApproval = ({ + amount, + token, + spender, + enabled = true, + children, + className = '', + ...rest +}: TokenApprovalProps): JSX.Element | null => { + const approval = useTokenApproval({ + amount, + token, + spender, + enabled, + }); + + if (approval[0] === ApprovalState.APPROVED || !enabled) return <>{children}; + const isPending = approval[0] === ApprovalState.PENDING; + const isLoading = approval[0] === ApprovalState.LOADING; + return ( + + ); +}; +export default TokenApproval; diff --git a/src/components/Asset/Selector/Modal/index.tsx b/src/components/Asset/Selector/Modal/index.tsx index 42ec620c..cc1c5578 100644 --- a/src/components/Asset/Selector/Modal/index.tsx +++ b/src/components/Asset/Selector/Modal/index.tsx @@ -1,8 +1,10 @@ +import { CheckIcon } from '@heroicons/react/20/solid'; import { matchSorter } from 'match-sorter'; import { ChangeEvent, useMemo, useState } from 'preact/compat'; import { Avatar, Button, Input, Modal, ModalProps } from 'react-daisyui'; import { repeat } from '../../../../helpers/general'; import { Asset } from '../../../../models/Asset'; +import ModalCloseButton from '../../../Button/ModalClose'; import { Skeleton } from '../../../Skeleton'; export interface AssetListProps { @@ -31,21 +33,24 @@ const AssetList = ({ assets, onSelect, selected }: AssetListProps): JSX.Element {filteredTokens?.map((token) => ( ))} @@ -70,9 +75,7 @@ export const AssetSelectorModal = ({ return ( - +

Select a token

diff --git a/src/components/Balance/index.tsx b/src/components/Balance/index.tsx index 077effcd..2db76de3 100644 --- a/src/components/Balance/index.tsx +++ b/src/components/Balance/index.tsx @@ -1,19 +1,25 @@ +import { ComponentChildren } from 'preact'; import { QueryOptions } from '../../constants/cache'; -import { useBalance } from '../../hooks/useBalance'; -import { Skeleton } from '../Skeleton'; +import { useContractBalance } from '../../shared/useContractBalance'; +import { numberLoader } from '../Loader'; export type BalanceProps = { - address: string; + address?: string; fallback?: string | number; loader?: boolean; options?: QueryOptions; + children?: ComponentChildren; }; -const Balance = ({ address, fallback = 0, loader = true, options }: BalanceProps): JSX.Element | null => { - const { isLoading, formatted } = useBalance(address, options); - if (isLoading) { - return loader ? 10000 : null; - } - return {formatted ?? fallback ?? null}; +const Balance = ({ address, fallback = 0, loader = true, options, children }: BalanceProps): JSX.Element | null => { + const { isLoading, formatted, enabled } = useContractBalance({ contractAddress: address }, options); + if (!address || !enabled) return <>{fallback ?? null}; + if (isLoading) return loader ? numberLoader : null; + return ( + + {children} + {formatted ?? fallback ?? null} + + ); }; export default Balance; diff --git a/src/components/Button/ModalClose/index.tsx b/src/components/Button/ModalClose/index.tsx new file mode 100644 index 00000000..b9d781ee --- /dev/null +++ b/src/components/Button/ModalClose/index.tsx @@ -0,0 +1,8 @@ +import { Button, ButtonProps } from 'react-daisyui'; + +const ModalCloseButton = (props: ButtonProps): JSX.Element | null => ( + +); +export default ModalCloseButton; diff --git a/src/components/ChainSelector.tsx b/src/components/ChainSelector.tsx index 871964ce..d0de3752 100644 --- a/src/components/ChainSelector.tsx +++ b/src/components/ChainSelector.tsx @@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import AmplitudeLogo from '../assets/AmplitudeLogo'; import PendulumLogo from '../assets/PendulumLogo'; import { toTitle } from '../helpers/string'; +import { buildTenantPath } from '../helpers/url'; import { TenantName } from '../models/Tenant'; const options = [TenantName.Pendulum, TenantName.Amplitude, TenantName.Foucoco]; @@ -33,7 +34,7 @@ const ChainSelector = ({ tenantName }: { tenantName: TenantName | undefined }): { - navigateTo(buildPath(tenantName, option, location)); + navigateTo(buildTenantPath(tenantName, option, location)); window.location.reload(); }} > @@ -51,8 +52,4 @@ const ChainSelector = ({ tenantName }: { tenantName: TenantName | undefined }): ); }; -function buildPath(current: TenantName | undefined, next: TenantName, location: string) { - return current ? location.replace(current, next) : location; -} - export default ChainSelector; diff --git a/src/components/Loader/Page/index.tsx b/src/components/Loader/Page/index.tsx index 32493e9c..dc55a87b 100644 --- a/src/components/Loader/Page/index.tsx +++ b/src/components/Loader/Page/index.tsx @@ -1,11 +1,17 @@ -import logoLoader from '../../../assets/pendulum-icon-loading.svg'; +import { Skeleton } from '../../Skeleton'; -export const defaultPageLoader = ( -
- Pendulum +export const PageLoader = ({ className = '' }: { className?: string }) => ( +
+ + + + +
); -export const PageLoader = (props: { className?: string }) => ( - Pendulum +export const defaultPageLoader = ( +
+ +
); diff --git a/src/components/Loader/index.tsx b/src/components/Loader/index.tsx new file mode 100644 index 00000000..4da69ba7 --- /dev/null +++ b/src/components/Loader/index.tsx @@ -0,0 +1,3 @@ +import { Skeleton } from '../Skeleton'; + +export const numberLoader = 10000; diff --git a/src/components/Pools/Backstop/AddLiquidity/index.tsx b/src/components/Pools/Backstop/AddLiquidity/index.tsx new file mode 100644 index 00000000..efa32a1e --- /dev/null +++ b/src/components/Pools/Backstop/AddLiquidity/index.tsx @@ -0,0 +1,132 @@ +import { ArrowLeftIcon } from '@heroicons/react/24/outline'; +import { ChangeEvent } from 'preact/compat'; +import { Button, Range } from 'react-daisyui'; +import { PoolProgress } from '../..'; +import { calcSharePercentage } from '../../../../helpers/calc'; +import { BackstopPool } from '../../../../models/BackstopPool'; +import { nativeToDecimal } from '../../../../shared/parseNumbers'; +import TokenApproval from '../../../Asset/Approval'; +import { numberLoader } from '../../../Loader'; +import TransactionProgress from '../../../Transaction/Progress'; +import { useAddLiquidity } from './useAddLiquidity'; + +export type AddLiquidityProps = { + data: BackstopPool; +}; + +const AddLiquidity = ({ data }: AddLiquidityProps): JSX.Element | null => { + const { + toggle, + mutation, + balanceQuery, + depositQuery, + form: { register, handleSubmit, setValue, watch }, + } = useAddLiquidity(data.address, data.asset.address); + const amount = Number(watch('amount') || 0); + const balance = balanceQuery.balance || 0; + + const hideCss = mutation.isLoading ? 'hidden' : ''; + return ( +
+ + + +
+ +

Deposit {data.asset.symbol}

+
+
+
mutation.mutate(data))}> +
+

+ Deposited: {depositQuery.isLoading ? numberLoader : `${depositQuery.formatted || 0} ${data.asset.symbol}`} +

+

+ Balance: {balanceQuery.isLoading ? numberLoader : `${balanceQuery.formatted || 0} ${data.asset.symbol}`} +

+
+
+
+
+ + +
+
+ ) => + setValue('amount', (Number(ev.currentTarget.value) / 100) * balance, { + shouldDirty: true, + shouldTouch: false, + }) + } + /> +
+
+
+
Fee
+
! TODO
+
+
+
Total deposit
+
{depositQuery.isLoading ? numberLoader : `${balance + amount} ${data.asset.symbol}`}
+
+
+
Pool Share
+
+ {depositQuery.isLoading + ? numberLoader + : calcSharePercentage(nativeToDecimal(data.totalSupply || 0).toNumber() + amount, balance + amount)} + % +
+
+
+ 0} + > + + + +
+
+
+ ); +}; +export default AddLiquidity; diff --git a/src/components/Pools/Backstop/AddLiquidity/schema.ts b/src/components/Pools/Backstop/AddLiquidity/schema.ts new file mode 100644 index 00000000..881c0653 --- /dev/null +++ b/src/components/Pools/Backstop/AddLiquidity/schema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup'; +import { transformNumber } from '../../../../helpers/yup'; +import { AddLiquidityValues } from './types'; + +const schema = Yup.object().shape({ + amount: Yup.number().positive().required().transform(transformNumber), +}); + +export default schema; diff --git a/src/components/Pools/Backstop/AddLiquidity/types.ts b/src/components/Pools/Backstop/AddLiquidity/types.ts new file mode 100644 index 00000000..79046f40 --- /dev/null +++ b/src/components/Pools/Backstop/AddLiquidity/types.ts @@ -0,0 +1,3 @@ +export type AddLiquidityValues = { + amount: number; +}; diff --git a/src/components/Pools/Backstop/AddLiquidity/useAddLiquidity.ts b/src/components/Pools/Backstop/AddLiquidity/useAddLiquidity.ts new file mode 100644 index 00000000..43965380 --- /dev/null +++ b/src/components/Pools/Backstop/AddLiquidity/useAddLiquidity.ts @@ -0,0 +1,38 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { backstopPoolAbi } from '../../../../contracts/nabla/BackstopPool'; +import { createWriteOptions } from '../../../../services/api/helpers'; +import { useModalToggle } from '../../../../services/modal'; +import { decimalToNative } from '../../../../shared/parseNumbers'; +import { useContractBalance } from '../../../../shared/useContractBalance'; +import { useContractWrite } from '../../../../shared/useContractWrite'; +import schema from './schema'; +import { AddLiquidityValues } from './types'; + +export const useAddLiquidity = (poolAddress: string, tokenAddress: string) => { + const toggle = useModalToggle(); + + const balanceQuery = useContractBalance({ contractAddress: tokenAddress }); + const depositQuery = useContractBalance({ contractAddress: poolAddress }); + + const form = useForm({ + resolver: yupResolver(schema), + defaultValues: {}, + }); + + const mutation = useContractWrite({ + abi: backstopPoolAbi, + address: poolAddress, + fn: ({ contract, api }, variables: AddLiquidityValues) => + contract.tx.deposit(createWriteOptions(api), decimalToNative(variables.amount).toString()), + onError: () => { + // TODO: handle error + }, + onSuccess: () => { + balanceQuery.refetch(); + depositQuery.refetch(); + }, + }); + + return { form, mutation, toggle, balanceQuery, depositQuery }; +}; diff --git a/src/components/Pools/Backstop/Modal/Deposit/Form/index.tsx b/src/components/Pools/Backstop/Modal/Deposit/Form/index.tsx deleted file mode 100644 index 5d8ea4cb..00000000 --- a/src/components/Pools/Backstop/Modal/Deposit/Form/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Button, Range } from 'react-daisyui'; -import { BackstopPool } from '../../../../../../models/BackstopPool'; -import AssetSelector from '../../../../../Asset/Selector'; -import { useBackstopPoolForm } from './useBackstopPoolForm'; - -export type BackstopPoolFormProps = { - pool: BackstopPool; -}; - -const BackstopPoolForm = ({ pool }: BackstopPoolFormProps): JSX.Element | null => { - const { - mutation, - form: { - register, - handleSubmit, - watch, - setValue, - formState: { errors }, - }, - } = useBackstopPoolForm(); - /* const deposited = 0; - const balance = 120.53; */ - const amount = watch('amount'); - - return ( -
mutation.mutate(data))}> -
-
-
- - setValue('address', asset.address, { - shouldDirty: true, - shouldTouch: true, - }) - } - className={errors.address ? 'border-red-700 bg-red-100' : ''} - size="sm" - /> -
-
-
{amount}%
- -
-
- -
-
-
-
Fee
-
0.99 USDC
-
-
-
Minimum Received
-
0.99 USDC
-
-
- -
- ); -}; -export default BackstopPoolForm; diff --git a/src/components/Pools/Backstop/Modal/Deposit/Form/schema.ts b/src/components/Pools/Backstop/Modal/Deposit/Form/schema.ts deleted file mode 100644 index 79f005ed..00000000 --- a/src/components/Pools/Backstop/Modal/Deposit/Form/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Yup from 'yup'; -import { WithdrawBackstopPoolValues } from './types'; - -const schema = Yup.object().shape({ - amount: Yup.number().min(0).max(100).required(), - address: Yup.string().min(1).required(), -}); - -export default schema; diff --git a/src/components/Pools/Backstop/Modal/Deposit/Form/types.ts b/src/components/Pools/Backstop/Modal/Deposit/Form/types.ts deleted file mode 100644 index 0772f373..00000000 --- a/src/components/Pools/Backstop/Modal/Deposit/Form/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type WithdrawBackstopPoolValues = { - amount: number; - address: string; -}; diff --git a/src/components/Pools/Backstop/Modal/Deposit/Form/useBackstopPoolForm.ts b/src/components/Pools/Backstop/Modal/Deposit/Form/useBackstopPoolForm.ts deleted file mode 100644 index 923e9a77..00000000 --- a/src/components/Pools/Backstop/Modal/Deposit/Form/useBackstopPoolForm.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { useMutation } from '@tanstack/react-query'; -import { useForm } from 'react-hook-form'; -import schema from './schema'; -import { WithdrawBackstopPoolValues } from './types'; - -export const useBackstopPoolForm = () => { - const form = useForm({ - resolver: yupResolver(schema), - defaultValues: { - amount: 0, - address: '', - }, - }); - - const mutation = useMutation( - async (data) => { - console.log(data); - }, - { - onSuccess: () => undefined, - }, - ); - - return { form, mutation }; -}; diff --git a/src/components/Pools/Backstop/Modal/Deposit/index.tsx b/src/components/Pools/Backstop/Modal/Deposit/index.tsx deleted file mode 100644 index a1de8a2b..00000000 --- a/src/components/Pools/Backstop/Modal/Deposit/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { BackstopPool } from '../../../../../models/BackstopPool'; -import BackstopPoolForm from './Form'; - -export type DepositProps = { - pool: BackstopPool; -}; - -const Deposit = ({ pool }: DepositProps): JSX.Element | null => { - return ( -
- -
- ); -}; -export default Deposit; diff --git a/src/components/Pools/Backstop/Modal/Withdraw/Form/index.tsx b/src/components/Pools/Backstop/Modal/Withdraw/Form/index.tsx deleted file mode 100644 index f842dda5..00000000 --- a/src/components/Pools/Backstop/Modal/Withdraw/Form/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Button, Range } from 'react-daisyui'; -import { BackstopPool } from '../../../../../../models/BackstopPool'; -import AssetSelector from '../../../../../Asset/Selector'; -import { useBackstopPoolForm } from './useBackstopPoolForm'; - -export type BackstopPoolFormProps = { - pool: BackstopPool; -}; - -const BackstopPoolForm = ({ pool }: BackstopPoolFormProps): JSX.Element | null => { - const { - mutation, - form: { - register, - handleSubmit, - watch, - setValue, - formState: { errors }, - }, - } = useBackstopPoolForm(); - /* const deposited = 0; - const balance = 120.53; */ - const amount = watch('amount'); - - return ( -
mutation.mutate(data))}> -
-
-
- - setValue('address', asset.address, { - shouldDirty: true, - shouldTouch: true, - }) - } - className={errors.address ? 'border-red-700 bg-red-100' : ''} - size="sm" - /> -
-
-
{amount}%
- -
-
- -
-
-
-
Fee
-
0.99 USDC
-
-
-
Minimum Received
-
0.99 USDC
-
-
- -
- ); -}; -export default BackstopPoolForm; diff --git a/src/components/Pools/Backstop/Modal/Withdraw/Form/schema.ts b/src/components/Pools/Backstop/Modal/Withdraw/Form/schema.ts deleted file mode 100644 index 79f005ed..00000000 --- a/src/components/Pools/Backstop/Modal/Withdraw/Form/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Yup from 'yup'; -import { WithdrawBackstopPoolValues } from './types'; - -const schema = Yup.object().shape({ - amount: Yup.number().min(0).max(100).required(), - address: Yup.string().min(1).required(), -}); - -export default schema; diff --git a/src/components/Pools/Backstop/Modal/Withdraw/Form/types.ts b/src/components/Pools/Backstop/Modal/Withdraw/Form/types.ts deleted file mode 100644 index 0772f373..00000000 --- a/src/components/Pools/Backstop/Modal/Withdraw/Form/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type WithdrawBackstopPoolValues = { - amount: number; - address: string; -}; diff --git a/src/components/Pools/Backstop/Modal/Withdraw/Form/useBackstopPoolForm.ts b/src/components/Pools/Backstop/Modal/Withdraw/Form/useBackstopPoolForm.ts deleted file mode 100644 index 923e9a77..00000000 --- a/src/components/Pools/Backstop/Modal/Withdraw/Form/useBackstopPoolForm.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { useMutation } from '@tanstack/react-query'; -import { useForm } from 'react-hook-form'; -import schema from './schema'; -import { WithdrawBackstopPoolValues } from './types'; - -export const useBackstopPoolForm = () => { - const form = useForm({ - resolver: yupResolver(schema), - defaultValues: { - amount: 0, - address: '', - }, - }); - - const mutation = useMutation( - async (data) => { - console.log(data); - }, - { - onSuccess: () => undefined, - }, - ); - - return { form, mutation }; -}; diff --git a/src/components/Pools/Backstop/Modal/Withdraw/index.tsx b/src/components/Pools/Backstop/Modal/Withdraw/index.tsx deleted file mode 100644 index 6f18a882..00000000 --- a/src/components/Pools/Backstop/Modal/Withdraw/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { BackstopPool } from '../../../../../models/BackstopPool'; -import BackstopPoolForm from './Form'; - -export type WithdrawProps = { - pool: BackstopPool; -}; - -const Withdraw = ({ pool }: WithdrawProps): JSX.Element | null => { - return ( -
- -
- ); -}; -export default Withdraw; diff --git a/src/components/Pools/Backstop/Modal/index.tsx b/src/components/Pools/Backstop/Modal/index.tsx deleted file mode 100644 index 6b572319..00000000 --- a/src/components/Pools/Backstop/Modal/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Button, Modal } from 'react-daisyui'; -import { joinOn } from '../../../../helpers/array'; -import { BackstopPool } from '../../../../models/BackstopPool'; -import Deposit from './Deposit'; -import Withdraw from './Withdraw'; - -export type ModalProps = { - type?: 'deposit' | 'withdraw'; - pool?: BackstopPool; - onClose: () => void; -}; - -const BackstopPoolModal = ({ pool, type, onClose }: ModalProps): JSX.Element | null => { - const isDeposit = type === 'deposit'; - return ( - - - -

- {isDeposit ? 'Deposit' : 'Withdraw'}: {joinOn(pool?.assets, 'symbol')} -

-
- {!!pool && (isDeposit ? : )} -
- ); -}; -export default BackstopPoolModal; diff --git a/src/components/Pools/Backstop/Modals/index.tsx b/src/components/Pools/Backstop/Modals/index.tsx new file mode 100644 index 00000000..0d99e3b4 --- /dev/null +++ b/src/components/Pools/Backstop/Modals/index.tsx @@ -0,0 +1,30 @@ +import { FunctionalComponent } from 'preact'; +import { Modal } from 'react-daisyui'; +import { useModal } from '../../../../services/modal'; +import ModalCloseButton from '../../../Button/ModalClose'; +import AddLiquidity from '../AddLiquidity'; +import WithdrawLiquidity from '../Withdraw'; +import { LiquidityModalProps } from './types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const modalsUi: Record> = { + 2: AddLiquidity, + 3: WithdrawLiquidity, +}; + +const Modals = () => { + const [{ type, props }, setModal] = useModal(); + + const Component = type ? modalsUi[type] : undefined; + return ( + <> + + + setModal()} /> + + {Component ? : null} + + + ); +}; +export default Modals; diff --git a/src/components/Pools/Backstop/Modals/types.ts b/src/components/Pools/Backstop/Modals/types.ts new file mode 100644 index 00000000..515622f2 --- /dev/null +++ b/src/components/Pools/Backstop/Modals/types.ts @@ -0,0 +1,10 @@ +import { BackstopPool } from '../../../../models/BackstopPool'; + +export const ModalTypes = { + AddLiquidity: 2, + WithdrawLiquidity: 3, +}; + +export type LiquidityModalProps = { + data?: BackstopPool; +}; diff --git a/src/components/Pools/Backstop/Withdraw/index.tsx b/src/components/Pools/Backstop/Withdraw/index.tsx new file mode 100644 index 00000000..6ac57aaf --- /dev/null +++ b/src/components/Pools/Backstop/Withdraw/index.tsx @@ -0,0 +1,121 @@ +import { ArrowLeftIcon } from '@heroicons/react/24/outline'; +import { ChangeEvent } from 'preact/compat'; +import { Button, Range } from 'react-daisyui'; +import { PoolProgress } from '../..'; +import { calcSharePercentage } from '../../../../helpers/calc'; +import { BackstopPool } from '../../../../models/BackstopPool'; +import { nativeToDecimal } from '../../../../shared/parseNumbers'; +import { numberLoader } from '../../../Loader'; +import TransactionProgress from '../../../Transaction/Progress'; +import { useWithdrawLiquidity } from './useWithdrawLiquidity'; + +export type WithdrawLiquidityProps = { + data: BackstopPool; +}; + +const WithdrawLiquidity = ({ data }: WithdrawLiquidityProps): JSX.Element | null => { + const { + toggle, + mutation, + balanceQuery, + depositQuery, + form: { register, handleSubmit, setValue, watch }, + } = useWithdrawLiquidity(data.address, data.asset.address); + const amount = Number(watch('amount') || 0); + const balance = balanceQuery.balance || 0; + const deposit = depositQuery.balance || 0; + + const hideCss = mutation.isLoading ? 'hidden' : ''; + return ( +
+ + + +
+ +

Withdraw {data.asset.symbol}

+
+
+
mutation.mutate(data))}> +
+

+ Deposited: {depositQuery.isLoading ? numberLoader : `${depositQuery.formatted || 0} ${data.asset.symbol}`} +

+

+ Balance: {balanceQuery.isLoading ? numberLoader : `${balanceQuery.formatted || 0} ${data.asset.symbol}`} +

+
+
+
+
+ + +
+
+ ) => + setValue('amount', (Number(ev.currentTarget.value) / 100) * deposit, { + shouldDirty: true, + shouldTouch: false, + }) + } + /> +
+
+
+
Fee
+
{'! TODO'}
+
+
+
Remaining deposit
+
{deposit - amount || 0}
+
+
+
Remaining pool share
+
+ {calcSharePercentage(nativeToDecimal(data.totalSupply || 0).toNumber() - amount, deposit - amount)}% +
+
+
+ + +
+
+
+ ); +}; +export default WithdrawLiquidity; diff --git a/src/components/Pools/Backstop/Withdraw/schema.ts b/src/components/Pools/Backstop/Withdraw/schema.ts new file mode 100644 index 00000000..6040d872 --- /dev/null +++ b/src/components/Pools/Backstop/Withdraw/schema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup'; +import { transformNumber } from '../../../../helpers/yup'; +import { WithdrawLiquidityValues } from './types'; + +const schema = Yup.object().shape({ + amount: Yup.number().positive().required().transform(transformNumber), +}); + +export default schema; diff --git a/src/components/Pools/Backstop/Withdraw/types.ts b/src/components/Pools/Backstop/Withdraw/types.ts new file mode 100644 index 00000000..5780abd9 --- /dev/null +++ b/src/components/Pools/Backstop/Withdraw/types.ts @@ -0,0 +1,3 @@ +export type WithdrawLiquidityValues = { + amount: number; +}; diff --git a/src/components/Pools/Backstop/Withdraw/useWithdrawLiquidity.ts b/src/components/Pools/Backstop/Withdraw/useWithdrawLiquidity.ts new file mode 100644 index 00000000..53557f56 --- /dev/null +++ b/src/components/Pools/Backstop/Withdraw/useWithdrawLiquidity.ts @@ -0,0 +1,44 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { backstopPoolAbi } from '../../../../contracts/nabla/BackstopPool'; +import { calcPercentage } from '../../../../helpers/calc'; +import { createWriteOptions } from '../../../../services/api/helpers'; +import { useModalToggle } from '../../../../services/modal'; +import { decimalToNative } from '../../../../shared/parseNumbers'; +import { useContractBalance } from '../../../../shared/useContractBalance'; +import { useContractWrite } from '../../../../shared/useContractWrite'; +import schema from './schema'; +import { WithdrawLiquidityValues } from './types'; + +export const useWithdrawLiquidity = (poolAddress: string, tokenAddress: string) => { + const toggle = useModalToggle(); + + const balanceQuery = useContractBalance({ contractAddress: tokenAddress }); + const depositQuery = useContractBalance({ contractAddress: poolAddress }); + + const form = useForm({ + resolver: yupResolver(schema), + defaultValues: {}, + }); + + const mutation = useContractWrite({ + abi: backstopPoolAbi, + address: poolAddress, + fn: ({ contract, api }, variables: WithdrawLiquidityValues) => + contract.tx.withdraw( + createWriteOptions(api), + decimalToNative(calcPercentage(variables.amount, 0.01)).toString(), + decimalToNative(variables.amount).toString(), + ), + onError: () => { + // TODO: handle error + }, + onSuccess: () => { + // TODO: wait for transaction to complete + balanceQuery.refetch(); + depositQuery.refetch(); + }, + }); + + return { form, mutation, toggle, balanceQuery, depositQuery }; +}; diff --git a/src/components/Pools/Backstop/index.tsx b/src/components/Pools/Backstop/index.tsx index b71859a0..9bd255d6 100644 --- a/src/components/Pools/Backstop/index.tsx +++ b/src/components/Pools/Backstop/index.tsx @@ -1,42 +1,75 @@ import { useQuery } from '@tanstack/react-query'; -import { useState } from 'preact/compat'; import { Button, Card } from 'react-daisyui'; +import { useGlobalState } from '../../../GlobalStateProvider'; import { cacheKeys } from '../../../constants/cache'; -import { BackstopPool as IBackstopPool } from '../../../models/BackstopPool'; +import { BackstopPool } from '../../../models/BackstopPool'; +import { backstopPool } from '../../../services/mocks'; +import ModalProvider, { useModalToggle } from '../../../services/modal'; +import Balance from '../../Balance'; import { Skeleton } from '../../Skeleton'; -import BackstopPoolModal from './Modal'; +import Modals from './Modals'; +import { LiquidityModalProps, ModalTypes } from './Modals/types'; -const BackstopPools = (): JSX.Element | null => { - const [selected, setSelected] = useState<[IBackstopPool, 'deposit' | 'withdraw']>(); - // ! TODO: get backstop pool and info - const { data, isLoading } = useQuery([cacheKeys.backstopPools], () => []); +const BackstopPoolsBody = (): JSX.Element | null => { + const toggle = useModalToggle(); + const { tenantName } = useGlobalState(); + const { data, isLoading } = useQuery([cacheKeys.backstopPools, tenantName], () => { + return backstopPool; + }); if (isLoading) return ; const pool = data?.[0]; - if (!pool) return null; + if (!pool) return null; // TODO: empty state UI return ( <> -
+
-
+

My pool balance

-
$0.78
+
+ +
- -
- setSelected(undefined)} /> + ); }; -export default BackstopPools; +const BactopPools = (): JSX.Element | null => { + return ( + + + + ); +}; + +export default BactopPools; diff --git a/src/components/Pools/Swap/AddLiquidity/index.tsx b/src/components/Pools/Swap/AddLiquidity/index.tsx index b707e774..665a2da5 100644 --- a/src/components/Pools/Swap/AddLiquidity/index.tsx +++ b/src/components/Pools/Swap/AddLiquidity/index.tsx @@ -1,8 +1,11 @@ import { ArrowLeftIcon } from '@heroicons/react/24/outline'; import { Button } from 'react-daisyui'; -import pendulumIcon from '../../../../assets/pendulum-icon.svg'; -import Spinner from '../../../../assets/spinner'; -import { ModalTypes } from '../Modals/types'; +import { PoolProgress } from '../..'; +import { calcSharePercentage } from '../../../../helpers/calc'; +import { nativeToDecimal } from '../../../../shared/parseNumbers'; +import TokenApproval from '../../../Asset/Approval'; +import { numberLoader } from '../../../Loader'; +import TransactionProgress from '../../../Transaction/Progress'; import { SwapPoolColumn } from '../columns'; import { useAddLiquidity } from './useAddLiquidity'; @@ -11,115 +14,106 @@ export interface AddLiquidityProps { } const AddLiquidity = ({ data }: AddLiquidityProps): JSX.Element | null => { - // ! TODO: get pool stats, create add liquidity transaction const { toggle, mutation, - form: { register, handleSubmit, getValues }, - } = useAddLiquidity(data.asset.address); - const deposited = 0; - const balance = 120.53; + balanceQuery, + depositQuery, + form: { register, handleSubmit, setValue, watch }, + } = useAddLiquidity(data.address, data.asset.address); + const amount = Number(watch('amount') || 0); + const balance = balanceQuery.balance || 0; + const deposit = depositQuery.balance || 0; - const hideCss = mutation.isLoading ? 'hidden' : ''; + const hideCss = !mutation.isIdle ? 'hidden' : ''; return ( -
- {mutation.isLoading ? ( - <> -
- -

Waiting for Confirmation

-

Please confirm this transaction in your wallet

-
-
-
-
- Pendulum -
-
- {data.asset.symbol} -
-
-
{getValues('amount')}
-
- - ) : null} +
+ + +
-

Confirm deposit

-
mutation.mutate(data))}> -
-
-

- Deposited: {deposited} {data.asset?.symbol} -

-

- Balance: {balance} {data.asset?.symbol} -

+
+ mutation.mutate(data))}> +
+
+

+ Deposited:{' '} + {depositQuery.isLoading ? numberLoader : `${depositQuery.formatted || 0} ${data.asset.symbol}`} +

+

+ Balance: {balanceQuery.isLoading ? numberLoader : `${balanceQuery.formatted || 0} ${data.asset.symbol}`} +

+
+
+ + +
+
+
+
+
Fee
+
{'! TODO'}
+
+
+
Total deposit
+
{depositQuery.isLoading ? numberLoader : `${deposit + amount} ${data.asset.symbol}`}
+
+
+
Pool Share
+
+ {depositQuery.isLoading + ? numberLoader + : calcSharePercentage(nativeToDecimal(data.totalSupply || 0).toNumber() + amount, deposit + amount)} + % +
+
-
- undefined })} - /> +
+ 0} + > + +
-
-
-
-
Effective Deposit
-
0.99 USDC
-
-
-
Fee / Penalty
-
0.99 USDC
-
-
-
My Total Deposits
-
0.99 USDC
-
-
-
Pool Share
-
0.99 USDC
-
-
-
- - -
- + +
); }; diff --git a/src/components/Pools/Swap/AddLiquidity/useAddLiquidity.ts b/src/components/Pools/Swap/AddLiquidity/useAddLiquidity.ts index 3dff6d5b..962fc240 100644 --- a/src/components/Pools/Swap/AddLiquidity/useAddLiquidity.ts +++ b/src/components/Pools/Swap/AddLiquidity/useAddLiquidity.ts @@ -1,30 +1,35 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; +import { swapPoolAbi } from '../../../../contracts/nabla/SwapPool'; +import { createWriteOptions } from '../../../../services/api/helpers'; import { useModalToggle } from '../../../../services/modal'; +import { decimalToNative } from '../../../../shared/parseNumbers'; +import { useContractBalance } from '../../../../shared/useContractBalance'; +import { useContractWrite } from '../../../../shared/useContractWrite'; import schema from './schema'; import { AddLiquidityValues } from './types'; -export const useAddLiquidity = (_address: string) => { +export const useAddLiquidity = (poolAddress: string, tokenAddress: string) => { const toggle = useModalToggle(); + const balanceQuery = useContractBalance({ contractAddress: tokenAddress }); + const depositQuery = useContractBalance({ contractAddress: poolAddress }); + const form = useForm({ resolver: yupResolver(schema), - defaultValues: { - amount: 0, - }, + defaultValues: {}, }); - const mutation = useMutation( - async (data) => { - console.log(data); + const mutation = useContractWrite({ + abi: swapPoolAbi, + address: poolAddress, + fn: ({ contract, api }, variables: AddLiquidityValues) => + contract.tx.deposit(createWriteOptions(api), decimalToNative(variables.amount).toString()), + onSuccess: () => { + balanceQuery.refetch(); + depositQuery.refetch(); }, - { - onSuccess: () => { - toggle(); - }, - }, - ); + }); - return { form, mutation, toggle }; + return { form, mutation, toggle, balanceQuery, depositQuery }; }; diff --git a/src/components/Pools/Swap/Modals/index.tsx b/src/components/Pools/Swap/Modals/index.tsx index a7267a2b..5bdf8ae4 100644 --- a/src/components/Pools/Swap/Modals/index.tsx +++ b/src/components/Pools/Swap/Modals/index.tsx @@ -1,14 +1,13 @@ import { FunctionalComponent } from 'preact'; -import { Button, Modal } from 'react-daisyui'; +import { Modal } from 'react-daisyui'; import { useModal } from '../../../../services/modal'; +import ModalCloseButton from '../../../Button/ModalClose'; import AddLiquidity from '../AddLiquidity'; -import PoolOverview from '../Overview'; import WithdrawLiquidity from '../WithdrawLiquidity'; import { LiquidityModalProps } from './types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const modalsUi: Record> = { - 1: PoolOverview, 2: AddLiquidity, 3: WithdrawLiquidity, }; @@ -21,9 +20,7 @@ const PoolsModals = () => { <> - + setModal()} /> {Component ? : null} diff --git a/src/components/Pools/Swap/Modals/types.ts b/src/components/Pools/Swap/Modals/types.ts index 6c38f6f2..322834e5 100644 --- a/src/components/Pools/Swap/Modals/types.ts +++ b/src/components/Pools/Swap/Modals/types.ts @@ -1,7 +1,6 @@ import { SwapPoolColumn } from '../columns'; export const ModalTypes = { - Overview: 1, AddLiquidity: 2, WithdrawLiquidity: 3, }; diff --git a/src/components/Pools/Swap/Overview/index.tsx b/src/components/Pools/Swap/Overview/index.tsx deleted file mode 100644 index 63295bfb..00000000 --- a/src/components/Pools/Swap/Overview/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Button } from 'react-daisyui'; -import { useModalToggle } from '../../../../services/modal'; -import { ModalTypes } from '../Modals/types'; -import { SwapPoolColumn } from '../columns'; - -export type PoolOverviewProps = { - data: SwapPoolColumn; -}; - -const PoolOverview = ({ data }: PoolOverviewProps) => { - // ! TODO: get pool info and stats, get user pool liquidity, get user asset wallet balance - const toggle = useModalToggle(); - const totalBalance = 0.78; - - return ( - <> -
-

My Pool Balance

-
-
-
- - {totalBalance} {data.asset.symbol} - -
-
-
TODO: name, logo, stats - earned fees, deposited date, TVL, APR
- - - - ); -}; - -export default PoolOverview; diff --git a/src/components/Pools/Swap/WithdrawLiquidity/index.tsx b/src/components/Pools/Swap/WithdrawLiquidity/index.tsx index f64ac7b3..a213dbbe 100644 --- a/src/components/Pools/Swap/WithdrawLiquidity/index.tsx +++ b/src/components/Pools/Swap/WithdrawLiquidity/index.tsx @@ -1,8 +1,11 @@ import { ArrowLeftIcon } from '@heroicons/react/24/outline'; +import { ChangeEvent } from 'preact/compat'; import { Button, Range } from 'react-daisyui'; -import pendulumIcon from '../../../../assets/pendulum-icon.svg'; -import Spinner from '../../../../assets/spinner'; -import { ModalTypes } from '../Modals/types'; +import { PoolProgress } from '../..'; +import { calcSharePercentage } from '../../../../helpers/calc'; +import { nativeToDecimal } from '../../../../shared/parseNumbers'; +import { numberLoader } from '../../../Loader'; +import TransactionProgress from '../../../Transaction/Progress'; import { SwapPoolColumn } from '../columns'; import { useWithdrawLiquidity } from './useWithdrawLiquidity'; @@ -11,63 +14,26 @@ export interface WithdrawLiquidityProps { } const WithdrawLiquidity = ({ data }: WithdrawLiquidityProps): JSX.Element | null => { - // ! TODO: get stats, create withdraw liquidity transaction const { toggle, mutation, - form: { register, handleSubmit, watch, setValue }, - } = useWithdrawLiquidity(data.asset.address); - const deposited = 0; - const balance = 120.53; - const amount = watch('amount'); + balanceQuery, + depositQuery, + form: { register, handleSubmit, setValue, watch }, + } = useWithdrawLiquidity(data.address, data.asset.address); + const amount = Number(watch('amount') || 0); + const balance = balanceQuery.balance || 0; + const deposit = depositQuery.balance || 0; - const hideCss = mutation.isLoading ? 'hidden' : ''; + const hideCss = !mutation.isIdle ? 'hidden' : ''; return ( -
- {mutation.isLoading ? ( - <> -
- -

Waiting for Confirmation

-

Please confirm this transaction in your wallet

-
-
-
-
- Pendulum -
-
- {data.asset.symbol} -
-
-
{amount}%
-
-
-
-
Fee
-
0.99 USDC
-
-
-
Minimum Received
-
0.99 USDC
-
-
- - ) : null} +
+ + +
-

Withdraw {data.asset?.symbol}

@@ -75,21 +41,28 @@ const WithdrawLiquidity = ({ data }: WithdrawLiquidityProps): JSX.Element | null
mutation.mutate(data))}>

- Deposited: {deposited} {data.asset?.symbol} + Deposited: {depositQuery.isLoading ? numberLoader : `${depositQuery.formatted || 0} ${data.asset.symbol}`}

- Balance: {balance} {data.asset?.symbol} + Balance: {balanceQuery.isLoading ? numberLoader : `${balanceQuery.formatted || 0} ${data.asset.symbol}`}

-
+
-
{amount}%
+
- + ) => + setValue('amount', (Number(ev.currentTarget.value) / 100) * deposit, { + shouldDirty: true, + shouldTouch: false, + }) + } + />
-
-
-
Amount deposit (after fee)
-
0.99 USDC
-
+
-
Fee / Penalty
-
0.99 USDC
-
-
-
Minimum Received
-
0.99 USDC
+
Fee
+
{'! TODO'}
-
My Remaining Deposit
-
0.99 USDC
+
Remaining deposit
+
{deposit - amount || 0}
-
My Remain Pool Share
-
0.99 USDC
+
Remaining pool share
+
+ {calcSharePercentage(nativeToDecimal(data.totalSupply || 0).toNumber() - amount, deposit - amount)}% +
+ + ); +}; +export default ApprovalSubmit; diff --git a/src/components/Swap/From/index.tsx b/src/components/Swap/From/index.tsx index f94d1bdf..c3471906 100644 --- a/src/components/Swap/From/index.tsx +++ b/src/components/Swap/From/index.tsx @@ -1,9 +1,13 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { Fragment } from 'preact'; +import { useMemo } from 'preact/compat'; import { Button } from 'react-daisyui'; import { useFormContext, useWatch } from 'react-hook-form'; import pendulumIcon from '../../../assets/pendulum-icon.svg'; -import { useBalance } from '../../../hooks/useBalance'; +import { nablaConfig } from '../../../config/apps/nabla'; +import { getAssets } from '../../../helpers/array'; +import { useGetTenantData } from '../../../hooks/useGetTenantData'; +import { useContractBalance } from '../../../shared/useContractBalance'; import TokenPrice from '../../Asset/Price'; import { SwapFormValues } from '../types'; @@ -13,13 +17,17 @@ export interface FromProps { } const From = ({ onOpenSelector, className }: FromProps): JSX.Element | null => { + const { assets } = useGetTenantData(nablaConfig) || {}; const { register, setValue, control } = useFormContext(); const from = useWatch({ control, name: 'from', }); - const token = { symbol: 'ETH', address: '6jceNg9gHuob4LBURVto44LtTsWBNpL2vHoUSa184FVcu57t' }; // ! TODO: get token info - const { balance } = useBalance(token.address); + const { [from]: token } = useMemo( + () => (from.length > 0 ? getAssets(assets || [], { [from]: true }) : {}), + [assets, from], + ); + const { formatted, balance } = useContractBalance({ contractAddress: token?.address }); return ( <>
@@ -41,7 +49,7 @@ const From = ({ onOpenSelector, className }: FromProps): JSX.Element | null => { Pendulum - {token.symbol} + {token?.symbol}
@@ -50,7 +58,7 @@ const From = ({ onOpenSelector, className }: FromProps): JSX.Element | null => {
{balance !== undefined && ( - Balance: {balance} + Balance: {formatted} - - - ); - } - } - +const SwapProgress = ({ onClose, children, mutation, ...rest }: SwapProgressProps): JSX.Element | null => { return ( - + - {ui} + + {!!mutation && ( + + {children} + + )} + ); }; -export default Progress; +export default SwapProgress; diff --git a/src/components/Swap/To/index.tsx b/src/components/Swap/To/index.tsx index 4729c936..333626a1 100644 --- a/src/components/Swap/To/index.tsx +++ b/src/components/Swap/To/index.tsx @@ -1,10 +1,17 @@ import { ArrowPathRoundedSquareIcon, ChevronDownIcon } from '@heroicons/react/20/solid'; import { InformationCircleIcon } from '@heroicons/react/24/outline'; +import { useMemo } from 'preact/compat'; import { Button } from 'react-daisyui'; import { useFormContext, useWatch } from 'react-hook-form'; import pendulumIcon from '../../../assets/pendulum-icon.svg'; +import { config } from '../../../config'; +import { nablaConfig } from '../../../config/apps/nabla'; +import { getAssets } from '../../../helpers/array'; +import { calcPercentage } from '../../../helpers/calc'; import useBoolean from '../../../hooks/useBoolean'; import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; +import { useGetTenantData } from '../../../hooks/useGetTenantData'; +import { roundNumber } from '../../../shared/parseNumbers'; import TokenPrice from '../../Asset/Price'; import Balance from '../../Balance'; import { Skeleton } from '../../Skeleton'; @@ -16,8 +23,9 @@ export interface ToProps { } const To = ({ onOpenSelector, className }: ToProps): JSX.Element | null => { + const { assets } = useGetTenantData(nablaConfig) || {}; const [isOpen, { toggle }] = useBoolean(); - const { setValue, control } = useFormContext(); + const { /* setValue, */ control } = useFormContext(); const from = useWatch({ control, name: 'from', @@ -39,14 +47,21 @@ const To = ({ onOpenSelector, className }: ToProps): JSX.Element | null => { name: 'slippage', }), ); - const token = { symbol: 'ETH', address: '6jceNg9gHuob4LBURVto44LtTsWBNpL2vHoUSa184FVcu57t' }; - const fromToken = { symbol: 'USDC', address: '6jceNg9gHuob4LBURVto44LtTsWBNpL2vHoUSa184FVcu57t' }; + const { [from]: fromToken, [to]: toToken } = useMemo( + () => + getAssets(assets || [], { + [from]: true, + [to]: true, + }), + [assets, from, to], + ); const debouncedFromAmount = useDebouncedValue(fromAmount, 800); const { isLoading, data, refetch } = { - data: 154.432, + data: debouncedFromAmount, isLoading: false, refetch: () => undefined, - }; /* useTokenOutAmount({ + }; + /* useTokenOutAmount({ chainId, amount: debouncedFromAmount, from, @@ -68,10 +83,12 @@ const To = ({ onOpenSelector, className }: ToProps): JSX.Element | null => { 10000 ) : value ? ( `${value}` - ) : ( - + ) : ( + <>  )}
-
{!!token && }
+
{!!toToken && }
- Balance: + Balance:
-
- -
- 1 USDC = 0.00 ETH ($1.00) + {fromToken && toToken && value && fromAmount ? ( + <> +
+ +
+ {`1${fromToken.symbol} = ${roundNumber(Number(value) / fromAmount, 6)}${toToken.symbol}`} + + ) : null}
- {'! TODO'} - +
+ +
@@ -115,17 +142,21 @@ const To = ({ onOpenSelector, className }: ToProps): JSX.Element | null => {
Expected Output:
- {value} {token.symbol} + {value} {toToken?.symbol}
-
Price Impact:
-
{'! TODO'}%
+
Minimum received:
+
+ + {calcPercentage(Number(value), slippage ?? config.swap.defaults.slippage)} {toToken.symbol} + +
-
Minimum received after slippage (0.56%):
-
{'! TODO'} USDC
+
Price Impact:
+
{'! TODO'}%
Network Fee:
diff --git a/src/components/Swap/index.tsx b/src/components/Swap/index.tsx index b1e97ae7..58741e22 100644 --- a/src/components/Swap/index.tsx +++ b/src/components/Swap/index.tsx @@ -4,8 +4,9 @@ import { Button, Card, Dropdown, Input } from 'react-daisyui'; import { FormProvider } from 'react-hook-form'; import { errorClass } from '../../helpers/form'; import { AssetSelectorModal } from '../Asset/Selector/Modal'; +import ApprovalSubmit from './Approval'; import From from './From'; -import Progress from './Progress'; +import SwapProgress from './Progress'; import To from './To'; import { UseSwapComponentProps, useSwapComponent } from './useSwapComponent'; @@ -13,35 +14,43 @@ const inputCls = 'bg-neutral-100 dark:bg-neutral-900 text-right text-neutral-600 const Swap = (props: UseSwapComponentProps): JSX.Element | null => { const { + assets, tokensModal: [modalType, setModalType], onFromChange, onToChange, + swapMutation, form, + from, updateStorage, - onSubmit, - swapMutation, + progressClose, } = useSwapComponent(props); const { setValue, - getValues, + handleSubmit, register, + getValues, formState: { errors }, } = form; - const isSwapLoading = swapMutation.status === 'loading'; - const progressUi = useMemo( - () => - isSwapLoading - ? `Swapping ${getValues('from')} ${getValues().from} for ${getValues().toAmount} ${getValues().to}` - : null, - [getValues, isSwapLoading], - ); + const progressUi = useMemo(() => { + if (!swapMutation.isLoading) return ''; + const { from: fromV, to: toV, fromAmount = 0, toAmount = 0 } = getValues(); + // TODO: optimize finding tokens with object map + const fromAsset = assets?.find((x) => x.address === fromV); + const toAsset = assets?.find((x) => x.address === toV); + return ( +

{`Swapping ${fromAmount} ${fromAsset?.symbol} for ${toAmount} ${toAsset?.symbol}`}

+ ); + }, [assets, getValues, swapMutation.isLoading]); return ( <> - + swapMutation.mutate(data))} + >
Swap @@ -133,28 +142,21 @@ const Swap = (props: UseSwapComponentProps): JSX.Element | null => { />
{/* */} - +
setModalType(undefined)} /> - + {progressUi} - + ); }; diff --git a/src/components/Swap/schema.ts b/src/components/Swap/schema.ts index 17036abf..089efa37 100644 --- a/src/components/Swap/schema.ts +++ b/src/components/Swap/schema.ts @@ -1,12 +1,14 @@ import * as Yup from 'yup'; +import { transformNumber } from '../../helpers/yup'; import { SwapFormValues } from './types'; const schema = Yup.object().shape({ - from: Yup.string().required(), - fromAmount: Yup.number().positive().required(), - to: Yup.string().required(), - //slippage: Yup.number().nullable(), - //deadline: Yup.number().nullable(), + from: Yup.string().min(3).required(), + fromAmount: Yup.number().positive().required().transform(transformNumber), + to: Yup.string().min(3).required(), + toAmount: Yup.number().positive().required(), + slippage: Yup.number().nullable().transform(transformNumber), + deadline: Yup.number().nullable().transform(transformNumber), }); export default schema; diff --git a/src/components/Swap/useSwapComponent.ts b/src/components/Swap/useSwapComponent.ts index 362be4ba..62cf0429 100644 --- a/src/components/Swap/useSwapComponent.ts +++ b/src/components/Swap/useSwapComponent.ts @@ -1,14 +1,21 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { useMutation } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/compat'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef, useState } from 'preact/compat'; import { Resolver, useForm, useWatch } from 'react-hook-form'; -import { useGlobalState } from '../../GlobalStateProvider'; import { config } from '../../config'; +import { nablaConfig } from '../../config/apps/nabla'; +import { cacheKeys } from '../../constants/cache'; import { storageKeys } from '../../constants/localStorage'; +import { routerAbi } from '../../contracts/nabla/Router'; +import { calcPercentage } from '../../helpers/calc'; import { debounce } from '../../helpers/function'; +import { useGetTenantData } from '../../hooks/useGetTenantData'; import { Asset } from '../../models/Asset'; import { SwapSettings } from '../../models/Swap'; +import { createWriteOptions } from '../../services/api/helpers'; import { storageService } from '../../services/storage/local'; +import { decimalToNative } from '../../shared/parseNumbers'; +import { useContractWrite } from '../../shared/useContractWrite'; import schema from './schema'; import { SwapFormValues } from './types'; @@ -27,9 +34,10 @@ const storageSet = debounce(storageService.set, 1000); export const useSwapComponent = (props: UseSwapComponentProps) => { const { onChange } = props; + const { assets } = useGetTenantData(nablaConfig) || {}; const hadMountedRef = useRef(false); - const { walletAccount } = useGlobalState(); - const { address } = walletAccount || {}; + const queryClient = useQueryClient(); + const { router } = useGetTenantData(nablaConfig) || {}; const tokensModal = useState(); const setTokenModal = tokensModal[1]; const storageState = useRef(getInitialValues()); @@ -44,7 +52,7 @@ export const useSwapComponent = (props: UseSwapComponentProps) => { resolver: yupResolver(schema) as Resolver, defaultValues: defaultFormValues, }); - const { setValue, reset, getValues, handleSubmit, control } = form; + const { setValue, reset, getValues, control } = form; const from = useWatch({ control, name: 'from', @@ -64,37 +72,38 @@ export const useSwapComponent = (props: UseSwapComponentProps) => { [getValues], ); - const swapMutation = useMutation( - async (data) => new Promise((r) => setTimeout(() => r(data), 6500)), - { - onError: () => { - // ! TODO: display error to user - }, - onSuccess: () => { - reset(); - // ! TODO: display response - // ! update balances - }, + const swapMutation = useContractWrite({ + abi: routerAbi, // ? should be chain specific + address: router, + fn: ({ contract, address, api }, variables: SwapFormValues) => { + // ! TODO: complete and test + const time = Math.floor(Date.now() / 1000) + variables.deadline; + const deadline = decimalToNative(time); + const slippage = variables.slippage ?? defaultValues.slippage; + const fromAmount = decimalToNative(variables.fromAmount).toString(); + const toMinAmount = decimalToNative(calcPercentage(variables.toAmount, slippage)).toString(); + const spender = address; + return contract.tx.swapExactTokensForTokens( + createWriteOptions(api), + spender, + fromAmount, + toMinAmount, + [variables.from, variables.to], + address, + deadline, + ); }, - ); - const { mutate: swap } = swapMutation; - - const onSubmit = useMemo( - () => - handleSubmit(async (data) => { - if (!address) return; // TODO: error alert - console.log(data, swap); - /* const time = Math.floor(Date.now() / 1000) + data.deadline; - const deadline = BigNumber.from(time).toBigInt(); - const slippage = data.slippage ?? defaultValues.slippage; - const fromAmount = parseEther(`${data.fromAmount}`); - const toMinAmount = parseEther(`${calcPercentage(data.toAmount, slippage)}`); - swap({ - args: [fromAmount, toMinAmount, [data.from, data.to], address, deadline], - }); */ - }), - [address, handleSubmit, swap], - ); + onError: () => { + // ? log error + }, + onSuccess: () => { + // update token balances + queryClient.refetchQueries({ queryKey: [cacheKeys.walletBalance, getValues('from')], type: 'active' }); + queryClient.refetchQueries({ queryKey: [cacheKeys.walletBalance, getValues('to')], type: 'active' }); + // reset form + reset(); + }, + }); const onFromChange = useCallback( (a: string | Asset, event = true) => { @@ -147,13 +156,16 @@ export const useSwapComponent = (props: UseSwapComponentProps) => { return { form, + assets, swapMutation, tokensModal, onFromChange, onToChange, onReverse, - onSubmit, updateStorage, from, + progressClose: () => { + swapMutation.reset(); + }, }; }; diff --git a/src/components/Table/GlobalFilter/index.tsx b/src/components/Table/GlobalFilter/index.tsx index 56f70f0d..f226c486 100644 --- a/src/components/Table/GlobalFilter/index.tsx +++ b/src/components/Table/GlobalFilter/index.tsx @@ -30,7 +30,13 @@ export const GlobalFilter = ({ globalFilter, setGlobalFilter }: GlobalFilterProp defaultValue={globalFilter} placeholder="Search..." /> - diff --git a/src/components/Transaction/Progress/index.tsx b/src/components/Transaction/Progress/index.tsx new file mode 100644 index 00000000..1d6b0dcd --- /dev/null +++ b/src/components/Transaction/Progress/index.tsx @@ -0,0 +1,65 @@ +import { CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { UseMutationResult } from '@tanstack/react-query'; +import { ComponentChildren } from 'preact'; +import { Button } from 'react-daisyui'; +import Spinner from '../../../assets/spinner'; +import { TransactionsStatus } from '../../../shared/useContractWrite'; + +export interface TransactionProgressProps { + mutation: Pick< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + UseMutationResult, + 'isIdle' | 'isLoading' | 'isSuccess' | 'isError' | 'data' + >; + children?: ComponentChildren; + onClose: () => void; +} + +const TransactionProgress = ({ mutation, children, onClose }: TransactionProgressProps): JSX.Element | null => { + if (mutation.isIdle) return null; + if (mutation.isLoading) { + return ( + <> +
+ +

Waiting for confirmation

+

Please confirm this transaction in your wallet

+
+ {children} + + ); + } + return ( + <> +
+ {mutation.isSuccess ? ( + + ) : ( + + )} +
+
+

+ {mutation.isSuccess ? 'Transaction successfull' : 'Transaction failed'} +

+
+ {children} + {!!onClose && ( + + )} + + + ); +}; +export default TransactionProgress; diff --git a/src/components/Wallet/index.tsx b/src/components/Wallet/index.tsx index a6d17047..a2da1ab9 100644 --- a/src/components/Wallet/index.tsx +++ b/src/components/Wallet/index.tsx @@ -4,7 +4,7 @@ import { Button, Dropdown } from 'react-daisyui'; import { useGlobalState } from '../../GlobalStateProvider'; import { useNodeInfoState } from '../../NodeInfoProvider'; import { getAddressForFormat, trimAddress } from '../../helpers/addressFormatter'; -import { useAccountBalance } from '../../hooks/useAccountBalance'; +import { useAccountBalance } from '../../shared/useAccountBalance'; import { Skeleton } from '../Skeleton'; import WalletConnect from './WalletConnect'; diff --git a/src/config/apps/index.ts b/src/config/apps/index.ts new file mode 100644 index 00000000..86db7bea --- /dev/null +++ b/src/config/apps/index.ts @@ -0,0 +1,9 @@ +import { nablaConfig } from './nabla'; +import { AppConfig } from './types'; + +export const apps = 'nabla'; +export type Apps = typeof apps; + +export const appsConfigs = { + nabla: nablaConfig, +} satisfies Record; diff --git a/src/config/apps/nabla.ts b/src/config/apps/nabla.ts new file mode 100644 index 00000000..aa42b8fd --- /dev/null +++ b/src/config/apps/nabla.ts @@ -0,0 +1,68 @@ +import { Asset } from '../../models/Asset'; +import { SwapPool } from '../../models/SwapPool'; +import { TenantName } from '../../models/Tenant'; +import type { AppConfig } from './types'; + +export type NablaConfig = AppConfig & + Partial< + Record< + TenantName, + { + router: string; + oracle: string; + curve: string; + backstopPool: string; // ! temporary (should come from indexer) + swapPools: SwapPool[]; // ! temporary (should come from indexer) + assets: Asset[]; // ! temporary (should come from indexer) + } + > + >; + +const assets = [ + { + address: '6h6JMHYBV7P6uQekZXzHmmpzx7tzHutTyx448MnFogR6dNde', + name: 'Mock USD', + symbol: 'mUSD', + decimals: 12, + }, + { + address: '6hJHAXrCYNFbuDzgwZb2S1UhamDBtR8Jw1cn9gKQ4QewvSh1', + name: 'Mock EUR', + symbol: 'mEUR', + decimals: 12, + }, + { + address: '6kwiNhGTHAfGb5x3gZBQxhiG2rf9F8W7Da3HhQRdBHQopSHv', + name: 'Mock ETH', + symbol: 'mETH', + decimals: 12, + }, +]; + +export const nablaConfig = { + tenants: [TenantName.Foucoco], + foucoco: { + router: '6mrTyH54tYXKsVxrahapG1S54cVMqqwqtnmTLLbj3NZT2f1k', + oracle: '6n32n4F11qfFXfFYhVj15fChZTXpVP5zJSM98361gK5QKrxW', + curve: '6mnENTpY6B5mqtUHsjv3BxwKucT9hqF761QrYGfD22ccLzdC', + backstopPool: '6h7p67AZyzWiN42FSzkWyGZaqMuajo2BAm43LXBQHVXJ8sq7', + assets: assets, + swapPools: [ + { + address: '6gxRBjkhfaWMAhMQmEA1MUvGssc2f9ercXPZrzFUKWTTaCyq', + asset: assets[0], + apr: 0.0, + }, + { + address: '6kauoQTrdZzBCR3RcqJKJwxEGeQyj6zd3yx8H7XBNwbzrcT5', + asset: assets[1], + apr: 0.0, + }, + { + address: '6mMDtTPgghASfTpW4cuwdxSJvuM6mvGMxTHZxXQf9cWVUioS', + asset: assets[2], + apr: 0.0, + }, + ], + }, +} satisfies NablaConfig; diff --git a/src/config/apps/types.ts b/src/config/apps/types.ts new file mode 100644 index 00000000..f6884153 --- /dev/null +++ b/src/config/apps/types.ts @@ -0,0 +1,5 @@ +import { TenantName } from '../../models/Tenant'; + +export type AppConfig = { + tenants: TenantName[]; +}; diff --git a/src/config/index.ts b/src/config/index.ts index 81b1610a..4a826095 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -10,35 +10,41 @@ type Tenants = Record< } >; +const env = process.env.NODE_ENV; + export const config = { + env, + isProd: env === 'production', + isDev: env === 'development', defaultPage: '/pendulum/dashboard', tenants: { [TenantName.Amplitude]: { name: 'Amplitude', rpc: 'wss://rpc-amplitude.pendulumchain.tech', theme: ThemeName.Amplitude, + explorer: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frpc-foucoco.pendulumchain.tech#/explorer/', }, [TenantName.Pendulum]: { name: 'Pendulum', rpc: 'wss://rpc-pendulum.prd.pendulumchain.tech', theme: ThemeName.Pendulum, + explorer: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frpc-foucoco.pendulumchain.tech#/explorer/', }, [TenantName.Foucoco]: { name: 'Foucoco', rpc: 'wss://rpc-foucoco.pendulumchain.tech', theme: ThemeName.Amplitude, + explorer: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frpc-foucoco.pendulumchain.tech#/explorer/query', }, [TenantName.Local]: { name: 'Local', rpc: 'ws://localhost:9944', theme: ThemeName.Amplitude, + explorer: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frpc-foucoco.pendulumchain.tech#/explorer/', }, } as Tenants, swap: { defaults: { - // ! TODO: update to address - from: 'ETH', - to: 'USDC', slippage: 0.5, deadline: 30, }, diff --git a/src/constants/cache.ts b/src/constants/cache.ts index 520734fe..7804a383 100644 --- a/src/constants/cache.ts +++ b/src/constants/cache.ts @@ -6,6 +6,7 @@ export const cacheKeys = { swapData: 'swapData', swapPools: 'swapPools', tokens: 'tokens', + tokenAllowance: 'tokenAllowance', balance: 'balance', walletBalance: 'walletBalance', walletBalances: 'walletBalances', @@ -22,7 +23,6 @@ const getOptions = cacheTime: time, staleTime: time, retry: 2, - //refetchOnMount: active, refetchOnReconnect: active, refetchOnWindowFocus: active, }); @@ -36,6 +36,7 @@ export const activeOptions = { '1m': getActiveOptions(60000), '30s': getActiveOptions(30000), '15s': getActiveOptions(15000), + '3s': getActiveOptions(3000), '0': getActiveOptions(0), }; export const getInactiveOptions = getOptions(false); @@ -47,5 +48,6 @@ export const inactiveOptions = { '1m': getInactiveOptions(60000), '30s': getInactiveOptions(30000), '15s': getInactiveOptions(15000), + '3s': getInactiveOptions(3000), '0': getInactiveOptions(0), }; diff --git a/src/constants/colors.ts b/src/constants/colors.ts new file mode 100644 index 00000000..04935efa --- /dev/null +++ b/src/constants/colors.ts @@ -0,0 +1,26 @@ +export const colors = { + whiteAlpha: { + 50: 'rgba(255, 255, 255, 0.04)', + 100: 'rgba(255, 255, 255, 0.06)', + 200: 'rgba(255, 255, 255, 0.08)', + 300: 'rgba(255, 255, 255, 0.16)', + 400: 'rgba(255, 255, 255, 0.24)', + 500: 'rgba(255, 255, 255, 0.36)', + 600: 'rgba(255, 255, 255, 0.48)', + 700: 'rgba(255, 255, 255, 0.64)', + 800: 'rgba(255, 255, 255, 0.80)', + 900: 'rgba(255, 255, 255, 0.92)', + }, + blackAlpha: { + 50: 'rgba(0, 0, 0, 0.04)', + 100: 'rgba(0, 0, 0, 0.06)', + 200: 'rgba(0, 0, 0, 0.08)', + 300: 'rgba(0, 0, 0, 0.16)', + 400: 'rgba(0, 0, 0, 0.24)', + 500: 'rgba(0, 0, 0, 0.36)', + 600: 'rgba(0, 0, 0, 0.48)', + 700: 'rgba(0, 0, 0, 0.64)', + 800: 'rgba(0, 0, 0, 0.80)', + 900: 'rgba(0, 0, 0, 0.92)', + }, +}; diff --git a/src/contracts/NablaAddresses.ts b/src/contracts/NablaAddresses.ts deleted file mode 100644 index 680e44ec..00000000 --- a/src/contracts/NablaAddresses.ts +++ /dev/null @@ -1,55 +0,0 @@ -export const addresses = { - foucoco: { - router: '6hsepFx79kt6ig5yRA3kRWFiUqEmXsa5ftRqzvRHHMxrXSJm', - oracle: '6iWCXGdWbjCeDm7Mx6cxKkQtcyPerq8xxxHZqk2uDgheWnNM', - curve: '6mNbisZq65VC2Y5vVAwxbPGvGGdqMg6x8WayRUUdNk3UGbC4', - backstopPool: '6koGpLFoFAbBRCPDBVpqsENKqPeDASTJL9UCoqh7wFLbS3Tf', - swapPools: [ - '6inUPTCwptpXa8GqiuzB9bFdszLn5hAC1381Lrfn4Ln8vWu9', - '6i3bUwzTUJyiCMo753P9zKo3t4g8JVEd3NCGtUW4Tn2u2G8q', - '6khvKsaX64PuyULLCGSRDVNGPqAL9jFkYFjjzZKMBu8akJhD', - ], - swapPoolsWithMeta: [ - { - address: '6inUPTCwptpXa8GqiuzB9bFdszLn5hAC1381Lrfn4Ln8vWu9', - asset: '6mohRoHcjMwgkWX8s4UHmvdcSWmuB58uXFSWghj2HgiM3B8Q', - apr: 0.0, - }, - { - address: '6i3bUwzTUJyiCMo753P9zKo3t4g8JVEd3NCGtUW4Tn2u2G8q', - asset: '6kX7XiYtzMK9UT6GQHxMqDFYwGu9N7LgHtEdbhd1s73cncNR', - apr: 0.0, - }, - { - address: '6khvKsaX64PuyULLCGSRDVNGPqAL9jFkYFjjzZKMBu8akJhD', - asset: '6nCMLKYGgiv4UvjK2dFaq3maZd7grhDYRJVEwUM2o14tTET1', - apr: 0.0, - }, - ], - tokens: [ - '6nCMLKYGgiv4UvjK2dFaq3maZd7grhDYRJVEwUM2o14tTET1', - '6mohRoHcjMwgkWX8s4UHmvdcSWmuB58uXFSWghj2HgiM3B8Q', - '6kX7XiYtzMK9UT6GQHxMqDFYwGu9N7LgHtEdbhd1s73cncNR', - ], - tokensWithMeta: [ - { - address: '6nCMLKYGgiv4UvjK2dFaq3maZd7grhDYRJVEwUM2o14tTET1', - name: 'USDC', - symbol: 'USDC', - decimals: 12, - }, - { - address: '6mohRoHcjMwgkWX8s4UHmvdcSWmuB58uXFSWghj2HgiM3B8Q', - name: 'EUR', - symbol: 'EUR', - decimals: 12, - }, - { - address: '6kX7XiYtzMK9UT6GQHxMqDFYwGu9N7LgHtEdbhd1s73cncNR', - name: 'ETH', - symbol: 'ETH', - decimals: 12, - }, - ], - }, -}; diff --git a/src/contracts/AmberCurve.ts b/src/contracts/nabla/AmberCurve.ts similarity index 100% rename from src/contracts/AmberCurve.ts rename to src/contracts/nabla/AmberCurve.ts diff --git a/src/contracts/BackstopPool.ts b/src/contracts/nabla/BackstopPool.ts similarity index 100% rename from src/contracts/BackstopPool.ts rename to src/contracts/nabla/BackstopPool.ts diff --git a/src/contracts/ChainlinkAdapter.ts b/src/contracts/nabla/ChainlinkAdapter.ts similarity index 100% rename from src/contracts/ChainlinkAdapter.ts rename to src/contracts/nabla/ChainlinkAdapter.ts diff --git a/src/contracts/MockERC20.ts b/src/contracts/nabla/MockERC20.ts similarity index 100% rename from src/contracts/MockERC20.ts rename to src/contracts/nabla/MockERC20.ts diff --git a/src/contracts/Router.ts b/src/contracts/nabla/Router.ts similarity index 100% rename from src/contracts/Router.ts rename to src/contracts/nabla/Router.ts diff --git a/src/contracts/SwapPool.ts b/src/contracts/nabla/SwapPool.ts similarity index 100% rename from src/contracts/SwapPool.ts rename to src/contracts/nabla/SwapPool.ts diff --git a/src/global.d.ts b/src/global.d.ts index 198bfe1c..9d537282 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1 +1,32 @@ +/** Empty value */ +declare type Empty = null | undefined; +/** Falsy value */ +declare type Falsy = false | 0 | '' | null | undefined; +/** Object */ declare type Dict = Record; +/** Object value types */ +declare type ValueOf = T[keyof T]; +/** Keys of type */ +declare type KeysOf = (keyof T)[]; +/** Keys with values of given type */ +declare type KeyOfType = { + [P in keyof T]-?: T[P] extends U ? P : never; +}[keyof T]; + +/** Value or undefined */ +declare type Maybe = T | undefined; +/** Value or null */ +declare type Nullable = T | null; +/** Partial or null object */ +declare type PartialNull = { [K in keyof T]: T[K] | null }; +/** Deep partial object */ +declare type DeepPartial = { + [P in keyof T]?: T[P] extends Array ? Array> : DeepPartial; +}; + +/** Any function */ +declare type Fn = (...args: any[]) => any; +/** Required function */ +declare type FnR = Exclude; +/** Is never */ +declare type IsNever = [T] extends [never] ? never : T; diff --git a/src/helpers/array.ts b/src/helpers/array.ts index 9804078c..03724711 100644 --- a/src/helpers/array.ts +++ b/src/helpers/array.ts @@ -1,3 +1,5 @@ +import { Asset } from '../models/Asset'; + export type MapFunction = (item: T) => string | undefined; /** * Join array of objects on key @@ -11,3 +13,13 @@ export const joinOn = ( const mapFunc = typeof mapKey === 'function' ? mapKey : (item: T) => (item[mapKey] ? String(item[mapKey]) : ''); return arr.map(mapFunc).filter(Boolean).join(separator); }; + +export const getAssets = (assets: Asset[], addresses: Record) => { + const result: Record = {}; + for (let i = 0; i < assets.length; i++) { + if (addresses[assets[i].address]) { + result[assets[i].address] = assets[i]; + } + } + return result; +}; diff --git a/src/helpers/calc.ts b/src/helpers/calc.ts index 1dfc5c7b..23d687f0 100644 --- a/src/helpers/calc.ts +++ b/src/helpers/calc.ts @@ -1,4 +1,4 @@ -import { roundNumber } from './parseNumbers'; +import { roundNumber } from '../shared/parseNumbers'; export type Percent = number; @@ -17,7 +17,7 @@ export function calcFiatValuePriceImpact( export const calcPercentage = (value = 0, percent = 0, round = 2) => roundNumber(value * (1 - percent / 100), round); /** Calculate share percentage */ -export const calcSharePercentage = (total = 0, share = 0, round = 2) => roundNumber((share / total) * 100, round); +export const calcSharePercentage = (total = 0, share = 0, round = 2) => roundNumber((share / total) * 100, round) || 0; /** Calculate pool APY (daily fee * 365 / TVL) */ export const calcAPR = (dailyFees: number, tvl: number, round = 2) => diff --git a/src/helpers/transaction.ts b/src/helpers/transaction.ts new file mode 100644 index 00000000..f4a2e5bb --- /dev/null +++ b/src/helpers/transaction.ts @@ -0,0 +1,6 @@ +import { toast } from 'react-toastify'; + +export const transactionErrorToast = (err: unknown) => { + const cancelled = String(err).startsWith('Error: Cancelled'); + toast(cancelled ? 'Transaction cancelled' : 'Transaction failed', { type: 'error' }); +}; diff --git a/src/helpers/url.ts b/src/helpers/url.ts new file mode 100644 index 00000000..58665d62 --- /dev/null +++ b/src/helpers/url.ts @@ -0,0 +1,5 @@ +import { TenantName } from '../models/Tenant'; + +export function buildTenantPath(current: TenantName | undefined, next: TenantName, location: string) { + return current ? location.replace(current, next) : location; +} diff --git a/src/helpers/yup.ts b/src/helpers/yup.ts new file mode 100644 index 00000000..42a57c47 --- /dev/null +++ b/src/helpers/yup.ts @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const transformNumber = (value: any, originalValue: any) => { + if (!originalValue) return value; + if (typeof originalValue === 'string') value = Number(originalValue) ?? 0; + return value; +}; diff --git a/src/hooks/spacewalk/fee.tsx b/src/hooks/spacewalk/fee.tsx index d1bebd6e..9eb2e846 100644 --- a/src/hooks/spacewalk/fee.tsx +++ b/src/hooks/spacewalk/fee.tsx @@ -3,7 +3,7 @@ import { SpacewalkPrimitivesCurrencyId } from '@polkadot/types/lookup'; import Big from 'big.js'; import { useEffect, useMemo, useState } from 'preact/hooks'; import { useNodeInfoState } from '../../NodeInfoProvider'; -import { fixedPointToDecimal } from '../../helpers/parseNumbers'; +import { fixedPointToDecimal } from '../../shared/parseNumbers'; export function useFeePallet() { const [issueFee, setIssueFee] = useState(new Big(0)); diff --git a/src/hooks/staking/staking.tsx b/src/hooks/staking/staking.tsx index 3579a40f..ac95ee52 100644 --- a/src/hooks/staking/staking.tsx +++ b/src/hooks/staking/staking.tsx @@ -1,7 +1,7 @@ import { SubmittableExtrinsic } from '@polkadot/api/promise/types'; import { Option } from '@polkadot/types-codec'; import Big from 'big.js'; -import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; import { useGlobalState } from '../../GlobalStateProvider'; import { useNodeInfoState } from '../../NodeInfoProvider'; import { getAddressForFormat } from '../../helpers/addressFormatter'; @@ -56,11 +56,11 @@ export function useStakingPallet() { const [estimatedRewards, setEstimatedRewards] = useState('0'); const [fees, setFees] = useState(defaultTransactionFees); - const fetchEstimatedReward = async () => { - if (!api || !walletAccount) return '0'; + const fetchEstimatedReward = useCallback(async () => { + if (!api || !walletAccount?.address) return '0'; const formattedAddr = ss58Format ? getAddressForFormat(walletAccount.address, ss58Format) : walletAccount.address; return (await api.query.parachainStaking.rewards(formattedAddr)).toString(); - }; + }, [api, ss58Format, walletAccount?.address]); useEffect(() => { if (!api) { @@ -119,7 +119,7 @@ export function useStakingPallet() { if (api.consts.parachainStaking?.minDelegatorStake) { setMinDelegatorStake((api.consts.parachainStaking.minDelegatorStake.toHuman() as string) || '0'); } - }, [api, walletAccount, walletAccount?.address, fees, ss58Format]); + }, [api, walletAccount, walletAccount?.address, fees, ss58Format, fetchEstimatedReward]); const memo = useMemo(() => { return { @@ -188,7 +188,7 @@ export function useStakingPallet() { return api.tx.utility.batch(txs); }, }; - }, [api, candidates, inflationInfo, fees, minDelegatorStake, estimatedRewards]); + }, [candidates, inflationInfo, minDelegatorStake, estimatedRewards, fees, fetchEstimatedReward, api]); return memo; } diff --git a/src/hooks/useAccountBalance.ts b/src/hooks/useAccountBalance.ts deleted file mode 100644 index 383fd1fc..00000000 --- a/src/hooks/useAccountBalance.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FrameSystemAccountInfo } from '@polkadot/types/lookup'; -import { useQuery, UseQueryResult } from '@tanstack/react-query'; -import { useMemo } from 'preact/compat'; -import { cacheKeys, inactiveOptions } from '../constants/cache'; -import { useGlobalState } from '../GlobalStateProvider'; -import { emptyFn } from '../helpers/general'; -import { nativeToDecimal, prettyNumbers } from '../helpers/parseNumbers'; -import { useNodeInfoState } from '../NodeInfoProvider'; - -export interface UseAccountBalanceResponse { - globalState: ReturnType; - query: UseQueryResult; - balance?: string; -} - -export const useAccountBalance = (): UseAccountBalanceResponse => { - const globalState = useGlobalState(); - const { walletAccount } = globalState; - const { - state: { api }, - } = useNodeInfoState(); - - const enabled = !!api && !!walletAccount; - const query = useQuery( - enabled ? [cacheKeys.walletBalance, walletAccount?.address] : [''], - enabled ? () => api.query.system.account(walletAccount.address) : emptyFn, - { - enabled, - ...inactiveOptions[0], - onError: (err) => console.error(err), - }, - ); - const { data } = query; - - const balance = useMemo(() => { - if (!data?.data || !walletAccount) return undefined; - return prettyNumbers(nativeToDecimal(data.data.free).toNumber()); - }, [data?.data, walletAccount]); - - return { - globalState, - query, - balance, - }; -}; diff --git a/src/hooks/useAssets.ts b/src/hooks/useAssets.ts index 7692e413..f8f71155 100644 --- a/src/hooks/useAssets.ts +++ b/src/hooks/useAssets.ts @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query'; +import { appsConfigs } from '../config/apps'; import { cacheKeys, inactiveOptions } from '../constants/cache'; import { Asset } from '../models/Asset'; -// ! TODO +// ! TODO: fetch correct tokens export const useAssets = () => { - return useQuery([cacheKeys.assets], () => [], inactiveOptions['15m']); + return useQuery([cacheKeys.assets], () => appsConfigs.nabla.foucoco.assets, inactiveOptions['15m']); }; diff --git a/src/hooks/useBalance.ts b/src/hooks/useBalance.ts deleted file mode 100644 index 3f4fb9cc..00000000 --- a/src/hooks/useBalance.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ApiPromise } from '@polkadot/api'; -import { WeightV2 } from '@polkadot/types/interfaces'; -import { FrameSystemAccountInfo } from '@polkadot/types/lookup'; -import { UseQueryResult } from '@tanstack/react-query'; -import { useMemo } from 'preact/compat'; -import { cacheKeys, inactiveOptions, QueryOptions } from '../constants/cache'; -import { mockERC20 } from '../contracts/MockERC20'; -import { useGlobalState } from '../GlobalStateProvider'; -import { nativeToDecimal, prettyNumbers } from '../helpers/parseNumbers'; -import { useNodeInfoState } from '../NodeInfoProvider'; -import { useContract } from './useContract'; - -export type UseBalanceResponse = UseQueryResult & { - balance?: number; - formatted?: string; -}; - -export const createOptions = (api: ApiPromise) => ({ - gasLimit: api.createType('WeightV2', { - refTime: '100000000000', - proofSize: '1000000', - }) as WeightV2, - storageDepositLimit: null, -}); - -export const useBalance = (tokenAddress?: string, options?: QueryOptions): UseBalanceResponse => { - const { - state: { api }, - } = useNodeInfoState(); - const { address } = useGlobalState().walletAccount || {}; - - const enabled = !!api && !!address && options?.enabled !== false; - const query = useContract([cacheKeys.walletBalance, tokenAddress, address], { - abi: mockERC20, - address: tokenAddress, // contract address - // ! TODO: fix types - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fn: (contract) => (contract.query as any).balanceOf(address, api ? createOptions(api) : {}, address), - ...inactiveOptions['3m'], - ...options, - enabled, - }); - const { data } = query; - const val = useMemo(() => { - if (!data?.result.isOk || !data.output) return {}; - const balance = nativeToDecimal(parseFloat(data.output.toString()) || 0).toNumber(); - return { balance, formatted: prettyNumbers(balance) }; - }, [data]); - - return { - ...query, - ...val, - }; -}; diff --git a/src/hooks/userinterface.ts b/src/hooks/useClipboard.ts similarity index 100% rename from src/hooks/userinterface.ts rename to src/hooks/useClipboard.ts diff --git a/src/hooks/useGetTenantData.ts b/src/hooks/useGetTenantData.ts new file mode 100644 index 00000000..4d81bbe9 --- /dev/null +++ b/src/hooks/useGetTenantData.ts @@ -0,0 +1,7 @@ +import { useGlobalState } from '../GlobalStateProvider'; +import { TenantName } from '../models/Tenant'; + +export const useGetTenantData = (data: Partial>): T | undefined => { + const tenant = useGlobalState().tenantName; + return data[tenant] as T; +}; diff --git a/src/index.css b/src/index.css index 63843f7b..efdd0b7c 100644 --- a/src/index.css +++ b/src/index.css @@ -39,6 +39,8 @@ --text-primary-disabled: rgba(88, 102, 126, 0.4); --tag-background: rgba(78, 229, 154, 0.16); /* --primary 16% */ --network-bg: #252733; + --scroll-track: rgba(0, 0, 0, 0.5); + --scroll-bg: rgba(255, 255, 255, 0.3); } [data-theme='pendulum'] { @@ -59,6 +61,25 @@ --text-primary-disabled: rgba(88, 102, 126, 0.4); --tag-background: rgba(144, 126, 160, 0.16); /* --primary 16% */ --network-bg: hsla(0, 0%, 0%, 0.04); + --scroll-track: rgba(0, 0, 0, 0.12); + --scroll-bg: rgba(0, 0, 0, 0.25); +} + +::-webkit-scrollbar { + position: relative; + width: 10px; + height: 10px; + background-color: transparent; + z-index: 9999999; +} +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px var(--scroll-track); + background-color: transparent; +} +::-webkit-scrollbar-thumb { + border-radius: 5px; + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.1); + background-color: var(--scroll-bg); } body { @@ -269,6 +290,16 @@ table th:hover .sort { opacity: 0.8 !important; } +.btn-disabled, +.btn-disabled:hover, +.btn[disabled], +.btn[disabled]:hover { + --tw-bg-opacity: 1; + --tw-text-opacity: 1; + background-color: hsl(var(--s)); + opacity: 0.3; +} + w3m-modal { position: relative; z-index: 1000; diff --git a/src/main.tsx b/src/main.tsx index 06b3abff..8a80dcbb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { Theme } from 'react-daisyui'; import { BrowserRouter } from 'react-router-dom'; import { GlobalState, GlobalStateContext, GlobalStateProvider } from './GlobalStateProvider'; import { NodeInfoProvider } from './NodeInfoProvider'; +import SharedProvider from './SharedProvider'; import { App } from './app'; import { emptyFn } from './helpers/general'; import './index.css'; @@ -20,9 +21,11 @@ render( const { tenantRPC, getThemeName = emptyFn } = globalState as GlobalState; return ( - - - + + + + + ); }} diff --git a/src/models/Asset.ts b/src/models/Asset.ts index 2c9d9666..893b4976 100644 --- a/src/models/Asset.ts +++ b/src/models/Asset.ts @@ -3,4 +3,5 @@ export interface Asset { decimals: number; symbol: string; name: string; + logoURI?: string; } diff --git a/src/models/BackstopPool.ts b/src/models/BackstopPool.ts index 3206dd72..89f5a129 100644 --- a/src/models/BackstopPool.ts +++ b/src/models/BackstopPool.ts @@ -1,5 +1,8 @@ import type { Asset } from './Asset'; export interface BackstopPool { - assets: Asset[]; + address: string; + asset: Asset; + liabilities: number; + totalSupply: number; } diff --git a/src/models/SwapPool.ts b/src/models/SwapPool.ts index 22a23df7..7f61f752 100644 --- a/src/models/SwapPool.ts +++ b/src/models/SwapPool.ts @@ -1,8 +1,10 @@ import type { Asset } from './Asset'; export interface SwapPool { + address: string; asset: Asset; - liabilities: number; - balance: number; - apr: number; + liabilities?: number; + totalSupply?: number; + balance?: number; + apr?: number; } diff --git a/src/pages/bridge/Issue.tsx b/src/pages/bridge/Issue.tsx index 93618171..435430ef 100644 --- a/src/pages/bridge/Issue.tsx +++ b/src/pages/bridge/Issue.tsx @@ -16,7 +16,6 @@ import { CopyableAddress, PublicKey } from '../../components/PublicKey'; import { AssetSelector, VaultSelector } from '../../components/Selector'; import TransferCountdown from '../../components/TransferCountdown'; import OpenWallet from '../../components/Wallet'; -import { decimalToStellarNative, nativeStellarToDecimal, nativeToDecimal } from '../../helpers/parseNumbers'; import { calculateDeadline, convertCurrencyToStellarAsset, deriveShortenedRequestId } from '../../helpers/spacewalk'; import { convertRawHexKeyToPublicKey, isCompatibleStellarAmount, stringifyStellarAsset } from '../../helpers/stellar'; import { getErrors, getEventBySectionAndMethod } from '../../helpers/substrate'; @@ -24,6 +23,7 @@ import { useFeePallet } from '../../hooks/spacewalk/fee'; import { RichIssueRequest, useIssuePallet } from '../../hooks/spacewalk/issue'; import { useSecurityPallet } from '../../hooks/spacewalk/security'; import { ExtendedRegistryVault, useVaultRegistryPallet } from '../../hooks/spacewalk/vaultRegistry'; +import { decimalToStellarNative, nativeStellarToDecimal, nativeToDecimal } from '../../shared/parseNumbers'; interface FeeBoxProps { bridgedAsset?: Asset; diff --git a/src/pages/bridge/Redeem.tsx b/src/pages/bridge/Redeem.tsx index c2befce2..6a7ccd58 100644 --- a/src/pages/bridge/Redeem.tsx +++ b/src/pages/bridge/Redeem.tsx @@ -12,7 +12,6 @@ import LabelledInputField from '../../components/LabelledInputField'; import { CopyableAddress, PublicKey } from '../../components/PublicKey'; import { AssetSelector, VaultSelector } from '../../components/Selector'; import OpenWallet from '../../components/Wallet'; -import { decimalToStellarNative, nativeStellarToDecimal, nativeToDecimal } from '../../helpers/parseNumbers'; import { convertCurrencyToStellarAsset } from '../../helpers/spacewalk'; import { StellarPublicKeyPattern, @@ -25,6 +24,7 @@ import { getErrors, getEventBySectionAndMethod } from '../../helpers/substrate'; import { useFeePallet } from '../../hooks/spacewalk/fee'; import { RichRedeemRequest, useRedeemPallet } from '../../hooks/spacewalk/redeem'; import { ExtendedRegistryVault, useVaultRegistryPallet } from '../../hooks/spacewalk/vaultRegistry'; +import { decimalToStellarNative, nativeStellarToDecimal, nativeToDecimal } from '../../shared/parseNumbers'; interface FeeBoxProps { bridgedAsset?: Asset; diff --git a/src/pages/bridge/TransferDialog.tsx b/src/pages/bridge/TransferDialog.tsx index 03b23703..3a6fe5b8 100644 --- a/src/pages/bridge/TransferDialog.tsx +++ b/src/pages/bridge/TransferDialog.tsx @@ -11,12 +11,12 @@ import WarningDialogIcon from '../../assets/dialog-status-warning'; import { CloseButton } from '../../components/CloseButton'; import { CopyableAddress } from '../../components/PublicKey'; import TransferCountdown from '../../components/TransferCountdown'; -import { nativeToDecimal } from '../../helpers/parseNumbers'; import { calculateDeadline, currencyToString, deriveShortenedRequestId } from '../../helpers/spacewalk'; import { convertRawHexKeyToPublicKey } from '../../helpers/stellar'; import { toTitle } from '../../helpers/string'; import { useSecurityPallet } from '../../hooks/spacewalk/security'; import { useVaultRegistryPallet } from '../../hooks/spacewalk/vaultRegistry'; +import { nativeToDecimal } from '../../shared/parseNumbers'; import { TTransfer, TransferType } from './TransfersColumns'; interface BaseTransferDialogProps { diff --git a/src/pages/bridge/Transfers.tsx b/src/pages/bridge/Transfers.tsx index 8fcc4b41..5b09dd88 100644 --- a/src/pages/bridge/Transfers.tsx +++ b/src/pages/bridge/Transfers.tsx @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import { useEffect, useMemo, useState } from 'preact/compat'; import { useGlobalState } from '../../GlobalStateProvider'; import Table from '../../components/Table'; -import { nativeToDecimal } from '../../helpers/parseNumbers'; import { calculateDeadline, convertCurrencyToStellarAsset, estimateRequestCreationTime } from '../../helpers/spacewalk'; import { useIssuePallet } from '../../hooks/spacewalk/issue'; import { useRedeemPallet } from '../../hooks/spacewalk/redeem'; import { useSecurityPallet } from '../../hooks/spacewalk/security'; +import { nativeToDecimal } from '../../shared/parseNumbers'; import { CancelledTransferDialog, CompletedTransferDialog, diff --git a/src/pages/collators/CollatorRewards.tsx b/src/pages/collators/CollatorRewards.tsx index 4376b5ae..d5e3d0b6 100644 --- a/src/pages/collators/CollatorRewards.tsx +++ b/src/pages/collators/CollatorRewards.tsx @@ -6,9 +6,9 @@ import { useNodeInfoState } from '../../NodeInfoProvider'; import RewardsIcon from '../../assets/collators-rewards-icon'; import StakedIcon from '../../assets/collators-staked-icon'; import { getAddressForFormat } from '../../helpers/addressFormatter'; -import { nativeToFormat } from '../../helpers/parseNumbers'; import { getErrors } from '../../helpers/substrate'; import { useStakingPallet } from '../../hooks/staking/staking'; +import { nativeToFormat } from '../../shared/parseNumbers'; import { UserStaking } from './columns'; import ClaimRewardsDialog from './dialogs/ClaimRewardsDialog'; @@ -58,7 +58,7 @@ function CollatorRewards() { fetchUnstaking(); fetchAvailableBalance(); - }, [api, walletAccount]); + }, [api, tokenSymbol, walletAccount]); const updateRewardsExtrinsic = useMemo(() => { if (!api) { @@ -74,6 +74,7 @@ function CollatorRewards() { } setSubmissionPending(true); updateRewardsExtrinsic + // eslint-disable-next-line @typescript-eslint/no-explicit-any .signAndSend(walletAccount.address, { signer: walletAccount.signer as any }, (result) => { const { status, events } = result; const errors = getErrors(events, api); @@ -97,7 +98,7 @@ function CollatorRewards() { }); setSubmissionPending(false); }); - }, [api, setSubmissionPending, updateRewardsExtrinsic, walletAccount]); + }, [api, refreshRewards, updateRewardsExtrinsic, walletAccount]); return ( <> diff --git a/src/pages/collators/CollatorsTable.tsx b/src/pages/collators/CollatorsTable.tsx index f5662984..4372464f 100644 --- a/src/pages/collators/CollatorsTable.tsx +++ b/src/pages/collators/CollatorsTable.tsx @@ -1,12 +1,11 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { useGlobalState } from '../../GlobalStateProvider'; import { useNodeInfoState } from '../../NodeInfoProvider'; -import { nativeToFormat } from '../../helpers/parseNumbers'; - import Table from '../../components/Table'; import { getAddressForFormat } from '../../helpers/addressFormatter'; import { ParachainStakingCandidate, useStakingPallet } from '../../hooks/staking/staking'; import { PalletIdentityInfo, useIdentityPallet } from '../../hooks/useIdentityPallet'; +import { nativeToFormat } from '../../shared/parseNumbers'; import { TCollator, UserStaking, @@ -62,6 +61,7 @@ function CollatorsTable() { }, [api, walletAccount]); useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const identitiesPrefetch = async (candidatesArray: any) => { const m: Map = new Map(); for (let i = 0; i < candidatesArray.length; i++) { diff --git a/src/pages/collators/columns.tsx b/src/pages/collators/columns.tsx index 7edfc09a..6f1c9643 100644 --- a/src/pages/collators/columns.tsx +++ b/src/pages/collators/columns.tsx @@ -4,9 +4,9 @@ import { StateUpdater } from 'preact/hooks'; import { Button } from 'react-daisyui'; import UnlinkIcon from '../../assets/UnlinkIcon'; import { CopyableAddress } from '../../components/PublicKey'; -import { nativeToFormat } from '../../helpers/parseNumbers'; import { ParachainStakingCandidate } from '../../hooks/staking/staking'; import { PalletIdentityInfo } from '../../hooks/useIdentityPallet'; +import { nativeToFormat } from '../../shared/parseNumbers'; export interface TCollator { candidate: ParachainStakingCandidate; diff --git a/src/pages/collators/dialogs/ClaimRewardsDialog.tsx b/src/pages/collators/dialogs/ClaimRewardsDialog.tsx index 2d125bdf..bfe75dfa 100644 --- a/src/pages/collators/dialogs/ClaimRewardsDialog.tsx +++ b/src/pages/collators/dialogs/ClaimRewardsDialog.tsx @@ -5,9 +5,9 @@ import { useGlobalState } from '../../../GlobalStateProvider'; import { useNodeInfoState } from '../../../NodeInfoProvider'; import SuccessDialogIcon from '../../../assets/dialog-status-success'; import { CloseButton } from '../../../components/CloseButton'; -import { format, nativeToDecimal } from '../../../helpers/parseNumbers'; import { getErrors } from '../../../helpers/substrate'; import { ParachainStakingInflationInflationInfo, useStakingPallet } from '../../../hooks/staking/staking'; +import { nativeToDecimal, format } from '../../../shared/parseNumbers'; interface Props { userRewardsBalance?: string; diff --git a/src/pages/collators/dialogs/ConfirmDelegateDialog.tsx b/src/pages/collators/dialogs/ConfirmDelegateDialog.tsx index e2a3e527..035f3213 100644 --- a/src/pages/collators/dialogs/ConfirmDelegateDialog.tsx +++ b/src/pages/collators/dialogs/ConfirmDelegateDialog.tsx @@ -1,7 +1,7 @@ import Big from 'big.js'; import { Button, Modal } from 'react-daisyui'; import { CloseButton } from '../../../components/CloseButton'; -import { nativeToDecimal } from '../../../helpers/parseNumbers'; +import { nativeToDecimal } from '../../../shared/parseNumbers'; import { DelegationMode } from './ExecuteDelegationDialogs'; interface ConfirmDelegateDialogProps { diff --git a/src/pages/collators/dialogs/DelegateToCollatorDialog.tsx b/src/pages/collators/dialogs/DelegateToCollatorDialog.tsx index 5d932b0f..93f0eda3 100644 --- a/src/pages/collators/dialogs/DelegateToCollatorDialog.tsx +++ b/src/pages/collators/dialogs/DelegateToCollatorDialog.tsx @@ -4,8 +4,8 @@ import AmplitudeLogo from '../../../assets/AmplitudeLogo'; import { CloseButton } from '../../../components/CloseButton'; import LabelledInputField from '../../../components/LabelledInputField'; import { PublicKey } from '../../../components/PublicKey'; -import { nativeToDecimal } from '../../../helpers/parseNumbers'; import { ParachainStakingCandidate, ParachainStakingInflationInflationInfo } from '../../../hooks/staking/staking'; +import { nativeToDecimal } from '../../../shared/parseNumbers'; import { DelegationMode } from './ExecuteDelegationDialogs'; interface DelegateToCollatorDialogProps { diff --git a/src/pages/collators/dialogs/ExecuteDelegationDialogs.tsx b/src/pages/collators/dialogs/ExecuteDelegationDialogs.tsx index 8134b984..881cbe49 100644 --- a/src/pages/collators/dialogs/ExecuteDelegationDialogs.tsx +++ b/src/pages/collators/dialogs/ExecuteDelegationDialogs.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo, useState } from 'preact/hooks'; import { useGlobalState } from '../../../GlobalStateProvider'; import { useNodeInfoState } from '../../../NodeInfoProvider'; -import { decimalToNative, nativeToDecimal } from '../../../helpers/parseNumbers'; import { ParachainStakingCandidate, useStakingPallet } from '../../../hooks/staking/staking'; +import { decimalToNative, nativeToDecimal } from '../../../shared/parseNumbers'; import ConfirmDelegateDialog from './ConfirmDelegateDialog'; import DelegateToCollatorDialog from './DelegateToCollatorDialog'; import DelegationSuccessfulDialog from './DelegationSuccessfulDialog'; diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx index 4e255e65..37af1d2d 100644 --- a/src/pages/dashboard/Dashboard.tsx +++ b/src/pages/dashboard/Dashboard.tsx @@ -9,9 +9,9 @@ import { useGlobalState } from '../../GlobalStateProvider'; import { useNodeInfoState } from '../../NodeInfoProvider'; import Banner from '../../assets/banner-spacewalk-4x.png'; import { getAddressForFormat } from '../../helpers/addressFormatter'; -import { nativeToDecimal, prettyNumbers } from '../../helpers/parseNumbers'; import { currencyToString } from '../../helpers/spacewalk'; import { useVaultRegistryPallet } from '../../hooks/spacewalk/vaultRegistry'; +import { nativeToDecimal, prettyNumbers } from '../../shared/parseNumbers'; import './styles.css'; interface TokenBalances { diff --git a/src/pages/nabla/dev/index.tsx b/src/pages/nabla/dev/index.tsx new file mode 100644 index 00000000..d9b86c24 --- /dev/null +++ b/src/pages/nabla/dev/index.tsx @@ -0,0 +1,65 @@ +import { useNavigate } from 'react-router-dom'; +import { useGlobalState } from '../../../GlobalStateProvider'; +import { config } from '../../../config'; +import { nablaConfig } from '../../../config/apps/nabla'; +import { mockERC20 } from '../../../contracts/nabla/MockERC20'; +import { useGetTenantData } from '../../../hooks/useGetTenantData'; +import { Asset } from '../../../models/Asset'; +import { createOptions } from '../../../services/api/helpers'; +import { decimalToNative } from '../../../shared/parseNumbers'; +import { UseContractWriteProps, useContractWrite } from '../../../shared/useContractWrite'; + +const amount = decimalToNative(1000).toString(); +const mintFn: UseContractWriteProps['fn'] = ({ contract, api, address }) => + contract.tx.mint(createOptions(api), address, amount); + +const TokenItem = ({ token }: { token: Asset }) => { + const { mutate, isLoading } = useContractWrite({ + abi: mockERC20, + address: token.address, + fn: mintFn, + onError: console.error, + }); + return ( +
+
+

{token.name}

+

{token.address}

+
+
+ +
+
+ ); +}; + +const DevPage = () => { + const nav = useNavigate(); + const wallet = useGlobalState().walletAccount; + const { assets } = useGetTenantData(nablaConfig) || {}; + + if (!config.isDev) nav('/'); + if (!wallet?.address) { + return <>Please connect your wallet.; + } + return ( +
+
+
+

Tokens

+ {assets?.map((token) => ( + + ))} +
+
+
+ ); +}; + +export default DevPage; diff --git a/src/services/api/helpers.ts b/src/services/api/helpers.ts index f22160cc..ed762c0c 100644 --- a/src/services/api/helpers.ts +++ b/src/services/api/helpers.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/ban-types */ import type { ApiPromise } from '@polkadot/api'; +import { ContractOptions } from '@polkadot/api-contract/types'; import { emptyFn } from '../../helpers/general'; export type ApiArgs = [never]> = { api: ApiPromise } & T; @@ -11,3 +12,21 @@ export const fnOrEmpty = (fn: (api: ApiPromise, ...args: T) => R) => (api: ApiPromise | undefined, ...args: T): (() => R | undefined) => isApiConnected(api) ? () => fn(api, ...args) : emptyFn; + +// https://substrate.stackexchange.com/questions/6401/smart-contract-function-call-error/6402#6402 +export const createOptions = (api: ApiPromise, opts?: ContractOptions) => ({ + gasLimit: api.createType('WeightV2', { + refTime: '100000000000', + proofSize: '1000000', + }), + storageDepositLimit: null, + ...opts, +}); +export const createWriteOptions = (api: ApiPromise, opts?: ContractOptions) => ({ + gasLimit: api.createType('WeightV2', { + refTime: '100000000000', + proofSize: '1000000', + }), + storageDepositLimit: null, + ...opts, +}); diff --git a/src/services/mocks.ts b/src/services/mocks.ts index 0d19f4aa..444a8197 100644 --- a/src/services/mocks.ts +++ b/src/services/mocks.ts @@ -1,38 +1,13 @@ import { SwapPoolColumn } from '../components/Pools/Swap/columns'; -import { Asset } from '../models/Asset'; +import { nablaConfig } from '../config/apps/nabla'; import { BackstopPool } from '../models/BackstopPool'; -// ! TODO: remove -export const swapTokens: Asset[] = [ - { - address: '123456', - name: 'Ethereum', - symbol: 'ETH', - decimals: 2, - }, - { - address: '123457', - name: 'USDC', - symbol: 'USDC', - decimals: 2, - }, - { - address: '123458', - name: 'Pendulum', - symbol: 'PEN', - decimals: 2, - }, - { - address: '123459', - name: 'Bitcoin', - symbol: 'BTC', - decimals: 2, - }, -]; +const mock = nablaConfig.foucoco; + export const swapPools: SwapPoolColumn[] = [ { - asset: swapTokens[0], - apr: 5, + ...mock.swapPools[0], + asset: mock.assets[0], balance: 100, liabilities: 1, wallet: undefined, @@ -40,8 +15,8 @@ export const swapPools: SwapPoolColumn[] = [ coverage: 5, }, { - asset: swapTokens[1], - apr: 1.5, + ...mock.swapPools[1], + asset: mock.assets[1], balance: 0, liabilities: 1, wallet: undefined, @@ -49,32 +24,20 @@ export const swapPools: SwapPoolColumn[] = [ coverage: 15, }, { - asset: swapTokens[2], - apr: 8.1, + ...mock.swapPools[2], + asset: mock.assets[2], balance: 0, liabilities: 3, wallet: undefined, myAmount: 54, coverage: 0, }, - { - asset: swapTokens[3], - apr: 10.5, - balance: 89, - liabilities: 3, - wallet: undefined, - myAmount: 11, - coverage: 2, - }, ]; export const backstopPool: BackstopPool[] = [ { - assets: [swapTokens[0], swapTokens[1], swapTokens[2]], - }, - { - assets: [swapTokens[1], swapTokens[2], swapTokens[3]], - }, - { - assets: [swapTokens[0], swapTokens[2]], + address: mock.backstopPool, + asset: mock.assets[2], + liabilities: 100, + totalSupply: 1000, }, ]; diff --git a/src/services/walletConnect/index.ts b/src/services/walletConnect/index.ts index 524982ca..720bdaad 100644 --- a/src/services/walletConnect/index.ts +++ b/src/services/walletConnect/index.ts @@ -9,10 +9,12 @@ import { config } from '../../config'; export const walletConnectService = { provider: undefined as UniversalProvider | undefined, getProvider: async function getProvider(): Promise { - this.provider = this.provider || await UniversalProvider.init({ - projectId: config.walletConnect.projectId, - relayUrl: config.walletConnect.url, - }); + this.provider = + this.provider || + (await UniversalProvider.init({ + projectId: config.walletConnect.projectId, + relayUrl: config.walletConnect.url, + })); return this.provider; }, init: async function init(session: SessionTypes.Struct, chainId: string): Promise { diff --git a/src/shared/Provider.tsx b/src/shared/Provider.tsx new file mode 100644 index 00000000..b0111fa0 --- /dev/null +++ b/src/shared/Provider.tsx @@ -0,0 +1,29 @@ +import { ApiPromise } from '@polkadot/api'; +import { ComponentChildren, createContext } from 'preact'; +import { useContext, useMemo } from 'preact/compat'; + +export interface State { + api?: ApiPromise; + signer?: unknown; // TODO: fix type + address?: string; +} + +const SharedStateContext = createContext(undefined); + +export const SharedStateProvider = ({ children, api, signer, address }: { children: ComponentChildren } & State) => { + const providerValue = useMemo( + () => ({ + api, + signer, + address, + }), + [api, signer, address], + ); + return {children}; +}; + +export const useSharedState = () => { + const state = useContext(SharedStateContext); + if (!state) throw 'SharedStateProvider not defined!'; + return state; +}; diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 00000000..f1375724 --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,5 @@ +export const cacheKeys = { + balance: 'balance', + accountBalance: 'accountBalance', + tokenAllowance: 'tokenAllowance', +}; diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts new file mode 100644 index 00000000..4a495643 --- /dev/null +++ b/src/shared/helpers.ts @@ -0,0 +1,9 @@ +import type { QueryKey, UseQueryOptions } from '@tanstack/react-query'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type QueryOptions = Partial< + Omit, 'queryKey' | 'queryFn'> +>; + +export const emptyFn = () => undefined; +export const emptyCacheKey = ['']; diff --git a/src/helpers/parseNumbers.ts b/src/shared/parseNumbers.ts similarity index 100% rename from src/helpers/parseNumbers.ts rename to src/shared/parseNumbers.ts diff --git a/src/shared/useAccountBalance.ts b/src/shared/useAccountBalance.ts new file mode 100644 index 00000000..8da27c33 --- /dev/null +++ b/src/shared/useAccountBalance.ts @@ -0,0 +1,49 @@ +import { FrameSystemAccountInfo } from '@polkadot/types/lookup'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { useMemo } from 'preact/compat'; +import { cacheKeys } from './constants'; +import { emptyCacheKey, emptyFn, QueryOptions } from './helpers'; +import { nativeToDecimal, prettyNumbers } from './parseNumbers'; +import { useSharedState } from './Provider'; + +export interface UseAccountBalanceResponse { + query: UseQueryResult; + balance?: string; + enabled: boolean; +} + +export const useAccountBalance = ( + address?: string, + options?: QueryOptions, +): UseAccountBalanceResponse => { + const { api, address: defAddress } = useSharedState(); + + const accountAddress = address || defAddress; + const enabled = !!api && !!accountAddress && options?.enabled !== false; + const query = useQuery( + enabled ? [cacheKeys.accountBalance, accountAddress] : emptyCacheKey, + enabled ? () => api.query.system.account(accountAddress) : emptyFn, + { + cacheTime: 0, + staleTime: 0, + retry: 2, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + onError: console.error, + ...options, + enabled, + }, + ); + const { data } = query; + + const balance = useMemo(() => { + if (!data?.data || !accountAddress) return undefined; + return prettyNumbers(nativeToDecimal(data.data.free).toNumber()); + }, [data?.data, accountAddress]); + + return { + query, + balance, + enabled, + }; +}; diff --git a/src/hooks/useContract.ts b/src/shared/useContract.ts similarity index 51% rename from src/hooks/useContract.ts rename to src/shared/useContract.ts index 66bf3d4d..7bf3ee19 100644 --- a/src/hooks/useContract.ts +++ b/src/shared/useContract.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/ban-types */ +import type { ApiPromise } from '@polkadot/api'; import { Abi, ContractPromise } from '@polkadot/api-contract'; import { QueryKey, useQuery } from '@tanstack/react-query'; import { useMemo } from 'preact/compat'; -import { useNodeInfoState } from '../NodeInfoProvider'; -import { QueryOptions } from '../constants/cache'; -import { emptyFn } from '../helpers/general'; +import { useSharedState } from './Provider'; +import { QueryOptions, emptyCacheKey, emptyFn } from './helpers'; export type UseContractProps = QueryOptions & { abi: T; @@ -13,17 +13,24 @@ export type UseContractProps = QueryOptions & { }; export const useContract = < T extends Abi | Record, - TFn extends (contract: ContractPromise) => () => Promise, + TFn extends (data: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + contract: any; //ContractPromise; // TODO: fix contract type + api: ApiPromise; + }) => () => Promise, >( key: QueryKey, { abi, address, fn, ...rest }: UseContractProps, ) => { - const { api } = useNodeInfoState().state; + const { api } = useSharedState(); const contract = useMemo( () => (api && address ? new ContractPromise(api, abi, address) : undefined), [abi, address, api], ); - const enabled = !!contract && rest.enabled !== false; - const query = useQuery(enabled ? key : [''], enabled ? () => fn(contract) : emptyFn, { ...rest, enabled }); + const enabled = !!contract && rest.enabled !== false && !!api; + const query = useQuery(enabled ? key : emptyCacheKey, enabled ? fn({ contract, api }) : emptyFn, { + ...rest, + enabled, + }); return { ...query, enabled }; }; diff --git a/src/shared/useContractBalance.ts b/src/shared/useContractBalance.ts new file mode 100644 index 00000000..987827cf --- /dev/null +++ b/src/shared/useContractBalance.ts @@ -0,0 +1,67 @@ +import { FrameSystemAccountInfo } from '@polkadot/types/lookup'; +import { UseQueryResult } from '@tanstack/react-query'; +import { useMemo } from 'preact/compat'; +import { mockERC20 } from '../contracts/nabla/MockERC20'; +import { useSharedState } from './Provider'; +import { cacheKeys } from './constants'; +import { QueryOptions } from './helpers'; +import { nativeToDecimal, prettyNumbers } from './parseNumbers'; +import { useContract } from './useContract'; + +export type UseBalanceProps = { + /** token or contract address */ + contractAddress?: string; + /** account address */ + account?: string; +}; +export type UseBalanceResponse = UseQueryResult & { + balance?: number; + formatted?: string; + enabled: boolean; +}; + +export const useContractBalance = ( + { contractAddress, account }: UseBalanceProps, + options?: QueryOptions, +): UseBalanceResponse => { + const { api, address: defAddress } = useSharedState(); + const address = account || defAddress; + + const enabled = !!api && !!address && options?.enabled !== false; + const query = useContract([cacheKeys.balance, contractAddress, address], { + cacheTime: 180000, + staleTime: 180000, + retry: 2, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + ...options, + abi: mockERC20, + address: contractAddress, + fn: + ({ contract, api }) => + () => + contract.query.balanceOf( + address, + { + gasLimit: api.createType('WeightV2', { + refTime: '100000000000', + proofSize: '1000000', + }), + storageDepositLimit: null, + }, + address, + ), + enabled, + }); + const { data } = query; + const val = useMemo(() => { + if (!data?.result?.isOk || data?.output === undefined) return {}; + const balance = nativeToDecimal(parseFloat(data.output.toString()) || 0).toNumber(); + return { balance, formatted: prettyNumbers(balance) }; + }, [data]); + + return { + ...query, + ...val, + }; +}; diff --git a/src/shared/useContractWrite.ts b/src/shared/useContractWrite.ts new file mode 100644 index 00000000..de4ab546 --- /dev/null +++ b/src/shared/useContractWrite.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +import { ApiPromise } from '@polkadot/api'; +import { SubmittableResultValue } from '@polkadot/api-base/types'; +import { Abi, ContractPromise } from '@polkadot/api-contract'; +import { DispatchError, ExtrinsicStatus } from '@polkadot/types/interfaces'; +import { MutationOptions, useMutation } from '@tanstack/react-query'; +import { useMemo, useState } from 'preact/compat'; +import { useSharedState } from './Provider'; + +// TODO: fix types +export type TransactionsStatus = { + hex?: string; + status?: ExtrinsicStatus['type'] | 'Pending'; +}; + +export type UseContractWriteProps< + TAbi extends Abi | Record = Record, + TVariables = void, +> = Partial> & { + abi: TAbi; + address?: string; + fn?: ( + data: { + contract: any; //ContractPromise; + api: ApiPromise; + address: string; + signer: unknown; + }, + variables: TVariables, + ) => any; +}; + +export const useContractWrite = , TVariables = void>({ + abi, + address, + fn, + ...rest +}: UseContractWriteProps) => { + const { api, signer, address: walletAddress } = useSharedState(); + + const [transaction, setTransaction] = useState(); + const contract = useMemo( + () => (api && address ? new ContractPromise(api, abi, address) : undefined), + [abi, address, api], + ); + const isReady = !!contract && !!fn && !!api && !!walletAddress && !!signer; + const submit = async (variables: TVariables) => { + return new Promise((resolve, reject) => { + if (!isReady) return reject(undefined); + setTransaction({ status: 'Pending' }); + const unsubPromise: Promise | undefined = fn({ contract, api, signer, address: walletAddress }, variables) + .signAndSend(walletAddress, { signer }, (result: SubmittableResultValue) => { + const tx = { + hex: result.txHash.toHex(), + status: result.status.type, + }; + setTransaction(tx); + if (result.dispatchError) { + // TODO: improve this part - log and format errors + if (result.dispatchError.isModule) { + // for module errors, we have the section indexed, lookup + const decoded = api.registry.findMetaError(result.dispatchError.asModule); + const { docs, name, section } = decoded; + console.log(`${section}.${name}: ${docs.join(' ')}`); + } else { + // Other, CannotLookup, BadOrigin, no extra info + console.log(result.dispatchError.toString()); + } + reject(result.dispatchError); + } + if (result.status.isFinalized) { + if (unsubPromise) { + unsubPromise.then((unsub) => (typeof unsub === 'function' ? unsub() : undefined)); + } + resolve(tx); + } + }) + .catch((err: Error) => { + console.log(err); + setTransaction(undefined); + reject(err); + }); + }); + }; + const mutation = useMutation(submit, rest); + return { ...mutation, data: transaction, isReady }; +}; diff --git a/src/shared/useTokenAllowance.ts b/src/shared/useTokenAllowance.ts new file mode 100644 index 00000000..0a79fe72 --- /dev/null +++ b/src/shared/useTokenAllowance.ts @@ -0,0 +1,44 @@ +import { mockERC20 } from '../contracts/nabla/MockERC20'; +import { cacheKeys } from './constants'; +import { QueryOptions } from './helpers'; +import { nativeToDecimal } from './parseNumbers'; +import { useContract } from './useContract'; + +export type UseTokenAllowance = { + token?: string; + spender: string | undefined; + owner: string | undefined; +}; + +export const useTokenAllowance = ({ token, owner, spender }: UseTokenAllowance, options?: QueryOptions) => { + const isEnabled = Boolean(token && owner && spender && options?.enabled); + return useContract([cacheKeys.tokenAllowance, spender, token, owner], { + cacheTime: 180000, + staleTime: 180000, + retry: 2, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + ...options, + abi: mockERC20, + address: token, + fn: + ({ contract, api }) => + async () => { + const data = await contract.query.allowance( + owner, + { + gasLimit: api.createType('WeightV2', { + refTime: '100000000000', + proofSize: '1000000', + }), + storageDepositLimit: null, + }, + owner, + spender, + ); + if (!data?.result?.isOk || data?.output === undefined) throw new Error(data); + return nativeToDecimal(parseFloat(data.output.toString()) || 0); + }, + enabled: isEnabled, + }); +}; diff --git a/src/shared/useTokenApproval.ts b/src/shared/useTokenApproval.ts new file mode 100644 index 00000000..5b526c23 --- /dev/null +++ b/src/shared/useTokenApproval.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useMemo, useState } from 'react'; +import { mockERC20 } from '../contracts/nabla/MockERC20'; +import { useSharedState } from './Provider'; +import { decimalToNative } from './parseNumbers'; +import { UseContractWriteProps, useContractWrite } from './useContractWrite'; +import { useTokenAllowance } from './useTokenAllowance'; + +export enum ApprovalState { + UNKNOWN, + LOADING, + PENDING, + NOT_APPROVED, + APPROVED, +} + +interface UseTokenApprovalParams { + spender?: string; + token?: string; + amount?: number; + approveMax?: boolean; + enabled?: boolean; + onError?: (err: any) => void; + onSuccess?: UseContractWriteProps['onSuccess']; +} + +const maxInt = decimalToNative(Number.MAX_SAFE_INTEGER).toString(); + +export const useTokenApproval = ({ + token, + amount = 0, + spender, + enabled = true, + approveMax, + onError, + onSuccess, +}: UseTokenApprovalParams) => { + const { address } = useSharedState(); + const [pending, setPending] = useState(false); + const amountBI = decimalToNative(amount); + const isEnabled = Boolean(token && spender && address && enabled); + const { + data: allowance, + isLoading: isAllowanceLoading, + refetch, + } = useTokenAllowance( + { + token, + owner: address, + spender, + }, + { enabled: isEnabled }, + ); + + const mutation = useContractWrite({ + abi: mockERC20, + address: token, + fn: + isEnabled && allowance !== undefined && !isAllowanceLoading + ? ({ contract, api }) => + contract.tx.approve( + { + gasLimit: api.createType('WeightV2', { + refTime: '100000000000', + proofSize: '100000000000', + }), + storageDepositLimit: null, + }, + spender, + approveMax ? maxInt : amountBI.toString(), + ) + : undefined, + onError: (err) => { + setPending(false); + if (onError) onError(err); + }, + onSuccess: (...args) => { + setPending(true); + if (onSuccess) onSuccess(...args); + setTimeout(() => { + refetch(); + setPending(false); + }, 2000); + }, + }); + + return useMemo<[ApprovalState, typeof mutation]>(() => { + let state = ApprovalState.UNKNOWN; + // if (amount?.currency.isNative) state = ApprovalState.APPROVED; + if (isAllowanceLoading) state = ApprovalState.LOADING; + else if (!mutation.isReady) state = ApprovalState.UNKNOWN; + else if (allowance !== undefined && amount !== undefined && allowance >= amount) { + state = ApprovalState.APPROVED; + } else if (pending || mutation.isLoading) state = ApprovalState.PENDING; + else if (allowance !== undefined && amount !== undefined && allowance < amount) { + state = ApprovalState.NOT_APPROVED; + } + return [state, mutation]; + }, [allowance, amount, mutation, isAllowanceLoading, pending]); +}; diff --git a/tailwind.config.cjs b/tailwind.config.cjs index cc5d5d1c..5a0f41e8 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,3 +1,5 @@ +import { colors } from './src/constants/colors'; + // eslint-disable-next-line no-undef module.exports = { darkMode: 'class', @@ -11,6 +13,7 @@ module.exports = { themes: ['pendulum', 'amplitude'], theme: { extend: { + colors, screens: { xs: '480px', }, @@ -24,33 +27,32 @@ module.exports = { { pendulum: { primary: '#907EA0', - 'primary-content': '#ffffff', + 'primary-content': '#fff', secondary: '#F4F5F6', 'secondary-content': '#58667E', accent: '#1DE7DF', neutral: '#EFF2F5', 'base-100': '#fbfcfe', - 'base-200': '#ffffff', + 'base-200': '#fff', 'base-300': '#eff2f5', 'base-content': '#58667E', - '--rounded-btn': '9px', '--btn-text-case': 'none', }, amplitude: { primary: '#4EE59A', - 'primary-content': '#ffffff', - secondary: '#F4F5F6', - 'secondary-content': '#58667E', + 'primary-content': '#fff', + secondary: '#404040', + 'secondary-content': '#aaa', accent: '#00F197', - 'accent-content': '#ffffff', + 'accent-content': '#fff', neutral: '#191D24', 'neutral-focus': '#111318', 'neutral-content': '#A6ADBB', 'base-100': '#202020', 'base-200': '#1c1c1c', 'base-300': '#2c2c2c', - 'base-content': '#FFFFFF', + 'base-content': '#fff', '--rounded-btn': '9px', '--btn-text-case': 'none',