Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 76 additions & 21 deletions app/api/evm-chain-faucet/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { createWalletClient, http, parseEther, createPublicClient, defineChain, isAddress } from 'viem';
import { createWalletClient, http, parseEther, createPublicClient, defineChain, isAddress, parseUnits } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { avalancheFuji } from 'viem/chains';
import { getAuthSession } from '@/lib/auth/authSession';
import { rateLimit } from '@/lib/rateLimit';
import { getL1ListStore, type L1ListItem } from '@/components/toolbox/stores/l1ListStore';
import ERC20ABI from '@/contracts/icm-contracts/compiled/ExampleERC20.json';

const SERVER_PRIVATE_KEY = process.env.FAUCET_C_CHAIN_PRIVATE_KEY;
const FAUCET_ADDRESS = process.env.FAUCET_C_CHAIN_ADDRESS;
Expand All @@ -14,10 +15,19 @@ if (!SERVER_PRIVATE_KEY || !FAUCET_ADDRESS) {
}

// Helper function to find a testnet chain that supports BuilderHub faucet
function findSupportedChain(chainId: number): L1ListItem | undefined {
function findSupportedChain(chainId: number, faucetId?: string): L1ListItem | undefined {
const testnetStore = getL1ListStore(true);
const allChains = testnetStore.getState().l1List;

return testnetStore.getState().l1List.find(
// If faucetId is provided, use it for exact match
if (faucetId) {
return allChains.find(
(chain: L1ListItem) => chain.id === faucetId && chain.hasBuilderHubFaucet
);
}

// Otherwise, find by chainId (original behavior for backward compatibility)
return allChains.find(
(chain: L1ListItem) => chain.evmChainId === chainId && chain.hasBuilderHubFaucet
);
}
Expand Down Expand Up @@ -62,13 +72,14 @@ async function transferEVMTokens(
sourceAddress: string,
destinationAddress: string,
chainId: number,
amount: string
amount: string,
faucetId?: string
): Promise<{ txHash: string }> {
if (!account) {
throw new Error('Wallet not initialized');
}

const l1Data = findSupportedChain(chainId);
const l1Data = findSupportedChain(chainId, faucetId);
if (!l1Data) {
throw new Error(`ChainID ${chainId} is not supported by Builder Hub Faucet`);
}
Expand All @@ -77,22 +88,63 @@ async function transferEVMTokens(
const walletClient = createWalletClient({ account, chain: viemChain, transport: http() });
const publicClient = createPublicClient({ chain: viemChain, transport: http() });

const balance = await publicClient.getBalance({
address: sourceAddress as `0x${string}`
});

const amountToSend = parseEther(amount);
// Check if this is an ERC20 faucet or native token faucet
const isERC20Faucet = !!l1Data.erc20TokenAddress;

if (isERC20Faucet) {
// ERC20 token transfer
const tokenAddress = l1Data.erc20TokenAddress as `0x${string}`;

// Get token decimals
const decimals = await publicClient.readContract({
address: tokenAddress,
abi: ERC20ABI.abi,
functionName: 'decimals',
}) as number;

// Parse amount with correct decimals
const amountToSend = parseUnits(amount, decimals);

// Check ERC20 balance
const balance = await publicClient.readContract({
address: tokenAddress,
abi: ERC20ABI.abi,
functionName: 'balanceOf',
args: [sourceAddress],
}) as bigint;

if (balance < amountToSend) {
throw new Error(`Insufficient ${l1Data.coinName} balance in faucet on ${l1Data.name}`);
}

if (balance < amountToSend) {
throw new Error(`Insufficient faucet balance on ${l1Data.name}`);
}
// Transfer ERC20 tokens
const txHash = await walletClient.writeContract({
address: tokenAddress,
abi: ERC20ABI.abi,
functionName: 'transfer',
args: [destinationAddress, amountToSend],
});

return { txHash };
} else {
// Native token transfer (existing logic)
const balance = await publicClient.getBalance({
address: sourceAddress as `0x${string}`
});

const amountToSend = parseEther(amount);

if (balance < amountToSend) {
throw new Error(`Insufficient faucet balance on ${l1Data.name}`);
}

const txHash = await walletClient.sendTransaction({
to: destinationAddress as `0x${string}`,
value: amountToSend,
});
const txHash = await walletClient.sendTransaction({
to: destinationAddress as `0x${string}`,
value: amountToSend,
});

return { txHash };
return { txHash };
}
}

async function validateFaucetRequest(request: NextRequest): Promise<NextResponse | null> {
Expand All @@ -115,6 +167,7 @@ async function validateFaucetRequest(request: NextRequest): Promise<NextResponse
const searchParams = request.nextUrl.searchParams;
const destinationAddress = searchParams.get('address');
const chainId = searchParams.get('chainId');
const faucetId = searchParams.get('faucetId'); // Optional unique identifier

if (!destinationAddress) {
return NextResponse.json(
Expand All @@ -131,7 +184,7 @@ async function validateFaucetRequest(request: NextRequest): Promise<NextResponse
}

const parsedChainId = parseInt(chainId);
const supportedChain = findSupportedChain(parsedChainId);
const supportedChain = findSupportedChain(parsedChainId, faucetId || undefined);
if (!supportedChain) {
return NextResponse.json(
{ success: false, message: `Chain ${chainId} does not support BuilderHub faucet` },
Expand Down Expand Up @@ -170,14 +223,16 @@ async function handleFaucetRequest(request: NextRequest): Promise<NextResponse>
const searchParams = request.nextUrl.searchParams;
const destinationAddress = searchParams.get('address')!;
const chainId = parseInt(searchParams.get('chainId')!);
const supportedChain = findSupportedChain(chainId);
const faucetId = searchParams.get('faucetId');
const supportedChain = findSupportedChain(chainId, faucetId || undefined);
const dripAmount = (supportedChain?.faucetThresholds?.dripAmount || 3).toString();

const tx = await transferEVMTokens(
FAUCET_ADDRESS!,
destinationAddress,
chainId,
dripAmount
dripAmount,
faucetId || undefined
);

const response: TransferResponse = {
Expand Down
16 changes: 11 additions & 5 deletions components/toolbox/components/ConnectWallet/EVMFaucetButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ const LOW_BALANCE_THRESHOLD = 1;

interface EVMFaucetButtonProps {
chainId: number;
faucetChainId?: string; // to distinguish between multiple faucets on same chainId
className?: string;
buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
children?: React.ReactNode;
}

export const EVMFaucetButton = ({
chainId,
faucetChainId,
className,
buttonProps,
children,
Expand All @@ -29,10 +31,13 @@ export const EVMFaucetButton = ({
const l1List = useL1List();
const { claimEVMTokens, isClaimingEVM } = useTestnetFaucet();

const chainConfig = l1List.find(
(chain: L1ListItem) =>
chain.evmChainId === chainId && chain.hasBuilderHubFaucet
);
// If faucetChainId is provided, find by ID, otherwise find by evmChainId for backward compatibility
const chainConfig = faucetChainId
? l1List.find((chain: L1ListItem) => chain.id === faucetChainId)
: l1List.find(
(chain: L1ListItem) =>
chain.evmChainId === chainId && chain.hasBuilderHubFaucet
);

if (!isTestnet || !chainConfig) {
return null;
Expand All @@ -44,7 +49,8 @@ export const EVMFaucetButton = ({
if (isRequestingTokens || !walletEVMAddress) return;

try {
await claimEVMTokens(chainId, false);
// Pass the unique faucetChainId if available, otherwise just chainId
await claimEVMTokens(chainId, false, faucetChainId);
} catch (error) {
// error handled via notifications from useTestnetFaucet
}
Expand Down
3 changes: 2 additions & 1 deletion components/toolbox/console/primary-network/Faucet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ function EVMFaucetCard({ chain }: { chain: L1ListItem }) {

<EVMFaucetButton
chainId={chain.evmChainId}
faucetChainId={chain.id}
className="w-full px-4 py-3 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Droplets className="w-4 h-4" />
Request {chain.name} Tokens
Request {chain.coinName} Tokens
</EVMFaucetButton>
</div>
);
Expand Down
27 changes: 27 additions & 0 deletions components/toolbox/stores/l1ListStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type L1ListItem = {
symbol: string;
decimals: number;
};
erc20TokenAddress?: string; // If provided, faucet will drip this ERC20 token instead of native tokens
};

const l1ListInitialStateFuji = {
Expand Down Expand Up @@ -110,6 +111,32 @@ const l1ListInitialStateFuji = {
"EVM-compatible L1 chain",
"Deploy dApps & test interoperability with Dispatch"
]
},
{
id: "dexalot-alot-cchain-fuji",
name: "Dexalot (ALOT on C-Chain)",
description: "Get ALOT ERC20 tokens on Fuji C-Chain for testing",
rpcUrl: "https://api.avax-test.network/ext/bc/C/rpc",
evmChainId: 43113,
coinName: "ALOT",
isTestnet: true,
subnetId: "11111111111111111111111111111111LpoYY",
wrappedTokenAddress: "0xd00ae08403B9bbb9124bB305C09058E32C39A48c",
validatorManagerAddress: "",
logoUrl: "https://images.ctfassets.net/gcj8jwzm6086/6tKCXL3AqxfxSUzXLGfN6r/be31715b87bc30c0e4d3da01a3d24e9a/dexalot-subnet.png",
wellKnownTeleporterRegistryAddress: "0xF86Cb19Ad8405AEFa7d09C778215D2Cb6eBfB228",
hasBuilderHubFaucet: true,
externalFaucetUrl: "https://core.app/tools/testnet-faucet",
explorerUrl: "https://testnet.snowtrace.io",
faucetThresholds: {
threshold: 10,
dripAmount: 0.2
},
erc20TokenAddress: "0x9983F755Bbd60d1886CbfE103c98C272AA0F03d6",
features: [
"ERC20 ALOT tokens on C-Chain",
"Get testnet ALOT for Dexalot"
]
}
] as L1ListItem[],
}
Expand Down
11 changes: 8 additions & 3 deletions hooks/useTestnetFaucet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,24 @@ export const useTestnetFaucet = () => {
return l1List.filter((chain: L1ListItem) => chain.hasBuilderHubFaucet);
}, [l1List]);

const claimEVMTokens = useCallback(async (chainId: number, silent: boolean = false): Promise<FaucetClaimResult> => {
const claimEVMTokens = useCallback(async (chainId: number, silent: boolean = false, faucetChainId?: string): Promise<FaucetClaimResult> => {
if (!walletEVMAddress) { throw new Error("Wallet address is required") }
if (!isTestnet) { throw new Error("Faucet is only available on testnet") }

const chainConfig = l1List.find((chain: L1ListItem) => chain.evmChainId === chainId && chain.hasBuilderHubFaucet);
// If faucetChainId is provided, find by ID, otherwise find by evmChainId for backward compatibility
const chainConfig = faucetChainId
? l1List.find((chain: L1ListItem) => chain.id === faucetChainId)
: l1List.find((chain: L1ListItem) => chain.evmChainId === chainId && chain.hasBuilderHubFaucet);

if (!chainConfig) { throw new Error(`Unsupported chain or faucet not available for chain ID ${chainId}`) }

setIsClaimingEVM(prev => ({ ...prev, [chainId]: true }));

try {
const faucetRequest = async () => {
const response = await fetch(`/api/evm-chain-faucet?address=${walletEVMAddress}&chainId=${chainId}`);
// Include the faucet ID to distinguish between multiple faucets on the same chain
const faucetIdParam = chainConfig.id ? `&faucetId=${encodeURIComponent(chainConfig.id)}` : '';
const response = await fetch(`/api/evm-chain-faucet?address=${walletEVMAddress}&chainId=${chainId}${faucetIdParam}`);
const rawText = await response.text();

let data;
Expand Down