diff --git a/cli/MultiSig.md b/cli/MultiSig.md index f7afa0f..ed816cf 100644 --- a/cli/MultiSig.md +++ b/cli/MultiSig.md @@ -1,12 +1,19 @@ ## Prerequisites -- Install golang: https://go.dev/doc/install +- Install golang (v1.22): https://go.dev/doc/install +- Create common rfox directory in your home directory. This is where all output files from the script will be stored and where shared files (unsigned transactions, signatures, signed transactions, etc.) should be saved. + ```bash + mkdir ~/rfox + ``` ## Clone and Build ```bash git clone https://gitlab.com/thorchain/thornode.git -cd thornode/cmd/thornode +cd thornode +git checkout develop +git pull +cd cmd/thornode go build --tags cgo,ledger ``` @@ -22,8 +29,12 @@ go build --tags cgo,ledger ``` - Import signer pubkeys: ```bash - ./thornode keys add {person2} --pubkey {pubkey} - ./thornode keys add {person3} --pubkey {pubkey} + ./thornode keys add {person2} --pubkey '{person2_pubkey}' + ./thornode keys add {person3} --pubkey '{person3_pubkey}' + ``` +- View keys: + ```bash + ./thornode keys list ``` - Add multisig key: ```bash @@ -38,15 +49,15 @@ go build --tags cgo,ledger - Person 1 signs: ```bash - ./thornode tx sign --from {person1} --multisig multisig {unsignedTx_epoch-N.json} --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --from ledger --ledger --sign-mode amino-json > signedTx_{person1}.json + ./thornode tx sign --from {person1} --multisig multisig ~/rfox/unsignedTx_epoch-{N}.json --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --ledger --sign-mode amino-json > ~/rfox/signedTx_epoch-{N}_{person1}.json ``` - Person 2 signs: ```bash - ./thornode tx sign --from {person2} --multisig multisig {unsignedTx_epoch-N.json} --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --from ledger --ledger --sign-mode amino-json > signedTx_{person2}.json + ./thornode tx sign --from {person2} --multisig multisig ~/rfox/unsignedTx_epoch-{N}.json --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --ledger --sign-mode amino-json > ~/rfox/signedTx_epoch-{N}_{person2}.json ``` - Multisign: ```bash - ./thornode tx multisign {unsignedTx_epoch-N.json} multisig signedTx_{person1}.json signedTx_{person2}.json --from multisig --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc > signedTx_multisig.json + ./thornode tx multisign ~/rfox/unsignedTx_epoch-{N}.json multisig ~/rfox/signedTx_epoch-{N}_{person1}.json ~/rfox/signedTx_epoch-{N}_{person2}.json --from multisig --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc > ~/rfox/signedTx_epoch-{N}_multisig.json ``` ## Send Transaction @@ -54,13 +65,14 @@ go build --tags cgo,ledger - Simulate transaction: ```bash - ./thornode tx broadcast signedTx_multisig.json --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --gas auto --dry-run > simulatedTx.json + ./thornode tx broadcast ~/rfox/signedTx_epoch-{N}_multisig.json --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --dry-run > ~/rfox/simulatedTx_epoch-{N}.json ``` - Validate contents of `simulatedTx.json` for accuracy before broadcasting - Broadcast transaction: ```bash - ./thornode tx broadcast signedTx_multisig.json --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --gas auto > tx.json + ./thornode tx broadcast ~/rfox/signedTx_epoch-{N}_multisig.json --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --gas auto > tx.json ``` - - Copy the `txhash` value from `tx.json` to supply to the cli in order to continue + +At this point, the cli should pick up the funding transaction and continue running the distribution from the hot wallet. diff --git a/cli/src/index.ts b/cli/src/index.ts index dc28d7d..e6bfde9 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 { Epoch, RFOXMetadata } 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,14 +109,15 @@ const processEpoch = async () => { const run = async () => { const ipfs = await IPFS.new() - const epoch = await ipfs.getEpoch() + const metadata = await ipfs.getMetadata('process') + const epoch = await ipfs.getEpochFromMetadata(metadata) if (isEpochDistributionStarted(epoch.number)) { const confirmed = await prompts.confirm({ message: 'It looks like you have already started a distribution for this epoch. Do you want to continue? ', }) - if (confirmed) return recover(epoch) + if (confirmed) return recover(metadata) info(`Please move or delete all existing files for epoch-${epoch.number} from ${RFOX_DIR} before re-running.`) warn('This action should never be taken unless you are absolutely sure you know what you are doing!!!') @@ -135,30 +138,57 @@ const run = async () => { const wallet = await Wallet.new(mnemonic) - await processDistribution(epoch, wallet, ipfs) + await processDistribution(metadata, epoch, wallet, ipfs) } -const recover = async (epoch?: Epoch) => { +const recover = async (metadata?: RFOXMetadata) => { const ipfs = await IPFS.new() - if (!epoch) epoch = await ipfs.getEpoch() + if (!metadata) { + metadata = await ipfs.getMetadata('process') + } + + const epoch = await ipfs.getEpochFromMetadata(metadata) const keystoreFile = path.join(RFOX_DIR, `keystore_epoch-${epoch.number}.txt`) const mnemonic = await recoverKeystore(keystoreFile) const wallet = await Wallet.new(mnemonic) - await processDistribution(epoch, wallet, ipfs) + await processDistribution(metadata, epoch, wallet, ipfs) } -const processDistribution = async (epoch: Epoch, wallet: Wallet, ipfs: IPFS) => { - await wallet.fund(epoch) - const processedEpoch = await wallet.distribute(epoch) +const update = async () => { + const ipfs = await IPFS.new() - const processedEpochHash = await ipfs.addEpoch(processedEpoch) 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 (metadata: RFOXMetadata, epoch: Epoch, wallet: Wallet, ipfs: IPFS) => { + const epochHash = metadata.ipfsHashByEpoch[epoch.number] + + await wallet.fund(epoch, epochHash) + const processedEpoch = await wallet.distribute(epoch, epochHash) + + const processedEpochHash = await ipfs.addEpoch({ + ...processedEpoch, + distributionStatus: 'complete', + }) + + const metadataHash = await ipfs.updateMetadata(metadata, { + epoch: { number: processedEpoch.number, hash: processedEpochHash }, + }) - await ipfs.updateMetadata(metadata, { epoch: { number: processedEpoch.number, hash: processedEpochHash } }) + if (!metadataHash) return success(`rFOX reward distribution for Epoch #${processedEpoch.number} has been completed!`) @@ -183,23 +213,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 +246,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..e62890f 100644 --- a/cli/src/ipfs.ts +++ b/cli/src/ipfs.ts @@ -4,6 +4,7 @@ import axios from 'axios' import BigNumber from 'bignumber.js' import { error, info } from './logging' import { Epoch, 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 } 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 { @@ -149,18 +153,6 @@ export class IPFS { metadata.epochEndTimestamp = overrides.metadata.epochEndTimestamp if (overrides.epoch) { - const hash = metadata.ipfsHashByEpoch[overrides.epoch.number] - - if (hash) { - info(`The metadata already contains an IPFS hash for this epoch: ${hash}`) - - const confirmed = await prompts.confirm({ - message: `Do you want to update the metadata with the new IPFS hash: ${overrides.epoch.hash}?`, - }) - - if (!confirmed) return - } - metadata.ipfsHashByEpoch[overrides.epoch.number] = overrides.epoch.hash const { IpfsHash } = await this.client.pinJSONToIPFS(metadata, { @@ -173,9 +165,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? `, + }), + ) + + 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) + } - return + 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 +288,34 @@ 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(metadata: RFOXMetadata): Promise { + 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/wallet.ts b/cli/src/wallet.ts index 6f1f4d8..a04aea1 100644 --- a/cli/src/wallet.ts +++ b/cli/src/wallet.ts @@ -70,7 +70,7 @@ export class Wallet { } } - private async buildFundingTransaction(amount: string, epoch: number) { + private async buildFundingTransaction(amount: string, epoch: Epoch, hash: string) { 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: ${hash})`, timeout_height: '0', extension_options: [], non_critical_extension_options: [], @@ -106,7 +106,7 @@ export class Wallet { } } - async fund(epoch: Epoch) { + async fund(epoch: Epoch, epochHash: string) { 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, epochHash) 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: Epoch, epochHash: string): 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: ${epochHash})`, signatures: [], }, } @@ -330,8 +330,8 @@ export class Wallet { return epoch } - async distribute(epoch: Epoch): Promise { - const txsByStakingAddress = await this.signTransactions(epoch) + async distribute(epoch: Epoch, epochHash: string): Promise { + const txsByStakingAddress = await this.signTransactions(epoch, epochHash) return this.broadcastTransactions(epoch, txsByStakingAddress) } }