import { HardhatRuntimeEnvironment } from "hardhat/types"; import hre from "hardhat"; import { sleep, getConfig } from "./utils"; import { fetchPriceFromUniswap } from "./fetchPriceFromUniswap"; import { parseEther } from "viem"; const oraTokenAbi = [ { type: "function", name: "approve", stateMutability: "nonpayable", inputs: [ { name: "spender", type: "address" }, { name: "amount", type: "uint256" }, ], outputs: [{ name: "", type: "bool" }], }, { type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ name: "owner", type: "address" }], outputs: [{ name: "", type: "uint256" }], }, { type: "function", name: "allowance", stateMutability: "view", inputs: [ { name: "owner", type: "address" }, { name: "spender", type: "address" }, ], outputs: [{ name: "", type: "uint256" }], }, { type: "function", name: "transfer", stateMutability: "nonpayable", inputs: [ { name: "to", type: "address" }, { name: "amount", type: "uint256" }, ], outputs: [{ name: "", type: "bool" }], }, ] as const; type WalletClient = Awaited>[number]; const normalizeNodeInfo = (raw: any) => { const zero = 0n; if (!raw) return { stakedAmount: zero, lastReportedBucket: zero, reportCount: zero, claimedReportCount: zero, firstBucket: zero, active: false, }; const get = (idx: number, name: string) => { const byName = raw[name]; const byIndex = Array.isArray(raw) ? raw[idx] : undefined; if (typeof byName === "bigint") return byName as bigint; if (typeof byIndex === "bigint") return byIndex as bigint; const val = byName ?? byIndex ?? 0; try { return BigInt(String(val)); } catch { return zero; } }; return { stakedAmount: get(0, "stakedAmount"), lastReportedBucket: get(1, "lastReportedBucket"), reportCount: get(2, "reportCount"), claimedReportCount: get(3, "claimedReportCount"), firstBucket: get(4, "firstBucket"), active: typeof raw?.active === "boolean" ? (raw.active as boolean) : Array.isArray(raw) && typeof raw[5] === "boolean" ? (raw[5] as boolean) : false, }; }; // Current base price used by the bot. Initialized once at start from Uniswap // and updated from on-chain contract prices thereafter. let currentPrice: bigint | null = null; const stringToBool = (value: string | undefined | null): boolean => { if (!value) return false; const normalized = value.toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; }; // Feature flag: enable automatic slashing when the AUTO_SLASH environment variable is truthy const AUTO_SLASH: boolean = stringToBool(process.env.AUTO_SLASH); const getStakingOracleDeployment = async (runtime: HardhatRuntimeEnvironment) => { const deployment = await runtime.deployments.get("StakingOracle"); return { address: deployment.address as `0x${string}`, abi: deployment.abi, deployedBlock: deployment.receipt?.blockNumber ? BigInt(deployment.receipt.blockNumber) : 0n, } as const; }; const getActiveNodeWalletClients = async ( runtime: HardhatRuntimeEnvironment, stakingAddress: `0x${string}`, stakingAbi: any, ): Promise => { const accounts = await runtime.viem.getWalletClients(); // Filter to only those that are registered (firstBucket != 0) const publicClient = await runtime.viem.getPublicClient(); const nodeClients: WalletClient[] = []; for (const client of accounts) { try { const rawNodeInfo = await publicClient.readContract({ address: stakingAddress, abi: stakingAbi, functionName: "nodes", args: [client.account.address], }); const node = normalizeNodeInfo(rawNodeInfo); if (node.firstBucket !== 0n && node.active) { nodeClients.push(client); } } catch { // ignore } } return nodeClients; }; const findNodeIndex = async ( runtime: HardhatRuntimeEnvironment, stakingAddress: `0x${string}`, stakingAbi: any, nodeAddress: `0x${string}`, ): Promise => { const publicClient = await runtime.viem.getPublicClient(); // Iterate indices until out-of-bounds revert try { const addresses = (await publicClient.readContract({ address: stakingAddress, abi: stakingAbi, functionName: "getNodeAddresses", args: [], })) as `0x${string}`[]; return addresses.findIndex(addr => addr.toLowerCase() === nodeAddress.toLowerCase()); } catch {} return null; }; const getReportIndexForNode = async ( publicClient: Awaited>, stakingAddress: `0x${string}`, stakingAbi: any, bucketNumber: bigint, nodeAddress: `0x${string}`, fromBlock: bigint, ): Promise => { try { const events = (await publicClient.getContractEvents({ address: stakingAddress, abi: stakingAbi, eventName: "PriceReported", fromBlock, toBlock: "latest", })) as any[]; const bucketEvents = events.filter((ev: any) => { const bucket = ev.args?.bucketNumber as bigint | undefined; return bucket !== undefined && bucket === bucketNumber; }); const idx = bucketEvents.findIndex((ev: any) => { const reporter = (ev.args?.node as string | undefined) ?? ""; return reporter.toLowerCase() === nodeAddress.toLowerCase(); }); return idx === -1 ? null : idx; } catch (error) { console.warn("Failed to compute report index:", (error as Error).message); } return null; }; const runCycle = async (runtime: HardhatRuntimeEnvironment) => { try { const { address, abi, deployedBlock } = await getStakingOracleDeployment(runtime); const publicClient = await runtime.viem.getPublicClient(); const allWalletClients = await runtime.viem.getWalletClients(); const blockNumber = await publicClient.getBlockNumber(); console.log(`\n[Block ${blockNumber}] Starting new oracle cycle...`); // Read current bucket window and bucket number const [bucketWindow, currentBucket] = await Promise.all([ publicClient .readContract({ address, abi, functionName: "BUCKET_WINDOW", args: [] }) .then(value => BigInt(String(value))), publicClient .readContract({ address, abi, functionName: "getCurrentBucketNumber", args: [] }) .then(value => BigInt(String(value))), ]); const previousBucket = currentBucket > 0n ? currentBucket - 1n : 0n; console.log(`BUCKET_WINDOW=${bucketWindow} | currentBucket=${currentBucket}`); // Update base price from previous bucket using the RECORDED MEDIAN (not an average of reports). // Fallback to contract's latest price, then to previous cached value. try { const previous = previousBucket; if (previous > 0n) { try { // `getPastPrice(bucket)` returns the recorded median for that bucket (0 if not recorded yet). const pastMedian = await publicClient.readContract({ address, abi, functionName: "getPastPrice", args: [previous], }); const median = BigInt(String(pastMedian)); if (median > 0n) { currentPrice = median; } } catch { // ignore and fall back } if (currentPrice === null) { // Fallback to on-chain latest average (previous bucket average) try { const onchain = await publicClient.readContract({ address, abi, functionName: "getLatestPrice", args: [] }); currentPrice = BigInt(String(onchain)); } catch { // keep prior currentPrice } } } } catch { // keep prior currentPrice } // Load config once per cycle so runtime edits to the config file are picked up const cfg = getConfig(); // 1) Reporting: each node only once per bucket const nodeWalletClients = await getActiveNodeWalletClients(runtime, address, abi); // Ensure we have an initial price (set once at startup in run()) if (currentPrice === null) { currentPrice = await fetchPriceFromUniswap(); } const reportTxHashes: `0x${string}`[] = []; for (const client of nodeWalletClients) { try { const rawNodeInfo = await publicClient.readContract({ address, abi, functionName: "nodes", args: [client.account.address], }); const node = normalizeNodeInfo(rawNodeInfo); if (node.lastReportedBucket !== currentBucket) { // Determine node config (probability to skip and variance) const nodeCfg = cfg.NODE_CONFIGS[client.account.address.toLowerCase()] || cfg.NODE_CONFIGS.default; const skipProb = Number(nodeCfg.PROBABILITY_OF_SKIPPING_REPORT ?? 0); if (Math.random() < skipProb) { console.log(`Skipping report (by probability) for ${client.account.address}`); continue; } // Compute deviated price as integer math using parts-per-million (ppm) const variancePpm = Math.floor((Number(nodeCfg.PRICE_VARIANCE) || 0) * 1_000_000); const randomPpm = variancePpm > 0 ? Math.floor(Math.random() * (variancePpm * 2 + 1)) - variancePpm : 0; const basePrice = currentPrice!; // derived from previous bucket excluding outliers const delta = (basePrice * BigInt(randomPpm)) / 1_000_000n; const priceToReport = basePrice + delta; console.log( `Reporting price for node ${client.account.address} in bucket ${currentBucket} (price=${priceToReport})...`, ); const txHash = await client.writeContract({ address, abi, functionName: "reportPrice", args: [priceToReport], }); reportTxHashes.push(txHash as `0x${string}`); } } catch (err) { console.warn(`Skipping report for ${client.account.address}:`, (err as Error).message); } } // Wait for report transactions to be mined so subsequent reads (claiming) see the updated state. if (reportTxHashes.length > 0) { try { await Promise.all(reportTxHashes.map(hash => publicClient.waitForTransactionReceipt({ hash } as any))); } catch (err) { // If waiting fails, continue — claims will be attempted anyway but may not see the latest reports. console.warn("Error while waiting for report tx receipts:", (err as Error).message); } } // 2) Finalize median automatically when quorum is reached // You can only finalize buckets strictly in the past, so we finalize the *previous* bucket (current - 1). if (previousBucket > 0n) { let medianAlreadyRecorded = false; try { const median = await publicClient.readContract({ address, abi, functionName: "getPastPrice", args: [previousBucket], }); medianAlreadyRecorded = BigInt(String(median)) > 0n; } catch { medianAlreadyRecorded = false; } if (!medianAlreadyRecorded) { try { const activeNodeAddresses = (await publicClient.readContract({ address, abi, functionName: "getNodeAddresses", args: [], })) as `0x${string}`[]; const reportStatuses = await Promise.all( activeNodeAddresses.map(async nodeAddr => { try { const [price] = (await publicClient.readContract({ address, abi, functionName: "getSlashedStatus", args: [nodeAddr, previousBucket], })) as [bigint, boolean]; return price; } catch { return 0n; } }), ); const reportedCount = reportStatuses.reduce((acc, price) => acc + (price > 0n ? 1n : 0n), 0n); const requiredReports = activeNodeAddresses.length === 0 ? 0n : (2n * BigInt(activeNodeAddresses.length) + 2n) / 3n; if (activeNodeAddresses.length === 0) { console.log("No active nodes; skipping recordBucketMedian evaluation."); } else if (reportedCount >= requiredReports) { const finalizer = allWalletClients[0]; try { await finalizer.writeContract({ address, abi, functionName: "recordBucketMedian", args: [previousBucket], }); console.log( `Recorded median for bucket ${previousBucket} (reports ${reportedCount}/${requiredReports}).`, ); } catch (err) { console.warn(`Failed to record median for bucket ${previousBucket}:`, (err as Error).message); } } else { console.log( `Skipping median recording for bucket ${previousBucket}; only ${reportedCount}/${requiredReports} reports.`, ); } } catch (err) { console.warn("Unable to evaluate automatic recordBucketMedian:", (err as Error).message); } } } // 3) Slashing: if previous bucket had outliers if (AUTO_SLASH) { try { const outliers = (await publicClient.readContract({ address, abi, functionName: "getOutlierNodes", args: [previousBucket], })) as `0x${string}`[]; if (outliers.length > 0) { console.log(`Found ${outliers.length} outliers in bucket ${previousBucket}, attempting to slash...`); // Use the first wallet (deployer) to slash const slasher = allWalletClients[0]; for (const nodeAddr of outliers) { const index = await findNodeIndex(runtime, address, abi, nodeAddr); if (index === null) { console.warn(`Index not found for node ${nodeAddr}, skipping slashing.`); continue; } const reportIndex = await getReportIndexForNode( publicClient, address, abi, previousBucket, nodeAddr, deployedBlock, ); if (reportIndex === null) { console.warn(`Report index not found for node ${nodeAddr}, skipping slashing.`); continue; } try { await slasher.writeContract({ address, abi, functionName: "slashNode", args: [nodeAddr, previousBucket, BigInt(reportIndex), BigInt(index)], }); console.log( `Slashed node ${nodeAddr} for bucket ${previousBucket} at indices report=${reportIndex}, node=${index}`, ); } catch (err) { console.warn(`Failed to slash ${nodeAddr}:`, (err as Error).message); } } } } catch (err) { // getOutlierNodes may revert for small sample sizes (e.g., 0 or 1 report) console.log(`Skipping slashing check for bucket ${previousBucket}:`, (err as Error).message); } } else { // Auto-slash disabled by flag console.log(`Auto-slash disabled; skipping slashing for bucket ${previousBucket}`); } // 4) Rewards: claim when there are unclaimed reports // Wait a couple seconds after reports have been mined before claiming console.log("Waiting 2s before claiming rewards..."); await sleep(2000); for (const client of nodeWalletClients) { try { const rawNodeInfo = await publicClient.readContract({ address, abi, functionName: "nodes", args: [client.account.address], }); const node = normalizeNodeInfo(rawNodeInfo); if (node.reportCount > node.claimedReportCount) { await client.writeContract({ address, abi, functionName: "claimReward", args: [] }); console.log(`Claimed rewards for ${client.account.address}`); } } catch (err) { console.warn(`Failed to claim rewards for ${client.account.address}:`, (err as Error).message); } } } catch (error) { console.error("Error in oracle cycle:", error); } }; const run = async () => { console.log("Starting oracle bot system..."); // Fetch Uniswap price once at startup; subsequent cycles will base price on on-chain reports currentPrice = await fetchPriceFromUniswap(); console.log(`Initial base price from Uniswap: ${currentPrice}`); // Spin up nodes (fund + approve + register) for local testing if they aren't registered yet. try { const { address, abi } = await getStakingOracleDeployment(hre); const publicClient = await hre.viem.getPublicClient(); const accounts = await hre.viem.getWalletClients(); // Mirror deploy script: use accounts[1..10] as oracle nodes const nodeAccounts = accounts.slice(1, 11); const deployerClient = accounts[0]; const [minimumStake, oraTokenAddress] = await Promise.all([ publicClient.readContract({ address, abi, functionName: "MINIMUM_STAKE", args: [] }).then(v => BigInt(String(v))), publicClient .readContract({ address, abi, functionName: "oracleToken", args: [], }) .then(v => v as unknown as `0x${string}`), ]); // Default bot stake for local simulations (keep it small so it matches the new UX expectations) const defaultStake = parseEther("500"); const stakeAmount = minimumStake > defaultStake ? minimumStake : defaultStake; // Build an idempotent setup plan based on current on-chain state (so restarts resume cleanly). const snapshots = await Promise.all( nodeAccounts.map(async nodeClient => { const nodeAddress = nodeClient.account.address; const [rawNodeInfo, balance, allowance] = await Promise.all([ publicClient .readContract({ address, abi, functionName: "nodes", args: [nodeAddress] }) .catch(() => null as any), publicClient.readContract({ address: oraTokenAddress, abi: oraTokenAbi, functionName: "balanceOf", args: [nodeAddress], }) as Promise, publicClient.readContract({ address: oraTokenAddress, abi: oraTokenAbi, functionName: "allowance", args: [nodeAddress, address], }) as Promise, ]); const node = normalizeNodeInfo(rawNodeInfo); const effectiveStake = node.active ? await publicClient .readContract({ address, abi, functionName: "getEffectiveStake", args: [nodeAddress] }) .then(v => BigInt(String(v))) .catch(() => 0n) : 0n; return { nodeClient, nodeAddress, node, effectiveStake, balance, allowance }; }), ); const transfers: { to: `0x${string}`; amount: bigint }[] = []; const perNodeActions: { nodeClient: WalletClient; nodeAddress: `0x${string}`; approveAmount: bigint; kind: "register" | "addStake" | "none"; amount: bigint; note: string; }[] = []; for (const snap of snapshots) { const { nodeClient, nodeAddress, node, effectiveStake, balance, allowance } = snap; if (node.active) { if (effectiveStake < minimumStake) { const needed = minimumStake - effectiveStake; const transferAmount = balance < needed ? needed - balance : 0n; if (transferAmount > 0n) transfers.push({ to: nodeAddress, amount: transferAmount }); const approveAmount = allowance < needed ? needed : 0n; perNodeActions.push({ nodeClient, nodeAddress, approveAmount, kind: "addStake", amount: needed, note: `top up effectiveStake=${effectiveStake} by ${needed}`, }); } else { perNodeActions.push({ nodeClient, nodeAddress, approveAmount: 0n, kind: "none", amount: 0n, note: "already active (no action)", }); } continue; } // Inactive -> fund/approve/register. On restart, we only do the missing pieces. const transferAmount = balance < stakeAmount ? stakeAmount - balance : 0n; if (transferAmount > 0n) transfers.push({ to: nodeAddress, amount: transferAmount }); const approveAmount = allowance < stakeAmount ? stakeAmount : 0n; perNodeActions.push({ nodeClient, nodeAddress, approveAmount, kind: "register", amount: stakeAmount, note: `register with stake=${stakeAmount}`, }); } // 1) Fund nodes in one burst from deployer using nonce chaining. if (transfers.length > 0) { const deployerNonce = await publicClient.getTransactionCount({ address: deployerClient.account.address }); const transferTxs: `0x${string}`[] = []; console.log(`Funding ${transfers.length} node(s) from deployer (burst)...`); for (const [i, t] of transfers.entries()) { const tx = await deployerClient.writeContract({ address: oraTokenAddress, abi: oraTokenAbi, functionName: "transfer", nonce: deployerNonce + i, args: [t.to, t.amount], }); transferTxs.push(tx as `0x${string}`); } await Promise.all(transferTxs.map(hash => publicClient.waitForTransactionReceipt({ hash }))); console.log("Funding burst mined."); } // 2) For each node, chain approve -> (register|addStake) with explicit nonces, then wait for all receipts once. const nodeNonces = await Promise.all( perNodeActions.map(a => publicClient.getTransactionCount({ address: a.nodeAddress })), ); const nodeTxs: `0x${string}`[] = []; for (const [idx, action] of perNodeActions.entries()) { const { nodeClient, nodeAddress, approveAmount, kind, amount, note } = action; let nonce = nodeNonces[idx]; if (kind === "none") { console.log(`Node ${nodeAddress}: ${note}`); continue; } console.log(`Node ${nodeAddress}: ${note}`); if (approveAmount > 0n) { const tx = await nodeClient.writeContract({ address: oraTokenAddress, abi: oraTokenAbi, functionName: "approve", nonce, args: [address, approveAmount], }); nodeTxs.push(tx as `0x${string}`); nonce += 1; } if (kind === "register") { const tx = await nodeClient.writeContract({ address, abi, functionName: "registerNode", nonce, args: [amount], }); nodeTxs.push(tx as `0x${string}`); } else if (kind === "addStake") { const tx = await nodeClient.writeContract({ address, abi, functionName: "addStake", nonce, args: [amount], }); nodeTxs.push(tx as `0x${string}`); } } if (nodeTxs.length > 0) { console.log(`Waiting for ${nodeTxs.length} node tx(s) to be mined...`); await Promise.all(nodeTxs.map(hash => publicClient.waitForTransactionReceipt({ hash }))); console.log("Node setup txs mined."); } } catch (err) { console.warn("Node registration step failed:", (err as Error).message); } while (true) { await runCycle(hre); await sleep(12000); } }; run().catch(error => { console.error("Fatal error in oracle bot system:", error); process.exit(1); }); // Handle process termination signals process.on("SIGINT", async () => { console.log("\nReceived SIGINT (Ctrl+C). Cleaning up..."); process.exit(0); }); process.on("SIGTERM", async () => { console.log("\nReceived SIGTERM. Cleaning up..."); process.exit(0); }); // Handle uncaught exceptions process.on("uncaughtException", async error => { console.error("Uncaught Exception:", error); process.exit(1); }); // Handle unhandled promise rejections process.on("unhandledRejection", async (reason, promise) => { console.error("Unhandled Rejection at:", promise, "reason:", reason); process.exit(1); });