Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: hot wallet cli part 3 #77

Merged
merged 5 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions scripts/hotWalletCli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@
## Setup

- Install dependencies:

```bash
yarn
```

- Copy sample env file:

```bash
cp sample.env .env
```

- Request environment variables and update `.env` with the appropriate values

## Running

- Run script:
Expand Down
3 changes: 0 additions & 3 deletions scripts/hotWalletCli/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,3 @@ import os from 'node:os'
import path from 'node:path'

export const RFOX_DIR = path.join(os.homedir(), 'rfox')
export const BIP32_PATH = `m/44'/931'/0'/0/0`
export const SHAPESHIFT_MULTISIG_ADDRESS = 'thor1xmaggkcln5m5fnha2780xrdrulmplvfrz6wj3l'
export const THORNODE_URL = 'https://daemon.thorchain.shapeshift.com'
3 changes: 2 additions & 1 deletion scripts/hotWalletCli/file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'node:fs'
import { RFOX_DIR } from './constants'
import { error, info } from './logging'
import { error, info, warn } from './logging'

const deleteIfExists = (file: string) => {
try {
Expand All @@ -15,6 +15,7 @@ export const write = (file: string, data: string) => {
fs.writeFileSync(file, data, { mode: 0o400, encoding: 'utf8' })
} catch {
error(`Failed to write file ${file}, exiting.`)
warn('Manually save the contents at the specified file location if possible, or a temporary file for recovery!!!')
info(data)
process.exit(1)
}
Expand Down
39 changes: 29 additions & 10 deletions scripts/hotWalletCli/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import 'dotenv/config'
import * as prompts from '@inquirer/prompts'
import fs from 'node:fs'
import path from 'node:path'
import { Epoch } from '../types'
import { RFOX_DIR } from './constants'
import { isEpochDistributionStarted } from './file'
import { Client } from './ipfs'
import { error, warn } from './logging'
import { IPFS } from './ipfs'
import { error, info, success, warn } from './logging'
import { create, recoverKeystore } from './mnemonic'
import { Wallet } from './wallet'

const run = async () => {
const ipfs = await Client.new()
const ipfs = await IPFS.new()

const epoch = await ipfs.getEpoch()

Expand All @@ -20,6 +21,11 @@ const run = async () => {
})

if (cont) return recover(epoch)

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!!!')

process.exit(0)
}

const mnemonic = await create(epoch.number)
Expand All @@ -34,22 +40,35 @@ const run = async () => {
}

const wallet = await Wallet.new(mnemonic)
await wallet.fund(epoch)
await wallet.distribute(epoch)

await processEpoch(epoch, wallet, ipfs)
}

const recover = async (epoch?: Epoch) => {
if (!epoch) {
const ipfs = await Client.new()
epoch = await ipfs.getEpoch()
}
const ipfs = await IPFS.new()

if (!epoch) epoch = await ipfs.getEpoch()

const keystoreFile = path.join(RFOX_DIR, `keystore_epoch-${epoch.number}.txt`)
const mnemonic = await recoverKeystore(keystoreFile)

const wallet = await Wallet.new(mnemonic)

await processEpoch(epoch, wallet, ipfs)
}

const processEpoch = async (epoch: Epoch, wallet: Wallet, ipfs: IPFS) => {
await wallet.fund(epoch)
await wallet.distribute(epoch)
const processedEpoch = await wallet.distribute(epoch)

const processedEpochHash = await ipfs.addEpoch(processedEpoch)
await ipfs.updateMetadata({ number: processedEpoch.number, hash: processedEpochHash })

success(`rFOX reward distribution for Epoch #${processedEpoch.number} has been completed!`)

info(
'Please update the rFOX Wiki (https://github.com/shapeshift/rFOX/wiki/rFOX-Metadata) and notify the DAO accordingly. Thanks!',
)
}

