Initial commit with 🏗️ create-eth @ 2.0.4
This commit is contained in:
14
packages/nextjs/.env.example
Normal file
14
packages/nextjs/.env.example
Normal 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
38
packages/nextjs/.gitignore
vendored
Normal 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
|
||||
9
packages/nextjs/.prettierrc.js
Normal file
9
packages/nextjs/.prettierrc.js
Normal 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")],
|
||||
};
|
||||
56
packages/nextjs/app/api/config/price-variance/route.ts
Normal file
56
packages/nextjs/app/api/config/price-variance/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
56
packages/nextjs/app/api/config/skip-probability/route.ts
Normal file
56
packages/nextjs/app/api/config/skip-probability/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
88
packages/nextjs/app/api/ora-faucet/route.ts
Normal file
88
packages/nextjs/app/api/ora-faucet/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal 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";
|
||||
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal 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;
|
||||
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal 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;
|
||||
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
83
packages/nextjs/app/blockexplorer/page.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
38
packages/nextjs/app/debug/_components/ContractUI.tsx
Normal file
38
packages/nextjs/app/debug/_components/ContractUI.tsx
Normal 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} />;
|
||||
};
|
||||
71
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
71
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
packages/nextjs/app/debug/page.tsx
Normal file
28
packages/nextjs/app/debug/page.tsx
Normal 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;
|
||||
31
packages/nextjs/app/layout.tsx
Normal file
31
packages/nextjs/app/layout.tsx
Normal 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;
|
||||
16
packages/nextjs/app/not-found.tsx
Normal file
16
packages/nextjs/app/not-found.tsx
Normal 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're looking for doesn't exist.</p>
|
||||
<Link href="/" className="btn btn-primary">
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
packages/nextjs/app/optimistic/page.tsx
Normal file
117
packages/nextjs/app/optimistic/page.tsx
Normal 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;
|
||||
102
packages/nextjs/app/page.tsx
Normal file
102
packages/nextjs/app/page.tsx
Normal 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'll explore different
|
||||
oracle architectures and implementations. You'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'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;
|
||||
42
packages/nextjs/app/staking/page.tsx
Normal file
42
packages/nextjs/app/staking/page.tsx
Normal 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;
|
||||
26
packages/nextjs/app/whitelist/page.tsx
Normal file
26
packages/nextjs/app/whitelist/page.tsx
Normal 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;
|
||||
80
packages/nextjs/components/Footer.tsx
Normal file
80
packages/nextjs/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
packages/nextjs/components/Header.tsx
Normal file
115
packages/nextjs/components/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
58
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
58
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
42
packages/nextjs/components/SwitchTheme.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
9
packages/nextjs/components/ThemeProvider.tsx
Normal 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>;
|
||||
};
|
||||
33
packages/nextjs/components/TooltipInfo.tsx
Normal file
33
packages/nextjs/components/TooltipInfo.tsx
Normal 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;
|
||||
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
86
packages/nextjs/components/oracle/BucketCountdown.tsx
Normal file
86
packages/nextjs/components/oracle/BucketCountdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
74
packages/nextjs/components/oracle/BuyOraWidget.tsx
Normal file
74
packages/nextjs/components/oracle/BuyOraWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
84
packages/nextjs/components/oracle/ConfigSlider.tsx
Normal file
84
packages/nextjs/components/oracle/ConfigSlider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
152
packages/nextjs/components/oracle/EditableCell.tsx
Normal file
152
packages/nextjs/components/oracle/EditableCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
packages/nextjs/components/oracle/HighlightedCell.tsx
Normal file
41
packages/nextjs/components/oracle/HighlightedCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
200
packages/nextjs/components/oracle/NodeRow.tsx
Normal file
200
packages/nextjs/components/oracle/NodeRow.tsx
Normal 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 */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
665
packages/nextjs/components/oracle/NodesTable.tsx
Normal file
665
packages/nextjs/components/oracle/NodesTable.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
packages/nextjs/components/oracle/PriceWidget.tsx
Normal file
103
packages/nextjs/components/oracle/PriceWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
214
packages/nextjs/components/oracle/SelfNodeReporter.tsx
Normal file
214
packages/nextjs/components/oracle/SelfNodeReporter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
279
packages/nextjs/components/oracle/SelfNodeRow.tsx
Normal file
279
packages/nextjs/components/oracle/SelfNodeRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
177
packages/nextjs/components/oracle/StakingEditableCell.tsx
Normal file
177
packages/nextjs/components/oracle/StakingEditableCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
packages/nextjs/components/oracle/TimeAgo.tsx
Normal file
55
packages/nextjs/components/oracle/TimeAgo.tsx
Normal 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;
|
||||
43
packages/nextjs/components/oracle/TotalSlashedWidget.tsx
Normal file
43
packages/nextjs/components/oracle/TotalSlashedWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
packages/nextjs/components/oracle/optimistic/AssertedRow.tsx
Normal file
50
packages/nextjs/components/oracle/optimistic/AssertedRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
260
packages/nextjs/components/oracle/optimistic/AssertionModal.tsx
Normal file
260
packages/nextjs/components/oracle/optimistic/AssertionModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
packages/nextjs/components/oracle/optimistic/DisputedRow.tsx
Normal file
48
packages/nextjs/components/oracle/optimistic/DisputedRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
15
packages/nextjs/components/oracle/optimistic/EmptyRow.tsx
Normal file
15
packages/nextjs/components/oracle/optimistic/EmptyRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
62
packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx
Normal file
62
packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
21
packages/nextjs/components/oracle/optimistic/LoadingRow.tsx
Normal file
21
packages/nextjs/components/oracle/optimistic/LoadingRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
packages/nextjs/components/oracle/optimistic/ProposedRow.tsx
Normal file
52
packages/nextjs/components/oracle/optimistic/ProposedRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
75
packages/nextjs/components/oracle/optimistic/SettledRow.tsx
Normal file
75
packages/nextjs/components/oracle/optimistic/SettledRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
62
packages/nextjs/components/oracle/optimistic/TimeLeft.tsx
Normal file
62
packages/nextjs/components/oracle/optimistic/TimeLeft.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
packages/nextjs/components/oracle/types.ts
Normal file
69
packages/nextjs/components/oracle/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
67
packages/nextjs/components/oracle/whitelist/WhitelistRow.tsx
Normal file
67
packages/nextjs/components/oracle/whitelist/WhitelistRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
110
packages/nextjs/components/oracle/whitelist/WhitelistTable.tsx
Normal file
110
packages/nextjs/components/oracle/whitelist/WhitelistTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
140
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
140
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
4
packages/nextjs/components/scaffold-eth/index.tsx
Normal file
4
packages/nextjs/components/scaffold-eth/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./BlockieAvatar";
|
||||
export * from "./Faucet";
|
||||
export * from "./FaucetButton";
|
||||
export * from "./RainbowKitCustomConnectButton";
|
||||
9
packages/nextjs/contracts/deployedContracts.ts
Normal file
9
packages/nextjs/contracts/deployedContracts.ts
Normal 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;
|
||||
16
packages/nextjs/contracts/externalContracts.ts
Normal file
16
packages/nextjs/contracts/externalContracts.ts
Normal 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;
|
||||
32
packages/nextjs/eslint.config.mjs
Normal file
32
packages/nextjs/eslint.config.mjs
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
14
packages/nextjs/hooks/scaffold-eth/index.ts
Normal file
14
packages/nextjs/hooks/scaffold-eth/index.ts
Normal 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";
|
||||
40
packages/nextjs/hooks/scaffold-eth/useContractLogs.ts
Normal file
40
packages/nextjs/hooks/scaffold-eth/useContractLogs.ts
Normal 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;
|
||||
};
|
||||
19
packages/nextjs/hooks/scaffold-eth/useCopyToClipboard.ts
Normal file
19
packages/nextjs/hooks/scaffold-eth/useCopyToClipboard.ts
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
133
packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts
Normal file
133
packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
22
packages/nextjs/hooks/scaffold-eth/useNetworkColor.ts
Normal file
22
packages/nextjs/hooks/scaffold-eth/useNetworkColor.ts
Normal 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);
|
||||
};
|
||||
23
packages/nextjs/hooks/scaffold-eth/useOutsideClick.ts
Normal file
23
packages/nextjs/hooks/scaffold-eth/useOutsideClick.ts
Normal 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]);
|
||||
};
|
||||
65
packages/nextjs/hooks/scaffold-eth/useScaffoldContract.ts
Normal file
65
packages/nextjs/hooks/scaffold-eth/useScaffoldContract.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
292
packages/nextjs/hooks/scaffold-eth/useScaffoldEventHistory.ts
Normal file
292
packages/nextjs/hooks/scaffold-eth/useScaffoldEventHistory.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
194
packages/nextjs/hooks/scaffold-eth/useScaffoldWriteContract.ts
Normal file
194
packages/nextjs/hooks/scaffold-eth/useScaffoldWriteContract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
packages/nextjs/hooks/scaffold-eth/useSelectedNetwork.ts
Normal file
19
packages/nextjs/hooks/scaffold-eth/useSelectedNetwork.ts
Normal 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;
|
||||
}
|
||||
24
packages/nextjs/hooks/scaffold-eth/useTargetNetwork.ts
Normal file
24
packages/nextjs/hooks/scaffold-eth/useTargetNetwork.ts
Normal 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]);
|
||||
}
|
||||
115
packages/nextjs/hooks/scaffold-eth/useTransactor.tsx
Normal file
115
packages/nextjs/hooks/scaffold-eth/useTransactor.tsx
Normal 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
6
packages/nextjs/next-env.d.ts
vendored
Normal 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.
|
||||
29
packages/nextjs/next.config.ts
Normal file
29
packages/nextjs/next.config.ts
Normal 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
Reference in New Issue
Block a user