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 }) => (
); const NoNodesRow = ({ colSpan = 5 }: { colSpan?: number }) => ( No nodes found ); 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([]); 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 ( ); }; 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(null); const [internalSelectedBucket, setInternalSelectedBucket] = useState("current"); const selectedBucket = externalSelectedBucket ?? internalSelectedBucket; const isViewingCurrentBucket = selectedBucket === "current"; const targetBucket = useMemo(() => { // 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(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(); (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 => { 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 ( <>

Oracle Nodes

Min Stake: {minimumStakeFormatted} ORA
{/* Slash button near navigation (left of left arrow) */} {selectedBucket !== "current" && } {/* Previous (<) */} {/* Current selected bucket label (non-clickable) */} {selectedBucket === "current" ? currentBucket !== undefined ? currentBucket.toString() : "..." : (selectedBucket as bigint).toString()} {/* Next (>) */} {/* Go to Current button */} {/* Inline settings toggle */}
{connectedAddress && !isSelfRegistered ? ( ) : ( <> )}
{showInlineSettings ? ( <> ) : selectedBucket === "current" ? ( <> ) : ( <> )} {!showInlineSettings && ( <> {selectedBucket === "current" ? ( isSelfRegistered || hasEverRegisteredSelf ? ( ) : null ) : isSelfRegistered || hasEverRegisteredSelf ? ( ) : null} {isSelfRegistered && ( )} )} {isLoadingRegistered || isLoadingExited ? ( ) : filteredNodeAddresses.length === 0 ? ( ) : ( filteredNodeAddresses.map((address: string, index: number) => ( )) )}
Node Address Node SettingsNode Address Stake Rewards Reported Price
Deviation
Node Address Reported Price
Deviation
Simulation Script Nodes
{editingNode && (
)} ); };