From a8509d5451fc4d03c0ed9c7c1504ceaf76d3082a Mon Sep 17 00:00:00 2001 From: greypixel Date: Wed, 27 Mar 2024 20:48:33 +0000 Subject: [PATCH] WIP custom tokens --- src/features/claims/AddCustomTokenForm.tsx | 66 ++++++++ src/features/claims/ClaimForm.tsx | 23 ++- .../claims/ClaimableTokensLineItem.tsx | 20 ++- .../claims/hooks/useClaimCalculations.ts | 38 ++++- .../claims/hooks/useClaimableAmounts.ts | 79 ++++++++-- .../claims/hooks/useDefillamaBatchPrices.ts | 33 ++-- .../common/hooks/useMultipleTokenInfo.ts | 11 +- .../votes/store/useClaimSelectionStore.tsx | 148 +++++++++++------- src/main.tsx | 12 +- 9 files changed, 330 insertions(+), 100 deletions(-) create mode 100644 src/features/claims/AddCustomTokenForm.tsx diff --git a/src/features/claims/AddCustomTokenForm.tsx b/src/features/claims/AddCustomTokenForm.tsx new file mode 100644 index 0000000..dc08357 --- /dev/null +++ b/src/features/claims/AddCustomTokenForm.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { Address, isAddress } from "viem"; +import { useChainId } from "wagmi"; +import { useMultipleTokenInfo } from "../common/hooks/useMultipleTokenInfo"; +import { useClaimSelectionStore } from "../votes/store/useClaimSelectionStore"; +import { useClaimableAmounts } from "./hooks/useClaimableAmounts"; + +export const AddCustomTokenForm = ({ className }: { className?: string }) => { + const [address, setAddress] = useState(""); + const chainId = useChainId(); + + const isValidAddress = isAddress(address || ""); + + const [{ data: tokenInfo, isError }] = useMultipleTokenInfo({ + chainId, + tokenAddresses: [address as Address], + enabled: isValidAddress, + }); + + const { addCustomToken, selectedClaims, pointsClaimableByEpoch } = + useClaimSelectionStore((state) => ({ + addCustomToken: state.addCustomToken, + selectedClaims: state.selectedClaims, + pointsClaimableByEpoch: state.pointsClaimableByEpoch, + })); + + const totalPointsClaimable = Object.values(pointsClaimableByEpoch).reduce( + (acc, epoch) => acc + epoch, + 0, + ); + + const pointsSelected = + selectedClaims.reduce((acc, claim) => acc + claim.value, 0) || + totalPointsClaimable; + + const { refetch: refetchClaimable } = useClaimableAmounts(pointsSelected); + + return ( +
+ setAddress(e.target.value)} + className="text-black" + /> + {!isValidAddress &&
Invalid address
} +
+ {tokenInfo && ( +
+
{tokenInfo.name}
+
{tokenInfo.symbol}
+
{tokenInfo.decimals}
+ +
+ )} +
+
+ ); +}; diff --git a/src/features/claims/ClaimForm.tsx b/src/features/claims/ClaimForm.tsx index bc5edd5..e28bbdf 100644 --- a/src/features/claims/ClaimForm.tsx +++ b/src/features/claims/ClaimForm.tsx @@ -17,6 +17,7 @@ import { Button } from "../common/Button"; import { TransactionTracker } from "../common/TransactionTracker"; import { formatNumber } from "../common/utils/formatNumber"; import { useClaimSelectionStore } from "../votes/store/useClaimSelectionStore"; +import { AddCustomTokenForm } from "./AddCustomTokenForm"; import { ClaimableTokensLineItem, ClaimableTokensLineItemLoading, @@ -25,6 +26,7 @@ import { useClaimableAmounts } from "./hooks/useClaimableAmounts"; import { useResetClaimStatus } from "./hooks/useResetClaimStatus"; export const ClaimForm = ({}: {}) => { + const [newCustomTokenAddress, setNewCustomTokenAddress] = useState(); const [pool] = useContractAddresses([ContractTypes.AirSwapPool], {}); const { address: connectedAccount } = useAccount(); @@ -210,12 +212,21 @@ export const ClaimForm = ({}: {}) => { gridTemplateColumns: "auto 1fr auto", }} > +
+ +
{claimable.map( - ({ claimableAmount, claimableValue, price, tokenInfo }, i) => { - const isLoaded = - tokenInfo?.decimals && - claimableAmount != null && - price; + ( + { + claimableAmount, + claimableValue, + price, + tokenInfo, + isCustomToken, + }, + i, + ) => { + const isLoaded = tokenInfo?.decimals && claimableAmount != null; return isLoaded ? ( { symbol={tokenInfo?.symbol || "???"} value={claimableValue || 0} key={tokenInfo?.address || i} + isCustomToken={isCustomToken} + address={tokenInfo?.address} /> ) : ( diff --git a/src/features/claims/ClaimableTokensLineItem.tsx b/src/features/claims/ClaimableTokensLineItem.tsx index f5ad383..dfa9e91 100644 --- a/src/features/claims/ClaimableTokensLineItem.tsx +++ b/src/features/claims/ClaimableTokensLineItem.tsx @@ -1,6 +1,10 @@ +import { IoMdClose } from "react-icons/io"; import { twJoin } from "tailwind-merge"; +import { Address } from "viem"; +import { useChainId } from "wagmi"; import { Checkbox } from "../common/Checkbox"; import { formatNumber } from "../common/utils/formatNumber"; +import { useClaimSelectionStore } from "../votes/store/useClaimSelectionStore"; export const ClaimableTokensLineItem = ({ symbol, @@ -9,6 +13,8 @@ export const ClaimableTokensLineItem = ({ value, isSelected, onSelect, + isCustomToken = false, + address, }: { symbol: string; decimals: number; @@ -16,7 +22,14 @@ export const ClaimableTokensLineItem = ({ value: number; isSelected: boolean; onSelect: () => void; + isCustomToken?: boolean; + address: Address; }) => { + const chainId = useChainId(); + const removeCustomToken = useClaimSelectionStore( + (state) => state.removeCustomToken, + ); + return ( <> state === true && onSelect()} /> - + {formatNumber(amount, decimals)} {symbol} + {isCustomToken && ( + + )} { const { chain } = useNetwork(); const [poolContract] = useContractAddresses([ContractTypes.AirSwapPool], { @@ -35,13 +37,37 @@ export const useClaimCalculations = ( return multicallResponse.map((response) => response.result || 0n); }; - return useQuery(["claimCalculations", chain!.id, points], fetch, { + const claimableTokenAddressesHash = hashMessage(claimableTokens.join(",")); + + console.log({ enabled: Boolean( - _points > 0 && poolContract.address && claimableTokens.length && chain, + enabled && + _points > 0 && + poolContract.address && + claimableTokens.length && + chain, ), - // 1 minute - cacheTime: 60_000, - staleTime: 30_000, - refetchInterval: 30_000, + enabledb: enabled, + _points, + pooladdr: poolContract.address, + claimableTokens: claimableTokens.length, + chain, }); + return useQuery( + ["claimCalculations", chain!.id, claimableTokenAddressesHash, points], + fetch, + { + enabled: Boolean( + enabled && + _points > 0 && + poolContract.address && + claimableTokens.length && + chain, + ), + // 1 minute + cacheTime: 60_000, + staleTime: 30_000, + refetchInterval: 30_000, + }, + ); }; diff --git a/src/features/claims/hooks/useClaimableAmounts.ts b/src/features/claims/hooks/useClaimableAmounts.ts index 9ddcb2f..e06f95f 100644 --- a/src/features/claims/hooks/useClaimableAmounts.ts +++ b/src/features/claims/hooks/useClaimableAmounts.ts @@ -1,9 +1,10 @@ import BigNumber from "bignumber.js"; import { useMemo } from "react"; +import { getAddress } from "viem"; import { Address, useChainId } from "wagmi"; import { useMultipleTokenInfo } from "../../common/hooks/useMultipleTokenInfo"; +import { useClaimSelectionStore } from "../../votes/store/useClaimSelectionStore"; import { - TestnetClaimableToken, claimableTokens, testnetClaimableTokens, } from "../config/claimableTokens"; @@ -18,15 +19,45 @@ const testnets = Object.keys(testnetClaimableTokens).map((t) => parseInt(t)); export const useClaimableAmounts = (points: number) => { const chainId = useChainId() as SupportedChainId; - const isTestnet = testnets.includes(chainId); + const savedCustomTokens = useClaimSelectionStore( + (state) => state.customTokens, + ); + + const { tokenList, tokenAddresses, customTokens } = useMemo(() => { + const isTestnet = testnets.includes(chainId); + const tokens = isTestnet + ? testnetClaimableTokens[chainId] + : claimableTokens[chainId]; + + if (isTestnet) { + return { + tokenList: tokens, + tokenAddresses: tokens as Address[], + customTokens: [] as Address[], + }; + } + + const defaultTokens: Address[] = tokens as Address[]; - const tokenList = isTestnet - ? testnetClaimableTokens[chainId] - : claimableTokens[chainId]; + // filter out custom tokens that are already in the tokenList. + const customTokensForChain = savedCustomTokens[chainId] || []; + const checksummedTokenList = defaultTokens.map((token) => + getAddress(token), + ); + const customTokensNotInDefaults = customTokensForChain.filter( + (customToken) => !checksummedTokenList.includes(customToken), + ); - const tokenAddresses = isTestnet - ? (tokenList as TestnetClaimableToken[]).map((t) => t.address) - : (tokenList as Address[]); + return { + tokenList: customTokensNotInDefaults.length + ? [...defaultTokens, ...customTokensNotInDefaults] + : (defaultTokens as Address[]), + tokenAddresses: customTokensNotInDefaults.length + ? [...defaultTokens, ...customTokensNotInDefaults] + : (defaultTokens as Address[]), + customTokens: customTokensNotInDefaults, + }; + }, [savedCustomTokens, chainId]); const { data: claimableAmounts, refetch } = useClaimCalculations( points, @@ -40,17 +71,31 @@ export const useClaimableAmounts = (points: number) => { const { data: prices } = useDefiLlamaBatchPrices({ chainId, - tokenAddresses: tokenList.map((token) => - typeof token === "object" ? token.mainnetEquivalentAddress : token, - ), + tokenAddresses: tokenList + .map((token) => { + const addr = + typeof token === "object" ? token.mainnetEquivalentAddress : token; + return typeof token === "object" + ? token.mainnetEquivalentAddress + : token; + }) + .concat(chainId === 1 ? customTokens : []), }); return useMemo(() => { const data = tokenList - .map((_, index) => { + .map((token, index) => { + const address = typeof token === "object" ? token.address : token; const tokenInfo = tokenInfoResults[index].data; const price = prices?.[index].price; const claimableAmount = claimableAmounts?.[index]; + console.log( + "symbol", + tokenInfo?.symbol, + claimableAmount, + "price", + price, + ); const claimableValue = tokenInfo?.decimals && price && @@ -65,9 +110,17 @@ export const useClaimableAmounts = (points: number) => { tokenInfo, price, claimableValue, + isCustomToken: customTokens.includes(getAddress(address)), }; }) .sort((a, b) => (b.claimableValue || 0) - (a.claimableValue || 0)); return { data, refetch }; - }, [prices, tokenInfoResults, claimableAmounts, tokenList, refetch]); + }, [ + prices, + tokenInfoResults, + claimableAmounts, + tokenList, + refetch, + customTokens, + ]); }; diff --git a/src/features/claims/hooks/useDefillamaBatchPrices.ts b/src/features/claims/hooks/useDefillamaBatchPrices.ts index eecbf15..73580bd 100644 --- a/src/features/claims/hooks/useDefillamaBatchPrices.ts +++ b/src/features/claims/hooks/useDefillamaBatchPrices.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { hashMessage } from "viem"; import { useQuery } from "wagmi"; import { claimableTokens } from "../config/claimableTokens"; @@ -27,17 +28,25 @@ type CurrentPricesResponse = { }; const fetch = async (chainName: string, addresses: `0x${string}`[]) => { - const response = await axios.get( - `${path}/${addresses - .map((address) => `${chainName}:${address}`) - .join(",")}`, - ); - const results = response.data.coins; - // return an array of {address: price} objects, where `price` is results[address].price - return addresses.map((address) => ({ - address, - price: results[`${chainName}:${address}`].price, - })); + console.log("fetching prices for", addresses); + if (addresses.length === 37) debugger; + try { + const response = await axios.get( + `${path}/${addresses + .map((address) => `${chainName}:${address}`) + .join(",")}`, + ); + const results = response.data.coins; + + // return an array of {address: price} objects, where `price` is results[address].price + return addresses.map((address) => ({ + address, + price: results[`${chainName}:${address}`]?.price || 0, + })); + } catch (e) { + console.log(e); + throw e; + } }; export const useDefiLlamaBatchPrices = ({ @@ -48,7 +57,7 @@ export const useDefiLlamaBatchPrices = ({ tokenAddresses: `0x${string}`[]; }) => { return useQuery( - ["defillama", "prices", chainId, tokenAddresses], + ["defillama", "prices", chainId, hashMessage(tokenAddresses.join(""))], () => fetch(DefillamaChainNames[chainId], tokenAddresses), { cacheTime: 3_600_000, // 1 hour diff --git a/src/features/common/hooks/useMultipleTokenInfo.ts b/src/features/common/hooks/useMultipleTokenInfo.ts index b887900..9b2320f 100644 --- a/src/features/common/hooks/useMultipleTokenInfo.ts +++ b/src/features/common/hooks/useMultipleTokenInfo.ts @@ -45,17 +45,22 @@ const fetchTokenInfo = async ({ export const useMultipleTokenInfo = ({ chainId, tokenAddresses, + enabled = true, }: { tokenAddresses?: `0x${string}`[]; chainId?: number; + enabled?: boolean; }) => { - const enabled = - chainId != null && tokenAddresses != null && tokenAddresses.length > 0; + const _enabled = + enabled && + chainId != null && + tokenAddresses != null && + tokenAddresses.length > 0; const queries = useQueries({ queries: tokenAddresses!.map((tokenAddress) => ({ queryKey: [chainId, tokenAddress, "tokenInfo"] as TokenInfoQueryKey, queryFn: fetchTokenInfo, - enabled, + enabled: _enabled, cacheTime: 2_592_000_000, // 1 month staleTime: Infinity, // doesn't change })), diff --git a/src/features/votes/store/useClaimSelectionStore.tsx b/src/features/votes/store/useClaimSelectionStore.tsx index 6f7bd45..6ebdcc8 100644 --- a/src/features/votes/store/useClaimSelectionStore.tsx +++ b/src/features/votes/store/useClaimSelectionStore.tsx @@ -1,13 +1,15 @@ +import { Address, Hash, getAddress } from "viem"; import { create } from "zustand"; +import { persist } from "zustand/middleware"; export type Claim = { - tree: `0x${string}`; + tree: Hash; /** * NOTE: This is a number of points, it needs to be converted to a bigint * before it is sent to the contract. */ value: number; - proof: `0x${string}`[]; + proof: Hash[]; }; export type SelectedClaimState = { @@ -18,6 +20,7 @@ export type SelectedClaimState = { */ selectedClaims: Claim[]; allClaims: Claim[]; + customTokens: Record; /** Amount of points claimable by epoch - set by the past epoch card when the * claim is loaded, and used by the claim float to determine the total number * of points available for claiming. @@ -25,7 +28,9 @@ export type SelectedClaimState = { pointsClaimableByEpoch: Record; setPointsClaimableForEpoch: (epoch: string, points: number) => void; addClaim: (claim: Claim) => void; - removeClaimForTree: (tree: `0x${string}`) => void; + addCustomToken: (chainId: number, address: Address) => void; + removeCustomToken: (chainId: number, address: Address) => void; + removeClaimForTree: (tree: Hash) => void; setClaimSelected: (claim: Claim, selected: boolean) => void; toggleClaimSelected: (claim: Claim) => void; clearSelectedClaims: () => void; @@ -37,6 +42,7 @@ export type SelectedClaimState = { }; const defaultState = { + customTokens: [], selectedClaims: [], allClaims: [], pointsClaimableByEpoch: {}, @@ -44,58 +50,88 @@ const defaultState = { isClaimLoading: false, }; -export const useClaimSelectionStore = create( - (set, get) => ({ - ...defaultState, - addClaim(claim: Claim) { - set((state) => { - // only add claim if it doesn't already exist (check `tree`) - const allClaims = state.allClaims.find((c) => c.tree === claim.tree) - ? state.allClaims - : [...state.allClaims, claim]; - return { allClaims }; - }); +export const useClaimSelectionStore = create()( + persist( + (set, get) => ({ + ...defaultState, + addClaim(claim: Claim) { + set((state) => { + // only add claim if it doesn't already exist (check `tree`) + const allClaims = state.allClaims.find((c) => c.tree === claim.tree) + ? state.allClaims + : [...state.allClaims, claim]; + return { allClaims }; + }); + }, + removeClaimForTree(tree: Hash) { + set((state) => { + const allClaims = state.allClaims.filter((c) => c.tree !== tree); + return { allClaims }; + }); + }, + setPointsClaimableForEpoch(epoch: string, points: number) { + set((state) => { + const pointsClaimableByEpoch = { + ...state.pointsClaimableByEpoch, + [epoch]: points, + }; + return { pointsClaimableByEpoch }; + }); + }, + setClaimSelected(claim: Claim, selected: boolean) { + set((state) => { + const selectedClaims = selected + ? [...state.selectedClaims, claim] + : state.selectedClaims.filter((c) => c.tree !== claim.tree); + return { selectedClaims }; + }); + }, + isClaimSelected(tree: string) { + return !!get().selectedClaims.find((c) => c.tree === tree); + }, + toggleClaimSelected(claim: Claim) { + get().setClaimSelected(claim, !get().isClaimSelected(claim.tree)); + }, + clearSelectedClaims() { + set({ selectedClaims: [] }); + }, + setShowClaimModal(show: boolean) { + set({ showClaimModal: show }); + }, + setIsClaimLoading(loading: boolean) { + set({ isClaimLoading: loading }); + }, + addCustomToken(chainId: number, address: Address) { + set((state) => { + const checksummedAddress = getAddress(address); + const customTokens = { + ...state.customTokens, + [chainId]: [ + ...(state.customTokens[chainId] || []), + checksummedAddress, + ], + }; + return { customTokens }; + }); + }, + removeCustomToken(chainId: number, address: Address) { + set((state) => { + const checksummedAddress = getAddress(address); + const customTokens = { + ...state.customTokens, + [chainId]: (state.customTokens[chainId] || []).filter( + (a) => a !== checksummedAddress, + ), + }; + return { customTokens }; + }); + }, + reset() { + set(defaultState); + }, + }), + { + name: "selected-claims", }, - removeClaimForTree(tree: `0x${string}`) { - set((state) => { - const allClaims = state.allClaims.filter((c) => c.tree !== tree); - return { allClaims }; - }); - }, - setPointsClaimableForEpoch(epoch: string, points: number) { - set((state) => { - const pointsClaimableByEpoch = { - ...state.pointsClaimableByEpoch, - [epoch]: points, - }; - return { pointsClaimableByEpoch }; - }); - }, - setClaimSelected(claim: Claim, selected: boolean) { - set((state) => { - const selectedClaims = selected - ? [...state.selectedClaims, claim] - : state.selectedClaims.filter((c) => c.tree !== claim.tree); - return { selectedClaims }; - }); - }, - isClaimSelected(tree: string) { - return !!get().selectedClaims.find((c) => c.tree === tree); - }, - toggleClaimSelected(claim: Claim) { - get().setClaimSelected(claim, !get().isClaimSelected(claim.tree)); - }, - clearSelectedClaims() { - set({ selectedClaims: [] }); - }, - setShowClaimModal(show: boolean) { - set({ showClaimModal: show }); - }, - setIsClaimLoading(loading: boolean) { - set({ isClaimLoading: loading }); - }, - reset() { - set(defaultState); - }, - }), + ), ); diff --git a/src/main.tsx b/src/main.tsx index 0bb5dc9..a9c68c0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,8 +12,7 @@ import { CoinbaseWalletConnector } from "wagmi/connectors/coinbaseWallet"; import { InjectedConnector } from "wagmi/connectors/injected"; import { MetaMaskConnector } from "wagmi/connectors/metaMask"; import { WalletConnectConnector } from "wagmi/connectors/walletConnect"; -import { infuraProvider } from "wagmi/providers/infura"; -import { publicProvider } from "wagmi/providers/public"; +import { jsonRpcProvider } from "wagmi/providers/jsonRpc"; import App from "./App.tsx"; import AirSwapLogo from "./assets/airswap-logo.svg"; import "./index.css"; @@ -31,8 +30,13 @@ if (!window.Buffer) { const { chains, publicClient, webSocketPublicClient } = configureChains( [mainnet, goerli, avalanche, bsc, polygon], [ - infuraProvider({ apiKey: import.meta.env.VITE_INFURA_API_KEY || "" }), - publicProvider(), + jsonRpcProvider({ + rpc: () => ({ + http: "http://localhost:8545", + }), + }), + // infuraProvider({ apiKey: import.meta.env.VITE_INFURA_API_KEY || "" }), + // publicProvider(), ], { batch: {