Skip to content

Commit

Permalink
Merge pull request #79 from shapeshift/cli-account-script
Browse files Browse the repository at this point in the history
  • Loading branch information
kaladinlight committed Jul 16, 2024
2 parents bf6db8d + 39f5912 commit ef2c5d0
Show file tree
Hide file tree
Showing 34 changed files with 1,527 additions and 5,436 deletions.
13 changes: 0 additions & 13 deletions .github/workflows/typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,3 @@ jobs:
with:
node-version-file: .nvmrc
cache: yarn

- name: Install Dependencies
id: install
run: |
yarn
cd scripts/rewards-distribution
yarn
- name: Run Typescript tests
id: test
run: |
cd scripts/rewards-distribution
yarn test
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,4 @@ Please check out the [wiki](https://github.com/shapeshift/rFOX/wiki/rFOX) for mo
### CLI dev

- Run foundry's anvil with `anvil`
- Rewards distribution can be tested locally with `ts-node scripts/rewards-distribution/index.ts`
- Rewards distribution script can be debugged locally with `cd scripts/rewards-distribution` and `NODE_OPTIONS="-r ts-node/register" node --inspect-brk index.ts` then going to `chrome://inspect` in Chrome to open the Node.JS debugging tools
- Ensure you CTRL + C anvil and restart it between run to clear your local blockchain state
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"typescript": "^5.4.5"
},
"scripts": {
"build": "forge build --root foundry && cd scripts/rewards-distribution && yarn && yarn wagmi generate",
"build": "forge build --root foundry && cd scripts/hotWalletCli && yarn && yarn generate",
"lint:sol": "npx prettier --write --plugin=prettier-plugin-solidity foundry/src/**/*.sol foundry/test/**/*.t.sol"
},
"simple-git-hooks": {
Expand Down
282 changes: 282 additions & 0 deletions scripts/hotWalletCli/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import * as prompts from '@inquirer/prompts'
import axios, { isAxiosError } from 'axios'
import BigNumber from 'bignumber.js'
import ora, { Ora } from 'ora'
import { Address, PublicClient, createPublicClient, getAddress, getContract, http } from 'viem'
import { arbitrum } from 'viem/chains'
import { RFOX_REWARD_RATE } from './constants'
import { stakingV1Abi } from './generated/abi'
import { error, info, warn } from './logging'
import { RewardDistribution } from './types'

const INFURA_API_KEY = process.env['INFURA_API_KEY']

if (!INFURA_API_KEY) {
error('INFURA_API_KEY not set. Please make sure you copied the sample.env and filled out your .env file.')
process.exit(1)
}

const AVERAGE_BLOCK_TIME_BLOCKS = 1000
const ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS = '0xac2a4fd70bcd8bab0662960455c363735f0e2b56'

type Revenue = {
address: string
amount: string
}

type ClosingState = {
rewardUnits: bigint
totalRewardUnits: bigint
runeAddress: string
}

type ClosingStateByStakingAddress = Record<string, ClosingState>

export class Client {
private rpc: PublicClient

constructor() {
this.rpc = createPublicClient({
chain: arbitrum,
transport: http(`https://arbitrum-mainnet.infura.io/v3/${INFURA_API_KEY}`),
})
}

static async new(): Promise<Client> {
return new Client()
}

async getBlockByTimestamp(targetTimestamp: bigint, blockMode: 'earliest' | 'latest', spinner?: Ora): Promise<bigint> {
try {
const latestBlock = await this.rpc.getBlock()

if (targetTimestamp > latestBlock.timestamp) {
spinner?.fail()
error(`Block does not exit for target timestamp: ${targetTimestamp.toString()}, exiting.`)
process.exit(1)
}

const historicalBlock = await this.rpc.getBlock({
blockNumber: latestBlock.number - BigInt(AVERAGE_BLOCK_TIME_BLOCKS),
})

const averageBlockTimeSeconds =
Number(latestBlock.timestamp - historicalBlock.timestamp) / AVERAGE_BLOCK_TIME_BLOCKS

const timeDifferenceSeconds = latestBlock.timestamp - targetTimestamp
const targetBlocksToMove = BigInt(Math.floor(Number(timeDifferenceSeconds) / averageBlockTimeSeconds))

let blockNumber = latestBlock.number - targetBlocksToMove
while (true) {
if (blockNumber <= 0n) return 0n

const block = await this.rpc.getBlock({ blockNumber })

const timeDifferenceSeconds = targetTimestamp - block.timestamp

// Block is within 1 block before the target timestamp
if (timeDifferenceSeconds >= 0n && timeDifferenceSeconds <= averageBlockTimeSeconds) break

const blocksToMove = BigInt(Math.ceil(Math.abs(Number(timeDifferenceSeconds)) / averageBlockTimeSeconds))

if (block.timestamp > targetTimestamp) {
blockNumber -= blocksToMove
} else {
blockNumber += blocksToMove
}
}

// In case of multiple batched blocks for a target timestamp, find the earliest or latest block based on blockMode
while (true) {
if (blockNumber <= 0n) return 0n
if (blockNumber >= latestBlock.number) return latestBlock.number

const nextBlockNumber = blockMode === 'earliest' ? blockNumber - 1n : blockNumber + 1n

const block = await this.rpc.getBlock({ blockNumber: nextBlockNumber })

if (block.timestamp !== targetTimestamp) break

blockNumber = nextBlockNumber
}

return blockNumber
} catch (err) {
if (err instanceof Error) {
const text = `Failed to get block for timestamp: ${targetTimestamp}: ${err.message}, exiting.`
spinner ? spinner.fail(text) : error(text)
} else {
const text = `Failed to get block for timestamp: ${targetTimestamp}, exiting.`
spinner ? spinner.fail(text) : error(text)
}

process.exit(1)
}
}

async getRevenue(startTimestamp: number, endTimestamp: number): Promise<Revenue> {
try {
const { data } = await axios.get<Revenue>(
`https://api.thorchain.shapeshift.com/api/v1/affiliate/revenue?start=${startTimestamp}&end=${endTimestamp}`,
)
return data
} catch (err) {
if (isAxiosError(err)) {
error(
`Failed to get revenue for period (start: ${startTimestamp} - end: ${endTimestamp}): ${err.message}, exiting.`,
)
} else {
error(`Failed to get revenue for period (start: ${startTimestamp} - end: ${endTimestamp}), exiting.`)
}

process.exit(1)
}
}

private async getClosingStateByStakingAddress(
addresses: Address[],
startBlock: bigint,
endBlock: bigint,
): Promise<Record<string, ClosingState>> {
const contract = getContract({
address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS,
abi: stakingV1Abi,
client: { public: this.rpc },
})

const prevEpochEndBlock = startBlock - 1n

const closingStateByStakingAddress: Record<string, ClosingState> = {}
for await (const address of addresses) {
const [, , , , runeAddress] = await contract.read.stakingInfo([getAddress(address)], { blockNumber: endBlock })

const totalRewardUnitsPrevEpoch = await contract.read.earned([address], {
blockNumber: prevEpochEndBlock,
})
const totalRewardUnits = await contract.read.earned([address], { blockNumber: endBlock })

const rewardUnits = totalRewardUnits - totalRewardUnitsPrevEpoch

if (rewardUnits <= 0) continue

closingStateByStakingAddress[address] = { rewardUnits, totalRewardUnits, runeAddress }
}

return closingStateByStakingAddress
}

private async getDistributionsByStakingAddress(
closingStateByStakingAddress: ClosingStateByStakingAddress,
totalDistribution: BigNumber,
) {
const totalEpochRewardUnits = Object.values(closingStateByStakingAddress).reduce(
(prev, { rewardUnits }) => prev + rewardUnits,
0n,
)

const distributionsByStakingAddress: Record<string, RewardDistribution> = {}
for await (const [address, { rewardUnits, totalRewardUnits, runeAddress }] of Object.entries(
closingStateByStakingAddress,
)) {
const percentageShare = BigNumber(rewardUnits.toString()).div(totalEpochRewardUnits.toString())
const amount = percentageShare.times(totalDistribution.toString()).toFixed(0)

distributionsByStakingAddress[address] = {
amount,
rewardUnits: rewardUnits.toString(),
totalRewardUnits: totalRewardUnits.toString(),
rewardAddress: runeAddress,
txId: '',
}
}

return distributionsByStakingAddress
}

async calculateRewards(
startBlock: bigint,
endBlock: bigint,
secondsInEpoch: bigint,
totalDistribution: BigNumber,
): Promise<{ totalRewardUnits: string; distributionsByStakingAddress: Record<string, RewardDistribution> }> {
const spinner = ora('Calculating reward distribution').start()

try {
const stakeEvents = await this.rpc.getContractEvents({
address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS,
abi: stakingV1Abi,
eventName: 'Stake',
fromBlock: 'earliest',
toBlock: endBlock,
})

const addresses = [
...new Set(stakeEvents.map(event => event.args.account).filter(address => Boolean(address))),
] as Address[]

const closingStateByStakingAddress = await this.getClosingStateByStakingAddress(addresses, startBlock, endBlock)
const distributionsByStakingAddress = await this.getDistributionsByStakingAddress(
closingStateByStakingAddress,
totalDistribution,
)

const totalEpochRewardUnits = Object.values(closingStateByStakingAddress).reduce(
(prev, { rewardUnits }) => prev + rewardUnits,
0n,
)

const totalEpochDistribution = Object.values(distributionsByStakingAddress).reduce(
(prev, { amount }) => prev.plus(BigNumber(amount)),
BigNumber(0),
)

spinner.succeed()

info(`Total addresses receiving rewards: ${addresses.length}`)

const epochRewardUnits = RFOX_REWARD_RATE * secondsInEpoch
const epochRewardUnitsMargin = BigNumber(epochRewardUnits.toString()).times(0.0001)

if (epochRewardUnitsMargin.lte(Math.abs(Number(epochRewardUnits - totalEpochRewardUnits)))) {
warn(
'The total reward units calculated for all stakers is outside of the expected .01% margin of the total epoch reward units.',
)

info(`Total Reward Units Calculated: ${totalEpochRewardUnits}`)
info(`Total Epoch Reward Units: ${epochRewardUnits}`)

const confirmed = await prompts.confirm({ message: 'Do you want to continue? ' })

if (!confirmed) process.exit(0)
}

const totalDistributionMargin = totalDistribution.times(0.0001)

if (totalDistributionMargin.lte(Math.abs(totalDistribution.minus(totalEpochDistribution).toNumber()))) {
warn(
'The total reward distribution calculated for all stakers is outside of the expected .01% margin of the total rewards to be distributed.',
)

info(`Total Distribtution Calculated: ${totalEpochDistribution.div(100000000).toFixed()} RUNE`)
info(`Total Epoch Distribution: ${totalDistribution.div(100000000).toFixed()} RUNE`)

const confirmed = await prompts.confirm({ message: 'Do you want to continue? ' })

if (!confirmed) process.exit(0)
}

return {
totalRewardUnits: totalEpochRewardUnits.toString(),
distributionsByStakingAddress,
}
} catch (err) {
if (err instanceof Error) {
spinner.fail(`${err.message}, exiting.`)
} else {
spinner.fail('An unknown error occured while calculating reward distribution, exiting.')
}

process.exit(1)
}
}
}
16 changes: 16 additions & 0 deletions scripts/hotWalletCli/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,19 @@ import os from 'node:os'
import path from 'node:path'

export const RFOX_DIR = path.join(os.homedir(), 'rfox')
export const RFOX_REWARD_RATE = 1n * 10n ** 27n

export const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
Loading

0 comments on commit ef2c5d0

Please sign in to comment.