-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: staking rewards accounting with on-chain validation
- Loading branch information
1 parent
afa3e4c
commit 8c7f76f
Showing
5 changed files
with
375 additions
and
83 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
284 changes: 245 additions & 39 deletions
284
scripts/rewards-distribution/calculateRewards/calculateRewards.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string, RFoxLog[]>, | ||
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<Address, bigint> = {}; | ||
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<Address, bigint> = {}; | ||
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<Address, StakingInfo> = {}; | ||
|
||
const stakingLogs = logs.filter( | ||
( | ||
logWithTimestamp, | ||
): logWithTimestamp is { log: StakeLog | UnstakeLog; timestamp: bigint } => | ||
isLogType("Stake", logWithTimestamp.log) || | ||
isLogType("Unstake", logWithTimestamp.log), | ||
); | ||
|
||
const epochStartRewardsByAccount: Record<Address, bigint> = {}; | ||
const epochEndRewardsByAccount: Record<Address, bigint> = {}; | ||
|
||
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<StakeLog>("Stake", log): | ||
({ | ||
stakingInfo, | ||
rewardPerTokenStored, | ||
lastUpdateTimestamp, | ||
totalStaked, | ||
} = stake( | ||
log.args.amount, | ||
log.args.runeAddress, | ||
stakingInfo, | ||
rewardPerTokenStored, | ||
totalStaked, | ||
currentTimestamp, | ||
lastUpdateTimestamp, | ||
)); | ||
break; | ||
case isLogType<UnstakeLog>("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<Address, bigint> = {}; | ||
|
||
for (const [account, epochEndReward] of Object.entries( | ||
epochEndRewardsByAccount, | ||
)) { | ||
earnedRewardsByAccount[account as Address] = | ||
epochEndReward - (epochStartRewardsByAccount[account as Address] ?? 0n); | ||
} | ||
|
||
return epochRewardByAccount; | ||
return earnedRewardsByAccount; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.