From 39f69100f5e59562eba82494336601dcb06a0dc9 Mon Sep 17 00:00:00 2001 From: pop-punk Date: Thu, 14 Sep 2023 15:21:39 -0400 Subject: [PATCH 01/12] feat: fetch friendtech holders --- components/containers/FriendtechContainer.tsx | 188 ++++++++++++++++++ .../hooks/friendtech/useFriendtechData.ts | 40 ++++ components/providers/FriendtechProvider.tsx | 172 ++++++++++++++++ components/providers/NewProvider.tsx | 11 +- components/types/airdrop.ts | 1 + pages/index.tsx | 10 + 6 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 components/containers/FriendtechContainer.tsx create mode 100644 components/hooks/friendtech/useFriendtechData.ts create mode 100644 components/providers/FriendtechProvider.tsx diff --git a/components/containers/FriendtechContainer.tsx b/components/containers/FriendtechContainer.tsx new file mode 100644 index 0000000..ccc62b1 --- /dev/null +++ b/components/containers/FriendtechContainer.tsx @@ -0,0 +1,188 @@ +import React, { + Dispatch, + ChangeEvent, + SetStateAction, + useEffect, + useRef, +} from "react"; +import { useNetwork } from "wagmi"; +import { AirdropRecipient } from "../types/airdrop"; +import { ModalSelector } from "../types/modals"; +import ConfirmModal from "../ui/modals/ConfirmModal"; +import CongratsModal from "../ui/modals/CongratsModal"; +import AddressContainer from "./AddressContainer"; +import { formatUnits } from "viem"; + +interface Props { + allowance?: BigInt; + balanceData: any; + errorMessage: string | false; + formattedTokenBalance: string | undefined; + isERC721: boolean; + loadingMessage: string | false; + openModal: false | ModalSelector; + parsedRecipients: AirdropRecipient[]; + tokenAddress: `0x${string}`; + tokenBalance: BigInt | undefined; + tokenSymbol: string; + validToken: boolean; + approveWrite: (() => void) | undefined; + displayModal: () => void; + handleTokenAddressChange: (e: ChangeEvent) => void; + resetForm: () => void; + setErrorMessage: Dispatch>; + setOpenModal: Dispatch>; + setRecipients: Dispatch>; +} + +const ERCContainer = (props: Props) => { + const { + allowance, + balanceData, + errorMessage, + formattedTokenBalance = undefined, + isERC721, + loadingMessage, + openModal, + parsedRecipients, + tokenAddress, + tokenBalance, + tokenSymbol, + validToken, + approveWrite, + displayModal, + handleTokenAddressChange, + resetForm, + setErrorMessage, + setOpenModal, + setRecipients, + } = props; + + const inputRef = useRef(null); + + const { chain } = useNetwork(); + + const pillStyle = + "flex items-center border rounded-md md:rounded-full md:inline-block py-2 px-2 md:px-3 text-sm mt-2 min-h-fit"; + + const isBalanceZero = tokenBalance === BigInt(0); + + // Focus on the input when rendered + useEffect(() => inputRef.current?.focus(), []); + + return ( +
+ {openModal === "confirm" && ( + setOpenModal(val ? "confirm" : false)} + recipients={parsedRecipients} + balanceData={balanceData} + loadingMessage={loadingMessage || undefined} + errorMessage={errorMessage || undefined} + setErrorMessage={setErrorMessage} + onSubmit={() => { + // If already approved, transfer tokens, else do the approve first + if (isERC721) { + try { + approveWrite?.(); + } catch (error) { + console.log("ERC-721 Approval Failed: ", error); + } + } else { + try { + approveWrite?.(); + } catch (error) { + console.log("ERC-20 Approval Failed: ", error); + } + } + }} + /> + )} + + {openModal === "congrats" && ( +
+ { + resetForm(); + setOpenModal(val ? "congrats" : false); + }} + resetForm={resetForm} + /> +
+ )} + +
+

+ Enter your Friendtech wallet address: +

+ + + +
+
+ Friendtech +
+ +
+ {validToken ? ( + <>{`You have ${ + isERC721 ? tokenBalance?.toString() : formattedTokenBalance + } ${tokenSymbol} to airdrop`} + ) : ( + `Enter a valid token address to see your available balance.` + )} +
+ + {!isBalanceZero && !isERC721 && allowance && ( +
+ Your allowance is set to{" "} + {formatUnits( + BigInt(allowance!.toString()), + balanceData.tokenDecimals + )}{" "} + {tokenSymbol} +
+ )} +
+ + {(() => { + if (isBalanceZero) { + return ( +

+ Please make sure you're connected with the correct wallet + or top up your balance to perform an airdrop with {tokenSymbol}{" "} + on {chain?.name}. +

+ ); + } else { + return ( + + ); + } + })()} +
+
+ ); +}; + +export default ERCContainer; diff --git a/components/hooks/friendtech/useFriendtechData.ts b/components/hooks/friendtech/useFriendtechData.ts new file mode 100644 index 0000000..c84efa3 --- /dev/null +++ b/components/hooks/friendtech/useFriendtechData.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from "react"; + +export default function useFriendtechData(accountAddress: String) { + + const [usersData, setUsersData] = useState< + { user: string; twitterUsername: string }[] + >([]); + const [error, setError] = useState(null); + + const apiURL = `https://prod-api.kosetto.com/users/${accountAddress.toString()}/token/holders`; + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch(apiURL); + + if (!response.ok) { + throw new Error("Failed to fetch"); + } + + const data = await response.json(); + console.log(data); + + // Map over the data to extract the desired properties + const extractedData = data.users.map((user: any) => ({ + user: user.address, + twitterUsername: user.twitterUsername, + })); + + setUsersData(extractedData); + } catch (err) { + setError(err); + } + }; + + fetchData(); + }, [apiURL]); + console.log(usersData); + return { usersData, error }; +} diff --git a/components/providers/FriendtechProvider.tsx b/components/providers/FriendtechProvider.tsx new file mode 100644 index 0000000..9f787ea --- /dev/null +++ b/components/providers/FriendtechProvider.tsx @@ -0,0 +1,172 @@ +import React, { useState, useMemo, ChangeEvent } from "react"; +import { toast } from "sonner"; +import { Address } from "wagmi"; +import FriendtechContainer from "../containers/FriendtechContainer"; +import useApproveAllowance from "../hooks/erc20/useApproveAllowance"; +import useTokenData from "../hooks/erc20/useTokenData"; +import useFriendtechData from "../hooks/friendtech/useFriendtechData"; +import useApproveAirdrop from "../hooks/erc20/useApproveAirdrop"; +import { AirdropRecipient, IAirdropEthProps } from "../types/airdrop"; +import { ModalSelector } from "../types/modals"; +import { recipientsParser } from "../types/parsers"; +import { formatUnits } from "viem"; + +export default function Friendtech(props: IAirdropEthProps) { + const { contractAddress = null, setSelected, setContractAddress } = props; + + // tokenAddress is the contract token address (e.g. the WETH contract) + const [tokenAddress, setTokenAddress] = useState
( + (contractAddress as Address) || "" + ); + const [recipients, setRecipients] = useState<[string, string][]>([]); + const [openModal, setOpenModal] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(false); + const [errorMessage, setErrorMessage] = useState(false); + + const pattern = useMemo(() => /^0x[a-fA-F0-9]{40}$/, []); + + const displayMessage = (message: string, type?: "success" | "error") => { + const errorMsg = type === "error" ? message : false; + setErrorMessage(errorMsg); + + const loadingMsg = type === "error" ? false : message; + setLoadingMessage(loadingMsg); + + type === "success" || type === "error" + ? toast[type](message) + : toast(message); + }; + + const displayModal = () => { + setOpenModal("confirm"); + setLoadingMessage(false); + setErrorMessage(false); + }; + + const { + name: tokenName, + symbol: tokenSymbol, + balance: tokenBalance, + decimals: tokenDecimals, + isERC721, + nameSuccess, + symbolSuccess, + } = useTokenData(tokenAddress); + + const result = useFriendtechData(tokenAddress); + + const balanceData = useMemo( + () => ({ + name: tokenName, + symbol: tokenSymbol, + value: tokenBalance, + decimals: tokenDecimals, + }), + [tokenName, tokenSymbol, tokenBalance, tokenDecimals] + ); + + const formattedTokenBalance = useMemo(() => { + return tokenBalance ? formatUnits(tokenBalance, tokenDecimals || 18) : "0"; + }, [tokenBalance, tokenDecimals]); + + const parsedRecipients = useMemo(() => { + try { + return ( + recipients.length + ? recipientsParser(tokenDecimals).parse(recipients) + : [] + ) as AirdropRecipient[]; + } catch (e) { + displayMessage((e as Error).message, "error"); + return [] as AirdropRecipient[]; + } + }, [tokenDecimals, recipients]); + + // low-priority todo: improve the typing without casting + const totalAllowance = useMemo(() => { + return parsedRecipients.reduce((acc: BigInt, { amount }) => { + const a = BigInt(acc.toString()); + const b = BigInt(amount.toString()); + + return a + b; + }, BigInt(0)); + }, [parsedRecipients]); + + const { write: airdropWrite } = useApproveAirdrop( + tokenAddress, + parsedRecipients, + () => displayMessage("Airdrop transaction pending..."), + function onSuccess() { + displayMessage("Airdrop transaction successful!", "success"); + setOpenModal("congrats"); + }, + function onError(error) { + displayMessage(error, "error"); + } + ); + + const { allowance, write: approveWrite } = useApproveAllowance( + tokenAddress, + totalAllowance, + () => displayMessage("Approval transaction pending..."), // on Pending + function onSuccess() { + displayMessage("Approval transaction submitted!", "success"); + airdropWrite?.(); + }, + function onError(error) { + displayMessage(error, "error"); + } + ); + + // Switch over to ERC-721 if the contract entered is a 721 + if (isERC721) { + setContractAddress?.(tokenAddress); + setSelected("ERC721"); + return null; + } + + const handleTokenAddressChange = (e: ChangeEvent) => { + const rawInput = e.target.value; + if (rawInput.length > 42) return; + + const address = rawInput as Address; + setTokenAddress(address); + setContractAddress?.(address); + + if (!pattern.test(rawInput)) { + setSelected("UNSET"); + } + }; + + const validToken = tokenName && tokenSymbol && !isERC721 ? true : false; + + const resetForm = () => { + setSelected("UNSET"); + setRecipients([]); + setContractAddress?.(""); + }; + + return ( + + ); +} diff --git a/components/providers/NewProvider.tsx b/components/providers/NewProvider.tsx index 6a6f166..01d2b39 100644 --- a/components/providers/NewProvider.tsx +++ b/components/providers/NewProvider.tsx @@ -112,7 +112,7 @@ const NewProvider = (props: IAirdropEthProps) => { onChange={handleTokenAddressChange} onKeyDown={handleKeyDown} onPaste={handlePaste} - placeholder={'0x'} + placeholder={"0x"} /> + + {contractAddress && contractAddress.length === 42 && notAContractAddress && (

- Oops! That doesn't look like a valid contract address on{' '} + Oops! That doesn't look like a valid contract address on{" "} {chain?.name}. Double check the address and please try again.

)} diff --git a/components/types/airdrop.ts b/components/types/airdrop.ts index f538e2d..983cf4d 100644 --- a/components/types/airdrop.ts +++ b/components/types/airdrop.ts @@ -4,6 +4,7 @@ export const AirdropType = { ERC20: 'ERC20', ERC721: 'ERC721', ETH: 'ETH', + FRIENDTECH: 'FRIENDTECH', unset: 'UNSET' } as const; diff --git a/pages/index.tsx b/pages/index.tsx index c8a1985..c22737d 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -8,6 +8,7 @@ import DisconnectedView from '@/components/ui/DisconnectedView'; import ERC20Provider from '@/components/providers/ERC20Provider'; import ERC721Provider from '@/components/providers/ERC721Provider'; import ETHProvider from '@/components/providers/ETHProvider'; +import FriendtechProvider from '@/components/providers/FriendtechProvider'; import Footer from '@/components/ui/Footer'; import Navbar from '@/components/ui/Navbar'; import NewProvider from '@/components/providers/NewProvider'; @@ -139,6 +140,15 @@ const Home = () => { setContractAddress={setContractAddress} /> ); + } else if (airdropType === AirdropType.FRIENDTECH) { + return ( + + ); } else { return ( Date: Thu, 14 Sep 2023 20:31:39 -0400 Subject: [PATCH 02/12] feat: show count and return holders --- components/containers/FriendtechContainer.tsx | 16 ++++++++++------ components/hooks/friendtech/useFriendtechData.ts | 8 ++++---- components/providers/FriendtechProvider.tsx | 4 +++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/components/containers/FriendtechContainer.tsx b/components/containers/FriendtechContainer.tsx index ccc62b1..aa4a53d 100644 --- a/components/containers/FriendtechContainer.tsx +++ b/components/containers/FriendtechContainer.tsx @@ -14,6 +14,7 @@ import AddressContainer from "./AddressContainer"; import { formatUnits } from "viem"; interface Props { + holders: string[]; allowance?: BigInt; balanceData: any; errorMessage: string | false; @@ -35,8 +36,9 @@ interface Props { setRecipients: Dispatch>; } -const ERCContainer = (props: Props) => { +const FriendtechContainer = (props: Props) => { const { + holders, allowance, balanceData, errorMessage, @@ -70,6 +72,8 @@ const ERCContainer = (props: Props) => { // Focus on the input when rendered useEffect(() => inputRef.current?.focus(), []); + console.log(holders.length); + return (
{openModal === "confirm" && ( @@ -136,12 +140,12 @@ const ERCContainer = (props: Props) => {
- {validToken ? ( + {holders ? ( <>{`You have ${ - isERC721 ? tokenBalance?.toString() : formattedTokenBalance - } ${tokenSymbol} to airdrop`} + holders.length > 0 ? holders.length : "0" + } key holders to airdrop to`} ) : ( - `Enter a valid token address to see your available balance.` + `Enter your friendtech wallet address to airdrop.` )}
@@ -185,4 +189,4 @@ const ERCContainer = (props: Props) => { ); }; -export default ERCContainer; +export default FriendtechContainer; diff --git a/components/hooks/friendtech/useFriendtechData.ts b/components/hooks/friendtech/useFriendtechData.ts index c84efa3..17e65e0 100644 --- a/components/hooks/friendtech/useFriendtechData.ts +++ b/components/hooks/friendtech/useFriendtechData.ts @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; export default function useFriendtechData(accountAddress: String) { - const [usersData, setUsersData] = useState< + const [holders, setHolders] = useState< { user: string; twitterUsername: string }[] >([]); const [error, setError] = useState(null); @@ -27,7 +27,7 @@ export default function useFriendtechData(accountAddress: String) { twitterUsername: user.twitterUsername, })); - setUsersData(extractedData); + setHolders(extractedData); } catch (err) { setError(err); } @@ -35,6 +35,6 @@ export default function useFriendtechData(accountAddress: String) { fetchData(); }, [apiURL]); - console.log(usersData); - return { usersData, error }; + console.log(holders); + return { holders, error }; } diff --git a/components/providers/FriendtechProvider.tsx b/components/providers/FriendtechProvider.tsx index 9f787ea..95d48fd 100644 --- a/components/providers/FriendtechProvider.tsx +++ b/components/providers/FriendtechProvider.tsx @@ -53,7 +53,8 @@ export default function Friendtech(props: IAirdropEthProps) { symbolSuccess, } = useTokenData(tokenAddress); - const result = useFriendtechData(tokenAddress); + const { holders } = useFriendtechData(tokenAddress); + const balanceData = useMemo( () => ({ @@ -148,6 +149,7 @@ export default function Friendtech(props: IAirdropEthProps) { return ( Date: Thu, 14 Sep 2023 21:10:00 -0400 Subject: [PATCH 03/12] feat: wip --- components/containers/AddressContainer.tsx | 3 +- components/containers/FriendtechContainer.tsx | 141 ++++++++++-------- 2 files changed, 82 insertions(+), 62 deletions(-) diff --git a/components/containers/AddressContainer.tsx b/components/containers/AddressContainer.tsx index 3c3f366..b44b688 100644 --- a/components/containers/AddressContainer.tsx +++ b/components/containers/AddressContainer.tsx @@ -8,10 +8,11 @@ interface Props { tokenSymbol: string; displayModal: () => void; setRecipients: Dispatch>; + holderAddresses?: string[]; } const AddressContainer = (props: Props) => { - const { show, isERC721, tokenSymbol, displayModal, setRecipients } = props; + const { show, isERC721, tokenSymbol, displayModal, setRecipients, holderAddresses } = props; const [showCSVUpload, setShowCSVUpload] = useState(false); diff --git a/components/containers/FriendtechContainer.tsx b/components/containers/FriendtechContainer.tsx index aa4a53d..10fbe34 100644 --- a/components/containers/FriendtechContainer.tsx +++ b/components/containers/FriendtechContainer.tsx @@ -4,14 +4,21 @@ import React, { SetStateAction, useEffect, useRef, + useState, + useMemo, } from "react"; -import { useNetwork } from "wagmi"; +import { toast } from "sonner"; +import { useChainId, useNetwork } from "wagmi"; +import { useBalance, useAccount } from "wagmi"; import { AirdropRecipient } from "../types/airdrop"; import { ModalSelector } from "../types/modals"; +import { recipientsParser } from "../types/parsers"; import ConfirmModal from "../ui/modals/ConfirmModal"; import CongratsModal from "../ui/modals/CongratsModal"; import AddressContainer from "./AddressContainer"; +import useAirdrop from "../hooks/eth/useAirdrop"; import { formatUnits } from "viem"; +import useNetworkNativeToken from "../hooks/networkNativeToken"; interface Props { holders: string[]; @@ -23,10 +30,6 @@ interface Props { loadingMessage: string | false; openModal: false | ModalSelector; parsedRecipients: AirdropRecipient[]; - tokenAddress: `0x${string}`; - tokenBalance: BigInt | undefined; - tokenSymbol: string; - validToken: boolean; approveWrite: (() => void) | undefined; displayModal: () => void; handleTokenAddressChange: (e: ChangeEvent) => void; @@ -39,69 +42,97 @@ interface Props { const FriendtechContainer = (props: Props) => { const { holders, - allowance, balanceData, - errorMessage, - formattedTokenBalance = undefined, isERC721, - loadingMessage, - openModal, - parsedRecipients, - tokenAddress, - tokenBalance, - tokenSymbol, - validToken, approveWrite, displayModal, handleTokenAddressChange, resetForm, - setErrorMessage, - setOpenModal, - setRecipients, } = props; const inputRef = useRef(null); + const chainId = useChainId(); + const { address, isConnected } = useAccount(); + + const [recipients, setRecipients] = useState<[string, string][]>([]); + const [openModal, setOpenModal] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(false); + const [errorMessage, setErrorMessage] = useState(false); + + const displayMessage = (message: string, type?: "success" | "error") => { + if (type === "error") { + setLoadingMessage(false); + setErrorMessage(message); + toast[type](message); + } else if (type === "success") { + setLoadingMessage(message); + setErrorMessage(false); + toast[type](message); + } else { + setLoadingMessage(message); + setErrorMessage(false); + toast(message); + } + }; + + const { nativeToken } = useNetworkNativeToken(); + + const { data: balance } = useBalance({ + address: address, + onError: (error) => displayMessage(error.message, "error"), + chainId, + }); const { chain } = useNetwork(); + const parsedRecipients = useMemo(() => { + try { + return ( + recipients.length + ? recipientsParser(balance?.decimals).parse(recipients) + : [] + ) as AirdropRecipient[]; + } catch (e) { + displayMessage((e as Error).message, "error"); + return [] as AirdropRecipient[]; + } + }, [balance?.decimals, recipients]); + + const { write: airdropWrite } = useAirdrop( + parsedRecipients, + () => displayMessage("Airdrop transaction pending..."), + function onSuccess() { + displayMessage("Airdrop transaction successful!", "success"); + setOpenModal("congrats"); + }, + function onError(error: string) { + displayMessage(error, "error"); + } + ); + const pillStyle = "flex items-center border rounded-md md:rounded-full md:inline-block py-2 px-2 md:px-3 text-sm mt-2 min-h-fit"; - const isBalanceZero = tokenBalance === BigInt(0); - // Focus on the input when rendered useEffect(() => inputRef.current?.focus(), []); - console.log(holders.length); - return (
{openModal === "confirm" && ( setOpenModal(val ? "confirm" : false)} + symbol="ETH" recipients={parsedRecipients} - balanceData={balanceData} - loadingMessage={loadingMessage || undefined} - errorMessage={errorMessage || undefined} + balanceData={balance} + loadingMessage={loadingMessage ? loadingMessage : undefined} + errorMessage={errorMessage ? errorMessage : undefined} setErrorMessage={setErrorMessage} onSubmit={() => { - // If already approved, transfer tokens, else do the approve first - if (isERC721) { - try { - approveWrite?.(); - } catch (error) { - console.log("ERC-721 Approval Failed: ", error); - } - } else { - try { - approveWrite?.(); - } catch (error) { - console.log("ERC-20 Approval Failed: ", error); - } + try { + airdropWrite?.(); + } catch (error) { + console.log("ETH: ", error); } }} /> @@ -122,14 +153,14 @@ const FriendtechContainer = (props: Props) => {

- Enter your Friendtech wallet address: + Enter your Friendtech wallet address:

@@ -148,38 +179,26 @@ const FriendtechContainer = (props: Props) => { `Enter your friendtech wallet address to airdrop.` )}
- - {!isBalanceZero && !isERC721 && allowance && ( -
- Your allowance is set to{" "} - {formatUnits( - BigInt(allowance!.toString()), - balanceData.tokenDecimals - )}{" "} - {tokenSymbol} -
- )}
{(() => { - if (isBalanceZero) { + if (!holders || holders.length === 0) { return (

Please make sure you're connected with the correct wallet - or top up your balance to perform an airdrop with {tokenSymbol}{" "} + or top up your balance to perform an airdrop with {nativeToken}{" "} on {chain?.name}.

); } else { return ( ); } From 6ea533d19dc5ada4f679c7ca23e5586cb5f3e32c Mon Sep 17 00:00:00 2001 From: pop-punk Date: Thu, 14 Sep 2023 23:09:00 -0400 Subject: [PATCH 04/12] feat: working magictextarea --- components/containers/AddressContainer.tsx | 1 + components/containers/FriendtechContainer.tsx | 4 +- .../hooks/friendtech/useFriendtechData.ts | 10 +---- components/ui/MagicTextArea.tsx | 41 ++++++++++++++++--- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/components/containers/AddressContainer.tsx b/components/containers/AddressContainer.tsx index b44b688..2d2794a 100644 --- a/components/containers/AddressContainer.tsx +++ b/components/containers/AddressContainer.tsx @@ -43,6 +43,7 @@ const AddressContainer = (props: Props) => { isERC721={isERC721} displayModal={displayModal} setRecipients={setRecipients} + holderAddresses={holderAddresses} />