Initial commit with 🏗️ create-eth @ 2.0.4

This commit is contained in:
han
2026-01-23 20:20:58 +07:00
commit b330aba2b4
185 changed files with 36981 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
# Template for NextJS environment variables.
# For local development, copy this file, rename it to .env.local, and fill in the values.
# When deploying live, you'll need to store the vars in Vercel/System config.
# If not set, we provide default values (check `scaffold.config.ts`) so developers can start prototyping out of the box,
# but we recommend getting your own API Keys for Production Apps.
# To access the values stored in this env file you can use: process.env.VARIABLENAME
# You'll need to prefix the variables names with NEXT_PUBLIC_ if you want to access them on the client side.
# More info: https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
NEXT_PUBLIC_ALCHEMY_API_KEY=
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=

38
packages/nextjs/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
.vercel
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# typescript
*.tsbuildinfo
ipfs-upload.config.json

View File

@@ -0,0 +1,9 @@
module.exports = {
arrowParens: "avoid",
printWidth: 120,
tabWidth: 2,
trailingComma: "all",
importOrder: ["^react$", "^next/(.*)$", "<THIRD_PARTY_MODULES>", "^@heroicons/(.*)$", "^~~/(.*)$"],
importOrderSortSpecifiers: true,
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
};

View File

@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const CONFIG_PATH = path.join(process.cwd(), "..", "hardhat", "scripts", "oracle-bot", "config.json");
export async function POST(request: Request) {
try {
const body = await request.json();
const { value, nodeAddress } = body;
if (typeof value !== "number" || value < 0) {
return NextResponse.json({ error: "Value must be a non-negative number" }, { status: 400 });
}
// Read current config
const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8");
const config = JSON.parse(configContent);
// Update node-specific config
if (!config.NODE_CONFIGS[nodeAddress]) {
config.NODE_CONFIGS[nodeAddress] = { ...config.NODE_CONFIGS.default };
}
config.NODE_CONFIGS[nodeAddress].PRICE_VARIANCE = value;
// Write back to file
await fs.promises.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
return NextResponse.json({ success: true, value });
} catch (error) {
console.error("Error updating price variance:", error);
return NextResponse.json({ error: "Failed to update configuration" }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const nodeAddress = searchParams.get("nodeAddress");
if (!nodeAddress) {
return NextResponse.json({ error: "nodeAddress parameter is required" }, { status: 400 });
}
const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8");
const config = JSON.parse(configContent);
const nodeConfig = config.NODE_CONFIGS[nodeAddress] || config.NODE_CONFIGS.default;
return NextResponse.json({
value: nodeConfig.PRICE_VARIANCE,
});
} catch (error) {
console.error("Error reading price variance:", error);
return NextResponse.json({ error: "Failed to read configuration" }, { status: 500 });
}
}

View File

@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const CONFIG_PATH = path.join(process.cwd(), "..", "hardhat", "scripts", "oracle-bot", "config.json");
export async function POST(request: Request) {
try {
const body = await request.json();
const { value, nodeAddress } = body;
if (typeof value !== "number" || value < 0 || value > 1) {
return NextResponse.json({ error: "Value must be a number between 0 and 1" }, { status: 400 });
}
// Read current config
const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8");
const config = JSON.parse(configContent);
// Update node-specific config
if (!config.NODE_CONFIGS[nodeAddress]) {
config.NODE_CONFIGS[nodeAddress] = { ...config.NODE_CONFIGS.default };
}
config.NODE_CONFIGS[nodeAddress].PROBABILITY_OF_SKIPPING_REPORT = value;
// Write back to file
await fs.promises.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
return NextResponse.json({ success: true, value });
} catch (error) {
console.error("Error updating skip probability:", error);
return NextResponse.json({ error: "Failed to update configuration" }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const nodeAddress = searchParams.get("nodeAddress");
if (!nodeAddress) {
return NextResponse.json({ error: "nodeAddress parameter is required" }, { status: 400 });
}
const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8");
const config = JSON.parse(configContent);
const nodeConfig = config.NODE_CONFIGS[nodeAddress] || config.NODE_CONFIGS.default;
return NextResponse.json({
value: nodeConfig.PROBABILITY_OF_SKIPPING_REPORT,
});
} catch (error) {
console.error("Error reading skip probability:", error);
return NextResponse.json({ error: "Failed to read configuration" }, { status: 500 });
}
}

View File

@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { createPublicClient, createWalletClient, http, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { hardhat } from "viem/chains";
import deployedContracts from "~~/contracts/deployedContracts";
const oraTokenAbi = [
{
type: "function",
name: "transfer",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
const stakingOracleAbi = [
{
type: "function",
name: "oracleToken",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "address" }],
},
] as const;
const DEPLOYER_PRIVATE_KEY =
(process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY as `0x${string}` | undefined) ??
// Hardhat default account #0 private key (localhost only).
("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const);
function isAddress(value: unknown): value is `0x${string}` {
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
}
export async function POST(request: Request) {
try {
const body = await request.json();
const to = body?.to;
const amount = body?.amount ?? "2000";
if (!isAddress(to)) {
return NextResponse.json({ error: "Invalid `to` address" }, { status: 400 });
}
if (typeof amount !== "string" || !/^\d+(\.\d+)?$/.test(amount)) {
return NextResponse.json({ error: "Invalid `amount`" }, { status: 400 });
}
// Safety: this faucet is intended for local Hardhat usage only.
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "ORA faucet disabled in production" }, { status: 403 });
}
const publicClient = createPublicClient({ chain: hardhat, transport: http() });
const account = privateKeyToAccount(DEPLOYER_PRIVATE_KEY);
const walletClient = createWalletClient({ chain: hardhat, transport: http(), account });
const stakingOracleAddress = (deployedContracts as any)?.[hardhat.id]?.StakingOracle?.address as
| `0x${string}`
| undefined;
if (!stakingOracleAddress) {
return NextResponse.json({ error: "StakingOracle not deployed on this network" }, { status: 500 });
}
const oraTokenAddress = (await publicClient.readContract({
address: stakingOracleAddress,
abi: stakingOracleAbi,
functionName: "oracleToken",
})) as `0x${string}`;
const hash = await walletClient.writeContract({
address: oraTokenAddress,
abi: oraTokenAbi,
functionName: "transfer",
args: [to, parseEther(amount)],
});
await publicClient.waitForTransactionReceipt({ hash });
return NextResponse.json({ success: true, hash });
} catch (error) {
console.error("Error funding ORA:", error);
return NextResponse.json({ error: "Failed to fund ORA" }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
type AddressCodeTabProps = {
bytecode: string;
assembly: string;
};
export const AddressCodeTab = ({ bytecode, assembly }: AddressCodeTabProps) => {
const formattedAssembly = Array.from(assembly.matchAll(/\w+( 0x[a-fA-F0-9]+)?/g))
.map(it => it[0])
.join("\n");
return (
<div className="flex flex-col gap-3 p-4">
Bytecode
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
<pre className="px-5">
<code className="whitespace-pre-wrap overflow-auto break-words">{bytecode}</code>
</pre>
</div>
Opcodes
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
<pre className="px-5">
<code>{formattedAssembly}</code>
</pre>
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
"use client";
import { BackButton } from "./BackButton";
import { ContractTabs } from "./ContractTabs";
import { Address, Balance } from "@scaffold-ui/components";
import { Address as AddressType } from "viem";
import { hardhat } from "viem/chains";
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
export const AddressComponent = ({
address,
contractData,
}: {
address: AddressType;
contractData: { bytecode: string; assembly: string } | null;
}) => {
const { targetNetwork } = useTargetNetwork();
return (
<div className="m-10 mb-20">
<div className="flex justify-start mb-5">
<BackButton />
</div>
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-10">
<div className="col-span-1 flex flex-col">
<div className="bg-base-100 border-base-300 border shadow-md shadow-secondary rounded-3xl px-6 lg:px-8 mb-6 space-y-1 py-4 overflow-x-auto">
<div className="flex">
<div className="flex flex-col gap-1">
<Address
address={address}
format="long"
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${address}` : undefined
}
/>
<div className="flex gap-1 items-center">
<span className="font-bold text-sm">Balance:</span>
<Balance address={address} />
</div>
</div>
</div>
</div>
</div>
</div>
<ContractTabs address={address} contractData={contractData} />
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { Address } from "viem";
import { useContractLogs } from "~~/hooks/scaffold-eth";
import { replacer } from "~~/utils/scaffold-eth/common";
export const AddressLogsTab = ({ address }: { address: Address }) => {
const contractLogs = useContractLogs(address);
return (
<div className="flex flex-col gap-3 p-4">
<div className="mockup-code overflow-auto max-h-[500px]">
<pre className="px-5 whitespace-pre-wrap break-words">
{contractLogs.map((log, i) => (
<div key={i}>
<strong>Log:</strong> {JSON.stringify(log, replacer, 2)}
</div>
))}
</pre>
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { Address, createPublicClient, http, toHex } from "viem";
import { hardhat } from "viem/chains";
const publicClient = createPublicClient({
chain: hardhat,
transport: http(),
});
export const AddressStorageTab = ({ address }: { address: Address }) => {
const [storage, setStorage] = useState<string[]>([]);
useEffect(() => {
const fetchStorage = async () => {
try {
const storageData = [];
let idx = 0;
while (true) {
const storageAtPosition = await publicClient.getStorageAt({
address: address,
slot: toHex(idx),
});
if (storageAtPosition === "0x" + "0".repeat(64)) break;
if (storageAtPosition) {
storageData.push(storageAtPosition);
}
idx++;
}
setStorage(storageData);
} catch (error) {
console.error("Failed to fetch storage:", error);
}
};
fetchStorage();
}, [address]);
return (
<div className="flex flex-col gap-3 p-4">
{storage.length > 0 ? (
<div className="mockup-code overflow-auto max-h-[500px]">
<pre className="px-5 whitespace-pre-wrap break-words">
{storage.map((data, i) => (
<div key={i}>
<strong>Storage Slot {i}:</strong> {data}
</div>
))}
</pre>
</div>
) : (
<div className="text-lg">This contract does not have any variables.</div>
)}
</div>
);
};

View File

@@ -0,0 +1,12 @@
"use client";
import { useRouter } from "next/navigation";
export const BackButton = () => {
const router = useRouter();
return (
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
Back
</button>
);
};

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import { AddressCodeTab } from "./AddressCodeTab";
import { AddressLogsTab } from "./AddressLogsTab";
import { AddressStorageTab } from "./AddressStorageTab";
import { PaginationButton } from "./PaginationButton";
import { TransactionsTable } from "./TransactionsTable";
import { Address, createPublicClient, http } from "viem";
import { hardhat } from "viem/chains";
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
type AddressCodeTabProps = {
bytecode: string;
assembly: string;
};
type PageProps = {
address: Address;
contractData: AddressCodeTabProps | null;
};
const publicClient = createPublicClient({
chain: hardhat,
transport: http(),
});
export const ContractTabs = ({ address, contractData }: PageProps) => {
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage } = useFetchBlocks();
const [activeTab, setActiveTab] = useState("transactions");
const [isContract, setIsContract] = useState(false);
useEffect(() => {
const checkIsContract = async () => {
const contractCode = await publicClient.getBytecode({ address: address });
setIsContract(contractCode !== undefined && contractCode !== "0x");
};
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 && (
<div role="tablist" className="tabs tabs-lift">
<button
role="tab"
className={`tab ${activeTab === "transactions" ? "tab-active" : ""}`}
onClick={() => setActiveTab("transactions")}
>
Transactions
</button>
<button
role="tab"
className={`tab ${activeTab === "code" ? "tab-active" : ""}`}
onClick={() => setActiveTab("code")}
>
Code
</button>
<button
role="tab"
className={`tab ${activeTab === "storage" ? "tab-active" : ""}`}
onClick={() => setActiveTab("storage")}
>
Storage
</button>
<button
role="tab"
className={`tab ${activeTab === "logs" ? "tab-active" : ""}`}
onClick={() => setActiveTab("logs")}
>
Logs
</button>
</div>
)}
{activeTab === "transactions" && (
<div className="pt-4">
<TransactionsTable blocks={filteredBlocks} transactionReceipts={transactionReceipts} />
<PaginationButton
currentPage={currentPage}
totalItems={Number(totalBlocks)}
setCurrentPage={setCurrentPage}
/>
</div>
)}
{activeTab === "code" && contractData && (
<AddressCodeTab bytecode={contractData.bytecode} assembly={contractData.assembly} />
)}
{activeTab === "storage" && <AddressStorageTab address={address} />}
{activeTab === "logs" && <AddressLogsTab address={address} />}
</>
);
};

View File

@@ -0,0 +1,39 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
type PaginationButtonProps = {
currentPage: number;
totalItems: number;
setCurrentPage: (page: number) => void;
};
const ITEMS_PER_PAGE = 20;
export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => {
const isPrevButtonDisabled = currentPage === 0;
const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE);
const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
if (isNextButtonDisabled && isPrevButtonDisabled) return null;
return (
<div className="mt-5 justify-end flex gap-3 mx-5">
<button
className={`btn btn-sm ${prevButtonClass}`}
disabled={isPrevButtonDisabled}
onClick={() => setCurrentPage(currentPage - 1)}
>
<ArrowLeftIcon className="h-4 w-4" />
</button>
<span className="self-center text-primary-content font-medium">Page {currentPage + 1}</span>
<button
className={`btn btn-sm ${nextButtonClass}`}
disabled={isNextButtonDisabled}
onClick={() => setCurrentPage(currentPage + 1)}
>
<ArrowRightIcon className="h-4 w-4" />
</button>
</div>
);
};

View File

@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { isAddress, isHex } from "viem";
import { hardhat } from "viem/chains";
import { usePublicClient } from "wagmi";
export const SearchBar = () => {
const [searchInput, setSearchInput] = useState("");
const router = useRouter();
const client = usePublicClient({ chainId: hardhat.id });
const handleSearch = async (event: React.FormEvent) => {
event.preventDefault();
if (isHex(searchInput)) {
try {
const tx = await client?.getTransaction({ hash: searchInput });
if (tx) {
router.push(`/blockexplorer/transaction/${searchInput}`);
return;
}
} catch (error) {
console.error("Failed to fetch transaction:", error);
}
}
if (isAddress(searchInput)) {
router.push(`/blockexplorer/address/${searchInput}`);
return;
}
};
return (
<form onSubmit={handleSearch} className="flex items-center justify-end mb-5 space-x-3 mx-5">
<input
className="border-primary bg-base-100 text-base-content placeholder:text-base-content/50 p-2 mr-2 w-full md:w-1/2 lg:w-1/3 rounded-md shadow-md focus:outline-hidden focus:ring-2 focus:ring-accent"
type="text"
value={searchInput}
placeholder="Search by hash or address"
onChange={e => setSearchInput(e.target.value)}
/>
<button className="btn btn-sm btn-primary" type="submit">
Search
</button>
</form>
);
};

View File

@@ -0,0 +1,28 @@
import Link from "next/link";
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
export const TransactionHash = ({ hash }: { hash: string }) => {
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
useCopyToClipboard();
return (
<div className="flex items-center">
<Link href={`/blockexplorer/transaction/${hash}`}>
{hash?.substring(0, 6)}...{hash?.substring(hash.length - 4)}
</Link>
{isAddressCopiedToClipboard ? (
<CheckCircleIcon
className="ml-1.5 text-xl font-normal text-base-content h-5 w-5 cursor-pointer"
aria-hidden="true"
/>
) : (
<DocumentDuplicateIcon
className="ml-1.5 text-xl font-normal h-5 w-5 cursor-pointer"
aria-hidden="true"
onClick={() => copyAddressToClipboard(hash)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,97 @@
import { TransactionHash } from "./TransactionHash";
import { Address } from "@scaffold-ui/components";
import { formatEther } from "viem";
import { hardhat } from "viem/chains";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { TransactionWithFunction } from "~~/utils/scaffold-eth";
import { TransactionsTableProps } from "~~/utils/scaffold-eth/";
export const TransactionsTable = ({ blocks, transactionReceipts }: TransactionsTableProps) => {
const { targetNetwork } = useTargetNetwork();
return (
<div className="flex justify-center px-4 md:px-0">
<div className="overflow-x-auto w-full shadow-2xl rounded-xl">
<table className="table text-xl bg-base-100 table-zebra w-full md:table-md table-sm">
<thead>
<tr className="rounded-xl text-sm text-base-content">
<th className="bg-primary">Transaction Hash</th>
<th className="bg-primary">Function Called</th>
<th className="bg-primary">Block Number</th>
<th className="bg-primary">Time Mined</th>
<th className="bg-primary">From</th>
<th className="bg-primary">To</th>
<th className="bg-primary text-end">Value ({targetNetwork.nativeCurrency.symbol})</th>
</tr>
</thead>
<tbody>
{blocks.map(block =>
(block.transactions as TransactionWithFunction[]).map(tx => {
const receipt = transactionReceipts[tx.hash];
const timeMined = new Date(Number(block.timestamp) * 1000).toLocaleString();
const functionCalled = tx.input.substring(0, 10);
return (
<tr key={tx.hash} className="hover text-sm">
<td className="w-1/12 md:py-4">
<TransactionHash hash={tx.hash} />
</td>
<td className="w-2/12 md:py-4">
{tx.functionName === "0x" ? "" : <span className="mr-1">{tx.functionName}</span>}
{functionCalled !== "0x" && (
<span className="badge badge-primary font-bold text-xs">{functionCalled}</span>
)}
</td>
<td className="w-1/12 md:py-4">{block.number?.toString()}</td>
<td className="w-2/12 md:py-4">{timeMined}</td>
<td className="w-2/12 md:py-4">
<Address
address={tx.from}
size="sm"
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${tx.from}` : undefined
}
/>
</td>
<td className="w-2/12 md:py-4">
{!receipt?.contractAddress ? (
tx.to && (
<Address
address={tx.to}
size="sm"
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${tx.to}` : undefined
}
/>
)
) : (
<div className="relative">
<Address
address={receipt.contractAddress}
size="sm"
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id
? `/blockexplorer/address/${receipt.contractAddress}`
: undefined
}
/>
<small className="absolute top-4 left-4">(Contract Creation)</small>
</div>
)}
</td>
<td className="text-right md:py-4">
{formatEther(tx.value)} {targetNetwork.nativeCurrency.symbol}
</td>
</tr>
);
}),
)}
</tbody>
</table>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./SearchBar";
export * from "./BackButton";
export * from "./AddressCodeTab";
export * from "./TransactionHash";
export * from "./ContractTabs";
export * from "./PaginationButton";
export * from "./TransactionsTable";

View File

@@ -0,0 +1,101 @@
import fs from "fs";
import path from "path";
import { Address } from "viem";
import { hardhat } from "viem/chains";
import { AddressComponent } from "~~/app/blockexplorer/_components/AddressComponent";
import deployedContracts from "~~/contracts/deployedContracts";
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
type PageProps = {
params: Promise<{ address: Address }>;
};
async function fetchByteCodeAndAssembly(buildInfoDirectory: string, contractPath: string) {
const buildInfoFiles = fs.readdirSync(buildInfoDirectory);
let bytecode = "";
let assembly = "";
for (let i = 0; i < buildInfoFiles.length; i++) {
const filePath = path.join(buildInfoDirectory, buildInfoFiles[i]);
const buildInfo = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (buildInfo.output.contracts[contractPath]) {
for (const contract in buildInfo.output.contracts[contractPath]) {
bytecode = buildInfo.output.contracts[contractPath][contract].evm.bytecode.object;
assembly = buildInfo.output.contracts[contractPath][contract].evm.bytecode.opcodes;
break;
}
}
if (bytecode && assembly) {
break;
}
}
return { bytecode, assembly };
}
const getContractData = async (address: Address) => {
const contracts = deployedContracts as GenericContractsDeclaration | null;
const chainId = hardhat.id;
if (!contracts || !contracts[chainId] || Object.keys(contracts[chainId]).length === 0) {
return null;
}
let contractPath = "";
const buildInfoDirectory = path.join(
__dirname,
"..",
"..",
"..",
"..",
"..",
"..",
"..",
"hardhat",
"artifacts",
"build-info",
);
if (!fs.existsSync(buildInfoDirectory)) {
throw new Error(`Directory ${buildInfoDirectory} not found.`);
}
const deployedContractsOnChain = contracts[chainId];
for (const [contractName, contractInfo] of Object.entries(deployedContractsOnChain)) {
if (contractInfo.address.toLowerCase() === address.toLowerCase()) {
contractPath = `contracts/${contractName}.sol`;
break;
}
}
if (!contractPath) {
// No contract found at this address
return null;
}
const { bytecode, assembly } = await fetchByteCodeAndAssembly(buildInfoDirectory, contractPath);
return { bytecode, assembly };
};
export function generateStaticParams() {
// An workaround to enable static exports in Next.js, generating single dummy page.
return [{ address: "0x0000000000000000000000000000000000000000" }];
}
const AddressPage = async (props: PageProps) => {
const params = await props.params;
const address = params?.address as Address;
if (isZeroAddress(address)) return null;
const contractData: { bytecode: string; assembly: string } | null = await getContractData(address);
return <AddressComponent address={address} contractData={contractData} />;
};
export default AddressPage;

View File

@@ -0,0 +1,12 @@
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
export const metadata = getMetadata({
title: "Block Explorer",
description: "Block Explorer created with 🏗 Scaffold-ETH 2",
});
const BlockExplorerLayout = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default BlockExplorerLayout;

View File

@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { PaginationButton, SearchBar, TransactionsTable } from "./_components";
import type { NextPage } from "next";
import { hardhat } from "viem/chains";
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { notification } from "~~/utils/scaffold-eth";
const BlockExplorer: NextPage = () => {
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage, error } = useFetchBlocks();
const { targetNetwork } = useTargetNetwork();
const [isLocalNetwork, setIsLocalNetwork] = useState(true);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (targetNetwork.id !== hardhat.id) {
setIsLocalNetwork(false);
}
}, [targetNetwork.id]);
useEffect(() => {
if (targetNetwork.id === hardhat.id && error) {
setHasError(true);
}
}, [targetNetwork.id, error]);
useEffect(() => {
if (!isLocalNetwork) {
notification.error(
<>
<p className="font-bold mt-0 mb-1">
<code className="italic bg-base-300 text-base font-bold"> targetNetwork </code> is not localhost
</p>
<p className="m-0">
- You are on <code className="italic bg-base-300 text-base font-bold">{targetNetwork.name}</code> .This
block explorer is only for <code className="italic bg-base-300 text-base font-bold">localhost</code>.
</p>
<p className="mt-1 break-normal">
- You can use{" "}
<a className="text-accent" href={targetNetwork.blockExplorers?.default.url}>
{targetNetwork.blockExplorers?.default.name}
</a>{" "}
instead
</p>
</>,
);
}
}, [
isLocalNetwork,
targetNetwork.blockExplorers?.default.name,
targetNetwork.blockExplorers?.default.url,
targetNetwork.name,
]);
useEffect(() => {
if (hasError) {
notification.error(
<>
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
<p className="m-0">
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
</p>
<p className="mt-1 break-normal">
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
</p>
</>,
);
}
}, [hasError]);
return (
<div className="container mx-auto my-10">
<SearchBar />
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
<PaginationButton currentPage={currentPage} totalItems={Number(totalBlocks)} setCurrentPage={setCurrentPage} />
</div>
);
};
export default BlockExplorer;

View File

@@ -0,0 +1,23 @@
import TransactionComp from "../_components/TransactionComp";
import type { NextPage } from "next";
import { Hash } from "viem";
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
type PageProps = {
params: Promise<{ txHash?: Hash }>;
};
export function generateStaticParams() {
// An workaround to enable static exports in Next.js, generating single dummy page.
return [{ txHash: "0x0000000000000000000000000000000000000000" }];
}
const TransactionPage: NextPage<PageProps> = async (props: PageProps) => {
const params = await props.params;
const txHash = params?.txHash as Hash;
if (isZeroAddress(txHash)) return null;
return <TransactionComp txHash={txHash} />;
};
export default TransactionPage;

View File

@@ -0,0 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Address } from "@scaffold-ui/components";
import { Hash, Transaction, TransactionReceipt, formatEther, formatUnits } from "viem";
import { hardhat } from "viem/chains";
import { usePublicClient } from "wagmi";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { decodeTransactionData, getFunctionDetails } from "~~/utils/scaffold-eth";
import { replacer } from "~~/utils/scaffold-eth/common";
const TransactionComp = ({ txHash }: { txHash: Hash }) => {
const client = usePublicClient({ chainId: hardhat.id });
const router = useRouter();
const [transaction, setTransaction] = useState<Transaction>();
const [receipt, setReceipt] = useState<TransactionReceipt>();
const [functionCalled, setFunctionCalled] = useState<string>();
const { targetNetwork } = useTargetNetwork();
useEffect(() => {
if (txHash && client) {
const fetchTransaction = async () => {
const tx = await client.getTransaction({ hash: txHash });
const receipt = await client.getTransactionReceipt({ hash: txHash });
const transactionWithDecodedData = decodeTransactionData(tx);
setTransaction(transactionWithDecodedData);
setReceipt(receipt);
const functionCalled = transactionWithDecodedData.input.substring(0, 10);
setFunctionCalled(functionCalled);
};
fetchTransaction();
}
}, [client, txHash]);
return (
<div className="container mx-auto mt-10 mb-20 px-10 md:px-0">
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
Back
</button>
{transaction ? (
<div className="overflow-x-auto">
<h2 className="text-3xl font-bold mb-4 text-center text-primary-content">Transaction Details</h2>{" "}
<table className="table rounded-lg bg-base-100 w-full shadow-lg md:table-lg table-md">
<tbody>
<tr>
<td>
<strong>Transaction Hash:</strong>
</td>
<td>{transaction.hash}</td>
</tr>
<tr>
<td>
<strong>Block Number:</strong>
</td>
<td>{Number(transaction.blockNumber)}</td>
</tr>
<tr>
<td>
<strong>From:</strong>
</td>
<td>
<Address
address={transaction.from}
format="long"
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${transaction.from}` : undefined
}
/>
</td>
</tr>
<tr>
<td>
<strong>To:</strong>
</td>
<td>
{!receipt?.contractAddress ? (
transaction.to && (
<Address
address={transaction.to}
format="long"
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${transaction.to}` : undefined
}
/>
)
) : (
<span>
Contract Creation:
<Address
address={receipt.contractAddress}
format="long"
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id
? `/blockexplorer/address/${receipt.contractAddress}`
: undefined
}
/>
</span>
)}
</td>
</tr>
<tr>
<td>
<strong>Value:</strong>
</td>
<td>
{formatEther(transaction.value)} {targetNetwork.nativeCurrency.symbol}
</td>
</tr>
<tr>
<td>
<strong>Function called:</strong>
</td>
<td>
<div className="w-full md:max-w-[600px] lg:max-w-[800px] overflow-x-auto whitespace-nowrap">
{functionCalled === "0x" ? (
"This transaction did not call any function."
) : (
<>
<span className="mr-2">{getFunctionDetails(transaction)}</span>
<span className="badge badge-primary font-bold">{functionCalled}</span>
</>
)}
</div>
</td>
</tr>
<tr>
<td>
<strong>Gas Price:</strong>
</td>
<td>{formatUnits(transaction.gasPrice || 0n, 9)} Gwei</td>
</tr>
<tr>
<td>
<strong>Data:</strong>
</td>
<td className="form-control">
<textarea
readOnly
value={transaction.input}
className="p-0 w-full textarea-primary bg-inherit h-[150px]"
/>
</td>
</tr>
<tr>
<td>
<strong>Logs:</strong>
</td>
<td>
<ul>
{receipt?.logs?.map((log, i) => (
<li key={i}>
<strong>Log {i} topics:</strong> {JSON.stringify(log.topics, replacer, 2)}
</li>
))}
</ul>
</td>
</tr>
</tbody>
</table>
</div>
) : (
<p className="text-2xl text-base-content">Loading...</p>
)}
</div>
);
};
export default TransactionComp;

View File

@@ -0,0 +1,38 @@
"use client";
// @refresh reset
import { Contract } from "@scaffold-ui/debug-contracts";
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { ContractName } from "~~/utils/scaffold-eth/contract";
type ContractUIProps = {
contractName: ContractName;
className?: string;
};
/**
* UI component to interface with deployed contracts.
**/
export const ContractUI = ({ contractName }: ContractUIProps) => {
const { targetNetwork } = useTargetNetwork();
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({ contractName });
if (deployedContractLoading) {
return (
<div className="mt-14">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!deployedContractData) {
return (
<p className="text-3xl mt-14">
No contract found by the name of {contractName} on chain {targetNetwork.name}!
</p>
);
}
return <Contract contractName={contractName as string} contract={deployedContractData} chainId={targetNetwork.id} />;
};

View File

@@ -0,0 +1,71 @@
"use client";
import { useEffect, useMemo } from "react";
import { ContractUI } from "./ContractUI";
import "@scaffold-ui/debug-contracts/styles.css";
import { useSessionStorage } from "usehooks-ts";
import { BarsArrowUpIcon } from "@heroicons/react/20/solid";
import { ContractName, GenericContract } from "~~/utils/scaffold-eth/contract";
import { useAllContracts } from "~~/utils/scaffold-eth/contractsData";
const selectedContractStorageKey = "scaffoldEth2.selectedContract";
export function DebugContracts() {
const contractsData = useAllContracts();
const contractNames = useMemo(
() =>
Object.keys(contractsData).sort((a, b) => {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}) as ContractName[],
[contractsData],
);
const [selectedContract, setSelectedContract] = useSessionStorage<ContractName>(
selectedContractStorageKey,
contractNames[0],
{ initializeWithValue: false },
);
useEffect(() => {
if (!contractNames.includes(selectedContract)) {
setSelectedContract(contractNames[0]);
}
}, [contractNames, selectedContract, setSelectedContract]);
return (
<div className="flex flex-col gap-y-6 lg:gap-y-8 py-8 lg:py-12 justify-center items-center">
{contractNames.length === 0 ? (
<p className="text-3xl mt-14">No contracts found!</p>
) : (
<>
{contractNames.length > 1 && (
<div className="flex flex-row gap-2 w-full max-w-7xl pb-1 px-6 lg:px-10 flex-wrap">
{contractNames.map(contractName => (
<button
className={`btn btn-secondary btn-sm font-light hover:border-transparent ${
contractName === selectedContract
? "bg-base-300 hover:bg-base-300 no-animation"
: "bg-base-100 hover:bg-secondary"
}`}
key={contractName}
onClick={() => setSelectedContract(contractName)}
>
{contractName}
{(contractsData[contractName] as GenericContract)?.external && (
<span className="tooltip tooltip-top tooltip-accent" data-tip="External contract">
<BarsArrowUpIcon className="h-4 w-4 cursor-pointer" />
</span>
)}
</button>
))}
</div>
)}
{contractNames.map(
contractName =>
contractName === selectedContract && <ContractUI key={contractName} contractName={contractName} />,
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { DebugContracts } from "./_components/DebugContracts";
import type { NextPage } from "next";
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
export const metadata = getMetadata({
title: "Debug Contracts",
description: "Debug your deployed 🏗 Scaffold-ETH 2 contracts in an easy way",
});
const Debug: NextPage = () => {
return (
<>
<DebugContracts />
<div className="text-center mt-8 bg-secondary p-10">
<h1 className="text-4xl my-0">Debug Contracts</h1>
<p className="text-neutral">
You can debug & interact with your deployed contracts here.
<br /> Check{" "}
<code className="italic bg-base-300 text-base font-bold [word-spacing:-0.5rem] px-1">
packages / nextjs / app / debug / page.tsx
</code>{" "}
</p>
</div>
</>
);
};
export default Debug;

View File

@@ -0,0 +1,31 @@
import { Space_Grotesk } from "next/font/google";
import "@rainbow-me/rainbowkit/styles.css";
import "@scaffold-ui/components/styles.css";
import { ScaffoldEthAppWithProviders } from "~~/components/ScaffoldEthAppWithProviders";
import { ThemeProvider } from "~~/components/ThemeProvider";
import "~~/styles/globals.css";
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-space-grotesk",
});
export const metadata = getMetadata({
title: "Oracles | Speedrun Ethereum",
description: "Built with 🏗 Scaffold-ETH 2",
});
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
return (
<html suppressHydrationWarning className={`${spaceGrotesk.variable} font-space-grotesk`}>
<body>
<ThemeProvider enableSystem>
<ScaffoldEthAppWithProviders>{children}</ScaffoldEthAppWithProviders>
</ThemeProvider>
</body>
</html>
);
};
export default ScaffoldEthApp;

View File

@@ -0,0 +1,16 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex items-center h-full flex-1 justify-center bg-base-200">
<div className="text-center">
<h1 className="text-6xl font-bold m-0 mb-1">404</h1>
<h2 className="text-2xl font-semibold m-0">Page Not Found</h2>
<p className="text-base-content/70 m-0 mb-4">The page you&apos;re looking for doesn&apos;t exist.</p>
<Link href="/" className="btn btn-primary">
Go Home
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import type { NextPage } from "next";
import { useReadContracts } from "wagmi";
import { AssertedTable } from "~~/components/oracle/optimistic/AssertedTable";
import { AssertionModal } from "~~/components/oracle/optimistic/AssertionModal";
import { DisputedTable } from "~~/components/oracle/optimistic/DisputedTable";
import { ExpiredTable } from "~~/components/oracle/optimistic/ExpiredTable";
import { ProposedTable } from "~~/components/oracle/optimistic/ProposedTable";
import { SettledTable } from "~~/components/oracle/optimistic/SettledTable";
import { SubmitAssertionButton } from "~~/components/oracle/optimistic/SubmitAssertionButton";
import { useDeployedContractInfo, useScaffoldReadContract } from "~~/hooks/scaffold-eth";
import { useChallengeState } from "~~/services/store/challengeStore";
// Loading spinner component
const LoadingSpinner = () => (
<div className="flex justify-center items-center min-h-[400px]">
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-500"></div>
</div>
);
const Home: NextPage = () => {
const setRefetchAssertionStates = useChallengeState(state => state.setRefetchAssertionStates);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const { data: nextAssertionId, isLoading: isLoadingNextAssertionId } = useScaffoldReadContract({
contractName: "OptimisticOracle",
functionName: "nextAssertionId",
query: {
placeholderData: (previousData: any) => previousData,
},
});
// get deployed contract address
const { data: deployedContractAddress, isLoading: isLoadingDeployedContract } = useDeployedContractInfo({
contractName: "OptimisticOracle",
});
// Create contracts array to get state for all assertions from 1 to nextAssertionId-1
const assertionContracts = nextAssertionId
? Array.from({ length: Number(nextAssertionId) - 1 }, (_, i) => ({
address: deployedContractAddress?.address as `0x${string}`,
abi: deployedContractAddress?.abi,
functionName: "getState",
args: [BigInt(i + 1)],
})).filter(contract => contract.address && contract.abi)
: [];
const {
data: assertionStates,
refetch: refetchAssertionStates,
isLoading: isLoadingAssertionStates,
} = useReadContracts({
contracts: assertionContracts,
query: {
placeholderData: (previousData: any) => previousData,
},
});
// Set the refetch function in the global store
useEffect(() => {
if (refetchAssertionStates) {
setRefetchAssertionStates(refetchAssertionStates);
}
}, [refetchAssertionStates, setRefetchAssertionStates]);
// Map assertion IDs to their states and filter out expired ones (state 5)
const assertionStateMap =
nextAssertionId && assertionStates
? Array.from({ length: Number(nextAssertionId) - 1 }, (_, i) => ({
assertionId: i + 1,
state: (assertionStates[i]?.result as number) || 0, // Default to 0 (Invalid) if no result
}))
: [];
// Track when initial loading is complete
const isFirstLoading =
isInitialLoading && (isLoadingNextAssertionId || isLoadingAssertionStates || isLoadingDeployedContract);
// Mark as initially loaded when all data is available
useEffect(() => {
if (isInitialLoading && !isLoadingNextAssertionId && !isLoadingDeployedContract && !isLoadingAssertionStates) {
setIsInitialLoading(false);
}
}, [isInitialLoading, isLoadingNextAssertionId, isLoadingDeployedContract, isLoadingAssertionStates]);
return (
<div className="container mx-auto px-8 py-8 max-w-screen-lg xl:max-w-screen-xl">
{/* Show loading spinner only during initial load */}
{isFirstLoading ? (
<LoadingSpinner />
) : (
<>
{/* Submit Assertion Button with Modal */}
<SubmitAssertionButton />
{/* Tables */}
<h2 className="text-2xl font-bold my-4">Asserted</h2>
<AssertedTable assertions={assertionStateMap.filter(assertion => assertion.state === 1)} />
<h2 className="text-2xl font-bold mt-12 mb-4">Proposed</h2>
<ProposedTable assertions={assertionStateMap.filter(assertion => assertion.state === 2)} />
<h2 className="text-2xl font-bold mt-12 mb-4">Disputed</h2>
<DisputedTable assertions={assertionStateMap.filter(assertion => assertion.state === 3)} />
<h2 className="text-2xl font-bold mt-12 mb-4">Settled</h2>
<SettledTable assertions={assertionStateMap.filter(assertion => assertion.state === 4)} />
<h2 className="text-2xl font-bold mt-12 mb-4">Expired</h2>
<ExpiredTable assertions={assertionStateMap.filter(assertion => assertion.state === 5)} />
</>
)}
<AssertionModal />
</div>
);
};
export default Home;

View File

@@ -0,0 +1,102 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Address } from "@scaffold-ui/components";
import type { NextPage } from "next";
import { hardhat } from "viem/chains";
import { useAccount } from "wagmi";
import { BugAntIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
const Home: NextPage = () => {
const { address: connectedAddress } = useAccount();
const { targetNetwork } = useTargetNetwork();
return (
<>
<div className="flex items-center flex-col grow pt-10">
<div className="px-5">
<h1 className="text-center">
<span className="block text-2xl mb-2">Welcome to</span>
<span className="block text-4xl font-bold">Scaffold-ETH 2</span>
<span className="block text-xl font-bold">(Speedrun Ethereum Oracles extension)</span>
</h1>
<div className="flex justify-center items-center space-x-2 flex-col">
<p className="my-2 font-medium">Connected Address:</p>
<Address
address={connectedAddress}
chain={targetNetwork}
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${connectedAddress}` : undefined
}
/>
</div>
<div className="flex items-center flex-col flex-grow mt-4">
<div className="px-5 w-[90%]">
<h1 className="text-center mb-6">
<span className="block text-4xl font-bold">Oracles</span>
</h1>
<div className="flex flex-col items-center justify-center">
<Image
src="/hero.png"
width="727"
height="231"
alt="challenge banner"
className="rounded-xl border-4 border-primary"
/>
<div className="max-w-3xl">
<p className="text-center text-lg mt-8">
🔮 Build your own decentralized oracle network! In this challenge, you&apos;ll explore different
oracle architectures and implementations. You&apos;ll dive deep into concepts like staking
mechanisms, consensus algorithms, slashing conditions, and dispute resolution all crucial
components of a robust oracle system.
</p>
<p className="text-center text-lg">
🌟 The final deliverable is a comprehensive understanding of oracle architectures through hands-on
implementation. You&apos;ll explore two existing oracle systems (Whitelist and Staking) to
understand their mechanics, then implement the Optimistic Oracle from scratch. Deploy your
optimistic oracle to a testnet and demonstrate how it handles assertions, proposals, disputes, and
settlements. Then build and upload your app to a public web server. Submit the url on{" "}
<a href="https://speedrunethereum.com/" target="_blank" rel="noreferrer" className="underline">
SpeedrunEthereum.com
</a>{" "}
!
</p>
</div>
</div>
</div>
</div>
</div>
<div className="grow bg-base-300 w-full mt-16 px-8 py-12">
<div className="flex justify-center items-center gap-12 flex-col md:flex-row">
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
<BugAntIcon className="h-8 w-8 fill-secondary" />
<p>
Tinker with your smart contract using the{" "}
<Link href="/debug" passHref className="link">
Debug Contracts
</Link>{" "}
tab.
</p>
</div>
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
<MagnifyingGlassIcon className="h-8 w-8 fill-secondary" />
<p>
Explore your local transactions with the{" "}
<Link href="/blockexplorer" passHref className="link">
Block Explorer
</Link>{" "}
tab.
</p>
</div>
</div>
</div>
</div>
</>
);
};
export default Home;

View File

@@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import type { NextPage } from "next";
import { BucketCountdown } from "~~/components/oracle/BucketCountdown";
import { BuyOraWidget } from "~~/components/oracle/BuyOraWidget";
import { NodesTable } from "~~/components/oracle/NodesTable";
import { PriceWidget } from "~~/components/oracle/PriceWidget";
import { TotalSlashedWidget } from "~~/components/oracle/TotalSlashedWidget";
const Home: NextPage = () => {
const [selectedBucket, setSelectedBucket] = useState<bigint | "current">("current");
return (
<>
<div className="flex items-center flex-col flex-grow pt-2">
<div className="w-full px-0 sm:px-2">
<div className="flex justify-end mr-4 pt-2">
<BuyOraWidget />
</div>
</div>
<div className="px-5 w-full max-w-5xl mx-auto">
<div className="flex flex-col gap-8">
<div className="w-full">
<div className="grid w-full items-stretch grid-cols-1 md:grid-cols-3 gap-4">
<PriceWidget contractName="StakingOracle" />
<BucketCountdown />
<TotalSlashedWidget />
</div>
</div>
<div className="w-full">
<NodesTable selectedBucket={selectedBucket} onBucketChange={setSelectedBucket} />
</div>
</div>
</div>
</div>
</>
);
};
export default Home;

View File

@@ -0,0 +1,26 @@
"use client";
import type { NextPage } from "next";
import { PriceWidget } from "~~/components/oracle/PriceWidget";
import { WhitelistTable } from "~~/components/oracle/whitelist/WhitelistTable";
const Home: NextPage = () => {
return (
<>
<div className="flex items-center flex-col flex-grow pt-10">
<div className="px-5 w-full max-w-5xl mx-auto">
<div className="flex flex-col gap-8">
<div className="w-full">
<PriceWidget contractName="WhitelistOracle" />
</div>
<div className="w-full">
<WhitelistTable />
</div>
</div>
</div>
</div>
</>
);
};
export default Home;

View File

@@ -0,0 +1,80 @@
import React from "react";
import Link from "next/link";
import { useFetchNativeCurrencyPrice } from "@scaffold-ui/hooks";
import { hardhat } from "viem/chains";
import { CurrencyDollarIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { HeartIcon } from "@heroicons/react/24/outline";
import { SwitchTheme } from "~~/components/SwitchTheme";
import { BuidlGuidlLogo } from "~~/components/assets/BuidlGuidlLogo";
import { Faucet } from "~~/components/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
/**
* Site footer
*/
export const Footer = () => {
const { targetNetwork } = useTargetNetwork();
const isLocalNetwork = targetNetwork.id === hardhat.id;
const { price: nativeCurrencyPrice } = useFetchNativeCurrencyPrice();
return (
<div className="min-h-0 py-5 px-1 mb-11 lg:mb-0">
<div>
<div className="fixed flex justify-between items-center w-full z-10 p-4 bottom-0 left-0 pointer-events-none">
<div className="flex flex-col md:flex-row gap-2 pointer-events-auto">
{nativeCurrencyPrice > 0 && (
<div>
<div className="btn btn-primary btn-sm font-normal gap-1 cursor-auto">
<CurrencyDollarIcon className="h-4 w-4" />
<span>{nativeCurrencyPrice.toFixed(2)}</span>
</div>
</div>
)}
{isLocalNetwork && (
<>
<Faucet />
<Link href="/blockexplorer" passHref className="btn btn-primary btn-sm font-normal gap-1">
<MagnifyingGlassIcon className="h-4 w-4" />
<span>Block Explorer</span>
</Link>
</>
)}
</div>
<SwitchTheme className={`pointer-events-auto ${isLocalNetwork ? "self-end md:self-auto" : ""}`} />
</div>
</div>
<div className="w-full">
<ul className="menu menu-horizontal w-full">
<div className="flex justify-center items-center gap-2 text-sm w-full">
<div className="text-center">
<a href="https://github.com/scaffold-eth/se-2" target="_blank" rel="noreferrer" className="link">
Fork me
</a>
</div>
<span>·</span>
<div className="flex justify-center items-center gap-2">
<p className="m-0 text-center">
Built with <HeartIcon className="inline-block h-4 w-4" /> at
</p>
<a
className="flex justify-center items-center gap-1"
href="https://buidlguidl.com/"
target="_blank"
rel="noreferrer"
>
<BuidlGuidlLogo className="w-3 h-5 pb-1" />
<span className="link">BuidlGuidl</span>
</a>
</div>
<span>·</span>
<div className="text-center">
<a href="https://t.me/joinchat/KByvmRe5wkR-8F_zz6AjpA" target="_blank" rel="noreferrer" className="link">
Support
</a>
</div>
</div>
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
"use client";
import React, { useRef } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { hardhat } from "viem/chains";
import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline";
import { FaucetButton, RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
import { useOutsideClick, useTargetNetwork } from "~~/hooks/scaffold-eth";
type HeaderMenuLink = {
label: string;
href: string;
icon?: React.ReactNode;
};
export const menuLinks: HeaderMenuLink[] = [
{
label: "Home",
href: "/",
},
{
label: "Whitelist",
href: "/whitelist",
},
{
label: "Staking",
href: "/staking",
},
{
label: "Optimistic",
href: "/optimistic",
},
{
label: "Debug Contracts",
href: "/debug",
icon: <BugAntIcon className="h-4 w-4" />,
},
];
export const HeaderMenuLinks = () => {
const pathname = usePathname();
return (
<>
{menuLinks.map(({ label, href, icon }) => {
const isActive = pathname === href;
return (
<li key={href}>
<Link
href={href}
passHref
className={`${
isActive ? "bg-secondary shadow-md" : ""
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
>
{icon}
<span>{label}</span>
</Link>
</li>
);
})}
</>
);
};
/**
* Site header
*/
export const Header = () => {
const { targetNetwork } = useTargetNetwork();
const isLocalNetwork = targetNetwork.id === hardhat.id;
const burgerMenuRef = useRef<HTMLDetailsElement>(null);
useOutsideClick(burgerMenuRef, () => {
burgerMenuRef?.current?.removeAttribute("open");
});
return (
<div className="sticky lg:static top-0 navbar bg-base-100 min-h-0 shrink-0 justify-between z-20 shadow-md shadow-secondary px-0 sm:px-2">
<div className="navbar-start w-auto lg:w-1/2">
<details className="dropdown" ref={burgerMenuRef}>
<summary className="ml-1 btn btn-ghost lg:hidden hover:bg-transparent">
<Bars3Icon className="h-1/2" />
</summary>
<ul
className="menu menu-compact dropdown-content mt-3 p-2 shadow-sm bg-base-100 rounded-box w-52"
onClick={() => {
burgerMenuRef?.current?.removeAttribute("open");
}}
>
<HeaderMenuLinks />
</ul>
</details>
<Link href="/" passHref className="hidden lg:flex items-center gap-2 ml-4 mr-6 shrink-0">
<div className="flex relative w-10 h-10">
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
</div>
<div className="flex flex-col">
<span className="font-bold leading-tight">SRE Challenges</span>
<span className="text-xs">Oracles</span>
</div>
</Link>
<ul className="hidden lg:flex lg:flex-nowrap menu menu-horizontal px-1 gap-2">
<HeaderMenuLinks />
</ul>
</div>
<div className="navbar-end grow mr-4">
<RainbowKitCustomConnectButton />
{isLocalNetwork && <FaucetButton />}
</div>
</div>
);
};

View File

@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppProgressBar as ProgressBar } from "next-nprogress-bar";
import { useTheme } from "next-themes";
import { Toaster } from "react-hot-toast";
import { WagmiProvider } from "wagmi";
import { Footer } from "~~/components/Footer";
import { Header } from "~~/components/Header";
import { BlockieAvatar } from "~~/components/scaffold-eth";
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
return (
<>
<div className={`flex flex-col min-h-screen font-space-grotesk`}>
<Header />
<main className="relative flex flex-col flex-1">{children}</main>
<Footer />
</div>
<Toaster />
</>
);
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
export const ScaffoldEthAppWithProviders = ({ children }: { children: React.ReactNode }) => {
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
avatar={BlockieAvatar}
theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()}
>
<ProgressBar height="3px" color="#2299dd" />
<ScaffoldEthApp>{children}</ScaffoldEthApp>
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
};

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
export const SwitchTheme = ({ className }: { className?: string }) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const isDarkMode = resolvedTheme === "dark";
const handleToggle = () => {
if (isDarkMode) {
setTheme("light");
return;
}
setTheme("dark");
};
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
<input
id="theme-toggle"
type="checkbox"
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
onChange={handleToggle}
checked={isDarkMode}
/>
<label htmlFor="theme-toggle" className={`swap swap-rotate ${!isDarkMode ? "swap-active" : ""}`}>
<SunIcon className="swap-on h-5 w-5" />
<MoonIcon className="swap-off h-5 w-5" />
</label>
</div>
);
};

View File

@@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};

View File

@@ -0,0 +1,33 @@
import React from "react";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
interface TooltipInfoProps {
top?: number;
right?: number;
infoText: string;
className?: string;
}
// Note: The relative positioning is required for the tooltip to work.
const TooltipInfo: React.FC<TooltipInfoProps> = ({ top, right, infoText, className = "" }) => {
const baseClasses = "tooltip tooltip-secondary font-normal [--radius-field:0.25rem]";
const tooltipClasses = className ? `${baseClasses} ${className}` : `${baseClasses} tooltip-right`;
if (top !== undefined && right !== undefined) {
return (
<span className="absolute z-10" style={{ top: `${top * 0.25}rem`, right: `${right * 0.25}rem` }}>
<div className={tooltipClasses} data-tip={infoText}>
<QuestionMarkCircleIcon className="h-5 w-5 m-1" />
</div>
</span>
);
}
return (
<div className={tooltipClasses} data-tip={infoText}>
<QuestionMarkCircleIcon className="h-5 w-5 m-1" />
</div>
);
};
export default TooltipInfo;

View File

@@ -0,0 +1,18 @@
export const BuidlGuidlLogo = ({ className }: { className: string }) => {
return (
<svg
className={className}
width="53"
height="72"
viewBox="0 0 53 72"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M25.9 17.434v15.638h3.927v9.04h9.718v-9.04h6.745v18.08l-10.607 19.88-12.11-.182-12.11.183L.856 51.152v-18.08h6.713v9.04h9.75v-9.04h4.329V2.46a2.126 2.126 0 0 1 4.047-.914c1.074.412 2.157 1.5 3.276 2.626 1.33 1.337 2.711 2.726 4.193 3.095 1.496.373 2.605-.026 3.855-.475 1.31-.47 2.776-.997 5.005-.747 1.67.197 2.557 1.289 3.548 2.509 1.317 1.623 2.82 3.473 6.599 3.752l-.024.017c-2.42 1.709-5.726 4.043-10.86 3.587-1.605-.139-2.736-.656-3.82-1.153-1.546-.707-2.997-1.37-5.59-.832-2.809.563-4.227 1.892-5.306 2.903-.236.221-.456.427-.67.606Z"
clipRule="evenodd"
/>
</svg>
);
};

View File

@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from "react";
import TooltipInfo from "../TooltipInfo";
import { usePublicClient } from "wagmi";
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
export const BucketCountdown = () => {
const publicClient = usePublicClient();
const { data: bucketWindow } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "BUCKET_WINDOW",
}) as { data: bigint | undefined };
const [remainingSec, setRemainingSec] = useState<number | null>(null);
const [currentBucketNum, setCurrentBucketNum] = useState<bigint | null>(null);
const lastBucketCheckTime = useRef<number>(0);
// Poll getCurrentBucketNumber every second for accuracy
const { data: contractBucketNum } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getCurrentBucketNumber",
watch: true,
}) as { data: bigint | undefined };
useEffect(() => {
if (contractBucketNum !== undefined) {
setCurrentBucketNum(contractBucketNum);
lastBucketCheckTime.current = Date.now();
}
}, [contractBucketNum]);
useEffect(() => {
if (!bucketWindow || !publicClient || !currentBucketNum) return;
let mounted = true;
const update = async () => {
try {
const block = await publicClient.getBlock();
const blockNum = Number(block.number);
const w = Number(bucketWindow);
if (w <= 0) {
setRemainingSec(null);
return;
}
// Calculate blocks remaining in current bucket
// Bucket number = (block.number / BUCKET_WINDOW) + 1
// So current bucket started at: (currentBucketNum - 1) * BUCKET_WINDOW
const bucketStartBlock = (Number(currentBucketNum) - 1) * w;
const nextBucketBlock = bucketStartBlock + w;
const blocksRemaining = nextBucketBlock - blockNum;
// Add 2 second offset since node is ahead of system time
const estimatedSecondsRemaining = Math.max(0, blocksRemaining + 2);
if (mounted) setRemainingSec(estimatedSecondsRemaining > 24 ? 24 : estimatedSecondsRemaining);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// ignore
}
};
update();
const id = setInterval(update, 1000);
return () => {
mounted = false;
clearInterval(id);
};
}, [bucketWindow, publicClient, currentBucketNum]);
return (
<div className="flex flex-col gap-2 h-full">
<h2 className="text-xl font-bold">Bucket Countdown</h2>
<div className="bg-base-100 rounded-lg p-4 w-full flex justify-center items-center relative h-full min-h-[140px]">
<TooltipInfo
top={0}
right={0}
className="tooltip-left"
infoText="Shows the current bucket number and countdown to the next bucket. Each bucket lasts 24 blocks."
/>
<div className="flex flex-col items-center gap-2">
<div className="text-sm text-gray-500">Bucket #{currentBucketNum?.toString() ?? "..."}</div>
<div className="font-bold text-3xl">{remainingSec !== null ? `${remainingSec}s` : "..."}</div>
<div className="text-xs text-gray-500">until next bucket</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,74 @@
"use client";
import { useMemo, useState } from "react";
import { erc20Abi, formatEther, parseEther } from "viem";
import { useAccount, useReadContract } from "wagmi";
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
const ETH_IN = "0.5";
const ORA_OUT = "100";
export const BuyOraWidget = () => {
const { address: connectedAddress } = useAccount();
const [isBuying, setIsBuying] = useState(false);
// NOTE: `deployedContracts.ts` is autogenerated from deployments. If ORA isn't listed yet,
// the hook will show a "Target Contract is not deployed" notification until you run `yarn deploy`.
// We keep TS compiling while deployments/ABIs are catching up.
const { writeContractAsync: writeOraUnsafe } = useScaffoldWriteContract({ contractName: "ORA" as any });
const writeOra = writeOraUnsafe as any;
// Read ORA balance using the token address wired into StakingOracle
const { data: oracleTokenAddress } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "oracleToken",
});
const { data: oraBalance, refetch: refetchOraBalance } = useReadContract({
address: oracleTokenAddress as `0x${string}` | undefined,
abi: erc20Abi,
functionName: "balanceOf",
args: connectedAddress ? [connectedAddress] : undefined,
query: { enabled: !!oracleTokenAddress && !!connectedAddress, refetchInterval: 5000 },
});
const oraBalanceFormatted = useMemo(() => {
if (oraBalance === undefined) return "—";
return Number(formatEther(oraBalance as bigint)).toLocaleString(undefined, { maximumFractionDigits: 2 });
}, [oraBalance]);
const handleBuy = async () => {
setIsBuying(true);
try {
await writeOra({
functionName: "buy",
value: parseEther(ETH_IN),
});
// Ensure the widget updates immediately after the tx confirms (instead of waiting for polling).
await refetchOraBalance();
} catch (e: any) {
console.error(e);
} finally {
setIsBuying(false);
}
};
return (
<div className="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm w-full md:w-auto">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold">Buy ORA</div>
<div className="text-xs text-gray-500 mt-1">
<span className="font-mono">{ETH_IN} ETH</span> <span className="font-mono">{ORA_OUT} ORA</span>
</div>
<div className="text-xs text-gray-500 mt-2">
Your ORA balance: <span className="font-mono">{oraBalanceFormatted}</span>
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={handleBuy} disabled={!connectedAddress || isBuying}>
{isBuying ? "Buying..." : "Buy ORA"}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
interface ConfigSliderProps {
nodeAddress: string;
endpoint: string;
label: string;
}
export const ConfigSlider = ({ nodeAddress, endpoint, label }: ConfigSliderProps) => {
const [value, setValue] = useState<number>(0.0);
const [isLoading, setIsLoading] = useState(false);
const [localValue, setLocalValue] = useState<number>(0.0);
// Fetch initial value
useEffect(() => {
const fetchValue = async () => {
try {
const response = await fetch(`/api/config/${endpoint}?nodeAddress=${nodeAddress}`);
const data = await response.json();
if (data.value !== undefined) {
setValue(data.value);
setLocalValue(data.value);
}
} catch (error) {
console.error(`Error fetching ${endpoint}:`, error);
}
};
fetchValue();
}, [nodeAddress, endpoint]);
const handleChange = (newValue: number) => {
setLocalValue(newValue);
};
const handleFinalChange = async () => {
if (localValue === value) return; // Don't send request if value hasn't changed
setIsLoading(true);
try {
const response = await fetch(`/api/config/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ value: localValue, nodeAddress }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `Failed to update ${endpoint}`);
}
setValue(localValue); // Update the committed value after successful API call
} catch (error) {
console.error(`Error updating ${endpoint}:`, error);
setLocalValue(value); // Reset to last known good value on error
} finally {
setIsLoading(false);
}
};
return (
<td className="relative">
<input
type="range"
min="0"
max="1"
step="0.01"
value={localValue}
onChange={e => handleChange(parseFloat(e.target.value))}
onMouseUp={handleFinalChange}
onTouchEnd={handleFinalChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<div className="text-xs font-medium text-neutral dark:text-neutral-content mt-1 text-center">
{(localValue * 100).toFixed(0)}% {label}
</div>
{isLoading && (
<div className="absolute inset-0 bg-white/50 dark:bg-black/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
</div>
)}
</td>
);
};

View File

@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react";
import { HighlightedCell } from "./HighlightedCell";
import { parseEther } from "viem";
import { useWriteContract } from "wagmi";
import { ArrowPathIcon, PencilIcon } from "@heroicons/react/24/outline";
import { SIMPLE_ORACLE_ABI } from "~~/utils/constants";
import { notification } from "~~/utils/scaffold-eth";
type EditableCellProps = {
value: string | number;
address: string;
highlightColor?: string;
};
export const EditableCell = ({ value, address, highlightColor = "" }: EditableCellProps) => {
const [isEditing, setIsEditing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [editValue, setEditValue] = useState(Number(value.toString()) || "");
const inputRef = useRef<HTMLInputElement>(null);
const { writeContractAsync } = useWriteContract();
// Update edit value when prop value changes
useEffect(() => {
if (!isEditing) {
setEditValue(Number(value.toString()) || "");
}
}, [value, isEditing]);
// Focus input when editing starts
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleSubmit = async () => {
const parsedValue = Number(editValue);
if (isNaN(parsedValue)) {
notification.error("Invalid number");
return;
}
try {
await writeContractAsync({
abi: SIMPLE_ORACLE_ABI,
address: address,
functionName: "setPrice",
args: [parseEther(parsedValue.toString())],
});
setIsEditing(false);
} catch (error) {
console.error("Submit failed:", error);
}
};
// Resubmits the currently displayed value without entering edit mode
const handleRefresh = async () => {
const parsedValue = Number(value.toString());
if (isNaN(parsedValue)) {
notification.error("Invalid number");
return;
}
try {
await writeContractAsync({
abi: SIMPLE_ORACLE_ABI,
address: address,
functionName: "setPrice",
args: [parseEther(parsedValue.toString())],
});
} catch (error) {
console.error("Refresh failed:", error);
}
};
const handleCancel = () => {
setIsEditing(false);
};
const startEditing = () => {
setIsEditing(true);
};
return (
<HighlightedCell
value={value}
highlightColor={highlightColor}
className={`w-[6rem] max-w-[6rem] whitespace-nowrap overflow-hidden`}
>
<div className="flex w-full items-start">
{/* 70% width for value display/editing */}
<div className="w-[70%]">
{isEditing ? (
<div className="relative px-1">
<input
ref={inputRef}
type={"text"}
value={editValue}
onChange={e => setEditValue(e.target.value)}
className="w-full text-sm bg-secondary rounded-md"
/>
</div>
) : (
<div className="flex items-center gap-2 h-full items-stretch">
<span className="truncate">{value}</span>
<div className="flex items-stretch gap-1">
<button
className="px-2 text-sm bg-primary rounded cursor-pointer"
onClick={startEditing}
title="Edit price"
>
<PencilIcon className="w-2.5 h-2.5" />
</button>
<button
className="px-2 text-sm bg-secondary rounded cursor-pointer disabled:opacity-50"
onClick={() => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
void handleRefresh();
} catch {}
setTimeout(() => setIsRefreshing(false), 3000);
}}
disabled={isRefreshing}
title="Resubmit price"
>
<ArrowPathIcon className={`w-2.5 h-2.5 ${isRefreshing ? "animate-spin" : ""}`} />
</button>
</div>
</div>
)}
</div>
{/* 30% width for action buttons */}
<div className="w-[30%] items-stretch justify-start pl-2">
{isEditing && (
<div className="flex items-stretch gap-1 w-full h-full">
<button onClick={handleSubmit} className="px-2 text-sm bg-primary rounded cursor-pointer">
</button>
<button onClick={handleCancel} className="px-2 text-sm bg-secondary rounded cursor-pointer">
</button>
</div>
)}
</div>
</div>
</HighlightedCell>
);
};

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";
export const HighlightedCell = ({
value,
highlightColor,
children,
className,
handleClick,
}: {
value: string | number;
highlightColor: string;
children: React.ReactNode;
className?: string;
handleClick?: () => void;
}) => {
const [isHighlighted, setIsHighlighted] = useState(false);
const prevValue = useRef<string | number | undefined>(undefined);
useEffect(() => {
if (value === undefined) return;
if (value === "Not reported") return;
if (value === "Loading...") return;
const hasPrev = typeof prevValue.current === "number" || typeof prevValue.current === "string";
if (hasPrev && value !== prevValue.current) {
setIsHighlighted(true);
const timer = setTimeout(() => setIsHighlighted(false), 1000);
return () => clearTimeout(timer);
}
prevValue.current = value;
}, [value]);
return (
<td
className={`transition-colors duration-300 ${isHighlighted ? highlightColor : ""} ${className}`}
onClick={handleClick}
>
{children}
</td>
);
};

View File

@@ -0,0 +1,200 @@
import { useMemo } from "react";
import { ConfigSlider } from "./ConfigSlider";
import { NodeRowProps } from "./types";
import { Address } from "@scaffold-ui/components";
import { erc20Abi, formatEther } from "viem";
import { useReadContract } from "wagmi";
import { HighlightedCell } from "~~/components/oracle/HighlightedCell";
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
import { getHighlightColorForPrice } from "~~/utils/helpers";
export interface NodeRowEditRequest {
address: string;
buttonRect: { top: number; left: number; bottom: number; right: number };
}
interface NodeRowWithEditProps extends NodeRowProps {
onEditRequest?: (req: NodeRowEditRequest) => void;
isEditing?: boolean;
showInlineSettings?: boolean;
}
export const NodeRow = ({ address, bucketNumber, showInlineSettings }: NodeRowWithEditProps) => {
// Hooks and contract reads
const { data: oracleTokenAddress } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "oracleToken",
});
const { data: oraBalance } = useReadContract({
address: oracleTokenAddress as `0x${string}`,
abi: erc20Abi,
functionName: "balanceOf",
args: [address],
query: { enabled: !!oracleTokenAddress, refetchInterval: 5000 },
});
const { data: minimumStake } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "MINIMUM_STAKE",
args: undefined,
});
const { data: currentBucket } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getCurrentBucketNumber",
}) as { data: bigint | undefined };
const previousBucket = useMemo(
() => (currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n),
[currentBucket],
);
const shouldFetchPrevMedian = currentBucket !== undefined && previousBucket > 0n;
const { data: prevBucketMedian } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getPastPrice",
args: [previousBucket] as any,
query: { enabled: shouldFetchPrevMedian },
}) as { data: bigint | undefined };
const { data: effectiveStake } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getEffectiveStake",
args: [address],
}) as { data: bigint | undefined };
// Get current bucket price
const { data: currentBucketPrice } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getSlashedStatus",
args: [address, currentBucket ?? 0n] as const,
watch: true,
}) as { data?: [bigint, boolean] };
const reportedPriceInCurrentBucket = currentBucketPrice?.[0];
// Past bucket data (always call hook; gate via enabled)
const isCurrentView = bucketNumber === null || bucketNumber === undefined;
const { data: addressDataAtBucket } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getSlashedStatus",
args: [address, (bucketNumber ?? 0n) as any],
query: { enabled: !isCurrentView },
}) as { data?: [bigint, boolean] };
const pastReportedPrice = !isCurrentView && addressDataAtBucket ? addressDataAtBucket[0] : undefined;
const pastSlashed = !isCurrentView && addressDataAtBucket ? addressDataAtBucket[1] : undefined;
const { data: selectedBucketMedian } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getPastPrice",
args: [bucketNumber ?? 0n] as any,
query: {
enabled: !isCurrentView && bucketNumber !== null && bucketNumber !== undefined && (bucketNumber as bigint) > 0n,
},
}) as { data: bigint | undefined };
// Formatting
const stakedAmountFormatted =
effectiveStake !== undefined
? Number(formatEther(effectiveStake)).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "Loading...";
const lastReportedPriceFormatted =
reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n
? `$${Number(parseFloat(formatEther(reportedPriceInCurrentBucket)).toFixed(2))}`
: "Not reported";
const oraBalanceFormatted =
oraBalance !== undefined
? Number(formatEther(oraBalance as bigint)).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "Loading...";
const isInsufficientStake =
effectiveStake !== undefined && minimumStake !== undefined && effectiveStake < (minimumStake as bigint);
// Calculate deviation for past buckets
const deviationText = useMemo(() => {
if (isCurrentView) return "—";
if (!pastReportedPrice || pastReportedPrice === 0n) return "—";
if (!selectedBucketMedian || selectedBucketMedian === 0n) return "—";
const median = Number(formatEther(selectedBucketMedian));
const price = Number(formatEther(pastReportedPrice));
if (!Number.isFinite(median) || median === 0) return "—";
const pct = ((price - median) / median) * 100;
const sign = pct > 0 ? "+" : "";
return `${sign}${pct.toFixed(2)}%`;
}, [isCurrentView, pastReportedPrice, selectedBucketMedian]);
// Deviation for current bucket vs previous bucket average
const currentDeviationText = useMemo(() => {
if (!isCurrentView) return "—";
if (!reportedPriceInCurrentBucket || reportedPriceInCurrentBucket === 0n) return "—";
if (!prevBucketMedian || prevBucketMedian === 0n) return "—";
const avg = Number(formatEther(prevBucketMedian));
const price = Number(formatEther(reportedPriceInCurrentBucket));
if (!Number.isFinite(avg) || avg === 0) return "—";
const pct = ((price - avg) / avg) * 100;
const sign = pct > 0 ? "+" : "";
return `${sign}${pct.toFixed(2)}%`;
}, [isCurrentView, reportedPriceInCurrentBucket, prevBucketMedian]);
return (
<>
<tr className={isInsufficientStake ? "opacity-40" : ""}>
<td>
<div className="flex flex-col">
<Address address={address} size="sm" format="short" onlyEnsOrAddress={true} />
<span className="text-xs opacity-70">{oraBalanceFormatted} ORA</span>
</div>
</td>
{showInlineSettings ? (
// Inline settings mode: only show the settings sliders column
<td className="whitespace-nowrap">
<div className="flex flex-col gap-2 min-w-[220px]">
<div className="flex items-center gap-2">
<ConfigSlider nodeAddress={address.toLowerCase()} endpoint="skip-probability" label="skip rate" />
<ConfigSlider nodeAddress={address.toLowerCase()} endpoint="price-variance" label="price deviation" />
</div>
</div>
</td>
) : isCurrentView ? (
<>
<HighlightedCell value={stakedAmountFormatted} highlightColor="bg-error">
{stakedAmountFormatted}
</HighlightedCell>
<HighlightedCell value={oraBalanceFormatted} highlightColor="bg-success">
{oraBalanceFormatted}
</HighlightedCell>
<HighlightedCell
value={lastReportedPriceFormatted}
highlightColor={getHighlightColorForPrice(reportedPriceInCurrentBucket, prevBucketMedian)}
className={""}
>
{lastReportedPriceFormatted}
</HighlightedCell>
<td>{currentDeviationText}</td>
</>
) : (
<>
<HighlightedCell
value={
pastReportedPrice !== undefined && pastReportedPrice !== 0n
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
: "Not reported"
}
highlightColor={
pastSlashed ? "bg-error" : getHighlightColorForPrice(pastReportedPrice, selectedBucketMedian)
}
className={pastSlashed ? "border-2 border-error" : ""}
>
{pastReportedPrice !== undefined && pastReportedPrice !== 0n
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
: "Not reported"}
{pastSlashed && <span className="ml-2 text-xs text-error">Slashed</span>}
</HighlightedCell>
<td>{deviationText}</td>
</>
)}
</tr>
{/* No inline editor row; editor is rendered by parent as floating panel */}
</>
);
};

