diff --git a/foundry/src/StakingV1.sol b/foundry/src/StakingV1.sol index 42e0dc2..f504121 100644 --- a/foundry/src/StakingV1.sol +++ b/foundry/src/StakingV1.sol @@ -177,6 +177,7 @@ contract StakingV1 is StakingInfo storage info = stakingInfo[msg.sender]; info.stakingBalance += amount; info.runeAddress = runeAddress; + totalStaked += amount; emit Stake(msg.sender, amount, runeAddress); @@ -201,6 +202,7 @@ contract StakingV1 is // Set staking / unstaking amounts info.stakingBalance -= amount; info.unstakingBalance += amount; + totalStaked -= amount; totalCoolingDown += amount; diff --git a/scripts/rewards-distribution/calculateRewards/calculateRewards.ts b/scripts/rewards-distribution/calculateRewards/calculateRewards.ts index a07d4e6..912f540 100644 --- a/scripts/rewards-distribution/calculateRewards/calculateRewards.ts +++ b/scripts/rewards-distribution/calculateRewards/calculateRewards.ts @@ -1,57 +1,263 @@ -import { Address } from "viem"; +import { Address, Block } from "viem"; import { RFoxLog, StakeLog, UnstakeLog } from "../events"; import { getStakingAmount, isLogType } from "../helpers"; +import { REWARD_RATE, WAD } from "../constants"; +import assert from "assert"; -export const calculateRewards = ( - fromBlock: bigint, - toBlock: bigint, - logsByBlockNumber: Record, - epochBlockReward: bigint, +type StakingInfo = { + stakingBalance: bigint; + earnedRewards: bigint; + rewardPerTokenStored: bigint; + runeAddress: String; +}; + +const getEmptyStakingInfo = () => { + return { + stakingBalance: 0n, + earnedRewards: 0n, + rewardPerTokenStored: 0n, + runeAddress: "", + }; +}; + +const rewardPerToken = ( + rewardPerTokenStored: bigint, + totalStaked: bigint, + currentTimestamp: bigint, + lastUpdateTimestamp: bigint, +) => { + if (totalStaked == 0n) { + return rewardPerTokenStored; + } + return ( + rewardPerTokenStored + + ((currentTimestamp - lastUpdateTimestamp) * REWARD_RATE * WAD) / totalStaked + ); +}; + +const earned = ( + stakingInfo: StakingInfo, + rewardPerTokenStored: bigint, + totalStaked: bigint, + currentTimestamp: bigint, + lastUpdateTimestamp: bigint, +) => { + return ( + (stakingInfo.stakingBalance * + (rewardPerToken( + rewardPerTokenStored, + totalStaked, + currentTimestamp, + lastUpdateTimestamp, + ) - + stakingInfo.rewardPerTokenStored)) / + WAD + + stakingInfo.earnedRewards + ); +}; + +const updateReward = ( + stakingInfo: StakingInfo, + rewardPerTokenStored: bigint, totalStaked: bigint, + currentTimestamp: bigint, + lastUpdateTimestamp: bigint, ) => { - const balanceByAccountBaseUnit: Record = {}; + rewardPerTokenStored = rewardPerToken( + rewardPerTokenStored, + totalStaked, + currentTimestamp, + lastUpdateTimestamp, + ); + lastUpdateTimestamp = currentTimestamp; + stakingInfo.earnedRewards = earned( + stakingInfo, + rewardPerTokenStored, + totalStaked, + currentTimestamp, + lastUpdateTimestamp, + ); + stakingInfo.rewardPerTokenStored = rewardPerTokenStored; + return { stakingInfo, rewardPerTokenStored, lastUpdateTimestamp }; +}; - // this must be initialized to empty - const epochRewardByAccount: Record = {}; +const stake = ( + amount: bigint, + runeAddress: String, + stakingInfo: StakingInfo, + rewardPerTokenStored: bigint, + totalStaked: bigint, + currentTimestamp: bigint, + lastUpdateTimestamp: bigint, +) => { + ({ stakingInfo, rewardPerTokenStored, lastUpdateTimestamp } = updateReward( + stakingInfo, + rewardPerTokenStored, + totalStaked, + currentTimestamp, + lastUpdateTimestamp, + )); - for (let blockNumber = fromBlock; blockNumber <= toBlock; blockNumber++) { - const incomingLogs: RFoxLog[] = - logsByBlockNumber[blockNumber.toString()] ?? []; + stakingInfo.stakingBalance += amount; + stakingInfo.runeAddress = runeAddress; + totalStaked += amount; - const stakingLogs = incomingLogs.filter( - (log): log is StakeLog | UnstakeLog => - isLogType("Stake", log) || isLogType("Unstake", log), - ); + return { + stakingInfo, + rewardPerTokenStored, + lastUpdateTimestamp, + totalStaked, + }; +}; - // process logs if there are any - for (const log of stakingLogs) { - const account = log.args.account; - if (!balanceByAccountBaseUnit[account]) { - balanceByAccountBaseUnit[account] = 0n; - } - const stakingAmountBaseUnit = getStakingAmount(log); - balanceByAccountBaseUnit[account] += stakingAmountBaseUnit; - totalStaked += stakingAmountBaseUnit; +const unstake = ( + amount: bigint, + stakingInfo: StakingInfo, + rewardPerTokenStored: bigint, + totalStaked: bigint, + currentTimestamp: bigint, + lastUpdateTimestamp: bigint, +) => { + ({ stakingInfo, rewardPerTokenStored, lastUpdateTimestamp } = updateReward( + stakingInfo, + rewardPerTokenStored, + totalStaked, + currentTimestamp, + lastUpdateTimestamp, + )); - // clear empty balances - if (balanceByAccountBaseUnit[account] === 0n) { - delete balanceByAccountBaseUnit[account]; + stakingInfo.stakingBalance -= amount; + totalStaked -= amount; + + return { + stakingInfo, + rewardPerTokenStored, + lastUpdateTimestamp, + totalStaked, + }; +}; + +export const calculateRewards = ( + contractCreationBlock: Block, + // The reward is computed as an all-time value, so we need to subtract the rewards at the end of + // the previous epoch. This prevents us missing rewards for the first block in the epoch. + previousEpochEndBlock: Block, + epochEndBlock: Block, + logs: { log: RFoxLog; timestamp: bigint }[], +) => { + let totalStaked = 0n; + let rewardPerTokenStored = 0n; + let lastUpdateTimestamp = contractCreationBlock.timestamp; + const stakingInfoByAccount: Record = {}; + + const stakingLogs = logs.filter( + ( + logWithTimestamp, + ): logWithTimestamp is { log: StakeLog | UnstakeLog; timestamp: bigint } => + isLogType("Stake", logWithTimestamp.log) || + isLogType("Unstake", logWithTimestamp.log), + ); + + const epochStartRewardsByAccount: Record = {}; + const epochEndRewardsByAccount: Record = {}; + + const previousEpochEndBlockNumber = previousEpochEndBlock.number; + const epochEndBlockNumber = epochEndBlock.number; + + assert( + previousEpochEndBlockNumber !== null, + "Epoch start block number is null", + ); + assert(epochEndBlockNumber !== null, "Epoch end block number is null"); + + let hasCalcedStartRewards = false; + + // process logs + for (const { log, timestamp: currentTimestamp } of stakingLogs) { + // Paranoia in case we get logs past the end of the epoch + assert(log.blockNumber <= epochEndBlockNumber); + + // When the block number passes the start of the epoch, assign the reward values for the start of the epoch + if ( + !hasCalcedStartRewards && + log.blockNumber > previousEpochEndBlockNumber + ) { + for (const [account, stakingInfo] of Object.entries( + stakingInfoByAccount, + )) { + epochStartRewardsByAccount[account as Address] = earned( + stakingInfo, + rewardPerTokenStored, + totalStaked, + previousEpochEndBlock.timestamp, + lastUpdateTimestamp, + ); + + hasCalcedStartRewards = true; } } - for (const account of Object.keys(balanceByAccountBaseUnit) as Address[]) { - // calculate rewards for the current block - const reward = - totalStaked > 0n - ? (epochBlockReward * balanceByAccountBaseUnit[account]) / totalStaked - : 0n; + let stakingInfo = + stakingInfoByAccount[log.args.account] ?? getEmptyStakingInfo(); - if (epochRewardByAccount[account] == undefined) { - epochRewardByAccount[account] = 0n; - } - epochRewardByAccount[account] += reward; + switch (true) { + case isLogType("Stake", log): + ({ + stakingInfo, + rewardPerTokenStored, + lastUpdateTimestamp, + totalStaked, + } = stake( + log.args.amount, + log.args.runeAddress, + stakingInfo, + rewardPerTokenStored, + totalStaked, + currentTimestamp, + lastUpdateTimestamp, + )); + break; + case isLogType("Unstake", log): + ({ + stakingInfo, + rewardPerTokenStored, + lastUpdateTimestamp, + totalStaked, + } = unstake( + log.args.amount, + stakingInfo, + rewardPerTokenStored, + totalStaked, + currentTimestamp, + lastUpdateTimestamp, + )); + break; + default: + break; } + + stakingInfoByAccount[log.args.account] = stakingInfo; + } + + // Grab the reward values for the end of the epoch + for (const [account, stakingInfo] of Object.entries(stakingInfoByAccount)) { + epochEndRewardsByAccount[account as Address] = earned( + stakingInfo, + rewardPerTokenStored, + totalStaked, + epochEndBlock.timestamp, + lastUpdateTimestamp, + ); + } + + const earnedRewardsByAccount: Record = {}; + + for (const [account, epochEndReward] of Object.entries( + epochEndRewardsByAccount, + )) { + earnedRewardsByAccount[account as Address] = + epochEndReward - (epochStartRewardsByAccount[account as Address] ?? 0n); } - return epochRewardByAccount; + return earnedRewardsByAccount; }; diff --git a/scripts/rewards-distribution/constants.ts b/scripts/rewards-distribution/constants.ts index 33a13df..a347f17 100644 --- a/scripts/rewards-distribution/constants.ts +++ b/scripts/rewards-distribution/constants.ts @@ -2,9 +2,12 @@ import { Address } from "viem"; export const RUNE_DECIMALS = 8; +export const WAD = 1n * 10n ** 18n; +export const REWARD_RATE = 1_000_000_000n; + // The number of blocks to query at a time, when fetching logs export const GET_LOGS_BLOCK_STEP_SIZE = 20000n; // RFOX on Arbitrum ERC1967Proxy contract address export const ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS: Address = - "0x0c66f315542fdec1d312c415b14eef614b0910ef"; + "0xd612B64A134f3D4830542B7463CE8ca8a29D7268"; diff --git a/scripts/rewards-distribution/helpers.ts b/scripts/rewards-distribution/helpers.ts index bf53b7e..a022725 100644 --- a/scripts/rewards-distribution/helpers.ts +++ b/scripts/rewards-distribution/helpers.ts @@ -1,9 +1,33 @@ import BigNumber from "bignumber.js"; -import { GET_LOGS_BLOCK_STEP_SIZE } from "./constants"; -import { AbiEvent, Log, PublicClient } from "viem"; +import { + ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, + GET_LOGS_BLOCK_STEP_SIZE, +} from "./constants"; +import { AbiEvent, Block, Log, PublicClient } from "viem"; import cliProgress from "cli-progress"; import colors from "ansi-colors"; import { RFoxEvent, RFoxLog, StakeLog, UnstakeLog } from "./events"; +import { stakingV1Abi } from "./generated/abi-types"; + +// we cache promises to prevent async race conditions hydrating the cache +const blockNumberToTimestampCache: Record> = {}; + +export const getBlockTimestamp = async ( + publicClient: PublicClient, + blockNumber: bigint, +): Promise => { + if (blockNumberToTimestampCache[blockNumber.toString()] !== undefined) { + return blockNumberToTimestampCache[blockNumber.toString()]; + } + + const timestampPromise = publicClient + .getBlock({ blockNumber }) + .then((block) => block.timestamp); + + blockNumberToTimestampCache[blockNumber.toString()] = timestampPromise; + + return timestampPromise; +}; export const assertUnreachable = (x: never): never => { throw Error(`unhandled case: ${x}`); @@ -32,12 +56,11 @@ export const fromBaseUnit = ( }; // Get logs from the blockchain in chunks -export const getLogsChunked = async ( +export const getLogsChunked = async ( publicClient: PublicClient, - events: E, fromBlock: bigint, toBlock: bigint, -): Promise => { +): Promise<{ log: RFoxLog; timestamp: bigint }[]> => { const logs = []; const progressBar = new cliProgress.SingleBar({ @@ -51,7 +74,7 @@ export const getLogsChunked = async ( }); progressBar.start( - Number((toBlock - fromBlock) / GET_LOGS_BLOCK_STEP_SIZE), + Math.ceil(Number(toBlock - fromBlock) / Number(GET_LOGS_BLOCK_STEP_SIZE)), 0, { speed: "N/A", @@ -68,18 +91,44 @@ export const getLogsChunked = async ( toBlockInner = toBlock; } - const logsChunk = await publicClient.getLogs({ - events, + const logsChunk = await publicClient.getContractEvents({ + address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, + abi: stakingV1Abi, fromBlock: fromBlockInner, toBlock: toBlockInner, }); - logs.push(...(logsChunk as RFoxLog[])); + // Attach the block timestamp for each log + /** + TEMP: Free-tier rate limiting means we cant do a promise.all here + const logsChunkWithTimestamp: { log: RFoxLog; timestamp: bigint }[] = + await Promise.all( + logsChunk.map(async (log) => { + const timestamp = await getBlockTimestamp( + publicClient, + log.blockNumber, + ); + return { + log: log as RFoxLog, + timestamp, + }; + }), + ); + */ + const logsChunkWithTimestamp: { log: RFoxLog; timestamp: bigint }[] = []; + for (const log of logsChunk) { + const timestamp = await getBlockTimestamp(publicClient, log.blockNumber); + logsChunkWithTimestamp.push({ + log: log as RFoxLog, + timestamp, + }); + } - // Set fromBlockInner toBlockInner + 1 so we don't double fetch that block - fromBlockInner = toBlockInner + 1n; + logs.push(...logsChunkWithTimestamp); progressBar.increment(); + + fromBlockInner = toBlockInner; } progressBar.stop(); diff --git a/scripts/rewards-distribution/index.ts b/scripts/rewards-distribution/index.ts index fdd9985..6f04b52 100644 --- a/scripts/rewards-distribution/index.ts +++ b/scripts/rewards-distribution/index.ts @@ -3,12 +3,12 @@ import { ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, RUNE_DECIMALS, } from "./constants"; -import { fromBaseUnit, getLogsChunked, indexBy, toBaseUnit } from "./helpers"; +import { fromBaseUnit, getLogsChunked, toBaseUnit } from "./helpers"; import { publicClient } from "./client"; -import { rFoxEvents } from "./events"; import { calculateRewards } from "./calculateRewards/calculateRewards"; import { stakingV1Abi } from "./generated/abi-types"; import assert from "assert"; +import { Address } from "viem"; const inquireBlockRange = async (): Promise<{ fromBlock: bigint; @@ -22,13 +22,13 @@ const inquireBlockRange = async (): Promise<{ type: "number", name: "fromBlock", message: "What is the START block number of this epoch?", - default: 215722218, // TODO: remove this default + default: 216083216, // TODO: remove this default }, { type: "number", name: "toBlock", message: "What is the END block number of this epoch?", - default: 215722586, // TODO: remove this default + default: 216092990, // TODO: remove this default }, ]; @@ -95,43 +95,75 @@ const main = async () => { fromBaseUnit(totalRuneAmountToDistroBaseUnit, RUNE_DECIMALS), ); - const totalStaked = await publicClient.readContract({ - // TODO: dotenv or similar for contract addresses - address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, - abi: stakingV1Abi, - functionName: "totalStaked", - args: [], - blockNumber: toBlock, - }); - - const epochBlockReward = await publicClient.readContract({ - // TODO: dotenv or similar for contract addresses - address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, - abi: stakingV1Abi, - functionName: "rewardPerToken", - args: [], - blockNumber: toBlock, + const [previousEpochEndBlock, epochEndBlock, [initLog]] = await Promise.all([ + publicClient.getBlock({ + blockNumber: fromBlock - 1n, + }), + publicClient.getBlock({ + blockNumber: toBlock, + }), + publicClient.getContractEvents({ + address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, + abi: stakingV1Abi, + eventName: "Initialized", + fromBlock: "earliest", + toBlock: "latest", + }), + ]); + + const contractCreationBlockNumber = initLog.blockNumber; + + const contractCreationBlock = await publicClient.getBlock({ + blockNumber: contractCreationBlockNumber, }); const logs = await getLogsChunked( publicClient, - rFoxEvents, - fromBlock, + contractCreationBlockNumber, toBlock, ); - const logsByBlockNumber = indexBy(logs, "blockNumber"); - - const epochRewardByAccount = calculateRewards( - fromBlock, - toBlock, - logsByBlockNumber, - epochBlockReward, - totalStaked, + const earnedRewardsByAccount = calculateRewards( + contractCreationBlock, + previousEpochEndBlock, + epochEndBlock, + logs, ); - console.log("rewards to be distributed:"); - console.log(epochRewardByAccount); + // validate rewards per account against the contract + for (const [account, calculatedReward] of Object.entries( + earnedRewardsByAccount, + )) { + const [previousTotalEarnedForAccount, currentTotalEarnedForAccount] = + await Promise.all([ + publicClient.readContract({ + // TODO: dotenv or similar for contract addresses + address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, + abi: stakingV1Abi, + functionName: "earned", + args: [account as Address], + blockNumber: fromBlock - 1n, // The end of the previous epoch + }), + publicClient.readContract({ + // TODO: dotenv or similar for contract addresses + address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, + abi: stakingV1Abi, + functionName: "earned", + args: [account as Address], + blockNumber: toBlock, + }), + ]); + + const onChainReward = + currentTotalEarnedForAccount - previousTotalEarnedForAccount; + + assert( + calculatedReward === onChainReward, + `Expected reward for ${account} to be ${onChainReward}, got ${calculatedReward}`, + ); + } + + console.log("Validation passed."); // TODO: Confirm details again before proceeding };