const shutdown = () => {
Expand Down
210 changes: 159 additions & 51 deletions scripts/hotWalletCli/ipfs.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,190 @@
import * as prompts from '@inquirer/prompts'
import { create, IPFSHTTPClient } from 'ipfs-http-client'
import { Epoch, RewardDistribution } from '../types'
import PinataClient from '@pinata/sdk'
import axios from 'axios'
import BigNumber from 'bignumber.js'
import { Epoch, RFOXMetadata, RewardDistribution } from '../types'
import { error, info } from './logging'

export function isRewardDistribution(obj: any): obj is RewardDistribution {
return (
typeof obj.amount === 'string' &&
typeof obj.rewardUnits === 'string' &&
typeof obj.txId === 'string' &&
typeof obj.rewardAddress === 'string'
)
const PINATA_API_KEY = process.env['PINATA_API_KEY']
const PINATA_SECRET_API_KEY = process.env['PINATA_SECRET_API_KEY']
const PINATA_GATEWAY_URL = process.env['PINATA_GATEWAY_URL']
const PINATA_GATEWAY_API_KEY = process.env['PINATA_GATEWAY_API_KEY']

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

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

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

export function isEpoch(obj: any): obj is Epoch {
if (typeof obj !== 'object' || obj === null) return false

return (
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' &&
obj.distributionsByStakingAddress !== null &&
Object.values(obj.distributionsByStakingAddress).every(isRewardDistribution)
)
if (!PINATA_GATEWAY_API_KEY) {
error('PINATA_GATEWAY_API_KEY not set. Please make sure you copied the sample.env and filled out your .env file.')
process.exit(1)
}

export class Client {
private ipfs: IPFSHTTPClient
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')

constructor(ipfs: IPFSHTTPClient) {
this.ipfs = ipfs
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'

export class IPFS {
private client: PinataClient

constructor(client: PinataClient) {
this.client = client
}

static async new(): Promise<Client> {
static async new(): Promise<IPFS> {
try {
const ipfs = create()
return new Client(ipfs)
} catch (err) {
const client = new PinataClient({ pinataApiKey: PINATA_API_KEY, pinataSecretApiKey: PINATA_SECRET_API_KEY })
await client.testAuthentication()
return new IPFS(client)
} catch {
error('Failed to connect to IPFS, exiting.')
process.exit(1)
}
}

async addEpoch(epoch: Epoch): Promise<string> {
const buffer = Buffer.from(JSON.stringify(epoch))
const { cid } = await this.ipfs.add(buffer)
try {
const { IpfsHash } = await this.client.pinJSONToIPFS(epoch, {
pinataMetadata: { name: `rFoxEpoch${epoch.number}.json` },
})

return cid.toString()
info(`rFOX Epoch #${epoch.number} IPFS hash: ${IpfsHash}`)

return IpfsHash
} catch {
error('Failed to add epoch to IPFS, exiting.')
process.exit(1)
}
}

async getEpoch(): Promise<Epoch> {
const cid = await prompts.input({
message: 'What is the IPFS CID for the rFOX distribution epoch you wish to process? ',
const hash = await prompts.input({
message: 'What is the IPFS hash for the rFOX reward distribution you want to process? ',
})

const decoder = new TextDecoder()
try {
const { data } = await axios.get(`${PINATA_GATEWAY_URL}/ipfs/${hash}`, {
headers: {
'x-pinata-gateway-token': PINATA_GATEWAY_API_KEY,
},
})

let content = ''
for await (const chunk of this.ipfs.cat(cid)) {
content += decoder.decode(chunk, { stream: true })
}
if (isEpoch(data)) {
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 epoch = JSON.parse(content)
info(
`Processing rFOX reward distribution for Epoch #${data.number}:\n - Total Rewards: ${totalRewards} RUNE\n - Total Addresses: ${totalAddresses}`,
)

if (isEpoch(epoch)) {
info(`Processing rFOX distribution for Epoch #${epoch.number}.`)
return epoch
} else {
error(`The contents of IPFS CID (${cid}) are not valid, exiting.`)
return data
} else {
error(`The contents of IPFS hash (${hash}) are not valid, exiting.`)
process.exit(1)
}
} catch {
error(`Failed to get content of IPFS hash (${hash}), exiting.`)
process.exit(1)
}
}

async updateMetadata(cid: string) {}
async updateMetadata(epoch?: { number: number; hash: string }): Promise<string | undefined> {
const metadata = await this.getMetadata()

if (epoch) {
const hash = metadata.ipfsHashByEpoch[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: ${epoch.hash}?`,
})

if (!confirmed) return
}

metadata.ipfsHashByEpoch[epoch.number] = epoch.hash

const { IpfsHash } = await this.client.pinJSONToIPFS(metadata, {
pinataMetadata: { name: 'rFoxMetadata.json' },
})

info(`rFOX Metadata IPFS hash: ${IpfsHash}`)

return IpfsHash
}

// TODO: manual update walkthrough

return
}

async getMetadata(): Promise<RFOXMetadata> {
const hash = await prompts.input({
message: 'What is the IPFS hash for the rFOX metadata you want to update? ',
})

try {
const { data } = await axios.get(`${PINATA_GATEWAY_URL}/ipfs/${hash}`, {
headers: {
'x-pinata-gateway-token': PINATA_GATEWAY_API_KEY,
},
})

if (isMetadata(data)) return data

error(`The contents of IPFS hash (${hash}) are not valid, exiting.`)
process.exit(1)
} catch {
error(`Failed to get content of IPFS hash (${hash}), exiting.`)
process.exit(1)
}
}
}
2 changes: 1 addition & 1 deletion scripts/hotWalletCli/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ export const error = (text: string) => {
}

export const success = (text: string) => {
console.log(symbols.success, chalk.green(text))
console.log(symbols.success, chalk.white(text))
}
9 changes: 7 additions & 2 deletions scripts/hotWalletCli/mnemonic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ const decryptMnemonic = (encryptedMnemonic: string, password: string): string |
}
}

export const create = async (epoch: number): Promise<string> => {
export const create = async (epoch: number, attempt = 0): Promise<string> => {
if (attempt >= 3) {
error('Failed to create hot wallet, exiting.')
process.exit(1)
}

const password = await prompts.password({
message: 'Enter a password for encrypting keystore file: ',
mask: true,
Expand All @@ -59,7 +64,7 @@ export const create = async (epoch: number): Promise<string> => {

if (password !== password2) {
error(`Your passwords don't match.`)
process.exit(1)
return create(epoch, ++attempt)
}

const mnemonic = generateMnemonic()
Expand Down
Loading