Initial commit with 🏗️ create-eth @ 2.0.4
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user