View File

@@ -0,0 +1,665 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import TooltipInfo from "../TooltipInfo";
import { ConfigSlider } from "./ConfigSlider";
import { NodeRow, NodeRowEditRequest } from "./NodeRow";
import { SelfNodeRow } from "./SelfNodeRow";
import { erc20Abi, formatEther, parseEther } from "viem";
import { useAccount, usePublicClient, useReadContract, useWriteContract } from "wagmi";
import { Cog6ToothIcon } from "@heroicons/react/24/outline";
import {
useDeployedContractInfo,
useScaffoldEventHistory,
useScaffoldReadContract,
useScaffoldWriteContract,
} from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
const LoadingRow = ({ colCount = 5 }: { colCount?: number }) => (
<tr>
<td colSpan={colCount} className="animate-pulse">
<div className="h-8 bg-secondary rounded w-full" />
</td>
</tr>
);
const NoNodesRow = ({ colSpan = 5 }: { colSpan?: number }) => (
<tr>
<td colSpan={colSpan} className="text-center">
No nodes found
</td>
</tr>
);
const SlashAllButton = ({ selectedBucket }: { selectedBucket: bigint }) => {
const publicClient = usePublicClient();
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" });
const { data: outliers } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getOutlierNodes",
args: [selectedBucket] as any,
watch: true,
}) as { data: string[] | undefined };
const { data: nodeAddresses } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getNodeAddresses",
watch: true,
}) as { data: string[] | undefined };
const [unslashed, setUnslashed] = React.useState<string[]>([]);
const { data: priceEvents } = useScaffoldEventHistory({
contractName: "StakingOracle",
eventName: "PriceReported",
watch: true,
});
const bucketReports = React.useMemo(() => {
if (!priceEvents) return [];
const filtered = priceEvents.filter(ev => {
const bucket = ev?.args?.bucketNumber as bigint | undefined;
return bucket !== undefined && bucket === selectedBucket;
});
// IMPORTANT: `slashNode` expects `reportIndex` to match the on-chain `timeBuckets[bucket].reporters[]` index,
// which follows the order reports were submitted (tx order). Event history may be returned newest-first,
// so we sort by (blockNumber, logIndex) ascending to match insertion order.
return [...filtered].sort((a: any, b: any) => {
const aBlock = BigInt(a?.blockNumber ?? 0);
const bBlock = BigInt(b?.blockNumber ?? 0);
if (aBlock !== bBlock) return aBlock < bBlock ? -1 : 1;
const aLog = Number(a?.logIndex ?? 0);
const bLog = Number(b?.logIndex ?? 0);
return aLog - bLog;
});
}, [priceEvents, selectedBucket]);
React.useEffect(() => {
const check = async () => {
if (!outliers || !publicClient || !stakingDeployment) {
setUnslashed([]);
return;
}
const list: string[] = [];
for (const addr of outliers) {
try {
const [, isSlashed] = (await publicClient.readContract({
address: stakingDeployment.address as `0x${string}`,
abi: stakingDeployment.abi as any,
functionName: "getSlashedStatus",
args: [addr, selectedBucket],
})) as [bigint, boolean];
if (!isSlashed) list.push(addr);
} catch {
// assume not slashed on read error
list.push(addr);
}
}
setUnslashed(list);
};
check();
const id = setInterval(check, 2000);
return () => clearInterval(id);
}, [outliers, selectedBucket, publicClient, stakingDeployment]);
const handleSlashAll = async () => {
if (!unslashed.length || !nodeAddresses) return;
try {
for (const addr of unslashed) {
const idx = nodeAddresses.findIndex(a => a?.toLowerCase() === addr.toLowerCase());
if (idx === -1) continue;
const reportIndex = bucketReports.findIndex(ev => {
const reporter = (ev?.args?.node as string | undefined) || "";
return reporter.toLowerCase() === addr.toLowerCase();
});
if (reportIndex === -1) {
console.warn(`Report index not found for node ${addr}, skipping slashing.`);
continue;
}
try {
await writeStakingOracle({
functionName: "slashNode",
args: [addr as `0x${string}`, selectedBucket, BigInt(reportIndex), BigInt(idx)],
});
} catch {
// continue slashing the rest
}
}
} catch (e: any) {
console.error(e);
}
};
return (
<button
className="btn btn-error btn-sm mr-2"
onClick={handleSlashAll}
disabled={unslashed.length === 0}
title={unslashed.length ? `Slash ${unslashed.length} outlier node(s)` : "No slashable nodes"}
>
Slash{unslashed.length ? ` (${unslashed.length})` : ""}
</button>
);
};
export const NodesTable = ({
selectedBucket: externalSelectedBucket,
onBucketChange,
}: {
selectedBucket?: bigint | "current";
onBucketChange?: (bucket: bigint | "current") => void;
} = {}) => {
const [editingNode, setEditingNode] = useState<{ address: string; pos: { top: number; left: number } } | null>(null);
const [showInlineSettings, setShowInlineSettings] = useState(false);
const handleEditRequest = (req: NodeRowEditRequest) => {
setEditingNode({ address: req.address, pos: { top: req.buttonRect.bottom + 8, left: req.buttonRect.left } });
};
const handleCloseEditor = () => setEditingNode(null);
const { address: connectedAddress } = useAccount();
const publicClient = usePublicClient();
const { data: currentBucketData } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getCurrentBucketNumber",
}) as { data: bigint | undefined };
const currentBucket = currentBucketData ?? undefined;
const [isRecordingMedian, setIsRecordingMedian] = useState(false);
const [isMedianRecorded, setIsMedianRecorded] = useState<boolean | null>(null);
const [internalSelectedBucket, setInternalSelectedBucket] = useState<bigint | "current">("current");
const selectedBucket = externalSelectedBucket ?? internalSelectedBucket;
const isViewingCurrentBucket = selectedBucket === "current";
const targetBucket = useMemo<bigint | null>(() => {
// When viewing "current", we actually want to record the *last completed* bucket (current - 1),
// since the current bucket is still in progress and cannot be finalized.
if (selectedBucket === "current") {
if (currentBucket === undefined) return null;
if (currentBucket <= 1n) return null;
return currentBucket - 1n;
}
return selectedBucket ?? null;
}, [selectedBucket, currentBucket]);
const setSelectedBucket = (bucket: bigint | "current") => {
setInternalSelectedBucket(bucket);
onBucketChange?.(bucket);
};
const [animateDir, setAnimateDir] = useState<"left" | "right" | null>(null);
const [animateKey, setAnimateKey] = useState(0);
const [entering, setEntering] = useState(true);
const lastCurrentBucketRef = useRef<bigint | null>(null);
const { data: registeredEvents, isLoading: isLoadingRegistered } = useScaffoldEventHistory({
contractName: "StakingOracle",
eventName: "NodeRegistered",
watch: true,
});
const { data: exitedEvents, isLoading: isLoadingExited } = useScaffoldEventHistory({
contractName: "StakingOracle",
eventName: "NodeExited",
watch: true,
});
const eventDerivedNodeAddresses: string[] = (() => {
const set = new Set<string>();
(registeredEvents || []).forEach(ev => {
const addr = (ev?.args?.node as string | undefined)?.toLowerCase();
if (addr) set.add(addr);
});
(exitedEvents || []).forEach(ev => {
const addr = (ev?.args?.node as string | undefined)?.toLowerCase();
if (addr) set.delete(addr);
});
return Array.from(set.values());
})();
const hasEverRegisteredSelf = useMemo(() => {
if (!connectedAddress) return false;
const lower = connectedAddress.toLowerCase();
return (registeredEvents || []).some(ev => {
const addr = (ev?.args?.node as string | undefined)?.toLowerCase();
return addr === lower;
});
}, [registeredEvents, connectedAddress]);
useEffect(() => {
if (currentBucket === undefined) return;
const last = lastCurrentBucketRef.current;
// In inline settings mode, keep the UI stable (no animation on bucket changes)
if (showInlineSettings) {
lastCurrentBucketRef.current = currentBucket;
return;
}
if (last !== null && currentBucket > last) {
if (selectedBucket === "current") {
setAnimateDir("left");
setAnimateKey(k => k + 1);
setEntering(false);
setTimeout(() => setEntering(true), 20);
}
}
lastCurrentBucketRef.current = currentBucket;
}, [currentBucket, selectedBucket, showInlineSettings]);
const changeBucketWithAnimation = (newBucket: bigint | "current", dir: "left" | "right") => {
setAnimateDir(dir);
setAnimateKey(k => k + 1);
setEntering(false);
setSelectedBucket(newBucket);
setTimeout(() => setEntering(true), 20);
};
const triggerSlide = (dir: "left" | "right") => {
setAnimateDir(dir);
setAnimateKey(k => k + 1);
setEntering(false);
setTimeout(() => setEntering(true), 20);
};
const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" });
const { data: nodeAddresses } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getNodeAddresses",
watch: true,
});
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
const { writeContractAsync: writeErc20 } = useWriteContract();
const { data: oracleTokenAddress } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "oracleToken",
});
const { data: oraBalance } = useReadContract({
address: oracleTokenAddress as `0x${string}` | undefined,
abi: erc20Abi,
functionName: "balanceOf",
args: connectedAddress ? [connectedAddress] : undefined,
query: { enabled: !!oracleTokenAddress && !!connectedAddress },
});
const { data: minimumStake } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "MINIMUM_STAKE",
}) as { data: bigint | undefined };
const minimumStakeFormatted = minimumStake !== undefined ? Number(formatEther(minimumStake)).toLocaleString() : "...";
const tooltipText = `This table displays registered oracle nodes that provide price data to the system. Rows are dimmed when the node's effective ORA stake falls below the minimum (${minimumStakeFormatted} ORA). You can edit the skip probability and price variance of an oracle node with the slider.`;
const registerButtonLabel = "Register Node";
const readMedianValue = useCallback(async (): Promise<boolean | null> => {
if (!targetBucket) {
return null;
}
if (targetBucket <= 0n) {
return false;
}
if (!publicClient || !stakingDeployment?.address) {
return null;
}
try {
const median = await publicClient.readContract({
address: stakingDeployment.address as `0x${string}`,
abi: stakingDeployment.abi as any,
functionName: "getPastPrice",
args: [targetBucket],
});
return BigInt(String(median)) > 0n;
} catch {
return false;
}
}, [publicClient, stakingDeployment, targetBucket]);
useEffect(() => {
let cancelled = false;
const run = async () => {
const result = await readMedianValue();
if (!cancelled) {
setIsMedianRecorded(result);
}
};
void run();
const interval = setInterval(() => {
void run();
}, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [readMedianValue]);
const canRecordMedian = Boolean(
targetBucket && targetBucket > 0n && isMedianRecorded === false && !isRecordingMedian,
);
const recordMedianButtonLabel = isRecordingMedian
? "Recording..."
: isViewingCurrentBucket
? "Record last Bucket Median"
: "Record Median";
const handleRecordMedian = async () => {
if (!stakingDeployment?.address || !targetBucket || targetBucket <= 0n) {
return;
}
setIsRecordingMedian(true);
try {
await writeStakingOracle({ functionName: "recordBucketMedian", args: [targetBucket] });
const refreshed = await readMedianValue();
setIsMedianRecorded(refreshed);
} catch (e: any) {
console.error(e);
} finally {
setIsRecordingMedian(false);
}
};
const isSelfRegistered = Boolean(
(nodeAddresses as string[] | undefined)?.some(
addr => addr?.toLowerCase() === (connectedAddress || "").toLowerCase(),
),
);
const handleRegisterSelf = async () => {
if (!connectedAddress) return;
if (!stakingDeployment?.address || !oracleTokenAddress) return;
if (!publicClient) return;
const stakeAmount = minimumStake ?? parseEther("100");
try {
const currentBalance = (oraBalance as bigint | undefined) ?? 0n;
if (currentBalance < stakeAmount) {
notification.error(
`Insufficient ORA to register. Need ${formatEther(stakeAmount)} ORA to stake (you have ${formatEther(
currentBalance,
)}). Use “Buy ORA” first.`,
);
return;
}
// Wait for approval to be mined before registering.
// (writeContractAsync returns the tx hash)
const approveHash = await writeErc20({
address: oracleTokenAddress as `0x${string}`,
abi: erc20Abi,
functionName: "approve",
args: [stakingDeployment.address as `0x${string}`, stakeAmount],
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });
const registerHash = await writeStakingOracle({ functionName: "registerNode", args: [stakeAmount] });
if (registerHash) {
await publicClient.waitForTransactionReceipt({ hash: registerHash as `0x${string}` });
}
} catch (e: any) {
console.error(e);
}
};
const handleClaimRewards = async () => {
if (!connectedAddress) return;
try {
await writeStakingOracle({ functionName: "claimReward" });
} catch (e: any) {
console.error(e);
}
};
const handleExitNode = async () => {
if (!connectedAddress) return;
if (!isSelfRegistered) return;
if (!nodeAddresses) return;
const list = nodeAddresses as string[];
const idx = list.findIndex(addr => addr?.toLowerCase() === connectedAddress.toLowerCase());
if (idx === -1) return;
try {
await writeStakingOracle({ functionName: "exitNode", args: [BigInt(idx)] });
} catch (e: any) {
console.error(e);
}
};
const filteredNodeAddresses = (eventDerivedNodeAddresses || []).filter(
(addr: string) => addr?.toLowerCase() !== (connectedAddress || "").toLowerCase(),
);
return (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">Oracle Nodes</h2>
<span>
<TooltipInfo infoText={tooltipText} />
</span>
<span className="text-xs bg-base-100 px-2 py-1 rounded-full opacity-70">
Min Stake: {minimumStakeFormatted} ORA
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<button
className="btn btn-secondary btn-sm"
onClick={handleRecordMedian}
disabled={!canRecordMedian}
title={
targetBucket && targetBucket > 0n
? isMedianRecorded === true
? isViewingCurrentBucket
? "Last bucket median already recorded"
: "Median already recorded for this bucket"
: isViewingCurrentBucket
? "Record the median for the last completed bucket"
: "Record the median for the selected bucket"
: isViewingCurrentBucket
? "No completed bucket available yet"
: "Median can only be recorded for completed buckets"
}
>
{recordMedianButtonLabel}
</button>
{/* Slash button near navigation (left of left arrow) */}
{selectedBucket !== "current" && <SlashAllButton selectedBucket={selectedBucket as bigint} />}
{/* Previous (<) */}
<button
className="btn btn-ghost btn-sm"
onClick={() => {
if (selectedBucket === "current" && currentBucket && currentBucket > 1n) {
changeBucketWithAnimation(currentBucket - 1n, "right");
} else if (typeof selectedBucket === "bigint" && selectedBucket > 1n) {
changeBucketWithAnimation(selectedBucket - 1n, "right");
}
}}
disabled={selectedBucket === "current" ? !currentBucket || currentBucket <= 1n : selectedBucket <= 1n}
title="Previous bucket"
>
</button>
{/* Current selected bucket label (non-clickable) */}
<span className="px-2 text-sm tabular-nums select-none">
{selectedBucket === "current"
? currentBucket !== undefined
? currentBucket.toString()
: "..."
: (selectedBucket as bigint).toString()}
</span>
{/* Next (>) */}
<button
className="btn btn-ghost btn-sm"
onClick={() => {
if (selectedBucket === "current") return;
if (typeof selectedBucket === "bigint" && currentBucket && selectedBucket < currentBucket - 1n) {
changeBucketWithAnimation(selectedBucket + 1n, "left");
} else if (
typeof selectedBucket === "bigint" &&
currentBucket &&
selectedBucket === currentBucket - 1n
) {
changeBucketWithAnimation("current", "left");
}
}}
disabled={
selectedBucket === "current" ||
currentBucket === undefined ||
(typeof selectedBucket === "bigint" && selectedBucket >= currentBucket)
}
title="Next bucket"
>
</button>
{/* Go to Current button */}
<button
className="btn btn-ghost btn-sm ml-2"
onClick={() => {
const dir: "left" | "right" = showInlineSettings ? "right" : "left";
if (showInlineSettings) setShowInlineSettings(false);
changeBucketWithAnimation("current", dir);
}}
disabled={showInlineSettings ? false : selectedBucket === "current"}
title="Go to current bucket"
>
Go to Current
</button>
{/* Inline settings toggle */}
<button
className={`btn btn-sm ml-1 px-3 ${showInlineSettings ? "btn-primary" : "btn-secondary"}`}
style={{ display: "inline-flex" }}
onClick={() => {
if (!showInlineSettings) {
// Opening settings: slide left
triggerSlide("left");
} else {
// Closing settings: slide right for a natural return
triggerSlide("right");
}
setShowInlineSettings(v => !v);
}}
title={showInlineSettings ? "Hide inline settings" : "Show inline settings"}
>
<Cog6ToothIcon className="w-4 h-4" />
</button>
</div>
{connectedAddress && !isSelfRegistered ? (
<button
className="btn btn-primary btn-sm font-normal"
onClick={handleRegisterSelf}
disabled={!oracleTokenAddress || !stakingDeployment?.address}
>
{registerButtonLabel}
</button>
) : (
<>
<button
className="btn btn-primary btn-sm font-normal"
onClick={handleClaimRewards}
disabled={!isSelfRegistered}
>
Claim Rewards
</button>
<button
className="btn btn-error btn-sm font-normal"
onClick={handleExitNode}
disabled={!isSelfRegistered}
>
Exit Node
</button>
</>
)}
</div>
</div>
<div className="bg-base-100 rounded-lg p-4 relative">
<div className="overflow-x-auto">
<div
key={animateKey}
className={`transform transition-transform duration-300 ${
entering ? "translate-x-0" : animateDir === "left" ? "translate-x-full" : "-translate-x-full"
}`}
>
<table className="table w-full">
<thead>
<tr>
{showInlineSettings ? (
<>
<th>Node Address</th>
<th>Node Settings</th>
</>
) : selectedBucket === "current" ? (
<>
<th>Node Address</th>
<th>Stake</th>
<th>Rewards</th>
<th>Reported Price</th>
<th>
<div className="flex items-center gap-1">
Deviation
<TooltipInfo
className="tooltip-left"
infoText="Percentage difference versus the previous bucket median"
/>
</div>
</th>
</>
) : (
<>
<th>Node Address</th>
<th>Reported Price</th>
<th>
<div className="flex items-center gap-1">
Deviation
<TooltipInfo
className="tooltip-left"
infoText="Percentage difference from the recorded bucket median"
/>
</div>
</th>
</>
)}
</tr>
</thead>
<tbody>
{!showInlineSettings && (
<>
{selectedBucket === "current" ? (
isSelfRegistered || hasEverRegisteredSelf ? (
<SelfNodeRow isStale={false} bucketNumber={null} />
) : null
) : isSelfRegistered || hasEverRegisteredSelf ? (
<SelfNodeRow isStale={false} bucketNumber={selectedBucket as bigint} />
) : null}
{isSelfRegistered && (
<tr>
<td colSpan={9} className="py-2">
<div className="text-center text-xs uppercase tracking-wider">Simulation Script Nodes</div>
</td>
</tr>
)}
</>
)}
{isLoadingRegistered || isLoadingExited ? (
<LoadingRow colCount={showInlineSettings ? 2 : selectedBucket === "current" ? 5 : 4} />
) : filteredNodeAddresses.length === 0 ? (
<NoNodesRow colSpan={showInlineSettings ? 2 : selectedBucket === "current" ? 5 : 4} />
) : (
filteredNodeAddresses.map((address: string, index: number) => (
<NodeRow
key={index}
index={index}
address={address}
bucketNumber={selectedBucket === "current" ? null : (selectedBucket as bigint)}
onEditRequest={
!showInlineSettings && selectedBucket === "current" ? handleEditRequest : undefined
}
showInlineSettings={showInlineSettings}
isEditing={editingNode?.address === address}
/>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
{editingNode && (
<div
style={{ position: "fixed", top: editingNode.pos.top, left: editingNode.pos.left, zIndex: 60, minWidth: 220 }}
className="mt-2 p-3 bg-base-200 rounded shadow-lg border"
>
<div className="flex flex-col gap-2">
<ConfigSlider
nodeAddress={editingNode.address.toLowerCase()}
endpoint="skip-probability"
label="skip rate"
/>
<ConfigSlider nodeAddress={editingNode.address.toLowerCase()} endpoint="price-variance" label="variance" />
<div className="flex justify-end">
<button className="btn btn-sm btn-ghost" onClick={handleCloseEditor}>
Close
</button>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from "react";
import TooltipInfo from "../TooltipInfo";
import { formatEther } from "viem";
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
const getHighlightColor = (oldPrice: bigint | undefined, newPrice: bigint | undefined): string => {
if (oldPrice === undefined || newPrice === undefined) return "";
const change = Math.abs(parseFloat(formatEther(newPrice)) - parseFloat(formatEther(oldPrice)));
if (change < 50) return "bg-success";
if (change < 100) return "bg-warning";
return "bg-error";
};
interface PriceWidgetProps {
contractName: "StakingOracle" | "WhitelistOracle";
}
export const PriceWidget = ({ contractName }: PriceWidgetProps) => {
const [highlight, setHighlight] = useState(false);
const [highlightColor, setHighlightColor] = useState("");
const prevPrice = useRef<bigint | undefined>(undefined);
const prevBucket = useRef<bigint | null>(null);
const [showBucketLoading, setShowBucketLoading] = useState(false);
// Poll getCurrentBucketNumber to detect bucket changes
const { data: contractBucketNum } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getCurrentBucketNumber",
watch: true,
}) as { data: bigint | undefined };
useEffect(() => {
if (contractBucketNum !== undefined) {
// Check if bucket changed
if (prevBucket.current !== null && contractBucketNum !== prevBucket.current) {
setShowBucketLoading(true);
setTimeout(() => setShowBucketLoading(false), 2000); // Show loading for 2 seconds after bucket change
}
prevBucket.current = contractBucketNum;
}
}, [contractBucketNum]);
const isStaking = contractName === "StakingOracle";
// For WhitelistOracle, check if there are any active oracles (reported within staleness window)
const { data: activeOracles } = useScaffoldReadContract({
contractName: "WhitelistOracle",
functionName: "getActiveOracleNodes",
watch: true,
}) as { data: readonly `0x${string}`[] | undefined };
const { data: currentPrice, isError } = useScaffoldReadContract({
contractName,
functionName: isStaking ? ("getLatestPrice" as any) : ("getPrice" as any),
watch: true,
}) as { data: bigint | undefined; isError: boolean; isLoading: boolean };
// For WhitelistOracle: no active oracles means no fresh price
// For StakingOracle: rely on error state
const noActiveOracles = !isStaking && activeOracles !== undefined && activeOracles.length === 0;
const hasValidPrice = !isError && !noActiveOracles && currentPrice !== undefined && currentPrice !== 0n;
useEffect(() => {
if (currentPrice !== undefined && prevPrice.current !== undefined && currentPrice !== prevPrice.current) {
setHighlightColor(getHighlightColor(prevPrice.current, currentPrice));
setHighlight(true);
setTimeout(() => {
setHighlight(false);
setHighlightColor("");
}, 650);
}
prevPrice.current = currentPrice;
}, [currentPrice]);
return (
<div className="flex flex-col gap-2 h-full">
<h2 className="text-xl font-bold">Current Price</h2>
<div className="bg-base-100 rounded-lg p-4 w-full flex justify-center items-center relative h-full min-h-[140px]">
<TooltipInfo
top={0}
right={0}
className="tooltip-left"
infoText="Displays the median price. If no oracle nodes have reported prices in the last 24 seconds, it will display 'No fresh price'. Color highlighting indicates how big of a change there was in the price."
/>
<div className={`rounded-lg transition-colors duration-1000 ${highlight ? highlightColor : ""}`}>
<div className="font-bold h-10 text-4xl flex items-center justify-center gap-4">
{showBucketLoading ? (
<div className="animate-pulse">
<div className="h-10 bg-secondary rounded-md w-32"></div>
</div>
) : hasValidPrice ? (
<span>{`$${parseFloat(formatEther(currentPrice)).toFixed(2)}`}</span>
) : (
<div className="text-error text-xl">No fresh price</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,214 @@
"use client";
import { useMemo, useState } from "react";
import { erc20Abi, formatEther, parseEther } from "viem";
import { useAccount, usePublicClient, useReadContract, useWriteContract } from "wagmi";
import TooltipInfo from "~~/components/TooltipInfo";
import { useDeployedContractInfo, useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
export const SelfNodeReporter = () => {
const { address: connectedAddress } = useAccount();
const publicClient = usePublicClient();
const [stakeAmount, setStakeAmount] = useState<string>("1000");
const [newPrice, setNewPrice] = useState<string>("");
// Helper to get node index for connected address
const { data: nodeAddresses } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getNodeAddresses",
watch: true,
});
const { data: oracleTokenAddress } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "oracleToken",
});
const { data: oraBalance } = useReadContract({
address: oracleTokenAddress as `0x${string}` | undefined,
abi: erc20Abi,
functionName: "balanceOf",
args: connectedAddress ? [connectedAddress] : undefined,
query: { enabled: !!oracleTokenAddress && !!connectedAddress, refetchInterval: 5000 },
});
// Add exit node handler
const handleExitNode = async () => {
if (!isRegistered) {
return;
}
if (!nodeAddresses || !connectedAddress) {
return;
}
// Find index of connected address in nodeAddresses
const index = nodeAddresses.findIndex((addr: string) => addr.toLowerCase() === connectedAddress.toLowerCase());
if (index === -1) {
return;
}
try {
await writeStaking({ functionName: "exitNode", args: [BigInt(index)] });
} catch (e: any) {
console.error(e);
}
};
const { data: nodeData } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "nodes",
args: [connectedAddress ?? "0x0000000000000000000000000000000000000000"] as any,
watch: true,
});
// firstBucket is at index 4 of OracleNode struct
const firstBucket = (nodeData?.[4] as bigint | undefined) ?? undefined;
const lastReportedBucket = (nodeData?.[1] as bigint | undefined) ?? undefined;
const stakedAmountRaw = (nodeData?.[0] as bigint | undefined) ?? undefined;
const { writeContractAsync: writeStaking } = useScaffoldWriteContract({ contractName: "StakingOracle" });
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
const stakingAddress = stakingDeployment?.address as `0x${string}` | undefined;
const { writeContractAsync: writeErc20 } = useWriteContract();
const isRegistered = useMemo(() => {
return Boolean(firstBucket && firstBucket > 0n);
}, [firstBucket]);
// Fetch last reported price using helper view: getSlashedStatus(address, bucket)
const { data: addressDataAtBucket } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getSlashedStatus",
args: [connectedAddress ?? "0x0000000000000000000000000000000000000000", lastReportedBucket ?? 0n] as any,
watch: true,
});
const lastReportedPrice = (addressDataAtBucket?.[0] as bigint | undefined) ?? undefined;
const stakedOraFormatted =
stakedAmountRaw !== undefined
? Number(formatEther(stakedAmountRaw)).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "—";
const lastReportedPriceFormatted =
lastReportedPrice !== undefined
? Number(formatEther(lastReportedPrice)).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "—";
const oraBalanceFormatted =
oraBalance !== undefined
? Number(formatEther(oraBalance as bigint)).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "—";
const handleStake = async () => {
if (!connectedAddress) {
notification.error("Connect a wallet to register a node");
return;
}
if (!publicClient) {
notification.error("RPC client not ready yet. Please try again in a moment.");
return;
}
if (!stakingAddress || !oracleTokenAddress) {
notification.error("Staking contracts not yet loaded");
return;
}
const numericAmount = Number(stakeAmount);
if (isNaN(numericAmount) || numericAmount <= 0) {
notification.error("Enter a valid ORA stake amount");
return;
}
const stakeAmountWei = parseEther(stakeAmount);
try {
const approveHash = await writeErc20({
address: oracleTokenAddress as `0x${string}`,
abi: erc20Abi,
functionName: "approve",
args: [stakingAddress, stakeAmountWei],
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });
const registerHash = await writeStaking({
functionName: "registerNode",
args: [stakeAmountWei],
});
if (registerHash) {
await publicClient.waitForTransactionReceipt({ hash: registerHash as `0x${string}` });
}
} catch (e: any) {
console.error(e);
}
};
const handleReport = async () => {
const price = Number(newPrice);
if (isNaN(price)) {
notification.error("Enter a valid price");
return;
}
try {
await writeStaking({ functionName: "reportPrice", args: [parseEther(price.toString())] });
setNewPrice("");
} catch (e: any) {
console.error(e);
}
};
return (
<div className="bg-base-100 rounded-lg p-4 relative">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">My Node</h2>
<TooltipInfo infoText="Manage your own node with the connected wallet: stake to register, then report prices." />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<div className="text-sm text-gray-500">Node Address</div>
<div className="font-mono break-all">{connectedAddress ?? "Not connected"}</div>
<div className="text-sm text-gray-500">Staked ORA</div>
<div className="font-semibold">{stakedOraFormatted}</div>
<div className="text-sm text-gray-500">Last Reported Price (USD)</div>
<div className="font-semibold">{lastReportedPriceFormatted}</div>
<div className="text-sm text-gray-500">ORA Balance</div>
<div className="font-semibold">{oraBalanceFormatted}</div>
{/* Claim rewards and Exit Node buttons (shown if registered) */}
{isRegistered && (
<div className="flex gap-2 mt-2">
<button className="btn btn-secondary btn-sm" onClick={handleExitNode} disabled={!connectedAddress}>
Exit Node
</button>
{/* Placeholder for Claim Rewards button if/when implemented */}
</div>
)}
</div>
<div className="flex flex-col gap-3">
{!isRegistered ? (
<div className="flex items-end gap-2">
<div>
<div className="text-sm text-gray-500">Stake Amount (ORA)</div>
<input
className="input input-bordered input-sm w-40"
type="text"
value={stakeAmount}
onChange={e => setStakeAmount(e.target.value)}
/>
</div>
<button className="btn btn-primary btn-sm" onClick={handleStake} disabled={!connectedAddress}>
Stake & Register
</button>
</div>
) : (
<div className="flex items-end gap-2">
<div>
<div className="text-sm text-gray-500">Report Price (USD)</div>
<input
className="input input-bordered input-sm w-40"
type="text"
value={newPrice}
onChange={e => setNewPrice(e.target.value)}
/>
</div>
<button className="btn btn-primary btn-sm" onClick={handleReport} disabled={!connectedAddress}>
Report
</button>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,279 @@
import { useEffect, useMemo, useRef } from "react";
import { Address } from "@scaffold-ui/components";
import { erc20Abi, formatEther, maxUint256, parseEther } from "viem";
import { useAccount, usePublicClient, useReadContract, useWriteContract } from "wagmi";
import { PlusIcon } from "@heroicons/react/24/outline";
import { HighlightedCell } from "~~/components/oracle/HighlightedCell";
import { StakingEditableCell } from "~~/components/oracle/StakingEditableCell";
import { useDeployedContractInfo, useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { getHighlightColorForPrice } from "~~/utils/helpers";
type SelfNodeRowProps = {
isStale: boolean;
bucketNumber?: bigint | null;
};
export const SelfNodeRow = ({ isStale, bucketNumber }: SelfNodeRowProps) => {
const { address: connectedAddress } = useAccount();
const publicClient = usePublicClient();
const { data: nodeData } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "nodes",
args: [connectedAddress as any],
watch: true,
});
// OracleNode struct layout: [0]=stakedAmount, [1]=lastReportedBucket, [2]=reportCount, [3]=claimedReportCount, [4]=firstBucket
const stakedAmount = nodeData?.[0] as bigint | undefined;
const claimedReportCount = nodeData?.[3] as bigint | undefined;
const { data: currentBucket } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getCurrentBucketNumber",
}) as { data: bigint | undefined };
const previousBucket = currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n;
const shouldFetchPreviousMedian = currentBucket !== undefined && previousBucket > 0n;
const { data: previousMedian } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getPastPrice",
args: [previousBucket] as any,
query: { enabled: shouldFetchPreviousMedian },
}) as { data: bigint | undefined };
const { data: oracleTokenAddress } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "oracleToken",
});
// Registered addresses array; authoritative for current membership
const { data: allNodeAddresses } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getNodeAddresses",
watch: true,
}) as { data: string[] | undefined };
const { data: rewardPerReport } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "REWARD_PER_REPORT",
}) as { data: bigint | undefined };
const { data: oraBalance } = useReadContract({
address: oracleTokenAddress as `0x${string}` | undefined,
abi: erc20Abi,
functionName: "balanceOf",
args: connectedAddress ? [connectedAddress] : undefined,
query: { enabled: !!oracleTokenAddress && !!connectedAddress, refetchInterval: 5000 },
}) as { data: bigint | undefined };
const oraBalanceFormatted = useMemo(() => {
if (oraBalance === undefined) return "—";
return Number(formatEther(oraBalance)).toLocaleString(undefined, { maximumFractionDigits: 2 });
}, [oraBalance]);
const { writeContractAsync: writeStaking } = useScaffoldWriteContract({ contractName: "StakingOracle" });
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
const { writeContractAsync: writeErc20 } = useWriteContract();
const stakingAddress = stakingDeployment?.address as `0x${string}` | undefined;
const isRegistered = useMemo(() => {
if (!connectedAddress) return false;
if (!allNodeAddresses) return false;
return allNodeAddresses.some(a => a?.toLowerCase() === connectedAddress.toLowerCase());
}, [allNodeAddresses, connectedAddress]);
// Use wagmi's useReadContract for enabled gating to avoid reverts when not registered
const { data: effectiveStake } = useReadContract({
address: (stakingDeployment?.address as `0x${string}`) || undefined,
abi: (stakingDeployment?.abi as any) || undefined,
functionName: "getEffectiveStake",
args: connectedAddress ? [connectedAddress] : undefined,
query: { enabled: !!stakingDeployment?.address && !!connectedAddress && isRegistered, refetchInterval: 5000 },
}) as { data: bigint | undefined };
const stakedAmountFormatted =
effectiveStake !== undefined
? Number(formatEther(effectiveStake)).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "Loading...";
// Current bucket reported price from contract (align with NodeRow)
const { data: currentBucketPrice } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getSlashedStatus",
args: [connectedAddress || "0x0000000000000000000000000000000000000000", currentBucket ?? 0n] as const,
watch: true,
}) as { data?: [bigint, boolean] };
const reportedPriceInCurrentBucket = currentBucketPrice?.[0];
const hasReportedThisBucket = reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n;
const lastReportedPriceFormatted =
reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n
? `$${Number(parseFloat(formatEther(reportedPriceInCurrentBucket)).toFixed(2))}`
: "Not reported";
const claimedRewardsFormatted = useMemo(() => {
const rpr = rewardPerReport ?? parseEther("1");
const claimed = (claimedReportCount ?? 0n) * rpr;
const wholeOra = claimed / 10n ** 18n;
return new Intl.NumberFormat("en-US").format(wholeOra);
}, [claimedReportCount, rewardPerReport]);
// Track previous staked amount to determine up/down changes for highlight
const prevStakedAmountRef = useRef<bigint | undefined>(undefined);
const prevStakedAmount = prevStakedAmountRef.current;
let stakeHighlightColor = "";
if (prevStakedAmount !== undefined && stakedAmount !== undefined && stakedAmount !== prevStakedAmount) {
stakeHighlightColor = stakedAmount > prevStakedAmount ? "bg-success" : "bg-error";
}
useEffect(() => {
prevStakedAmountRef.current = stakedAmount;
}, [stakedAmount]);
// Deviation for current bucket vs previous bucket average
const currentDeviationText = useMemo(() => {
if (!reportedPriceInCurrentBucket || reportedPriceInCurrentBucket === 0n) return "—";
if (!previousMedian || previousMedian === 0n) return "—";
const avg = Number(formatEther(previousMedian));
const price = Number(formatEther(reportedPriceInCurrentBucket));
if (!Number.isFinite(avg) || avg === 0) return "—";
const pct = ((price - avg) / avg) * 100;
const sign = pct > 0 ? "+" : "";
return `${sign}${pct.toFixed(2)}%`;
}, [reportedPriceInCurrentBucket, previousMedian]);
const isCurrentView = bucketNumber === null || bucketNumber === undefined;
// For past buckets, fetch the reported price at that bucket
const { data: selectedBucketMedian } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getPastPrice",
args: [bucketNumber ?? 0n] as any,
query: {
enabled: !isCurrentView && bucketNumber !== null && bucketNumber !== undefined && (bucketNumber as bigint) > 0n,
},
}) as { data: bigint | undefined };
const { data: pastBucketPrice } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getSlashedStatus",
args: [
connectedAddress || "0x0000000000000000000000000000000000000000",
!isCurrentView && bucketNumber ? bucketNumber : 0n,
] as const,
watch: true,
}) as { data?: [bigint, boolean] };
const pastReportedPrice = !isCurrentView && pastBucketPrice ? pastBucketPrice[0] : undefined;
const pastSlashed = !isCurrentView && pastBucketPrice ? pastBucketPrice[1] : undefined;
// Calculate deviation for past bucket
const pastDeviationText = useMemo(() => {
if (isCurrentView) return "—";
if (!pastReportedPrice || pastReportedPrice === 0n || !bucketNumber) return "—";
if (!selectedBucketMedian || selectedBucketMedian === 0n) return "—";
const avg = Number(formatEther(selectedBucketMedian));
const price = Number(formatEther(pastReportedPrice));
if (!Number.isFinite(avg) || avg === 0) return "—";
const pct = ((price - avg) / avg) * 100;
const sign = pct > 0 ? "+" : "";
return `${sign}${pct.toFixed(2)}%`;
}, [isCurrentView, pastReportedPrice, selectedBucketMedian, bucketNumber]);
const handleAddStake = async () => {
if (!connectedAddress || !oracleTokenAddress || !stakingAddress || !publicClient) return;
const additionalStake = parseEther("100");
try {
// Approve max so user doesn't need to re-approve each time
const approveHash = await writeErc20({
address: oracleTokenAddress as `0x${string}`,
abi: erc20Abi,
functionName: "approve",
args: [stakingAddress, maxUint256],
});
// Wait for approval to be mined before calling addStake
await publicClient.waitForTransactionReceipt({ hash: approveHash });
await writeStaking({ functionName: "addStake", args: [additionalStake] });
} catch (e: any) {
console.error(e);
}
};
return (
<tr className={isStale ? "opacity-40" : ""}>
<td>
<div className="flex flex-col gap-0.5">
{connectedAddress ? <Address address={connectedAddress} size="sm" format="short" onlyEnsOrAddress /> : "—"}
<span className="text-xs opacity-70" title="Your ORA wallet balance">
{oraBalanceFormatted} ORA
</span>
</div>
</td>
{isCurrentView ? (
isRegistered ? (
<>
<HighlightedCell value={stakedAmountFormatted} highlightColor={stakeHighlightColor}>
<div className="flex items-center gap-2 h-full items-stretch">
<span>{stakedAmountFormatted}</span>
<button
className="px-2 text-sm bg-primary rounded cursor-pointer"
onClick={handleAddStake}
title="Add 1000 ORA"
>
<PlusIcon className="w-2.5 h-2.5" />
</button>
</div>
</HighlightedCell>
<HighlightedCell value={claimedRewardsFormatted} highlightColor="bg-success">
{claimedRewardsFormatted}
</HighlightedCell>
<StakingEditableCell
value={lastReportedPriceFormatted}
nodeAddress={connectedAddress || "0x0000000000000000000000000000000000000000"}
highlightColor={getHighlightColorForPrice(reportedPriceInCurrentBucket, previousMedian)}
className={""}
canEdit={isRegistered}
disabled={hasReportedThisBucket}
/>
<td>{currentDeviationText}</td>
</>
) : (
<>
<HighlightedCell value={"—"} highlightColor="">
</HighlightedCell>
<HighlightedCell value={claimedRewardsFormatted} highlightColor="bg-success">
{claimedRewardsFormatted}
</HighlightedCell>
<StakingEditableCell
value={"Must re-register"}
nodeAddress={connectedAddress || "0x0000000000000000000000000000000000000000"}
highlightColor={""}
className={""}
canEdit={false}
/>
<td></td>
</>
)
) : (
<>
<HighlightedCell
value={
pastReportedPrice !== undefined && pastReportedPrice !== 0n
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
: "Not reported"
}
highlightColor={
pastSlashed ? "bg-error" : getHighlightColorForPrice(pastReportedPrice, selectedBucketMedian)
}
className={pastSlashed ? "border-2 border-error" : ""}
>
{pastReportedPrice !== undefined && pastReportedPrice !== 0n
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
: "Not reported"}
{pastSlashed && <span className="ml-2 text-xs text-error">Slashed</span>}
</HighlightedCell>
<td>{pastDeviationText}</td>
</>
)}
</tr>
);
};

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { HighlightedCell } from "./HighlightedCell";
import { formatEther, parseEther } from "viem";
import { ArrowPathIcon, PencilIcon } from "@heroicons/react/24/outline";
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
type StakingEditableCellProps = {
value: string | number;
nodeAddress: string;
highlightColor?: string;
className?: string;
canEdit?: boolean;
disabled?: boolean;
};
export const StakingEditableCell = ({
value,
nodeAddress,
highlightColor = "",
className = "",
canEdit = true,
disabled = false,
}: StakingEditableCellProps) => {
const [isEditing, setIsEditing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const coerceToNumber = (val: string | number) => {
if (typeof val === "number") return val;
const numeric = Number(String(val).replace(/[^0-9.\-]/g, ""));
return Number.isFinite(numeric) ? numeric : NaN;
};
const [editValue, setEditValue] = useState<number | string>(coerceToNumber(value) || "");
const inputRef = useRef<HTMLInputElement>(null);
const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" });
// Read current bucket and previous bucket average for refresh
const { data: currentBucket } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getCurrentBucketNumber",
}) as { data: bigint | undefined };
const previousBucket = useMemo(
() => (currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n),
[currentBucket],
);
const { data: prevBucketAverage } = useScaffoldReadContract({
contractName: "StakingOracle",
functionName: "getPastPrice",
args: [previousBucket] as any,
}) as { data: bigint | undefined };
const hasPrevAvg = typeof prevBucketAverage === "bigint" && prevBucketAverage > 0n;
useEffect(() => {
if (!isEditing) {
setEditValue(coerceToNumber(value) || "");
}
}, [value, isEditing]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleSubmit = async () => {
const parsedValue = Number(editValue);
if (isNaN(parsedValue)) {
notification.error("Invalid number");
return;
}
try {
await writeStakingOracle({
functionName: "reportPrice",
args: [parseEther(parsedValue.toString())],
account: nodeAddress as `0x${string}`,
});
setIsEditing(false);
} catch (error: any) {
console.error(error?.shortMessage || "Failed to update price");
}
};
// Resubmits the average price from the previous bucket
const handleRefresh = async () => {
if (!prevBucketAverage || prevBucketAverage === 0n) {
notification.error("No previous bucket average available");
return;
}
const avgPrice = Number(formatEther(prevBucketAverage));
try {
await writeStakingOracle({
functionName: "reportPrice",
args: [parseEther(avgPrice.toString())],
account: nodeAddress as `0x${string}`,
});
} catch (error: any) {
console.error(error);
}
};
const handleCancel = () => setIsEditing(false);
const startEditing = () => {
if (!canEdit || disabled) return;
setIsEditing(true);
};
return (
<HighlightedCell
value={value}
highlightColor={highlightColor}
className={`min-w-[14rem] w-[16rem] whitespace-nowrap overflow-visible ${className}`}
>
<div className="flex w-full items-start">
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="relative px-1">
<input
ref={inputRef}
type="text"
value={editValue}
onChange={e => setEditValue(e.target.value)}
className="w-full text-sm bg-secondary rounded-md"
/>
</div>
) : (
<div className="flex items-center gap-2 h-full items-stretch">
<span className="truncate">{value}</span>
{canEdit && (
<div className="flex items-stretch gap-1">
<button
className="px-2 text-sm bg-primary rounded disabled:opacity-50 cursor-pointer"
onClick={startEditing}
disabled={!canEdit || disabled}
title="Edit price"
>
<PencilIcon className="w-2.5 h-2.5" />
</button>
<button
className="px-2 text-sm bg-secondary rounded disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
onClick={() => {
if (isRefreshing || !hasPrevAvg || disabled) return;
setIsRefreshing(true);
try {
void handleRefresh();
} catch {}
setTimeout(() => setIsRefreshing(false), 3000);
}}
disabled={!canEdit || disabled || isRefreshing || !hasPrevAvg}
title={hasPrevAvg ? "Report previous bucket average" : "No past price available"}
>
<ArrowPathIcon className={`w-2.5 h-2.5 ${isRefreshing ? "animate-spin" : ""}`} />
</button>
</div>
)}
</div>
)}
</div>
<div className="shrink-0 items-stretch justify-start pl-2">
{isEditing && (
<div className="flex items-stretch gap-1 w-full h-full">
<button onClick={handleSubmit} className="px-2 text-sm bg-primary rounded cursor-pointer">
</button>
<button onClick={handleCancel} className="px-2 text-sm bg-secondary rounded cursor-pointer">
</button>
</div>
)}
</div>
</div>
</HighlightedCell>
);
};

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { useChallengeState } from "~~/services/store/challengeStore";
type TimeAgoProps = {
timestamp?: bigint;
staleWindow?: bigint;
className?: string;
};
const formatTimeAgo = (tsSec: number | undefined, nowSec: number): string => {
if (tsSec === undefined) return "—";
if (tsSec === 0) return "never";
// Clamp to avoid negative display in rare race conditions
const diffSec = Math.max(0, nowSec - tsSec);
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
return `${diffHr}h ago`;
};
export const TimeAgo = ({ timestamp, staleWindow, className = "" }: TimeAgoProps) => {
const { timestamp: networkTimestamp } = useChallengeState();
const [currentTime, setCurrentTime] = useState<number>(() =>
networkTimestamp ? Number(networkTimestamp) : Math.floor(Date.now() / 1000),
);
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const tsSec = typeof timestamp === "bigint" ? Number(timestamp) : timestamp;
const displayNow = currentTime;
const text = formatTimeAgo(tsSec, displayNow);
// Determine staleness coloring
let colorClass = "";
if (tsSec === undefined) {
colorClass = "";
} else if (tsSec === 0) {
colorClass = "text-error";
} else if (typeof staleWindow === "bigint") {
const isStale = tsSec === undefined ? false : displayNow - tsSec > Number(staleWindow);
colorClass = isStale ? "text-error" : "text-success";
}
return <span className={`whitespace-nowrap ${colorClass} ${className}`}>{text}</span>;
};
export default TimeAgo;

