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 #74

Merged
merged 21 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from 11 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 .prettierrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
{
"plugins": ["prettier-plugin-solidity"],
"printWidth": 120,
"endOfLine": "lf",
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"semi": false,
"arrowParens": "avoid",
"jsxSingleQuote": true,
"trailingComma": "all",
"overrides": [
{
"files": "*.sol",
Expand Down
2 changes: 1 addition & 1 deletion foundry/test/StakingTestUpgrades.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ contract FoxStakingTestUpgrades is Test {
// confrim still on old version
assertEq(foxStakingV1.version(), expectedCurrentVersion);

// Change the owner
// Change the owner
vm.startPrank(owner);
foxStakingV1.transferOwnership(newOwner);
vm.stopPrank();
Expand Down
66 changes: 66 additions & 0 deletions scripts/hotWalletCli/MultiSig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Prerequisites

- Install golang: https://go.dev/doc/install

## Clone and Build

```bash
git clone https://gitlab.com/thorchain/thornode.git
cd thornode/cmd/thornode
go build --tags cgo,ledger
```

## Create MultiSig

- Add your key:
```bash
./thornode keys add {person1} --ledger
```
- Export pubkey:
```bash
./thornode keys show {person1} --pubkey
```
- Import signer pubkeys:
```bash
./thornode keys add {person2} --pubkey {pubkey}
./thornode keys add {person3} --pubkey {pubkey}
```
- Add multisig key:
```bash
./thornode keys add multisig --multisig {person1},{person2},{person3} --multisig-threshold 2
```
- Validate multisig address:
```bash
./thornode keys show multisig --address
```

## Sign Transaction

- Person 1 signs:
```bash
./thornode tx sign --from {person1} --multisig multisig {unsigned_tx.json} --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --from ledger --ledger --sign-mode amino-json > tx_signed_{person1}.json
```
- Person 2 signs:
```bash
./thornode tx sign --from {person2} --multisig multisig {unsigned_tx.json} --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --from ledger --ledger --sign-mode amino-json > tx_signed_{person2}.json
```
- Multisign:
```bash
./thornode tx multisign {unsigned_tx.json} multisig tx_signed_{person1}.json tx_signed_{person2}.json --from multisig --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc > tx_signed_multisig.json
```

## Send Transaction

- Simulate transaction:

```bash
./thornode tx broadcast tx_signed_multisig.json --chain-id thorchain-mainnet-v1 --node https://daemon.thorchain.shapeshift.com:443/rpc --gas auto --dry-run > simulated_tx.json
```

- Validate contents of `simulated_tx.json` for accuracy before broadcasting

- Broadcast transaction:
```bash
./thornode tx broadcast tx_signed_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
18 changes: 18 additions & 0 deletions scripts/hotWalletCli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## Prerequisites

- NodeJS (v18+): https://nodejs.org/en/download/package-manager
- Yarn: https://classic.yarnpkg.com/lang/en/docs/install/#debian-stable

## Setup

- Install dependencies:
```bash
yarn
```

## Running

- Run script:
```bash
yarn start
```
7 changes: 7 additions & 0 deletions scripts/hotWalletCli/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import os from 'node:os'
import path from 'node:path'

export const RFOX_DIR = path.join(os.homedir(), 'rfox')
kaladinlight marked this conversation as resolved.
Show resolved Hide resolved
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'
91 changes: 91 additions & 0 deletions scripts/hotWalletCli/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import fs from 'node:fs'
import path from 'node:path'
import * as prompts from '@inquirer/prompts'
import { create, recoverKeystore } from './mnemonic.js'
import { info, error, warn } from './logging.js'
import { createWallet, fund } from './wallet.js'
kaladinlight marked this conversation as resolved.
Show resolved Hide resolved
import { RFOX_DIR } from './constants.js'

const run = async () => {
const { mnemonic, keystoreFile: keystore } = await create()

info(`Encrypted keystore file created (${keystore})`)
info('Please back up your mnemonic in another secure way in case keystore file recovery fails!!!')
info(`Mnemonic: ${mnemonic}`)
warn('DO NOT INTERACT WITH THIS WALLET FOR ANY REASON OUTSIDE OF THIS SCRIPT!!!')

const confirmed = await prompts.confirm({
message: 'Have you securely backed up your mnemonic? ',
})

if (!confirmed) {
error('Unable to proceed knowing you have not securely backed up your mnemonic, exiting.')
process.exit(1)
}

const wallet = await createWallet(mnemonic)

// TODO: get total amount from distribution file (total distribution + fees to pay for all transactions)
const amount = '1'

await fund(wallet, amount)
}

const recover = async () => {
const keystore = path.join(RFOX_DIR, 'keystore.txt')
const mnemonic = await recoverKeystore(keystore)
const wallet = await createWallet(mnemonic)

// TODO: get total amount from distribution file (total distribution + fees to pay for all transactions)
const amount = '1'

await fund(wallet, amount)
}

const shutdown = () => {
console.log()
warn('Received shutdown signal, exiting.')
process.exit(0)
}

const main = async () => {
try {
fs.mkdirSync(RFOX_DIR)
} catch (err) {
if (err instanceof Error) {
const fsError = err as NodeJS.ErrnoException
if (fsError.code !== 'EEXIST') throw err
}
}

const choice = await prompts.select<'run' | 'recover'>({
message: 'What do you want to do?',
choices: [
{
name: 'Run rFox distribution',
value: 'run',
description: 'Start here to process a new rFox distribution epoch',
},
{
name: 'Recover rFox distribution',
value: 'recover',
description: 'Use this to recover from an error during an rFox distribution epoch',
},
],
})

switch (choice) {
case 'run':
return await run()
case 'recover':
return await recover()
default:
error(`Invalid choice: ${choice}, exiting.`)
process.exit(1)
}
}

process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)

main()
13 changes: 13 additions & 0 deletions scripts/hotWalletCli/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import chalk from 'chalk'

export const info = (text: string) => {
console.log(chalk.white('- ') + chalk.dim.white(text))
}

export const warn = (text: string) => {
console.log(chalk.yellow('* ') + chalk.yellow(text))
}

export const error = (text: string) => {
console.log(chalk.red('! ') + chalk.bold.red(text))
}
153 changes: 153 additions & 0 deletions scripts/hotWalletCli/mnemonic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import * as prompts from '@inquirer/prompts'
import { generateMnemonic, validateMnemonic } from 'bip39'
import { error } from './logging.js'
import { RFOX_DIR } from './constants.js'

const recoveryChoices = [
{
name: 'Re-enter password',
value: 'password',
description: 'Provide your password to decrypt keystore file.',
},
{
name: 'Custom keystore file',
value: 'file',
description: 'Provide a custom path to a local keystore file.',
},
{
name: 'Manual mnemonic entry',
value: 'mnemonic',
description: 'Provide your mnemonic in plain text.',
},
]

export const encryptMnemonic = (mnemonic: string, password: string): string => {
const iv = crypto.randomBytes(16)
const key = crypto.scryptSync(password, 'salt', 32)
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
const encrypted = cipher.update(mnemonic, 'utf8', 'hex') + cipher.final('hex')
return iv.toString('hex') + ':' + encrypted
}

const decryptMnemonic = (encryptedMnemonic: string, password: string): string | undefined => {
try {
const [ivHex, encryptedHex] = encryptedMnemonic.split(':')
const iv = Buffer.from(ivHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const key = crypto.scryptSync(password, 'salt', 32)
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
const decrypted = decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8')
return decrypted
} catch (err) {
error('Failed to decrypt mnemonic.')
}
}

export const create = async (): Promise<{
mnemonic: string
keystoreFile: string
}> => {
const password = await prompts.password({
message: 'Enter a password for encrypting keystore file: ',
mask: true,
})

const password2 = await prompts.password({
message: 'Re-enter your password: ',
mask: true,
})

if (password !== password2) {
error(`Your passwords don't match.`)
process.exit(1)
}

const mnemonic = generateMnemonic()
const encryptedMnemonic = encryptMnemonic(mnemonic, password)

// TODO: save file as related to epoch
const keystoreFile = path.join(RFOX_DIR, 'keystore.txt')

fs.writeFileSync(keystoreFile, encryptedMnemonic, 'utf8')

return { mnemonic, keystoreFile }
}

export const recoverKeystore = async (keystoreFile: string, attempt = 0): Promise<string> => {
const encryptedMnemonic = (() => {
try {
return fs.readFileSync(keystoreFile, 'utf8')
} catch (err) {
error('No keystore file found.')
}
})()

if (!encryptedMnemonic) {
return recoveryChoice(
attempt,
recoveryChoices.filter(choice => choice.value !== 'password'),
)
}

const password = await prompts.password({
message: 'Enter password to decrypt your keystore file: ',
mask: true,
})

const mnemonic = decryptMnemonic(encryptedMnemonic, password)

if (!mnemonic) {
return recoveryChoice(attempt, recoveryChoices, keystoreFile)
}

return mnemonic
}

const recoverMnemonic = async (mnemonic: string, attempt = 1): Promise<string> => {
const valid = validateMnemonic(mnemonic)

if (!valid) {
error('Mnemonic not valid.')
return recoveryChoice(attempt)
}

return mnemonic
}

const recoveryChoice = async (attempt: number, choices = recoveryChoices, keystoreFile = ''): Promise<string> => {
if (attempt >= 2) {
error('Failed to recover hot wallet, exiting.')
process.exit(1)
}

const choice = await prompts.select({
message: 'How do you want to recover your hot wallet?',
choices,
})

switch (choice) {
case 'password': {
return recoverKeystore(keystoreFile, ++attempt)
}
case 'file': {
const path = await prompts.input({
message: `Enter absolute path to your keystore file (ex. /home/user/rfox/keystore.txt): `,
})

return recoverKeystore(path, ++attempt)
}
case 'mnemonic': {
const mnemonic = await prompts.input({
message: 'Enter your mnemonic: ',
})

return recoverMnemonic(mnemonic, ++attempt)
}
default:
error(`Invalid choice: ${choice}, exiting.`)
process.exit(1)
}
}
Loading