Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/backmerge-02-07-26.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"create-eth": patch
---

- fix: duplicated logs (https://github.com/scaffold-eth/scaffold-eth-2/pull/1300)
- cache block explorer transaction total to avoid full-chain rescans on pagination (https://github.com/scaffold-eth/scaffold-eth-2/pull/1301)
- UI tweaks (https://github.com/scaffold-eth/scaffold-eth-2/pull/1304)
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const publicClient = createPublicClient({
});

export const ContractTabs = ({ address, contractData }: PageProps) => {
const { blocks, transactionReceipts, currentPage, totalTransactions, setCurrentPage } = useFetchBlocks();
const { blocks, transactionReceipts, currentPage, hasNextPage, setCurrentPage } = useFetchBlocks(address);
const [activeTab, setActiveTab] = useState("transactions");
const [isContract, setIsContract] = useState(false);

Expand All @@ -39,15 +39,6 @@ export const ContractTabs = ({ address, contractData }: PageProps) => {
checkIsContract();
}, [address]);

const filteredBlocks = blocks.filter(block =>
block.transactions.some(tx => {
if (typeof tx === "string") {
return false;
}
return tx.from.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase();
}),
);

return (
<>
{isContract && (
Expand Down Expand Up @@ -84,8 +75,8 @@ export const ContractTabs = ({ address, contractData }: PageProps) => {
)}
{activeTab === "transactions" && (
<div className="pt-4">
<TransactionsTable blocks={filteredBlocks} transactionReceipts={transactionReceipts} />
<PaginationButton currentPage={currentPage} totalItems={totalTransactions} setCurrentPage={setCurrentPage} />
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
<PaginationButton currentPage={currentPage} hasNextPage={hasNextPage} setCurrentPage={setCurrentPage} />
</div>
)}
{activeTab === "code" && contractData && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";

type PaginationButtonProps = {
currentPage: number;
totalItems: number;
hasNextPage: boolean;
setCurrentPage: (page: number) => void;
};

const ITEMS_PER_PAGE = 20;

export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => {
export const PaginationButton = ({ currentPage, hasNextPage, setCurrentPage }: PaginationButtonProps) => {
const isPrevButtonDisabled = currentPage === 0;
const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE);
const isNextButtonDisabled = !hasNextPage;

const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
Expand Down
4 changes: 2 additions & 2 deletions templates/base/packages/nextjs/app/blockexplorer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { notification } from "~~/utils/scaffold-eth";

const BlockExplorer: NextPage = () => {
const { blocks, transactionReceipts, currentPage, totalTransactions, setCurrentPage, error } = useFetchBlocks();
const { blocks, transactionReceipts, currentPage, hasNextPage, setCurrentPage, error } = useFetchBlocks();
const { targetNetwork } = useTargetNetwork();
const [isLocalNetwork, setIsLocalNetwork] = useState(true);
const [hasError, setHasError] = useState(false);
Expand Down Expand Up @@ -75,7 +75,7 @@ const BlockExplorer: NextPage = () => {
<div className="container mx-auto my-10">
<SearchBar />
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
<PaginationButton currentPage={currentPage} totalItems={totalTransactions} setCurrentPage={setCurrentPage} />
<PaginationButton currentPage={currentPage} hasNextPage={hasNextPage} setCurrentPage={setCurrentPage} />
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const HeaderMenuLinks = () => {
passHref
className={\`\${
isActive ? "bg-base-300" : ""
} hover:bg-base-300 focus:!bg-base-300 h-full px-4 text-sm gap-2 flex items-center\`}
} hover:bg-base-300 focus:!bg-base-300 h-full px-4 text-sm gap-2 flex items-center whitespace-nowrap\`}
>
{icon}
<span>{label}</span>
Expand All @@ -77,7 +77,7 @@ export const Header = () => {

return (
<div className="sticky lg:static top-0 navbar bg-base-100 min-h-16 shrink-0 justify-between z-20 border-b-2 border-base-300 p-0 sm:px-2">
<div className="navbar-start w-auto lg:w-1/2 self-stretch">
<div className="navbar-start w-auto self-stretch">
<details className="dropdown" ref={burgerMenuRef}>
<summary className="ml-1 btn btn-ghost lg:hidden hover:bg-transparent">
<Bars3Icon className="h-1/2" />
Expand Down
3 changes: 2 additions & 1 deletion templates/base/packages/nextjs/components/SwitchTheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ export const SwitchTheme = ({ className }: { className?: string }) => {

return (
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
{/* [--depth:0] removes DaisyUI's glossy top highlight so the indicator is a single flat color */}
<input
id="theme-toggle"
type="checkbox"
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all [--depth:0]"
onChange={handleToggle}
checked={isDarkMode}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,67 @@ export const useContractLogs = (address: Address) => {
const client = usePublicClient({ chainId: targetNetwork.id });

useEffect(() => {
setLogs([]);
let unwatch: (() => void) | undefined;
let lastFetchedBlock = 0n;
// Set to true on effect cleanup. Guards against in-flight fetchLogs completing after
// unmount or re-run (e.g. React Strict Mode, address/client change) and registering
// a duplicate watcher or calling setLogs with stale data.
let ignore = false;

const fetchLogs = async () => {
if (!client) return console.error("Client not found");
try {
const latestBlock = await client.getBlockNumber();
if (ignore) return;

const existingLogs = await client.getLogs({
address: address,
fromBlock: 0n,
toBlock: "latest",
toBlock: latestBlock,
});
if (ignore) return;

lastFetchedBlock = latestBlock;
setLogs(existingLogs);

unwatch = client.watchBlockNumber({
onBlockNumber: async blockNumber => {
const fromBlock = lastFetchedBlock + 1n;
if (fromBlock > blockNumber) return;

// Advance cursor before await so concurrent block callbacks don't overlap
lastFetchedBlock = blockNumber;

try {
const newLogs = await client.getLogs({
address: address,
fromBlock: fromBlock,
toBlock: blockNumber,
});
if (ignore) return;

if (newLogs.length > 0) {
setLogs(prevLogs => [...prevLogs, ...newLogs]);
}
} catch (error) {
// Roll back the cursor so the next block callback retries this range
lastFetchedBlock = fromBlock - 1n;
console.error("Failed to fetch logs:", error);
}
},
});
} catch (error) {
console.error("Failed to fetch logs:", error);
}
};

fetchLogs();

return client?.watchBlockNumber({
onBlockNumber: async (_blockNumber, prevBlockNumber) => {
const newLogs = await client.getLogs({
address: address,
fromBlock: prevBlockNumber,
toBlock: "latest",
});
setLogs(prevLogs => [...prevLogs, ...newLogs]);
},
});
return () => {
ignore = true;
unwatch?.();
};
}, [address, client]);

return logs;
Expand Down
64 changes: 42 additions & 22 deletions templates/base/packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import {
Address,
Block,
Hash,
Transaction,
Expand Down Expand Up @@ -48,14 +49,17 @@ const groupTransactionsByBlock = (items: { block: Block; tx: Transaction }[]): B
return groupedBlocks;
};

const fetchTransactionsPage = async (
// Collects the requested page plus one extra transaction (the cheap "is there a next page?"
// signal), counting only transactions that pass matchesFilter — so address views paginate over
// the address's own transactions instead of slicing a global page.
const fetchPageItems = async (
latestBlock: bigint,
page: number,
): Promise<{ items: { block: Block; tx: Transaction }[]; totalTransactions: number }> => {
matchesFilter: (tx: Transaction) => boolean,
): Promise<{ block: Block; tx: Transaction }[]> => {
const skipCount = page * TRANSACTIONS_PER_PAGE;
const pageItems: { block: Block; tx: Transaction }[] = [];
let skipped = 0;
let totalTransactions = 0;

for (let blockNum = latestBlock; blockNum >= 0n; ) {
const batchEnd = blockNum - BigInt(BLOCK_BATCH_SIZE - 1);
Expand All @@ -72,40 +76,53 @@ const fetchTransactionsPage = async (

for (const block of fetchedBlocks) {
for (const tx of block.transactions as Transaction[]) {
totalTransactions++;
if (!matchesFilter(tx)) {
continue;
}

if (skipped < skipCount) {
skipped++;
continue;
}

if (pageItems.length < TRANSACTIONS_PER_PAGE) {
pageItems.push({ block, tx });
pageItems.push({ block, tx });
if (pageItems.length > TRANSACTIONS_PER_PAGE) {
return pageItems;
}
}
}

blockNum = batchStart - 1n;
}

return { items: pageItems, totalTransactions };
return pageItems;
};

export const useFetchBlocks = () => {
export const useFetchBlocks = (addressFilter?: Address) => {
const [blocks, setBlocks] = useState<Block[]>([]);
const [transactionReceipts, setTransactionReceipts] = useState<{
[key: string]: TransactionReceipt;
}>({});
const [currentPage, setCurrentPage] = useState(0);
const [totalTransactions, setTotalTransactions] = useState(0);
const [hasNextPage, setHasNextPage] = useState(false);
const [error, setError] = useState<Error | null>(null);

const matchesAddressFilter = useCallback(
(tx: Transaction) => {
if (!addressFilter) return true;
const filter = addressFilter.toLowerCase();
return tx.from.toLowerCase() === filter || tx.to?.toLowerCase() === filter;
},
[addressFilter],
);

const fetchBlocks = useCallback(async () => {
setError(null);

try {
const blockNumber = await testClient.getBlockNumber();
const { items, totalTransactions: totalTxCount } = await fetchTransactionsPage(blockNumber, currentPage);
const fetchedItems = await fetchPageItems(blockNumber, currentPage, matchesAddressFilter);
const items = fetchedItems.slice(0, TRANSACTIONS_PER_PAGE);

items.forEach(({ tx }) => decodeTransactionData(tx));

Expand All @@ -122,12 +139,12 @@ export const useFetchBlocks = () => {
);

setBlocks(groupTransactionsByBlock(items));
setTotalTransactions(totalTxCount);
setHasNextPage(fetchedItems.length > TRANSACTIONS_PER_PAGE);
setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...txReceipts) }));
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
}
}, [currentPage]);
}, [currentPage, matchesAddressFilter]);

useEffect(() => {
fetchBlocks();
Expand All @@ -146,6 +163,12 @@ export const useFetchBlocks = () => {
blockWithTxDetails = { ...newBlock, transactions: transactionsDetails };
}

const matchingTransactions = (blockWithTxDetails.transactions as Transaction[]).filter(matchesAddressFilter);
if (matchingTransactions.length === 0) {
return;
}
blockWithTxDetails = { ...blockWithTxDetails, transactions: matchingTransactions };

(blockWithTxDetails.transactions as Transaction[]).forEach(tx => decodeTransactionData(tx));

const receipts = await Promise.all(
Expand All @@ -163,26 +186,23 @@ export const useFetchBlocks = () => {
setBlocks(prevBlocks => {
const latestBlockNumber = blockWithTxDetails.number!;
const existingBlockIndex = prevBlocks.findIndex(block => block.number === latestBlockNumber);
const existingBlockTransactionsCount =
existingBlockIndex >= 0 ? prevBlocks[existingBlockIndex].transactions.length : 0;
const latestBlockTransactionsCount = blockWithTxDetails.transactions.length;

const nextBlocks =
existingBlockIndex >= 0
? prevBlocks.map((block, index) => (index === existingBlockIndex ? blockWithTxDetails : block))
: [blockWithTxDetails, ...prevBlocks];

const transactionsDelta = latestBlockTransactionsCount - existingBlockTransactionsCount;
if (transactionsDelta !== 0) {
setTotalTransactions(prevTotal => Math.max(0, prevTotal + transactionsDelta));
}

const trimmedBlocks = [...nextBlocks];
let transactionsInTrimmedBlocks = trimmedBlocks.reduce(
(count, block) => count + block.transactions.length,
0,
);

// More than one page of transactions are now in view, so a next page exists.
if (transactionsInTrimmedBlocks > TRANSACTIONS_PER_PAGE) {
setHasNextPage(true);
}

while (transactionsInTrimmedBlocks > TRANSACTIONS_PER_PAGE && trimmedBlocks.length > 0) {
const removedBlock = trimmedBlocks.pop();
if (removedBlock) {
Expand All @@ -201,13 +221,13 @@ export const useFetchBlocks = () => {
};

return testClient.watchBlocks({ onBlock: handleNewBlock, includeTransactions: true });
}, [currentPage]);
}, [currentPage, matchesAddressFilter]);

return {
blocks,
transactionReceipts,
currentPage,
totalTransactions,
hasNextPage,
setCurrentPage,
error,
};
Expand Down
Loading