diff --git a/cli/src/client.ts b/cli/src/client.ts index 64db584..5a80538 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -7,7 +7,7 @@ import { arbitrum } from 'viem/chains' import { stakingV1Abi } from '../generated/abi' import { RFOX_REWARD_RATE } from './constants' import { error, info, warn } from './logging' -import { RewardDistribution } from './types' +import { CalculateRewardsArgs, RewardDistribution } from './types' const INFURA_API_KEY = process.env['INFURA_API_KEY'] @@ -17,9 +17,13 @@ if (!INFURA_API_KEY) { } const AVERAGE_BLOCK_TIME_BLOCKS = 1000 -const ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS = '0xac2a4fd70bcd8bab0662960455c363735f0e2b56' const THORCHAIN_PRECISION = 8 +export const ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_FOX: Address = '0xaC2a4fD70BCD8Bab0662960455c363735f0e2b56' +export const ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_UNIV2_ETH_FOX: Address = '0x5F6Ce0Ca13B87BD738519545d3E018e70E339c24' + +export const stakingContracts = [ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_FOX] + type Revenue = { addresses: string[] amount: string @@ -145,6 +149,7 @@ export class Client { } } + // TODO: get price for UNI-V2 ETH/FOX pool async getPrice(): Promise { try { const { data } = await axios.get( @@ -176,12 +181,13 @@ export class Client { } private async getClosingStateByStakingAddress( + stakingContract: Address, addresses: Address[], startBlock: bigint, endBlock: bigint, ): Promise> { const contract = getContract({ - address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, + address: stakingContract, abi: stakingV1Abi, client: { public: this.rpc }, }) @@ -235,17 +241,22 @@ export class Client { return distributionsByStakingAddress } - async calculateRewards( - startBlock: bigint, - endBlock: bigint, - secondsInEpoch: bigint, - totalDistribution: BigNumber, - ): Promise<{ totalRewardUnits: string; distributionsByStakingAddress: Record }> { + async calculateRewards({ + stakingContract, + startBlock, + endBlock, + secondsInEpoch, + distributionRate, + totalRevenue, + }: CalculateRewardsArgs): Promise<{ + totalRewardUnits: string + distributionsByStakingAddress: Record + }> { const spinner = ora('Calculating reward distribution').start() try { const stakeEvents = await this.rpc.getContractEvents({ - address: ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS, + address: stakingContract, abi: stakingV1Abi, eventName: 'Stake', fromBlock: 'earliest', @@ -256,7 +267,15 @@ export class Client { ...new Set(stakeEvents.map(event => event.args.account).filter(address => Boolean(address))), ] as Address[] - const closingStateByStakingAddress = await this.getClosingStateByStakingAddress(addresses, startBlock, endBlock) + const totalDistribution = BigNumber(totalRevenue).times(distributionRate) + + const closingStateByStakingAddress = await this.getClosingStateByStakingAddress( + stakingContract, + addresses, + startBlock, + endBlock, + ) + const distributionsByStakingAddress = await this.getDistributionsByStakingAddress( closingStateByStakingAddress, totalDistribution, diff --git a/cli/src/index.ts b/cli/src/index.ts index 9593c9a..52060c6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,13 +5,13 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import ora from 'ora' -import { Client } from './client' +import { ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_FOX, Client, stakingContracts } from './client' import { MONTHS } from './constants' import { isEpochDistributionStarted } from './file' import { IPFS } from './ipfs' import { error, info, success, warn } from './logging' import { create, recoverKeystore } from './mnemonic' -import { Epoch, RFOXMetadata } from './types' +import { Epoch, EpochDetails, RFOXMetadata } from './types' import { Wallet } from './wallet' import { Command } from 'commander' @@ -48,13 +48,28 @@ const processEpoch = async () => { `Total ${month} revenue earned by ${revenue.addresses}: ${BigNumber(revenue.amount).div(100000000).toFixed(8)} RUNE`, ) - info(`Share of total revenue to be distributed as rewards: ${metadata.distributionRate * 100}%`) - info(`Share of total revenue to buy back fox and burn: ${metadata.burnRate * 100}%`) - - const totalDistribution = BigNumber(BigNumber(revenue.amount).times(metadata.distributionRate).toFixed(0)) + const totalDistributionRate = Object.entries(metadata.distributionRateByStakingContract).reduce( + (prev, [stakingContract, distributionRate]) => { + info( + `Share of total revenue to be distributed as rewards for staking contract: ${stakingContract}: ${distributionRate * 100}%`, + ) + const totalDistribution = BigNumber(BigNumber(revenue.amount).times(distributionRate).toFixed(0)) + info( + `Total rewards to be distributed for staking contract: ${stakingContract}: ${totalDistribution.div(100000000).toFixed()} RUNE`, + ) + return prev + distributionRate + }, + 0, + ) + info(`Share of total revenue to be distributed as rewards: ${totalDistributionRate * 100}%`) + const totalDistribution = BigNumber(BigNumber(revenue.amount).times(totalDistributionRate).toFixed(0)) info(`Total rewards to be distributed: ${totalDistribution.div(100000000).toFixed()} RUNE`) + info(`Share of total revenue to buy back fox and burn: ${metadata.burnRate * 100}%`) + const totalBurn = BigNumber(BigNumber(revenue.amount).times(metadata.burnRate).toFixed(0)) + info(`Total amount to be used to buy back fox and burn: ${totalBurn.div(100000000).toFixed()} RUNE`) + const spinner = ora('Detecting epoch start and end blocks...').start() const startBlock = await client.getBlockByTimestamp( @@ -76,30 +91,41 @@ const processEpoch = async () => { const secondsInEpoch = BigInt(Math.floor((metadata.epochEndTimestamp - metadata.epochStartTimestamp) / 1000)) - const { totalRewardUnits, distributionsByStakingAddress } = await client.calculateRewards( - startBlock, - endBlock, - secondsInEpoch, - totalDistribution, - ) - const { assetPriceUsd, runePriceUsd } = await client.getPrice() + const detailsByStakingContract: Record = {} + for (const stakingContract of stakingContracts) { + const distributionRate = metadata.distributionRateByStakingContract[stakingContract] + + const { totalRewardUnits, distributionsByStakingAddress } = await client.calculateRewards({ + stakingContract, + startBlock, + endBlock, + secondsInEpoch, + distributionRate, + totalRevenue: revenue.amount, + }) + + detailsByStakingContract[stakingContract] = { + assetPriceUsd, + distributionRate, + distributionsByStakingAddress, + totalRewardUnits, + } + } + const epochHash = await ipfs.addEpoch({ number: metadata.epoch, startTimestamp: metadata.epochStartTimestamp, endTimestamp: metadata.epochEndTimestamp, startBlock: Number(startBlock), endBlock: Number(endBlock), + treasuryAddress: metadata.treasuryAddress, totalRevenue: revenue.amount, - totalRewardUnits, - distributionRate: metadata.distributionRate, burnRate: metadata.burnRate, - assetPriceUsd, runePriceUsd, - treasuryAddress: metadata.treasuryAddress, distributionStatus: 'pending', - distributionsByStakingAddress, + detailsByStakingContract, }) const nextEpochStartDate = new Date(metadata.epochEndTimestamp + 1) @@ -195,6 +221,24 @@ const update = async () => { ) } +const migrate = async () => { + const ipfs = await IPFS.new() + + const metadata = await ipfs.migrate() + const hash = await ipfs.updateMetadata(metadata) + + if (!hash) return + + success(`rFOX metadata has been updated!`) + + info( + 'Please update the rFOX Wiki (https://github.com/shapeshift/rFOX/wiki/rFOX-Metadata) and notify the DAO accordingly. Thanks!', + ) + warn( + 'Important: CURRENT_EPOCH_METADATA_IPFS_HASH must be updated in web (https://github.com/shapeshift/web/blob/develop/src/pages/RFOX/constants.ts).', + ) +} + const processDistribution = async (metadata: RFOXMetadata, epoch: Epoch, wallet: Wallet, ipfs: IPFS) => { const client = await Client.new() @@ -205,12 +249,11 @@ const processDistribution = async (metadata: RFOXMetadata, epoch: Epoch, wallet: const { assetPriceUsd, runePriceUsd } = await client.getPrice() - const processedEpochHash = await ipfs.addEpoch({ - ...processedEpoch, - assetPriceUsd, - runePriceUsd, - distributionStatus: 'complete', - }) + processedEpoch.runePriceUsd = runePriceUsd + processedEpoch.distributionStatus = 'complete' + processedEpoch.detailsByStakingContract[ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_FOX].assetPriceUsd = assetPriceUsd + + const processedEpochHash = await ipfs.addEpoch(processedEpoch) const metadataHash = await ipfs.updateMetadata(metadata, { epoch: { number: processedEpoch.number, hash: processedEpochHash }, @@ -241,7 +284,7 @@ const main = async () => { } } - const choice = await prompts.select<'process' | 'run' | 'recover' | 'update'>({ + const choice = await prompts.select<'process' | 'run' | 'recover' | 'update' | 'migrate'>({ message: 'What do you want to do?', choices: [ { @@ -264,6 +307,11 @@ const main = async () => { value: 'update', description: 'Start here to update an rFOX metadata.', }, + { + name: 'Migrate rFOX metadata', + value: 'migrate', + description: 'Start here to migrate an existing rFOX metadata to the new format.', + }, ], }) @@ -276,6 +324,8 @@ const main = async () => { return recover() case 'update': return update() + case 'migrate': + return migrate() default: error(`Invalid choice: ${choice}, exiting.`) process.exit(1) diff --git a/cli/src/ipfs.ts b/cli/src/ipfs.ts index 4c5c4e1..66dc655 100644 --- a/cli/src/ipfs.ts +++ b/cli/src/ipfs.ts @@ -3,8 +3,9 @@ import PinataClient from '@pinata/sdk' import axios, { isAxiosError } from 'axios' import BigNumber from 'bignumber.js' import { error, info } from './logging' -import { Epoch, RFOXMetadata, RewardDistribution } from './types' +import { Epoch, EpochDetails, RFOXMetadata, RewardDistribution } from './types' import { MONTHS } from './constants' +import { ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_FOX } from './client' const PINATA_API_KEY = process.env['PINATA_API_KEY'] const PINATA_SECRET_API_KEY = process.env['PINATA_SECRET_API_KEY'] @@ -31,40 +32,68 @@ if (!PINATA_GATEWAY_API_KEY) { process.exit(1) } -const isMetadata = (obj: any): obj is RFOXMetadata => - obj && - typeof obj === 'object' && - typeof obj.epoch === 'number' && - typeof obj.epochStartTimestamp === 'number' && - typeof obj.epochEndTimestamp === 'number' && - typeof obj.distributionRate === 'number' && - typeof obj.burnRate === 'number' && - typeof obj.treasuryAddress === 'string' && - typeof obj.ipfsHashByEpoch === 'object' && - Object.values(obj.ipfsHashByEpoch ?? {}).every(value => typeof value === 'string') - -const isEpoch = (obj: any): obj is Epoch => - obj && - typeof obj === 'object' && - typeof obj.number === 'number' && - typeof obj.startTimestamp === 'number' && - typeof obj.endTimestamp === 'number' && - typeof obj.startBlock === 'number' && - typeof obj.endBlock === 'number' && - typeof obj.totalRevenue === 'string' && - typeof obj.totalRewardUnits === 'string' && - typeof obj.distributionRate === 'number' && - typeof obj.burnRate === 'number' && - typeof obj.distributionsByStakingAddress === 'object' && - Object.values(obj.distributionsByStakingAddress ?? {}).every(isRewardDistribution) - -const isRewardDistribution = (obj: any): obj is RewardDistribution => - obj && - typeof obj === 'object' && - typeof obj.amount === 'string' && - typeof obj.rewardUnits === 'string' && - typeof obj.txId === 'string' && - typeof obj.rewardAddress === 'string' +const isMetadata = (obj: any): obj is RFOXMetadata => { + return ( + obj !== null && + typeof obj === 'object' && + typeof obj.epoch === 'number' && + typeof obj.epochStartTimestamp === 'number' && + typeof obj.epochEndTimestamp === 'number' && + typeof obj.treasuryAddress === 'string' && + Boolean(obj.treasuryAddress) && + typeof obj.burnRate === 'number' && + obj.distributionRateByStakingContract !== null && + typeof obj.distributionRateByStakingContract === 'object' && + Object.values(obj.distributionRateByStakingContract).every(value => typeof value === 'number') && + obj.ipfsHashByEpoch !== null && + typeof obj.ipfsHashByEpoch === 'object' && + Object.values(obj.ipfsHashByEpoch).every(value => typeof value === 'string' && Boolean(value)) + ) +} + +const isEpoch = (obj: any): obj is Epoch => { + return ( + obj !== null && + typeof obj === 'object' && + typeof obj.number === 'number' && + typeof obj.startTimestamp === 'number' && + typeof obj.endTimestamp === 'number' && + typeof obj.startBlock === 'number' && + typeof obj.endBlock === 'number' && + typeof obj.treasuryAddress === 'string' && + Boolean(obj.treasuryAddress) && + typeof obj.totalRevenue === 'string' && + Boolean(obj.totalRevenue) && + typeof obj.burnRate === 'number' && + (obj.distributionStatus === 'pending' || obj.distributionStatus === 'complete') && + obj.detailsByStakingContract !== null && + typeof obj.detailsByStakingContract === 'object' && + Object.values(obj.detailsByStakingContract).every(isEpochDetails) + ) +} + +export function isEpochDetails(obj: any): obj is EpochDetails { + return ( + obj !== null && + typeof obj === 'object' && + typeof obj.totalRewardUnits === 'string' && + typeof obj.distributionRate === 'number' && + obj.distributionsByStakingAddress !== null && + typeof obj.distributionsByStakingAddress === 'object' && + Object.values(obj.distributionsByStakingAddress).every(isRewardDistribution) + ) +} + +const isRewardDistribution = (obj: any): obj is RewardDistribution => { + return ( + obj !== null && + typeof obj === 'object' && + typeof obj.amount === 'string' && + typeof obj.rewardUnits === 'string' && + typeof obj.txId === 'string' && + typeof obj.rewardAddress === 'string' + ) +} export class IPFS { private client: PinataClient @@ -115,13 +144,17 @@ export class IPFS { if (isEpoch(data)) { const month = MONTHS[new Date(data.startTimestamp).getUTCMonth()] - const totalAddresses = Object.keys(data.distributionsByStakingAddress).length - const totalRewards = Object.values(data.distributionsByStakingAddress) - .reduce((prev, distribution) => { - return prev.plus(distribution.amount) - }, BigNumber(0)) - .div(100000000) - .toFixed() + + const distributions = Object.values(data.detailsByStakingContract).flatMap(details => + Object.values(details.distributionsByStakingAddress), + ) + + const totalDistributionAmount = distributions.reduce((prev, distribution) => { + return prev.plus(distribution.amount) + }, BigNumber(0)) + + const totalRewards = totalDistributionAmount.div(100000000).toFixed() + const totalAddresses = distributions.length info( `Running ${month} rFOX reward distribution for Epoch #${data.number}:\n - Total Rewards: ${totalRewards} RUNE\n - Total Addresses: ${totalAddresses}`, @@ -197,9 +230,17 @@ export class IPFS { switch (choice) { case 'distributionRate': { + const choice = await prompts.select({ + message: 'What staking contract do you want to update', + choices: Object.keys(metadata.distributionRateByStakingContract).map(stakingContract => ({ + name: stakingContract, + value: stakingContract, + })), + }) + const distributionRate = parseFloat( await prompts.input({ - message: `The distribution rate is currently set to ${metadata.distributionRate}, what do you want to update it to? `, + message: `The distribution rate is currently set to ${metadata.distributionRateByStakingContract[choice]}, what do you want to update it to? `, }), ) @@ -208,7 +249,7 @@ export class IPFS { return this.updateMetadata(metadata) } - metadata.distributionRate = distributionRate + metadata.distributionRateByStakingContract[choice] = distributionRate break } @@ -254,7 +295,14 @@ export class IPFS { if (confirmed) { return this.updateMetadata(metadata) } else { - if (metadata.distributionRate + metadata.burnRate > 1) { + const totalDistributionRate = Object.values(metadata.distributionRateByStakingContract).reduce( + (prev, distributionRate) => { + return prev + distributionRate + }, + 0, + ) + + if (totalDistributionRate + metadata.burnRate > 1) { error( `Invalid rates, the sum of the distribution rate and burn rate must be a number between 0 and 1 (ex. 0.5).`, ) @@ -262,7 +310,7 @@ export class IPFS { } info( - `The new metadata values will be:\n - Distribtution Rate: ${metadata.distributionRate}\n - Burn Rate: ${metadata.burnRate}\n - Treasury Address: ${metadata.treasuryAddress}`, + `The new metadata values will be:\n - Distribtution Rates: ${JSON.stringify(metadata.distributionRateByStakingContract)}\n - Burn Rate: ${metadata.burnRate}\n - Treasury Address: ${metadata.treasuryAddress}`, ) const confirmed = await prompts.confirm({ @@ -332,4 +380,86 @@ export class IPFS { return epoch } + + async migrate(): Promise { + const metadataHash = await prompts.input({ + message: `What is the IPFS hash for the rFOX metadata you want to migrate? `, + }) + + try { + const { data } = await axios.get(`${PINATA_GATEWAY_URL}/ipfs/${metadataHash}`, { + headers: { + 'x-pinata-gateway-token': PINATA_GATEWAY_API_KEY, + }, + }) + + const metadata = ((): RFOXMetadata => { + if (isMetadata(data)) return data + + return { + epoch: data.epoch, + epochStartTimestamp: data.epochStartTimestamp, + epochEndTimestamp: data.epochEndTimestamp, + treasuryAddress: data.treasuryAddress, + burnRate: data.burnRate, + distributionRateByStakingContract: { + [ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_FOX]: data.distributionRate, + }, + ipfsHashByEpoch: {}, + } + })() + + for (const [epochNum, epochHash] of Object.entries(data.ipfsHashByEpoch)) { + const { data } = await axios.get(`${PINATA_GATEWAY_URL}/ipfs/${epochHash}`, { + headers: { + 'x-pinata-gateway-token': PINATA_GATEWAY_API_KEY, + }, + }) + + if (isEpoch(data)) { + metadata.ipfsHashByEpoch[epochNum] = epochHash as string + continue + } + + const epoch: Epoch = { + number: data.number, + startTimestamp: data.startTimestamp, + endTimestamp: data.endTimestamp, + startBlock: data.startBlock, + endBlock: data.endBlock, + treasuryAddress: data.treasuryAddress, + totalRevenue: data.totalRevenue, + burnRate: data.burnRate, + ...(data.runePriceUsd && { + runePriceUsd: data.runePriceUsd, + }), + distributionStatus: data.distributionStatus, + detailsByStakingContract: { + [ARBITRUM_RFOX_PROXY_CONTRACT_ADDRESS_FOX]: { + totalRewardUnits: data.totalRewardUnits, + distributionRate: data.distributionRate, + ...(data.assetPriceUsd && { + assetPriceUsd: data.assetPriceUsd, + }), + distributionsByStakingAddress: data.distributionsByStakingAddress, + }, + }, + } + + metadata.ipfsHashByEpoch[epochNum] = await this.addEpoch(epoch) + } + + return metadata + } catch (err) { + if (isAxiosError(err)) { + error( + `Failed to get content of IPFS hash (${metadataHash}): ${err.request?.data || err.response?.data || err.message}, exiting.`, + ) + } else { + error(`Failed to get content of IPFS hash (${metadataHash}): ${err}, exiting.`) + } + + process.exit(1) + } + } } diff --git a/cli/src/types.ts b/cli/src/types.ts index 0d6c727..4380fda 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -1,3 +1,5 @@ +import { Address } from 'viem' + /** * Metadata for rFOX staking program (IPFS) * @typedef {Object} RFOXMetadata @@ -5,9 +7,9 @@ * @property {number} epochStartTimestamp - The start timestamp for the current epoch * @property {number} epochEndTimestamp - The end timestamp for the current epoch * @property {string} treasuryAddress - The treasury address on THORChain used to determine revenue earned by the DAO for rFOX reward distributions and total burn - * @property {number} distributionRate - The percentage of revenue (in RUNE) accumulated by the treasury to be distributed as rewards * @property {number} burnRate - The percentage of revenue (in RUNE) accumulated by the treasury to be used to buy FOX from the open market and subsequently burned - * @property {Record} ipfsHashByEpoch - A record of epoch numbers to their corresponding IPFS hashes + * @property {number} distributionRateByStakingContract - The percentage of revenue (in RUNE) accumulated by the treasury to be distributed as rewards for each staking contract + * @property {Record} ipfsHashByEpoch - The IPFS hashes for each epoch */ export type RFOXMetadata = { /** The current epoch number */ @@ -16,18 +18,18 @@ export type RFOXMetadata = { epochStartTimestamp: number /** The end timestamp for the current epoch */ epochEndTimestamp: number - /** The current percentage of revenue (RUNE) earned by the treasury to be distributed as rewards */ - distributionRate: number - /** The current percentage of revenue (RUNE) earned by the treasury to be used to buy FOX from the open market and subsequently burned */ - burnRate: number /** The treasury address on THORChain used to determine revenue earned by the DAO for rFOX reward distributions and total burn */ treasuryAddress: string - /** A record of epoch number to their corresponding IPFS hashes */ - ipfsHashByEpoch: Record + /** The current percentage of revenue (RUNE) earned by the treasury to be used to buy FOX from the open market and subsequently burned */ + burnRate: number + /** The current percentage of revenue (RUNE) earned by the treasury to be distributed as rewards for each staking contract */ + distributionRateByStakingContract: Record + /** The IPFS hashes for each epoch */ + ipfsHashByEpoch: Record } /** - * Details for a single reward distribution + * Details for a single reward distribution (IPFS) * @typedef {Object} RewardDistribution * @property {string} amount - The amount (RUNE) distributed to the reward address * @property {string} rewardUnits - The rFOX staking reward units earned for the current epoch @@ -52,15 +54,16 @@ export type RewardDistribution = { * Details for a completed epoch (IPFS) * @typedef {Object} Epoch * @property {number} number - The epoch number for this epoch - * @property {number} days - The number of days in this epoch + * @property {number} startTimestamp - The start timestamp for this epoch + * @property {number} endTimestamp - The end timestamp for this epoch * @property {number} startBlock - The start block for this epoch * @property {number} endBlock - The end block for this epoch + * @property {string} treasuryAddress - The treasury address on THORChain used to determine revenue earned by the DAO for rFOX reward distributions and total burn * @property {string} totalRevenue - The total revenue (RUNE) earned by the treasury for this epoch - * @property {number} distributionRate - The percentage of revenue (RUNE) accumulated by the treasury to be distributed as rewards for this epoch * @property {number} burnRate - The percentage of revenue (RUNE) accumulated by the treasury to be used to buy FOX from the open market and subsequently burned for this epoch - * @property {string} treasuryAddress - The treasury address on THORChain used to determine revenue earned by the DAO for rFOX reward distributions and total burn + * @property {string} runePriceUsd - The spot price of rune in USD * @property {'pending' | 'complete'} distributionStatus - The status of the reward distribution - * @property {Record} distributionsByStakingAddress - A record of staking address to distribution for this epoch + * @property {Record} detailsByStakingAddress - The details for each staking contract for this epoch */ export type Epoch = { /** The epoch number for this epoch */ @@ -73,22 +76,44 @@ export type Epoch = { startBlock: number /** The end block for this epoch */ endBlock: number + /** The treasury address on THORChain used to determine revenue earned by the DAO for rFOX reward distributions and total burn */ + treasuryAddress: string /** The total revenue (RUNE) earned by the treasury for this epoch */ totalRevenue: string - /** The total rFOX staking reward units for this epoch */ - totalRewardUnits: string - /** The percentage of revenue (RUNE) accumulated by the treasury to be distributed as rewards for this epoch */ - distributionRate: number /** The percentage of revenue (RUNE) accumulated by the treasury to be used to buy FOX from the open market and subsequently burned for this epoch */ burnRate: number - /** The spot price of asset in USD */ - assetPriceUsd: string /** The spot price of rune in USD */ runePriceUsd: string - /** The treasury address on THORChain used to determine revenue earned by the DAO for rFOX reward distributions and total burn */ - treasuryAddress: string /** The status of the reward distribution */ distributionStatus: 'pending' | 'complete' - /** A record of staking address to reward distribution for this epoch */ + /** The details for each staking contract for this epoch */ + detailsByStakingContract: Record +} + +/** + * Epoch details for a staking contract (IPFS) + * @typedef {Object} EpochDetails + * @property {number} totalRewardUnits - The total rFOX staking reward units for this epoch + * @property {number} distributionRate - The percentage of revenue (RUNE) accumulated by the treasury to be distributed as rewards for this epoch + * @property {string} assetPriceUsd - The spot price of asset in USD + * @property {Record} distributionsByStakingAddress - The reward distribution for each staking address for this epoch + */ +export type EpochDetails = { + /** The total rFOX staking reward units for this epoch */ + totalRewardUnits: string + /** The percentage of revenue (RUNE) accumulated by the treasury to be distributed as rewards for this epoch */ + distributionRate: number + /** The spot price of asset in USD */ + assetPriceUsd: string + /** The reward distribution for each staking address for this epoch */ distributionsByStakingAddress: Record } + +export type CalculateRewardsArgs = { + stakingContract: Address + startBlock: bigint + endBlock: bigint + secondsInEpoch: bigint + distributionRate: number + totalRevenue: string +} diff --git a/cli/src/wallet.ts b/cli/src/wallet.ts index 2cd1301..3484948 100644 --- a/cli/src/wallet.ts +++ b/cli/src/wallet.ts @@ -17,6 +17,7 @@ const THORNODE_URL = 'https://daemon.thorchain.shapeshift.com' const addressNList = bip32ToAddressNList(BIP32_PATH) type TxsByStakingAddress = Record +type TxsByStakingContract = Record const suffix = (text: string): string => { return `\n${symbols.error} ${chalk.bold.red(text)}` @@ -109,7 +110,9 @@ export class Wallet { async fund(epoch: Epoch, epochHash: string) { const { address } = await this.getAddress() - const distributions = Object.values(epoch.distributionsByStakingAddress) + const distributions = Object.values(epoch.detailsByStakingContract).flatMap(details => + Object.values(details.distributionsByStakingAddress), + ) const totalDistribution = distributions.reduce((prev, distribution) => { return prev + BigInt(distribution.amount) @@ -173,15 +176,19 @@ export class Wallet { })() } - private async signTransactions(epoch: Epoch, epochHash: string): Promise { + private async signTransactions(epoch: Epoch, epochHash: string): Promise { const txsFile = path.join(RFOX_DIR, `txs_epoch-${epoch.number}.json`) const txs = read(txsFile) - const totalTxs = Object.values(epoch.distributionsByStakingAddress).length + const distributions = Object.values(epoch.detailsByStakingContract).flatMap(details => + Object.values(details.distributionsByStakingAddress), + ) + + const totalTxs = distributions.length const spinner = ora(`Signing ${totalTxs} transactions...`).start() - const txsByStakingAddress = await (async () => { - if (txs) return JSON.parse(txs) as TxsByStakingAddress + const txsByStakingContract = await (async () => { + if (txs) return JSON.parse(txs) as TxsByStakingContract const { address } = await this.getAddress() @@ -207,48 +214,49 @@ export class Wallet { })() let i = 0 - const txsByStakingAddress: TxsByStakingAddress = {} + const txsByStakingContract: TxsByStakingContract = {} try { - for await (const [stakingAddress, distribution] of Object.entries(epoch.distributionsByStakingAddress)) { - const unsignedTx = { - account_number: account.account_number, - addressNList, - chain_id: 'thorchain-1', - sequence: String(Number(account.sequence) + i), - tx: { - msg: [ - { - type: 'thorchain/MsgSend', - value: { - amount: [{ amount: distribution.amount, denom: 'rune' }], - from_address: address, - to_address: distribution.rewardAddress, + for (const [stakingContract, details] of Object.entries(epoch.detailsByStakingContract)) + for (const [stakingAddress, distribution] of Object.entries(details.distributionsByStakingAddress)) { + const unsignedTx = { + account_number: account.account_number, + addressNList, + chain_id: 'thorchain-1', + sequence: String(Number(account.sequence) + i), + tx: { + msg: [ + { + type: 'thorchain/MsgSend', + value: { + amount: [{ amount: distribution.amount, denom: 'rune' }], + from_address: address, + to_address: distribution.rewardAddress, + }, }, + ], + fee: { + amount: [], + gas: '0', }, - ], - fee: { - amount: [], - gas: '0', + memo: `rFOX reward (Staking Contract: ${stakingContract}, Staking Address: ${stakingAddress}) - Epoch #${epoch.number} (IPFS Hash: ${epochHash})`, + signatures: [], }, - memo: `rFOX reward (Staking Address: ${stakingAddress}) - Epoch #${epoch.number} (IPFS Hash: ${epochHash})`, - signatures: [], - }, - } + } - const signedTx = await this.hdwallet.thorchainSignTx(unsignedTx) + const signedTx = await this.hdwallet.thorchainSignTx(unsignedTx) - if (!signedTx?.serialized) { - spinner.suffixText = suffix('Failed to sign transaction.') - break - } + if (!signedTx?.serialized) { + spinner.suffixText = suffix('Failed to sign transaction.') + break + } - txsByStakingAddress[stakingAddress] = { - signedTx: signedTx.serialized, - txId: '', - } + txsByStakingContract[stakingContract][stakingAddress] = { + signedTx: signedTx.serialized, + txId: '', + } - i++ - } + i++ + } } catch (err) { if (err instanceof Error) { spinner.suffixText = suffix(`Failed to sign transaction: ${err.message}.`) @@ -257,24 +265,30 @@ export class Wallet { } } - return txsByStakingAddress + return txsByStakingContract })() - const processedTxs = Object.values(txsByStakingAddress).filter(tx => !!tx.signedTx).length + const processedTxs = Object.values(txsByStakingContract) + .flatMap(txsByStakingAddress => Object.values(txsByStakingAddress)) + .filter(tx => !!tx.signedTx).length if (processedTxs !== totalTxs) { spinner.fail(`${processedTxs}/${totalTxs} transactions signed, exiting.`) process.exit(1) } - write(txsFile, JSON.stringify(txsByStakingAddress, null, 2)) + write(txsFile, JSON.stringify(txsByStakingContract, null, 2)) spinner.succeed(`${processedTxs}/${totalTxs} transactions signed.`) - return txsByStakingAddress + return txsByStakingContract } - async broadcastTransactions(epoch: Epoch, txsByStakingAddress: TxsByStakingAddress): Promise { - const totalTxs = Object.values(epoch.distributionsByStakingAddress).length + async broadcastTransactions(epoch: Epoch, txsByStakingContract: TxsByStakingContract): Promise { + const distributions = Object.values(epoch.detailsByStakingContract).flatMap(details => + Object.values(details.distributionsByStakingAddress), + ) + + const totalTxs = distributions.length const spinner = ora(`Broadcasting ${totalTxs} transactions...`).start() const doBroadcast = async (stakingAddress: string, signedTx: string, retryAttempt = 0) => { @@ -309,17 +323,20 @@ export class Wallet { } try { - for await (const [stakingAddress, { signedTx, txId }] of Object.entries(txsByStakingAddress)) { - if (txId) { - epoch.distributionsByStakingAddress[stakingAddress].txId = txId - continue - } + for (const [stakingContract, txsByStakingAddress] of Object.entries(txsByStakingContract)) { + for (const [stakingAddress, { signedTx, txId }] of Object.entries(txsByStakingAddress)) { + if (txId) { + epoch.detailsByStakingContract[stakingContract].distributionsByStakingAddress[stakingAddress].txId = txId + continue + } - const data = await doBroadcast(stakingAddress, signedTx) - if (!data) break + const data = await doBroadcast(stakingAddress, signedTx) + if (!data) break - txsByStakingAddress[stakingAddress].txId = data.result.hash - epoch.distributionsByStakingAddress[stakingAddress].txId = data.result.hash + txsByStakingContract[stakingContract][stakingAddress].txId = data.result.hash + epoch.detailsByStakingContract[stakingContract].distributionsByStakingAddress[stakingAddress].txId = + data.result.hash + } } } catch (err) { if (isAxiosError(err)) { @@ -332,9 +349,11 @@ export class Wallet { } const txsFile = path.join(RFOX_DIR, `txs_epoch-${epoch.number}.json`) - write(txsFile, JSON.stringify(txsByStakingAddress, null, 2)) + write(txsFile, JSON.stringify(txsByStakingContract, null, 2)) - const processedTxs = Object.values(txsByStakingAddress).filter(tx => !!tx.txId).length + const processedTxs = Object.values(txsByStakingContract) + .flatMap(txsByStakingAddress => Object.values(txsByStakingAddress)) + .filter(tx => !!tx.signedTx).length if (processedTxs !== totalTxs) { spinner.fail(`${processedTxs}/${totalTxs} transactions broadcasted, exiting.`) @@ -347,7 +366,7 @@ export class Wallet { } async distribute(epoch: Epoch, epochHash: string): Promise { - const txsByStakingAddress = await this.signTransactions(epoch, epochHash) - return this.broadcastTransactions(epoch, txsByStakingAddress) + const txsByStakingContract = await this.signTransactions(epoch, epochHash) + return this.broadcastTransactions(epoch, txsByStakingContract) } }