diff --git a/Dockerfile b/Dockerfile index 1d16d21..435b6dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM node:20-alpine AS builder -RUN apk add --no-cache tini +RUN apk upgrade --no-cache && apk add --no-cache tini WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci @@ -8,7 +8,8 @@ COPY src ./src RUN NODE_OPTIONS=--max-old-space-size=4096 npx tsc RUN npm prune --production -FROM dhi.io/node:20-alpine3.23 +FROM node:20-alpine +RUN apk upgrade --no-cache WORKDIR /app COPY --from=builder /sbin/tini /sbin/tini COPY --from=builder /app/dist ./dist diff --git a/README.md b/README.md index c6b33f1..0badb5b 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ MNEMONIC="..." vbd-relayer **Pre-built image** (after the repo has been pushed to `main` on GitHub, image is built at GHCR; use tag `latest` or `main`): ```bash -docker run -it --env MNEMONIC="your twelve word mnemonic phrase here" ghcr.io/vechain/vebetterdao-relayer-node:latest +docker run -it --name vbd-relayer --env MNEMONIC="your twelve word mnemonic phrase here" ghcr.io/vechain/vebetterdao-relayer-node:latest ``` **Build locally** (works without publishing): @@ -95,7 +95,7 @@ docker run -it --env MNEMONIC="your twelve word mnemonic phrase here" ghcr.io/ve git clone https://github.com/vechain/vebetterdao-relayer-node.git cd vebetterdao-relayer-node docker build -t vbd-relayer . -docker run -it --env MNEMONIC="your twelve word mnemonic phrase here" vbd-relayer +docker run -it --name vbd-relayer --env MNEMONIC="your twelve word mnemonic phrase here" vbd-relayer ``` ### Alternative: Docker Compose with Secrets (recommended) diff --git a/docker-compose.yml b/docker-compose.yml index cb65847..e79a15c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: relayer: + container_name: vbd-relayer # image: ghcr.io/vechain/vebetterdao-relayer-node:latest # uncomment to pull the image from GitHub Container Registry build: . secrets: diff --git a/package.json b/package.json index ac94f3d..8727191 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vechain/vebetterdao-relayer-node", - "version": "0.0.3", + "version": "0.1.0", "description": "Standalone relayer node for VeBetterDAO auto-voting and reward claiming", "main": "dist/index.js", "license": "MIT", diff --git a/src/block-timestamps.ts b/src/block-timestamps.ts new file mode 100644 index 0000000..d825e42 --- /dev/null +++ b/src/block-timestamps.ts @@ -0,0 +1,27 @@ +const blockTimestampCache = new Map() + +export async function getBlockTimestamp( + nodeUrl: string, + blockNumber: number, +): Promise { + if (blockNumber <= 0) return null + + const cacheKey = `${nodeUrl}:${blockNumber}` + if (blockTimestampCache.has(cacheKey)) { + return blockTimestampCache.get(cacheKey) ?? null + } + + try { + const res = await fetch(`${nodeUrl.replace(/\/$/, "")}/blocks/${blockNumber}`, { + cache: "no-store", + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as { timestamp?: number } + const timestamp = typeof data.timestamp === "number" ? data.timestamp : null + blockTimestampCache.set(cacheKey, timestamp) + return timestamp + } catch { + blockTimestampCache.set(cacheKey, null) + return null + } +} diff --git a/src/contracts.ts b/src/contracts.ts index f8fd803..12fc218 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -5,7 +5,6 @@ import { VoterRewards__factory, RelayerRewardsPool__factory, } from "@vechain/vebetterdao-contracts/typechain-types" -import { NetworkConfig, RelayerSummary } from "./types" const xavAbi = ABIContract.ofAbi(XAllocationVoting__factory.abi) const rrpAbi = ABIContract.ofAbi(RelayerRewardsPool__factory.abi) @@ -13,6 +12,8 @@ const vrAbi = ABIContract.ofAbi(VoterRewards__factory.abi) const CALL_RETRIES = 3 const CALL_RETRY_MS = 500 +const STATUS_CHECK_BATCH = 50 +const STATUS_CHECK_DELAY_MS = 0 async function call(thor: ThorClient, address: string, abi: any, method: string, args: any[] = []): Promise { for (let attempt = 1; attempt <= CALL_RETRIES; attempt++) { @@ -74,6 +75,36 @@ export async function hasVoted(thor: ThorClient, addr: string, roundId: number, return Boolean(r[0]) } +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function getVotedUsersForRound( + thor: ThorClient, + contractAddress: string, + roundId: number, + users: string[], +): Promise> { + const voted = new Set() + + for (let i = 0; i < users.length; i += STATUS_CHECK_BATCH) { + const chunk = users.slice(i, i + STATUS_CHECK_BATCH) + const checks = await Promise.all(chunk.map((user) => hasVoted(thor, contractAddress, roundId, user))) + + for (let j = 0; j < chunk.length; j++) { + if (checks[j]) { + voted.add(chunk[j].toLowerCase()) + } + } + + if (STATUS_CHECK_DELAY_MS > 0 && i + STATUS_CHECK_BATCH < users.length) { + await delay(STATUS_CHECK_DELAY_MS) + } + } + + return voted +} + // ── RelayerRewardsPool reads ──────────────────────────────── export async function getRegisteredRelayers(thor: ThorClient, addr: string): Promise { @@ -96,6 +127,11 @@ export async function getClaimableRewards(thor: ThorClient, addr: string, relaye return BigInt(r[0]) } +export async function getRelayerFee(thor: ThorClient, addr: string, roundId: number, user: string): Promise { + const r = await call(thor, addr, vrAbi, "getRelayerFee", [roundId, user]) + return BigInt(r[0] ?? 0) +} + export async function isRewardClaimable(thor: ThorClient, addr: string, roundId: number): Promise { const r = await call(thor, addr, rrpAbi, "isRewardClaimable", [roundId]) return Boolean(r[0]) @@ -161,6 +197,28 @@ export async function getRelayerWeightedActions(thor: ThorClient, addr: string, return BigInt(r[0]) } +export async function estimateRewardPoolForRound( + thor: ThorClient, + voterRewardsAddress: string, + roundId: number, + votedUsers: Set, +): Promise { + if (votedUsers.size === 0) return 0n + + let totalEstimatedFees = 0n + const users = [...votedUsers] + + for (let i = 0; i < users.length; i += STATUS_CHECK_BATCH) { + const chunk = users.slice(i, i + STATUS_CHECK_BATCH) + const fees = await Promise.all(chunk.map((user) => getRelayerFee(thor, voterRewardsAddress, roundId, user))) + for (const fee of fees) { + totalEstimatedFees += fee + } + } + + return totalEstimatedFees +} + // ── Event fetching: auto-voting users ─────────────────────── const MAX_EVENTS = 1000 @@ -333,111 +391,3 @@ export async function getAlreadyClaimedForRound( return claimed } - -// ── Full summary fetch ────────────────────────────────────── - -export async function fetchSummary( - thor: ThorClient, - config: NetworkConfig, - relayerAddress: string, -): Promise { - const xav = config.xAllocationVotingAddress - const rrp = config.relayerRewardsPoolAddress - - const currentRoundId = await getCurrentRoundId(thor, xav) - const previousRoundId = currentRoundId > 1 ? currentRoundId - 1 : 0 - - const best = await thor.blocks.getBestBlockCompressed() - const latestBlock = best?.number ?? 0 - - const [ - roundSnapshot, - roundDeadline, - active, - autoVotingUsers, - totalVoters, - totalVotes, - registeredRelayers, - isReg, - voteWeight, - claimWeight, - feePercentage, - feeDenominator, - feeCap, - earlyAccessBlocks, - currentTotalRewards, - currentRelayerClaimable, - currentTotalActions, - currentCompletedWeighted, - currentTotalWeighted, - currentMissedUsers, - currentRelayerActions, - currentRelayerWeighted, - previousTotalRewards, - previousRelayerClaimable, - previousRewardClaimable, - previousRelayerActions, - ] = await Promise.all([ - getRoundSnapshot(thor, xav, currentRoundId), - getRoundDeadline(thor, xav, currentRoundId), - isRoundActive(thor, xav, currentRoundId), - getTotalAutoVotingUsersAtRoundStart(thor, xav), - getTotalVoters(thor, xav, currentRoundId), - getTotalVotes(thor, xav, currentRoundId), - getRegisteredRelayers(thor, rrp), - isRegisteredRelayer(thor, rrp, relayerAddress), - getVoteWeight(thor, rrp), - getClaimWeight(thor, rrp), - getRelayerFeePercentage(thor, rrp), - getRelayerFeeDenominator(thor, rrp), - getFeeCap(thor, rrp), - getEarlyAccessBlocks(thor, rrp), - getTotalRewards(thor, rrp, currentRoundId), - getClaimableRewards(thor, rrp, relayerAddress, currentRoundId), - getTotalActions(thor, rrp, currentRoundId), - getCompletedWeightedActions(thor, rrp, currentRoundId), - getTotalWeightedActions(thor, rrp, currentRoundId), - getMissedAutoVotingUsersCount(thor, rrp, currentRoundId), - getRelayerActions(thor, rrp, relayerAddress, currentRoundId), - getRelayerWeightedActions(thor, rrp, relayerAddress, currentRoundId), - previousRoundId > 0 ? getTotalRewards(thor, rrp, previousRoundId) : Promise.resolve(0n), - previousRoundId > 0 ? getClaimableRewards(thor, rrp, relayerAddress, previousRoundId) : Promise.resolve(0n), - previousRoundId > 0 ? isRewardClaimable(thor, rrp, previousRoundId) : Promise.resolve(false), - previousRoundId > 0 ? getRelayerActions(thor, rrp, relayerAddress, previousRoundId) : Promise.resolve(0n), - ]) - - return { - network: config.name, - nodeUrl: config.nodeUrl, - relayerAddress, - isRegistered: isReg, - registeredRelayers, - currentRoundId, - roundSnapshot, - roundDeadline, - isRoundActive: active, - latestBlock, - autoVotingUsers, - totalVoters, - totalVotes, - voteWeight, - claimWeight, - feePercentage, - feeDenominator, - feeCap, - earlyAccessBlocks, - currentTotalRewards, - currentRelayerClaimable, - currentTotalActions, - currentCompletedWeighted, - currentTotalWeighted, - currentMissedUsers, - currentRelayerActions, - currentRelayerWeighted, - previousRoundId, - previousTotalRewards, - previousRelayerClaimable, - previousRewardClaimable, - previousRelayerActions, - } -} diff --git a/src/display.ts b/src/display.ts index 910fc1e..b7481bd 100644 --- a/src/display.ts +++ b/src/display.ts @@ -1,151 +1,477 @@ -import chalk from "chalk" -import { RelayerSummary, CycleResult } from "./types" +import chalk from "chalk"; +import { RelayerSummary, CycleResult } from "./types"; function formatB3TR(wei: bigint): string { - const whole = wei / 10n ** 18n - const frac = (wei % 10n ** 18n) / 10n ** 16n - return `${whole}.${frac.toString().padStart(2, "0")} B3TR` + const whole = wei / 10n ** 18n; + const frac = (wei % 10n ** 18n) / 10n ** 16n; + return `${whole.toLocaleString("en-US")}.${frac + .toString() + .padStart(2, "0")} B3TR`; } function formatVOT3(wei: bigint): string { - const whole = wei / 10n ** 18n - const frac = (wei % 10n ** 18n) / 10n ** 16n - return `${whole}.${frac.toString().padStart(2, "0")} VOT3` + const whole = wei / 10n ** 18n; + const frac = (wei % 10n ** 18n) / 10n ** 16n; + return `${whole.toLocaleString("en-US")}.${frac + .toString() + .padStart(2, "0")} VOT3`; +} + +function formatVTHO(wei: bigint): string { + const whole = wei / 10n ** 18n; + const frac = (wei % 10n ** 18n) / 10n ** 16n; + return `${whole.toLocaleString("en-US")}.${frac + .toString() + .padStart(2, "0")} VTHO`; } function shortAddr(addr: string): string { - return addr.slice(0, 6) + "..." + addr.slice(-4) + return addr.slice(0, 6) + "..." + addr.slice(-4); } function pct(num: bigint, den: bigint): string { - if (den === 0n) return "—" - return ((Number(num) / Number(den)) * 100).toFixed(2) + "%" + if (den === 0n) return "—"; + return ((Number(num) / Number(den)) * 100).toFixed(2) + "%"; } function stripAnsi(str: string): number { - return str.replace(/\x1b\[[0-9;]*m/g, "").length + return str.replace(/\x1b\[[0-9;]*m/g, "").length; } -function pad(left: string, right: string, width: number = 62): string { - const gap = width - stripAnsi(left) - stripAnsi(right) - return left + " ".repeat(Math.max(1, gap)) + right +function pad(left: string, right: string, width: number = 72): string { + const gap = width - stripAnsi(left) - stripAnsi(right); + return left + " ".repeat(Math.max(1, gap)) + right; } function heading(text: string): string { - return chalk.bold.cyan(text) + return chalk.bold.cyan(text); } function label(text: string): string { - return chalk.dim(text) -} - -export function renderSummary(s: RelayerSummary): string { - const out: string[] = [] - - out.push("") - out.push(heading(" VeBetterDAO Relayer Node")) - out.push(chalk.dim(" " + "─".repeat(60))) - out.push("") - - // Node info - const regStatus = s.isRegistered ? chalk.green("Registered") : chalk.red("Not registered") - out.push(" " + pad(label("Network") + " " + chalk.white.bold(s.network), label("Block") + " " + chalk.white(s.latestBlock.toLocaleString()))) - out.push(" " + pad(label("Node") + " " + chalk.gray(new URL(s.nodeUrl).hostname), "")) - out.push(" " + pad(label("Address") + " " + chalk.yellow(shortAddr(s.relayerAddress)), regStatus)) - - out.push("") - out.push(chalk.dim(" " + "─".repeat(60))) - out.push("") - - // Round info - const roundStatus = s.isRoundActive ? chalk.green("Active") : chalk.dim("Ended") - out.push(" " + heading(`Round #${s.currentRoundId}`) + " " + roundStatus) - out.push(" " + pad(label("Snapshot") + " " + chalk.white(s.roundSnapshot.toString()), label("Deadline") + " " + chalk.white(s.roundDeadline.toString()))) - out.push(" " + pad(label("Auto-voters") + " " + chalk.white.bold(s.autoVotingUsers.toString()), label("Relayers") + " " + chalk.white.bold(s.registeredRelayers.length.toString()))) - out.push(" " + pad(label("Voters") + " " + chalk.white(s.totalVoters.toString()), label("Total") + " " + chalk.cyan(formatVOT3(s.totalVotes)))) - - out.push("") - out.push(chalk.dim(" " + "─".repeat(60))) - out.push("") - - // Fee config - const feeStr = s.feeDenominator > 0n ? pct(s.feePercentage, s.feeDenominator) : "—" - out.push(" " + pad(label("Vote Weight") + " " + chalk.white.bold(s.voteWeight.toString()), label("Claim Weight") + " " + chalk.white.bold(s.claimWeight.toString()))) - out.push(" " + pad(label("Fee") + " " + chalk.yellow(feeStr), label("Cap") + " " + chalk.yellow(formatB3TR(s.feeCap)))) - out.push(" " + pad(label("Early Access") + " " + chalk.white(s.earlyAccessBlocks.toString()) + chalk.dim(" blocks"), "")) - - out.push("") - out.push(chalk.dim(" " + "─".repeat(60))) - out.push("") - - // This round stats - out.push(" " + heading("This Round")) - const completionPct = s.currentTotalWeighted > 0n - ? pct(s.currentCompletedWeighted, s.currentTotalWeighted) - : "—" - const completionColor = s.currentTotalWeighted > 0n && s.currentCompletedWeighted >= s.currentTotalWeighted - ? chalk.green : chalk.yellow - out.push(" " + pad( - label("Completion") + " " + completionColor(completionPct), - label("Missed") + " " + (s.currentMissedUsers > 0n ? chalk.red(s.currentMissedUsers.toString()) : chalk.green(s.currentMissedUsers.toString())), - )) - out.push(" " + pad( - label("Pool") + " " + chalk.green(formatB3TR(s.currentTotalRewards)), - label("Your share") + " " + chalk.greenBright.bold(formatB3TR(s.currentRelayerClaimable)), - )) - out.push(" " + pad( - label("Actions") + " " + chalk.white(s.currentRelayerActions.toString()) + chalk.dim(" (wt: ") + chalk.white(s.currentRelayerWeighted.toString()) + chalk.dim(")"), - label("Total") + " " + chalk.white(s.currentTotalActions.toString()), - )) - - // Previous round + return chalk.dim(text); +} + +function formatProgress(done: number, total: number): string { + return `${done.toLocaleString("en-US")}/${total.toLocaleString("en-US")}`; +} + +function formatPercent(done: number, total: number): string { + if (total <= 0) return "100.00%"; + return `${((done / total) * 100).toFixed(2)}%`; +} + +function phaseTag(phase: CycleResult["phase"]): string { + return phase === "vote" + ? chalk.bgCyan.black(" VOTE ") + : chalk.bgMagenta.black(" CLAIM "); +} + +function formatBlockTime(timestamp: number | null): string { + if (timestamp == null) return "—"; + return new Date(timestamp * 1000).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function renderSummary(s: RelayerSummary, version?: string): string { + const out: string[] = []; + const totalPlannedActions = + s.currentEligibleVoters + s.previousEligibleClaims; + const completedActions = s.currentVotedCount + s.previousClaimedCount; + const completionPct = formatPercent(completedActions, totalPlannedActions); + const completionDone = + totalPlannedActions === 0 || completedActions >= totalPlannedActions; + const completionColor = completionDone ? chalk.green : chalk.yellow; + const currentWeightPct = + s.currentCompletedWeighted > 0n + ? pct(s.currentRelayerWeighted, s.currentCompletedWeighted) + : "—"; + const previousWeightPct = + s.previousCompletedWeighted > 0n + ? pct(s.previousRelayerWeighted, s.previousCompletedWeighted) + : "—"; + const currentPoolLabel = s.isRoundActive ? "Est. Reward Pool" : "Pool"; + const currentPoolValue = s.isRoundActive + ? s.currentEstimatedPool + : s.currentTotalRewards; + const earlyAccessLabel = + s.currentEarlyAccessRemainingBlocks > 0 + ? `${s.currentEarlyAccessRemainingBlocks.toLocaleString("en-US")} blocks` + : "ended"; + const previousRewardsLine = + s.previousRelayerClaimable > 0n + ? `${formatB3TR(s.previousRelayerClaimable)} ${chalk.dim( + "(need claiming)", + )}` + : s.previousRelayerClaimed > 0n + ? `${formatB3TR(s.previousRelayerClaimed)} ${chalk.dim("(claimed)")}` + : formatB3TR(0n); + + out.push(""); + out.push( + heading(` VeBetterDAO Relayer Node${version ? ` v${version}` : ""}`), + ); + out.push(chalk.dim(" " + "─".repeat(80))); + out.push(""); + + out.push(" " + heading("Network")); + out.push( + " " + + pad( + label("Network") + " " + chalk.white.bold(s.network), + label("Current Block") + + " " + + chalk.white(s.latestBlock.toLocaleString()), + ), + ); + out.push( + " " + + pad( + label("Node") + " " + chalk.gray(new URL(s.nodeUrl).hostname), + label("Total Relayers") + + " " + + chalk.white.bold(s.registeredRelayers.length.toString()), + ), + ); + + out.push(""); + out.push(chalk.dim(" " + "─".repeat(80))); + out.push(""); + + out.push(" " + heading("Your Relayer")); + out.push( + " " + + pad( + label("Address") + " " + chalk.yellow(shortAddr(s.relayerAddress)), + s.isRegistered + ? chalk.green("Registered") + : chalk.red("Not registered"), + ), + ); + out.push( + " " + + pad( + label("Available to claim") + + " " + + chalk.greenBright.bold(formatB3TR(s.relayerAvailableToClaim)), + "", + ), + ); + out.push( + " " + + label("Total earned") + + " " + + chalk.green(formatB3TR(s.relayerLifetimeEarned)), + ); + + out.push( + " " + + label("Total spent") + + " " + + chalk.yellow(formatVTHO(s.relayerLifetimeSpent)), + ); + + out.push( + " " + + pad( + label("Total actions") + + " " + + chalk.white( + `${s.relayerLifetimeVotes.toLocaleString( + "en-US", + )} votes + ${s.relayerLifetimeClaims.toLocaleString( + "en-US", + )} claims`, + ), + "", + ), + ); + + out.push(""); + out.push(chalk.dim(" " + "─".repeat(80))); + out.push(""); + + out.push( + " " + + heading(`Current Round (#${s.currentRoundId})`) + + " " + + (s.isRoundActive ? chalk.green("Active") : chalk.dim("Ended")), + ); + out.push( + " " + + pad( + label("Snapshot") + + " " + + chalk.white(s.roundSnapshot.toLocaleString("en-US")) + + chalk.dim(` (${formatBlockTime(s.roundSnapshotTimestamp)})`), + label("Deadline") + + " " + + chalk.white(s.roundDeadline.toLocaleString("en-US")) + + chalk.dim(` (${formatBlockTime(s.roundDeadlineTimestamp)})`), + ), + ); + out.push( + " " + + pad( + label("Users with autovoting") + + " " + + chalk.white.bold(s.autoVotingUsers.toString()), + label("Total users") + " " + chalk.white(s.totalVoters.toString()), + ), + ); + out.push( + " " + + pad( + label("Votes") + + " " + + chalk.white.bold( + formatProgress(s.currentVotedCount, s.currentEligibleVoters), + ), + label("Claims (prev)") + + " " + + chalk.white.bold( + formatProgress(s.previousClaimedCount, s.previousEligibleClaims), + ), + ), + ); + out.push(" " + label("Completion") + " " + completionColor(completionPct)); + out.push( + " " + label("Early access ends in") + " " + chalk.white(earlyAccessLabel), + ); + + out.push(""); + out.push(" " + label("Your Activity")); + out.push( + " " + + label("Weight") + + " " + + chalk.greenBright.bold(currentWeightPct) + + chalk.dim(" of completed"), + ); + out.push( + " " + + label("Actions performed") + + " " + + chalk.white( + `${s.currentVotesPerformed} votes + ${s.currentClaimsPerformed} claims`, + ) + + chalk.dim(` (wt: ${s.currentRelayerWeighted.toString()})`), + ); + + out.push( + label(" " + "Est. Rewards") + + " " + + chalk.greenBright.bold(formatB3TR(s.currentEstimatedRewards)), + ); + + out.push( + " " + + label(currentPoolLabel) + + " " + + chalk.green(formatB3TR(currentPoolValue)), + ); + + out.push( + " " + + label("Total spent") + + " " + + chalk.yellow(formatVTHO(s.currentSpent)), + ); + if (s.previousRoundId > 0) { - out.push("") - out.push(chalk.dim(" " + "─".repeat(60))) - out.push("") - const claimStatus = s.previousRewardClaimable ? chalk.green("Claimable") : chalk.dim("Not yet") - out.push(" " + heading(`Previous Round #${s.previousRoundId}`)) - out.push(" " + pad( - label("Pool") + " " + chalk.green(formatB3TR(s.previousTotalRewards)), - label("Your share") + " " + chalk.greenBright.bold(formatB3TR(s.previousRelayerClaimable)), - )) - out.push(" " + pad( - label("Actions") + " " + chalk.white(s.previousRelayerActions.toString()), - claimStatus, - )) + out.push(""); + out.push(chalk.dim(" " + "─".repeat(80))); + out.push(""); + + out.push(" " + heading(`Previous Round (#${s.previousRoundId})`)); + out.push( + " " + + pad( + label("Status") + + " " + + (s.previousRewardClaimable + ? chalk.green("claimable") + : chalk.dim("not claimable")), + "", + ), + ); + out.push( + " " + + pad( + label("Votes") + + " " + + chalk.white.bold( + formatProgress(s.previousVotedCount, s.previousEligibleVoters), + ), + label("Claims") + + " " + + chalk.white.bold( + formatProgress(s.previousClaimedCount, s.previousEligibleClaims), + ), + ), + ); + out.push( + " " + + pad( + label("Your weight") + + " " + + chalk.greenBright.bold(previousWeightPct) + + chalk.dim(" of completed"), + "", + ), + ); + out.push( + " " + + pad( + label("Actions performed") + + " " + + chalk.white( + `${s.previousVotesPerformed} votes + ${s.previousClaimsPerformed} claims`, + ) + + chalk.dim(` (wt: ${s.previousRelayerWeighted.toString()})`), + "", + ), + ); + out.push( + " " + + label("Pool rewards") + + " " + + chalk.green(formatB3TR(s.previousTotalRewards)), + ); + out.push( + " " + + label("Your rewards") + + " " + + chalk.greenBright.bold(previousRewardsLine), + ); + out.push( + " " + + label("Total spent") + + " " + + chalk.yellow(formatVTHO(s.previousSpent)), + ); } - out.push("") - return out.join("\n") + out.push(""); + out.push(chalk.dim(" " + "─".repeat(80))); + out.push(""); + + const feeStr = + s.feeDenominator > 0n ? pct(s.feePercentage, s.feeDenominator) : "—"; + out.push(" " + heading("Rules")); + out.push( + " " + + pad( + label("Vote Weight") + " " + chalk.white.bold(s.voteWeight.toString()), + label("Claim Weight") + + " " + + chalk.white.bold(s.claimWeight.toString()), + ), + ); + out.push( + " " + + pad( + label("Fee") + " " + chalk.yellow(feeStr), + label("Cap") + " " + chalk.yellow(formatB3TR(s.feeCap)), + ), + ); + out.push( + " " + + pad( + label("Early Access") + + " " + + chalk.white(s.earlyAccessBlocks.toString()) + + chalk.dim(" blocks"), + "", + ), + ); + + out.push(""); + return out.join("\n"); } export function renderCycleResult(r: CycleResult): string[] { - const lines: string[] = [] - const label = r.phase === "vote" ? "Cast-vote" : "Claim" - const dryTag = r.dryRun ? chalk.yellow(" (DRY RUN)") : "" + const lines: string[] = []; + const dryTag = r.dryRun ? chalk.yellow(" DRY RUN") : ""; + const divider = chalk.dim("─".repeat(80)); + + lines.push(divider); if (r.totalUsers === 0) { - lines.push(`${label} round #${r.roundId}: ${chalk.dim("no users")}${dryTag}`) - return lines + lines.push( + `${phaseTag(r.phase)} ${chalk.white(`Round #${r.roundId}`)} ${chalk.dim( + "no users discovered", + )}${dryTag}`, + ); + return lines; + } + + if (r.actionableUsers === 0) { + lines.push( + `${phaseTag(r.phase)} ${chalk.white( + `Round #${r.roundId}`, + )} ${chalk.green("nothing pending")}${dryTag}${chalk.dim( + ` (${r.totalUsers} snapshot users)`, + )} `, + ); + return lines; } - const ratio = r.successful === r.totalUsers - ? chalk.green.bold(`${r.successful}/${r.totalUsers}`) - : chalk.yellow(`${r.successful}/${r.totalUsers}`) - lines.push(`${label} round #${r.roundId}: ${ratio} successful${dryTag}`) + const failedCount = r.failed.length; + const retryableCount = r.pendingUsers; + const doneCount = r.actionableUsers - retryableCount; + const doneRatio = + doneCount === r.actionableUsers && failedCount === 0 + ? chalk.green.bold(`${doneCount}/${r.actionableUsers}`) + : chalk.yellow.bold(`${doneCount}/${r.actionableUsers}`); + + lines.push( + `${phaseTag(r.phase)} ${chalk.white( + `Round #${r.roundId}`, + )} ${doneRatio} ${chalk.dim("resolved")} ${ + failedCount > 0 + ? chalk.red(`${failedCount} failed`) + : chalk.green("0 failed") + } ${ + retryableCount > 0 + ? chalk.yellow(`${retryableCount} retryable`) + : chalk.green("0 retryable") + }${dryTag}`, + ); if (r.failed.length > 0) - lines.push(chalk.red(` ${r.failed.length} failed`) + chalk.gray(` (${r.failed.slice(0, 3).map((f) => shortAddr(f.user)).join(", ")}${r.failed.length > 3 ? "..." : ""})`)) + lines.push( + chalk.red( + ` failed: ${r.failed + .slice(0, 3) + .map((f) => shortAddr(f.user)) + .join(", ")}${r.failed.length > 3 ? "..." : ""}`, + ), + ); if (r.transient.length > 0) - lines.push(chalk.yellow(` ${r.transient.length} transient failures`)) + lines.push( + chalk.yellow( + ` retry: ${r.transient + .slice(0, 3) + .map((f) => shortAddr(f.user)) + .join(", ")}${r.transient.length > 3 ? "..." : ""}`, + ), + ); if (r.txIds.length > 0 && !r.dryRun) - lines.push(chalk.gray(` txs: ${r.txIds.map((t) => t.slice(0, 10) + "...").join(", ")}`)) + lines.push( + chalk.gray( + ` txs: ${r.txIds.map((t) => t.slice(0, 10) + "...").join(", ")}`, + ), + ); - return lines + return lines; } export function timestamp(): string { - return chalk.gray(`[${new Date().toLocaleTimeString()}]`) + return chalk.gray(`[${new Date().toLocaleTimeString()}]`); } diff --git a/src/index.ts b/src/index.ts index add45a7..e9ab66b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,10 +22,21 @@ import { ThorClient } from "@vechain/sdk-network" import { Address, HDKey } from "@vechain/sdk-core" import chalk from "chalk" import { getNetworkConfig } from "./config" -import { fetchSummary } from "./contracts" +import { fetchSummary } from "./summary" +import type { ReportCache } from "./report" import { runCastVoteCycle, runClaimRewardCycle } from "./relayer" +import { CycleResult, RelayerSummary } from "./types" import { renderSummary, renderCycleResult, timestamp } from "./display" +const { version: APP_VERSION = "unknown" } = require("../package.json") as { + version?: string +} +const reportCache: ReportCache = { fetchedAt: 0, source: null, data: null } + +const BLOCK_TIME_MS = 10_000 +const MIN_POLL_MS = 60_000 +const MAX_IDLE_POLL_MS = 60 * 60 * 1000 + const SECRETS_DIR = "/run/secrets" const ALLOWED_SECRETS = new Set(["mnemonic", "relayer_private_key"]) @@ -54,11 +65,11 @@ function envOrSecret(envKey: string, secretName: string): string | undefined { function getWallet(): { walletAddress: string; privateKey: string } { const pk = envOrSecret("RELAYER_PRIVATE_KEY", "relayer_private_key") if (pk) { - const clean = pk.startsWith("0x") ? pk.slice(2) : pk + const clean = pk.startsWith("0x") ? pk.slice(2) : pk; return { walletAddress: Address.ofPrivateKey(Buffer.from(clean, "hex")).toString(), privateKey: clean, - } + }; } const mnemonic = envOrSecret("MNEMONIC", "mnemonic") const words = mnemonic?.split(/\s+/) @@ -66,120 +77,273 @@ function getWallet(): { walletAddress: string; privateKey: string } { console.error(chalk.red("Set MNEMONIC or RELAYER_PRIVATE_KEY (env var or Docker secret)")) process.exit(1) } - const child = HDKey.fromMnemonic(words).deriveChild(0) - const raw = child.privateKey + const child = HDKey.fromMnemonic(words).deriveChild(0); + const raw = child.privateKey; if (!raw) { - console.error(chalk.red("Failed to derive private key from mnemonic")) - process.exit(1) + console.error(chalk.red("Failed to derive private key from mnemonic")); + process.exit(1); } return { - walletAddress: Address.ofPublicKey(child.publicKey as Uint8Array).toString(), + walletAddress: Address.ofPublicKey( + child.publicKey as Uint8Array, + ).toString(), privateKey: Buffer.from(raw).toString("hex"), - } + }; } function envBool(key: string): boolean { - return /^(1|true|yes)$/i.test(process.env[key] || "") + return /^(1|true|yes)$/i.test(process.env[key] || ""); } -const activityLog: string[] = [] -const MAX_LOG = 200 +const activityLog: string[] = []; +const MAX_LOG = 200; function log(msg: string) { - const entry = `${timestamp()} ${msg}` - activityLog.push(entry) - if (activityLog.length > MAX_LOG) activityLog.shift() - console.log(entry) + if (msg === "") { + activityLog.push(""); + if (activityLog.length > MAX_LOG) activityLog.shift(); + console.log(""); + return; + } + + const entry = `${timestamp()} ${msg}`; + activityLog.push(entry); + if (activityLog.length > MAX_LOG) activityLog.shift(); + console.log(entry); +} + +function shortAddr(addr: string): string { + return addr.slice(0, 6) + "..." + addr.slice(-4); +} + +function printSummary(summary: RelayerSummary) { + console.log(""); + console.log(renderSummary(summary, APP_VERSION)); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function formatDuration(ms: number): string { + if (ms >= 60 * 60 * 1000) return `${(ms / (60 * 60 * 1000)).toFixed(1)}h`; + return `${Math.max(1, Math.round(ms / 60_000))}m`; +} + +function waitUntilBlock(currentBlock: number, targetBlock: number): number { + const deltaBlocks = Math.max(0, targetBlock - currentBlock); + const rawWaitMs = deltaBlocks * BLOCK_TIME_MS; + return Math.max(MIN_POLL_MS, Math.min(MAX_IDLE_POLL_MS, rawWaitMs)); +} + +function getCycleResult( + results: CycleResult[], + phase: CycleResult["phase"], +): CycleResult | undefined { + return results.find((result) => result.phase === phase); +} + +function computeNextPollMs( + summary: RelayerSummary, + results: CycleResult[], + fallbackMs: number, +): { waitMs: number; reason: string } { + const voteResult = getCycleResult(results, "vote"); + const claimResult = getCycleResult(results, "claim"); + const pendingUsers = results.reduce( + (count, result) => count + result.pendingUsers, + 0, + ); + if (pendingUsers > 0) { + return { + waitMs: fallbackMs, + reason: `${pendingUsers} action${ + pendingUsers === 1 ? "" : "s" + } still pending`, + }; + } + + const earlyAccessBlocks = Number(summary.earlyAccessBlocks); + const voteEarlyAccessEnd = summary.roundSnapshot + earlyAccessBlocks; + if (summary.latestBlock < voteEarlyAccessEnd) { + return { + waitMs: waitUntilBlock(summary.latestBlock, voteEarlyAccessEnd), + reason: `votes ${voteResult?.pendingUsers ?? 0} pending, claims ${ + claimResult?.pendingUsers ?? 0 + } pending; waiting for vote early access to end`, + }; + } + + if (summary.previousRoundId > 0) { + const claimEarlyAccessEnd = + summary.previousRoundDeadline + earlyAccessBlocks; + if (summary.latestBlock < claimEarlyAccessEnd) { + return { + waitMs: waitUntilBlock(summary.latestBlock, claimEarlyAccessEnd), + reason: `votes ${voteResult?.pendingUsers ?? 0} pending, claims ${ + claimResult?.pendingUsers ?? 0 + } pending; waiting for claim early access to end`, + }; + } + } + + if (summary.isRoundActive && summary.latestBlock < summary.roundDeadline) { + return { + waitMs: waitUntilBlock(summary.latestBlock, summary.roundDeadline), + reason: `votes ${voteResult?.pendingUsers ?? 0} pending, claims ${ + claimResult?.pendingUsers ?? 0 + } pending; early access passed, waiting for round #${ + summary.currentRoundId + } to end`, + }; + } + + return { + waitMs: Math.max(fallbackMs, MAX_IDLE_POLL_MS), + reason: `votes ${voteResult?.pendingUsers ?? 0} pending, claims ${ + claimResult?.pendingUsers ?? 0 + } pending; waiting for the next round check`, + }; } async function main() { - const network = process.env.RELAYER_NETWORK || "mainnet" - const config = getNetworkConfig(network, process.env.NODE_URL?.trim()) - const { walletAddress, privateKey } = getWallet() - const batchSize = Math.max(1, parseInt(process.env.BATCH_SIZE || "50", 10) || 50) - const dryRun = envBool("DRY_RUN") - const pollMs = Math.max(60_000, parseInt(process.env.POLL_INTERVAL_MS || "300000", 10) || 300_000) - const runOnce = envBool("RUN_ONCE") - - const thor = ThorClient.at(config.nodeUrl, { isPollingEnabled: false }) - - let running = true - let forceExit = false + const network = process.env.RELAYER_NETWORK || "mainnet"; + const config = getNetworkConfig(network, process.env.NODE_URL?.trim()); + const { walletAddress, privateKey } = getWallet(); + const batchSize = Math.max( + 1, + parseInt(process.env.BATCH_SIZE || "50", 10) || 50, + ); + const dryRun = envBool("DRY_RUN"); + const pollMs = Math.max( + MIN_POLL_MS, + parseInt(process.env.POLL_INTERVAL_MS || "300000", 10) || 300_000, + ); + const runOnce = envBool("RUN_ONCE"); + + const thor = ThorClient.at(config.nodeUrl, { isPollingEnabled: false }); + + let running = true; + let forceExit = false; const shutdown = () => { if (forceExit) { - log(chalk.red("Force exit.")) - process.exit(1) + log(chalk.red("Force exit.")); + process.exit(1); } - forceExit = true - running = false - log(chalk.yellow("Shutting down after current operation... (press Ctrl+C again to force quit)")) - } - process.on("SIGINT", shutdown) - process.on("SIGTERM", shutdown) + forceExit = true; + running = false; + log( + chalk.yellow( + "Shutting down after current operation... (press Ctrl+C again to force quit)", + ), + ); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + const CYCLE_RETRIES = 3; + const CYCLE_RETRY_MS = 3000; - const CYCLE_RETRIES = 3 - const CYCLE_RETRY_MS = 3000 + log(chalk.cyan(`Starting VeBetterDAO Relayer Node v${APP_VERSION}`)); + log(`Network: ${config.name} (${config.nodeUrl})`); + log( + `Relayer: ${shortAddr(walletAddress)}${ + dryRun ? chalk.yellow(" [dry run]") : "" + }`, + ); while (running) { - let lastErr: unknown + let lastErr: unknown; + let nextPoll = { waitMs: pollMs, reason: "default poll interval" }; for (let attempt = 1; attempt <= CYCLE_RETRIES; attempt++) { try { + log( + `Fetching summary for round monitoring${ + attempt > 1 ? ` (attempt ${attempt}/${CYCLE_RETRIES})` : "" + } (could take a while)...`, + ); + // Fetch and display summary - const summary = await fetchSummary(thor, config, walletAddress) - console.clear() - console.log(renderSummary(summary)) - console.log("") - console.log(chalk.bold("─── Activity Log ") + "─".repeat(49)) - - // Replay recent log entries after clear - for (const entry of activityLog.slice(-30)) { - console.log(entry) - } + const summary = await fetchSummary(thor, config, walletAddress, reportCache); + printSummary(summary); // Run cycles + const cycleResults: CycleResult[] = []; if (summary.isRoundActive) { - log("Starting cast-vote cycle...") - const voteResult = await runCastVoteCycle(thor, config, walletAddress, privateKey, batchSize, dryRun, log) - renderCycleResult(voteResult).forEach(log) + log(""); + log("Starting cast-vote cycle..."); + const voteResult = await runCastVoteCycle( + thor, + config, + walletAddress, + privateKey, + batchSize, + dryRun, + log, + ); + cycleResults.push(voteResult); + renderCycleResult(voteResult).forEach(log); } else { - log("Round not active, skipping cast-vote") + log("Round not active, skipping cast-vote"); } - log("Starting claim cycle...") - const claimResult = await runClaimRewardCycle(thor, config, walletAddress, privateKey, batchSize, dryRun, log) - renderCycleResult(claimResult).forEach(log) + log(""); + log("Starting claim cycle..."); + const claimResult = await runClaimRewardCycle( + thor, + config, + walletAddress, + privateKey, + batchSize, + dryRun, + log, + ); + cycleResults.push(claimResult); + renderCycleResult(claimResult).forEach(log); // Re-fetch and display updated summary - const updated = await fetchSummary(thor, config, walletAddress) - console.clear() - console.log(renderSummary(updated)) - console.log("") - console.log(chalk.bold("─── Activity Log ") + "─".repeat(49)) - for (const entry of activityLog.slice(-30)) { - console.log(entry) - } - lastErr = undefined - break + const updated = await fetchSummary(thor, config, walletAddress, reportCache); + printSummary(updated); + nextPoll = computeNextPollMs(updated, cycleResults, pollMs); + lastErr = undefined; + break; } catch (err) { - lastErr = err + lastErr = err; if (attempt < CYCLE_RETRIES) { - log(chalk.yellow(`Cycle attempt ${attempt}/${CYCLE_RETRIES} failed, retrying in ${CYCLE_RETRY_MS / 1000}s...`)) - await new Promise((r) => setTimeout(r, CYCLE_RETRY_MS)) + log( + chalk.yellow( + `Cycle attempt ${attempt}/${CYCLE_RETRIES} failed, retrying in ${ + CYCLE_RETRY_MS / 1000 + }s...`, + ), + ); + await sleep(CYCLE_RETRY_MS); } } } if (lastErr !== undefined) { - log(chalk.red(`Cycle error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`)) + log( + chalk.red( + `Cycle error: ${ + lastErr instanceof Error ? lastErr.message : String(lastErr) + }`, + ), + ); } if (runOnce) { - log("Run once complete. Exiting.") - break + log("Run once complete. Exiting."); + break; } - log(`Next cycle in ${(pollMs / 60_000).toFixed(0)}m...`) - await new Promise((r) => setTimeout(r, pollMs)) + log(""); + log( + `Next cycle in ${formatDuration(nextPoll.waitMs)} (${ + nextPoll.reason + })...`, + ); + await sleep(nextPoll.waitMs); } } -main() +main(); diff --git a/src/relayer.ts b/src/relayer.ts index fcb55bf..804f57f 100644 --- a/src/relayer.ts +++ b/src/relayer.ts @@ -12,7 +12,7 @@ import { getAutoVotingUsers, getAlreadySkippedVotersForRound, getAlreadyClaimedForRound, - hasVoted, + getVotedUsersForRound, } from "./contracts" const xavAbi = ABIContract.ofAbi(XAllocationVoting__factory.abi) @@ -192,7 +192,18 @@ export async function runCastVoteCycle( log(`Found ${allUsers.length} auto-voting users`) if (allUsers.length === 0) { - return { phase: "vote", roundId, totalUsers: 0, successful: 0, failed: [], transient: [], txIds: [], dryRun } + return { + phase: "vote", + roundId, + totalUsers: 0, + actionableUsers: 0, + pendingUsers: 0, + successful: 0, + failed: [], + transient: [], + txIds: [], + dryRun, + } } // Fetch ineligible users (AutoVoteSkipped) for this round @@ -209,36 +220,47 @@ export async function runCastVoteCycle( // Check who already voted (batched to avoid overwhelming the node) log("Checking vote status...") const unprocessed: string[] = [] + const votedSet = await getVotedUsersForRound(thor, config.xAllocationVotingAddress, roundId, allUsers) let voted = 0 let ineligible = 0 - const CHECK_BATCH = 10 - for (let i = 0; i < allUsers.length; i += CHECK_BATCH) { - const chunk = allUsers.slice(i, i + CHECK_BATCH) - const checks = await Promise.all(chunk.map((u) => hasVoted(thor, config.xAllocationVotingAddress, roundId, u))) - for (let j = 0; j < chunk.length; j++) { - if (checks[j]) { - voted++ - } else if (skippedSet.has(chunk[j].toLowerCase())) { - ineligible++ - } else { - unprocessed.push(chunk[j]) - } + + for (const user of allUsers) { + const normalizedUser = user.toLowerCase() + if (votedSet.has(normalizedUser)) { + voted++ + } else if (skippedSet.has(normalizedUser)) { + ineligible++ + } else { + unprocessed.push(user) } - if (i + CHECK_BATCH < allUsers.length) await delay(150) } log(`${allUsers.length} auto-voting users: ${voted} voted, ${ineligible} ineligible, ${unprocessed.length} pending`) if (unprocessed.length === 0) { - return { phase: "vote", roundId, totalUsers: allUsers.length, successful: 0, failed: [], transient: [], txIds: [], dryRun } + return { + phase: "vote", + roundId, + totalUsers: allUsers.length, + actionableUsers: 0, + pendingUsers: 0, + successful: 0, + failed: [], + transient: [], + txIds: [], + dryRun, + } } const clauseBuilder = (user: string) => buildCastVoteClause(config.xAllocationVotingAddress, roundId, user) const result = await processBatch(thor, unprocessed, clauseBuilder, walletAddress, privateKey, batchSize, dryRun, log) + const pendingUsers = Math.max(0, unprocessed.length - result.successful - result.failed.length) return { phase: "vote", roundId, totalUsers: allUsers.length, + actionableUsers: unprocessed.length, + pendingUsers, successful: result.successful, failed: result.failed, transient: result.transient, @@ -260,7 +282,18 @@ export async function runClaimRewardCycle( const previousRoundId = currentRoundId - 1 if (previousRoundId <= 0) { log("No previous round to claim for") - return { phase: "claim", roundId: 0, totalUsers: 0, successful: 0, failed: [], transient: [], txIds: [], dryRun } + return { + phase: "claim", + roundId: 0, + totalUsers: 0, + actionableUsers: 0, + pendingUsers: 0, + successful: 0, + failed: [], + transient: [], + txIds: [], + dryRun, + } } const snapshot = await getRoundSnapshot(thor, config.xAllocationVotingAddress, previousRoundId) @@ -270,7 +303,18 @@ export async function runClaimRewardCycle( const allUsers = await getAutoVotingUsers(thor, config.xAllocationVotingAddress, snapshot) if (allUsers.length === 0) { - return { phase: "claim", roundId: previousRoundId, totalUsers: 0, successful: 0, failed: [], transient: [], txIds: [], dryRun } + return { + phase: "claim", + roundId: previousRoundId, + totalUsers: 0, + actionableUsers: 0, + pendingUsers: 0, + successful: 0, + failed: [], + transient: [], + txIds: [], + dryRun, + } } // Only claim for users who voted AND haven't been claimed yet @@ -286,36 +330,47 @@ export async function runClaimRewardCycle( log("Checking vote status for previous round...") const unclaimed: string[] = [] + const votedSet = await getVotedUsersForRound(thor, config.xAllocationVotingAddress, previousRoundId, allUsers) let didNotVote = 0 let alreadyClaimed = 0 - const CHECK_BATCH = 10 - for (let i = 0; i < allUsers.length; i += CHECK_BATCH) { - const chunk = allUsers.slice(i, i + CHECK_BATCH) - const checks = await Promise.all(chunk.map((u) => hasVoted(thor, config.xAllocationVotingAddress, previousRoundId, u))) - for (let j = 0; j < chunk.length; j++) { - if (!checks[j]) { - didNotVote++ - } else if (claimedSet.has(chunk[j].toLowerCase())) { - alreadyClaimed++ - } else { - unclaimed.push(chunk[j]) - } + + for (const user of allUsers) { + const normalizedUser = user.toLowerCase() + if (!votedSet.has(normalizedUser)) { + didNotVote++ + } else if (claimedSet.has(normalizedUser)) { + alreadyClaimed++ + } else { + unclaimed.push(user) } - if (i + CHECK_BATCH < allUsers.length) await delay(150) } log(`${allUsers.length} auto-voting users: ${alreadyClaimed} claimed, ${didNotVote} did not vote, ${unclaimed.length} pending`) if (unclaimed.length === 0) { - return { phase: "claim", roundId: previousRoundId, totalUsers: allUsers.length, successful: 0, failed: [], transient: [], txIds: [], dryRun } + return { + phase: "claim", + roundId: previousRoundId, + totalUsers: allUsers.length, + actionableUsers: 0, + pendingUsers: 0, + successful: 0, + failed: [], + transient: [], + txIds: [], + dryRun, + } } const clauseBuilder = (user: string) => buildClaimRewardClause(config.voterRewardsAddress, previousRoundId, user) const result = await processBatch(thor, unclaimed, clauseBuilder, walletAddress, privateKey, batchSize, dryRun, log) + const pendingUsers = Math.max(0, unclaimed.length - result.successful - result.failed.length) return { phase: "claim", roundId: previousRoundId, totalUsers: allUsers.length, + actionableUsers: unclaimed.length, + pendingUsers, successful: result.successful, failed: result.failed, transient: result.transient, diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..e301a7e --- /dev/null +++ b/src/report.ts @@ -0,0 +1,102 @@ +import { NetworkConfig } from "./types" + +const DEFAULT_MAINNET_REPORT_URL = "https://relayers.vebetterdao.org/data/report.json" + +export interface ReportRoundAnalytics { + roundId: number + autoVotingUsersCount: number + votedForCount: number + rewardsClaimedCount: number + totalRelayerRewardsRaw: string + estimatedRelayerRewardsRaw: string + reducedUsersCount: number +} + +export interface ReportRelayerRoundBreakdown { + roundId: number + votedForCount: number + rewardsClaimedCount: number + weightedActions: number + actions: number + claimableRewardsRaw: string + relayerRewardsClaimedRaw: string + vthoSpentOnVotingRaw: string + vthoSpentOnClaimingRaw: string +} + +export interface ReportRelayerAnalytics { + address: string + rounds: ReportRelayerRoundBreakdown[] +} + +export interface ReportData { + generatedAt: string + network: string + currentRound: number + rounds: ReportRoundAnalytics[] + relayers: ReportRelayerAnalytics[] +} + +/** Caller-owned cache for fetchReport. Pass from the process that owns the lifecycle to avoid module-level state. */ +export interface ReportCache { + fetchedAt: number + source: string | null + data: ReportData | null +} + +export const REPORT_CACHE_MS = 5 * 60 * 1000 + +function getReportSource(config: NetworkConfig): string | null { + const explicitPath = process.env.RELAYER_REPORT_PATH?.trim() + if (explicitPath) return explicitPath + + const explicitUrl = process.env.RELAYER_REPORT_URL?.trim() + if (explicitUrl) return explicitUrl + + if (config.name === "mainnet") return DEFAULT_MAINNET_REPORT_URL + return null +} + +export async function fetchReport( + config: NetworkConfig, + cache?: ReportCache | null, +): Promise { + const source = getReportSource(config) + if (!source) return null + + if ( + cache && + cache.source === source && + cache.fetchedAt > 0 && + Date.now() - cache.fetchedAt < REPORT_CACHE_MS + ) { + return cache.data + } + + try { + let data: ReportData + + if (/^https?:\/\//i.test(source)) { + const res = await fetch(source, { cache: "no-store" }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + data = (await res.json()) as ReportData + } else { + const fs = require("fs") as typeof import("fs") + data = JSON.parse(fs.readFileSync(source, "utf-8")) as ReportData + } + + if (cache) { + cache.source = source + cache.data = data + cache.fetchedAt = Date.now() + } + return data + } catch { + if (cache) { + cache.source = source + cache.data = null + cache.fetchedAt = Date.now() + } + return null + } +} diff --git a/src/summary.ts b/src/summary.ts new file mode 100644 index 0000000..afab77e --- /dev/null +++ b/src/summary.ts @@ -0,0 +1,330 @@ +import { ThorClient } from "@vechain/sdk-network" +import { NetworkConfig, RelayerSummary } from "./types" +import { fetchReport, type ReportCache } from "./report" +import { getBlockTimestamp } from "./block-timestamps" +import { + getCurrentRoundId, + getRoundSnapshot, + getRoundDeadline, + isRoundActive, + getTotalVoters, + getTotalVotes, + getRegisteredRelayers, + isRegisteredRelayer, + getVoteWeight, + getClaimWeight, + getRelayerFeePercentage, + getRelayerFeeDenominator, + getFeeCap, + getEarlyAccessBlocks, + getTotalRewards, + getClaimableRewards, + getTotalActions, + getCompletedWeightedActions, + getTotalWeightedActions, + getRelayerActions, + getRelayerWeightedActions, + isRewardClaimable, + getAutoVotingUsers, + getAlreadySkippedVotersForRound, + getVotedUsersForRound, + getAlreadyClaimedForRound, + estimateRewardPoolForRound, +} from "./contracts" + +function splitActions( + totalActions: bigint, + weightedActions: bigint, +): { votes: number; claims: number } { + if (totalActions <= 0n || weightedActions <= 0n) return { votes: 0, claims: 0 } + const votes = + weightedActions >= totalActions ? (weightedActions - totalActions) / 2n : 0n + const claims = totalActions >= votes ? totalActions - votes : 0n + return { + votes: Number(votes), + claims: Number(claims), + } +} + +export async function fetchSummary( + thor: ThorClient, + config: NetworkConfig, + relayerAddress: string, + reportCache?: ReportCache | null, +): Promise { + const xav = config.xAllocationVotingAddress + const rrp = config.relayerRewardsPoolAddress + + const currentRoundId = await getCurrentRoundId(thor, xav) + const previousRoundId = currentRoundId > 1 ? currentRoundId - 1 : 0 + + const best = await thor.blocks.getBestBlockCompressed() + const latestBlock = best?.number ?? 0 + + const [ + roundSnapshot, + roundDeadline, + active, + totalVoters, + totalVotes, + registeredRelayers, + isReg, + voteWeight, + claimWeight, + feePercentage, + feeDenominator, + feeCap, + earlyAccessBlocks, + currentTotalRewards, + currentRelayerClaimable, + currentTotalActions, + currentCompletedWeighted, + currentTotalWeighted, + currentRelayerActions, + currentRelayerWeighted, + previousRoundSnapshot, + previousRoundDeadline, + previousCompletedWeighted, + previousRelayerWeighted, + previousTotalRewards, + previousRelayerClaimable, + previousRewardClaimable, + previousRelayerActions, + ] = await Promise.all([ + getRoundSnapshot(thor, xav, currentRoundId), + getRoundDeadline(thor, xav, currentRoundId), + isRoundActive(thor, xav, currentRoundId), + getTotalVoters(thor, xav, currentRoundId), + getTotalVotes(thor, xav, currentRoundId), + getRegisteredRelayers(thor, rrp), + isRegisteredRelayer(thor, rrp, relayerAddress), + getVoteWeight(thor, rrp), + getClaimWeight(thor, rrp), + getRelayerFeePercentage(thor, rrp), + getRelayerFeeDenominator(thor, rrp), + getFeeCap(thor, rrp), + getEarlyAccessBlocks(thor, rrp), + getTotalRewards(thor, rrp, currentRoundId), + getClaimableRewards(thor, rrp, relayerAddress, currentRoundId), + getTotalActions(thor, rrp, currentRoundId), + getCompletedWeightedActions(thor, rrp, currentRoundId), + getTotalWeightedActions(thor, rrp, currentRoundId), + getRelayerActions(thor, rrp, relayerAddress, currentRoundId), + getRelayerWeightedActions(thor, rrp, relayerAddress, currentRoundId), + previousRoundId > 0 ? getRoundSnapshot(thor, xav, previousRoundId) : Promise.resolve(0), + previousRoundId > 0 ? getRoundDeadline(thor, xav, previousRoundId) : Promise.resolve(0), + previousRoundId > 0 + ? getCompletedWeightedActions(thor, rrp, previousRoundId) + : Promise.resolve(0n), + previousRoundId > 0 + ? getRelayerWeightedActions(thor, rrp, relayerAddress, previousRoundId) + : Promise.resolve(0n), + previousRoundId > 0 ? getTotalRewards(thor, rrp, previousRoundId) : Promise.resolve(0n), + previousRoundId > 0 + ? getClaimableRewards(thor, rrp, relayerAddress, previousRoundId) + : Promise.resolve(0n), + previousRoundId > 0 ? isRewardClaimable(thor, rrp, previousRoundId) : Promise.resolve(false), + previousRoundId > 0 + ? getRelayerActions(thor, rrp, relayerAddress, previousRoundId) + : Promise.resolve(0n), + ]) + + const [currentAutoVotingUsers, previousAutoVotingUsers] = await Promise.all([ + getAutoVotingUsers(thor, xav, roundSnapshot), + previousRoundId > 0 + ? getAutoVotingUsers(thor, xav, previousRoundSnapshot) + : Promise.resolve([]), + ]) + + const [ + currentSkippedUsers, + currentVotedUsers, + previousSkippedUsers, + previousVotedUsers, + previousClaimedUsers, + roundSnapshotTimestamp, + roundDeadlineTimestamp, + report, + ] = await Promise.all([ + getAlreadySkippedVotersForRound(thor, xav, currentRoundId, roundSnapshot, latestBlock), + getVotedUsersForRound(thor, xav, currentRoundId, currentAutoVotingUsers), + previousRoundId > 0 + ? getAlreadySkippedVotersForRound( + thor, + xav, + previousRoundId, + previousRoundSnapshot, + previousRoundDeadline, + ) + : Promise.resolve(new Set()), + previousRoundId > 0 + ? getVotedUsersForRound(thor, xav, previousRoundId, previousAutoVotingUsers) + : Promise.resolve(new Set()), + previousRoundId > 0 + ? getAlreadyClaimedForRound( + thor, + config.voterRewardsAddress, + previousRoundId, + previousRoundDeadline, + latestBlock, + ) + : Promise.resolve(new Set()), + getBlockTimestamp(config.nodeUrl, roundSnapshot), + getBlockTimestamp(config.nodeUrl, roundDeadline), + fetchReport(config, reportCache ?? undefined), + ]) + + const currentSkippedCount = currentAutoVotingUsers.reduce((count, user) => { + return count + (currentSkippedUsers.has(user.toLowerCase()) ? 1 : 0) + }, 0) + const previousSkippedCount = previousAutoVotingUsers.reduce((count, user) => { + return count + (previousSkippedUsers.has(user.toLowerCase()) ? 1 : 0) + }, 0) + const previousClaimedCount = [...previousVotedUsers].reduce((count, user) => { + return count + (previousClaimedUsers.has(user) ? 1 : 0) + }, 0) + const currentEstimatedPool = await estimateRewardPoolForRound( + thor, + config.voterRewardsAddress, + currentRoundId, + currentVotedUsers, + ) + const currentEstimatedRewards = + currentCompletedWeighted > 0n && currentRelayerWeighted > 0n + ? (currentEstimatedPool * currentRelayerWeighted) / currentCompletedWeighted + : 0n + const currentActionSplit = splitActions(currentRelayerActions, currentRelayerWeighted) + const previousActionSplit = splitActions(previousRelayerActions, previousRelayerWeighted) + const currentEarlyAccessEndBlock = roundSnapshot + Number(earlyAccessBlocks) + const currentEarlyAccessRemainingBlocks = Math.max( + 0, + currentEarlyAccessEndBlock - latestBlock, + ) + + const reportRounds = report?.rounds ?? [] + const reportRelayers = report?.relayers ?? [] + const relayerReport = + reportRelayers.find( + (entry) => entry.address.toLowerCase() === relayerAddress.toLowerCase(), + ) ?? null + const previousRoundReport = reportRounds.find( + (entry) => entry.roundId === previousRoundId, + ) ?? null + const currentRelayerRoundReport = + relayerReport?.rounds.find((entry) => entry.roundId === currentRoundId) ?? null + const previousRelayerRoundReport = + relayerReport?.rounds.find((entry) => entry.roundId === previousRoundId) ?? null + + const totalWeightedByRound = new Map() + if (report) { + for (const relayer of reportRelayers) { + for (const round of relayer.rounds) { + totalWeightedByRound.set( + round.roundId, + (totalWeightedByRound.get(round.roundId) ?? 0) + round.weightedActions, + ) + } + } + } + + let relayerLifetimeEarned = 0n + let relayerLifetimeSpent = 0n + let relayerAvailableToClaim = 0n + let relayerLifetimeVotes = 0 + let relayerLifetimeClaims = 0 + + if (relayerReport) { + const roundById = new Map(reportRounds.map((round) => [round.roundId, round])) + for (const round of relayerReport.rounds) { + const roundMeta = roundById.get(round.roundId) + const totalWeighted = totalWeightedByRound.get(round.roundId) ?? 0 + const totalRewardsRaw = roundMeta ? BigInt(roundMeta.totalRelayerRewardsRaw) : 0n + if (totalWeighted > 0 && round.weightedActions > 0) { + relayerLifetimeEarned += + (totalRewardsRaw * BigInt(round.weightedActions)) / BigInt(totalWeighted) + } else { + relayerLifetimeEarned += BigInt(round.claimableRewardsRaw) + } + relayerLifetimeSpent += + BigInt(round.vthoSpentOnVotingRaw) + BigInt(round.vthoSpentOnClaimingRaw) + relayerAvailableToClaim += BigInt(round.claimableRewardsRaw) + relayerLifetimeVotes += round.votedForCount + relayerLifetimeClaims += round.rewardsClaimedCount + } + } + const currentSpent = currentRelayerRoundReport + ? BigInt(currentRelayerRoundReport.vthoSpentOnVotingRaw) + + BigInt(currentRelayerRoundReport.vthoSpentOnClaimingRaw) + : 0n + const previousSpent = previousRelayerRoundReport + ? BigInt(previousRelayerRoundReport.vthoSpentOnVotingRaw) + + BigInt(previousRelayerRoundReport.vthoSpentOnClaimingRaw) + : 0n + + return { + network: config.name, + nodeUrl: config.nodeUrl, + relayerAddress, + isRegistered: isReg, + registeredRelayers, + reportGeneratedAt: report?.generatedAt ?? null, + currentRoundId, + roundSnapshot, + roundSnapshotTimestamp, + roundDeadline, + roundDeadlineTimestamp, + isRoundActive: active, + latestBlock, + currentEarlyAccessEndBlock, + currentEarlyAccessRemainingBlocks, + autoVotingUsers: currentAutoVotingUsers.length, + totalVoters, + totalVotes, + voteWeight, + claimWeight, + feePercentage, + feeDenominator, + feeCap, + earlyAccessBlocks, + currentEligibleVoters: Math.max(0, currentAutoVotingUsers.length - currentSkippedCount), + currentVotedCount: currentVotedUsers.size, + currentTotalRewards, + currentEstimatedPool, + currentEstimatedRewards, + currentRelayerClaimable, + currentTotalActions, + currentCompletedWeighted, + currentTotalWeighted, + currentRelayerActions, + currentRelayerWeighted, + currentVotesPerformed: currentActionSplit.votes, + currentClaimsPerformed: currentActionSplit.claims, + currentSpent, + relayerLifetimeEarned, + relayerLifetimeSpent, + relayerAvailableToClaim, + relayerLifetimeVotes, + relayerLifetimeClaims, + previousRoundId, + previousRoundDeadline, + previousEligibleVoters: Math.max( + 0, + previousAutoVotingUsers.length - previousSkippedCount, + ), + previousVotedCount: previousVotedUsers.size, + previousEligibleClaims: previousVotedUsers.size, + previousClaimedCount, + previousTotalRewards, + previousRelayerClaimable, + previousRelayerClaimed: previousRelayerRoundReport + ? BigInt(previousRelayerRoundReport.relayerRewardsClaimedRaw) + : 0n, + previousRewardClaimable, + previousRelayerActions, + previousRelayerWeighted, + previousCompletedWeighted, + previousVotesPerformed: previousActionSplit.votes, + previousClaimsPerformed: previousActionSplit.claims, + previousSpent, + } +} diff --git a/src/types.ts b/src/types.ts index 734a548..b571106 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,12 +13,17 @@ export interface RelayerSummary { relayerAddress: string isRegistered: boolean registeredRelayers: string[] + reportGeneratedAt: string | null currentRoundId: number roundSnapshot: number + roundSnapshotTimestamp: number | null roundDeadline: number + roundDeadlineTimestamp: number | null isRoundActive: boolean latestBlock: number + currentEarlyAccessEndBlock: number + currentEarlyAccessRemainingBlocks: number autoVotingUsers: number totalVoters: number @@ -31,28 +36,54 @@ export interface RelayerSummary { feeCap: bigint earlyAccessBlocks: bigint + // Current round progress + currentEligibleVoters: number + currentVotedCount: number + // Current round relayer stats currentTotalRewards: bigint + currentEstimatedPool: bigint + currentEstimatedRewards: bigint currentRelayerClaimable: bigint currentTotalActions: bigint currentCompletedWeighted: bigint currentTotalWeighted: bigint - currentMissedUsers: bigint currentRelayerActions: bigint currentRelayerWeighted: bigint + currentVotesPerformed: number + currentClaimsPerformed: number + currentSpent: bigint + relayerLifetimeEarned: bigint + relayerLifetimeSpent: bigint + relayerAvailableToClaim: bigint + relayerLifetimeVotes: number + relayerLifetimeClaims: number // Previous round previousRoundId: number + previousRoundDeadline: number + previousEligibleVoters: number + previousVotedCount: number + previousEligibleClaims: number + previousClaimedCount: number previousTotalRewards: bigint previousRelayerClaimable: bigint + previousRelayerClaimed: bigint previousRewardClaimable: boolean previousRelayerActions: bigint + previousRelayerWeighted: bigint + previousCompletedWeighted: bigint + previousVotesPerformed: number + previousClaimsPerformed: number + previousSpent: bigint } export interface CycleResult { phase: "vote" | "claim" roundId: number totalUsers: number + actionableUsers: number + pendingUsers: number successful: number failed: { user: string; reason: string }[] transient: { user: string; reason: string }[]