View File

@@ -0,0 +1,43 @@
import { useMemo } from "react";
import TooltipInfo from "~~/components/TooltipInfo";
import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth";
export const TotalSlashedWidget = () => {
const { data: slashedEvents, isLoading } = useScaffoldEventHistory({
contractName: "StakingOracle",
eventName: "NodeSlashed",
watch: true,
});
const totalSlashedWei = useMemo(() => {
if (!slashedEvents) return 0n;
return slashedEvents.reduce((acc: bigint, current) => {
const amount = (current?.args?.amount as bigint | undefined) ?? 0n;
return acc + amount;
}, 0n);
}, [slashedEvents]);
const totalSlashedOraFormatted = useMemo(() => {
// ORA uses 18 decimals (same as ETH), but we intentionally display whole tokens only.
const wholeOra = totalSlashedWei / 10n ** 18n;
return new Intl.NumberFormat("en-US").format(wholeOra);
}, [totalSlashedWei]);
const tooltipText = "Aggregated ORA slashed across all nodes. Sums the amount from every NodeSlashed event.";
return (
<div className="flex flex-col gap-2 h-full">
<h2 className="text-xl font-bold">Total Slashed</h2>
<div className="bg-base-100 rounded-lg p-4 relative w-full h-full min-h-[140px]">
<TooltipInfo top={0} right={0} infoText={tooltipText} className="tooltip-left" />
<div className="flex flex-col gap-1 h-full items-center justify-center">
{isLoading ? (
<div className="animate-pulse h-10 bg-secondary rounded-md w-32" />
) : (
<div className="font-bold text-4xl">{totalSlashedOraFormatted} ORA</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import { TimeLeft } from "./TimeLeft";
import { formatEther } from "viem";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
import { useChallengeState } from "~~/services/store/challengeStore";
export const AssertedRow = ({ assertionId, state }: { assertionId: number; state: number }) => {
const { openAssertionModal } = useChallengeState();
const { data: assertionData } = useScaffoldReadContract({
contractName: "OptimisticOracle",
functionName: "getAssertion",
args: [BigInt(assertionId)],
});
if (!assertionData) return null;
return (
<tr
key={assertionId}
onClick={() => {
openAssertionModal({ ...assertionData, assertionId, state });
}}
className={`group border-b border-base-300 cursor-pointer`}
>
{/* Description Column */}
<td>
<div className="group-hover:text-error">{assertionData.description}</div>
</td>
{/* Bond Column */}
<td>{formatEther(assertionData.bond)} ETH</td>
{/* Reward Column */}
<td>{formatEther(assertionData.reward)} ETH</td>
{/* Time Left Column */}
<td>
<TimeLeft startTime={assertionData.startTime} endTime={assertionData.endTime} />
</td>
{/* Chevron Column */}
<td>
<div className="w-6 h-6 rounded-full border-error border flex items-center justify-center hover:bg-base-200 group-hover:bg-error transition-colors mx-auto">
<ChevronRightIcon className="w-4 h-4 text-error group-hover:text-white stroke-2 transition-colors" />
</div>
</td>
</tr>
);
};

View File

@@ -0,0 +1,34 @@
"use client";
import { OOTableProps } from "../types";
import { AssertedRow } from "./AssertedRow";
import { EmptyRow } from "./EmptyRow";
export const AssertedTable = ({ assertions }: OOTableProps) => {
return (
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
{/* Header */}
<thead>
<tr className="bg-base-300">
<th className="text-left font-semibold w-5/12">Description</th>
<th className="text-left font-semibold w-2/12">Bond</th>
<th className="text-left font-semibold w-2/12">Reward</th>
<th className="text-left font-semibold w-2/12">Time Left</th>
<th className="text-left font-semibold w-1/12">{/* Icon column */}</th>
</tr>
</thead>
<tbody>
{assertions.length > 0 ? (
assertions.map(assertion => (
<AssertedRow key={assertion.assertionId} assertionId={assertion.assertionId} state={assertion.state} />
))
) : (
<EmptyRow colspan={5} />
)}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,260 @@
"use client";
import { useState } from "react";
import { AssertionWithIdAndState } from "../types";
import { Address } from "@scaffold-ui/components";
import { formatEther } from "viem";
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { useChallengeState } from "~~/services/store/challengeStore";
import { ZERO_ADDRESS } from "~~/utils/scaffold-eth/common";
const getStateName = (state: number) => {
switch (state) {
case 0:
return "Invalid";
case 1:
return "Asserted";
case 2:
return "Proposed";
case 3:
return "Disputed";
case 4:
return "Settled";
case 5:
return "Expired";
default:
return "Invalid";
}
};
// Helper function to format timestamp to UTC
const formatTimestamp = (timestamp: bigint | string | number) => {
const timestampNumber = Number(timestamp);
const date = new Date(timestampNumber * 1000); // Convert from seconds to milliseconds
return date.toLocaleString();
};
const Description = ({ assertion }: { assertion: AssertionWithIdAndState }) => {
return (
<div className="bg-base-200 p-4 rounded-lg space-y-2 mb-4">
<div>
<span className="font-bold">AssertionId:</span> {assertion.assertionId}
</div>
<div>
<span className="font-bold">Description:</span> {assertion.description}
</div>
<div>
<span className="font-bold">Bond:</span> {formatEther(assertion.bond)} ETH
</div>
<div>
<span className="font-bold">Reward:</span> {formatEther(assertion.reward)} ETH
</div>
<div>
<span className="font-bold">Start Time:</span>
<span className="text-sm"> UTC: {formatTimestamp(assertion.startTime)}</span>
<span className="text-sm"> Timestamp: {assertion.startTime}</span>
</div>
<div>
<span className="font-bold">End Time:</span>
<span className="text-sm"> UTC: {formatTimestamp(assertion.endTime)}</span>
<span className="text-sm"> Timestamp: {assertion.endTime}</span>
</div>
{assertion.proposer !== ZERO_ADDRESS && (
<div>
<span className="font-bold">Proposed Outcome:</span> {assertion.proposedOutcome ? "True" : "False"}
</div>
)}
{assertion.proposer !== ZERO_ADDRESS && (
<div>
<span className="font-bold">Proposer:</span>{" "}
<Address address={assertion.proposer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
</div>
)}
{assertion.disputer !== ZERO_ADDRESS && (
<div>
<span className="font-bold">Disputer:</span>{" "}
<Address address={assertion.disputer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
</div>
)}
</div>
);
};
export const AssertionModal = () => {
const [isActionPending, setIsActionPending] = useState(false);
const { refetchAssertionStates, openAssertion, closeAssertionModal } = useChallengeState();
const isOpen = !!openAssertion;
const { writeContractAsync: writeOOContractAsync } = useScaffoldWriteContract({
contractName: "OptimisticOracle",
});
const { writeContractAsync: writeDeciderContractAsync } = useScaffoldWriteContract({
contractName: "Decider",
});
const handleAction = async (args: any) => {
if (!openAssertion) return;
try {
setIsActionPending(true);
if (args.functionName === "settleDispute") {
await writeDeciderContractAsync(args);
} else {
await writeOOContractAsync(args);
}
refetchAssertionStates();
closeAssertionModal();
} catch (error) {
console.log(error);
} finally {
setIsActionPending(false);
}
};
if (!openAssertion) return null;
return (
<>
<input type="checkbox" id="challenge-modal" className="modal-toggle" checked={isOpen} readOnly />
<label htmlFor="challenge-modal" className="modal cursor-pointer" onClick={closeAssertionModal}>
<label
className="modal-box relative max-w-2xl w-full bg-base-100"
htmlFor=""
onClick={e => e.stopPropagation()}
>
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
{/* Close button */}
<button onClick={closeAssertionModal} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</button>
{/* Modal Content */}
<div className="">
{/* Header with Current State */}
<div className="text-center mb-6">
<h2 className="text-lg">
Current State: <span className="font-bold">{getStateName(openAssertion.state)}</span>
</h2>
</div>
<Description assertion={openAssertion} />
{openAssertion.state === 1 && (
<>
{/* Proposed Outcome Section */}
<div className="rounded-lg p-4">
<div className="flex justify-center mb-4">
<span className="font-medium">Propose Outcome</span>
</div>
{isActionPending && <span className="loading loading-spinner loading-xs"></span>}
<div className="flex justify-center gap-4">
<button
className="btn btn-primary flex-1"
onClick={() =>
handleAction({
functionName: "proposeOutcome",
args: [BigInt(openAssertion.assertionId), true],
value: openAssertion.bond,
})
}
disabled={isActionPending}
>
True
</button>
<button
className="btn btn-primary flex-1"
onClick={() =>
handleAction({
functionName: "proposeOutcome",
args: [BigInt(openAssertion.assertionId), false],
value: openAssertion.bond,
})
}
disabled={isActionPending}
>
False
</button>
</div>
</div>
</>
)}
{openAssertion.state === 2 && (
<div className="rounded-lg p-4">
<div className="flex justify-center mb-4">
<span className="font-medium">Submit Dispute</span>
</div>
{isActionPending && <span className="loading loading-spinner loading-xs"></span>}
<div className="flex justify-center gap-4">
<button
className="btn btn-primary flex-1"
onClick={() =>
handleAction({
functionName: "disputeOutcome",
args: [BigInt(openAssertion.assertionId)],
value: openAssertion.bond,
})
}
disabled={isActionPending}
>
{!openAssertion.proposedOutcome ? "True" : "False"}
</button>
</div>
</div>
)}
{openAssertion.state === 3 && (
<div className="rounded-lg p-4">
<div className="flex flex-col items-center gap-2 mb-4">
<span className="text-2xl font-medium">Impersonate Decider</span>
<span className="font-medium">Resolve Answer to</span>
</div>
{isActionPending && <span className="loading loading-spinner loading-xs"></span>}
<div className="flex justify-center gap-4">
<button
className="btn btn-primary flex-1"
onClick={() =>
handleAction({
functionName: "settleDispute",
args: [BigInt(openAssertion.assertionId), true],
})
}
disabled={isActionPending}
>
True
</button>
<button
className="btn btn-primary flex-1"
onClick={() =>
handleAction({
functionName: "settleDispute",
args: [BigInt(openAssertion.assertionId), false],
})
}
disabled={isActionPending}
>
False
</button>
</div>
</div>
)}
</div>
</label>
</label>
</>
);
};

View File

@@ -0,0 +1,48 @@
import { Address } from "@scaffold-ui/components";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
import { useChallengeState } from "~~/services/store/challengeStore";
export const DisputedRow = ({ assertionId, state }: { assertionId: number; state: number }) => {
const { openAssertionModal } = useChallengeState();
const { data: assertionData } = useScaffoldReadContract({
contractName: "OptimisticOracle",
functionName: "getAssertion",
args: [BigInt(assertionId)],
});
if (!assertionData) return null;
return (
<tr
key={assertionId}
onClick={() => {
openAssertionModal({ ...assertionData, assertionId, state });
}}
className={`group border-b border-base-300 cursor-pointer`}
>
{/* Description Column */}
<td>
<div className="group-hover:text-error">{assertionData.description}</div>
</td>
{/* Proposer Column */}
<td>
<Address address={assertionData.proposer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
</td>
{/* Disputer Column */}
<td>
<Address address={assertionData.disputer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
</td>
{/* Chevron Column */}
<td className="">
<div className="w-6 h-6 rounded-full border-error border flex items-center justify-center hover:bg-base-200 group-hover:bg-error transition-colors mx-auto">
<ChevronRightIcon className="w-4 h-4 text-error group-hover:text-white stroke-2 transition-colors" />
</div>
</td>
</tr>
);
};

View File

@@ -0,0 +1,33 @@
"use client";
import { OOTableProps } from "../types";
import { DisputedRow } from "./DisputedRow";
import { EmptyRow } from "./EmptyRow";
export const DisputedTable = ({ assertions }: OOTableProps) => {
return (
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
{/* Header */}
<thead>
<tr className="bg-base-300">
<th className="text-left font-semibold w-5/12">Description</th>
<th className="text-left font-semibold w-3/12">Proposer</th>
<th className="text-left font-semibold w-3/12">Disputer</th>
<th className="text-left font-semibold w-1/12">{/* Icon column */}</th>
</tr>
</thead>
<tbody>
{assertions.length > 0 ? (
assertions.map(assertion => (
<DisputedRow key={assertion.assertionId} assertionId={assertion.assertionId} state={assertion.state} />
))
) : (
<EmptyRow colspan={4} />
)}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,15 @@
export const EmptyRow = ({
message = "No assertions match this state.",
colspan = 4,
}: {
message?: string;
colspan?: number;
}) => {
return (
<tr>
<td colSpan={colspan} className="text-center">
{message}
</td>
</tr>
);
};

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { Address } from "@scaffold-ui/components";
import { formatEther } from "viem";
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
export const ExpiredRow = ({ assertionId }: { assertionId: number }) => {
const [isClaiming, setIsClaiming] = useState(false);
const { data: assertionData } = useScaffoldReadContract({
contractName: "OptimisticOracle",
functionName: "getAssertion",
args: [BigInt(assertionId)],
});
const { writeContractAsync } = useScaffoldWriteContract({
contractName: "OptimisticOracle",
});
const handleClaim = async () => {
setIsClaiming(true);
try {
await writeContractAsync({
functionName: "claimRefund",
args: [BigInt(assertionId)],
});
} catch (error) {
console.error(error);
} finally {
setIsClaiming(false);
}
};
if (!assertionData) return null;
return (
<tr key={assertionId} className={`border-b border-base-300`}>
{/* Description Column */}
<td>{assertionData.description}</td>
{/* Asserter Column */}
<td>
<Address address={assertionData.asserter} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
</td>
{/* Reward Column */}
<td>{formatEther(assertionData.reward)} ETH</td>
{/* Claimed Column */}
<td>
{assertionData?.claimed ? (
<button className="btn btn-primary btn-xs" disabled>
Claimed
</button>
) : (
<button className="btn btn-primary btn-xs" onClick={handleClaim} disabled={isClaiming}>
Claim
</button>
)}
</td>
</tr>
);
};

View File

@@ -0,0 +1,31 @@
"use client";
import { OOTableProps } from "../types";
import { EmptyRow } from "./EmptyRow";
import { ExpiredRow } from "./ExpiredRow";
export const ExpiredTable = ({ assertions }: OOTableProps) => {
return (
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
{/* Header */}
<thead>
<tr className="bg-base-300">
<th className="text-left font-semibold w-5/12">Description</th>
<th className="text-left font-semibold w-3/12">Asserter</th>
<th className="text-left font-semibold w-2/12">Reward</th>
<th className="text-left font-semibold w-2/12">Claim Refund</th>
</tr>
</thead>
<tbody>
{assertions.length > 0 ? (
assertions.map(assertion => <ExpiredRow key={assertion.assertionId} assertionId={assertion.assertionId} />)
) : (
<EmptyRow colspan={4} />
)}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,21 @@
export const LoadingRow = () => {
return (
<tr className="border-b border-base-300">
<td>
<div className="h-5 bg-base-300 rounded animate-pulse"></div>
</td>
<td>
<div className="h-5 w-24 bg-base-300 rounded animate-pulse"></div>
</td>
<td>
<div className="h-5 w-24 bg-base-300 rounded animate-pulse"></div>
</td>
<td>
<div className="w-6 h-6 rounded-full bg-base-300 animate-pulse mx-auto"></div>
</td>
</tr>
);
};

View File

@@ -0,0 +1,52 @@
"use client";
import { OORowProps } from "../types";
import { TimeLeft } from "./TimeLeft";
import { formatEther } from "viem";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
import { useChallengeState } from "~~/services/store/challengeStore";
export const ProposedRow = ({ assertionId, state }: OORowProps) => {
const { openAssertionModal } = useChallengeState();
const { data: assertionData } = useScaffoldReadContract({
contractName: "OptimisticOracle",
functionName: "getAssertion",
args: [BigInt(assertionId)],
});
if (!assertionData) return null;
return (
<tr
key={assertionId}
className={`group border-b border-base-300 cursor-pointer`}
onClick={() => {
openAssertionModal({ ...assertionData, assertionId, state });
}}
>
{/* Query Column */}
<td>
<div className="group-hover:text-error">{assertionData?.description}</div>
</td>
{/* Bond Column */}
<td>{formatEther(assertionData?.bond)} ETH</td>
{/* Proposal Column */}
<td>{assertionData?.proposedOutcome ? "True" : "False"}</td>
{/* Challenge Period Column */}
<td>
<TimeLeft startTime={assertionData?.startTime} endTime={assertionData?.endTime} />
</td>
{/* Chevron Column */}
<td>
<div className="w-6 h-6 rounded-full border-error border flex items-center justify-center hover:bg-base-200 group-hover:bg-error transition-colors mx-auto">
<ChevronRightIcon className="w-4 h-4 text-error group-hover:text-white stroke-2 transition-colors" />
</div>
</td>
</tr>
);
};

View File

@@ -0,0 +1,32 @@
import { OOTableProps } from "../types";
import { EmptyRow } from "./EmptyRow";
import { ProposedRow } from "./ProposedRow";
export const ProposedTable = ({ assertions }: OOTableProps) => {
return (
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
{/* Header */}
<thead>
<tr className="bg-base-300">
<th className="text-left font-semibold w-5/12">Description</th>
<th className="text-left font-semibold w-2/12">Bond</th>
<th className="text-left font-semibold w-2/12">Proposal</th>
<th className="text-left font-semibold w-2/12">Time Left</th>
<th className="text-left font-semibold w-1/12">{/* Icon column */}</th>
</tr>
</thead>
<tbody>
{assertions.length > 0 ? (
assertions.map(assertion => (
<ProposedRow key={assertion.assertionId} assertionId={assertion.assertionId} state={assertion.state} />
))
) : (
<EmptyRow colspan={5} />
)}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { SettledRowProps } from "../types";
import { LoadingRow } from "./LoadingRow";
import { Address } from "@scaffold-ui/components";
import { formatEther } from "viem";
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { ZERO_ADDRESS } from "~~/utils/scaffold-eth/common";
export const SettledRow = ({ assertionId }: SettledRowProps) => {
const [isClaiming, setIsClaiming] = useState(false);
const { data: assertionData, isLoading } = useScaffoldReadContract({
contractName: "OptimisticOracle",
functionName: "getAssertion",
args: [BigInt(assertionId)],
});
const { writeContractAsync } = useScaffoldWriteContract({
contractName: "OptimisticOracle",
});
if (isLoading) return <LoadingRow />;
if (!assertionData) return null;
const handleClaim = async () => {
try {
setIsClaiming(true);
const functionName = assertionData?.winner === ZERO_ADDRESS ? "claimUndisputedReward" : "claimDisputedReward";
await writeContractAsync({
functionName,
args: [BigInt(assertionId)],
});
} catch (error) {
console.error(error);
} finally {
setIsClaiming(false);
}
};
const winner = assertionData?.winner === ZERO_ADDRESS ? assertionData?.proposer : assertionData?.winner;
const outcome =
assertionData?.winner === ZERO_ADDRESS ? assertionData?.proposedOutcome : assertionData?.resolvedOutcome;
return (
<tr key={assertionId} className={`border-b border-base-300`}>
{/* Query Column */}
<td>{assertionData?.description}</td>
{/* Answer Column */}
<td>{outcome ? "True" : "False"}</td>
{/* Winner Column */}
<td>
<Address address={winner} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
</td>
{/* Reward Column */}
<td>{formatEther(assertionData?.reward)} ETH</td>
{/* Claimed Column */}
<td>
{assertionData?.claimed ? (
<button className="btn btn-primary btn-xs" disabled>
Claimed
</button>
) : (
<button className="btn btn-primary btn-xs" onClick={handleClaim} disabled={isClaiming}>
Claim
</button>
)}
</td>
</tr>
);
};

View File

@@ -0,0 +1,32 @@
"use client";
import { OOTableProps } from "../types";
import { EmptyRow } from "./EmptyRow";
import { SettledRow } from "./SettledRow";
export const SettledTable = ({ assertions }: OOTableProps) => {
return (
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
{/* Header */}
<thead>
<tr className="bg-base-300">
<th className="text-left font-semibold w-4/12">Description</th>
<th className="text-left font-semibold w-1/12">Result</th>
<th className="text-left font-semibold w-3/12">Winner</th>
<th className="text-left font-semibold w-2/12">Reward</th>
<th className="text-left font-semibold w-2/12">Claim</th>
</tr>
</thead>
<tbody>
{assertions.length > 0 ? (
assertions.map(assertion => <SettledRow key={assertion.assertionId} assertionId={assertion.assertionId} />)
) : (
<EmptyRow colspan={5} />
)}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,245 @@
"use client";
import { useState } from "react";
import { IntegerInput } from "@scaffold-ui/debug-contracts";
import { parseEther } from "viem";
import { usePublicClient } from "wagmi";
import TooltipInfo from "~~/components/TooltipInfo";
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { useChallengeState } from "~~/services/store/challengeStore";
import { getRandomQuestion } from "~~/utils/helpers";
import { notification } from "~~/utils/scaffold-eth";
const MINIMUM_ASSERTION_WINDOW = 3;
const getStartTimestamp = (timestamp: bigint, startInMinutes: string) => {
if (startInMinutes.length === 0) return 0n;
if (Number(startInMinutes) === 0) return 0n;
return timestamp + BigInt(startInMinutes) * 60n;
};
const getEndTimestamp = (timestamp: bigint, startTimestamp: bigint, durationInMinutes: string) => {
if (durationInMinutes.length === 0) return 0n;
if (Number(durationInMinutes) === MINIMUM_ASSERTION_WINDOW) return 0n;
if (startTimestamp === 0n) return timestamp + BigInt(durationInMinutes) * 60n;
return startTimestamp + BigInt(durationInMinutes) * 60n;
};
interface SubmitAssertionModalProps {
isOpen: boolean;
onClose: () => void;
}
const SubmitAssertionModal = ({ isOpen, onClose }: SubmitAssertionModalProps) => {
const { timestamp } = useChallengeState();
const [isLoading, setIsLoading] = useState(false);
const publicClient = usePublicClient();
const [description, setDescription] = useState("");
const [reward, setReward] = useState<string>("");
const [startInMinutes, setStartInMinutes] = useState<string>("");
const [durationInMinutes, setDurationInMinutes] = useState<string>("");
const { writeContractAsync } = useScaffoldWriteContract({ contractName: "OptimisticOracle" });
const handleRandomQuestion = () => {
setDescription(getRandomQuestion());
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (durationInMinutes.length > 0 && Number(durationInMinutes) < MINIMUM_ASSERTION_WINDOW) {
notification.error(
`Duration must be at least ${MINIMUM_ASSERTION_WINDOW} minutes or leave blank to use default value`,
);
return;
}
if (Number(reward) === 0) {
notification.error(`Reward must be greater than 0 ETH`);
return;
}
if (!publicClient) {
notification.error("Public client not found");
return;
}
try {
setIsLoading(true);
let recentTimestamp = timestamp;
if (!recentTimestamp) {
const block = await publicClient.getBlock();
recentTimestamp = block.timestamp;
}
const startTimestamp = getStartTimestamp(recentTimestamp, startInMinutes);
const endTimestamp = getEndTimestamp(recentTimestamp, startTimestamp, durationInMinutes);
await writeContractAsync({
functionName: "assertEvent",
args: [description.trim(), startTimestamp, endTimestamp],
value: parseEther(reward),
});
// Reset form after successful submission
setDescription("");
setReward("");
setStartInMinutes("");
setDurationInMinutes("");
onClose();
} catch (error) {
console.log("Error with submission", error);
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onClose();
// Reset form when closing
setDescription("");
setReward("");
setStartInMinutes("");
setDurationInMinutes("");
};
if (!isOpen) return null;
const readyToSubmit = description.trim().length > 0 && reward.trim().length > 0;
return (
<>
<input type="checkbox" id="assertion-modal" className="modal-toggle" checked={isOpen} readOnly />
<label htmlFor="assertion-modal" className="modal cursor-pointer" onClick={handleClose}>
<label className="modal-box relative max-w-md w-full bg-base-100" htmlFor="" onClick={e => e.stopPropagation()}>
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
{/* Close button */}
<button onClick={handleClose} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</button>
<div className="relative">
<TooltipInfo
top={-2}
right={5}
className="tooltip-left"
infoText="Create a new assertion with your reward stake. Leave time inputs blank to use default values."
/>
</div>
{/* Modal Content */}
<div>
{/* Header */}
<div className="text-center mb-6">
<h2 className="text-xl font-bold">Submit New Assertion</h2>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Description Input */}
<div>
<label className="label">
<span className="text-accent font-medium">
Description <span className="text-red-500">*</span>
</span>
</label>
<div className="flex gap-2 items-start">
<div className="flex-1">
<div className="flex border-2 border-base-300 bg-base-200 rounded-full text-accent">
<textarea
name="description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Enter assertion description..."
className="input input-ghost focus-within:border-transparent leading-8 focus:outline-hidden focus:bg-transparent h-auto min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/70 text-base-content/70 focus:text-base-content/70 whitespace-pre-wrap overflow-x-hidden"
rows={2}
/>
</div>
</div>
<button
type="button"
onClick={handleRandomQuestion}
className="btn btn-secondary btn-sm"
title="Select random question"
>
🎲
</button>
</div>
</div>
<div>
<label className="label">
<span className="text-accent font-medium">
Reward (ETH) <span className="text-red-500">*</span>
</span>
</label>
<IntegerInput
name="reward"
placeholder={`0.01`}
value={reward}
onChange={newValue => setReward(newValue)}
disableMultiplyBy1e18
/>
</div>
{/* Start Time and End Time Inputs */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">
<span className="text-accent font-medium">Start in (minutes)</span>
</label>
<IntegerInput
name="startTime"
placeholder="blank = now"
value={startInMinutes}
onChange={newValue => setStartInMinutes(newValue)}
disableMultiplyBy1e18
/>
</div>
<div>
<label className="label">
<span className="text-accent font-medium">Duration (minutes)</span>
</label>
<IntegerInput
name="endTime"
placeholder={`minimum ${MINIMUM_ASSERTION_WINDOW} minutes`}
value={durationInMinutes}
onChange={newValue => setDurationInMinutes(newValue)}
disableMultiplyBy1e18
/>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3 mt-6">
<button type="submit" className="btn btn-primary flex-1" disabled={isLoading || !readyToSubmit}>
{isLoading && <span className="loading loading-spinner loading-xs"></span>}
Submit
</button>
</div>
</form>
</div>
</label>
</label>
</>
);
};
export const SubmitAssertionButton = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);
return (
<>
{/* Button */}
<div className="my-8 flex justify-center">
<button className="btn btn-primary btn-lg" onClick={openModal}>
Submit New Assertion
</button>
</div>
{/* Modal - only mounted when open */}
{isModalOpen && <SubmitAssertionModal isOpen={isModalOpen} onClose={closeModal} />}
</>
);
};

View File

@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { useChallengeState } from "~~/services/store/challengeStore";
function formatDuration(seconds: number, isPending: boolean) {
const totalSeconds = Math.max(seconds, 0);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m} m ${s} s${isPending ? " left to start" : ""}`;
}
export const TimeLeft = ({ startTime, endTime }: { startTime: bigint; endTime: bigint }) => {
const { timestamp, refetchAssertionStates } = useChallengeState();
const [currentTime, setCurrentTime] = useState<number>(() =>
timestamp ? Number(timestamp) : Math.floor(Date.now() / 1000),
);
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const start = Number(startTime);
const end = Number(endTime);
const now = currentTime;
const duration = end - now;
const ended = duration <= 0;
// Guard against division by zero and clamp to [0, 100]
const totalWindow = Math.max(end - start, 1);
const rawPercent = ((now - start) / totalWindow) * 100;
const progressPercent = Math.max(0, Math.min(100, rawPercent));
useEffect(() => {
if (ended && timestamp) {
refetchAssertionStates();
}
}, [ended, refetchAssertionStates, timestamp]);
let displayText: string;
if (ended) {
displayText = "Ended";
} else if (now < start) {
displayText = formatDuration(start - now, true);
} else {
displayText = formatDuration(Math.max(duration, 0), false);
}
return (
<div className="w-full space-y-1">
<div className={ended || duration < 60 ? "text-error" : ""}>{displayText}</div>
<div
className={`w-full h-1 bg-base-300 rounded-full overflow-hidden transition-opacity ${now > start ? "opacity-100" : "opacity-0"}`}
>
<div className="h-full bg-error transition-all" style={{ width: `${progressPercent}%` }} />
</div>
</div>
);
};

View File

@@ -0,0 +1,69 @@
export interface NodeRowProps {
address: string;
index?: number;
isStale?: boolean;
// When provided, the row should render data for this bucket. If omitted, shows current/latest.
bucketNumber?: bigint | null;
}
export interface WhitelistRowProps extends NodeRowProps {
isActive: boolean;
}
export interface NodeInfo {
stakedAmount: bigint | undefined;
lastReportedPrice: bigint | undefined;
oraBalance: bigint | undefined;
}
export interface HighlightState {
staked: boolean;
price: boolean;
oraBalance: boolean;
}
export interface Assertion {
asserter: string;
proposer: string;
disputer: string;
proposedOutcome: boolean;
resolvedOutcome: boolean;
reward: bigint;
bond: bigint;
startTime: bigint;
endTime: bigint;
claimed: boolean;
winner: string;
description: string;
}
export interface AssertionWithId extends Assertion {
assertionId: number;
}
export interface AssertionWithIdAndState extends Assertion {
assertionId: number;
state: number;
}
export interface AssertionModalProps {
assertion: AssertionWithIdAndState;
isOpen: boolean;
onClose: () => void;
}
export interface OOTableProps {
assertions: {
assertionId: number;
state: number;
}[];
}
export interface OORowProps {
assertionId: number;
state: number;
}
export interface SettledRowProps {
assertionId: number;
}

View File

@@ -0,0 +1,40 @@
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { usePublicClient, useWalletClient } from "wagmi";
import { PlusIcon } from "@heroicons/react/24/outline";
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
export const AddOracleButton = () => {
const { data: walletClient } = useWalletClient();
const publicClient = usePublicClient();
const { writeContractAsync: writeWhitelistOracle } = useScaffoldWriteContract({ contractName: "WhitelistOracle" });
const handleAddOracle = async () => {
if (!walletClient || !publicClient) {
notification.error("Please connect wallet and enter both oracle owner address and initial price");
return;
}
try {
// Generate a new oracle address
const privateKey = generatePrivateKey();
const oracleAddress = privateKeyToAccount(privateKey).address;
// Add oracle to whitelist
await writeWhitelistOracle({
functionName: "addOracle",
args: [oracleAddress],
});
} catch (error: any) {
console.log("Error adding oracle:", error);
}
};
return (
<button className="btn btn-primary h-full btn-sm font-normal gap-1" onClick={handleAddOracle}>
<PlusIcon className="h-4 w-4" />
<span>Add Oracle Node</span>
</button>
);
};

View File

@@ -0,0 +1,67 @@
import { useEffect } from "react";
import { EditableCell } from "../EditableCell";
import { Address } from "@scaffold-ui/components";
import { formatEther } from "viem";
import { useBlockNumber, useReadContract } from "wagmi";
import { HighlightedCell } from "~~/components/oracle/HighlightedCell";
import { TimeAgo } from "~~/components/oracle/TimeAgo";
import { WhitelistRowProps } from "~~/components/oracle/types";
import { useScaffoldReadContract, useSelectedNetwork } from "~~/hooks/scaffold-eth";
import { SIMPLE_ORACLE_ABI } from "~~/utils/constants";
import { getHighlightColorForPrice } from "~~/utils/helpers";
export const WhitelistRow = ({ address }: WhitelistRowProps) => {
const selectedNetwork = useSelectedNetwork();
const { data, refetch } = useReadContract({
address: address,
abi: SIMPLE_ORACLE_ABI,
functionName: "getPrice",
query: {
enabled: true,
},
}) as { data: readonly [bigint, bigint] | undefined; refetch: () => void };
const { data: blockNumber } = useBlockNumber({
watch: true,
chainId: selectedNetwork.id,
query: {
enabled: true,
},
});
useEffect(() => {
refetch();
}, [blockNumber, refetch]);
const { data: medianPrice } = useScaffoldReadContract({
contractName: "WhitelistOracle",
functionName: "getPrice",
watch: true,
}) as { data: bigint | undefined };
const { data: staleWindow } = useScaffoldReadContract({
contractName: "WhitelistOracle",
functionName: "STALE_DATA_WINDOW",
}) as { data: bigint | undefined };
const isNotReported = data !== undefined && data[0] === 0n && data[1] === 0n;
const lastReportedPriceFormatted =
data === undefined || isNotReported ? "Not reported" : Number(parseFloat(formatEther(data?.[0] ?? 0n)).toFixed(2));
return (
<tr className={`table-fixed`}>
<td>
<Address address={address} size="sm" format="short" onlyEnsOrAddress={true} />
</td>
<EditableCell
value={lastReportedPriceFormatted}
address={address}
highlightColor={getHighlightColorForPrice(data?.[0], medianPrice)}
/>
<HighlightedCell value={0} highlightColor={""}>
<TimeAgo timestamp={data?.[1]} staleWindow={staleWindow} />
</HighlightedCell>
</tr>
);
};

View File

@@ -0,0 +1,110 @@
import TooltipInfo from "~~/components/TooltipInfo";
import { AddOracleButton } from "~~/components/oracle/whitelist/AddOracleButton";
import { WhitelistRow } from "~~/components/oracle/whitelist/WhitelistRow";
import { useScaffoldEventHistory, useScaffoldReadContract } from "~~/hooks/scaffold-eth";
const LoadingRow = () => {
return (
<tr>
<td className="animate-pulse">
<div className="h-8 bg-secondary rounded w-32"></div>
</td>
<td className="animate-pulse">
<div className="h-8 bg-secondary rounded w-20"></div>
</td>
<td className="animate-pulse">
<div className="h-8 bg-secondary rounded w-24"></div>
</td>
</tr>
);
};
const NoNodesRow = () => {
return (
<tr>
<td colSpan={3} className="text-center">
No nodes found
</td>
</tr>
);
};
export const WhitelistTable = () => {
const { data: oraclesAdded, isLoading: isLoadingOraclesAdded } = useScaffoldEventHistory({
contractName: "WhitelistOracle",
eventName: "OracleAdded",
watch: true,
});
const { data: oraclesRemoved, isLoading: isLoadingOraclesRemoved } = useScaffoldEventHistory({
contractName: "WhitelistOracle",
eventName: "OracleRemoved",
watch: true,
});
const { data: activeOracleNodes } = useScaffoldReadContract({
contractName: "WhitelistOracle",
functionName: "getActiveOracleNodes",
watch: true,
});
const isLoading = isLoadingOraclesAdded || isLoadingOraclesRemoved;
const oracleAddresses = oraclesAdded
?.map((item, index) => ({
address: item?.args?.oracleAddress as string,
originalIndex: index,
}))
?.filter(item => !oraclesRemoved?.some(removedOracle => removedOracle?.args?.oracleAddress === item.address));
const tooltipText = `This table displays registered oracle nodes that provide price data to the system. Nodes are considered active if they've reported within the last 24 seconds. You can add a new oracle node by clicking the "Add Oracle Node" button or edit the price of an oracle node.`;
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">Oracle Nodes</h2>
<span className="text-sm text-gray-500">
<TooltipInfo infoText={tooltipText} className="tooltip-right" />
</span>
</div>
<div className="flex gap-2">
<AddOracleButton />
</div>
</div>
<div className="bg-base-100 rounded-lg p-4 relative">
<div className="overflow-x-auto">
<table className="table w-full">
<thead>
<tr>
<th>Node Address</th>
<th>
<div className="flex items-center gap-1">
Last Reported Price (USD)
<TooltipInfo infoText="Color shows proximity to median price" />
</div>
</th>
<th>Last Reported Time</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<LoadingRow />
) : oracleAddresses?.length === 0 ? (
<NoNodesRow />
) : (
oracleAddresses?.map(item => (
<WhitelistRow
key={item.address}
index={item.originalIndex}
address={item.address}
isActive={activeOracleNodes?.includes(item.address) ?? false}
/>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
"use client";
import { AvatarComponent } from "@rainbow-me/rainbowkit";
import { blo } from "blo";
// Custom Avatar for RainbowKit
export const BlockieAvatar: AvatarComponent = ({ address, ensImage, size }) => (
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
// eslint-disable-next-line @next/next/no-img-element
<img
className="rounded-full"
src={ensImage || blo(address as `0x${string}`)}
width={size}
height={size}
alt={`${address} avatar`}
/>
);

View File

@@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import { Address, AddressInput, Balance, EtherInput } from "@scaffold-ui/components";
import { Address as AddressType, createWalletClient, http, parseEther } from "viem";
import { hardhat } from "viem/chains";
import { useAccount } from "wagmi";
import { BanknotesIcon } from "@heroicons/react/24/outline";
import { useTargetNetwork, useTransactor } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
// Account index to use from generated hardhat accounts.
const FAUCET_ACCOUNT_INDEX = 0;
const localWalletClient = createWalletClient({
chain: hardhat,
transport: http(),
});
/**
* Faucet modal which lets you send ETH to any address.
*/
export const Faucet = () => {
const [loading, setLoading] = useState(false);
const [inputAddress, setInputAddress] = useState<AddressType>();
const [faucetAddress, setFaucetAddress] = useState<AddressType>();
const [sendValue, setSendValue] = useState("");
const { targetNetwork } = useTargetNetwork();
const { chain: ConnectedChain } = useAccount();
const faucetTxn = useTransactor(localWalletClient);
useEffect(() => {
const getFaucetAddress = async () => {
try {
const accounts = await localWalletClient.getAddresses();
setFaucetAddress(accounts[FAUCET_ACCOUNT_INDEX]);
} catch (error) {
notification.error(
<>
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
<p className="m-0">
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
</p>
<p className="mt-1 break-normal">
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
</p>
</>,
);
console.error("⚡️ ~ file: Faucet.tsx:getFaucetAddress ~ error", error);
}
};
getFaucetAddress();
}, []);
const sendETH = async () => {
if (!faucetAddress || !inputAddress) {
return;
}
try {
setLoading(true);
await faucetTxn({
to: inputAddress,
value: parseEther(sendValue as `${number}`),
account: faucetAddress,
});
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 !== hardhat.id) {
return null;
}
return (
<div>
<label htmlFor="faucet-modal" className="btn btn-primary btn-sm font-normal gap-1">
<BanknotesIcon className="h-4 w-4" />
<span>Faucet</span>
</label>
<input type="checkbox" id="faucet-modal" className="modal-toggle" />
<label htmlFor="faucet-modal" className="modal cursor-pointer">
<label className="modal-box relative">
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
<h3 className="text-xl font-bold mb-3">Local Faucet</h3>
<label htmlFor="faucet-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</label>
<div className="space-y-3">
<div className="flex space-x-4">
<div>
<span className="text-sm font-bold">From:</span>
<Address
address={faucetAddress}
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${faucetAddress}` : undefined
}
/>
</div>
<div>
<span className="text-sm font-bold pl-3">Available:</span>
<Balance address={faucetAddress} />
</div>
</div>
<div className="flex flex-col space-y-3">
<AddressInput
placeholder="Destination Address"
value={inputAddress ?? ""}
onChange={value => setInputAddress(value as AddressType)}
/>
<EtherInput
placeholder="Amount to send"
onValueChange={({ valueInEth }) => setSendValue(valueInEth)}
style={{ width: "100%" }}
/>
<button className="h-10 btn btn-primary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
{!loading ? (
<BanknotesIcon className="h-6 w-6" />
) : (
<span className="loading loading-spinner loading-sm"></span>
)}
<span>Send</span>
</button>
</div>
</div>
</label>
</label>
</div>
);
};

View File

@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { useWatchBalance } from "@scaffold-ui/hooks";
import { createWalletClient, http, parseEther } from "viem";
import { hardhat } from "viem/chains";
import { useAccount } from "wagmi";
import { BanknotesIcon } from "@heroicons/react/24/outline";
import { useTransactor } from "~~/hooks/scaffold-eth";
// Number of ETH faucet sends to an address
const NUM_OF_ETH = "1";
const FAUCET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const localWalletClient = createWalletClient({
chain: hardhat,
transport: http(),
});
/**
* FaucetButton button which lets you grab eth.
*/
export const FaucetButton = () => {
const { address, chain: ConnectedChain } = useAccount();
const { data: balance } = useWatchBalance({ address, chain: hardhat });
const [loading, setLoading] = useState(false);
const faucetTxn = useTransactor(localWalletClient);
const sendETH = async () => {
if (!address) return;
try {
setLoading(true);
await faucetTxn({
account: FAUCET_ADDRESS,
to: address,
value: parseEther(NUM_OF_ETH),
});
setLoading(false);
} catch (error) {
console.error("⚡️ ~ file: FaucetButton.tsx:sendETH ~ error", error);
setLoading(false);
}
};
// Render only on local chain
if (ConnectedChain?.id !== hardhat.id) {
return null;
}
const isBalanceZero = balance && balance.value === 0n;
return (
<div
className={
!isBalanceZero
? "ml-1"
: "ml-1 tooltip tooltip-bottom tooltip-primary tooltip-open font-bold before:left-auto before:transform-none before:content-[attr(data-tip)] before:-translate-x-2/5"
}
data-tip="Grab funds from faucet"
>
<button className="btn btn-secondary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
{!loading ? (
<BanknotesIcon className="h-4 w-4" />
) : (
<span className="loading loading-spinner loading-xs"></span>
)}
</button>
</div>
);
};

View File

@@ -0,0 +1,137 @@
import { useRef, useState } from "react";
import { NetworkOptions } from "./NetworkOptions";
import { getAddress } from "viem";
import { Address } from "viem";
import { useAccount, useDisconnect } from "wagmi";
import {
ArrowLeftOnRectangleIcon,
ArrowTopRightOnSquareIcon,
ArrowsRightLeftIcon,
CheckCircleIcon,
ChevronDownIcon,
DocumentDuplicateIcon,
EyeIcon,
QrCodeIcon,
} from "@heroicons/react/24/outline";
import { BlockieAvatar } from "~~/components/scaffold-eth";
import { useCopyToClipboard, useOutsideClick } from "~~/hooks/scaffold-eth";
import { getTargetNetworks } from "~~/utils/scaffold-eth";
import { isENS } from "~~/utils/scaffold-eth/common";
const BURNER_WALLET_ID = "burnerWallet";
const allowedNetworks = getTargetNetworks();
type AddressInfoDropdownProps = {
address: Address;
blockExplorerAddressLink: string | undefined;
displayName: string;
ensAvatar?: string;
};
export const AddressInfoDropdown = ({
address,
ensAvatar,
displayName,
blockExplorerAddressLink,
}: AddressInfoDropdownProps) => {
const { disconnect } = useDisconnect();
const { connector } = useAccount();
const checkSumAddress = getAddress(address);
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
useCopyToClipboard();
const [selectingNetwork, setSelectingNetwork] = useState(false);
const dropdownRef = useRef<HTMLDetailsElement>(null);
const closeDropdown = () => {
setSelectingNetwork(false);
dropdownRef.current?.removeAttribute("open");
};
useOutsideClick(dropdownRef, closeDropdown);
return (
<>
<details ref={dropdownRef} className="dropdown dropdown-end leading-3">
<summary className="btn btn-secondary btn-sm pl-0 pr-2 shadow-md dropdown-toggle gap-0 h-auto!">
<BlockieAvatar address={checkSumAddress} size={30} ensImage={ensAvatar} />
<span className="ml-2 mr-1">
{isENS(displayName) ? displayName : checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4)}
</span>
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
</summary>
<ul className="dropdown-content menu z-2 p-2 mt-2 shadow-center shadow-accent bg-base-200 rounded-box gap-1">
<NetworkOptions hidden={!selectingNetwork} />
<li className={selectingNetwork ? "hidden" : ""}>
<div
className="h-8 btn-sm rounded-xl! flex gap-3 py-3 cursor-pointer"
onClick={() => copyAddressToClipboard(checkSumAddress)}
>
{isAddressCopiedToClipboard ? (
<>
<CheckCircleIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
<span className="whitespace-nowrap">Copied!</span>
</>
) : (
<>
<DocumentDuplicateIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
<span className="whitespace-nowrap">Copy address</span>
</>
)}
</div>
</li>
<li className={selectingNetwork ? "hidden" : ""}>
<label htmlFor="qrcode-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3">
<QrCodeIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span className="whitespace-nowrap">View QR Code</span>
</label>
</li>
<li className={selectingNetwork ? "hidden" : ""}>
<button className="h-8 btn-sm rounded-xl! flex gap-3 py-3" type="button">
<ArrowTopRightOnSquareIcon className="h-6 w-4 ml-2 sm:ml-0" />
<a
target="_blank"
href={blockExplorerAddressLink}
rel="noopener noreferrer"
className="whitespace-nowrap"
>
View on Block Explorer
</a>
</button>
</li>
{allowedNetworks.length > 1 ? (
<li className={selectingNetwork ? "hidden" : ""}>
<button
className="h-8 btn-sm rounded-xl! flex gap-3 py-3"
type="button"
onClick={() => {
setSelectingNetwork(true);
}}
>
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Switch Network</span>
</button>
</li>
) : null}
{connector?.id === BURNER_WALLET_ID ? (
<li>
<label htmlFor="reveal-burner-pk-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3 text-error">
<EyeIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span>Reveal Private Key</span>
</label>
</li>
) : null}
<li className={selectingNetwork ? "hidden" : ""}>
<button
className="menu-item text-error h-8 btn-sm rounded-xl! flex gap-3 py-3"
type="button"
onClick={() => disconnect()}
>
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Disconnect</span>
</button>
</li>
</ul>
</details>
</>
);
};

View File

@@ -0,0 +1,44 @@
import { Address } from "@scaffold-ui/components";
import { QRCodeSVG } from "qrcode.react";
import { Address as AddressType } from "viem";
import { hardhat } from "viem/chains";
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
type AddressQRCodeModalProps = {
address: AddressType;
modalId: string;
};
export const AddressQRCodeModal = ({ address, modalId }: AddressQRCodeModalProps) => {
const { targetNetwork } = useTargetNetwork();
return (
<>
<div>
<input type="checkbox" id={`${modalId}`} className="modal-toggle" />
<label htmlFor={`${modalId}`} className="modal cursor-pointer">
<label className="modal-box relative">
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
<label htmlFor={`${modalId}`} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</label>
<div className="space-y-3 py-6">
<div className="flex flex-col items-center gap-6">
<QRCodeSVG value={address} size={256} />
<Address
address={address}
format="long"
disableAddressLink
onlyEnsOrAddress
blockExplorerAddressLink={
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${address}` : undefined
}
/>
</div>
</div>
</label>
</label>
</div>
</>
);
};

View File

@@ -0,0 +1,48 @@
import { useTheme } from "next-themes";
import { useAccount, useSwitchChain } from "wagmi";
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
import { getNetworkColor } from "~~/hooks/scaffold-eth";
import { getTargetNetworks } from "~~/utils/scaffold-eth";
const allowedNetworks = getTargetNetworks();
type NetworkOptionsProps = {
hidden?: boolean;
};
export const NetworkOptions = ({ hidden = false }: NetworkOptionsProps) => {
const { switchChain } = useSwitchChain();
const { chain } = useAccount();
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";
return (
<>
{allowedNetworks
.filter(allowedNetwork => allowedNetwork.id !== chain?.id)
.map(allowedNetwork => (
<li key={allowedNetwork.id} className={hidden ? "hidden" : ""}>
<button
className="menu-item btn-sm rounded-xl! flex gap-3 py-3 whitespace-nowrap"
type="button"
onClick={() => {
switchChain?.({ chainId: allowedNetwork.id });
}}
>
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span>
Switch to{" "}
<span
style={{
color: getNetworkColor(allowedNetwork, isDarkMode),
}}
>
{allowedNetwork.name}
</span>
</span>
</button>
</li>
))}
</>
);
};

View File

@@ -0,0 +1,59 @@
import { useRef } from "react";
import { rainbowkitBurnerWallet } from "burner-connector";
import { ShieldExclamationIcon } from "@heroicons/react/24/outline";
import { useCopyToClipboard } from "~~/hooks/scaffold-eth";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
const BURNER_WALLET_PK_KEY = "burnerWallet.pk";
export const RevealBurnerPKModal = () => {
const { copyToClipboard, isCopiedToClipboard } = useCopyToClipboard();
const modalCheckboxRef = useRef<HTMLInputElement>(null);
const handleCopyPK = async () => {
try {
const storage = rainbowkitBurnerWallet.useSessionStorage ? sessionStorage : localStorage;
const burnerPK = storage?.getItem(BURNER_WALLET_PK_KEY);
if (!burnerPK) throw new Error("Burner wallet private key not found");
await copyToClipboard(burnerPK);
notification.success("Burner wallet private key copied to clipboard");
} catch (e) {
const parsedError = getParsedError(e);
notification.error(parsedError);
if (modalCheckboxRef.current) modalCheckboxRef.current.checked = false;
}
};
return (
<>
<div>
<input type="checkbox" id="reveal-burner-pk-modal" className="modal-toggle" ref={modalCheckboxRef} />
<label htmlFor="reveal-burner-pk-modal" className="modal cursor-pointer">
<label className="modal-box relative">
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
<label htmlFor="reveal-burner-pk-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</label>
<div>
<p className="text-lg font-semibold m-0 p-0">Copy Burner Wallet Private Key</p>
<div role="alert" className="alert alert-warning mt-4">
<ShieldExclamationIcon className="h-6 w-6" />
<span className="font-semibold">
Burner wallets are intended for local development only and are not safe for storing real funds.
</span>
</div>
<p>
Your Private Key provides <strong>full access</strong> to your entire wallet and funds. This is
currently stored <strong>temporarily</strong> in your browser.
</p>
<button className="btn btn-outline btn-error" onClick={handleCopyPK} disabled={isCopiedToClipboard}>
Copy Private Key To Clipboard
</button>
</div>
</label>
</label>
</div>
</>
);
};

View File

@@ -0,0 +1,32 @@
import { NetworkOptions } from "./NetworkOptions";
import { useDisconnect } from "wagmi";
import { ArrowLeftOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
export const WrongNetworkDropdown = () => {
const { disconnect } = useDisconnect();
return (
<div className="dropdown dropdown-end mr-2">
<label tabIndex={0} className="btn btn-error btn-sm dropdown-toggle gap-1">
<span>Wrong network</span>
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
</label>
<ul
tabIndex={0}
className="dropdown-content menu p-2 mt-1 shadow-center shadow-accent bg-base-200 rounded-box gap-1"
>
<NetworkOptions />
<li>
<button
className="menu-item text-error btn-sm rounded-xl! flex gap-3 py-3"
type="button"
onClick={() => disconnect()}
>
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span>Disconnect</span>
</button>
</li>
</ul>
</div>
);
};

View File

@@ -0,0 +1,76 @@
"use client";
// @refresh reset
import { AddressInfoDropdown } from "./AddressInfoDropdown";
import { AddressQRCodeModal } from "./AddressQRCodeModal";
import { RevealBurnerPKModal } from "./RevealBurnerPKModal";
import { WrongNetworkDropdown } from "./WrongNetworkDropdown";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { Balance } from "@scaffold-ui/components";
import { Address } from "viem";
import { useNetworkColor } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
/**
* Custom Wagmi Connect Button (watch balance + custom design)
*/
export const RainbowKitCustomConnectButton = () => {
const networkColor = useNetworkColor();
const { targetNetwork } = useTargetNetwork();
return (
<ConnectButton.Custom>
{({ account, chain, openConnectModal, mounted }) => {
const connected = mounted && account && chain;
const blockExplorerAddressLink = account
? getBlockExplorerAddressLink(targetNetwork, account.address)
: undefined;
return (
<>
{(() => {
if (!connected) {
return (
<button className="btn btn-primary btn-sm" onClick={openConnectModal} type="button">
Connect Wallet
</button>
);
}
if (chain.unsupported || chain.id !== targetNetwork.id) {
return <WrongNetworkDropdown />;
}
return (
<>
<div className="flex flex-col items-center mr-2">
<Balance
address={account.address as Address}
style={{
minHeight: "0",
height: "auto",
fontSize: "0.8em",
}}
/>
<span className="text-xs" style={{ color: networkColor }}>
{chain.name}
</span>
</div>
<AddressInfoDropdown
address={account.address as Address}
displayName={account.displayName}
ensAvatar={account.ensAvatar}
blockExplorerAddressLink={blockExplorerAddressLink}
/>
<AddressQRCodeModal address={account.address as Address} modalId="qrcode-modal" />
<RevealBurnerPKModal />
</>
);
})()}
</>
);
}}
</ConnectButton.Custom>
);
};

View File

@@ -0,0 +1,4 @@
export * from "./BlockieAvatar";
export * from "./Faucet";
export * from "./FaucetButton";
export * from "./RainbowKitCustomConnectButton";

View File

@@ -0,0 +1,9 @@
/**
* This file is autogenerated by Scaffold-ETH.
* You should not edit it manually or your changes might be overwritten.
*/
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
const deployedContracts = {} as const;
export default deployedContracts satisfies GenericContractsDeclaration;

View File

@@ -0,0 +1,16 @@
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
/**
* @example
* const externalContracts = {
* 1: {
* DAI: {
* address: "0x...",
* abi: [...],
* },
* },
* } as const;
*/
const externalContracts = {} as const;
export default externalContracts satisfies GenericContractsDeclaration;

View File

@@ -0,0 +1,32 @@
import { FlatCompat } from "@eslint/eslintrc";
import prettierPlugin from "eslint-plugin-prettier";
import { defineConfig } from "eslint/config";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default defineConfig([
{
plugins: {
prettier: prettierPlugin,
},
extends: compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"prettier/prettier": [
"warn",
{
endOfLine: "auto",
},
],
},
},
]);

View File

@@ -0,0 +1,14 @@
export * from "./useContractLogs";
export * from "./useCopyToClipboard";
export * from "./useDeployedContractInfo";
export * from "./useFetchBlocks";
export * from "./useNetworkColor";
export * from "./useOutsideClick";
export * from "./useScaffoldContract";
export * from "./useScaffoldEventHistory";
export * from "./useScaffoldReadContract";
export * from "./useScaffoldWatchContractEvent";
export * from "./useScaffoldWriteContract";
export * from "./useTargetNetwork";
export * from "./useTransactor";
export * from "./useSelectedNetwork";

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { useTargetNetwork } from "./useTargetNetwork";
import { Address, Log } from "viem";
import { usePublicClient } from "wagmi";
export const useContractLogs = (address: Address) => {
const [logs, setLogs] = useState<Log[]>([]);
const { targetNetwork } = useTargetNetwork();
const client = usePublicClient({ chainId: targetNetwork.id });
useEffect(() => {
const fetchLogs = async () => {
if (!client) return console.error("Client not found");
try {
const existingLogs = await client.getLogs({
address: address,
fromBlock: 0n,
toBlock: "latest",
});
setLogs(existingLogs);
} 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]);
},
});
}, [address, client]);
return logs;
};

View File

@@ -0,0 +1,19 @@
import { useState } from "react";
export const useCopyToClipboard = () => {
const [isCopiedToClipboard, setIsCopiedToClipboard] = useState(false);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setIsCopiedToClipboard(true);
setTimeout(() => {
setIsCopiedToClipboard(false);
}, 800);
} catch (err) {
console.error("Failed to copy text:", err);
}
};
return { copyToClipboard, isCopiedToClipboard };
};

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useIsMounted } from "usehooks-ts";
import { usePublicClient } from "wagmi";
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
import {
Contract,
ContractCodeStatus,
ContractName,
UseDeployedContractConfig,
contracts,
} from "~~/utils/scaffold-eth/contract";
type DeployedContractData<TContractName extends ContractName> = {
data: Contract<TContractName> | undefined;
isLoading: boolean;
};
/**
* Gets the matching contract info for the provided contract name from the contracts present in deployedContracts.ts
* and externalContracts.ts corresponding to targetNetworks configured in scaffold.config.ts
*/
export function useDeployedContractInfo<TContractName extends ContractName>(
config: UseDeployedContractConfig<TContractName>,
): DeployedContractData<TContractName>;
/**
* @deprecated Use object parameter version instead: useDeployedContractInfo({ contractName: "YourContract" })
*/
export function useDeployedContractInfo<TContractName extends ContractName>(
contractName: TContractName,
): DeployedContractData<TContractName>;
export function useDeployedContractInfo<TContractName extends ContractName>(
configOrName: UseDeployedContractConfig<TContractName> | TContractName,
): DeployedContractData<TContractName> {
const isMounted = useIsMounted();
const finalConfig: UseDeployedContractConfig<TContractName> =
typeof configOrName === "string" ? { contractName: configOrName } : (configOrName as any);
useEffect(() => {
if (typeof configOrName === "string") {
console.warn(
"Using `useDeployedContractInfo` with a string parameter is deprecated. Please use the object parameter version instead.",
);
}
}, [configOrName]);
const { contractName, chainId } = finalConfig;
const selectedNetwork = useSelectedNetwork(chainId);
const deployedContract = contracts?.[selectedNetwork.id]?.[contractName as ContractName] as Contract<TContractName>;
const [status, setStatus] = useState<ContractCodeStatus>(ContractCodeStatus.LOADING);
const publicClient = usePublicClient({ chainId: selectedNetwork.id });
useEffect(() => {
const checkContractDeployment = async () => {
try {
if (!isMounted() || !publicClient) return;
if (!deployedContract) {
setStatus(ContractCodeStatus.NOT_FOUND);
return;
}
const code = await publicClient.getBytecode({
address: deployedContract.address,
});
// If contract code is `0x` => no contract deployed on that address
if (code === "0x") {
setStatus(ContractCodeStatus.NOT_FOUND);
return;
}
setStatus(ContractCodeStatus.DEPLOYED);
} catch (e) {
console.error(e);
setStatus(ContractCodeStatus.NOT_FOUND);
}
};
checkContractDeployment();
}, [isMounted, contractName, deployedContract, publicClient]);
return {
data: status === ContractCodeStatus.DEPLOYED ? deployedContract : undefined,
isLoading: status === ContractCodeStatus.LOADING,
};
}

View File

@@ -0,0 +1,133 @@
import { useCallback, useEffect, useState } from "react";
import {
Block,
Hash,
Transaction,
TransactionReceipt,
createTestClient,
publicActions,
walletActions,
webSocket,
} from "viem";
import { hardhat } from "viem/chains";
import { decodeTransactionData } from "~~/utils/scaffold-eth";
const BLOCKS_PER_PAGE = 20;
export const testClient = createTestClient({
chain: hardhat,
mode: "hardhat",
transport: webSocket("ws://127.0.0.1:8545"),
})
.extend(publicActions)
.extend(walletActions);
export const useFetchBlocks = () => {
const [blocks, setBlocks] = useState<Block[]>([]);
const [transactionReceipts, setTransactionReceipts] = useState<{
[key: string]: TransactionReceipt;
}>({});
const [currentPage, setCurrentPage] = useState(0);
const [totalBlocks, setTotalBlocks] = useState(0n);
const [error, setError] = useState<Error | null>(null);
const fetchBlocks = useCallback(async () => {
setError(null);
try {
const blockNumber = await testClient.getBlockNumber();
setTotalBlocks(blockNumber);
const startingBlock = blockNumber - BigInt(currentPage * BLOCKS_PER_PAGE);
const blockNumbersToFetch = Array.from(
{ length: Number(BLOCKS_PER_PAGE < startingBlock + 1n ? BLOCKS_PER_PAGE : startingBlock + 1n) },
(_, i) => startingBlock - BigInt(i),
);
const blocksWithTransactions = blockNumbersToFetch.map(async blockNumber => {
try {
return testClient.getBlock({ blockNumber, includeTransactions: true });
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
throw err;
}
});
const fetchedBlocks = await Promise.all(blocksWithTransactions);
fetchedBlocks.forEach(block => {
block.transactions.forEach(tx => decodeTransactionData(tx as Transaction));
});
const txReceipts = await Promise.all(
fetchedBlocks.flatMap(block =>
block.transactions.map(async tx => {
try {
const receipt = await testClient.getTransactionReceipt({ hash: (tx as Transaction).hash });
return { [(tx as Transaction).hash]: receipt };
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
throw err;
}
}),
),
);
setBlocks(fetchedBlocks);
setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...txReceipts) }));
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
}
}, [currentPage]);
useEffect(() => {
fetchBlocks();
}, [fetchBlocks]);
useEffect(() => {
const handleNewBlock = async (newBlock: any) => {
try {
if (currentPage === 0) {
if (newBlock.transactions.length > 0) {
const transactionsDetails = await Promise.all(
newBlock.transactions.map((txHash: string) => testClient.getTransaction({ hash: txHash as Hash })),
);
newBlock.transactions = transactionsDetails;
}
newBlock.transactions.forEach((tx: Transaction) => decodeTransactionData(tx as Transaction));
const receipts = await Promise.all(
newBlock.transactions.map(async (tx: Transaction) => {
try {
const receipt = await testClient.getTransactionReceipt({ hash: (tx as Transaction).hash });
return { [(tx as Transaction).hash]: receipt };
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred fetching receipt."));
throw err;
}
}),
);
setBlocks(prevBlocks => [newBlock, ...prevBlocks.slice(0, BLOCKS_PER_PAGE - 1)]);
setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...receipts) }));
}
if (newBlock.number) {
setTotalBlocks(newBlock.number);
}
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
}
};
return testClient.watchBlocks({ onBlock: handleNewBlock, includeTransactions: true });
}, [currentPage]);
return {
blocks,
transactionReceipts,
currentPage,
totalBlocks,
setCurrentPage,
error,
};
};

View File

@@ -0,0 +1,22 @@
import { useTheme } from "next-themes";
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
import { AllowedChainIds, ChainWithAttributes } from "~~/utils/scaffold-eth";
export const DEFAULT_NETWORK_COLOR: [string, string] = ["#666666", "#bbbbbb"];
export function getNetworkColor(network: ChainWithAttributes, isDarkMode: boolean) {
const colorConfig = network.color ?? DEFAULT_NETWORK_COLOR;
return Array.isArray(colorConfig) ? (isDarkMode ? colorConfig[1] : colorConfig[0]) : colorConfig;
}
/**
* Gets the color of the target network
*/
export const useNetworkColor = (chainId?: AllowedChainIds) => {
const { resolvedTheme } = useTheme();
const chain = useSelectedNetwork(chainId);
const isDarkMode = resolvedTheme === "dark";
return getNetworkColor(chain, isDarkMode);
};

View File

@@ -0,0 +1,23 @@
import React, { useEffect } from "react";
/**
* Handles clicks outside of passed ref element
* @param ref - react ref of the element
* @param callback - callback function to call when clicked outside
*/
export const useOutsideClick = (ref: React.RefObject<HTMLElement | null>, callback: { (): void }) => {
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (!(event.target instanceof Element)) {
return;
}
if (ref.current && !ref.current.contains(event.target)) {
callback();
}
}
document.addEventListener("click", handleOutsideClick);
return () => document.removeEventListener("click", handleOutsideClick);
}, [ref, callback]);
};

View File

@@ -0,0 +1,65 @@
import { Account, Address, Chain, Client, Transport, getContract } from "viem";
import { usePublicClient } from "wagmi";
import { GetWalletClientReturnType } from "wagmi/actions";
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
import { AllowedChainIds } from "~~/utils/scaffold-eth";
import { Contract, ContractName } from "~~/utils/scaffold-eth/contract";
/**
* Gets a viem instance of the contract present in deployedContracts.ts or externalContracts.ts corresponding to
* targetNetworks configured in scaffold.config.ts. Optional walletClient can be passed for doing write transactions.
* @param config - The config settings for the hook
* @param config.contractName - deployed contract name
* @param config.walletClient - optional walletClient from wagmi useWalletClient hook can be passed for doing write transactions
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
*/
export const useScaffoldContract = <
TContractName extends ContractName,
TWalletClient extends Exclude<GetWalletClientReturnType, null> | undefined,
>({
contractName,
walletClient,
chainId,
}: {
contractName: TContractName;
walletClient?: TWalletClient | null;
chainId?: AllowedChainIds;
}) => {
const selectedNetwork = useSelectedNetwork(chainId);
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({
contractName,
chainId: selectedNetwork?.id as AllowedChainIds,
});
const publicClient = usePublicClient({ chainId: selectedNetwork?.id });
let contract = undefined;
if (deployedContractData && publicClient) {
contract = getContract<
Transport,
Address,
Contract<TContractName>["abi"],
TWalletClient extends Exclude<GetWalletClientReturnType, null>
? {
public: Client<Transport, Chain>;
wallet: TWalletClient;
}
: { public: Client<Transport, Chain> },
Chain,
Account
>({
address: deployedContractData.address,
abi: deployedContractData.abi as Contract<TContractName>["abi"],
client: {
public: publicClient,
wallet: walletClient ? walletClient : undefined,
} as any,
});
}
return {
data: contract,
isLoading: deployedContractLoading,
};
};

View File

@@ -0,0 +1,292 @@
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,
};
};

