diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index 1cdea7a0..aa415137 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -7,6 +7,7 @@ import { useOutsideClick } from "~~/hooks/scaffold-stark"; import { CustomConnectButton } from "~~/components/scaffold-stark/CustomConnectButton"; import Image from "next/image"; import { usePathname } from "next/navigation"; +import { FaucetButton } from "~~/components/scaffold-stark/FaucetButton"; type HeaderMenuLink = { label: string; @@ -55,6 +56,7 @@ export const Header = () => {
+
); diff --git a/packages/nextjs/components/scaffold-stark/Faucet.tsx b/packages/nextjs/components/scaffold-stark/Faucet.tsx new file mode 100644 index 00000000..a71104a8 --- /dev/null +++ b/packages/nextjs/components/scaffold-stark/Faucet.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { Address as AddressType, devnet } from "@starknet-react/chains"; +import { BanknotesIcon } from "@heroicons/react/24/outline"; +import { + Address, + AddressInput, + Balance, + EtherInput, +} from "~~/components/scaffold-stark"; +import { useNetwork } from "@starknet-react/core"; +import { mintEth } from "~~/services/web3/faucet"; + +/** + * Faucet modal which lets you send ETH to any address. + */ +export const Faucet = () => { + const [loading, setLoading] = useState(false); + const [inputAddress, setInputAddress] = useState(); + const [faucetAddress] = useState( + "0x78662e7352d062084b0010068b99288486c2d8b914f6e2a55ce945f8792c8b1", + ); + const [sendValue, setSendValue] = useState(""); + + const { chain: ConnectedChain } = useNetwork(); + + const sendETH = async () => { + if (!faucetAddress || !inputAddress) { + return; + } + + try { + setLoading(true); + await mintEth(inputAddress, sendValue); + setLoading(false); + setInputAddress(undefined); + setSendValue(""); + } catch (error) { + console.error("⚡️ ~ file: Faucet.tsx:sendETH ~ error", error); + setLoading(false); + } + }; + + // Render only on local chain + if (ConnectedChain?.id !== devnet.id) { + return null; + } + + return ( +
+ + + +
+ ); +}; diff --git a/packages/nextjs/components/scaffold-stark/FaucetButton.tsx b/packages/nextjs/components/scaffold-stark/FaucetButton.tsx new file mode 100644 index 00000000..f1240f4d --- /dev/null +++ b/packages/nextjs/components/scaffold-stark/FaucetButton.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { BanknotesIcon } from "@heroicons/react/24/outline"; +import { mintEth } from "~~/services/web3/faucet"; +import { Address, devnet } from "@starknet-react/chains"; +import { useAccount, useBalance, useNetwork } from "@starknet-react/core"; +import { useTargetNetwork } from "~~/hooks/scaffold-stark/useTargetNetwork"; + +// Number of ETH faucet sends to an address +const NUM_OF_ETH = "1"; + +/** + * FaucetButton button which lets you grab eth. + */ +export const FaucetButton = () => { + const { address } = useAccount(); + const { data: balance, refetch } = useBalance({ + address, + watch: true, + refetchInterval: 4500, + }); + + const { targetNetwork } = useTargetNetwork(); + + const [loading, setLoading] = useState(false); + + const sendETH = async () => { + if (!address) { + return; + } + + try { + setLoading(true); + await mintEth(address as Address, NUM_OF_ETH); + await refetch(); + setLoading(false); + } catch (error) { + console.error("⚡️ ~ file: FaucetButton.tsx:sendETH ~ error", error); + setLoading(false); + } + }; + + // Render only on local chain + if (targetNetwork.id !== devnet.id) { + return null; + } + + const isBalanceZero = balance && balance.value === 0n; + + return ( +
+ +
+ ); +}; diff --git a/packages/nextjs/components/scaffold-stark/Input/AddressInput.tsx b/packages/nextjs/components/scaffold-stark/Input/AddressInput.tsx new file mode 100644 index 00000000..8dcece52 --- /dev/null +++ b/packages/nextjs/components/scaffold-stark/Input/AddressInput.tsx @@ -0,0 +1,134 @@ +import { useCallback } from "react"; +import { blo } from "blo"; +import { useDebounceValue } from "usehooks-ts"; +import { CommonInputProps, InputBase } from "~~/components/scaffold-stark"; +import { Address } from "@starknet-react/chains"; +import { isAddress } from "~~/utils/scaffold-stark/common"; +import Image from "next/image"; + +/** + * Address input with ENS name resolution + */ +export const AddressInput = ({ + value, + name, + placeholder, + onChange, + disabled, +}: CommonInputProps
) => { + // Debounce the input to keep clean RPC calls when resolving ENS names + // If the input is an address, we don't need to debounce it + const [_debouncedValue] = useDebounceValue(value, 500); + //const debouncedValue = isAddress(value) ? value : _debouncedValue; + //const isDebouncedValueLive = debouncedValue === value; + + // If the user changes the input after an ENS name is already resolved, we want to remove the stale result + //const settledValue = isDebouncedValueLive ? debouncedValue : undefined; + + // const { + // data: ensAddress, + // isLoading: isEnsAddressLoading, + // isError: isEnsAddressError, + // isSuccess: isEnsAddressSuccess, + // } = useEnsAddress({ + // name: settledValue, + // enabled: isDebouncedValueLive && isENS(debouncedValue), + // chainId: 1, + // cacheTime: 30_000, + // }); + // + //const [enteredEnsName, setEnteredEnsName] = useState(); + // const { + // data: ensName, + // isLoading: isEnsNameLoading, + // isError: isEnsNameError, + // isSuccess: isEnsNameSuccess, + // } = useEnsName({ + // address: settledValue as Address, + // enabled: isAddress(debouncedValue), + // chainId: 1, + // cacheTime: 30_000, + // }); + // + // const { data: ensAvatar, isLoading: isEnsAvtarLoading } = useEnsAvatar({ + // name: ensName, + // enabled: Boolean(ensName), + // chainId: 1, + // cacheTime: 30_000, + // }); + + // ens => address + // useEffect(() => { + // if (!ensAddress) return; + // + // // ENS resolved successfully + // setEnteredEnsName(debouncedValue); + // onChange(ensAddress); + // }, [ensAddress, onChange, debouncedValue]); + + const handleChange = useCallback( + (newValue: Address) => { + //setEnteredEnsName(undefined); + onChange(newValue); + }, + [onChange], + ); + + // const reFocus = + // isEnsAddressError || + // isEnsNameError || + // isEnsNameSuccess || + // isEnsAddressSuccess || + // ensName === null || + // ensAddress === null; + + return ( + + name={name} + placeholder={placeholder} + // error={ensAddress === null} + value={value as Address} + onChange={handleChange} + // disabled={isEnsAddressLoading || isEnsNameLoading || disabled} + disabled={disabled} + // reFocus={reFocus} + prefix={ + null + // ensName ? ( + //
+ // {isEnsAvtarLoading &&
} + // {ensAvatar ? ( + // + // { + // // eslint-disable-next-line + // {`${ensAddress} + // } + // + // ) : null} + // {enteredEnsName ?? ensName} + //
+ // ) : ( + // (isEnsNameLoading || isEnsAddressLoading) && ( + //
+ //
+ //
+ //
+ // ) + // ) + } + suffix={ + // Don't want to use nextJS Image here (and adding remote patterns for the URL) + // eslint-disable-next-line @next/next/no-img-element + value && ( + + ) + } + /> + ); +}; diff --git a/packages/nextjs/components/scaffold-stark/Input/EtherInput.tsx b/packages/nextjs/components/scaffold-stark/Input/EtherInput.tsx new file mode 100644 index 00000000..08365c27 --- /dev/null +++ b/packages/nextjs/components/scaffold-stark/Input/EtherInput.tsx @@ -0,0 +1,171 @@ +import { useEffect, useMemo, useState } from "react"; +import { ArrowsRightLeftIcon } from "@heroicons/react/24/outline"; +import { + CommonInputProps, + InputBase, + SIGNED_NUMBER_REGEX, +} from "~~/components/scaffold-stark"; +import { useGlobalState } from "~~/services/store/store"; + +const MAX_DECIMALS_USD = 2; + +function etherValueToDisplayValue( + usdMode: boolean, + etherValue: string, + nativeCurrencyPrice: number, +) { + if (usdMode && nativeCurrencyPrice) { + const parsedEthValue = parseFloat(etherValue); + if (Number.isNaN(parsedEthValue)) { + return etherValue; + } else { + // We need to round the value rather than use toFixed, + // since otherwise a user would not be able to modify the decimal value + return ( + Math.round( + parsedEthValue * nativeCurrencyPrice * 10 ** MAX_DECIMALS_USD, + ) / + 10 ** MAX_DECIMALS_USD + ).toString(); + } + } else { + return etherValue; + } +} + +function displayValueToEtherValue( + usdMode: boolean, + displayValue: string, + nativeCurrencyPrice: number, +) { + if (usdMode && nativeCurrencyPrice) { + const parsedDisplayValue = parseFloat(displayValue); + if (Number.isNaN(parsedDisplayValue)) { + // Invalid number. + return displayValue; + } else { + // Compute the ETH value if a valid number. + return (parsedDisplayValue / nativeCurrencyPrice).toString(); + } + } else { + return displayValue; + } +} + +/** + * Input for ETH amount with USD conversion. + * + * onChange will always be called with the value in ETH + */ +export const EtherInput = ({ + value, + name, + placeholder, + onChange, + disabled, + usdMode, +}: CommonInputProps & { usdMode?: boolean }) => { + const [transitoryDisplayValue, setTransitoryDisplayValue] = + useState(); + const nativeCurrencyPrice = useGlobalState( + (state) => state.nativeCurrencyPrice, + ); + const [internalUsdMode, setInternalUSDMode] = useState( + nativeCurrencyPrice > 0 ? Boolean(usdMode) : false, + ); + + useEffect(() => { + setInternalUSDMode(nativeCurrencyPrice > 0 ? Boolean(usdMode) : false); + }, [usdMode, nativeCurrencyPrice]); + + // The displayValue is derived from the ether value that is controlled outside of the component + // In usdMode, it is converted to its usd value, in regular mode it is unaltered + const displayValue = useMemo(() => { + const newDisplayValue = etherValueToDisplayValue( + internalUsdMode, + value, + nativeCurrencyPrice, + ); + if ( + transitoryDisplayValue && + parseFloat(newDisplayValue) === parseFloat(transitoryDisplayValue) + ) { + return transitoryDisplayValue; + } + // Clear any transitory display values that might be set + setTransitoryDisplayValue(undefined); + return newDisplayValue; + }, [nativeCurrencyPrice, transitoryDisplayValue, internalUsdMode, value]); + + const handleChangeNumber = (newValue: string) => { + if (newValue && !SIGNED_NUMBER_REGEX.test(newValue)) { + return; + } + + // Following condition is a fix to prevent usdMode from experiencing different display values + // than what the user entered. This can happen due to floating point rounding errors that are introduced in the back and forth conversion + if (internalUsdMode) { + const decimals = newValue.split(".")[1]; + if (decimals && decimals.length > MAX_DECIMALS_USD) { + return; + } + } + + // Since the display value is a derived state (calculated from the ether value), usdMode would not allow introducing a decimal point. + // This condition handles a transitory state for a display value with a trailing decimal sign + if (newValue.endsWith(".") || newValue.endsWith(".0")) { + setTransitoryDisplayValue(newValue); + } else { + setTransitoryDisplayValue(undefined); + } + + const newEthValue = displayValueToEtherValue( + internalUsdMode, + newValue, + nativeCurrencyPrice, + ); + onChange(newEthValue); + }; + + const toggleMode = () => { + if (nativeCurrencyPrice > 0) { + setInternalUSDMode(!internalUsdMode); + } + }; + + return ( + + {internalUsdMode ? "$" : "Ξ"} + + } + suffix={ +
0 + ? "" + : "tooltip tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none" + }`} + data-tip="Unable to fetch price" + > + +
+ } + /> + ); +}; diff --git a/packages/nextjs/components/scaffold-stark/Input/index.ts b/packages/nextjs/components/scaffold-stark/Input/index.ts index a12f8aea..9c0c3c3d 100644 --- a/packages/nextjs/components/scaffold-stark/Input/index.ts +++ b/packages/nextjs/components/scaffold-stark/Input/index.ts @@ -3,3 +3,5 @@ export * from "./utils"; export * from "./IntegerInput"; export * from "./InputBase"; +export * from "./EtherInput"; +export * from "./AddressInput"; diff --git a/packages/nextjs/components/scaffold-stark/index.tsx b/packages/nextjs/components/scaffold-stark/index.tsx index aa11036c..f70c5b25 100644 --- a/packages/nextjs/components/scaffold-stark/index.tsx +++ b/packages/nextjs/components/scaffold-stark/index.tsx @@ -2,3 +2,4 @@ export * from "./Address"; export * from "./Balance"; export * from "./Input"; export * from "./BlockieAvatar"; +export * from "./Faucet"; diff --git a/packages/nextjs/services/web3/faucet.ts b/packages/nextjs/services/web3/faucet.ts new file mode 100644 index 00000000..cd61e211 --- /dev/null +++ b/packages/nextjs/services/web3/faucet.ts @@ -0,0 +1,15 @@ +import { Address } from "@starknet-react/chains"; + +export async function mintEth(inputAddress: Address, eth: string) { + await fetch("http://0.0.0.0:5050/mint", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + address: inputAddress, + amount: parseFloat(eth) * 10 ** 18, + unit: "WEI", + }), + }); +} diff --git a/packages/nextjs/utils/scaffold-stark/common.ts b/packages/nextjs/utils/scaffold-stark/common.ts index 40330c6e..234ead70 100644 --- a/packages/nextjs/utils/scaffold-stark/common.ts +++ b/packages/nextjs/utils/scaffold-stark/common.ts @@ -1,4 +1,12 @@ // To be used in JSON.stringify when a field might be bigint // https://wagmi.sh/react/faq#bigint-serialization +import { Address } from "@starknet-react/chains"; + export const replacer = (_key: string, value: unknown) => typeof value === "bigint" ? value.toString() : value; + +const addressRegex = /^0x[a-fA-F0-9]{40}$/; + +export function isAddress(address: string): address is Address { + return addressRegex.test(address); +} diff --git a/packages/nextjs/utils/scaffold-stark/fetchPriceFromCoingecko.ts b/packages/nextjs/utils/scaffold-stark/fetchPriceFromCoingecko.ts index a0e42af7..328f919e 100644 --- a/packages/nextjs/utils/scaffold-stark/fetchPriceFromCoingecko.ts +++ b/packages/nextjs/utils/scaffold-stark/fetchPriceFromCoingecko.ts @@ -1,27 +1,45 @@ import { ChainWithAttributes } from "~~/utils/scaffold-stark"; +// Cache object to store the last fetched prices based on currency symbols +const priceCache: Record = {}; + export const fetchPriceFromCoingecko = async ( targetNetwork: ChainWithAttributes, + retryCount = 3, // Maximum retry attempts ): Promise => { + const { symbol } = targetNetwork.nativeCurrency; if ( - targetNetwork.nativeCurrency.symbol !== "ETH" && - targetNetwork.nativeCurrency.symbol !== "SEP" && + symbol !== "ETH" && + symbol !== "SEP" && !targetNetwork.nativeCurrencyTokenAddress ) { return 0; } + // Check cache first + if (priceCache[symbol] !== undefined) { + console.log(`Returning cached price for ${symbol}`); + return priceCache[symbol]; + } + try { const response = await fetch( - "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd", + `https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd`, ); const data = await response.json(); - return data.ethereum.usd; + const price = data.ethereum.usd; + priceCache[symbol] = price; // Update cache with new price + return price; } catch (error) { console.error( - `useNativeCurrencyPrice - Error fetching ${targetNetwork.nativeCurrency.symbol} price from Coingecko: `, + `useNativeCurrencyPrice - Error fetching ${symbol} price from Coingecko: `, error, ); + if (retryCount > 0) { + console.log("Retrying after 1 second..."); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second + return fetchPriceFromCoingecko(targetNetwork, retryCount - 1); + } return 0; } };