Files
sre-06-oracles/packages/hardhat/scripts/runStakingOracleBots.ts
2026-01-23 20:20:58 +07:00

689 lines
24 KiB
TypeScript

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<ReturnType<typeof hre.viem.getWalletClients>>[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<WalletClient[]> => {
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<number | null> => {
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<ReturnType<typeof hre.viem.getPublicClient>>,
stakingAddress: `0x${string}`,
stakingAbi: any,
bucketNumber: bigint,
nodeAddress: `0x${string}`,
fromBlock: bigint,
): Promise<number | null> => {
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<bigint>,
publicClient.readContract({
address: oraTokenAddress,
abi: oraTokenAbi,
functionName: "allowance",
args: [nodeAddress, address],
}) as Promise<bigint>,
]);
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);
});