View File

@@ -0,0 +1,80 @@
import { useEffect } from "react";
import { QueryObserverResult, RefetchOptions, useQueryClient } from "@tanstack/react-query";
import type { ExtractAbiFunctionNames } from "abitype";
import { ReadContractErrorType } from "viem";
import { useBlockNumber, useReadContract } from "wagmi";
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
import { AllowedChainIds } from "~~/utils/scaffold-eth";
import {
AbiFunctionReturnType,
ContractAbi,
ContractName,
UseScaffoldReadConfig,
} from "~~/utils/scaffold-eth/contract";
/**
* Wrapper around wagmi's useContractRead hook which automatically loads (by name) the contract ABI and address from
* the contracts present in deployedContracts.ts & externalContracts.ts corresponding to targetNetworks configured in scaffold.config.ts
* @param config - The config settings, including extra wagmi configuration
* @param config.contractName - deployed contract name
* @param config.functionName - name of the function to be called
* @param config.args - args to be passed to the function call
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
*/
export const useScaffoldReadContract = <
TContractName extends ContractName,
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "pure" | "view">,
>({
contractName,
functionName,
args,
chainId,
...readConfig
}: UseScaffoldReadConfig<TContractName, TFunctionName>) => {
const selectedNetwork = useSelectedNetwork(chainId);
const { data: deployedContract } = useDeployedContractInfo({
contractName,
chainId: selectedNetwork.id as AllowedChainIds,
});
const { query: queryOptions, watch, ...readContractConfig } = readConfig;
// set watch to true by default
const defaultWatch = watch ?? true;
const readContractHookRes = useReadContract({
chainId: selectedNetwork.id,
functionName,
address: deployedContract?.address,
abi: deployedContract?.abi,
args,
...(readContractConfig as any),
query: {
enabled: !Array.isArray(args) || !args.some(arg => arg === undefined),
...queryOptions,
},
}) as Omit<ReturnType<typeof useReadContract>, "data" | "refetch"> & {
data: AbiFunctionReturnType<ContractAbi, TFunctionName> | undefined;
refetch: (
options?: RefetchOptions | undefined,
) => Promise<QueryObserverResult<AbiFunctionReturnType<ContractAbi, TFunctionName>, ReadContractErrorType>>;
};
const queryClient = useQueryClient();
const { data: blockNumber } = useBlockNumber({
watch: defaultWatch,
chainId: selectedNetwork.id,
query: {
enabled: defaultWatch,
},
});
useEffect(() => {
if (defaultWatch) {
queryClient.invalidateQueries({ queryKey: readContractHookRes.queryKey });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blockNumber]);
return readContractHookRes;
};

View File

@@ -0,0 +1,40 @@
import { Abi, ExtractAbiEventNames } from "abitype";
import { Log } from "viem";
import { useWatchContractEvent } from "wagmi";
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
import { AllowedChainIds } from "~~/utils/scaffold-eth";
import { ContractAbi, ContractName, UseScaffoldEventConfig } from "~~/utils/scaffold-eth/contract";
/**
* Wrapper around wagmi's useEventSubscriber hook which automatically loads (by name) the contract ABI and
* address from the contracts present in deployedContracts.ts & externalContracts.ts
* @param config - The config settings
* @param config.contractName - deployed contract name
* @param config.eventName - name of the event to listen for
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
* @param config.onLogs - the callback that receives events.
*/
export const useScaffoldWatchContractEvent = <
TContractName extends ContractName,
TEventName extends ExtractAbiEventNames<ContractAbi<TContractName>>,
>({
contractName,
eventName,
chainId,
onLogs,
}: UseScaffoldEventConfig<TContractName, TEventName>) => {
const selectedNetwork = useSelectedNetwork(chainId);
const { data: deployedContractData } = useDeployedContractInfo({
contractName,
chainId: selectedNetwork.id as AllowedChainIds,
});
return useWatchContractEvent({
address: deployedContractData?.address,
abi: deployedContractData?.abi as Abi,
chainId: selectedNetwork.id,
onLogs: (logs: Log[]) => onLogs(logs as Parameters<typeof onLogs>[0]),
eventName,
});
};

View File

@@ -0,0 +1,194 @@
import { useEffect, useState } from "react";
import { MutateOptions } from "@tanstack/react-query";
import { Abi, ExtractAbiFunctionNames } from "abitype";
import { Config, UseWriteContractParameters, useAccount, useConfig, useWriteContract } from "wagmi";
import { WriteContractErrorType, WriteContractReturnType } from "wagmi/actions";
import { WriteContractVariables } from "wagmi/query";
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
import { useDeployedContractInfo, useTransactor } from "~~/hooks/scaffold-eth";
import { AllowedChainIds, notification } from "~~/utils/scaffold-eth";
import {
ContractAbi,
ContractName,
ScaffoldWriteContractOptions,
ScaffoldWriteContractVariables,
UseScaffoldWriteConfig,
simulateContractWriteAndNotifyError,
} from "~~/utils/scaffold-eth/contract";
type ScaffoldWriteContractReturnType<TContractName extends ContractName> = Omit<
ReturnType<typeof useWriteContract>,
"writeContract" | "writeContractAsync"
> & {
isMining: boolean;
writeContractAsync: <
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">,
>(
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
options?: ScaffoldWriteContractOptions,
) => Promise<WriteContractReturnType | undefined>;
writeContract: <TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">>(
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
options?: Omit<ScaffoldWriteContractOptions, "onBlockConfirmation" | "blockConfirmations">,
) => void;
};
export function useScaffoldWriteContract<TContractName extends ContractName>(
config: UseScaffoldWriteConfig<TContractName>,
): ScaffoldWriteContractReturnType<TContractName>;
/**
* @deprecated Use object parameter version instead: useScaffoldWriteContract({ contractName: "YourContract" })
*/
export function useScaffoldWriteContract<TContractName extends ContractName>(
contractName: TContractName,
writeContractParams?: UseWriteContractParameters,
): ScaffoldWriteContractReturnType<TContractName>;
/**
* Wrapper around wagmi's useWriteContract hook which automatically loads (by name) the contract ABI and address from
* the contracts present in deployedContracts.ts & externalContracts.ts corresponding to targetNetworks configured in scaffold.config.ts
* @param contractName - name of the contract to be written to
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
* @param writeContractParams - wagmi's useWriteContract parameters
*/
export function useScaffoldWriteContract<TContractName extends ContractName>(
configOrName: UseScaffoldWriteConfig<TContractName> | TContractName,
writeContractParams?: UseWriteContractParameters,
): ScaffoldWriteContractReturnType<TContractName> {
const finalConfig =
typeof configOrName === "string"
? { contractName: configOrName, writeContractParams, chainId: undefined }
: (configOrName as UseScaffoldWriteConfig<TContractName>);
const { contractName, chainId, writeContractParams: finalWriteContractParams } = finalConfig;
const wagmiConfig = useConfig();
useEffect(() => {
if (typeof configOrName === "string") {
console.warn(
"Using `useScaffoldWriteContract` with a string parameter is deprecated. Please use the object parameter version instead.",
);
}
}, [configOrName]);
const { chain: accountChain } = useAccount();
const writeTx = useTransactor();
const [isMining, setIsMining] = useState(false);
const wagmiContractWrite = useWriteContract(finalWriteContractParams);
const selectedNetwork = useSelectedNetwork(chainId);
const { data: deployedContractData } = useDeployedContractInfo({
contractName,
chainId: selectedNetwork.id as AllowedChainIds,
});
const sendContractWriteAsyncTx = async <
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">,
>(
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
options?: ScaffoldWriteContractOptions,
) => {
if (!deployedContractData) {
notification.error("Target Contract is not deployed, did you forget to run `yarn deploy`?");
return;
}
if (!accountChain?.id) {
notification.error("Please connect your wallet");
return;
}
if (accountChain?.id !== selectedNetwork.id) {
notification.error(`Wallet is connected to the wrong network. Please switch to ${selectedNetwork.name}`);
return;
}
try {
setIsMining(true);
const { blockConfirmations, onBlockConfirmation, ...mutateOptions } = options || {};
const writeContractObject = {
abi: deployedContractData.abi as Abi,
address: deployedContractData.address,
...variables,
} as WriteContractVariables<Abi, string, any[], Config, number>;
if (!finalConfig?.disableSimulate) {
await simulateContractWriteAndNotifyError({
wagmiConfig,
writeContractParams: writeContractObject,
chainId: selectedNetwork.id as AllowedChainIds,
});
}
const makeWriteWithParams = () =>
wagmiContractWrite.writeContractAsync(
writeContractObject,
mutateOptions as
| MutateOptions<
WriteContractReturnType,
WriteContractErrorType,
WriteContractVariables<Abi, string, any[], Config, number>,
unknown
>
| undefined,
);
const writeTxResult = await writeTx(makeWriteWithParams, { blockConfirmations, onBlockConfirmation });
return writeTxResult;
} catch (e: any) {
throw e;
} finally {
setIsMining(false);
}
};
const sendContractWriteTx = <
TContractName extends ContractName,
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">,
>(
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
options?: Omit<ScaffoldWriteContractOptions, "onBlockConfirmation" | "blockConfirmations">,
) => {
if (!deployedContractData) {
notification.error("Target Contract is not deployed, did you forget to run `yarn deploy`?");
return;
}
if (!accountChain?.id) {
notification.error("Please connect your wallet");
return;
}
if (accountChain?.id !== selectedNetwork.id) {
notification.error(`Wallet is connected to the wrong network. Please switch to ${selectedNetwork.name}`);
return;
}
wagmiContractWrite.writeContract(
{
abi: deployedContractData.abi as Abi,
address: deployedContractData.address,
...variables,
} as WriteContractVariables<Abi, string, any[], Config, number>,
options as
| MutateOptions<
WriteContractReturnType,
WriteContractErrorType,
WriteContractVariables<Abi, string, any[], Config, number>,
unknown
>
| undefined,
);
};
return {
...wagmiContractWrite,
isMining,
// Overwrite wagmi's writeContactAsync
writeContractAsync: sendContractWriteAsyncTx,
// Overwrite wagmi's writeContract
writeContract: sendContractWriteTx,
};
}

