-
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.
chore: initial rewards distribution cli scaffolding (#50)
* feat: added input prompts for rewards distribution * wip: rewards distribution cli * wip: wiring up staking rewards calculation * wip: staking rewards progression * feat: staking rewards accounting with on-chain validation * feat: add progress bar to rewards validation * chore: remove unused rewards simulation
- Loading branch information
1 parent
bd94f64
commit 1a1fa9c
Showing
12 changed files
with
971 additions
and
278 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
263 changes: 263 additions & 0 deletions
263
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 |
---|---|---|
@@ -0,0 +1,263 @@ | ||
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"; | ||
|
||
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, | ||
) => { | ||
rewardPerTokenStored = rewardPerToken( | ||
rewardPerTokenStored, | ||
totalStaked, | ||
currentTimestamp, | ||
lastUpdateTimestamp, | ||
); | ||
lastUpdateTimestamp = currentTimestamp; | ||
stakingInfo.earnedRewards = earned( | ||
stakingInfo, | ||
rewardPerTokenStored, | ||
totalStaked, | ||
currentTimestamp, | ||
lastUpdateTimestamp, | ||
); | ||
stakingInfo.rewardPerTokenStored = rewardPerTokenStored; | ||
return { stakingInfo, rewardPerTokenStored, lastUpdateTimestamp }; | ||
}; | ||
|
||
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, | ||
)); | ||
|
||
stakingInfo.stakingBalance += amount; | ||
stakingInfo.runeAddress = runeAddress; | ||
totalStaked += amount; | ||
|
||
return { | ||
stakingInfo, | ||
rewardPerTokenStored, | ||
lastUpdateTimestamp, | ||
totalStaked, | ||
}; | ||
}; | ||
|
||
const unstake = ( | ||
amount: bigint, | ||
stakingInfo: StakingInfo, | ||
rewardPerTokenStored: bigint, | ||
totalStaked: bigint, | ||
currentTimestamp: bigint, | ||
lastUpdateTimestamp: bigint, | ||
) => { | ||
({ stakingInfo, rewardPerTokenStored, lastUpdateTimestamp } = updateReward( | ||
stakingInfo, | ||
rewardPerTokenStored, | ||
totalStaked, | ||
currentTimestamp, | ||
lastUpdateTimestamp, | ||
)); | ||
|
||
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; | ||
} | ||
} | ||
|
||
let stakingInfo = | ||
stakingInfoByAccount[log.args.account] ?? getEmptyStakingInfo(); | ||
|
||
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 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import assert from "assert"; | ||
import { createPublicClient, http } from "viem"; | ||
import { arbitrum } from "viem/chains"; | ||
|
||
// TODO: dotenv or similar | ||
assert(process.env.ARBITRUM_JSON_RPC_URL, "ARBITRUM_JSON_RPC_URL is required"); | ||
|
||
export const publicClient = createPublicClient({ | ||
chain: arbitrum, | ||
transport: http(process.env.ARBITRUM_JSON_RPC_URL), | ||
}); |
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,25 +1,13 @@ | ||
import { createPublicClient, createWalletClient, http } from "viem"; | ||
import { privateKeyToAccount } from "viem/accounts"; | ||
import { localhost } from "viem/chains"; | ||
import { Address } from "viem"; | ||
|
||
export const localChain = { | ||
...localhost, | ||
id: 31337, | ||
} as const; | ||
export const RUNE_DECIMALS = 8; | ||
|
||
export const localOwnerWalletClient = createWalletClient({ | ||
chain: localChain, | ||
account: privateKeyToAccount(process.env.OWNER_PRIVATE_KEY as `0x${string}`), | ||
transport: http(process.env.ANVIL_JSON_RPC_URL), | ||
}); | ||
export const WAD = 1n * 10n ** 18n; | ||
export const REWARD_RATE = 1_000_000_000n; | ||
|
||
export const localUserWalletClient = createWalletClient({ | ||
chain: localChain, | ||
account: privateKeyToAccount(process.env.USER_PRIVATE_KEY as `0x${string}`), | ||
transport: http(process.env.ANVIL_JSON_RPC_URL), | ||
}); | ||
// The number of blocks to query at a time, when fetching logs | ||
export const GET_LOGS_BLOCK_STEP_SIZE = 20000n; | ||
|
||
export const localPublicClient = createPublicClient({ | ||
chain: localChain, | ||
transport: http(process.env.ANVIL_JSON_RPC_URL), | ||
}); | ||
// RFOX on Arbitrum ERC1967Proxy contract address | ||
export const ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS: Address = | ||
"0xd612B64A134f3D4830542B7463CE8ca8a29D7268"; |
Oops, something went wrong.