From a9faad7ae95b5b84dc99ad90c3fb3739a09d7276 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:17:37 -0600 Subject: [PATCH] feat: add metadata update flow and final tweaks --- cli/src/index.ts | 62 ++++++++++++++----- cli/src/ipfs.ts | 155 ++++++++++++++++++++++++++++++++++++++++++---- cli/src/types.ts | 2 + cli/src/wallet.ts | 18 +++--- 4 files changed, 201 insertions(+), 36 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index dc28d7d..594aa9a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -10,7 +10,7 @@ import { isEpochDistributionStarted } from './file' import { IPFS } from './ipfs' import { error, info, success, warn } from './logging' import { create, recoverKeystore } from './mnemonic' -import { Epoch } from './types' +import { EpochWithHash } from './types' import { Wallet } from './wallet' const processEpoch = async () => { @@ -21,7 +21,7 @@ const processEpoch = async () => { const month = MONTHS[new Date(metadata.epochStartTimestamp).getUTCMonth()] - info(`Processing Epoch #${metadata.epoch} for ${month} distribution.`) + info(`Processing rFOX Epoch #${metadata.epoch} for ${month} distribution.`) const now = Date.now() if (metadata.epochEndTimestamp > now) { @@ -88,7 +88,7 @@ const processEpoch = async () => { const nextEpochStartDate = new Date(metadata.epochEndTimestamp + 1) - await ipfs.updateMetadata(metadata, { + const hash = await ipfs.updateMetadata(metadata, { epoch: { number: metadata.epoch, hash: epochHash }, metadata: { epoch: metadata.epoch + 1, @@ -97,6 +97,8 @@ const processEpoch = async () => { }, }) + if (!hash) return + success(`rFOX Epoch #${metadata.epoch} has been processed!`) info( @@ -107,7 +109,7 @@ const processEpoch = async () => { const run = async () => { const ipfs = await IPFS.new() - const epoch = await ipfs.getEpoch() + const epoch = await ipfs.getEpochFromMetadata() if (isEpochDistributionStarted(epoch.number)) { const confirmed = await prompts.confirm({ @@ -138,10 +140,12 @@ const run = async () => { await processDistribution(epoch, wallet, ipfs) } -const recover = async (epoch?: Epoch) => { +const recover = async (epoch?: EpochWithHash) => { const ipfs = await IPFS.new() - if (!epoch) epoch = await ipfs.getEpoch() + if (!epoch) { + epoch = await ipfs.getEpochFromMetadata() + } const keystoreFile = path.join(RFOX_DIR, `keystore_epoch-${epoch.number}.txt`) const mnemonic = await recoverKeystore(keystoreFile) @@ -151,14 +155,33 @@ const recover = async (epoch?: Epoch) => { await processDistribution(epoch, wallet, ipfs) } -const processDistribution = async (epoch: Epoch, wallet: Wallet, ipfs: IPFS) => { +const update = async () => { + const ipfs = await IPFS.new() + + const metadata = await ipfs.getMetadata('update') + 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!', + ) +} + +const processDistribution = async (epoch: EpochWithHash, wallet: Wallet, ipfs: IPFS) => { await wallet.fund(epoch) const processedEpoch = await wallet.distribute(epoch) const processedEpochHash = await ipfs.addEpoch(processedEpoch) - const metadata = await ipfs.getMetadata('update') + const metadata = await ipfs.getMetadata('process') - await ipfs.updateMetadata(metadata, { epoch: { number: processedEpoch.number, hash: processedEpochHash } }) + const hash = await ipfs.updateMetadata(metadata, { + epoch: { number: processedEpoch.number, hash: processedEpochHash }, + }) + + if (!hash) return success(`rFOX reward distribution for Epoch #${processedEpoch.number} has been completed!`) @@ -183,23 +206,28 @@ const main = async () => { } } - const choice = await prompts.select<'process' | 'run' | 'recover'>({ + const choice = await prompts.select<'process' | 'run' | 'recover' | 'update'>({ message: 'What do you want to do?', choices: [ { - name: 'Process rFox epoch', + name: 'Process rFOX epoch', value: 'process', - description: 'Start here to process a completed rFox epoch', + description: 'Start here to process an rFOX epoch.', }, { - name: 'Run rFox distribution', + name: 'Run rFOX distribution', value: 'run', - description: 'Start here to begin running a new rFox rewards distribution', + description: 'Start here to run an rFOX rewards distribution.', }, { - name: 'Recover rFox distribution', + name: 'Recover rFOX distribution', value: 'recover', - description: 'Start here to recover an in progress rFox rewards distribution', + description: 'Start here to recover an rFOX rewards distribution.', + }, + { + name: 'Update rFOX metadata', + value: 'update', + description: 'Start here to update an rFOX metadata.', }, ], }) @@ -211,6 +239,8 @@ const main = async () => { return run() case 'recover': return recover() + case 'update': + return update() default: error(`Invalid choice: ${choice}, exiting.`) process.exit(1) diff --git a/cli/src/ipfs.ts b/cli/src/ipfs.ts index 7463435..9c86991 100644 --- a/cli/src/ipfs.ts +++ b/cli/src/ipfs.ts @@ -3,7 +3,8 @@ import PinataClient from '@pinata/sdk' import axios from 'axios' import BigNumber from 'bignumber.js' import { error, info } from './logging' -import { Epoch, RFOXMetadata, RewardDistribution } from './types' +import { Epoch, EpochWithHash, RFOXMetadata, RewardDistribution } from './types' +import { MONTHS } from './constants' const PINATA_API_KEY = process.env['PINATA_API_KEY'] const PINATA_SECRET_API_KEY = process.env['PINATA_SECRET_API_KEY'] @@ -98,10 +99,12 @@ export class IPFS { } } - async getEpoch(): Promise { - const hash = await prompts.input({ - message: 'What is the IPFS hash for the rFOX reward distribution you want to process? ', - }) + async getEpoch(hash?: string): Promise { + if (!hash) { + hash = await prompts.input({ + message: 'What is the IPFS hash for the rFOX epoch you want to process? ', + }) + } try { const { data } = await axios.get(`${PINATA_GATEWAY_URL}/ipfs/${hash}`, { @@ -111,6 +114,7 @@ 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) => { @@ -120,12 +124,12 @@ export class IPFS { .toFixed() info( - `Processing rFOX reward distribution for Epoch #${data.number}:\n - Total Rewards: ${totalRewards} RUNE\n - Total Addresses: ${totalAddresses}`, + `Running ${month} rFOX reward distribution for Epoch #${data.number}:\n - Total Rewards: ${totalRewards} RUNE\n - Total Addresses: ${totalAddresses}`, ) - return data + return { ...data, hash } } else { - error(`The contents of IPFS hash (${hash}) are not valid, exiting.`) + error(`The contents of IPFS hash (${hash}) are not valid epoch contents, exiting.`) process.exit(1) } } catch { @@ -173,9 +177,113 @@ export class IPFS { } } - // TODO: manual update walkthrough + const choice = await prompts.select<'distributionRate' | 'burnRate' | 'treasuryAddress'>({ + message: 'What do you want to update?', + choices: [ + { + name: 'Distribution Rate', + value: 'distributionRate', + description: + 'Update the current percentage of revenue (RUNE) earned by the treasury to be distributed as rewards.', + }, + { + name: 'Burn Rate', + value: 'burnRate', + description: + 'Update the current percentage of revenue (RUNE) earned by the treasury to be used to buy FOX and then burn it.', + }, + { + name: 'Treasury Address', + value: 'treasuryAddress', + description: 'Update the THORChain treasury address used to determine revenue earned by the DAO.', + }, + ], + }) + + switch (choice) { + case 'distributionRate': { + const distributionRate = parseFloat( + await prompts.input({ + message: `The distribution rate is currently set to ${metadata.distributionRate}, what do you want to update it to? `, + }), + ) - return + if (isNaN(distributionRate) || distributionRate < 0 || distributionRate > 1) { + error(`Invalid distribution rate, it must be a number between 0 and 1 (ex. 0.25).`) + return this.updateMetadata(metadata) + } + + metadata.distributionRate = distributionRate + + break + } + case 'burnRate': { + const burnRate = parseFloat( + await prompts.input({ + message: `The burn rate is currently set to ${metadata.burnRate}, what do you want to update it to? `, + }), + ) + + if (isNaN(burnRate) || burnRate < 0 || burnRate > 1) { + error(`Invalid burn rate, it must be a number between 0 and 1 (ex. 0.25).`) + return this.updateMetadata(metadata) + } + + metadata.burnRate = burnRate + + break + } + case 'treasuryAddress': { + const treasuryAddress = await prompts.input({ + message: `The treasury address is currently set to ${metadata.treasuryAddress}, what do you want to update it to? `, + }) + + if (!/^thor[a-z0-9]{39}$/.test(treasuryAddress)) { + error(`Invalid treasury address, please check your address and try again.`) + return this.updateMetadata(metadata) + } + + metadata.treasuryAddress = treasuryAddress + + break + } + default: + error(`Invalid choice: ${choice}, exiting.`) + process.exit(1) + } + + const confirmed = await prompts.confirm({ + message: `Do you want to update another value?`, + }) + + if (confirmed) { + return this.updateMetadata(metadata) + } else { + if (metadata.distributionRate + 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).`, + ) + return this.updateMetadata(metadata) + } + + info( + `The new metadata values will be:\n - Distribtution Rate: ${metadata.distributionRate}\n - Burn Rate: ${metadata.burnRate}\n - Treasury Address: ${metadata.treasuryAddress}`, + ) + + const confirmed = await prompts.confirm({ + message: `Do you want to update the metadata with the new values?`, + }) + + if (!confirmed) return + + const { IpfsHash } = await this.client.pinJSONToIPFS(metadata, { + pinataMetadata: { name: 'rFoxMetadata.json' }, + }) + + info(`rFOX Metadata IPFS hash: ${IpfsHash}`) + + return IpfsHash + } } async getMetadata(promptAction: string): Promise { @@ -192,11 +300,36 @@ export class IPFS { if (isMetadata(data)) return data - error(`The contents of IPFS hash (${hash}) are not valid, exiting.`) + error(`The contents of IPFS hash (${hash}) are not valid metadata contents, exiting.`) process.exit(1) } catch { error(`Failed to get content of IPFS hash (${hash}), exiting.`) process.exit(1) } } + + async getEpochFromMetadata(): Promise { + const metadata = await this.getMetadata('process') + + const hash = metadata.ipfsHashByEpoch[metadata.epoch - 1] + + if (!hash) { + error(`No IPFS hash found for epoch ${metadata.epoch - 1}, exiting.`) + process.exit(1) + } + + const epoch = await this.getEpoch(hash) + const month = MONTHS[new Date(epoch.startTimestamp).getUTCMonth()] + + switch (epoch.distributionStatus) { + case 'pending': + info(`Running ${month} rFOX reward distribution for Epoch #${epoch.number}.`) + break + case 'complete': + info(`The ${month} rFOX reward distribution for Epoch #${epoch.number} is already complete, exiting.`) + process.exit(0) + } + + return epoch + } } diff --git a/cli/src/types.ts b/cli/src/types.ts index 9ddcd72..45aecda 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -88,3 +88,5 @@ export type Epoch = { /** A record of staking address to reward distribution for this epoch */ distributionsByStakingAddress: Record } + +export type EpochWithHash = Epoch & { hash: string } diff --git a/cli/src/wallet.ts b/cli/src/wallet.ts index 6f1f4d8..6777a4d 100644 --- a/cli/src/wallet.ts +++ b/cli/src/wallet.ts @@ -8,7 +8,7 @@ import ora, { Ora } from 'ora' import { RFOX_DIR } from './constants' import { read, write } from './file' import { error, info, success } from './logging' -import { Epoch } from './types' +import { EpochWithHash } from './types' const BIP32_PATH = `m/44'/931'/0'/0/0` const SHAPESHIFT_MULTISIG_ADDRESS = 'thor1xmaggkcln5m5fnha2780xrdrulmplvfrz6wj3l' @@ -70,7 +70,7 @@ export class Wallet { } } - private async buildFundingTransaction(amount: string, epoch: number) { + private async buildFundingTransaction(amount: string, epoch: EpochWithHash) { const { address } = await this.getAddress() return { @@ -88,7 +88,7 @@ export class Wallet { ], }, ], - memo: `Fund rFOX rewards distribution - Epoch #${epoch}`, + memo: `Fund rFOX rewards distribution - Epoch #${epoch.number} (IPFS Hash: ${epoch.hash})`, timeout_height: '0', extension_options: [], non_critical_extension_options: [], @@ -106,7 +106,7 @@ export class Wallet { } } - async fund(epoch: Epoch) { + async fund(epoch: EpochWithHash) { const { address } = await this.getAddress() const distributions = Object.values(epoch.distributionsByStakingAddress) @@ -152,7 +152,7 @@ export class Wallet { if (await isFunded()) return - const unsignedTx = await this.buildFundingTransaction(totalAmount, epoch.number) + const unsignedTx = await this.buildFundingTransaction(totalAmount, epoch) const unsignedTxFile = path.join(RFOX_DIR, `unsignedTx_epoch-${epoch.number}.json`) write(unsignedTxFile, JSON.stringify(unsignedTx, null, 2)) @@ -173,7 +173,7 @@ export class Wallet { })() } - private async signTransactions(epoch: Epoch): Promise { + private async signTransactions(epoch: EpochWithHash): Promise { const txsFile = path.join(RFOX_DIR, `txs_epoch-${epoch.number}.json`) const txs = read(txsFile) @@ -230,7 +230,7 @@ export class Wallet { amount: [], gas: '0', }, - memo: `rFOX reward (Staking Address: ${stakingAddress}) - Epoch #${epoch.number}`, + memo: `rFOX reward (Staking Address: ${stakingAddress}) - Epoch #${epoch.number} (IPFS Hash: ${epoch.hash})`, signatures: [], }, } @@ -273,7 +273,7 @@ export class Wallet { return txsByStakingAddress } - async broadcastTransactions(epoch: Epoch, txsByStakingAddress: TxsByStakingAddress): Promise { + async broadcastTransactions(epoch: EpochWithHash, txsByStakingAddress: TxsByStakingAddress): Promise { const totalTxs = Object.values(epoch.distributionsByStakingAddress).length const spinner = ora(`Broadcasting ${totalTxs} transactions...`).start() @@ -330,7 +330,7 @@ export class Wallet { return epoch } - async distribute(epoch: Epoch): Promise { + async distribute(epoch: EpochWithHash): Promise { const txsByStakingAddress = await this.signTransactions(epoch) return this.broadcastTransactions(epoch, txsByStakingAddress) }