View File

@@ -0,0 +1,19 @@
import scaffoldConfig from "~~/scaffold.config";
import { useGlobalState } from "~~/services/store/store";
import { AllowedChainIds } from "~~/utils/scaffold-eth";
import { ChainWithAttributes, NETWORKS_EXTRA_DATA } from "~~/utils/scaffold-eth/networks";
/**
* Given a chainId, retrives the network object from `scaffold.config`,
* if not found default to network set by `useTargetNetwork` hook
*/
export function useSelectedNetwork(chainId?: AllowedChainIds): ChainWithAttributes {
const globalTargetNetwork = useGlobalState(({ targetNetwork }) => targetNetwork);
const targetNetwork = scaffoldConfig.targetNetworks.find(targetNetwork => targetNetwork.id === chainId);
if (targetNetwork) {
return { ...targetNetwork, ...NETWORKS_EXTRA_DATA[targetNetwork.id] };
}
return globalTargetNetwork;
}

View File

@@ -0,0 +1,24 @@
import { useEffect, useMemo } from "react";
import { useAccount } from "wagmi";
import scaffoldConfig from "~~/scaffold.config";
import { useGlobalState } from "~~/services/store/store";
import { ChainWithAttributes } from "~~/utils/scaffold-eth";
import { NETWORKS_EXTRA_DATA } from "~~/utils/scaffold-eth";
/**
* Retrieves the connected wallet's network from scaffold.config or defaults to the 0th network in the list if the wallet is not connected.
*/
export function useTargetNetwork(): { targetNetwork: ChainWithAttributes } {
const { chain } = useAccount();
const targetNetwork = useGlobalState(({ targetNetwork }) => targetNetwork);
const setTargetNetwork = useGlobalState(({ setTargetNetwork }) => setTargetNetwork);
useEffect(() => {
const newSelectedNetwork = scaffoldConfig.targetNetworks.find(targetNetwork => targetNetwork.id === chain?.id);
if (newSelectedNetwork && newSelectedNetwork.id !== targetNetwork.id) {
setTargetNetwork({ ...newSelectedNetwork, ...NETWORKS_EXTRA_DATA[newSelectedNetwork.id] });
}
}, [chain?.id, setTargetNetwork, targetNetwork.id]);
return useMemo(() => ({ targetNetwork }), [targetNetwork]);
}

