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
+ //
+ // }
+ //
+ // ) : 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;
}
};