293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
import { Abi, AbiEvent, ExtractAbiEventNames } from "abitype";
|
|
import { BlockNumber, GetLogsParameters } from "viem";
|
|
import { hardhat } from "viem/chains";
|
|
import { Config, UsePublicClientReturnType, useBlockNumber, usePublicClient } from "wagmi";
|
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
|
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
|
|
import { AllowedChainIds } from "~~/utils/scaffold-eth";
|
|
import { replacer } from "~~/utils/scaffold-eth/common";
|
|
import {
|
|
ContractAbi,
|
|
ContractName,
|
|
UseScaffoldEventHistoryConfig,
|
|
UseScaffoldEventHistoryData,
|
|
} from "~~/utils/scaffold-eth/contract";
|
|
|
|
const getEvents = async (
|
|
getLogsParams: GetLogsParameters<AbiEvent | undefined, AbiEvent[] | undefined, boolean, BlockNumber, BlockNumber>,
|
|
publicClient?: UsePublicClientReturnType<Config, number>,
|
|
Options?: {
|
|
blockData?: boolean;
|
|
transactionData?: boolean;
|
|
receiptData?: boolean;
|
|
},
|
|
) => {
|
|
const logs = await publicClient?.getLogs({
|
|
address: getLogsParams.address,
|
|
fromBlock: getLogsParams.fromBlock,
|
|
toBlock: getLogsParams.toBlock,
|
|
args: getLogsParams.args,
|
|
event: getLogsParams.event,
|
|
});
|
|
if (!logs) return undefined;
|
|
|
|
const finalEvents = await Promise.all(
|
|
logs.map(async log => {
|
|
return {
|
|
...log,
|
|
blockData:
|
|
Options?.blockData && log.blockHash ? await publicClient?.getBlock({ blockHash: log.blockHash }) : null,
|
|
transactionData:
|
|
Options?.transactionData && log.transactionHash
|
|
? await publicClient?.getTransaction({ hash: log.transactionHash })
|
|
: null,
|
|
receiptData:
|
|
Options?.receiptData && log.transactionHash
|
|
? await publicClient?.getTransactionReceipt({ hash: log.transactionHash })
|
|
: null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return finalEvents;
|
|
};
|
|
|
|
/**
|
|
* @deprecated **Recommended only for local (hardhat/anvil) chains and development.**
|
|
* It uses getLogs which can overload RPC endpoints (especially on L2s with short block times).
|
|
* For production, use an indexer such as ponder.sh or similar to query contract events efficiently.
|
|
*
|
|
* Reads events from a deployed contract.
|
|
* @param config - The config settings
|
|
* @param config.contractName - deployed contract name
|
|
* @param config.eventName - name of the event to listen for
|
|
* @param config.fromBlock - optional block number to start reading events from (defaults to `deployedOnBlock` in deployedContracts.ts if set for contract, otherwise defaults to 0)
|
|
* @param config.toBlock - optional block number to stop reading events at (if not provided, reads until current block)
|
|
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
|
|
* @param config.filters - filters to be applied to the event (parameterName: value)
|
|
* @param config.blockData - if set to true it will return the block data for each event (default: false)
|
|
* @param config.transactionData - if set to true it will return the transaction data for each event (default: false)
|
|
* @param config.receiptData - if set to true it will return the receipt data for each event (default: false)
|
|
* @param config.watch - if set to true, the events will be updated every pollingInterval milliseconds set at scaffoldConfig (default: false)
|
|
* @param config.enabled - set this to false to disable the hook from running (default: true)
|
|
* @param config.blocksBatchSize - optional batch size for fetching events. If specified, each batch will contain at most this many blocks (default: 500)
|
|
*/
|
|
export const useScaffoldEventHistory = <
|
|
TContractName extends ContractName,
|
|
TEventName extends ExtractAbiEventNames<ContractAbi<TContractName>>,
|
|
TBlockData extends boolean = false,
|
|
TTransactionData extends boolean = false,
|
|
TReceiptData extends boolean = false,
|
|
>({
|
|
contractName,
|
|
eventName,
|
|
fromBlock,
|
|
toBlock,
|
|
chainId,
|
|
filters,
|
|
blockData,
|
|
transactionData,
|
|
receiptData,
|
|
watch,
|
|
enabled = true,
|
|
blocksBatchSize = 500,
|
|
}: UseScaffoldEventHistoryConfig<TContractName, TEventName, TBlockData, TTransactionData, TReceiptData>) => {
|
|
const selectedNetwork = useSelectedNetwork(chainId);
|
|
|
|
// Runtime warning for non-local chains
|
|
useEffect(() => {
|
|
if (selectedNetwork.id !== hardhat.id) {
|
|
console.log(
|
|
"⚠️ useScaffoldEventHistory is not optimized for production use. It can overload RPC endpoints (especially on L2s)",
|
|
);
|
|
}
|
|
}, [selectedNetwork.id]);
|
|
|
|
const publicClient = usePublicClient({
|
|
chainId: selectedNetwork.id,
|
|
});
|
|
const [liveEvents, setLiveEvents] = useState<any[]>([]);
|
|
const [lastFetchedBlock, setLastFetchedBlock] = useState<bigint | null>(null);
|
|
const [isPollingActive, setIsPollingActive] = useState(false);
|
|
|
|
const { data: blockNumber } = useBlockNumber({ watch: watch, chainId: selectedNetwork.id });
|
|
|
|
const { data: deployedContractData } = useDeployedContractInfo({
|
|
contractName,
|
|
chainId: selectedNetwork.id as AllowedChainIds,
|
|
});
|
|
|
|
const event =
|
|
deployedContractData &&
|
|
((deployedContractData.abi as Abi).find(part => part.type === "event" && part.name === eventName) as AbiEvent);
|
|
|
|
const isContractAddressAndClientReady = Boolean(deployedContractData?.address) && Boolean(publicClient);
|
|
|
|
const fromBlockValue =
|
|
fromBlock !== undefined
|
|
? fromBlock
|
|
: BigInt(
|
|
deployedContractData && "deployedOnBlock" in deployedContractData
|
|
? deployedContractData.deployedOnBlock || 0
|
|
: 0,
|
|
);
|
|
|
|
const query = useInfiniteQuery({
|
|
queryKey: [
|
|
"eventHistory",
|
|
{
|
|
contractName,
|
|
address: deployedContractData?.address,
|
|
eventName,
|
|
fromBlock: fromBlockValue?.toString(),
|
|
toBlock: toBlock?.toString(),
|
|
chainId: selectedNetwork.id,
|
|
filters: JSON.stringify(filters, replacer),
|
|
blocksBatchSize: blocksBatchSize.toString(),
|
|
},
|
|
],
|
|
queryFn: async ({ pageParam }) => {
|
|
if (!isContractAddressAndClientReady) return undefined;
|
|
|
|
// Calculate the toBlock for this batch
|
|
let batchToBlock = toBlock;
|
|
const batchEndBlock = pageParam + BigInt(blocksBatchSize) - 1n;
|
|
const maxBlock = toBlock || (blockNumber ? BigInt(blockNumber) : undefined);
|
|
if (maxBlock) {
|
|
batchToBlock = batchEndBlock < maxBlock ? batchEndBlock : maxBlock;
|
|
}
|
|
|
|
const data = await getEvents(
|
|
{
|
|
address: deployedContractData?.address,
|
|
event,
|
|
fromBlock: pageParam,
|
|
toBlock: batchToBlock,
|
|
args: filters,
|
|
},
|
|
publicClient,
|
|
{ blockData, transactionData, receiptData },
|
|
);
|
|
|
|
setLastFetchedBlock(batchToBlock || blockNumber || 0n);
|
|
|
|
return data;
|
|
},
|
|
enabled: enabled && isContractAddressAndClientReady && !isPollingActive, // Disable when polling starts
|
|
initialPageParam: fromBlockValue,
|
|
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
|
if (!blockNumber || fromBlockValue >= blockNumber) return undefined;
|
|
|
|
const nextBlock = lastPageParam + BigInt(blocksBatchSize);
|
|
|
|
// Don't go beyond the specified toBlock or current block
|
|
const maxBlock = toBlock && toBlock < blockNumber ? toBlock : blockNumber;
|
|
|
|
if (nextBlock > maxBlock) return undefined;
|
|
|
|
return nextBlock;
|
|
},
|
|
select: data => {
|
|
const events = data.pages.flat() as unknown as UseScaffoldEventHistoryData<
|
|
TContractName,
|
|
TEventName,
|
|
TBlockData,
|
|
TTransactionData,
|
|
TReceiptData
|
|
>;
|
|
|
|
return {
|
|
pages: events?.reverse(),
|
|
pageParams: data.pageParams,
|
|
};
|
|
},
|
|
});
|
|
|
|
// Check if we're caught up and should start polling
|
|
const shouldStartPolling = () => {
|
|
if (!watch || !blockNumber || isPollingActive) return false;
|
|
|
|
return !query.hasNextPage && query.status === "success";
|
|
};
|
|
|
|
// Poll for new events when watch mode is enabled
|
|
useQuery({
|
|
queryKey: ["liveEvents", contractName, eventName, blockNumber?.toString(), lastFetchedBlock?.toString()],
|
|
enabled: Boolean(
|
|
watch && enabled && isContractAddressAndClientReady && blockNumber && (shouldStartPolling() || isPollingActive),
|
|
),
|
|
queryFn: async () => {
|
|
if (!isContractAddressAndClientReady || !blockNumber) return null;
|
|
|
|
if (!isPollingActive && shouldStartPolling()) {
|
|
setIsPollingActive(true);
|
|
}
|
|
|
|
const maxBlock = toBlock && toBlock < blockNumber ? toBlock : blockNumber;
|
|
const startBlock = lastFetchedBlock || maxBlock;
|
|
|
|
// Only fetch if there are new blocks to check
|
|
if (startBlock >= maxBlock) return null;
|
|
|
|
const newEvents = await getEvents(
|
|
{
|
|
address: deployedContractData?.address,
|
|
event,
|
|
fromBlock: startBlock + 1n,
|
|
toBlock: maxBlock,
|
|
args: filters,
|
|
},
|
|
publicClient,
|
|
{ blockData, transactionData, receiptData },
|
|
);
|
|
|
|
if (newEvents && newEvents.length > 0) {
|
|
setLiveEvents(prev => [...newEvents, ...prev]);
|
|
}
|
|
|
|
setLastFetchedBlock(maxBlock);
|
|
return newEvents;
|
|
},
|
|
refetchInterval: false,
|
|
});
|
|
|
|
// Manual trigger to fetch next page when previous page completes (only when not polling)
|
|
useEffect(() => {
|
|
if (
|
|
!isPollingActive &&
|
|
query.status === "success" &&
|
|
query.hasNextPage &&
|
|
!query.isFetchingNextPage &&
|
|
!query.error
|
|
) {
|
|
query.fetchNextPage();
|
|
}
|
|
}, [query, isPollingActive]);
|
|
|
|
// Combine historical data from infinite query with live events from watch hook
|
|
const historicalEvents = query.data?.pages || [];
|
|
const allEvents = [...liveEvents, ...historicalEvents] as typeof historicalEvents;
|
|
|
|
// remove duplicates
|
|
const seenEvents = new Set<string>();
|
|
const combinedEvents = allEvents.filter(event => {
|
|
const eventKey = `${event?.transactionHash}-${event?.logIndex}-${event?.blockHash}`;
|
|
if (seenEvents.has(eventKey)) {
|
|
return false;
|
|
}
|
|
seenEvents.add(eventKey);
|
|
return true;
|
|
}) as typeof historicalEvents;
|
|
|
|
return {
|
|
data: combinedEvents,
|
|
status: query.status,
|
|
error: query.error,
|
|
isLoading: query.isLoading,
|
|
isFetchingNewEvent: query.isFetchingNextPage,
|
|
refetch: query.refetch,
|
|
};
|
|
};
|