View File

@@ -0,0 +1,115 @@
import { Hash, SendTransactionParameters, TransactionReceipt, WalletClient } from "viem";
import { Config, useWalletClient } from "wagmi";
import { getPublicClient } from "wagmi/actions";
import { SendTransactionMutate } from "wagmi/query";
import scaffoldConfig from "~~/scaffold.config";
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
import { AllowedChainIds, getBlockExplorerTxLink, notification } from "~~/utils/scaffold-eth";
import { TransactorFuncOptions, getParsedErrorWithAllAbis } from "~~/utils/scaffold-eth/contract";
type TransactionFunc = (
tx: (() => Promise<Hash>) | Parameters<SendTransactionMutate<Config, undefined>>[0],
options?: TransactorFuncOptions,
) => Promise<Hash | undefined>;
/**
* Custom notification content for TXs.
*/
const TxnNotification = ({ message, blockExplorerLink }: { message: string; blockExplorerLink?: string }) => {
return (
<div className={`flex flex-col ml-1 cursor-default`}>
<p className="my-0">{message}</p>
{blockExplorerLink && blockExplorerLink.length > 0 ? (
<a href={blockExplorerLink} target="_blank" rel="noreferrer" className="block link">
check out transaction
</a>
) : null}
</div>
);
};
/**
* Runs Transaction passed in to returned function showing UI feedback.
* @param _walletClient - Optional wallet client to use. If not provided, will use the one from useWalletClient.
* @returns function that takes in transaction function as callback, shows UI feedback for transaction and returns a promise of the transaction hash
*/
export const useTransactor = (_walletClient?: WalletClient): TransactionFunc => {
let walletClient = _walletClient;
const { data } = useWalletClient();
if (walletClient === undefined && data) {
walletClient = data;
}
const result: TransactionFunc = async (tx, options) => {
if (!walletClient) {
notification.error("Cannot access account");
console.error("⚡️ ~ file: useTransactor.tsx ~ error");
return;
}
let notificationId = null;
let transactionHash: Hash | undefined = undefined;
let transactionReceipt: TransactionReceipt | undefined;
let blockExplorerTxURL = "";
let chainId: number = scaffoldConfig.targetNetworks[0].id;
try {
chainId = await walletClient.getChainId();
// Get full transaction from public client
const publicClient = getPublicClient(wagmiConfig);
notificationId = notification.loading(<TxnNotification message="Awaiting for user confirmation" />);
if (typeof tx === "function") {
// Tx is already prepared by the caller
const result = await tx();
transactionHash = result;
} else if (tx != null) {
transactionHash = await walletClient.sendTransaction(tx as SendTransactionParameters);
} else {
throw new Error("Incorrect transaction passed to transactor");
}
notification.remove(notificationId);
blockExplorerTxURL = chainId ? getBlockExplorerTxLink(chainId, transactionHash) : "";
notificationId = notification.loading(
<TxnNotification message="Waiting for transaction to complete." blockExplorerLink={blockExplorerTxURL} />,
);
transactionReceipt = await publicClient.waitForTransactionReceipt({
hash: transactionHash,
confirmations: options?.blockConfirmations,
});
notification.remove(notificationId);
if (transactionReceipt.status === "reverted") throw new Error("Transaction reverted");
notification.success(
<TxnNotification message="Transaction completed successfully!" blockExplorerLink={blockExplorerTxURL} />,
{
icon: "🎉",
},
);
if (options?.onBlockConfirmation) options.onBlockConfirmation(transactionReceipt);
} catch (error: any) {
if (notificationId) {
notification.remove(notificationId);
}
console.error("⚡️ ~ file: useTransactor.ts ~ error", error);
const message = getParsedErrorWithAllAbis(error, chainId as AllowedChainIds);
// if receipt was reverted, show notification with block explorer link and return error
if (transactionReceipt?.status === "reverted") {
notification.error(<TxnNotification message={message} blockExplorerLink={blockExplorerTxURL} />);
throw error;
}
notification.error(message);
throw error;
}
return transactionHash;
};
return result;
};

6
packages/nextjs/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,29 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
devIndicators: false,
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
webpack: config => {
config.resolve.fallback = { fs: false, net: false, tls: false };
config.externals.push("pino-pretty", "lokijs", "encoding");
return config;
},
};
const isIpfs = process.env.NEXT_PUBLIC_IPFS_BUILD === "true";
if (isIpfs) {
nextConfig.output = "export";
nextConfig.trailingSlash = true;
nextConfig.images = {
unoptimized: true,
};
}
module.exports = nextConfig;

Some files were not shown because too many files have changed in this diff Show More