diff --git a/src/App.tsx b/src/App.tsx index 47ebfbea6..cd8144326 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,6 @@ import { } from "react-router-dom" import { BigNumberish } from "@ethersproject/bignumber" import { Event } from "@ethersproject/contracts" -import { TokenContextProvider } from "./contexts/TokenContext" import theme from "./theme" import reduxStore, { resetStoreAction } from "./store" import ModalRoot from "./components/Modal" @@ -204,11 +203,9 @@ const App: FC = () => { - - - - - + + + diff --git a/src/components/TokenBalanceCard/index.tsx b/src/components/TokenBalanceCard/index.tsx index 857b49deb..921bcb394 100644 --- a/src/components/TokenBalanceCard/index.tsx +++ b/src/components/TokenBalanceCard/index.tsx @@ -8,7 +8,7 @@ import { useToken } from "../../hooks/useToken" import { tBTCFillBlack } from "../../static/icons/tBTCFillBlack" export interface TokenBalanceCardProps { - token: Exclude + token: Exclude title?: string tokenSymbol?: string withSymbol?: boolean @@ -18,7 +18,7 @@ const tokenToIconMap = { [Token.Keep]: KeepCircleBrand, [Token.Nu]: NuCircleBrand, [Token.T]: T, - [Token.TBTCV2]: tBTCFillBlack, + [Token.TBTC]: tBTCFillBlack, } const TokenBalanceCard: FC = ({ diff --git a/src/contexts/TokenContext.tsx b/src/contexts/TokenContext.tsx deleted file mode 100644 index 68467c305..000000000 --- a/src/contexts/TokenContext.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React, { createContext } from "react" -import { Contract } from "@ethersproject/contracts" -import { AddressZero } from "@ethersproject/constants" -import { useWeb3React } from "@web3-react/core" -import { useKeep } from "../web3/hooks/useKeep" -import { useNu } from "../web3/hooks/useNu" -import { useT } from "../web3/hooks/useT" -import { useTokenState } from "../hooks/useTokenState" -import { useTokensBalanceCall } from "../hooks/useTokensBalanceCall" -import { Token } from "../enums" -import { TokenState } from "../types" -import { useTBTCTokenContract } from "../web3/hooks" -import { useVendingMachineRatio } from "../web3/hooks/useVendingMachineRatio" -import { useFetchOwnerStakes } from "../hooks/useFetchOwnerStakes" -import { useTBTCv2TokenContract } from "../web3/hooks/useTBTCv2TokenContract" -import { featureFlags } from "../constants" - -interface TokenContextState extends TokenState { - contract: Contract | null -} - -export const TokenContext = createContext<{ - [key in Token]: TokenContextState -}>({ - [Token.Keep]: {} as TokenContextState, - [Token.Nu]: {} as TokenContextState, - [Token.T]: {} as TokenContextState, - [Token.TBTC]: {} as TokenContextState, - [Token.TBTCV2]: {} as TokenContextState, -}) - -// Context that handles data fetching when a user connects their wallet or -// switches their network -export const TokenContextProvider: React.FC = ({ children }) => { - const keep = useKeep() - const nu = useNu() - const t = useT() - const tbtc = useTBTCTokenContract() - const tbtcv2 = useTBTCv2TokenContract() - const nuConversion = useVendingMachineRatio(Token.Nu) - const keepConversion = useVendingMachineRatio(Token.Keep) - const { active, chainId, account } = useWeb3React() - const fetchOwnerStakes = useFetchOwnerStakes() - - const { - fetchTokenPriceUSD, - setTokenBalance, - setTokenConversionRate, - keep: keepData, - nu: nuData, - t: tData, - tbtc: tbtcData, - tbtcv2: tbtcv2Data, - } = useTokenState() - - const tokenContracts = [keep.contract!, nu.contract!, t.contract!] - - if (!!tbtcv2) tokenContracts.push(tbtcv2.contract) - - const fetchBalances = useTokensBalanceCall( - tokenContracts, - active ? account! : AddressZero - ) - - // - // SET T CONVERSION RATE FOR KEEP, NU - // - React.useEffect(() => { - setTokenConversionRate(Token.Nu, nuConversion) - setTokenConversionRate(Token.Keep, keepConversion) - }, [nuConversion, keepConversion]) - - // - // SET USD PRICE - // - React.useEffect(() => { - for (const token in Token) { - if (token) { - // @ts-ignore - fetchTokenPriceUSD(Token[token]) - } - } - }, []) - - // - // FETCH BALANCES ON WALLET LOAD OR NETWORK SWITCH - // - React.useEffect(() => { - if (active) { - fetchBalances().then( - ([keepBalance, nuBalance, tBalance, tbtcv2Balance]) => { - setTokenBalance(Token.Keep, keepBalance.toString()) - setTokenBalance(Token.Nu, nuBalance.toString()) - setTokenBalance(Token.T, tBalance.toString()) - if (featureFlags.TBTC_V2) { - setTokenBalance(Token.TBTCV2, tbtcv2Balance.toString()) - } - } - ) - } else { - // set all token balances to 0 if the user disconnects the wallet - for (const token in Token) { - if (token) { - // @ts-ignore - setTokenBalance(Token[token], 0) - } - } - } - }, [active, chainId, account]) - - // fetch user stakes when they connect their wallet - React.useEffect(() => { - fetchOwnerStakes(account!) - }, [fetchOwnerStakes, account]) - - return ( - - {children} - - ) -} diff --git a/src/enums/token.ts b/src/enums/token.ts index 024ccdac0..53527a97a 100644 --- a/src/enums/token.ts +++ b/src/enums/token.ts @@ -2,8 +2,8 @@ export enum Token { Keep = "KEEP", Nu = "NU", T = "T", + TBTCV1 = "TBTCV1", TBTC = "TBTC", - TBTCV2 = "TBTCV2", } export enum CoingeckoID { @@ -11,9 +11,9 @@ export enum CoingeckoID { NU = "nucypher", T = "threshold-network-token", ETH = "ethereum", - TBTC = "tbtc", + TBTCV1 = "tbtc", // TODO: add prope tbtc-v2 id when it lands on coingecko - TBTCV2 = "tbtc", + TBTC = "tbtc", } export enum TConversionRates { diff --git a/src/hooks/__tests__/useFetchTvl.test.tsx b/src/hooks/__tests__/useFetchTvl.test.tsx index 1eed3f3d7..674209be2 100644 --- a/src/hooks/__tests__/useFetchTvl.test.tsx +++ b/src/hooks/__tests__/useFetchTvl.test.tsx @@ -11,8 +11,7 @@ import { } from "../../web3/hooks" import { useETHData } from "../useETHData" import { useFetchTvl } from "../useFetchTvl" -import * as useTokenModule from "../useToken" -import { TokenContext } from "../../contexts/TokenContext" +import { useToken } from "../useToken" import * as usdUtils from "../../utils/getUsdBalance" jest.mock("../../web3/hooks", () => ({ @@ -30,6 +29,11 @@ jest.mock("../useETHData", () => ({ useETHData: jest.fn(), })) +jest.mock("../useToken", () => ({ + ...(jest.requireActual("../useToken") as {}), + useToken: jest.fn(), +})) + describe("Test `useFetchTvl` hook", () => { const keepContext = { contract: {} as any, @@ -56,18 +60,7 @@ describe("Test `useFetchTvl` hook", () => { const mockedMultiCallContract = { interface: {}, address: "0x3" } const mockedKeepAssetPoolContract = { interface: {}, address: "0x4" } - const wrapper = ({ children }) => ( - - {children} - - ) + const wrapper = ({ children }) => <>{children} const multicallRequest = jest.fn() const mockedETHData = { usdPrice: 20 } @@ -88,6 +81,16 @@ describe("Test `useFetchTvl` hook", () => { ;(useKeepTokenStakingContract as jest.Mock).mockReturnValue( mockedKeepTokenStakingContract ) + ;(useToken as jest.Mock).mockImplementation((token: Token) => { + switch (token) { + case Token.Keep: + return keepContext + case Token.T: + return tContext + case Token.TBTCV1: + return tbtcContext + } + }) }) test("should fetch tvl data correctly.", async () => { @@ -110,7 +113,6 @@ describe("Test `useFetchTvl` hook", () => { const spyOnFormatUnits = jest.spyOn(ethersUnits, "formatUnits") const spyOnToUsdBalance = jest.spyOn(usdUtils, "toUsdBalance") - const spyOnUseToken = jest.spyOn(useTokenModule, "useToken") const _expectedResult = { ecdsa: ethInKeepBonding.format * mockedETHData.usdPrice, @@ -144,9 +146,9 @@ describe("Test `useFetchTvl` hook", () => { // then expect(useETHData).toHaveBeenCalled() - expect(spyOnUseToken).toHaveBeenCalledWith(Token.Keep) - expect(spyOnUseToken).toHaveBeenCalledWith(Token.TBTC) - expect(spyOnUseToken).toHaveBeenCalledWith(Token.T) + expect(useToken).toHaveBeenCalledWith(Token.Keep) + expect(useToken).toHaveBeenCalledWith(Token.TBTCV1) + expect(useToken).toHaveBeenCalledWith(Token.T) expect(useKeepBondingContract).toHaveBeenCalled() expect(useMulticallContract).toHaveBeenCalled() expect(useKeepAssetPoolContract).toHaveBeenCalled() diff --git a/src/hooks/useFetchTvl.ts b/src/hooks/useFetchTvl.ts index 811977b02..5dbf4d2da 100644 --- a/src/hooks/useFetchTvl.ts +++ b/src/hooks/useFetchTvl.ts @@ -51,7 +51,7 @@ export const useFetchTvl = (): [TVLData, () => Promise] => { const eth = useETHData() const keep = useToken(Token.Keep) - const tbtc = useToken(Token.TBTC) + const tbtc = useToken(Token.TBTCV1) const t = useToken(Token.T) const keepBonding = useKeepBondingContract() const multicall = useMulticallContract() diff --git a/src/hooks/useToken.ts b/src/hooks/useToken.ts index be6a222ed..3a0204ee6 100644 --- a/src/hooks/useToken.ts +++ b/src/hooks/useToken.ts @@ -1,9 +1,38 @@ -import { useContext } from "react" -import { TokenContext } from "../contexts/TokenContext" import { Token } from "../enums" +import { selectTokenByTokenName } from "../store/tokens/selectors" +import { + useKeep, + useNu, + useT, + useTBTCTokenContract, + useTBTCv2TokenContract, +} from "../web3/hooks" +import { useAppSelector } from "./store" + +const useSupportedTokens = () => { + const keep = useKeep() + const nu = useNu() + const t = useT() + const tbtcv1 = useTBTCTokenContract() + const tbtc = useTBTCv2TokenContract() + + return { + [Token.Keep]: keep, + [Token.Nu]: nu, + [Token.T]: t, + [Token.TBTCV1]: tbtcv1, + [Token.TBTC]: tbtc, + } +} export const useToken = (token: Token) => { - const tokenContext = useContext(TokenContext) + const tokenState = useAppSelector((state) => + selectTokenByTokenName(state, token) + ) + const _token = useSupportedTokens()[token] - return tokenContext[token] + return { + ...tokenState, + ..._token, + } } diff --git a/src/hooks/useTokenState.ts b/src/hooks/useTokenState.ts index 7e1629d78..75964a769 100644 --- a/src/hooks/useTokenState.ts +++ b/src/hooks/useTokenState.ts @@ -1,46 +1,54 @@ -import { useSelector, useDispatch } from "react-redux" import { - setTokenBalance as setTokenBalanceAction, - setTokenLoading as setTokenLoadingAction, + tokenBalanceFetched as setTokenBalanceAction, + tokenBalanceFetching as setTokenLoadingAction, fetchTokenPriceUSD as fetchTokenPriceAction, - setTokenConversionRate as setTokenConversionRateAction, + tokenBalanceFetchFailed as setTokenBalanceErrorAction, } from "../store/tokens" -import { RootState } from "../store" +import { useAppDispatch, useAppSelector } from "./store" import { Token } from "../enums" import { UseTokenState } from "../types/token" +import { useCallback } from "react" export const useTokenState: UseTokenState = () => { - const keep = useSelector((state: RootState) => state.token[Token.Keep]) - const nu = useSelector((state: RootState) => state.token[Token.Nu]) - const t = useSelector((state: RootState) => state.token[Token.T]) - const tbtc = useSelector((state: RootState) => state.token[Token.TBTC]) - const tbtcv2 = useSelector((state: RootState) => state.token[Token.TBTCV2]) + const keep = useAppSelector((state) => state.token[Token.Keep]) + const nu = useAppSelector((state) => state.token[Token.Nu]) + const t = useAppSelector((state) => state.token[Token.T]) + const tbtcv1 = useAppSelector((state) => state.token[Token.TBTCV1]) + const tbtc = useAppSelector((state) => state.token[Token.TBTC]) - const dispatch = useDispatch() + const dispatch = useAppDispatch() - const setTokenConversionRate = ( - token: Token, - conversionRate: number | string - ) => dispatch(setTokenConversionRateAction({ token, conversionRate })) + const setTokenBalance = useCallback( + (token: Token, balance: number | string) => + dispatch(setTokenBalanceAction({ token, balance })), + [dispatch] + ) - const setTokenBalance = (token: Token, balance: number | string) => - dispatch(setTokenBalanceAction({ token, balance })) + const setTokenLoading = useCallback( + (token: Token) => dispatch(setTokenLoadingAction({ token })), + [dispatch] + ) - const setTokenLoading = (token: Token, loading: boolean) => - dispatch(setTokenLoadingAction({ token, loading })) + const fetchTokenPriceUSD = useCallback( + (token: Token) => dispatch(fetchTokenPriceAction({ token })), + [dispatch] + ) - const fetchTokenPriceUSD = (token: Token) => - dispatch(fetchTokenPriceAction({ token })) + const setTokenBalanceError = useCallback( + (token: Token, error: string) => + dispatch(setTokenBalanceErrorAction({ token, error })), + [dispatch] + ) return { keep, nu, t, - tbtc, - tbtcv2, + tbtc: tbtcv1, + tbtcv2: tbtc, fetchTokenPriceUSD, setTokenBalance, setTokenLoading, - setTokenConversionRate, + setTokenBalanceError, } } diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index c7a275d32..d23c85ec8 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -3,6 +3,7 @@ import { setTransactionStatus as setTransactionStatusAction } from "../store/tra import { RootState } from "../store" import { UseTransaction } from "../types/transaction" import { TransactionStatus, TransactionType } from "../enums/transactionType" +import { useCallback } from "react" export const useTransaction: UseTransaction = () => { const keepApproval = useSelector( @@ -20,10 +21,11 @@ export const useTransaction: UseTransaction = () => { const dispatch = useDispatch() - const setTransactionStatus = ( - type: TransactionType, - status: TransactionStatus - ) => dispatch(setTransactionStatusAction({ type, status })) + const setTransactionStatus = useCallback( + (type: TransactionType, status: TransactionStatus) => + dispatch(setTransactionStatusAction({ type, status })), + [dispatch] + ) return { setTransactionStatus, diff --git a/src/pages/Overview/Network/WalletBalances.tsx b/src/pages/Overview/Network/WalletBalances.tsx index 0b72fdd4f..0a8a6abd1 100644 --- a/src/pages/Overview/Network/WalletBalances.tsx +++ b/src/pages/Overview/Network/WalletBalances.tsx @@ -19,6 +19,7 @@ import InfoBox from "../../../components/InfoBox" import Link from "../../../components/Link" import useUpgradeHref from "../../../hooks/useUpgradeHref" import ButtonLink from "../../../components/ButtonLink" +import { useTExchangeRate } from "../../../hooks/useTExchangeRate" const BalanceStat: FC<{ balance: string | number @@ -39,7 +40,7 @@ const BalanceStat: FC<{ /> {conversionRate && ( - 1 {text} = {conversionRate} T + 1 {text} = {formatTokenAmount(conversionRate, "0.0000")} T )} @@ -86,6 +87,8 @@ const WalletBalances: FC = () => { const { amount: keepToT } = useTConvertedAmount(Token.Keep, keep.balance) const { amount: nuToT } = useTConvertedAmount(Token.Nu, nu.balance) + const { amount: keepToTConversionRate } = useTExchangeRate(Token.Keep) + const { amount: nuToTConversionRate } = useTExchangeRate(Token.Nu) const conversionToTAmount = useMemo(() => { return BigNumber.from(keepToT).add(nuToT).toString() @@ -105,14 +108,14 @@ const WalletBalances: FC = () => { )} > = ({ }) => { return ( { registerStakingListeners() registerStakingAppsListeners() registerAccountListeners() + registerTokensListeners() state = { eth: { ...state.eth }, token: { diff --git a/src/store/tokens/effects.ts b/src/store/tokens/effects.ts new file mode 100644 index 000000000..5dc63bb6d --- /dev/null +++ b/src/store/tokens/effects.ts @@ -0,0 +1,87 @@ +import { BigNumber } from "ethers" +import { featureFlags } from "../../constants" +import { Token } from "../../enums" +import { isAddress } from "../../web3/utils" +import { walletConnected } from "../account" +import { AppListenerEffectAPI } from "../listener" +import { + tokenBalanceFetched, + tokenBalanceFetching, + fetchTokenPriceUSD, + tokenBalanceFetchFailed, +} from "./tokenSlice" + +export const fetchTokenBalances = async ( + actionCreator: ReturnType, + listenerApi: AppListenerEffectAPI +) => { + const address = actionCreator.payload + if (!isAddress(address)) return + + const { keep, nu, t, tbtc, tbtcv1 } = listenerApi.extra.threshold.token + + const tokens = [ + { token: keep, name: Token.Keep }, + { token: nu, name: Token.Nu }, + { token: t, name: Token.T }, + { token: tbtcv1, name: Token.TBTCV1 }, + { token: tbtc, name: Token.TBTC }, + ] + + if (featureFlags.TBTC_V2) { + tokens.push({ token: tbtc, name: Token.TBTC }) + } + + listenerApi.unsubscribe() + try { + tokens.forEach((_) => { + listenerApi.dispatch( + tokenBalanceFetching({ + token: _.name, + }) + ) + }) + + const balances: BigNumber[] = + await listenerApi.extra.threshold.multicall.aggregate( + tokens + .map((_) => _.token) + .map((_) => ({ + interface: _.contract.interface, + address: _.contract.address, + method: "balanceOf", + args: [address], + })) + ) + + tokens.forEach((_, index) => { + listenerApi.dispatch( + tokenBalanceFetched({ + token: _.name, + balance: balances[index].toString(), + }) + ) + }) + + tokens + .map((_) => _.name) + .forEach((tokenName) => + listenerApi.dispatch(fetchTokenPriceUSD({ token: tokenName })) + ) + } catch (error) { + console.error("Could not fetch token balances", error) + tokens + .map((_) => _.name) + .forEach((tokenName) => + listenerApi.dispatch( + tokenBalanceFetchFailed({ + token: tokenName, + error: `Could not fetch token balances. Error: ${( + error as Error + )?.toString()}`, + }) + ) + ) + listenerApi.subscribe() + } +} diff --git a/src/store/tokens/selectors.ts b/src/store/tokens/selectors.ts new file mode 100644 index 000000000..78792d38e --- /dev/null +++ b/src/store/tokens/selectors.ts @@ -0,0 +1,13 @@ +import { createSelector } from "@reduxjs/toolkit" +import { RootState } from ".." +import { TokensState } from "./tokenSlice" +import { Token } from "../../enums" + +export const selectTokensState = (state: RootState) => state.token + +export const selectTokenByTokenName = createSelector( + [selectTokensState, (_: RootState, tokenName: Token) => tokenName], + (tokensState: TokensState, tokenName: Token) => { + return tokensState[tokenName] + } +) diff --git a/src/store/tokens/tokenSlice.ts b/src/store/tokens/tokenSlice.ts index daf7ebabe..3cd936dde 100644 --- a/src/store/tokens/tokenSlice.ts +++ b/src/store/tokens/tokenSlice.ts @@ -1,19 +1,18 @@ -import numeral from "numeral" -import axios from "axios" -import { FixedNumber } from "@ethersproject/bignumber" -import { formatUnits } from "@ethersproject/units" import { PayloadAction } from "@reduxjs/toolkit/dist/createAction" import { createAsyncThunk, createSlice } from "@reduxjs/toolkit" import { CoingeckoID, Token } from "../../enums/token" import { TokenState, SetTokenBalanceActionPayload, - SetTokenConversionRateActionPayload, SetTokenLoadingActionPayload, + SetTokenBalanceErrorActionPayload, } from "../../types/token" import { exchangeAPI } from "../../utils/exchangeAPI" import Icon from "../../enums/icon" import getUsdBalance from "../../utils/getUsdBalance" +import { startAppListening } from "../listener" +import { walletConnected } from "../account" +import { fetchTokenBalances } from "./effects" export const fetchTokenPriceUSD = createAsyncThunk( "tokens/fetchTokenPriceUSD", @@ -24,78 +23,80 @@ export const fetchTokenPriceUSD = createAsyncThunk( } ) +export type TokensState = Record + export const tokenSlice = createSlice({ name: "tokens", initialState: { [Token.Keep]: { loading: false, balance: 0, - conversionRate: 4.87, text: Token.Keep, icon: Icon.KeepCircleBrand, usdConversion: 0, usdBalance: "0", + error: "", }, [Token.Nu]: { loading: false, balance: 0, - conversionRate: 2.66, text: Token.Nu, icon: Icon.NuCircleBrand, usdConversion: 0, usdBalance: "0", + error: "", }, [Token.T]: { loading: false, balance: 0, - conversionRate: 1, text: Token.T, icon: Icon.TCircleBrand, usdConversion: 0, usdBalance: "0", + error: "", }, - [Token.TBTC]: { + [Token.TBTCV1]: { loading: false, balance: 0, usdConversion: 0, usdBalance: "0", + error: "", }, - [Token.TBTCV2]: { + [Token.TBTC]: { loading: false, balance: 0, usdConversion: 0, usdBalance: "0", + error: "", }, - } as Record, + } as TokensState, reducers: { - setTokenLoading: ( + tokenBalanceFetching: ( state, action: PayloadAction ) => { - state[action.payload.token].loading = action.payload.loading + state[action.payload.token].loading = true }, - setTokenBalance: ( + tokenBalanceFetched: ( state, action: PayloadAction ) => { const { token, balance } = action.payload + state[token].loading = false state[token].balance = balance state[token].usdBalance = getUsdBalance( state[token].balance, state[token].usdConversion ) + state[token].error = "" }, - setTokenConversionRate: ( + tokenBalanceFetchFailed: ( state, - action: PayloadAction + action: PayloadAction ) => { - const { token, conversionRate } = action.payload - - const formattedConversionRate = numeral( - +conversionRate / 10 ** 15 - ).format("0.0000") - - state[token].conversionRate = formattedConversionRate + const { token, error } = action.payload + state[token].loading = false + state[token].error = error }, }, extraReducers: (builder) => { @@ -111,5 +112,16 @@ export const tokenSlice = createSlice({ }, }) -export const { setTokenBalance, setTokenLoading, setTokenConversionRate } = - tokenSlice.actions +export const { + tokenBalanceFetched, + tokenBalanceFetching, + tokenBalanceFetchFailed, +} = tokenSlice.actions + +export const registerTokensListeners = () => { + startAppListening({ + actionCreator: walletConnected, + effect: fetchTokenBalances, + }) +} +registerTokensListeners() diff --git a/src/threshold-ts/index.ts b/src/threshold-ts/index.ts index 6fd493e8a..ec74edd9d 100644 --- a/src/threshold-ts/index.ts +++ b/src/threshold-ts/index.ts @@ -1,6 +1,7 @@ import { MultiAppStaking } from "./mas" import { IMulticall, Multicall } from "./multicall" import { IStaking, Staking } from "./staking" +import { ITokens, Tokens } from "./tokens" import { ThresholdConfig } from "./types" import { IVendingMachines, VendingMachines } from "./vending-machine" @@ -9,6 +10,7 @@ export class Threshold { staking!: IStaking multiAppStaking!: MultiAppStaking vendingMachines!: IVendingMachines + token!: ITokens constructor(config: ThresholdConfig) { this._initialize(config) @@ -16,6 +18,7 @@ export class Threshold { private _initialize = (config: ThresholdConfig) => { this.multicall = new Multicall(config.ethereum) + this.token = new Tokens(config.ethereum) this.vendingMachines = new VendingMachines(config.ethereum) this.staking = new Staking( config.ethereum, diff --git a/src/threshold-ts/tokens/__tests__/tokens.test.ts b/src/threshold-ts/tokens/__tests__/tokens.test.ts new file mode 100644 index 000000000..e587a78ce --- /dev/null +++ b/src/threshold-ts/tokens/__tests__/tokens.test.ts @@ -0,0 +1,73 @@ +import T from "@threshold-network/solidity-contracts/artifacts/T.json" +import NuCypherToken from "@threshold-network/solidity-contracts/artifacts/NuCypherToken.json" +import KeepToken from "@keep-network/keep-core/artifacts/KeepToken.json" +import { ethers } from "ethers" +import { ITokens, Tokens } from ".." +import { ERC20TokenWithApproveAndCall } from "../erc20" +import { EthereumConfig } from "../../types" +import { getContractAddressFromTruffleArtifact } from "../../utils" + +jest.mock("../erc20", () => ({ + ...(jest.requireActual("../erc20") as {}), + ERC20TokenWithApproveAndCall: jest.fn(), +})) + +jest.mock("../../utils", () => ({ + ...(jest.requireActual("../../utils") as {}), + getContractAddressFromTruffleArtifact: jest.fn(), +})) + +jest.mock("@threshold-network/solidity-contracts/artifacts/T.json", () => ({ + address: "0x6A55B762689Ba514569E565E439699aBC731f156", + abi: [], +})) + +jest.mock( + "@threshold-network/solidity-contracts/artifacts/NuCypherToken.json", + () => ({ + address: "0xd696d5a9b083959587F30e487038529a876b08C2", + abi: [], + }) +) + +jest.mock("@keep-network/keep-core/artifacts/KeepToken.json", () => ({ + address: "0x73A63e2Be2D911dc7eFAc189Bfdf48FbB6532B5b", + abi: [], +})) + +describe("ERC20 token test", () => { + let tokens: ITokens + let config: EthereumConfig + const account = "0xaC1933A3Ee78A26E16030801273fBa250631eD5f" + const keepTokenAddress = (KeepToken as unknown as { address: string }).address + + beforeEach(() => { + config = { + providerOrSigner: {} as ethers.providers.Provider, + chainId: 1, + account, + } + ;(getContractAddressFromTruffleArtifact as jest.Mock).mockReturnValue( + keepTokenAddress + ) + tokens = new Tokens(config) + }) + + test("should create the Tokens wrapper correctly", () => { + expect(ERC20TokenWithApproveAndCall).toHaveBeenNthCalledWith(1, config, { + address: T.address, + abi: T.abi, + }) + expect(ERC20TokenWithApproveAndCall).toHaveBeenNthCalledWith(2, config, { + address: NuCypherToken.address, + abi: NuCypherToken.abi, + }) + expect(ERC20TokenWithApproveAndCall).toHaveBeenNthCalledWith(3, config, { + address: keepTokenAddress, + abi: KeepToken.abi, + }) + expect(tokens.t).toBeInstanceOf(ERC20TokenWithApproveAndCall) + expect(tokens.keep).toBeInstanceOf(ERC20TokenWithApproveAndCall) + expect(tokens.nu).toBeInstanceOf(ERC20TokenWithApproveAndCall) + }) +}) diff --git a/src/threshold-ts/tokens/erc20/__tests__/erc20.test.ts b/src/threshold-ts/tokens/erc20/__tests__/erc20.test.ts new file mode 100644 index 000000000..8e2a0827c --- /dev/null +++ b/src/threshold-ts/tokens/erc20/__tests__/erc20.test.ts @@ -0,0 +1,143 @@ +import { BigNumber, ContractTransaction, ethers } from "ethers" +import { + BaseERC20Token, + ERC20TokenWithApproveAndCall, + IERC20, + IERC20WithApproveAndCall, +} from ".." +import { EthereumConfig } from "../../../types" +import { getContract } from "../../../utils" + +jest.mock("../../../utils", () => ({ + ...(jest.requireActual("../../../utils") as {}), + getContract: jest.fn(), +})) + +describe("ERC20 token test", () => { + let config: EthereumConfig + let artifact: { abi: any; address: string } + + const account = "0xaC1933A3Ee78A26E16030801273fBa250631eD5f" + const spender = "0x6c7960687253e43e98A0d3d602dD5085d2443e75" + const amount = BigNumber.from("100000000") + + beforeEach(() => { + artifact = { + abi: [], + address: "0xC49C8567DE3Cd9aA28c36b88dFb2A0EfF3BE41cE", + } + + config = { + providerOrSigner: {} as ethers.providers.Provider, + chainId: 1, + account, + } + }) + + describe("Base ERC20 token test", () => { + let erc20: IERC20 + let mockErc20TokenContract: { + balanceOf: jest.MockedFn + allowance: jest.MockedFn + totalSupply: jest.MockedFn + } + + beforeEach(() => { + mockErc20TokenContract = { + balanceOf: jest.fn(), + allowance: jest.fn(), + totalSupply: jest.fn(), + } + ;(getContract as jest.Mock).mockImplementation( + () => mockErc20TokenContract + ) + + erc20 = new BaseERC20Token(config, artifact) + }) + + test("should create the base erc20 token instance", () => { + expect(getContract).toHaveBeenCalledWith( + artifact.address, + artifact.abi, + config.providerOrSigner, + config.account + ) + expect(erc20.contract).toEqual(mockErc20TokenContract) + }) + + test("should return balance of a given address", async () => { + mockErc20TokenContract.balanceOf.mockResolvedValue(amount) + + const result = await erc20.balanceOf(account) + + expect(mockErc20TokenContract.balanceOf).toHaveBeenCalledWith(account) + expect(result).toEqual(amount) + }) + test("should return allowed amount of tokens that spender will be allowed to spend on behalf of owner", async () => { + mockErc20TokenContract.allowance.mockResolvedValue(amount) + + const result = await erc20.allowance(account, spender) + + expect(mockErc20TokenContract.allowance).toHaveBeenCalledWith( + account, + spender + ) + expect(result).toEqual(amount) + }) + + test("should return the total supply of token", async () => { + mockErc20TokenContract.totalSupply.mockResolvedValue(amount) + + const result = await erc20.totalSupply() + + expect(mockErc20TokenContract.totalSupply).toHaveBeenCalled() + expect(result).toEqual(amount) + }) + }) + + describe("ERC20 with approve and call pattern test", () => { + let erc20withApproveAndCall: IERC20WithApproveAndCall + let mockErc20TokenContract: { + balanceOf: jest.MockedFn + allowance: jest.MockedFn + totalSupply: jest.MockedFn + approveAndCall: jest.MockedFn + } + + beforeEach(() => { + mockErc20TokenContract = { + balanceOf: jest.fn(), + allowance: jest.fn(), + totalSupply: jest.fn(), + approveAndCall: jest.fn(), + } + ;(getContract as jest.Mock).mockImplementation( + () => mockErc20TokenContract + ) + + erc20withApproveAndCall = new ERC20TokenWithApproveAndCall( + config, + artifact + ) + }) + + test("should call the approve and call correctly", async () => { + const extraData = "0x123456789" + const mockTx = {} as ContractTransaction + mockErc20TokenContract.approveAndCall.mockResolvedValue(mockTx) + + const result = await erc20withApproveAndCall.approveAndCall( + spender, + amount.toString(), + extraData + ) + + expect(mockErc20TokenContract.approveAndCall).toHaveBeenCalledWith( + spender, + amount.toString(), + extraData + ) + expect(result).toEqual(mockTx) + }) + }) +}) diff --git a/src/threshold-ts/tokens/erc20/index.ts b/src/threshold-ts/tokens/erc20/index.ts new file mode 100644 index 000000000..995d0b2a1 --- /dev/null +++ b/src/threshold-ts/tokens/erc20/index.ts @@ -0,0 +1,68 @@ +import { BigNumber, Contract, ContractTransaction } from "ethers" +import { EthereumConfig } from "../../types" +import { getContract } from "../../utils" + +export interface IERC20 { + contract: Contract + balanceOf: (account: string) => Promise + allowance: (owner: string, spender: string) => Promise + approve: (spender: string, amount: string) => Promise + totalSupply: () => Promise +} + +export interface IERC20WithApproveAndCall extends IERC20 { + approveAndCall: ( + spender: string, + amount: string, + extraData: string + ) => Promise +} + +export class BaseERC20Token implements IERC20 { + protected _contract: Contract + + constructor(config: EthereumConfig, artifact: { abi: any; address: string }) { + this._contract = getContract( + artifact.address, + artifact.abi, + config.providerOrSigner, + config.account + ) + } + + balanceOf = async (account: string): Promise => { + return await this._contract.balanceOf(account) + } + + allowance = async (owner: string, spender: string): Promise => { + return await this._contract.allowance(owner, spender) + } + + totalSupply = async (): Promise => { + return await this._contract.totalSupply() + } + + approve = async ( + spender: string, + amount: string + ): Promise => { + return await this._contract.approve(spender, amount) + } + + get contract() { + return this._contract + } +} + +export class ERC20TokenWithApproveAndCall + extends BaseERC20Token + implements IERC20WithApproveAndCall +{ + approveAndCall = async ( + spender: string, + amount: string, + extraData: string + ): Promise => { + return await this._contract.approveAndCall(spender, amount, extraData) + } +} diff --git a/src/threshold-ts/tokens/index.ts b/src/threshold-ts/tokens/index.ts new file mode 100644 index 000000000..b05b454d3 --- /dev/null +++ b/src/threshold-ts/tokens/index.ts @@ -0,0 +1,47 @@ +import T from "@threshold-network/solidity-contracts/artifacts/T.json" +import NuCypherToken from "@threshold-network/solidity-contracts/artifacts/NuCypherToken.json" +import KeepToken from "@keep-network/keep-core/artifacts/KeepToken.json" +import TBTCV1Token from "@keep-network/tbtc/artifacts/TBTCToken.json" +import TBTC from "@keep-network/tbtc-v2/artifacts/TBTC.json" +import { IERC20WithApproveAndCall, ERC20TokenWithApproveAndCall } from "./erc20" +import { EthereumConfig } from "../types" +import { getContractAddressFromTruffleArtifact } from "../utils" + +export interface ITokens { + t: IERC20WithApproveAndCall + keep: IERC20WithApproveAndCall + nu: IERC20WithApproveAndCall + tbtcv1: IERC20WithApproveAndCall + tbtc: IERC20WithApproveAndCall +} + +export class Tokens implements ITokens { + public readonly t: IERC20WithApproveAndCall + public readonly nu: IERC20WithApproveAndCall + public readonly keep: IERC20WithApproveAndCall + public readonly tbtcv1: IERC20WithApproveAndCall + public readonly tbtc: IERC20WithApproveAndCall + + constructor(config: EthereumConfig) { + this.t = new ERC20TokenWithApproveAndCall(config, { + address: T.address, + abi: T.abi, + }) + this.nu = new ERC20TokenWithApproveAndCall(config, { + address: NuCypherToken.address, + abi: NuCypherToken.abi, + }) + this.keep = new ERC20TokenWithApproveAndCall(config, { + address: getContractAddressFromTruffleArtifact(KeepToken), + abi: KeepToken.abi, + }) + this.tbtcv1 = new ERC20TokenWithApproveAndCall(config, { + address: getContractAddressFromTruffleArtifact(TBTCV1Token), + abi: TBTCV1Token.abi, + }) + this.tbtc = new ERC20TokenWithApproveAndCall(config, { + address: TBTC.address, + abi: TBTC.abi, + }) + } +} diff --git a/src/types/token.ts b/src/types/token.ts index 3dde6a7a1..b89d3b0e1 100644 --- a/src/types/token.ts +++ b/src/types/token.ts @@ -2,16 +2,17 @@ import { Contract } from "@ethersproject/contracts" import { Token } from "../enums" import { TransactionType } from "../enums/transactionType" import Icon from "../enums/icon" +import { IERC20, IERC20WithApproveAndCall } from "../threshold-ts/tokens/erc20" export interface TokenState { loading: boolean - conversionRate: number | string text: string icon: Icon balance: number | string usdConversion: number usdBalance: string decimals?: number + error: string } export interface SetTokenBalanceActionPayload { @@ -19,14 +20,13 @@ export interface SetTokenBalanceActionPayload { balance: number | string } -export interface SetTokenConversionRateActionPayload { +export interface SetTokenBalanceErrorActionPayload { token: Token - conversionRate: string | number + error: string } export interface SetTokenLoadingActionPayload { token: Token - loading: boolean } export interface SetTokenBalance { @@ -37,14 +37,7 @@ export interface SetTokenLoading { payload: SetTokenLoadingActionPayload } -export interface SetTokenConversionRate { - payload: SetTokenConversionRateActionPayload -} - -export type TokenActionTypes = - | SetTokenBalance - | SetTokenLoading - | SetTokenConversionRate +export type TokenActionTypes = SetTokenBalance | SetTokenLoading export interface UseTokenState { (): { @@ -57,12 +50,9 @@ export interface UseTokenState { token: Token, balance: number | string ) => TokenActionTypes - setTokenConversionRate: ( - token: Token, - conversionRate: number | string - ) => TokenActionTypes setTokenLoading: (token: Token, loading: boolean) => TokenActionTypes fetchTokenPriceUSD: (token: Token) => void + setTokenBalanceError: (token: Token, error: string) => TokenActionTypes } } @@ -71,13 +61,13 @@ export interface BalanceOf { } export interface Approve { - (transactionType: TransactionType): any + (spender: string, amount: string): any } export interface UseErc20Interface { - (tokenAddress: string, withSignerIfPossible?: boolean, abi?: any): { - approve: Approve + (token: IERC20WithApproveAndCall | IERC20, tokenName: Token): { balanceOf: BalanceOf + wrapper: IERC20 | IERC20WithApproveAndCall contract: Contract | null } } diff --git a/src/web3/hooks/index.ts b/src/web3/hooks/index.ts index c18ab75d6..b0387739b 100644 --- a/src/web3/hooks/index.ts +++ b/src/web3/hooks/index.ts @@ -18,3 +18,4 @@ export * from "./useTStakingContract" export * from "./useKeepTokenStakingContract" export * from "./usePREContract" export * from "./useClaimMerkleRewardsTransaction" +export * from "./useTBTCv2TokenContract" diff --git a/src/web3/hooks/useERC20.ts b/src/web3/hooks/useERC20.ts index 51febb27c..1d352d065 100644 --- a/src/web3/hooks/useERC20.ts +++ b/src/web3/hooks/useERC20.ts @@ -1,76 +1,36 @@ import { useCallback } from "react" -import { MaxUint256 } from "@ethersproject/constants" -import { useWeb3React } from "@web3-react/core" -import { useContract } from "./useContract" -import ERC20_ABI from "../abi/ERC20.json" import { Token } from "../../enums" import { useTokenState } from "../../hooks/useTokenState" -import { Approve, UseErc20Interface } from "../../types/token" -import { useTransaction } from "../../hooks/useTransaction" -import { TransactionStatus } from "../../enums/transactionType" -import { isWalletRejectionError } from "../../utils/isWalletRejectionError" -import { once } from "@storybook/node-logger" +import { UseErc20Interface } from "../../types/token" +import { + IERC20, + IERC20WithApproveAndCall, +} from "../../threshold-ts/tokens/erc20" export const useErc20TokenContract: UseErc20Interface = ( - tokenAddress, - withSignerIfPossible, - abi = ERC20_ABI + token: IERC20WithApproveAndCall | IERC20, + tokenName: Token ) => { - const { account } = useWeb3React() - const { setTokenLoading, setTokenBalance } = useTokenState() - const { setTransactionStatus } = useTransaction() - - // TODO: Figure out how to type the ERC20 contract - // return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible) - const contract = useContract(tokenAddress, abi, withSignerIfPossible) - - const approve: Approve = useCallback( - async (transactionType) => { - if (account) { - try { - setTransactionStatus(transactionType, TransactionStatus.PendingWallet) - const tx = await contract?.approve( - tokenAddress, - MaxUint256.toString() - ) - setTransactionStatus( - transactionType, - TransactionStatus.PendingOnChain - ) - await tx.wait(1) - setTransactionStatus(transactionType, TransactionStatus.Succeeded) - } catch (error: any) { - setTransactionStatus( - transactionType, - isWalletRejectionError(error) - ? TransactionStatus.Rejected - : TransactionStatus.Failed - ) - } - } - }, - [contract, account] - ) + const { setTokenLoading, setTokenBalance, setTokenBalanceError } = + useTokenState() const balanceOf = useCallback( - async (token: Token) => { - if (account) { - try { - setTokenLoading(token, true) - const balance = await contract?.balanceOf(account as string) - setTokenBalance(token, balance.toString()) - setTokenLoading(token, false) - } catch (error) { - setTokenLoading(Token.Nu, false) - console.log( - `Error: Fetching ${token} balance failed for ${account}`, - error - ) - } + async (address) => { + try { + setTokenLoading(tokenName, true) + const balance = await token.balanceOf(address) + setTokenBalance(tokenName, balance.toString()) + } catch (error) { + const errorMessage = `Error: Fetching ${token} balance failed for ${address}` + setTokenBalanceError(tokenName, errorMessage) + console.log( + `Error: Fetching ${token} balance failed for ${address}`, + error + ) } }, - [account, contract] + [token, tokenName, setTokenLoading, setTokenBalanceError, setTokenBalance] ) - return { approve, balanceOf, contract } + return { balanceOf, contract: token.contract, wrapper: token } } diff --git a/src/web3/hooks/useKeep.ts b/src/web3/hooks/useKeep.ts index 91d093981..a3879cb6c 100644 --- a/src/web3/hooks/useKeep.ts +++ b/src/web3/hooks/useKeep.ts @@ -1,36 +1,9 @@ -import KeepToken from "@keep-network/keep-core/artifacts/KeepToken.json" import { useErc20TokenContract } from "./useERC20" import { Token } from "../../enums" -import { TransactionType } from "../../enums/transactionType" -import { Contract } from "@ethersproject/contracts" -import { getContractAddressFromTruffleArtifact } from "../../utils/getContract" +import { useThreshold } from "../../contexts/ThresholdContext" -export interface UseKeep { - (): { - approveKeep: () => void - fetchKeepBalance: () => void - contract: Contract | null - } -} - -export const useKeep: UseKeep = () => { - const { balanceOf, approve, contract } = useErc20TokenContract( - getContractAddressFromTruffleArtifact(KeepToken), - undefined, - KeepToken.abi - ) - - const approveKeep = () => { - approve(TransactionType.ApproveKeep) - } - - const fetchKeepBalance = () => { - balanceOf(Token.Keep) - } +export const useKeep = () => { + const threshold = useThreshold() - return { - approveKeep, - fetchKeepBalance, - contract, - } + return useErc20TokenContract(threshold.token.keep, Token.Keep) } diff --git a/src/web3/hooks/useNu.ts b/src/web3/hooks/useNu.ts index b902a337f..07d69ea9a 100644 --- a/src/web3/hooks/useNu.ts +++ b/src/web3/hooks/useNu.ts @@ -1,35 +1,8 @@ -import NuCypherToken from "@threshold-network/solidity-contracts/artifacts/NuCypherToken.json" -import { Contract } from "@ethersproject/contracts" import { useErc20TokenContract } from "./useERC20" import { Token } from "../../enums" -import { TransactionType } from "../../enums/transactionType" +import { useThreshold } from "../../contexts/ThresholdContext" -export interface UseNu { - (): { - approveNu: () => void - fetchNuBalance: () => void - contract: Contract | null - } -} - -export const useNu: UseNu = () => { - const { balanceOf, approve, contract } = useErc20TokenContract( - NuCypherToken.address, - undefined, - NuCypherToken.abi - ) - - const approveNu = () => { - approve(TransactionType.ApproveNu) - } - - const fetchNuBalance = () => { - balanceOf(Token.Nu) - } - - return { - fetchNuBalance, - approveNu, - contract, - } +export const useNu = () => { + const threshold = useThreshold() + return useErc20TokenContract(threshold.token.nu, Token.Nu) } diff --git a/src/web3/hooks/useT.ts b/src/web3/hooks/useT.ts index 68907448d..b22795688 100644 --- a/src/web3/hooks/useT.ts +++ b/src/web3/hooks/useT.ts @@ -1,34 +1,8 @@ -import T from "@threshold-network/solidity-contracts/artifacts/T.json" -import { Contract } from "@ethersproject/contracts" import { useErc20TokenContract } from "./useERC20" -import { Token, TransactionType } from "../../enums" +import { Token } from "../../enums" +import { useThreshold } from "../../contexts/ThresholdContext" -export interface UseT { - (): { - approveT: () => void - fetchTBalance: () => void - contract: Contract | null - } -} - -export const useT: UseT = () => { - const { balanceOf, approve, contract } = useErc20TokenContract( - T.address, - undefined, - T.abi - ) - - const approveT = () => { - approve(TransactionType.ApproveT) - } - - const fetchTBalance = () => { - balanceOf(Token.T) - } - - return { - approveT, - fetchTBalance, - contract, - } +export const useT = () => { + const threshold = useThreshold() + return useErc20TokenContract(threshold.token.t, Token.T) } diff --git a/src/web3/hooks/useTBTCTokenContract.ts b/src/web3/hooks/useTBTCTokenContract.ts index 1900fd6c7..7f5577138 100644 --- a/src/web3/hooks/useTBTCTokenContract.ts +++ b/src/web3/hooks/useTBTCTokenContract.ts @@ -1,7 +1,8 @@ -import TBTCToken from "@keep-network/tbtc/artifacts/TBTCToken.json" import { useErc20TokenContract } from "./useERC20" -import { getContractAddressFromTruffleArtifact } from "../../utils/getContract" +import { useThreshold } from "../../contexts/ThresholdContext" +import { Token } from "../../enums" export const useTBTCTokenContract = () => { - return useErc20TokenContract(getContractAddressFromTruffleArtifact(TBTCToken)) + const threshold = useThreshold() + return useErc20TokenContract(threshold.token.tbtcv1, Token.TBTCV1) } diff --git a/src/web3/hooks/useTBTCv2TokenContract.ts b/src/web3/hooks/useTBTCv2TokenContract.ts index 5235886ec..60b0b3b32 100644 --- a/src/web3/hooks/useTBTCv2TokenContract.ts +++ b/src/web3/hooks/useTBTCv2TokenContract.ts @@ -1,29 +1,8 @@ import { useErc20TokenContract } from "./useERC20" -import { Token, TransactionType } from "../../enums" -import TBTC from "@keep-network/tbtc-v2/artifacts/TBTC.json" -import { featureFlags } from "../../constants" +import { Token } from "../../enums" +import { useThreshold } from "../../contexts/ThresholdContext" -export const useTBTCv2TokenContract: any = () => { - if (featureFlags.TBTC_V2) { - const { balanceOf, approve, contract } = useErc20TokenContract( - TBTC.address, - undefined, - TBTC.abi - ) - - // TODO: - const approveTBTCV2 = () => {} - - const fetchTBTCV2Balance = () => { - balanceOf(Token.TBTCV2) - } - - return { - fetchTBTCV2Balance, - approveTBTCV2, - contract, - } - } - - return undefined +export const useTBTCv2TokenContract = () => { + const threshold = useThreshold() + return useErc20TokenContract(threshold.token.tbtc, Token.TBTC) }