diff --git a/ironfish-cli/package.json b/ironfish-cli/package.json index cfff8cf806..c283c7bad0 100644 --- a/ironfish-cli/package.json +++ b/ironfish-cli/package.json @@ -1,6 +1,6 @@ { "name": "ironfish", - "version": "2.7.0", + "version": "2.8.0", "description": "CLI for running and interacting with an Iron Fish node", "author": "Iron Fish (https://ironfish.network)", "main": "build/src/index.js", @@ -43,16 +43,17 @@ "yarn": "^1.22.10" }, "scripts": { - "build": "tsc -b", - "lint": "tsc -b && eslint --ext .ts,.tsx,.js,.jsx src/", + "build": "tsc -b tsconfig.build.json", + "build:tests": "tsc -b", + "lint": "yarn build && eslint --ext .ts,.tsx,.js,.jsx src/", "lint:deprecated": "yarn lint --rule \"deprecation/deprecation: warn\"", - "lint:fix": "tsc -b && eslint --ext .ts,.tsx,.js,.jsx src/ --fix", + "lint:fix": "yarn build && eslint --ext .ts,.tsx,.js,.jsx src/ --fix", "start:dev": "node start", "start": "yarn build && yarn start:js", "start:js": "cross-env OCLIF_TS_NODE=0 IRONFISH_DEBUG=1 node --expose-gc --inspect=:0 --inspect-publish-uid=http --enable-source-maps bin/run", "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", - "test:coverage:html": "tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns --coverage --coverage-reporters html", - "test:watch": "yarn clean && tsc -b && tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch --coverage false", + "test:coverage:html": "yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns --coverage --coverage-reporters html", + "test:watch": "yarn clean && yarn build && yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch --coverage false", "postpack": "rimraf oclif.manifest.json", "clean": "rimraf build", "prepack": "rimraf build && yarn build && oclif manifest && oclif readme", @@ -60,7 +61,8 @@ }, "dependencies": { "@ironfish/rust-nodejs": "2.7.0", - "@ironfish/sdk": "2.7.0", + "@ironfish/sdk": "2.8.0", + "@ledgerhq/errors": "6.17.0", "@ledgerhq/hw-transport-node-hid": "6.29.1", "@oclif/core": "4.0.11", "@oclif/plugin-help": "6.2.5", @@ -68,9 +70,8 @@ "@oclif/plugin-warn-if-update-available": "3.1.8", "@types/keccak": "3.0.4", "@types/tar": "6.1.1", - "@zondax/ledger-ironfish": "0.1.2", - "@zondax/ledger-ironfish-dkg": "npm:@zondax/ledger-ironfish@0.4.0", - "@zondax/ledger-js": "^1.0.1", + "@zondax/ledger-ironfish": "1.0.0", + "@zondax/ledger-js": "1.0.1", "axios": "1.7.2", "bech32": "2.0.0", "blessed": "0.1.81", diff --git a/ironfish-cli/src/commands/wallet/chainport/send.ts b/ironfish-cli/src/commands/wallet/chainport/send.ts index bcce482551..93595ba3e9 100644 --- a/ironfish-cli/src/commands/wallet/chainport/send.ts +++ b/ironfish-cli/src/commands/wallet/chainport/send.ts @@ -21,20 +21,20 @@ import * as ui from '../../../ui' import { ChainportBridgeTransaction, ChainportNetwork, - ChainportVerifiedToken, + ChainportToken, fetchChainportBridgeTransaction, - fetchChainportNetworkMap, - fetchChainportVerifiedTokens, + fetchChainportTokenPaths, + fetchChainportTokens, } from '../../../utils/chainport' import { isEthereumAddress } from '../../../utils/chainport/address' import { promptCurrency } from '../../../utils/currency' +import { promptExpiration } from '../../../utils/expiration' import { getExplorer } from '../../../utils/explorer' import { selectFee } from '../../../utils/fees' import { watchTransaction } from '../../../utils/transaction' export class BridgeCommand extends IronfishCommand { static description = `Use the Chainport bridge to bridge assets to EVM networks.` - static hidden = true static flags = { ...RemoteFlags, @@ -74,6 +74,15 @@ export class BridgeCommand extends IronfishCommand { default: false, description: 'Allow offline transaction creation', }), + unsignedTransaction: Flags.boolean({ + default: false, + description: + 'Return a serialized UnsignedTransaction. Use it to create a transaction and build proofs but not post to the network', + }), + ledger: Flags.boolean({ + default: false, + description: 'Send a transaction using a Ledger device', + }), } async start(): Promise { @@ -98,7 +107,7 @@ export class BridgeCommand extends IronfishCommand { } } - const { targetNetwork, from, to, amount, asset, assetData } = + const { targetNetwork, from, to, amount, asset, assetData, expiration } = await this.getAndValidateInputs(client, networkId) const rawTransaction = await this.constructBridgeTransaction( @@ -110,8 +119,31 @@ export class BridgeCommand extends IronfishCommand { amount, asset, assetData, + expiration, ) + if (flags.unsignedTransaction) { + const response = await client.wallet.buildTransaction({ + account: from, + rawTransaction: RawTransactionSerde.serialize(rawTransaction).toString('hex'), + }) + this.log('Unsigned Bridge Transaction') + this.log(response.content.unsignedTransaction) + this.exit(0) + } + + if (flags.ledger) { + await ui.sendTransactionWithLedger( + client, + rawTransaction, + from, + flags.watch, + true, + this.logger, + ) + this.exit(0) + } + await ui.confirmOrQuit() const postTransaction = await client.wallet.postTransaction({ @@ -178,11 +210,19 @@ export class BridgeCommand extends IronfishCommand { this.error('Invalid to ethereum address') } - if (flags.expiration !== undefined && flags.expiration < 0) { + let expiration = flags.expiration + + if (flags.unsignedTransaction && expiration === undefined) { + expiration = await promptExpiration({ logger: this.logger, client: client }) + } + + if (expiration !== undefined && expiration < 0) { this.error('Expiration sequence must be non-negative') } - const tokens = await fetchChainportVerifiedTokens(networkId) + ux.action.start('Fetching bridgeable assets') + const tokens = await fetchChainportTokens(networkId) + ux.action.stop() const tokenNames = tokens.map( (t, index) => `${index + 1}. ${t.name} (${t.symbol}) - ${t.web3_address}`, @@ -211,9 +251,7 @@ export class BridgeCommand extends IronfishCommand { assetId = asset.id } - const asset: ChainportVerifiedToken | undefined = tokens.find( - (t) => t.web3_address === assetId, - ) + const asset: ChainportToken | undefined = tokens.find((t) => t.web3_address === assetId) if (!asset) { this.logger.error( @@ -224,8 +262,6 @@ export class BridgeCommand extends IronfishCommand { this.exit(1) } - const targetNetworks = asset.target_networks - const assetData = ( await client.wallet.getAsset({ account: from, @@ -239,7 +275,7 @@ export class BridgeCommand extends IronfishCommand { assetData.verification.status = 'verified' } - const targetNetwork = await this.selectNetwork(networkId, targetNetworks, asset) + const targetNetwork = await this.selectNetwork(networkId, asset) let amount if (flags.amount) { @@ -270,7 +306,7 @@ export class BridgeCommand extends IronfishCommand { }, }) } - return { targetNetwork, from, to, amount, asset, assetData } + return { targetNetwork, from, to, amount, asset, assetData, expiration } } private async constructBridgeTransaction( @@ -280,12 +316,21 @@ export class BridgeCommand extends IronfishCommand { from: string, to: string, amount: bigint, - asset: ChainportVerifiedToken, + asset: ChainportToken, assetData: RpcAsset, + expiration: number | undefined, ) { const { flags } = await this.parse(BridgeCommand) - const txn = await fetchChainportBridgeTransaction(networkId, amount, to, network, asset) + ux.action.start('Fetching bridge transaction details') + const txn = await fetchChainportBridgeTransaction( + networkId, + amount, + asset.web3_address, + network.chainport_network_id, + to, + ) + ux.action.stop() const params: CreateTransactionRequest = { account: from, @@ -304,13 +349,14 @@ export class BridgeCommand extends IronfishCommand { ], fee: flags.fee ? CurrencyUtils.encode(flags.fee) : null, feeRate: flags.feeRate ? CurrencyUtils.encode(flags.feeRate) : null, - expiration: flags.expiration, + expiration, } let rawTransaction: RawTransaction if (params.fee === null && params.feeRate === null) { rawTransaction = await selectFee({ client, + account: from, transaction: params, logger: this.logger, }) @@ -320,7 +366,7 @@ export class BridgeCommand extends IronfishCommand { rawTransaction = RawTransactionSerde.deserialize(bytes) } - this.displayTransactionSummary(txn, rawTransaction, from, to, asset, assetData, network) + this.displayTransactionSummary(txn, rawTransaction, from, to, assetData, network) return rawTransaction } @@ -330,24 +376,18 @@ export class BridgeCommand extends IronfishCommand { raw: RawTransaction, from: string, to: string, - asset: ChainportVerifiedToken, assetData: RpcAsset, network: ChainportNetwork, ) { const bridgeAmount = CurrencyUtils.render( BigInt(txn.bridge_output.amount) - BigInt(txn.bridge_fee.source_token_fee_amount ?? 0), true, - asset.web3_address, + assetData.id, assetData.verification, ) const ironfishNetworkFee = CurrencyUtils.render(raw.fee, true) - const targetNetworkFee = CurrencyUtils.render( - BigInt(txn.gas_fee_output.amount), - true, - asset.web3_address, - assetData.verification, - ) + const targetNetworkFee = CurrencyUtils.render(BigInt(txn.gas_fee_output.amount), true) let chainportFee: string @@ -367,7 +407,7 @@ export class BridgeCommand extends IronfishCommand { chainportFee = CurrencyUtils.render( BigInt(txn.bridge_fee.source_token_fee_amount ?? 0), true, - asset.web3_address, + assetData.id, assetData.verification, ) } @@ -377,7 +417,7 @@ export class BridgeCommand extends IronfishCommand { From ${from} To ${to} - Target Network ${network.name} + Target Network ${network.label} Estimated Amount Received ${bridgeAmount} Fees: @@ -394,25 +434,13 @@ export class BridgeCommand extends IronfishCommand { private async selectNetwork( networkId: number, - targetNetworks: number[], - asset: ChainportVerifiedToken, + asset: ChainportToken, ): Promise { ux.action.start('Fetching available networks') - const networks = await fetchChainportNetworkMap(networkId) + const networks = await fetchChainportTokenPaths(networkId, asset.id) ux.action.stop() - const choices = Object.keys(networks).map((key) => { - return { - name: networks[key].label, - value: networks[key], - } - }) - - const filteredChoices = choices.filter((choice) => - targetNetworks.includes(choice.value.chainport_network_id), - ) - - if (filteredChoices.length === 0) { + if (networks.length === 0) { this.error(`No networks available for token ${asset.symbol} on Chainport`) } @@ -423,7 +451,10 @@ export class BridgeCommand extends IronfishCommand { name: 'selection', message: `Select the network you would like to bridge ${asset.symbol} to`, type: 'list', - choices: filteredChoices, + choices: networks.map((network) => ({ + name: network.label, + value: network, + })), }, ]) diff --git a/ironfish-cli/src/commands/wallet/import.ts b/ironfish-cli/src/commands/wallet/import.ts index 7c8e54badf..c36303fdf5 100644 --- a/ironfish-cli/src/commands/wallet/import.ts +++ b/ironfish-cli/src/commands/wallet/import.ts @@ -5,10 +5,11 @@ import { AccountFormat, encodeAccountImport } from '@ironfish/sdk' import { Args, Flags, ux } from '@oclif/core' import { IronfishCommand } from '../../command' import { RemoteFlags } from '../../flags' +import { LedgerError, LedgerSingleSigner } from '../../ledger' import { checkWalletUnlocked, inputPrompt } from '../../ui' +import * as ui from '../../ui' import { importFile, importPipe, longPrompt } from '../../ui/longPrompt' import { importAccount } from '../../utils' -import { Ledger, LedgerError } from '../../utils/ledger' export class ImportCommand extends IronfishCommand { static description = `import an account` @@ -118,9 +119,15 @@ export class ImportCommand extends IronfishCommand { async importLedger(): Promise { try { - const ledger = new Ledger(this.logger) - await ledger.connect() - const account = await ledger.importAccount() + const ledger = new LedgerSingleSigner() + + const account = await ui.ledger({ + ledger, + message: 'Import Wallet', + approval: true, + action: () => ledger.importAccount(), + }) + return encodeAccountImport(account, AccountFormat.Base64Json) } catch (e) { if (e instanceof LedgerError) { diff --git a/ironfish-cli/src/commands/wallet/mint.ts b/ironfish-cli/src/commands/wallet/mint.ts index bcea04220d..d2b5d8a294 100644 --- a/ironfish-cli/src/commands/wallet/mint.ts +++ b/ironfish-cli/src/commands/wallet/mint.ts @@ -23,7 +23,6 @@ import { promptCurrency } from '../../utils/currency' import { promptExpiration } from '../../utils/expiration' import { getExplorer } from '../../utils/explorer' import { selectFee } from '../../utils/fees' -import { sendTransactionWithLedger } from '../../utils/ledger' import { watchTransaction } from '../../utils/transaction' export class Mint extends IronfishCommand { @@ -314,7 +313,7 @@ This will create tokens and increase supply for a given asset.` ) if (flags.ledger) { - await sendTransactionWithLedger( + await ui.sendTransactionWithLedger( client, raw, account, diff --git a/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts b/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts index a78872b9fa..f2e9103da2 100644 --- a/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts @@ -6,8 +6,8 @@ import { RpcClient, UnsignedTransaction } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' -import { LedgerDkg } from '../../../../utils/ledger' import { MultisigTransactionJson } from '../../../../utils/multisig' import { renderUnsignedTransactionDetails } from '../../../../utils/transaction' @@ -124,7 +124,7 @@ export class CreateSigningCommitmentCommand extends IronfishCommand { unsignedTransaction: UnsignedTransaction, signers: string[], ): Promise { - const ledger = new LedgerDkg(this.logger) + const ledger = new LedgerMultiSigner() try { await ledger.connect() } catch (e) { @@ -138,16 +138,12 @@ export class CreateSigningCommitmentCommand extends IronfishCommand { const identityResponse = await client.wallet.multisig.getIdentity({ name: participantName }) const identity = identityResponse.content.identity - const transactionHash = await ledger.reviewTransaction( - unsignedTransaction.serialize().toString('hex'), - ) - - const rawCommitments = await ledger.dkgGetCommitments(transactionHash.toString('hex')) + const rawCommitments = await ledger.dkgGetCommitments(unsignedTransaction) const signingCommitment = multisig.SigningCommitment.fromRaw( identity, rawCommitments, - transactionHash, + unsignedTransaction.hash(), signers, ) diff --git a/ironfish-cli/src/commands/wallet/multisig/dkg/create.ts b/ironfish-cli/src/commands/wallet/multisig/dkg/create.ts index 12377e2ead..b53e2c9ace 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dkg/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/create.ts @@ -11,13 +11,17 @@ import { AccountFormat, Assert, encodeAccountImport, + PromiseUtils, RpcClient, } from '@ironfish/sdk' -import { Flags } from '@oclif/core' +import { Flags, ux } from '@oclif/core' +import fs from 'fs' +import path from 'path' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' +import { MultisigBrokerUtils, MultisigClient } from '../../../../multisigBroker' import * as ui from '../../../../ui' -import { LedgerDkg } from '../../../../utils/ledger' export class DkgCreateCommand extends IronfishCommand { static description = 'Interactive command to create a multisignature account using DKG' @@ -40,6 +44,40 @@ export class DkgCreateCommand extends IronfishCommand { description: "Block sequence to begin scanning from for the created account. Uses node's chain head by default", }), + server: Flags.boolean({ + description: 'connect to a multisig broker server', + }), + connection: Flags.string({ + char: 'c', + description: 'connection string for a multisig server session', + }), + hostname: Flags.string({ + description: 'hostname of the multisig broker server to connect to', + default: 'multisig.ironfish.network', + }), + port: Flags.integer({ + description: 'port to connect to on the multisig broker server', + default: 9035, + }), + sessionId: Flags.string({ + description: 'Unique ID for a multisig server session to join', + }), + passphrase: Flags.string({ + description: 'Passphrase to join the multisig server session', + }), + tls: Flags.boolean({ + description: 'connect to the multisig server over TLS', + dependsOn: ['server'], + allowNo: true, + }), + minSigners: Flags.integer({ + description: 'Minimum signers required to sign a transaction', + exclusive: ['sessionId'], + }), + totalParticipants: Flags.integer({ + description: 'The total number of participants for the multisig account', + exclusive: ['sessionId'], + }), } async start(): Promise { @@ -47,22 +85,13 @@ export class DkgCreateCommand extends IronfishCommand { const client = await this.connectRpc() await ui.checkWalletUnlocked(client) - let ledger: LedgerDkg | undefined = undefined + let ledger: LedgerMultiSigner | undefined = undefined if (flags.ledger) { - ledger = new LedgerDkg(this.logger) - try { - await ledger.connect() - } catch (e) { - if (e instanceof Error) { - this.error(e.message) - } else { - throw e - } - } + ledger = new LedgerMultiSigner() } - const accountName = await this.getAccountName(client, flags.name) + const accountName = await this.getAccountName(client, flags.name ?? flags.participant) let accountCreatedAt = flags.createdAt if (!accountCreatedAt) { @@ -70,58 +99,99 @@ export class DkgCreateCommand extends IronfishCommand { accountCreatedAt = statusResponse.content.blockchain.head.sequence } - const { name: participantName, identity } = ledger - ? await ui.retryStep( - () => { - Assert.isNotUndefined(ledger) - return this.getIdentityFromLedger(ledger, client, flags.participant) - }, - this.logger, - true, - ) - : await this.getParticipant(client, flags.participant) + let multisigClient: MultisigClient | null = null + if (flags.server || flags.connection || flags.sessionId || flags.passphrase) { + const { hostname, port, sessionId, passphrase } = + await MultisigBrokerUtils.parseConnectionOptions({ + connection: flags.connection, + hostname: flags.hostname, + port: flags.port, + sessionId: flags.sessionId, + passphrase: flags.passphrase, + logger: this.logger, + }) + + multisigClient = MultisigBrokerUtils.createClient(hostname, port, { + passphrase, + tls: flags.tls ?? true, + logger: this.logger, + }) + multisigClient.start() + + let connectionConfirmed = false + + multisigClient.onConnectedMessage.on(() => { + connectionConfirmed = true + Assert.isNotNull(multisigClient) + multisigClient.onConnectedMessage.clear() + }) + + if (sessionId) { + while (!connectionConfirmed) { + await PromiseUtils.sleep(500) + continue + } - this.log(`Identity for ${participantName}: \n${identity} \n`) + multisigClient.joinSession(sessionId) + } + } - const { round1, totalParticipants } = await ui.retryStep( + const { totalParticipants, minSigners } = await ui.retryStep( async () => { - return this.performRound1(client, participantName, identity, ledger) + return this.getDkgConfig( + multisigClient, + !!ledger, + flags.minSigners, + flags.totalParticipants, + ) }, this.logger, true, ) - this.log('\n============================================') - this.log('\nRound 1 Encrypted Secret Package:') - this.log(round1.secretPackage) - - this.log('\nRound 1 Public Package:') - this.log(round1.publicPackage) - this.log('\n============================================') + const { name: participantName, identity } = await this.getOrCreateIdentity( + client, + ledger, + accountName, + ) - this.log('\nShare your Round 1 Public Package with other participants.') + const { round1 } = await ui.retryStep( + async () => { + return this.performRound1( + client, + multisigClient, + participantName, + identity, + totalParticipants, + minSigners, + ledger, + ) + }, + this.logger, + true, + ) const { round2: round2Result, round1PublicPackages } = await ui.retryStep( async () => { - return this.performRound2(client, participantName, round1, totalParticipants, ledger) + return this.performRound2( + client, + multisigClient, + accountName, + participantName, + round1, + totalParticipants, + ledger, + ) }, this.logger, true, ) - this.log('\n============================================') - this.log('\nRound 2 Encrypted Secret Package:') - this.log(round2Result.secretPackage) - - this.log('\nRound 2 Public Package:') - this.log(round2Result.publicPackage) - this.log('\n============================================') - this.log('\nShare your Round 2 Public Package with other participants.') - await ui.retryStep( async () => { return this.performRound3( client, + multisigClient, accountName, participantName, round2Result, @@ -135,119 +205,197 @@ export class DkgCreateCommand extends IronfishCommand { true, ) + if (ledger) { + await ui.retryStep( + async () => { + Assert.isNotUndefined(ledger) + return this.createBackup(ledger, accountName) + }, + this.logger, + true, + ) + } + this.log('Multisig account created successfully using DKG!') + multisigClient?.stop() } - private async getParticipant(client: RpcClient, participantName?: string) { - const identities = (await client.wallet.multisig.getIdentities()).content.identities + private async createBackup(ledger: LedgerMultiSigner, accountName: string) { + this.log() + this.log('Creating an encrypted backup of multisig keys from your Ledger device...') + this.log() - if (participantName) { - const foundIdentity = identities.find((i) => i.name === participantName) - if (!foundIdentity) { - throw new Error(`Participant with name ${participantName} not found`) - } + const encryptedKeys = await ui.ledger({ + ledger, + message: 'Backup DKG Keys', + approval: true, + action: () => ledger.dkgBackupKeys(), + }) - return { - name: foundIdentity.name, - identity: foundIdentity.identity, - } + this.log() + this.log('Encrypted Ledger Multisig Backup:') + this.log(encryptedKeys.toString('hex')) + this.log() + this.log('Please save the encrypted keys shown above.') + this.log( + 'Use `ironfish wallet:multisig:ledger:restore` if you need to restore the keys to your Ledger.', + ) + + const dataDir = this.sdk.fileSystem.resolve(this.sdk.dataDir) + const backupKeysPath = path.join(dataDir, `ironfish-ledger-${accountName}.txt`) + + if (fs.existsSync(backupKeysPath)) { + await ui.confirmOrQuit( + `Error when backing up your keys: \nThe file ${backupKeysPath} already exists. \nOverwrite?`, + ) } - const name = await ui.inputPrompt('Enter the name of the participant', true) - const foundIdentity = identities.find((i) => i.name === name) + await fs.promises.writeFile(backupKeysPath, encryptedKeys.toString('hex')) + this.log(`A copy of your encrypted keys have been saved at ${backupKeysPath}`) + } - if (foundIdentity) { - this.log('Found an identity with the same name') + private async getOrCreateIdentity( + client: RpcClient, + ledger: LedgerMultiSigner | undefined, + name: string, + ): Promise<{ + identity: string + name: string + }> { + const identities = await client.wallet.multisig.getIdentities() - return { - ...foundIdentity, + if (ledger) { + const ledgerIdentity = await ui.ledger({ + ledger, + message: 'Getting Ledger Identity', + action: () => ledger.dkgGetIdentity(0), + }) + + const foundIdentity = identities.content.identities.find( + (i) => i.identity === ledgerIdentity.toString('hex'), + ) + + if (foundIdentity) { + this.debug('Identity from ledger already exists') + return foundIdentity } - } - const identity = (await client.wallet.multisig.createParticipant({ name })).content.identity + // We must use the ledger's identity + while (identities.content.identities.find((i) => i.name === name)) { + this.log('An identity with the same name already exists') + name = await ui.inputPrompt('Enter a new name for the identity', true) + } - return { - name, - identity, - } - } + const created = await client.wallet.multisig.importParticipant({ + name, + identity: ledgerIdentity.toString('hex'), + }) - private async getAccountName(client: RpcClient, accountName?: string) { - let name: string - if (accountName) { - name = accountName - } else { - name = await ui.inputPrompt('Enter a name for the new multisig account', true) + return { name, identity: created.content.identity } } - const accounts = (await client.wallet.getAccounts()).content.accounts + const foundIdentity = identities.content.identities.find((i) => i.name === name) - if (accounts.find((a) => a === name)) { - this.log('An account with the same name already exists') - name = await ui.inputPrompt('Enter a new name for the account', true) + if (foundIdentity) { + this.debug(`Identity already exists with name: ${foundIdentity.name}`) + return foundIdentity } - return name + const created = await client.wallet.multisig.createParticipant({ name }) + return { name, identity: created.content.identity } } - async getIdentityFromLedger( - ledger: LedgerDkg, - client: RpcClient, - name?: string, - ): Promise<{ - name: string - identity: string - }> { - // TODO(hughy): support multiple identities using index - const identity = await ledger.dkgGetIdentity(0) + private async getAccountName(client: RpcClient, name?: string) { + // eslint-disable-next-line no-constant-condition + while (true) { + if (!name) { + name = await ui.inputPrompt('Enter a name for the multisig account', true) + } - const allIdentities = (await client.wallet.multisig.getIdentities()).content.identities + const accounts = (await client.wallet.getAccounts()).content.accounts - const foundIdentity = allIdentities.find((i) => i.identity === identity.toString('hex')) + if (accounts.find((a) => a === name)) { + this.log('An account with the same name already exists') + name = undefined + continue + } - if (foundIdentity) { - this.log(`Identity already exists with name: ${foundIdentity.name}`) + break + } - return { - name: foundIdentity.name, - identity: identity.toString('hex'), + return name + } + + async getDkgConfig( + multisigClient: MultisigClient | null, + ledger: boolean, + minSigners?: number, + totalParticipants?: number, + ): Promise<{ totalParticipants: number; minSigners: number }> { + if (multisigClient?.sessionId) { + let totalParticipants = 0 + let minSigners = 0 + let waiting = true + multisigClient.onDkgStatus.on((message) => { + totalParticipants = message.maxSigners + minSigners = message.minSigners + waiting = false + }) + + ux.action.start('Waiting for signer config from server') + while (waiting) { + multisigClient.getDkgStatus() + await PromiseUtils.sleep(3000) } + multisigClient.onDkgStatus.clear() + ux.action.stop() + + return { totalParticipants, minSigners } } - name = await ui.inputPrompt('Enter a name for the identity', true) + if (!totalParticipants) { + totalParticipants = await ui.inputNumberPrompt( + this.logger, + 'Enter the total number of participants', + { required: true, integer: true }, + ) + } - while (allIdentities.find((i) => i.name === name)) { - this.log('An identity with the same name already exists') - name = await ui.inputPrompt('Enter a new name for the identity', true) + if (totalParticipants < 2) { + throw new Error('Total number of participants must be at least 2') } - await client.wallet.multisig.importParticipant({ - name, - identity: identity.toString('hex'), - }) + if (ledger && totalParticipants > 4) { + throw new Error('DKG with Ledger supports a maximum of 4 participants') + } - return { - name, - identity: identity.toString('hex'), + if (!minSigners) { + minSigners = await ui.inputNumberPrompt( + this.logger, + 'Enter the number of minimum signers', + { required: true, integer: true }, + ) } - } - async createParticipant( - client: RpcClient, - name: string, - ): Promise<{ - name: string - identity: string - }> { - const identity = (await client.wallet.multisig.createParticipant({ name })).content.identity - return { - name, - identity, + if (minSigners < 2 || minSigners > totalParticipants) { + throw new Error( + 'Minimum number of signers must be between 2 and the total number of participants', + ) } + + if (multisigClient) { + multisigClient.startDkgSession(totalParticipants, minSigners) + this.log('\nStarted new DKG session:') + this.log(`${multisigClient.sessionId}`) + this.log('\nDKG session connection string:') + this.log(`${multisigClient.connectionString}`) + } + + return { totalParticipants, minSigners } } async performRound1WithLedger( - ledger: LedgerDkg, + ledger: LedgerMultiSigner, client: RpcClient, participantName: string, identities: string[], @@ -263,7 +411,12 @@ export class DkgCreateCommand extends IronfishCommand { } // TODO(hughy): determine how to handle multiple identities using index - const { publicPackage, secretPackage } = await ledger.dkgRound1(0, identities, minSigners) + const { publicPackage, secretPackage } = await ui.ledger({ + ledger, + message: 'Round1 on Ledger', + approval: true, + action: () => ledger.dkgRound1(0, identities, minSigners), + }) return { round1: { @@ -275,64 +428,56 @@ export class DkgCreateCommand extends IronfishCommand { async performRound1( client: RpcClient, + multisigClient: MultisigClient | null, participantName: string, currentIdentity: string, - ledger: LedgerDkg | undefined, + totalParticipants: number, + minSigners: number, + ledger: LedgerMultiSigner | undefined, ): Promise<{ round1: { secretPackage: string; publicPackage: string } - totalParticipants: number }> { this.log('\nCollecting Participant Info and Performing Round 1...') - const totalParticipants = await ui.inputNumberPrompt( - this.logger, - 'Enter the total number of participants', - { required: true, integer: true }, - ) - - if (totalParticipants < 2) { - throw new Error('Total number of participants must be at least 2') - } + let identities: string[] = [currentIdentity] + if (!multisigClient) { + this.log(`Identity for ${participantName}: \n${currentIdentity} \n`) - if (ledger && totalParticipants > 4) { - throw new Error('DKG with Ledger supports a maximum of 4 participants') - } + this.log( + `\nEnter ${ + totalParticipants - 1 + } identities of all other participants (excluding yours) `, + ) + identities = await ui.collectStrings('Participant Identity', totalParticipants - 1, { + additionalStrings: [currentIdentity], + errorOnDuplicate: true, + }) + } else { + multisigClient.submitDkgIdentity(currentIdentity) - this.log( - `\nEnter ${ - totalParticipants - 1 - } identities of all other participants (excluding yours) `, - ) - const identities = await ui.collectStrings('Participant Identity', totalParticipants - 1, { - additionalStrings: [currentIdentity], - errorOnDuplicate: true, - }) + multisigClient.onDkgStatus.on((message) => { + identities = message.identities + }) - const minSigners = await ui.inputNumberPrompt( - this.logger, - 'Enter the number of minimum signers', - { required: true, integer: true }, - ) + ux.action.start('Waiting for Identities from server') + while (identities.length < totalParticipants) { + multisigClient.getDkgStatus() + ux.action.status = `${identities.length}/${totalParticipants}` + await PromiseUtils.sleep(3000) + } - if (minSigners < 2 || minSigners > totalParticipants) { - throw new Error( - 'Minimum number of signers must be between 2 and the total number of participants', - ) + multisigClient.onDkgStatus.clear() + ux.action.stop() } if (ledger) { - const result = await this.performRound1WithLedger( + return await this.performRound1WithLedger( ledger, client, participantName, identities, minSigners, ) - - return { - ...result, - totalParticipants, - } } this.log('\nPerforming DKG Round 1...') @@ -347,23 +492,23 @@ export class DkgCreateCommand extends IronfishCommand { secretPackage: response.content.round1SecretPackage, publicPackage: response.content.round1PublicPackage, }, - totalParticipants, } } async performRound2WithLedger( - ledger: LedgerDkg, + ledger: LedgerMultiSigner, round1PublicPackages: string[], round1SecretPackage: string, ): Promise<{ round2: { secretPackage: string; publicPackage: string } }> { // TODO(hughy): determine how to handle multiple identities using index - const { publicPackage, secretPackage } = await ledger.dkgRound2( - 0, - round1PublicPackages, - round1SecretPackage, - ) + const { publicPackage, secretPackage } = await ui.ledger({ + ledger, + message: 'Round2 on Ledger', + approval: true, + action: () => ledger.dkgRound2(0, round1PublicPackages, round1SecretPackage), + }) return { round2: { @@ -375,24 +520,53 @@ export class DkgCreateCommand extends IronfishCommand { async performRound2( client: RpcClient, + multisigClient: MultisigClient | null, + accountName: string, participantName: string, round1Result: { secretPackage: string; publicPackage: string }, totalParticipants: number, - ledger: LedgerDkg | undefined, + ledger: LedgerMultiSigner | undefined, ): Promise<{ round2: { secretPackage: string; publicPackage: string } round1PublicPackages: string[] }> { - this.log(`\nEnter ${totalParticipants - 1} Round 1 Public Packages (excluding yours) `) + let round1PublicPackages: string[] = [round1Result.publicPackage] + if (!multisigClient) { + this.log('\n============================================') + this.debug(`\nRound 1 Encrypted Secret Package for ${accountName}:`) + this.debug(round1Result.secretPackage) + + this.log(`\nRound 1 Public Package for ${accountName}:`) + this.log(round1Result.publicPackage) + this.log('\n============================================') + + this.log('\nShare your Round 1 Public Package with other participants.') + this.log(`\nEnter ${totalParticipants - 1} Round 1 Public Packages (excluding yours) `) + + round1PublicPackages = await ui.collectStrings( + 'Round 1 Public Package', + totalParticipants - 1, + { + additionalStrings: [round1Result.publicPackage], + errorOnDuplicate: true, + }, + ) + } else { + multisigClient.submitRound1PublicPackage(round1Result.publicPackage) + multisigClient.onDkgStatus.on((message) => { + round1PublicPackages = message.round1PublicPackages + }) + + ux.action.start('Waiting for Round 1 Public Packages from server') + while (round1PublicPackages.length < totalParticipants) { + multisigClient.getDkgStatus() + ux.action.status = `${round1PublicPackages.length}/${totalParticipants}` + await PromiseUtils.sleep(3000) + } - const round1PublicPackages = await ui.collectStrings( - 'Round 1 Public Package', - totalParticipants - 1, - { - additionalStrings: [round1Result.publicPackage], - errorOnDuplicate: true, - }, - ) + multisigClient.onDkgStatus.clear() + ux.action.stop() + } this.log('\nPerforming DKG Round 2...') @@ -424,7 +598,7 @@ export class DkgCreateCommand extends IronfishCommand { } async performRound3WithLedger( - ledger: LedgerDkg, + ledger: LedgerMultiSigner, client: RpcClient, accountName: string, participantName: string, @@ -452,9 +626,9 @@ export class DkgCreateCommand extends IronfishCommand { .sort((a, b) => a.senderIdentity.localeCompare(b.senderIdentity)) // Extract raw parts from round1 and round2 public packages - const participants = [] - const round1FrostPackages = [] - const gskBytes = [] + const participants: string[] = [] + const round1FrostPackages: string[] = [] + const gskBytes: string[] = [] for (const pkg of round1PublicPackages) { // Exclude participant's own identity and round1 public package if (pkg.identity !== identity) { @@ -468,19 +642,33 @@ export class DkgCreateCommand extends IronfishCommand { const round2FrostPackages = round2PublicPackages.map((pkg) => pkg.frostPackage) // Perform round3 with Ledger - await ledger.dkgRound3( - 0, - participants, - round1FrostPackages, - round2FrostPackages, - round2SecretPackage, - gskBytes, - ) + await ui.ledger({ + ledger, + message: 'Round3 on Ledger', + approval: true, + action: () => + ledger.dkgRound3( + 0, + participants, + round1FrostPackages, + round2FrostPackages, + round2SecretPackage, + gskBytes, + ), + }) // Retrieve all multisig account keys and publicKeyPackage - const dkgKeys = await ledger.dkgRetrieveKeys() + const dkgKeys = await ui.ledger({ + ledger, + message: 'Getting Ledger DKG keys', + action: () => ledger.dkgRetrieveKeys(), + }) - const publicKeyPackage = await ledger.dkgGetPublicPackage() + const publicKeyPackage = await ui.ledger({ + ledger, + message: 'Getting Ledger Public Package', + action: () => ledger.dkgGetPublicPackage(), + }) const accountImport = { ...dkgKeys, @@ -504,43 +692,56 @@ export class DkgCreateCommand extends IronfishCommand { this.log( `Account ${response.content.name} imported with public address: ${dkgKeys.publicAddress}`, ) - - this.log() - this.log('Creating an encrypted backup of multisig keys from your Ledger device...') - this.log() - - const encryptedKeys = await ledger.dkgBackupKeys() - - this.log() - this.log('Encrypted Ledger Multisig Backup:') - this.log(encryptedKeys.toString('hex')) - this.log() - this.log('Please save the encrypted keys shown above.') - this.log( - 'Use `ironfish wallet:multisig:ledger:restore` if you need to restore the keys to your Ledger.', - ) } async performRound3( client: RpcClient, + multisigClient: MultisigClient | null, accountName: string, participantName: string, round2Result: { secretPackage: string; publicPackage: string }, round1PublicPackages: string[], totalParticipants: number, - ledger: LedgerDkg | undefined, + ledger: LedgerMultiSigner | undefined, accountCreatedAt?: number, ): Promise { - this.log(`\nEnter ${totalParticipants - 1} Round 2 Public Packages (excluding yours) `) + let round2PublicPackages: string[] = [round2Result.publicPackage] + if (!multisigClient) { + this.log('\n============================================') + this.debug(`\nRound 2 Encrypted Secret Package for ${accountName}:`) + this.debug(round2Result.secretPackage) + + this.log(`\nRound 2 Public Package for ${accountName}:`) + this.log(round2Result.publicPackage) + this.log('\n============================================') + + this.log('\nShare your Round 2 Public Package with other participants.') + this.log(`\nEnter ${totalParticipants - 1} Round 2 Public Packages (excluding yours) `) + + round2PublicPackages = await ui.collectStrings( + 'Round 2 Public Package', + totalParticipants - 1, + { + additionalStrings: [round2Result.publicPackage], + errorOnDuplicate: true, + }, + ) + } else { + multisigClient.submitRound2PublicPackage(round2Result.publicPackage) + multisigClient.onDkgStatus.on((message) => { + round2PublicPackages = message.round2PublicPackages + }) + + ux.action.start('Waiting for Round 2 Public Packages from server') + while (round2PublicPackages.length < totalParticipants) { + multisigClient.getDkgStatus() + ux.action.status = `${round2PublicPackages.length}/${totalParticipants}` + await PromiseUtils.sleep(3000) + } - const round2PublicPackages = await ui.collectStrings( - 'Round 2 Public Package', - totalParticipants - 1, - { - additionalStrings: [round2Result.publicPackage], - errorOnDuplicate: true, - }, - ) + multisigClient.onDkgStatus.clear() + ux.action.stop() + } if (ledger) { await this.performRound3WithLedger( @@ -564,6 +765,7 @@ export class DkgCreateCommand extends IronfishCommand { round2PublicPackages, }) + this.log() this.log(`Account Name: ${response.content.name}`) this.log(`Public Address: ${response.content.publicAddress}`) } diff --git a/ironfish-cli/src/commands/wallet/multisig/dkg/round1.ts b/ironfish-cli/src/commands/wallet/multisig/dkg/round1.ts index 8b8c9d195c..488f786e1c 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dkg/round1.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/round1.ts @@ -5,8 +5,8 @@ import { RpcClient } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' -import { LedgerDkg } from '../../../../utils/ledger' export class DkgRound1Command extends IronfishCommand { static description = 'Perform round1 of the DKG protocol for multisig account creation' @@ -100,7 +100,7 @@ export class DkgRound1Command extends IronfishCommand { identities: string[], minSigners: number, ): Promise { - const ledger = new LedgerDkg(this.logger) + const ledger = new LedgerMultiSigner() try { await ledger.connect() } catch (e) { diff --git a/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts b/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts index f7f569bcf0..a09afdb7aa 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts @@ -4,8 +4,8 @@ import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' -import { LedgerDkg } from '../../../../utils/ledger' export class DkgRound2Command extends IronfishCommand { static description = 'Perform round2 of the DKG protocol for multisig account creation' @@ -97,7 +97,7 @@ export class DkgRound2Command extends IronfishCommand { round1PublicPackages: string[], round1SecretPackage: string, ): Promise { - const ledger = new LedgerDkg(this.logger) + const ledger = new LedgerMultiSigner() try { await ledger.connect() } catch (e) { diff --git a/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts b/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts index 7d4bc8083a..02541ef461 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts @@ -14,9 +14,9 @@ import { import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' import { importAccount } from '../../../../utils' -import { LedgerDkg } from '../../../../utils/ledger' export class DkgRound3Command extends IronfishCommand { static description = 'Perform round3 of the DKG protocol for multisig account creation' @@ -162,7 +162,7 @@ export class DkgRound3Command extends IronfishCommand { round2SecretPackage: string, accountCreatedAt?: number, ): Promise { - const ledger = new LedgerDkg(this.logger) + const ledger = new LedgerMultiSigner() try { await ledger.connect() } catch (e) { diff --git a/ironfish-cli/src/commands/wallet/multisig/ledger/backup.ts b/ironfish-cli/src/commands/wallet/multisig/ledger/backup.ts index a2b26435b5..7b5bf7d171 100644 --- a/ironfish-cli/src/commands/wallet/multisig/ledger/backup.ts +++ b/ironfish-cli/src/commands/wallet/multisig/ledger/backup.ts @@ -2,24 +2,21 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { IronfishCommand } from '../../../../command' -import { LedgerDkg } from '../../../../utils/ledger' +import { LedgerMultiSigner } from '../../../../ledger' +import * as ui from '../../../../ui' export class MultisigLedgerBackup extends IronfishCommand { static description = `show encrypted multisig keys from a Ledger device` async start(): Promise { - const ledger = new LedgerDkg(this.logger) - try { - await ledger.connect() - } catch (e) { - if (e instanceof Error) { - this.error(e.message) - } else { - throw e - } - } + const ledger = new LedgerMultiSigner() - const encryptedKeys = await ledger.dkgBackupKeys() + const encryptedKeys = await ui.ledger({ + ledger, + message: 'Getting Ledger Keys', + approval: true, + action: () => ledger.dkgBackupKeys(), + }) this.log() this.log('Encrypted Ledger Multisig Backup:') diff --git a/ironfish-cli/src/commands/wallet/multisig/ledger/import.ts b/ironfish-cli/src/commands/wallet/multisig/ledger/import.ts index 1641e382c5..b9199e8c6d 100644 --- a/ironfish-cli/src/commands/wallet/multisig/ledger/import.ts +++ b/ironfish-cli/src/commands/wallet/multisig/ledger/import.ts @@ -5,9 +5,9 @@ import { ACCOUNT_SCHEMA_VERSION, AccountFormat, encodeAccountImport } from '@iro import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' import { importAccount } from '../../../../utils' -import { LedgerDkg } from '../../../../utils/ledger' export class MultisigLedgerImport extends IronfishCommand { static description = `import a multisig account from a Ledger device` @@ -31,7 +31,7 @@ export class MultisigLedgerImport extends IronfishCommand { const name = flags.name ?? (await ui.inputPrompt('Enter a name for the account', true)) - const ledger = new LedgerDkg(this.logger) + const ledger = new LedgerMultiSigner() try { await ledger.connect() } catch (e) { diff --git a/ironfish-cli/src/commands/wallet/multisig/ledger/restore.ts b/ironfish-cli/src/commands/wallet/multisig/ledger/restore.ts index cad4c91e16..e9dfa98e08 100644 --- a/ironfish-cli/src/commands/wallet/multisig/ledger/restore.ts +++ b/ironfish-cli/src/commands/wallet/multisig/ledger/restore.ts @@ -3,8 +3,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { Args } from '@oclif/core' import { IronfishCommand } from '../../../../command' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' -import { LedgerDkg } from '../../../../utils/ledger' export class MultisigLedgerRestore extends IronfishCommand { static description = `restore encrypted multisig keys to a Ledger device` @@ -19,25 +19,21 @@ export class MultisigLedgerRestore extends IronfishCommand { async start(): Promise { const { args } = await this.parse(MultisigLedgerRestore) - let encryptedKeys = args.backup - if (!encryptedKeys) { - encryptedKeys = await ui.longPrompt( + const encryptedKeys = + args.backup || + (await ui.longPrompt( 'Enter the encrypted multisig key backup to restore to your Ledger device', - ) - } + { required: true }, + )) - const ledger = new LedgerDkg(this.logger) - try { - await ledger.connect() - } catch (e) { - if (e instanceof Error) { - this.error(e.message) - } else { - throw e - } - } + const ledger = new LedgerMultiSigner() - await ledger.dkgRestoreKeys(encryptedKeys) + await ui.ledger({ + ledger, + message: 'Restoring Keys to Ledger', + approval: true, + action: () => ledger.dkgRestoreKeys(encryptedKeys), + }) this.log() this.log('Encrypted multisig key backup restored to Ledger.') diff --git a/ironfish-cli/src/commands/wallet/multisig/participant/create.ts b/ironfish-cli/src/commands/wallet/multisig/participant/create.ts index 3630fcb43c..d9e7df0f66 100644 --- a/ironfish-cli/src/commands/wallet/multisig/participant/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/participant/create.ts @@ -5,8 +5,8 @@ import { RPC_ERROR_CODES, RpcRequestError } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' -import { LedgerDkg } from '../../../../utils/ledger' export class MultisigIdentityCreate extends IronfishCommand { static description = `Create a multisig participant identity` @@ -71,7 +71,7 @@ export class MultisigIdentityCreate extends IronfishCommand { } async getIdentityFromLedger(): Promise { - const ledger = new LedgerDkg(this.logger) + const ledger = new LedgerMultiSigner() try { await ledger.connect() } catch (e) { diff --git a/ironfish-cli/src/commands/wallet/multisig/server.ts b/ironfish-cli/src/commands/wallet/multisig/server.ts new file mode 100644 index 0000000000..1ad674f596 --- /dev/null +++ b/ironfish-cli/src/commands/wallet/multisig/server.ts @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { TlsUtils } from '@ironfish/sdk' +import { Flags } from '@oclif/core' +import { IronfishCommand } from '../../../command' +import { MultisigServer } from '../../../multisigBroker' +import { + IMultisigBrokerAdapter, + MultisigTcpAdapter, + MultisigTlsAdapter, +} from '../../../multisigBroker/adapters' + +export class MultisigServerCommand extends IronfishCommand { + static description = 'start a server to broker messages for a multisig session' + + static flags = { + host: Flags.string({ + description: 'host address for the multisig server', + default: '::', + }), + port: Flags.integer({ + description: 'port for the multisig server', + default: 9035, + }), + tls: Flags.boolean({ + description: 'enable TLS on the multisig server', + allowNo: true, + default: true, + }), + idleSessionTimeout: Flags.integer({ + description: 'time (in ms) to wait before cleaning up idle session data', + char: 'i', + }), + } + + async start(): Promise { + const { flags } = await this.parse(MultisigServerCommand) + + const server = new MultisigServer({ + logger: this.logger, + idleSessionTimeout: flags.idleSessionTimeout, + }) + + let adapter: IMultisigBrokerAdapter + if (flags.tls) { + const fileSystem = this.sdk.fileSystem + const nodeKeyPath = this.sdk.config.get('tlsKeyPath') + const nodeCertPath = this.sdk.config.get('tlsCertPath') + const tlsOptions = await TlsUtils.getTlsOptions( + fileSystem, + nodeKeyPath, + nodeCertPath, + this.logger, + ) + + adapter = new MultisigTlsAdapter({ + logger: this.logger, + host: flags.host, + port: flags.port, + tlsOptions, + }) + } else { + adapter = new MultisigTcpAdapter({ + logger: this.logger, + host: flags.host, + port: flags.port, + }) + } + + server.mount(adapter) + await server.start() + } +} diff --git a/ironfish-cli/src/commands/wallet/multisig/sign.ts b/ironfish-cli/src/commands/wallet/multisig/sign.ts index 4f75574b05..fda01bf01e 100644 --- a/ironfish-cli/src/commands/wallet/multisig/sign.ts +++ b/ironfish-cli/src/commands/wallet/multisig/sign.ts @@ -4,8 +4,10 @@ import { multisig } from '@ironfish/rust-nodejs' import { + Assert, CurrencyUtils, Identity, + PromiseUtils, RpcClient, Transaction, UnsignedTransaction, @@ -13,8 +15,9 @@ import { import { Flags, ux } from '@oclif/core' import { IronfishCommand } from '../../../command' import { RemoteFlags } from '../../../flags' +import { LedgerMultiSigner } from '../../../ledger' +import { MultisigBrokerUtils, MultisigClient } from '../../../multisigBroker' import * as ui from '../../../ui' -import { LedgerDkg } from '../../../utils/ledger' import { renderUnsignedTransactionDetails, watchTransaction } from '../../../utils/transaction' // todo(patnir): this command does not differentiate between a participant and an account. @@ -43,6 +46,32 @@ export class SignMultisigTransactionCommand extends IronfishCommand { default: false, description: 'Perform operation with a ledger device', }), + server: Flags.boolean({ + description: 'connect to a multisig broker server', + }), + connection: Flags.string({ + char: 'c', + description: 'connection string for a multisig server session', + }), + hostname: Flags.string({ + description: 'hostname of the multisig broker server to connect to', + default: 'multisig.ironfish.network', + }), + port: Flags.integer({ + description: 'port to connect to on the multisig broker server', + default: 9035, + }), + sessionId: Flags.string({ + description: 'Unique ID for a multisig server session to join', + }), + passphrase: Flags.string({ + description: 'Passphrase to join the multisig server session', + }), + tls: Flags.boolean({ + description: 'connect to the multisig server over TLS', + dependsOn: ['server'], + allowNo: true, + }), } async start(): Promise { @@ -50,19 +79,10 @@ export class SignMultisigTransactionCommand extends IronfishCommand { const client = await this.connectRpc() await ui.checkWalletUnlocked(client) - let ledger: LedgerDkg | undefined = undefined + let ledger: LedgerMultiSigner | undefined = undefined if (flags.ledger) { - ledger = new LedgerDkg(this.logger) - try { - await ledger.connect() - } catch (e) { - if (e instanceof Error) { - this.error(e.message) - } else { - throw e - } - } + ledger = new LedgerMultiSigner() } let multisigAccountName: string @@ -103,27 +123,57 @@ export class SignMultisigTransactionCommand extends IronfishCommand { ) } - const unsignedTransactionInput = - flags.unsignedTransaction ?? - (await ui.longPrompt('Enter the unsigned transaction', { required: true })) - const unsignedTransaction = new UnsignedTransaction( - Buffer.from(unsignedTransactionInput, 'hex'), - ) - await renderUnsignedTransactionDetails( - client, - unsignedTransaction, - multisigAccountName, - this.logger, + let multisigClient: MultisigClient | null = null + if (flags.server || flags.connection || flags.sessionId || flags.passphrase) { + const { hostname, port, sessionId, passphrase } = + await MultisigBrokerUtils.parseConnectionOptions({ + connection: flags.connection, + hostname: flags.hostname, + port: flags.port, + sessionId: flags.sessionId, + passphrase: flags.passphrase, + logger: this.logger, + }) + + multisigClient = MultisigBrokerUtils.createClient(hostname, port, { + passphrase, + tls: flags.tls ?? true, + logger: this.logger, + }) + multisigClient.start() + + let connectionConfirmed = false + + multisigClient.onConnectedMessage.on(() => { + connectionConfirmed = true + Assert.isNotNull(multisigClient) + multisigClient.onConnectedMessage.clear() + }) + + if (sessionId) { + while (!connectionConfirmed) { + await PromiseUtils.sleep(500) + continue + } + + multisigClient.joinSession(sessionId) + } + } + + const { unsignedTransaction, totalParticipants } = await this.getSigningConfig( + multisigClient, + flags.unsignedTransaction, ) const { commitment, identities } = await ui.retryStep( async () => { return this.performCreateSigningCommitment( client, + multisigClient, multisigAccountName, participant, + totalParticipants, unsignedTransaction, - unsignedTransactionInput, ledger, ) }, @@ -131,28 +181,18 @@ export class SignMultisigTransactionCommand extends IronfishCommand { true, ) - this.log('\n============================================') - this.log('\nCommitment:') - this.log(commitment) - this.log('\n============================================') - - this.log('\nShare your commitment with other participants.') - const signingPackage = await ui.retryStep(() => { return this.performAggregateCommitments( client, + multisigClient, multisigAccountName, commitment, identities, + totalParticipants, unsignedTransaction, ) }, this.logger) - this.log('\n============================================') - this.log('\nSigning Package:') - this.log(signingPackage) - this.log('\n============================================') - const signatureShare = await ui.retryStep( () => this.performCreateSignatureShare( @@ -167,17 +207,11 @@ export class SignMultisigTransactionCommand extends IronfishCommand { true, ) - this.log('\n============================================') - this.log('\nSignature Share:') - this.log(signatureShare) - this.log('\n============================================') - - this.log('\nShare your signature share with other participants.') - await ui.retryStep( () => this.performAggregateSignatures( client, + multisigClient, multisigAccountName, signingPackage, signatureShare, @@ -186,26 +220,111 @@ export class SignMultisigTransactionCommand extends IronfishCommand { this.logger, ) - this.log('Mutlisignature sign process completed!') + this.log('Multisignature sign process completed!') + multisigClient?.stop() + } + + async getSigningConfig( + multisigClient: MultisigClient | null, + unsignedTransactionFlag?: string, + ): Promise<{ unsignedTransaction: UnsignedTransaction; totalParticipants: number }> { + if (multisigClient?.sessionId) { + let totalParticipants = 0 + let unsignedTransactionHex = '' + let waiting = true + multisigClient.onSigningStatus.on((message) => { + totalParticipants = message.numSigners + unsignedTransactionHex = message.unsignedTransaction + waiting = false + }) + + ux.action.start('Waiting for signer config from server') + while (waiting) { + multisigClient.getSigningStatus() + await PromiseUtils.sleep(3000) + } + multisigClient.onSigningStatus.clear() + ux.action.stop() + + const unsignedTransaction = new UnsignedTransaction( + Buffer.from(unsignedTransactionHex, 'hex'), + ) + + return { totalParticipants, unsignedTransaction } + } + + const unsignedTransactionInput = + unsignedTransactionFlag ?? + (await ui.longPrompt('Enter the unsigned transaction', { required: true })) + const unsignedTransaction = new UnsignedTransaction( + Buffer.from(unsignedTransactionInput, 'hex'), + ) + + const totalParticipants = await ui.inputNumberPrompt( + this.logger, + 'Enter the number of participants in signing this transaction', + { required: true, integer: true }, + ) + + if (totalParticipants < 2) { + this.error('Minimum number of participants must be at least 2') + } + + if (multisigClient) { + multisigClient.startSigningSession(totalParticipants, unsignedTransactionInput) + this.log('\nStarted new signing session:') + this.log(`${multisigClient.sessionId}`) + this.log('\nSigning session connection string:') + this.log(`${multisigClient.connectionString}`) + } + + return { unsignedTransaction, totalParticipants } } private async performAggregateSignatures( client: RpcClient, + multisigClient: MultisigClient | null, accountName: string, signingPackage: string, signatureShare: string, totalParticipants: number, ): Promise { - this.log( - `Enter ${ - totalParticipants - 1 - } signature shares of the participants (excluding your own)`, - ) + let signatureShares: string[] = [signatureShare] + if (!multisigClient) { + this.log('\n============================================') + this.log('\nSignature Share:') + this.log(signatureShare) + this.log('\n============================================') + + this.log('\nShare your signature share with other participants.') + + this.log( + `Enter ${ + totalParticipants - 1 + } signature shares of the participants (excluding your own)`, + ) - const signatureShares = await ui.collectStrings('Signature Share', totalParticipants - 1, { - additionalStrings: [signatureShare], - errorOnDuplicate: true, - }) + signatureShares = await ui.collectStrings('Signature Share', totalParticipants - 1, { + additionalStrings: [signatureShare], + errorOnDuplicate: true, + }) + } else { + multisigClient.submitSignatureShare(signatureShare) + + multisigClient.onSigningStatus.on((message) => { + signatureShares = message.signatureShares + }) + + ux.action.start('Waiting for Signature Shares from server') + while (signatureShares.length < totalParticipants) { + multisigClient.getSigningStatus() + ux.action.status = `${signatureShares.length}/${totalParticipants}` + await PromiseUtils.sleep(3000) + } + + multisigClient.onSigningStatus.clear() + ux.action.stop() + } const broadcast = await ui.confirmPrompt('Do you want to broadcast the transaction?') const watch = await ui.confirmPrompt('Do you want to watch the transaction?') @@ -256,18 +375,28 @@ export class SignMultisigTransactionCommand extends IronfishCommand { identity: MultisigParticipant, signingPackageString: string, unsignedTransaction: UnsignedTransaction, - ledger: LedgerDkg | undefined, + ledger: LedgerMultiSigner | undefined, ): Promise { + this.debug('\n============================================') + this.debug('\nSigning Package:') + this.debug(signingPackageString) + this.debug('\n============================================') + let signatureShare: string const signingPackage = new multisig.SigningPackage(Buffer.from(signingPackageString, 'hex')) if (ledger) { - const frostSignatureShare = await ledger.dkgSign( - unsignedTransaction.publicKeyRandomness(), - signingPackage.frostSigningPackage().toString('hex'), - unsignedTransaction.hash().toString('hex'), - ) + const frostSignatureShare = await ui.ledger({ + ledger, + message: 'Sign Transaction', + approval: true, + action: () => + ledger.dkgSign( + unsignedTransaction, + signingPackage.frostSigningPackage().toString('hex'), + ), + }) signatureShare = multisig.SignatureShare.fromFrost( frostSignatureShare, @@ -289,19 +418,47 @@ export class SignMultisigTransactionCommand extends IronfishCommand { private async performAggregateCommitments( client: RpcClient, + multisigClient: MultisigClient | null, accountName: string, commitment: string, identities: string[], + totalParticipants: number, unsignedTransaction: UnsignedTransaction, ) { - this.log( - `Enter ${identities.length - 1} commitments of the participants (excluding your own)`, - ) + let commitments: string[] = [commitment] + if (!multisigClient) { + this.log('\n============================================') + this.log('\nCommitment:') + this.log(commitment) + this.log('\n============================================') - const commitments = await ui.collectStrings('Commitment', identities.length - 1, { - additionalStrings: [commitment], - errorOnDuplicate: true, - }) + this.log('\nShare your commitment with other participants.') + + this.log( + `Enter ${identities.length - 1} commitments of the participants (excluding your own)`, + ) + + commitments = await ui.collectStrings('Commitment', identities.length - 1, { + additionalStrings: [commitment], + errorOnDuplicate: true, + }) + } else { + multisigClient.submitSigningCommitment(commitment) + + multisigClient.onSigningStatus.on((message) => { + commitments = message.signingCommitments + }) + + ux.action.start('Waiting for Signing Commitments from server') + while (commitments.length < totalParticipants) { + multisigClient.getSigningStatus() + ux.action.status = `${commitments.length}/${totalParticipants}` + await PromiseUtils.sleep(3000) + } + + multisigClient.onSigningStatus.clear() + ux.action.stop() + } const signingPackageResponse = await client.wallet.multisig.createSigningPackage({ account: accountName, @@ -314,50 +471,66 @@ export class SignMultisigTransactionCommand extends IronfishCommand { private async performCreateSigningCommitment( client: RpcClient, + multisigClient: MultisigClient | null, accountName: string, participant: MultisigParticipant, + totalParticipants: number, unsignedTransaction: UnsignedTransaction, - unsignedTransactionInput: string, - ledger: LedgerDkg | undefined, + ledger: LedgerMultiSigner | undefined, ) { - this.log(`Identity for ${participant.name}: \n${participant.identity} \n`) - this.log('Share your participant identity with other signers.') + let identities: string[] = [participant.identity] + if (!multisigClient) { + this.log(`Identity for ${participant.name}: \n${participant.identity} \n`) + this.log('Share your participant identity with other signers.') - const input = await ui.inputPrompt( - 'Enter the number of participants in signing this transaction', - true, - ) - const totalParticipants = parseInt(input) + this.log( + `Enter ${totalParticipants - 1} identities of the participants (excluding your own)`, + ) - if (totalParticipants < 2) { - this.error('Minimum number of participants must be at least 2') + identities = await ui.collectStrings('Participant Identity', totalParticipants - 1, { + additionalStrings: [participant.identity], + errorOnDuplicate: true, + }) + } else { + multisigClient.submitSigningIdentity(participant.identity) + + multisigClient.onSigningStatus.on((message) => { + identities = message.identities + }) + + ux.action.start('Waiting for Identities from server') + while (identities.length < totalParticipants) { + multisigClient.getSigningStatus() + ux.action.status = `${identities.length}/${totalParticipants}` + await PromiseUtils.sleep(3000) + } + + multisigClient.onSigningStatus.clear() + ux.action.stop() } - this.log( - `Enter ${totalParticipants - 1} identities of the participants (excluding your own)`, - ) + const unsignedTransactionHex = unsignedTransaction.serialize().toString('hex') - const identities = await ui.collectStrings('Participant Identity', totalParticipants - 1, { - additionalStrings: [participant.identity], - errorOnDuplicate: true, - }) + await renderUnsignedTransactionDetails( + client, + unsignedTransaction, + accountName, + this.logger, + ) let commitment - if (ledger) { - await ledger.reviewTransaction(unsignedTransaction.serialize().toString('hex')) - commitment = await this.createSigningCommitmentWithLedger( ledger, participant, - unsignedTransaction.hash(), + unsignedTransaction, identities, ) } else { commitment = ( await client.wallet.multisig.createSigningCommitment({ account: accountName, - unsignedTransaction: unsignedTransactionInput, + unsignedTransaction: unsignedTransactionHex, signers: identities.map((identity) => ({ identity })), }) ).content.commitment @@ -370,17 +543,22 @@ export class SignMultisigTransactionCommand extends IronfishCommand { } async createSigningCommitmentWithLedger( - ledger: LedgerDkg, + ledger: LedgerMultiSigner, participant: MultisigParticipant, - transactionHash: Buffer, + unsignedTransaction: UnsignedTransaction, signers: string[], ): Promise { - const rawCommitments = await ledger.dkgGetCommitments(transactionHash.toString('hex')) + const rawCommitments = await ui.ledger({ + ledger, + message: 'Get Commitments', + approval: true, + action: () => ledger.dkgGetCommitments(unsignedTransaction), + }) const sigingCommitment = multisig.SigningCommitment.fromRaw( participant.identity, rawCommitments, - transactionHash, + unsignedTransaction.hash(), signers, ) diff --git a/ironfish-cli/src/commands/wallet/multisig/signature/create.ts b/ironfish-cli/src/commands/wallet/multisig/signature/create.ts index db52317a67..7bc41efa17 100644 --- a/ironfish-cli/src/commands/wallet/multisig/signature/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/signature/create.ts @@ -6,8 +6,8 @@ import { RpcClient, UnsignedTransaction } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' +import { LedgerMultiSigner } from '../../../../ledger' import * as ui from '../../../../ui' -import { LedgerDkg } from '../../../../utils/ledger' import { MultisigTransactionJson } from '../../../../utils/multisig' import { renderUnsignedTransactionDetails } from '../../../../utils/transaction' @@ -120,7 +120,7 @@ export class CreateSignatureShareCommand extends IronfishCommand { unsignedTransaction: UnsignedTransaction, frostSigningPackage: string, ): Promise { - const ledger = new LedgerDkg(this.logger) + const ledger = new LedgerMultiSigner() try { await ledger.connect() } catch (e) { @@ -134,15 +134,7 @@ export class CreateSignatureShareCommand extends IronfishCommand { const identityResponse = await client.wallet.multisig.getIdentity({ name: participantName }) const identity = identityResponse.content.identity - const transactionHash = await ledger.reviewTransaction( - unsignedTransaction.serialize().toString('hex'), - ) - - const frostSignatureShare = await ledger.dkgSign( - unsignedTransaction.publicKeyRandomness(), - frostSigningPackage, - transactionHash.toString('hex'), - ) + const frostSignatureShare = await ledger.dkgSign(unsignedTransaction, frostSigningPackage) const signatureShare = multisig.SignatureShare.fromFrost( frostSignatureShare, diff --git a/ironfish-cli/src/commands/wallet/send.ts b/ironfish-cli/src/commands/wallet/send.ts index 4c7efe5231..50d3875b89 100644 --- a/ironfish-cli/src/commands/wallet/send.ts +++ b/ironfish-cli/src/commands/wallet/send.ts @@ -20,7 +20,6 @@ import { promptCurrency } from '../../utils/currency' import { promptExpiration } from '../../utils/expiration' import { getExplorer } from '../../utils/explorer' import { selectFee } from '../../utils/fees' -import { sendTransactionWithLedger } from '../../utils/ledger' import { getSpendPostTimeInMs, updateSpendPostTimeInMs } from '../../utils/spendPostTime' import { displayTransactionSummary, @@ -258,7 +257,7 @@ export class Send extends IronfishCommand { } if (flags.ledger) { - await sendTransactionWithLedger( + await ui.sendTransactionWithLedger( client, raw, from, diff --git a/ironfish-cli/src/commands/wallet/transactions/info.ts b/ironfish-cli/src/commands/wallet/transactions/info.ts index ee816378ef..2a49f48418 100644 --- a/ironfish-cli/src/commands/wallet/transactions/info.ts +++ b/ironfish-cli/src/commands/wallet/transactions/info.ts @@ -14,9 +14,10 @@ import { IronfishCommand } from '../../../command' import { RemoteFlags } from '../../../flags' import * as ui from '../../../ui' import { + ChainportNetwork, displayChainportTransactionSummary, extractChainportDataFromTransaction, - fetchChainportNetworkMap, + fetchChainportNetworks, getAssetsByIDs, useAccount, } from '../../../utils' @@ -99,15 +100,27 @@ export class TransactionInfoCommand extends IronfishCommand { if (chainportTxnDetails) { this.log(`\n---Chainport Bridge Transaction Summary---\n`) - ux.action.start('Fetching network details') - const chainportNetworks = await fetchChainportNetworkMap(networkId) - ux.action.stop() + let network: ChainportNetwork | undefined + try { + ux.action.start('Fetching network details') + const chainportNetworks = await fetchChainportNetworks(networkId) + network = chainportNetworks.find( + (n) => n.chainport_network_id === chainportTxnDetails.chainportNetworkId, + ) + ux.action.stop() + } catch (e: unknown) { + ux.action.stop('error') + + if (e instanceof Error) { + this.logger.debug(e.message) + } + } await displayChainportTransactionSummary( networkId, transaction, chainportTxnDetails, - chainportNetworks[chainportTxnDetails.chainportNetworkId], + network, this.logger, ) } @@ -122,6 +135,7 @@ export class TransactionInfoCommand extends IronfishCommand { for (const note of transaction.notes) { const asset = await client.wallet.getAsset({ + account: account, id: note.assetId, }) diff --git a/ironfish-cli/src/commands/wallet/transactions/sign.ts b/ironfish-cli/src/commands/wallet/transactions/sign.ts index 87b57ca0b8..ca5c7ebc40 100644 --- a/ironfish-cli/src/commands/wallet/transactions/sign.ts +++ b/ironfish-cli/src/commands/wallet/transactions/sign.ts @@ -6,8 +6,8 @@ import { CurrencyUtils, RpcClient, Transaction } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../command' import { RemoteFlags } from '../../../flags' +import { LedgerSingleSigner } from '../../../ledger' import * as ui from '../../../ui' -import { Ledger } from '../../../utils/ledger' import { renderTransactionDetails, watchTransaction } from '../../../utils/transaction' export class TransactionsSignCommand extends IronfishCommand { @@ -109,7 +109,7 @@ export class TransactionsSignCommand extends IronfishCommand { } private async signWithLedger(client: RpcClient, unsignedTransaction: string) { - const ledger = new Ledger(this.logger) + const ledger = new LedgerSingleSigner() try { await ledger.connect() } catch (e) { diff --git a/ironfish-cli/src/ledger/README.md b/ironfish-cli/src/ledger/README.md new file mode 100644 index 0000000000..38ddfc627b --- /dev/null +++ b/ironfish-cli/src/ledger/README.md @@ -0,0 +1,36 @@ +# Ledger + +- Ironfish App: 0.1.0 +- Ironfish DKG App: 0.5.4 + +#### IronfishApp.appInfo() (OS CLA) + C APP + If Dashboard Open: + If Locked: throw 0x5515 DeviceLocked + If Unlocked: throw 0x6e01 (APP NOT OPEN) + If App Open: + If Locked: throw 0x5515 Device Locked + If Unlocked: returns successfully + RUST APP (OS RPC) + If Dashboard Open: + If Locked: throw 0x5515 DeviceLocked + If Unlocked: throw 0x6e01 (APP NOT OPEN) + If App Open: + If Locked: returns successfully + If Unlocked: returns successfully + +##### IronfishApp.getVersion (APP CLA) + C APP + If Dashboard Open: + If Locked: throw 0x5515 DeviceLocked + If Unlocked: throw 0x6e01 (APP NOT OPEN) + If App Open: + If Locked: throw 0x5515 DeviceLocked + If Unlocked: returns successfully + RUST APP + If Dashboard Open: + If Locked: throw 0x5515 DeviceLocked + If Unlocked: throw 0x6e01 (APP NOT OPEN) + If App Open: + If Locked: throw 0x5515 DeviceLocked + If Unlocked: returns successfully() diff --git a/ironfish-cli/src/ledger/index.ts b/ironfish-cli/src/ledger/index.ts new file mode 100644 index 0000000000..3fe51ee586 --- /dev/null +++ b/ironfish-cli/src/ledger/index.ts @@ -0,0 +1,6 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +export * from './ledger' +export * from './ledgerMultiSigner' +export * from './ledgerSingleSigner' diff --git a/ironfish-cli/src/ledger/ledger.ts b/ironfish-cli/src/ledger/ledger.ts new file mode 100644 index 0000000000..ef52631a62 --- /dev/null +++ b/ironfish-cli/src/ledger/ledger.ts @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Assert } from '@ironfish/sdk' +import { + DisconnectedDevice, + DisconnectedDeviceDuringOperation, + StatusCodes, + TransportStatusError, +} from '@ledgerhq/errors' +import TransportNodeHid from '@ledgerhq/hw-transport-node-hid' +import IronfishApp, { + KeyResponse, + ResponseAddress, + ResponseProofGenKey, + ResponseViewKey, +} from '@zondax/ledger-ironfish' +import { ResponseError, Transport } from '@zondax/ledger-js' + +export const IronfishLedgerStatusCodes = { + ...StatusCodes, + COMMAND_NOT_ALLOWED: 0x6986, + APP_NOT_OPEN: 0x6e01, + UNKNOWN_TRANSPORT_ERROR: 0xffff, + INVALID_TX_HASH: 0xb025, + PANIC: 0xe000, +} + +export class Ledger { + app: IronfishApp | undefined + PATH = "m/44'/1338'/0" + isMultisig: boolean + isConnecting: boolean = false + connectTimeout = 2000 + + constructor(isMultisig: boolean) { + this.app = undefined + this.isMultisig = isMultisig + } + + tryInstruction = async (instruction: (app: IronfishApp) => Promise) => { + try { + await this.connect() + + Assert.isNotUndefined(this.app, 'Unable to establish connection with Ledger device') + + return await instruction(this.app) + } catch (e: unknown) { + let error = e + + if (LedgerPortIsBusyError.IsError(e)) { + throw new LedgerPortIsBusyError() + } else if (LedgerConnectError.IsError(e)) { + throw new LedgerConnectError() + } else if (e instanceof DisconnectedDeviceDuringOperation) { + throw new LedgerConnectError() + } else if (e instanceof DisconnectedDevice) { + throw new LedgerConnectError() + } + + if (error instanceof TransportStatusError) { + error = new ResponseError(error.statusCode, error.statusText) + } + + if (error instanceof ResponseError) { + if (error.returnCode === IronfishLedgerStatusCodes.LOCKED_DEVICE) { + throw new LedgerDeviceLockedError() + } else if (error.returnCode === IronfishLedgerStatusCodes.CLA_NOT_SUPPORTED) { + throw new LedgerClaNotSupportedError() + } else if (error.returnCode === IronfishLedgerStatusCodes.PANIC) { + throw new LedgerPanicError() + } else if (error.returnCode === IronfishLedgerStatusCodes.GP_AUTH_FAILED) { + throw new LedgerGPAuthFailed() + } else if ( + [ + IronfishLedgerStatusCodes.COMMAND_NOT_ALLOWED, + IronfishLedgerStatusCodes.CONDITIONS_OF_USE_NOT_SATISFIED, + ].includes(error.returnCode) + ) { + throw new LedgerActionRejected() + } else if ( + [ + IronfishLedgerStatusCodes.TECHNICAL_PROBLEM, + IronfishLedgerStatusCodes.UNKNOWN_TRANSPORT_ERROR, + IronfishLedgerStatusCodes.APP_NOT_OPEN, + ].includes(error.returnCode) + ) { + throw new LedgerAppNotOpen( + `Unable to connect to Ironfish app on Ledger. Please check that the device is unlocked and the app is open.`, + ) + } else if (error.returnCode === IronfishLedgerStatusCodes.INVALID_TX_HASH) { + throw new LedgerInvalidTxHash() + } else if (e instanceof TransportStatusError) { + throw new LedgerConnectError() + } + + throw new LedgerError(error.message) + } + + throw error + } + } + + connect = async () => { + if (this.app || this.isConnecting) { + return + } + + this.isConnecting = true + + let transport: Transport | undefined = undefined + + try { + transport = await TransportNodeHid.create(this.connectTimeout, this.connectTimeout) + + transport.on('disconnect', async () => { + await transport?.close() + this.app = undefined + }) + + const app = new IronfishApp(transport, this.isMultisig) + + this.app = app + return { app, PATH: this.PATH } + } catch (e) { + await transport?.close() + throw e + } finally { + this.isConnecting = false + } + } + + close = () => { + void this.app?.transport.close() + } +} + +export function isResponseAddress(response: KeyResponse): response is ResponseAddress { + return 'publicAddress' in response +} + +export function isResponseViewKey(response: KeyResponse): response is ResponseViewKey { + return 'viewKey' in response +} + +export function isResponseProofGenKey(response: KeyResponse): response is ResponseProofGenKey { + return 'ak' in response && 'nsk' in response +} + +export class LedgerError extends Error { + name = this.constructor.name +} + +export class LedgerConnectError extends LedgerError { + static IsError(error: unknown): error is Error { + const ids = [ + 'ListenTimeout', + 'InvalidChannel', + 'InvalidTag', + 'InvalidSequence', + 'NoDeviceFound', + ] + + return ( + error instanceof Error && + 'id' in error && + typeof error['id'] === 'string' && + ids.includes(error.id) + ) + } +} + +export class LedgerPortIsBusyError extends LedgerError { + static IsError(error: unknown): error is Error { + return error instanceof Error && error.message.includes('cannot open device with path') + } +} + +export class LedgerDeviceLockedError extends LedgerError {} +export class LedgerGPAuthFailed extends LedgerError {} +export class LedgerClaNotSupportedError extends LedgerError {} +export class LedgerAppNotOpen extends LedgerError {} +export class LedgerActionRejected extends LedgerError {} +export class LedgerInvalidTxHash extends LedgerError {} +export class LedgerPanicError extends LedgerError {} diff --git a/ironfish-cli/src/ledger/ledgerMultiSigner.ts b/ironfish-cli/src/ledger/ledgerMultiSigner.ts new file mode 100644 index 0000000000..2057ecf5a1 --- /dev/null +++ b/ironfish-cli/src/ledger/ledgerMultiSigner.ts @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { UnsignedTransaction } from '@ironfish/sdk' +import { + IronfishKeys, + KeyResponse, + ResponseDkgRound1, + ResponseDkgRound2, +} from '@zondax/ledger-ironfish' +import { + isResponseAddress, + isResponseProofGenKey, + isResponseViewKey, + Ledger, + LedgerInvalidTxHash, +} from './ledger' + +export class LedgerMultiSigner extends Ledger { + constructor() { + super(true) + } + + dkgGetIdentity = async (index: number, approval = false): Promise => { + const response = await this.tryInstruction((app) => app.dkgGetIdentity(index, approval)) + + return response.identity + } + + dkgRound1 = async ( + index: number, + identities: string[], + minSigners: number, + ): Promise => { + return this.tryInstruction((app) => app.dkgRound1(index, identities, minSigners)) + } + + dkgRound2 = async ( + index: number, + round1PublicPackages: string[], + round1SecretPackage: string, + ): Promise => { + return this.tryInstruction((app) => + app.dkgRound2(index, round1PublicPackages, round1SecretPackage), + ) + } + + dkgRound3 = async ( + index: number, + participants: string[], + round1PublicPackages: string[], + round2PublicPackages: string[], + round2SecretPackage: string, + gskBytes: string[], + ): Promise => { + return this.tryInstruction((app) => + app.dkgRound3Min( + index, + participants, + round1PublicPackages, + round2PublicPackages, + round2SecretPackage, + gskBytes, + ), + ) + } + + dkgRetrieveKeys = async (): Promise<{ + publicAddress: string + viewKey: string + incomingViewKey: string + outgoingViewKey: string + proofAuthorizingKey: string + }> => { + const responseAddress: KeyResponse = await this.tryInstruction((app) => + app.dkgRetrieveKeys(IronfishKeys.PublicAddress), + ) + + if (!isResponseAddress(responseAddress)) { + throw new Error(`No public address returned.`) + } + + const responseViewKey = await this.tryInstruction((app) => + app.dkgRetrieveKeys(IronfishKeys.ViewKey), + ) + + if (!isResponseViewKey(responseViewKey)) { + throw new Error(`No view key returned.`) + } + + const responsePGK: KeyResponse = await this.tryInstruction((app) => + app.dkgRetrieveKeys(IronfishKeys.ProofGenerationKey), + ) + + if (!isResponseProofGenKey(responsePGK)) { + throw new Error(`No proof authorizing key returned.`) + } + + return { + publicAddress: responseAddress.publicAddress.toString('hex'), + viewKey: responseViewKey.viewKey.toString('hex'), + incomingViewKey: responseViewKey.ivk.toString('hex'), + outgoingViewKey: responseViewKey.ovk.toString('hex'), + proofAuthorizingKey: responsePGK.nsk.toString('hex'), + } + } + + dkgGetPublicPackage = async (): Promise => { + const response = await this.tryInstruction((app) => app.dkgGetPublicPackage()) + + return response.publicPackage + } + + reviewTransaction = async (transaction: string): Promise => { + const { hash } = await this.tryInstruction((app) => app.reviewTransaction(transaction)) + + return hash + } + + dkgGetCommitments = async (transaction: UnsignedTransaction): Promise => { + try { + const { commitments } = await this.tryInstruction(async (app) => { + return app.dkgGetCommitments(transaction.hash().toString('hex')) + }) + return commitments + } catch (e) { + if (e instanceof LedgerInvalidTxHash) { + await this.reviewTransaction(transaction.serialize().toString('hex')) + return this.dkgGetCommitments(transaction) + } + + throw e + } + } + + dkgSign = async ( + transaction: UnsignedTransaction, + frostSigningPackage: string, + ): Promise => { + try { + const { signature } = await this.tryInstruction(async (app) => { + return app.dkgSign( + transaction.publicKeyRandomness(), + frostSigningPackage, + transaction.hash().toString('hex'), + ) + }) + return signature + } catch (e) { + if (e instanceof LedgerInvalidTxHash) { + await this.reviewTransaction(transaction.serialize().toString('hex')) + return this.dkgSign(transaction, frostSigningPackage) + } + + throw e + } + } + + dkgBackupKeys = async (): Promise => { + const { encryptedKeys } = await this.tryInstruction((app) => app.dkgBackupKeys()) + + return encryptedKeys + } + + dkgRestoreKeys = async (encryptedKeys: string): Promise => { + await this.tryInstruction((app) => app.dkgRestoreKeys(encryptedKeys)) + } +} diff --git a/ironfish-cli/src/ledger/ledgerSingleSigner.ts b/ironfish-cli/src/ledger/ledgerSingleSigner.ts new file mode 100644 index 0000000000..8dc313a3a7 --- /dev/null +++ b/ironfish-cli/src/ledger/ledgerSingleSigner.ts @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { ACCOUNT_SCHEMA_VERSION, AccountImport } from '@ironfish/sdk' +import { IronfishKeys, KeyResponse, ResponseSign } from '@zondax/ledger-ironfish' +import { isResponseAddress, isResponseProofGenKey, isResponseViewKey, Ledger } from './ledger' + +export class LedgerSingleSigner extends Ledger { + constructor() { + super(false) + } + + getPublicAddress = async () => { + const response: KeyResponse = await this.tryInstruction((app) => + app.retrieveKeys(this.PATH, IronfishKeys.PublicAddress, false), + ) + + if (!isResponseAddress(response)) { + throw new Error(`No public address returned.`) + } + + return response.publicAddress.toString('hex') + } + + importAccount = async () => { + const publicAddress = await this.getPublicAddress() + + const responseViewKey: KeyResponse = await this.tryInstruction((app) => + app.retrieveKeys(this.PATH, IronfishKeys.ViewKey, true), + ) + + if (!isResponseViewKey(responseViewKey)) { + throw new Error(`No view key returned.`) + } + + const responsePGK: KeyResponse = await this.tryInstruction((app) => + app.retrieveKeys(this.PATH, IronfishKeys.ProofGenerationKey, false), + ) + + if (!isResponseProofGenKey(responsePGK)) { + throw new Error(`No proof authorizing key returned.`) + } + + const accountImport: AccountImport = { + version: ACCOUNT_SCHEMA_VERSION, + name: 'ledger', + publicAddress, + viewKey: responseViewKey.viewKey.toString('hex'), + incomingViewKey: responseViewKey.ivk.toString('hex'), + outgoingViewKey: responseViewKey.ovk.toString('hex'), + proofAuthorizingKey: responsePGK.nsk.toString('hex'), + spendingKey: null, + createdAt: null, + } + + return accountImport + } + + sign = async (message: string): Promise => { + const buffer = Buffer.from(message, 'hex') + + // max size of a transaction is 16kb + if (buffer.length > 16 * 1024) { + throw new Error('Transaction size is too large, must be less than 16kb.') + } + + const response: ResponseSign = await this.tryInstruction((app) => + app.sign(this.PATH, buffer), + ) + + return response.signature + } +} diff --git a/ironfish-cli/src/multisigBroker/README.md b/ironfish-cli/src/multisigBroker/README.md new file mode 100644 index 0000000000..91f2d80444 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/README.md @@ -0,0 +1,3 @@ +# Multisig Broker + +This is a server / client that allows multisig participants to broker creating DKG accounts and signing transactions through a trustless server. diff --git a/ironfish-cli/src/multisigBroker/adapters/adapter.ts b/ironfish-cli/src/multisigBroker/adapters/adapter.ts new file mode 100644 index 0000000000..267d6ea528 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/adapters/adapter.ts @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { MultisigServer } from '../server' + +/** + * An adapter represents a network transport that accepts connections from + * clients and routes them into the server. + */ +export interface IMultisigBrokerAdapter { + /** + * Called when the adapter is added to a MultisigServer. + */ + attach(server: MultisigServer): void + + /** + * Called when the adapter should start serving requests to the server + * This is when an adapter would normally listen on a port for data and + * create {@link Request } for the routing layer. + * + * For example, when an + * HTTP server starts listening, or an IPC layer opens an IPC socket. + */ + start(): Promise + + /** Called when the adapter should stop serving requests to the server. */ + stop(): Promise +} diff --git a/ironfish-cli/src/multisigBroker/adapters/index.ts b/ironfish-cli/src/multisigBroker/adapters/index.ts new file mode 100644 index 0000000000..436cbdef3f --- /dev/null +++ b/ironfish-cli/src/multisigBroker/adapters/index.ts @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export * from './adapter' +export * from './tcpAdapter' +export * from './tlsAdapter' diff --git a/ironfish-cli/src/multisigBroker/adapters/tcpAdapter.ts b/ironfish-cli/src/multisigBroker/adapters/tcpAdapter.ts new file mode 100644 index 0000000000..e895ec7d50 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/adapters/tcpAdapter.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logger } from '@ironfish/sdk' +import net from 'net' +import { MultisigServer } from '../server' +import { IMultisigBrokerAdapter } from './adapter' + +export class MultisigTcpAdapter implements IMultisigBrokerAdapter { + server: net.Server | null = null + multisigServer: MultisigServer | null = null + readonly logger: Logger + + readonly host: string + readonly port: number + + started = false + + constructor(options: { logger: Logger; host: string; port: number }) { + this.logger = options.logger + this.host = options.host + this.port = options.port + } + + protected createServer(): net.Server { + this.logger.info(`Hosting Multisig Server via TCP on ${this.host}:${this.port}`) + + return net.createServer((socket) => this.multisigServer?.onConnection(socket)) + } + + start(): Promise { + if (this.started) { + return Promise.resolve() + } + + this.started = true + + return new Promise((resolve, reject) => { + try { + this.server = this.createServer() + this.server.listen(this.port, this.host, () => { + resolve() + }) + } catch (e) { + reject(e) + } + }) + } + + stop(): Promise { + if (!this.started) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + this.server?.close((e) => { + return e ? reject(e) : resolve() + }) + }) + } + + attach(server: MultisigServer): void { + this.multisigServer = server + } +} diff --git a/ironfish-cli/src/multisigBroker/adapters/tlsAdapter.ts b/ironfish-cli/src/multisigBroker/adapters/tlsAdapter.ts new file mode 100644 index 0000000000..5669b73d13 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/adapters/tlsAdapter.ts @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logger } from '@ironfish/sdk' +import net from 'net' +import tls from 'tls' +import { MultisigTcpAdapter } from './tcpAdapter' + +export class MultisigTlsAdapter extends MultisigTcpAdapter { + readonly tlsOptions: tls.TlsOptions + + constructor(options: { + logger: Logger + host: string + port: number + tlsOptions: tls.TlsOptions + }) { + super(options) + + this.tlsOptions = options.tlsOptions + } + + protected createServer(): net.Server { + this.logger.info(`Hosting Multisig Server via TLS on ${this.host}:${this.port}`) + + return tls.createServer(this.tlsOptions, (socket) => + this.multisigServer?.onConnection(socket), + ) + } +} diff --git a/ironfish-cli/src/multisigBroker/clients/client.ts b/ironfish-cli/src/multisigBroker/clients/client.ts new file mode 100644 index 0000000000..413be1894c --- /dev/null +++ b/ironfish-cli/src/multisigBroker/clients/client.ts @@ -0,0 +1,435 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { xchacha20poly1305 } from '@ironfish/rust-nodejs' +import { + ErrorUtils, + Event, + Logger, + MessageBuffer, + SetTimeoutToken, + YupUtils, +} from '@ironfish/sdk' +import { v4 as uuid } from 'uuid' +import { ServerMessageMalformedError } from '../errors' +import { + ConnectedMessage, + ConnectedMessageSchema, + DkgGetStatusMessage, + DkgStartSessionMessage, + DkgStatusMessage, + DkgStatusSchema, + IdentityMessage, + JoinedSessionMessage, + JoinedSessionSchema, + JoinSessionMessage, + MultisigBrokerAckSchema, + MultisigBrokerMessage, + MultisigBrokerMessageSchema, + MultisigBrokerMessageWithError, + MultisigBrokerMessageWithErrorSchema, + Round1PublicPackageMessage, + Round2PublicPackageMessage, + SignatureShareMessage, + SigningCommitmentMessage, + SigningGetStatusMessage, + SigningStartSessionMessage, + SigningStatusMessage, + SigningStatusSchema, +} from '../messages' + +const RETRY_INTERVAL = 5000 +export abstract class MultisigClient { + readonly logger: Logger + readonly version: number + readonly hostname: string + readonly port: number + + private started: boolean + private isClosing = false + private connected: boolean + private connectWarned: boolean + private connectTimeout: SetTimeoutToken | null + private nextMessageId: number + private readonly messageBuffer = new MessageBuffer('\n') + + private disconnectUntil: number | null = null + + readonly onConnected = new Event<[]>() + readonly onDkgStatus = new Event<[DkgStatusMessage]>() + readonly onSigningStatus = new Event<[SigningStatusMessage]>() + readonly onConnectedMessage = new Event<[ConnectedMessage]>() + readonly onJoinedSession = new Event<[JoinSessionMessage]>() + readonly onMultisigBrokerError = new Event<[MultisigBrokerMessageWithError]>() + + sessionId: string | null = null + passphrase: string + + retries: Map = new Map() + + constructor(options: { hostname: string; port: number; passphrase: string; logger: Logger }) { + this.logger = options.logger + this.version = 3 + this.hostname = options.hostname + this.port = options.port + + this.started = false + this.nextMessageId = 0 + this.connected = false + this.connectWarned = false + this.connectTimeout = null + + this.passphrase = options.passphrase + } + + get connectionString(): string { + return `tcp://${this.sessionId}:${this.passphrase}@${this.hostname}:${this.port}` + } + + get key(): xchacha20poly1305.XChaCha20Poly1305Key { + if (!this.sessionId) { + throw new Error('Client must join a session before encrypting/decrypting messages') + } + + const sessionIdBytes = Buffer.from(this.sessionId) + const salt = sessionIdBytes.subarray(0, 32) + const nonce = sessionIdBytes.subarray(sessionIdBytes.length - 24) + + return xchacha20poly1305.XChaCha20Poly1305Key.fromParts(this.passphrase, salt, nonce) + } + + protected abstract connect(): Promise + protected abstract writeData(data: string): void + protected abstract close(): Promise + + start(): void { + if (this.started) { + return + } + + this.started = true + this.logger.debug('Connecting to server...') + void this.startConnecting() + } + + private async startConnecting(): Promise { + if (this.isClosing) { + return + } + + if (this.disconnectUntil && this.disconnectUntil > Date.now()) { + this.connectTimeout = setTimeout(() => void this.startConnecting(), 60 * 1000) + return + } + + const connected = await this.connect() + .then(() => true) + .catch(() => false) + + if (!this.started) { + return + } + + if (!connected) { + if (!this.connectWarned) { + this.logger.warn(`Failed to connect to server, retrying...`) + this.connectWarned = true + } + + this.connectTimeout = setTimeout(() => void this.startConnecting(), 5000) + return + } + + this.connectWarned = false + this.onConnect() + this.onConnected.emit() + } + + stop(): void { + this.isClosing = true + void this.close() + + if (this.connectTimeout) { + clearTimeout(this.connectTimeout) + } + + for (const retryInterval of this.retries.values()) { + clearInterval(retryInterval) + } + } + + isConnected(): boolean { + return this.connected + } + + joinSession(sessionId: string): void { + this.sessionId = sessionId + this.send('join_session', {}) + } + + startDkgSession(maxSigners: number, minSigners: number): void { + this.sessionId = uuid() + const challenge = this.key.encrypt(Buffer.from('DKG')).toString('hex') + this.send('dkg.start_session', { maxSigners, minSigners, challenge }) + } + + startSigningSession(numSigners: number, unsignedTransaction: string): void { + this.sessionId = uuid() + const challenge = this.key.encrypt(Buffer.from('SIGNING')).toString('hex') + this.send('sign.start_session', { numSigners, unsignedTransaction, challenge }) + } + + submitDkgIdentity(identity: string): void { + this.send('dkg.identity', { identity }) + } + + submitSigningIdentity(identity: string): void { + this.send('sign.identity', { identity }) + } + + submitRound1PublicPackage(round1PublicPackage: string): void { + this.send('dkg.round1', { package: round1PublicPackage }) + } + + submitRound2PublicPackage(round2PublicPackage: string): void { + this.send('dkg.round2', { package: round2PublicPackage }) + } + + getDkgStatus(): void { + this.send('dkg.get_status', {}) + } + + submitSigningCommitment(signingCommitment: string): void { + this.send('sign.commitment', { signingCommitment }) + } + + submitSignatureShare(signatureShare: string): void { + this.send('sign.share', { signatureShare }) + } + + getSigningStatus(): void { + this.send('sign.get_status', {}) + } + + private send(method: 'join_session', body: JoinSessionMessage): void + private send(method: 'dkg.start_session', body: DkgStartSessionMessage): void + private send(method: 'sign.start_session', body: SigningStartSessionMessage): void + private send(method: 'dkg.identity', body: IdentityMessage): void + private send(method: 'sign.identity', body: IdentityMessage): void + private send(method: 'dkg.round1', body: Round1PublicPackageMessage): void + private send(method: 'dkg.round2', body: Round2PublicPackageMessage): void + private send(method: 'dkg.get_status', body: DkgGetStatusMessage): void + private send(method: 'sign.commitment', body: SigningCommitmentMessage): void + private send(method: 'sign.share', body: SignatureShareMessage): void + private send(method: 'sign.get_status', body: SigningGetStatusMessage): void + private send(method: string, body?: unknown): void { + if (!this.sessionId) { + throw new Error('Client must join a session before sending messages') + } + + if (!this.connected) { + return + } + + const messageId = this.nextMessageId++ + + const message: MultisigBrokerMessage = { + id: messageId, + method, + sessionId: this.sessionId, + body: this.encryptMessageBody(body), + } + + this.writeData(JSON.stringify(message) + '\n') + + this.retries.set( + messageId, + setInterval(() => { + this.writeData(JSON.stringify(message) + '\n') + }, RETRY_INTERVAL), + ) + } + + protected onConnect(): void { + this.connected = true + + this.logger.debug('Successfully connected to server') + } + + protected onDisconnect = (): void => { + this.connected = false + this.messageBuffer.clear() + + if (!this.isClosing) { + this.logger.warn('Disconnected from server unexpectedly. Reconnecting.') + this.connectTimeout = setTimeout(() => void this.startConnecting(), 5000) + } + } + + protected onError = (error: unknown): void => { + if (error instanceof SessionDecryptionError) { + throw error + } + this.logger.error(`Error ${ErrorUtils.renderError(error)}`) + } + + protected async onData(data: Buffer): Promise { + this.messageBuffer.write(data) + + for (const message of this.messageBuffer.readMessages()) { + const payload: unknown = JSON.parse(message) + + const header = await YupUtils.tryValidate(MultisigBrokerMessageSchema, payload) + + if (header.error) { + // Try the error message instead. + const headerWithError = await YupUtils.tryValidate( + MultisigBrokerMessageWithErrorSchema, + payload, + ) + if (headerWithError.error) { + throw new ServerMessageMalformedError(header.error) + } + this.logger.debug( + `Server sent error ${headerWithError.result.error.message} for id ${headerWithError.result.error.id}`, + ) + this.onMultisigBrokerError.emit(headerWithError.result) + return + } + + this.logger.debug(`Server sent ${header.result.method} message`) + + switch (header.result.method) { + case 'ack': { + const body = await YupUtils.tryValidate(MultisigBrokerAckSchema, header.result.body) + + if (body.error) { + throw new ServerMessageMalformedError(body.error, header.result.method) + } + + const retryInterval = this.retries.get(body.result.messageId) + clearInterval(retryInterval) + this.retries.delete(body.result.messageId) + break + } + case 'dkg.status': { + const body = await YupUtils.tryValidate(DkgStatusSchema, header.result.body) + + if (body.error) { + throw new ServerMessageMalformedError(body.error, header.result.method) + } + + const decrypted = this.decryptMessageBody(body.result) + this.onDkgStatus.emit(decrypted) + break + } + case 'sign.status': { + const body = await YupUtils.tryValidate(SigningStatusSchema, header.result.body) + + if (body.error) { + throw new ServerMessageMalformedError(body.error, header.result.method) + } + + const decrypted = this.decryptMessageBody(body.result) + this.onSigningStatus.emit(decrypted) + break + } + case 'connected': { + const body = await YupUtils.tryValidate(ConnectedMessageSchema, header.result.body) + + if (body.error) { + throw new ServerMessageMalformedError(body.error, header.result.method) + } + + this.onConnectedMessage.emit(body.result) + break + } + case 'joined_session': { + const body = await YupUtils.tryValidate(JoinedSessionSchema, header.result.body) + if (body.error) { + throw new ServerMessageMalformedError(body.error, header.result.method) + } + + try { + const decrypted = this.decryptMessageBody(body.result) + this.onJoinedSession.emit(decrypted) + break + } catch { + throw new SessionDecryptionError( + 'Failed to decrypt session challenge. Passphrase is incorrect.', + ) + } + } + + default: + throw new ServerMessageMalformedError(`Invalid message ${header.result.method}`) + } + } + } + + private encryptMessageBody(body: unknown): object { + let encrypted = body as object + for (const [key, value] of Object.entries(body as object)) { + if (typeof value === 'string') { + encrypted = { + ...encrypted, + [key]: this.key.encrypt(Buffer.from(value)).toString('hex'), + } + } else if (value instanceof Array) { + const encryptedItems = [] + for (const item of value) { + if (typeof item === 'string') { + encryptedItems.push(this.key.encrypt(Buffer.from(item)).toString('hex')) + } else { + encryptedItems.push(item) + } + } + encrypted = { + ...encrypted, + [key]: encryptedItems, + } + } + } + + return encrypted + } + + private decryptMessageBody(body: T): T { + let decrypted = body + for (const [key, value] of Object.entries(body)) { + if (typeof value === 'string') { + decrypted = { + ...decrypted, + [key]: this.key.decrypt(Buffer.from(value, 'hex')).toString(), + } + } else if (value instanceof Array) { + const decryptedItems = [] + for (const item of value) { + if (typeof item === 'string') { + try { + decryptedItems.push(this.key.decrypt(Buffer.from(item, 'hex')).toString()) + } catch { + this.logger.debug( + 'Failed to decrypt submitted session data. Skipping invalid data.', + ) + } + } else { + decryptedItems.push(item) + } + } + decrypted = { + ...decrypted, + [key]: decryptedItems, + } + } + } + + return decrypted + } +} + +class SessionDecryptionError extends Error { + constructor(message: string) { + super(message) + } +} diff --git a/ironfish-cli/src/multisigBroker/clients/index.ts b/ironfish-cli/src/multisigBroker/clients/index.ts new file mode 100644 index 0000000000..5a5d770c3a --- /dev/null +++ b/ironfish-cli/src/multisigBroker/clients/index.ts @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export * from './client' +export * from './tcpClient' +export * from './tlsClient' diff --git a/ironfish-cli/src/multisigBroker/clients/tcpClient.ts b/ironfish-cli/src/multisigBroker/clients/tcpClient.ts new file mode 100644 index 0000000000..3de57b0423 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/clients/tcpClient.ts @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logger } from '@ironfish/sdk' +import net from 'net' +import { MultisigClient } from './client' + +export class MultisigTcpClient extends MultisigClient { + client: net.Socket | null = null + + constructor(options: { hostname: string; port: number; passphrase: string; logger: Logger }) { + super({ + hostname: options.hostname, + port: options.port, + passphrase: options.passphrase, + logger: options.logger, + }) + } + + protected onSocketDisconnect = (): void => { + this.client?.off('error', this.onError) + this.client?.off('close', this.onSocketDisconnect) + this.client?.off('data', this.onSocketData) + this.onDisconnect() + } + + protected onSocketData = (data: Buffer): void => { + this.onData(data).catch((e) => this.onError(e)) + } + + protected connect(): Promise { + return new Promise((resolve, reject): void => { + const onConnect = () => { + client.off('connect', onConnect) + client.off('error', onError) + + client.on('error', this.onError) + client.on('close', this.onSocketDisconnect) + + resolve() + } + + const onError = (error: unknown) => { + client.off('connect', onConnect) + client.off('error', onError) + reject(error) + } + + const client = new net.Socket() + client.on('error', onError) + client.on('connect', onConnect) + client.on('data', this.onSocketData) + client.connect({ host: this.hostname, port: this.port }) + this.client = client + }) + } + + protected writeData(data: string): void { + this.client?.write(data) + } + + protected close(): Promise { + this.client?.destroy() + return Promise.resolve() + } +} diff --git a/ironfish-cli/src/multisigBroker/clients/tlsClient.ts b/ironfish-cli/src/multisigBroker/clients/tlsClient.ts new file mode 100644 index 0000000000..dc4e163f03 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/clients/tlsClient.ts @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import tls from 'tls' +import { MultisigTcpClient } from './tcpClient' + +export class MultisigTlsClient extends MultisigTcpClient { + protected connect(): Promise { + return new Promise((resolve, reject): void => { + const onConnect = () => { + client.off('secureConnect', onConnect) + client.off('error', onError) + + client.on('error', this.onError) + client.on('close', this.onSocketDisconnect) + + resolve() + } + + const onError = (error: unknown) => { + client.off('secureConnect', onConnect) + client.off('error', onError) + reject(error) + } + + const client = tls.connect({ + host: this.hostname, + port: this.port, + rejectUnauthorized: false, + }) + client.on('error', onError) + client.on('secureConnect', onConnect) + client.on('data', this.onSocketData) + this.client = client + }) + } +} diff --git a/ironfish-cli/src/multisigBroker/constants.ts b/ironfish-cli/src/multisigBroker/constants.ts new file mode 100644 index 0000000000..437f002a98 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/constants.ts @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export enum DisconnectReason { + BAD_VERSION = 'bad_version', + BANNED = 'banned', + UNKNOWN = 'unknown', +} diff --git a/ironfish-cli/src/multisigBroker/errors.ts b/ironfish-cli/src/multisigBroker/errors.ts new file mode 100644 index 0000000000..5f0b8131e8 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/errors.ts @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import * as yup from 'yup' +import { MultisigServerClient } from './serverClient' + +export class MessageMalformedError extends Error { + name = this.constructor.name + + constructor(sender: string, error: yup.ValidationError | string, method?: string) { + super() + + if (typeof error === 'string') { + this.message = error + } else { + this.message = `${sender} sent malformed request` + if (method) { + this.message += ` (${method})` + } + this.message += `: ${error.message}` + } + } +} + +export class ClientMessageMalformedError extends MessageMalformedError { + client: MultisigServerClient + + constructor( + client: MultisigServerClient, + error: yup.ValidationError | string, + method?: string, + ) { + super(`Client ${client.id}`, error, method) + this.client = client + } +} + +export class ServerMessageMalformedError extends MessageMalformedError { + constructor(error: yup.ValidationError | string, method?: string) { + super('Server', error, method) + } +} diff --git a/ironfish-cli/src/multisigBroker/index.ts b/ironfish-cli/src/multisigBroker/index.ts new file mode 100644 index 0000000000..1344825732 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/index.ts @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export * from './clients' +export { MultisigServer } from './server' +export * from './utils' diff --git a/ironfish-cli/src/multisigBroker/messages.ts b/ironfish-cli/src/multisigBroker/messages.ts new file mode 100644 index 0000000000..8775a219e6 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/messages.ts @@ -0,0 +1,195 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import * as yup from 'yup' + +export type MultisigBrokerMessage = { + id: number + method: string + sessionId: string + body?: unknown +} + +export interface MultisigBrokerMessageWithError + extends Omit { + error: { + id: number + message: string + } +} + +export type MultisigBrokerAckMessage = { + messageId: number +} + +export type DkgStartSessionMessage = { + minSigners: number + maxSigners: number + challenge: string +} + +export type SigningStartSessionMessage = { + numSigners: number + unsignedTransaction: string + challenge: string +} + +export type JoinSessionMessage = object | undefined + +export type JoinedSessionMessage = { + challenge: string +} + +export type IdentityMessage = { + identity: string +} + +export type Round1PublicPackageMessage = { + package: string +} + +export type Round2PublicPackageMessage = { + package: string +} + +export type SigningCommitmentMessage = { + signingCommitment: string +} + +export type SignatureShareMessage = { + signatureShare: string +} + +export type DkgGetStatusMessage = object | undefined + +export type DkgStatusMessage = { + minSigners: number + maxSigners: number + identities: string[] + round1PublicPackages: string[] + round2PublicPackages: string[] +} + +export type SigningGetStatusMessage = object | undefined + +export type SigningStatusMessage = { + numSigners: number + unsignedTransaction: string + identities: string[] + signingCommitments: string[] + signatureShares: string[] +} + +export type ConnectedMessage = object | undefined + +export const MultisigBrokerMessageSchema: yup.ObjectSchema = yup + .object({ + id: yup.number().required(), + method: yup.string().required(), + sessionId: yup.string().required(), + body: yup.mixed().notRequired(), + }) + .required() + +export const MultisigBrokerMessageWithErrorSchema: yup.ObjectSchema = + yup + .object({ + id: yup.number().required(), + error: yup + .object({ + id: yup.number().required(), + message: yup.string().required(), + }) + .required(), + }) + .required() + +export const MultisigBrokerAckSchema: yup.ObjectSchema = yup + .object({ + messageId: yup.number().required(), + }) + .required() + +export const DkgStartSessionSchema: yup.ObjectSchema = yup + .object({ + minSigners: yup.number().defined(), + maxSigners: yup.number().defined(), + challenge: yup.string().defined(), + }) + .defined() + +export const SigningStartSessionSchema: yup.ObjectSchema = yup + .object({ + numSigners: yup.number().defined(), + unsignedTransaction: yup.string().defined(), + challenge: yup.string().defined(), + }) + .defined() + +export const JoinSessionSchema: yup.ObjectSchema = yup + .object({}) + .notRequired() + .default(undefined) + +export const JoinedSessionSchema: yup.ObjectSchema = yup + .object({ + challenge: yup.string().required(), + }) + .required() + +export const IdentitySchema: yup.ObjectSchema = yup + .object({ + identity: yup.string().defined(), + }) + .defined() + +export const Round1PublicPackageSchema: yup.ObjectSchema = yup + .object({ package: yup.string().defined() }) + .defined() + +export const Round2PublicPackageSchema: yup.ObjectSchema = yup + .object({ package: yup.string().defined() }) + .defined() + +export const SigningCommitmentSchema: yup.ObjectSchema = yup + .object({ signingCommitment: yup.string().defined() }) + .defined() + +export const SignatureShareSchema: yup.ObjectSchema = yup + .object({ signatureShare: yup.string().defined() }) + .defined() + +export const DkgGetStatusSchema: yup.ObjectSchema = yup + .object({}) + .notRequired() + .default(undefined) + +export const DkgStatusSchema: yup.ObjectSchema = yup + .object({ + minSigners: yup.number().defined(), + maxSigners: yup.number().defined(), + identities: yup.array(yup.string().defined()).defined(), + round1PublicPackages: yup.array(yup.string().defined()).defined(), + round2PublicPackages: yup.array(yup.string().defined()).defined(), + }) + .defined() + +export const SigningGetStatusSchema: yup.ObjectSchema = yup + .object({}) + .notRequired() + .default(undefined) + +export const SigningStatusSchema: yup.ObjectSchema = yup + .object({ + numSigners: yup.number().defined(), + unsignedTransaction: yup.string().defined(), + identities: yup.array(yup.string().defined()).defined(), + signingCommitments: yup.array(yup.string().defined()).defined(), + signatureShares: yup.array(yup.string().defined()).defined(), + }) + .defined() + +export const ConnectedMessageSchema: yup.ObjectSchema = yup + .object({}) + .notRequired() + .default(undefined) diff --git a/ironfish-cli/src/multisigBroker/server.ts b/ironfish-cli/src/multisigBroker/server.ts new file mode 100644 index 0000000000..cfc3be382f --- /dev/null +++ b/ironfish-cli/src/multisigBroker/server.ts @@ -0,0 +1,787 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { ErrorUtils, Logger, YupUtils } from '@ironfish/sdk' +import net from 'net' +import { IMultisigBrokerAdapter } from './adapters' +import { ClientMessageMalformedError } from './errors' +import { + ConnectedMessage, + DkgGetStatusSchema, + DkgStartSessionSchema, + DkgStatusMessage, + IdentitySchema, + JoinedSessionMessage, + MultisigBrokerAckMessage, + MultisigBrokerMessage, + MultisigBrokerMessageSchema, + MultisigBrokerMessageWithError, + Round1PublicPackageSchema, + Round2PublicPackageSchema, + SignatureShareSchema, + SigningCommitmentSchema, + SigningGetStatusSchema, + SigningStartSessionSchema, + SigningStatusMessage, +} from './messages' +import { MultisigServerClient } from './serverClient' + +enum MultisigSessionType { + DKG = 'DKG', + SIGNING = 'SIGNING', +} + +interface MultisigSession { + id: string + type: MultisigSessionType + clientIds: Set + status: DkgStatus | SigningStatus + challenge: string + timeout: NodeJS.Timeout | undefined +} + +interface DkgSession extends MultisigSession { + type: MultisigSessionType.DKG + status: DkgStatus +} + +interface SigningSession extends MultisigSession { + type: MultisigSessionType.SIGNING + status: SigningStatus +} + +export type DkgStatus = { + minSigners: number + maxSigners: number + identities: string[] + round1PublicPackages: string[] + round2PublicPackages: string[] +} + +export type SigningStatus = { + numSigners: number + unsignedTransaction: string + identities: string[] + signingCommitments: string[] + signatureShares: string[] +} + +export class MultisigServer { + readonly logger: Logger + readonly adapters: IMultisigBrokerAdapter[] = [] + + clients: Map + nextClientId: number + nextMessageId: number + + sessions: Map = new Map() + + private _isRunning = false + private _startPromise: Promise | null = null + private idleSessionTimeout: number + + constructor(options: { logger: Logger; idleSessionTimeout?: number }) { + this.logger = options.logger + + this.clients = new Map() + this.nextClientId = 1 + this.nextMessageId = 1 + this.idleSessionTimeout = options.idleSessionTimeout ?? 600000 + } + + get isRunning(): boolean { + return this._isRunning + } + + /** Starts the MultisigBroker server and tells any attached adapters to start serving requests */ + async start(): Promise { + if (this._isRunning) { + return + } + + this._startPromise = Promise.all(this.adapters.map((a) => a.start())) + this._isRunning = true + await this._startPromise + } + + /** Stops the MultisigBroker server and tells any attached adapters to stop serving requests */ + async stop(): Promise { + if (!this._isRunning) { + return + } + + if (this._startPromise) { + await this._startPromise + } + + for (const session of this.sessions.values()) { + clearTimeout(session.timeout) + } + + await Promise.all(this.adapters.map((a) => a.stop())) + this._isRunning = false + } + + /** Adds an adapter to the MultisigBroker server and starts it if the server has already been started */ + mount(adapter: IMultisigBrokerAdapter): void { + this.adapters.push(adapter) + adapter.attach(this) + + if (this._isRunning) { + let promise: Promise = adapter.start() + + if (this._startPromise) { + // Attach this promise to the start promise chain + // in case we call stop while were still starting up + promise = Promise.all([this._startPromise, promise]) + } + + this._startPromise = promise + } + } + + onConnection(socket: net.Socket): void { + const client = MultisigServerClient.accept(socket, this.nextClientId++) + + socket.on('data', (data: Buffer) => { + this.onData(client, data).catch((e) => this.onError(client, e)) + }) + + socket.on('close', () => this.onDisconnect(client)) + socket.on('error', (e) => this.onError(client, e)) + + this.send(socket, 'connected', '0', {}) + + this.logger.debug(`Client ${client.id} connected: ${client.remoteAddress}`) + this.clients.set(client.id, client) + } + + private onDisconnect(client: MultisigServerClient): void { + this.logger.debug(`Client ${client.id} disconnected (${this.clients.size - 1} total)`) + + this.clients.delete(client.id) + client.close() + client.socket.removeAllListeners('close') + client.socket.removeAllListeners('error') + + if (client.sessionId) { + const sessionId = client.sessionId + + this.removeClientFromSession(client) + + if (!this.isSessionActive(sessionId)) { + this.setSessionTimeout(sessionId) + } + } + } + + private async onData(client: MultisigServerClient, data: Buffer): Promise { + client.messageBuffer.write(data) + + for (const split of client.messageBuffer.readMessages()) { + const payload: unknown = JSON.parse(split) + const { error: parseError, result: message } = await YupUtils.tryValidate( + MultisigBrokerMessageSchema, + payload, + ) + + if (parseError) { + this.sendErrorMessage(client, 0, `Error parsing message`) + return + } + + this.logger.debug(`Client ${client.id} sent ${message.method} message`) + this.send(client.socket, 'ack', message.sessionId, { messageId: message.id }) + + if (message.method === 'dkg.start_session') { + await this.handleDkgStartSessionMessage(client, message) + return + } else if (message.method === 'sign.start_session') { + await this.handleSigningStartSessionMessage(client, message) + return + } else if (message.method === 'join_session') { + this.handleJoinSessionMessage(client, message) + return + } else if (message.method === 'dkg.identity') { + await this.handleDkgIdentityMessage(client, message) + return + } else if (message.method === 'dkg.round1') { + await this.handleRound1PublicPackageMessage(client, message) + return + } else if (message.method === 'dkg.round2') { + await this.handleRound2PublicPackageMessage(client, message) + return + } else if (message.method === 'dkg.get_status') { + await this.handleDkgGetStatusMessage(client, message) + return + } else if (message.method === 'sign.identity') { + await this.handleSigningIdentityMessage(client, message) + return + } else if (message.method === 'sign.commitment') { + await this.handleSigningCommitmentMessage(client, message) + return + } else if (message.method === 'sign.share') { + await this.handleSignatureShareMessage(client, message) + return + } else if (message.method === 'sign.get_status') { + await this.handleSigningGetStatusMessage(client, message) + return + } else { + throw new ClientMessageMalformedError(client, `Invalid message ${message.method}`) + } + } + } + + private onError(client: MultisigServerClient, error: unknown): void { + this.logger.debug( + `Error during handling of data from client ${client.id}: ${ErrorUtils.renderError( + error, + true, + )}`, + ) + + client.socket.removeAllListeners() + client.close() + + this.clients.delete(client.id) + } + + /** + * If a client has the given session ID and is connected, the associated + * session should still be considered active + */ + private isSessionActive(sessionId: string): boolean { + const session = this.sessions.get(sessionId) + if (!session) { + return false + } + + if (session.clientIds.size > 0) { + return true + } + + return false + } + + private setSessionTimeout(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (!session) { + return + } + + session.timeout = setTimeout(() => this.cleanupSession(sessionId), this.idleSessionTimeout) + } + + private cleanupSession(sessionId: string): void { + this.sessions.delete(sessionId) + this.logger.debug(`Session ${sessionId} cleaned up. Active sessions: ${this.sessions.size}`) + } + + private addClientToSession(client: MultisigServerClient, sessionId: string): void { + const session = this.sessions.get(sessionId) + if (!session) { + return + } + + client.sessionId = session.id + session.clientIds.add(client.id) + + clearTimeout(session.timeout) + session.timeout = undefined + } + + private removeClientFromSession(client: MultisigServerClient): void { + if (!client.sessionId) { + return + } + + const session = this.sessions.get(client.sessionId) + if (!session) { + return + } + + client.sessionId = null + session.clientIds.delete(client.id) + } + + private broadcast(method: 'dkg.status', sessionId: string, body?: DkgStatusMessage): void + private broadcast(method: 'sign.status', sessionId: string, body?: SigningStatusMessage): void + private broadcast(method: string, sessionId: string, body?: unknown): void { + const message: MultisigBrokerMessage = { + id: this.nextMessageId++, + method, + sessionId, + body, + } + + const serialized = JSON.stringify(message) + '\n' + + this.logger.debug('broadcasting to clients', { + method, + sessionId, + id: message.id, + numClients: this.clients.size, + messageLength: serialized.length, + }) + + let broadcasted = 0 + + const session = this.sessions.get(sessionId) + if (!session) { + this.logger.debug(`Session ${sessionId} does not exist, broadcast failed`) + return + } + + for (const clientId of session.clientIds) { + const client = this.clients.get(clientId) + if (!client) { + this.logger.debug( + `Client ${clientId} does not exist, but session ${sessionId} thinks it does, removing.`, + ) + session.clientIds.delete(clientId) + continue + } + + if (!client.connected) { + continue + } + + client.socket.write(serialized) + broadcasted++ + } + + this.logger.debug('completed broadcast to clients', { + method, + sessionId, + id: message.id, + numClients: broadcasted, + messageLength: serialized.length, + }) + } + + send( + socket: net.Socket, + method: 'dkg.status', + sessionId: string, + body: DkgStatusMessage, + ): void + send( + socket: net.Socket, + method: 'sign.status', + sessionId: string, + body: SigningStatusMessage, + ): void + send(socket: net.Socket, method: 'connected', sessionId: string, body: ConnectedMessage): void + send( + socket: net.Socket, + method: 'joined_session', + sessionId: string, + body: JoinedSessionMessage, + ): void + send( + socket: net.Socket, + method: 'ack', + sessionId: string, + body: MultisigBrokerAckMessage, + ): void + send(socket: net.Socket, method: string, sessionId: string, body?: unknown): void { + const message: MultisigBrokerMessage = { + id: this.nextMessageId++, + method, + sessionId, + body, + } + + const serialized = JSON.stringify(message) + '\n' + socket.write(serialized) + } + + sendErrorMessage(client: MultisigServerClient, id: number, message: string): void { + const msg: MultisigBrokerMessageWithError = { + id: this.nextMessageId++, + error: { + id: id, + message: message, + }, + } + const serialized = JSON.stringify(msg) + '\n' + client.socket.write(serialized) + } + + async handleDkgStartSessionMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(DkgStartSessionSchema, message.body) + + if (body.error) { + return + } + + const sessionId = message.sessionId + + if (this.sessions.has(sessionId)) { + this.sendErrorMessage(client, message.id, `Duplicate sessionId: ${sessionId}`) + return + } + + const session = { + id: sessionId, + type: MultisigSessionType.DKG, + clientIds: new Set(), + status: { + maxSigners: body.result.maxSigners, + minSigners: body.result.minSigners, + identities: [], + round1PublicPackages: [], + round2PublicPackages: [], + }, + challenge: body.result.challenge, + timeout: undefined, + } + + this.sessions.set(sessionId, session) + + this.logger.debug(`Client ${client.id} started dkg session ${message.sessionId}`) + + this.addClientToSession(client, sessionId) + } + + async handleSigningStartSessionMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(SigningStartSessionSchema, message.body) + + if (body.error) { + return + } + + const sessionId = message.sessionId + + if (this.sessions.has(sessionId)) { + this.sendErrorMessage(client, message.id, `Duplicate sessionId: ${sessionId}`) + return + } + + const session = { + id: sessionId, + type: MultisigSessionType.SIGNING, + clientIds: new Set(), + status: { + numSigners: body.result.numSigners, + unsignedTransaction: body.result.unsignedTransaction, + identities: [], + signingCommitments: [], + signatureShares: [], + }, + challenge: body.result.challenge, + timeout: undefined, + } + + this.sessions.set(sessionId, session) + + this.logger.debug(`Client ${client.id} started signing session ${message.sessionId}`) + + this.addClientToSession(client, sessionId) + } + + handleJoinSessionMessage(client: MultisigServerClient, message: MultisigBrokerMessage) { + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + this.logger.debug(`Client ${client.id} joined session ${message.sessionId}`) + + this.addClientToSession(client, message.sessionId) + + this.send(client.socket, 'joined_session', message.sessionId, { + challenge: session.challenge, + }) + } + + async handleDkgIdentityMessage(client: MultisigServerClient, message: MultisigBrokerMessage) { + const body = await YupUtils.tryValidate(IdentitySchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isDkgSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a dkg session: ${message.sessionId}`, + ) + return + } + + const identity = body.result.identity + if (!session.status.identities.includes(identity)) { + session.status.identities.push(identity) + this.sessions.set(message.sessionId, session) + + // Broadcast status after collecting all identities + if (session.status.identities.length === session.status.maxSigners) { + this.broadcast('dkg.status', message.sessionId, session.status) + } + } + } + + async handleSigningIdentityMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(IdentitySchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isSigningSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a signing session: ${message.sessionId}`, + ) + return + } + + const identity = body.result.identity + if (!session.status.identities.includes(identity)) { + session.status.identities.push(identity) + this.sessions.set(message.sessionId, session) + + // Broadcast status after collecting all identities + if (session.status.identities.length === session.status.numSigners) { + this.broadcast('sign.status', message.sessionId, session.status) + } + } + } + + async handleRound1PublicPackageMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(Round1PublicPackageSchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isDkgSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a dkg session: ${message.sessionId}`, + ) + return + } + + const round1PublicPackage = body.result.package + if (!session.status.round1PublicPackages.includes(round1PublicPackage)) { + session.status.round1PublicPackages.push(round1PublicPackage) + this.sessions.set(message.sessionId, session) + + // Broadcast status after collecting all packages + if (session.status.round1PublicPackages.length === session.status.maxSigners) { + this.broadcast('dkg.status', message.sessionId, session.status) + } + } + } + + async handleRound2PublicPackageMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(Round2PublicPackageSchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isDkgSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a dkg session: ${message.sessionId}`, + ) + return + } + + const round2PublicPackage = body.result.package + if (!session.status.round2PublicPackages.includes(round2PublicPackage)) { + session.status.round2PublicPackages.push(round2PublicPackage) + this.sessions.set(message.sessionId, session) + + // Broadcast status after collecting all packages + if (session.status.round2PublicPackages.length === session.status.maxSigners) { + this.broadcast('dkg.status', message.sessionId, session.status) + } + } + } + + async handleDkgGetStatusMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(DkgGetStatusSchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isDkgSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a dkg session: ${message.sessionId}`, + ) + return + } + + this.send(client.socket, 'dkg.status', message.sessionId, session.status) + } + + async handleSigningCommitmentMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(SigningCommitmentSchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isSigningSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a signing session: ${message.sessionId}`, + ) + return + } + + const signingCommitment = body.result.signingCommitment + if (!session.status.signingCommitments.includes(signingCommitment)) { + session.status.signingCommitments.push(signingCommitment) + this.sessions.set(message.sessionId, session) + + // Broadcast status after collecting all signing commitments + if (session.status.signingCommitments.length === session.status.numSigners) { + this.broadcast('sign.status', message.sessionId, session.status) + } + } + } + + async handleSignatureShareMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(SignatureShareSchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isSigningSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a signing session: ${message.sessionId}`, + ) + return + } + + const signatureShare = body.result.signatureShare + if (!session.status.signatureShares.includes(signatureShare)) { + session.status.signatureShares.push(signatureShare) + this.sessions.set(message.sessionId, session) + + // Broadcast status after collecting all signature shares + if (session.status.signatureShares.length === session.status.numSigners) { + this.broadcast('sign.status', message.sessionId, session.status) + } + } + } + + async handleSigningGetStatusMessage( + client: MultisigServerClient, + message: MultisigBrokerMessage, + ) { + const body = await YupUtils.tryValidate(SigningGetStatusSchema, message.body) + + if (body.error) { + return + } + + const session = this.sessions.get(message.sessionId) + if (!session) { + this.sendErrorMessage(client, message.id, `Session not found: ${message.sessionId}`) + return + } + + if (!isSigningSession(session)) { + this.sendErrorMessage( + client, + message.id, + `Session is not a signing session: ${message.sessionId}`, + ) + return + } + + this.send(client.socket, 'sign.status', message.sessionId, session.status) + } +} + +function isDkgSession(session: MultisigSession): session is DkgSession { + return session.type === MultisigSessionType.DKG +} + +function isSigningSession(session: MultisigSession): session is SigningSession { + return session.type === MultisigSessionType.SIGNING +} diff --git a/ironfish-cli/src/multisigBroker/serverClient.ts b/ironfish-cli/src/multisigBroker/serverClient.ts new file mode 100644 index 0000000000..886f71f284 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/serverClient.ts @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Assert, MessageBuffer } from '@ironfish/sdk' +import net from 'net' + +export class MultisigServerClient { + id: number + socket: net.Socket + connected: boolean + remoteAddress: string + messageBuffer: MessageBuffer + sessionId: string | null = null + + private constructor(options: { socket: net.Socket; id: number }) { + this.id = options.id + this.socket = options.socket + this.connected = true + this.messageBuffer = new MessageBuffer('\n') + + Assert.isNotUndefined(this.socket.remoteAddress) + this.remoteAddress = this.socket.remoteAddress + } + + static accept(socket: net.Socket, id: number): MultisigServerClient { + return new MultisigServerClient({ socket, id }) + } + + close(error?: Error): void { + if (!this.connected) { + return + } + + this.messageBuffer.clear() + this.connected = false + this.socket.destroy(error) + } +} diff --git a/ironfish-cli/src/multisigBroker/utils.ts b/ironfish-cli/src/multisigBroker/utils.ts new file mode 100644 index 0000000000..7fe8443f45 --- /dev/null +++ b/ironfish-cli/src/multisigBroker/utils.ts @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { ErrorUtils, Logger } from '@ironfish/sdk' +import * as ui from '../ui' +import { MultisigClient, MultisigTcpClient, MultisigTlsClient } from './clients' + +async function parseConnectionOptions(options: { + connection?: string + hostname: string + port: number + sessionId?: string + passphrase?: string + logger: Logger +}): Promise<{ + hostname: string + port: number + sessionId: string + passphrase: string +}> { + let hostname + let port + let sessionId + let passphrase + if (options.connection) { + try { + const url = new URL(options.connection) + if (url.host) { + hostname = url.hostname + } + if (url.port) { + port = Number(url.port) + } + if (url.username) { + sessionId = url.username + } + if (url.password) { + passphrase = url.password + } + } catch (e) { + if (e instanceof TypeError && e.message.includes('Invalid URL')) { + options.logger.error(ErrorUtils.renderError(e)) + } + throw e + } + } + + hostname = hostname ?? options.hostname + port = port ?? options.port + + sessionId = sessionId ?? options.sessionId + if (!sessionId) { + sessionId = await ui.inputPrompt( + 'Enter the ID of a multisig session to join, or press enter to start a new session', + false, + ) + } + + passphrase = passphrase ?? options.passphrase + if (!passphrase) { + passphrase = await ui.inputPrompt('Enter the passphrase for the multisig session', true) + } + + return { + hostname, + port, + sessionId, + passphrase, + } +} + +function createClient( + hostname: string, + port: number, + options: { passphrase: string; tls: boolean; logger: Logger }, +): MultisigClient { + if (options.tls) { + return new MultisigTlsClient({ + hostname, + port, + passphrase: options.passphrase, + logger: options.logger, + }) + } else { + return new MultisigTcpClient({ + hostname, + port, + passphrase: options.passphrase, + logger: options.logger, + }) + } +} + +export const MultisigBrokerUtils = { + parseConnectionOptions, + createClient, +} diff --git a/ironfish-cli/src/ui/index.ts b/ironfish-cli/src/ui/index.ts index 25752ab000..b010b28865 100644 --- a/ironfish-cli/src/ui/index.ts +++ b/ironfish-cli/src/ui/index.ts @@ -11,3 +11,4 @@ export * from './prompts' export * from './retry' export * from './table' export * from './wallet' +export * from './ledger' diff --git a/ironfish-cli/src/ui/ledger.ts b/ironfish-cli/src/ui/ledger.ts new file mode 100644 index 0000000000..de6f79365d --- /dev/null +++ b/ironfish-cli/src/ui/ledger.ts @@ -0,0 +1,202 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { + CurrencyUtils, + Logger, + PromiseUtils, + RawTransaction, + RawTransactionSerde, + RpcClient, + Transaction, +} from '@ironfish/sdk' +import { Errors, ux } from '@oclif/core' +import { + Ledger, + LedgerActionRejected, + LedgerAppNotOpen, + LedgerClaNotSupportedError, + LedgerConnectError, + LedgerDeviceLockedError, + LedgerGPAuthFailed, + LedgerPanicError, + LedgerPortIsBusyError, + LedgerSingleSigner, +} from '../ledger' +import * as ui from '../ui' +import { watchTransaction } from '../utils/transaction' + +export async function ledger({ + ledger, + action, + message = 'Ledger', + approval, +}: { + ledger: Ledger + action: () => TResult | Promise + message?: string + approval?: boolean +}): Promise { + const wasRunning = ux.action.running + + if (!wasRunning) { + ux.action.start(message) + } + + let clearStatusTimer + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + try { + clearStatusTimer = setTimeout(() => { + if (approval) { + ux.action.status = 'Approve on Ledger' + } else { + ux.action.status = undefined + } + }, ledger.connectTimeout + 500) + + const result = await action() + ux.action.stop() + return result + } catch (e) { + clearTimeout(clearStatusTimer) + + if (e instanceof LedgerDeviceLockedError) { + // If an app is running and it's locked, trying to poll the device + // will cause the Ledger device to hide the pin screen as the user + // is trying to enter their pin. When we run into this error, we + // cannot send any commands to the Ledger in the app's CLA. + ux.action.stop('Ledger Locked') + + const confirmed = await ui.confirmList( + 'Ledger Locked. Unlock and press enter to retry:', + 'Retry', + ) + + if (!confirmed) { + ux.stdout('Operation aborted.') + ux.exit(0) + } + + if (!wasRunning) { + ux.action.start(message) + } + } else if (e instanceof LedgerActionRejected) { + ux.action.status = 'User Rejected Ledger Request!' + ux.stdout('User Rejected Ledger Request!') + } else if (e instanceof LedgerConnectError) { + ux.action.status = 'Connect and unlock your Ledger' + } else if (e instanceof LedgerAppNotOpen) { + const appName = ledger.isMultisig ? 'Ironfish DKG' : 'Ironfish' + ux.action.status = `Open Ledger App ${appName}` + } else if (e instanceof LedgerPanicError) { + ux.action.status = 'Ledger App Crashed' + ux.stdout('Ledger App Crashed! ⚠️') + } else if (e instanceof LedgerPortIsBusyError) { + ux.action.status = 'Ledger is busy, retrying' + } else if (e instanceof LedgerGPAuthFailed) { + ux.action.status = 'Ledger handshake failed, retrying' + } else if (e instanceof LedgerClaNotSupportedError) { + const appName = ledger.isMultisig ? 'Ironfish DKG' : 'Ironfish' + ux.action.status = `Wrong Ledger app. Please open ${appName}` + } else { + throw e + } + + await PromiseUtils.sleep(1000) + continue + } + } + } finally { + // Don't interrupt an existing status outside of ledgerAction() + if (!wasRunning) { + clearTimeout(clearStatusTimer) + ux.action.stop() + } + } +} + +export async function sendTransactionWithLedger( + client: RpcClient, + raw: RawTransaction, + from: string | undefined, + watch: boolean, + confirm: boolean, + logger?: Logger, +): Promise { + const ledgerApp = new LedgerSingleSigner() + + const publicKey = (await client.wallet.getAccountPublicKey({ account: from })).content + .publicKey + + const ledgerPublicKey = await ledger({ + ledger: ledgerApp, + message: 'Get Public Address', + action: () => ledgerApp.getPublicAddress(), + }) + + if (publicKey !== ledgerPublicKey) { + Errors.error( + `The public key on the ledger device does not match the public key of the account '${from}'`, + ) + } + + const buildTransactionResponse = await client.wallet.buildTransaction({ + account: from, + rawTransaction: RawTransactionSerde.serialize(raw).toString('hex'), + }) + + const unsignedTransaction = buildTransactionResponse.content.unsignedTransaction + + ux.stdout('Please confirm the transaction on your Ledger device') + + const signature = await ledger({ + ledger: ledgerApp, + message: 'Sign Transaction', + approval: true, + action: () => ledgerApp.sign(unsignedTransaction), + }) + + ux.stdout(`\nSignature: ${signature.toString('hex')}`) + + const addSignatureResponse = await client.wallet.addSignature({ + unsignedTransaction, + signature: signature.toString('hex'), + }) + + const signedTransaction = addSignatureResponse.content.transaction + const bytes = Buffer.from(signedTransaction, 'hex') + + const transaction = new Transaction(bytes) + + ux.stdout(`\nSigned Transaction: ${signedTransaction}`) + ux.stdout(`\nHash: ${transaction.hash().toString('hex')}`) + ux.stdout(`Fee: ${CurrencyUtils.render(transaction.fee(), true)}`) + + await ui.confirmOrQuit('Would you like to broadcast this transaction?', confirm) + + const addTransactionResponse = await client.wallet.addTransaction({ + transaction: signedTransaction, + broadcast: true, + }) + + if (addTransactionResponse.content.accepted === false) { + Errors.error( + `Transaction '${transaction.hash().toString('hex')}' was not accepted into the mempool`, + ) + } + + if (watch) { + ux.stdout('') + + await watchTransaction({ + client, + logger, + account: from, + hash: transaction.hash().toString('hex'), + }) + } +} diff --git a/ironfish-cli/src/ui/prompt.ts b/ironfish-cli/src/ui/prompt.ts index 5685bfdd05..78c43e5e9a 100644 --- a/ironfish-cli/src/ui/prompt.ts +++ b/ironfish-cli/src/ui/prompt.ts @@ -138,6 +138,29 @@ export async function confirmPrompt(message: string): Promise { return result.prompt } +export async function confirmList(message: string, action = 'Confirm'): Promise { + const result = await inquirer.prompt<{ confirm: boolean }>([ + { + name: 'confirm', + message, + type: 'list', + choices: [ + { + name: action, + value: true, + default: true, + }, + { + name: 'Cancel', + value: false, + }, + ], + }, + ]) + + return result.confirm +} + export async function confirmOrQuit(message?: string, confirm?: boolean): Promise { if (confirm) { return diff --git a/ironfish-cli/src/ui/retry.ts b/ironfish-cli/src/ui/retry.ts index da206c5fb8..addc2038eb 100644 --- a/ironfish-cli/src/ui/retry.ts +++ b/ironfish-cli/src/ui/retry.ts @@ -1,8 +1,9 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Logger } from '@ironfish/sdk' -import { confirmPrompt } from './prompt' +import { ErrorUtils, Logger } from '@ironfish/sdk' +import { ux } from '@oclif/core' +import { confirmList } from './prompt' export async function retryStep( stepFunction: () => Promise, @@ -17,11 +18,13 @@ export async function retryStep( const result = await stepFunction() return result } catch (error) { - logger.log(`An Error Occurred: ${(error as Error).message}`) + logger.log(`An Error Occurred: ${ErrorUtils.renderError(error)}`) + if (askToRetry) { - const continueResponse = await confirmPrompt('Do you want to retry this step?') + const continueResponse = await confirmList('Do you want to retry this step?', 'Retry') if (!continueResponse) { - throw new Error('User chose to not continue') + ux.stdout('User chose to not continue.') + ux.exit(0) } } } diff --git a/ironfish-cli/src/utils/chainport/config.ts b/ironfish-cli/src/utils/chainport/config.ts index b8405e283f..5a6ffbb802 100644 --- a/ironfish-cli/src/utils/chainport/config.ts +++ b/ironfish-cli/src/utils/chainport/config.ts @@ -6,8 +6,7 @@ import { MAINNET, TESTNET } from '@ironfish/sdk' const config = { [TESTNET.id]: { - chainportId: 22, - endpoint: 'https://preprod-api.chainport.io', + endpoint: 'https://testnet.api.ironfish.network/', outgoingAddresses: new Set([ '06102d319ab7e77b914a1bd135577f3e266fd82a3e537a02db281421ed8b3d13', 'db2cf6ec67addde84cc1092378ea22e7bb2eecdeecac5e43febc1cb8fb64b5e5', @@ -18,8 +17,7 @@ const config = { ]), }, [MAINNET.id]: { - chainportId: 22, - endpoint: 'https://api.chainport.io', + endpoint: 'https://api.ironfish.network/', outgoingAddresses: new Set([ '576ffdcc27e11d81f5180d3dc5690294941170d492b2d9503c39130b1f180405', '7ac2d6a59e19e66e590d014af013cd5611dc146e631fa2aedf0ee3ed1237eebe', diff --git a/ironfish-cli/src/utils/chainport/requests.ts b/ironfish-cli/src/utils/chainport/requests.ts index 11124fe83c..558e0836c5 100644 --- a/ironfish-cli/src/utils/chainport/requests.ts +++ b/ironfish-cli/src/utils/chainport/requests.ts @@ -1,14 +1,13 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { MAINNET } from '@ironfish/sdk' import axios from 'axios' import { getConfig } from './config' import { ChainportBridgeTransaction, ChainportNetwork, + ChainportToken, ChainportTransactionStatus, - ChainportVerifiedToken, } from './types' // Wrappers around chainport API requests. Documentation here: https://docs.chainport.io/for-developers/integrate-chainport/iron-fish/iron-fish-to-evm @@ -18,50 +17,52 @@ export const fetchChainportTransactionStatus = async ( hash: string, ): Promise => { const config = getConfig(networkId) - const url = `${config.endpoint}/api/port?base_tx_hash=${hash}&base_network_id=${config.chainportId}` + const url = new URL(`/bridges/transactions/status`, config.endpoint) + url.searchParams.append('hash', hash) - return await makeChainportRequest(url) + return await makeChainportRequest(url.toString()) } -export const fetchChainportNetworkMap = async ( +export const fetchChainportNetworks = async ( networkId: number, -): Promise<{ [key: string]: ChainportNetwork }> => { +): Promise => { const config = getConfig(networkId) - const url = `${config.endpoint}/meta` + const url = new URL('/bridges/networks', config.endpoint).toString() - return ( - await makeChainportRequest<{ cp_network_ids: { [key: string]: ChainportNetwork } }>(url) - ).cp_network_ids + return (await makeChainportRequest<{ data: ChainportNetwork[] }>(url)).data } -export const fetchChainportVerifiedTokens = async ( - networkId: number, -): Promise => { +export const fetchChainportTokens = async (networkId: number): Promise => { const config = getConfig(networkId) - let url - if (networkId === MAINNET.id) { - url = `${config.endpoint}/token/list?network_name=IRONFISH` - } else { - url = `${config.endpoint}/token_list?network_name=IRONFISH` - } + const url = new URL('/bridges/tokens', config.endpoint).toString() + + return (await makeChainportRequest<{ data: ChainportToken[] }>(url)).data +} - return (await makeChainportRequest<{ verified_tokens: ChainportVerifiedToken[] }>(url)) - .verified_tokens +export const fetchChainportTokenPaths = async ( + networkId: number, + tokenId: number, +): Promise => { + const config = getConfig(networkId) + const url = new URL(`/bridges/tokens/${tokenId}/networks`, config.endpoint).toString() + return (await makeChainportRequest<{ data: ChainportNetwork[] }>(url)).data } export const fetchChainportBridgeTransaction = async ( networkId: number, amount: bigint, - to: string, - network: ChainportNetwork, - asset: ChainportVerifiedToken, + assetId: string, + targetNetworkId: number, + targetAddress: string, ): Promise => { const config = getConfig(networkId) - const url = `${config.endpoint}/ironfish/metadata?raw_amount=${amount.toString()}&asset_id=${ - asset.web3_address - }&target_network_id=${network.chainport_network_id.toString()}&target_web3_address=${to}` + const url = new URL(`/bridges/transactions/create`, config.endpoint) + url.searchParams.append('amount', amount.toString()) + url.searchParams.append('asset_id', assetId) + url.searchParams.append('target_network_id', targetNetworkId.toString()) + url.searchParams.append('target_address', targetAddress.toString()) - return await makeChainportRequest(url) + return await makeChainportRequest(url.toString()) } const makeChainportRequest = async (url: string): Promise => { diff --git a/ironfish-cli/src/utils/chainport/types.ts b/ironfish-cli/src/utils/chainport/types.ts index b0d40cddfb..7aaaee1cfc 100644 --- a/ironfish-cli/src/utils/chainport/types.ts +++ b/ironfish-cli/src/utils/chainport/types.ts @@ -25,25 +25,19 @@ export type ChainportBridgeTransaction = { export type ChainportNetwork = { chainport_network_id: number - shortname: string - name: string - chain_id: number explorer_url: string label: string - blockchain_type: string - native_token_symbol: string network_icon: string } -export type ChainportVerifiedToken = { - decimals: number +export type ChainportToken = { id: number + decimals: number name: string pinned: boolean web3_address: string symbol: string token_image: string - target_networks: number[] chain_id: number | null network_name: string network_id: number diff --git a/ironfish-cli/src/utils/chainport/utils.ts b/ironfish-cli/src/utils/chainport/utils.ts index f44474936b..9961ff3422 100644 --- a/ironfish-cli/src/utils/chainport/utils.ts +++ b/ironfish-cli/src/utils/chainport/utils.ts @@ -2,18 +2,12 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { - defaultNetworkName, - Logger, - RpcWalletTransaction, - TransactionStatus, - TransactionType, -} from '@ironfish/sdk' +import { Logger, RpcWalletTransaction, TransactionStatus, TransactionType } from '@ironfish/sdk' import { ux } from '@oclif/core' import { getConfig, isNetworkSupportedByChainport } from './config' import { ChainportMemoMetadata } from './metadata' import { fetchChainportTransactionStatus } from './requests' -import { ChainportNetwork } from './types' +import { ChainportNetwork, ChainportTransactionStatus } from './types' export type ChainportTransactionData = | { @@ -105,78 +99,85 @@ export const displayChainportTransactionSummary = async ( return } - if (!network) { - logger.log( - `This transaction is a ${ - data.type === TransactionType.SEND ? 'outgoing' : 'incoming' - } chainport bridge transaction. Error fetching network details.`, - ) - return - } - // Chainport does not give us a way to determine the source transaction hash of an incoming bridge transaction // So we can only display the source network and address if (data.type === TransactionType.RECEIVE) { logger.log(` Direction: Incoming -Source Network: ${network.name} +Source Network: ${network?.label ?? 'Error fetching network details'} Address: ${data.address} - Explorer Account: ${network.explorer_url + 'address/' + data.address} -Target (Ironfish) Network: ${defaultNetworkName(networkId)}`) + Explorer Account: ${ + network + ? new URL('address/' + data.address, network.explorer_url).toString() + : 'Error fetching network details' + }`) return } const basicInfo = ` Direction: Outgoing -============================================== -Source Network: ${defaultNetworkName(networkId)} - Transaction Status: ${transaction.status} - Transaction Hash: ${transaction.hash} -============================================== -Target Network: ${network.name} +Target Network: ${network?.label ?? 'Error fetching network details'} Address: ${data.address} - Explorer Account: ${network.explorer_url + 'address/' + data.address}` + Explorer Account: ${ + network + ? new URL('address/' + data.address, network.explorer_url).toString() + : 'Error fetching network details' + }` - // We'll wait to show the transaction status if the transaction is still pending on Ironfish + // We'll wait to show the transaction status if the transaction is still pending on Iron Fish if (transaction.status !== TransactionStatus.CONFIRMED) { logger.log(basicInfo) + logger.log(` Transaction Status: ${transaction.status} (Iron Fish)`) return } ux.action.start('Fetching transaction information on target network') - const transactionStatus = await fetchChainportTransactionStatus(networkId, transaction.hash) - logger.log(`Transaction status fetched`) - ux.action.stop() + let transactionStatus: ChainportTransactionStatus | undefined + try { + transactionStatus = await fetchChainportTransactionStatus(networkId, transaction.hash) + ux.action.stop() + } catch (e: unknown) { + ux.action.stop('error') + + if (e instanceof Error) { + logger.debug(e.message) + } + } logger.log(basicInfo) - if (Object.keys(transactionStatus).length === 0) { - logger.log(` -Transaction status not found on target network. -Note: Bridge transactions may take up to 30 minutes to surface on the target network. -If this issue persists, please contact chainport support: https://helpdesk.chainport.io/`) + if (!transactionStatus) { + logger.log(` Transaction Status: Error fetching transaction details`) return } - if (!transactionStatus.base_tx_hash || !transactionStatus.base_tx_status) { - logger.log(` Transaction Status: pending`) + // States taken from https://docs.chainport.io/for-developers/api-reference/port + if (Object.keys(transactionStatus).length === 0 || !transactionStatus.base_tx_status) { + logger.log(` Transaction Status: Pending confirmation (Iron Fish)`) return } - if (transactionStatus.target_tx_hash === null) { - logger.log(` Transaction Status: pending`) + if ( + transactionStatus.base_tx_hash && + transactionStatus.base_tx_status === 1 && + !transactionStatus.target_tx_hash + ) { + logger.log(` Transaction Status: Pending creation (target network)`) return } - if (transactionStatus.target_tx_status === 1) { - logger.log(` Transaction Status: completed`) - } else { - logger.log(` Transaction Status: in progress`) - } - + logger.log( + ` Transaction Status: ${ + transactionStatus.target_tx_status === 1 + ? 'Completed' + : 'Pending confirmation (target network)' + }`, + ) logger.log(` Transaction Hash: ${transactionStatus.target_tx_hash} Explorer Transaction: ${ - network.explorer_url + 'tx/' + transactionStatus.target_tx_hash + network + ? new URL('address/' + data.address, network.explorer_url).toString() + : 'Error fetching network details' }`) } diff --git a/ironfish-cli/src/utils/fees.ts b/ironfish-cli/src/utils/fees.ts index cda295d36e..fce9b08088 100644 --- a/ironfish-cli/src/utils/fees.ts +++ b/ironfish-cli/src/utils/fees.ts @@ -20,7 +20,7 @@ import { promptCurrency } from './currency' export async function selectFee(options: { client: Pick transaction: CreateTransactionRequest - account?: string + account: string | undefined confirmations?: number logger: Logger }): Promise { diff --git a/ironfish-cli/src/utils/ledger.ts b/ironfish-cli/src/utils/ledger.ts deleted file mode 100644 index 106051fab3..0000000000 --- a/ironfish-cli/src/utils/ledger.ts +++ /dev/null @@ -1,533 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { - ACCOUNT_SCHEMA_VERSION, - AccountImport, - Assert, - createRootLogger, - CurrencyUtils, - Logger, - RawTransaction, - RawTransactionSerde, - RpcClient, - Transaction, -} from '@ironfish/sdk' -import TransportNodeHid from '@ledgerhq/hw-transport-node-hid' -import { Errors, ux } from '@oclif/core' -import IronfishApp, { - IronfishKeys, - ResponseAddress, - ResponseProofGenKey, - ResponseSign, - ResponseViewKey, -} from '@zondax/ledger-ironfish' -import { - default as IronfishDkgApp, - KeyResponse, - ResponseAddress as ResponseAddressDkg, - ResponseDkgRound1, - ResponseDkgRound2, - ResponseIdentity, - ResponseProofGenKey as ResponseProofGenKeyDkg, - ResponseViewKey as ResponseViewKeyDkg, -} from '@zondax/ledger-ironfish-dkg' -import { ResponseError } from '@zondax/ledger-js' -import * as ui from '../ui' -import { watchTransaction } from './transaction' - -export class LedgerDkg { - app: IronfishDkgApp | undefined - logger: Logger - PATH = "m/44'/1338'/0" - - constructor(logger?: Logger) { - this.app = undefined - this.logger = logger ? logger : createRootLogger() - } - - tryInstruction = async (instruction: (app: IronfishDkgApp) => Promise) => { - await this.refreshConnection() - Assert.isNotUndefined(this.app, 'Unable to establish connection with Ledger device') - - try { - return await instruction(this.app) - } catch (error: unknown) { - if (isResponseError(error)) { - this.logger.debug(`Ledger ResponseError returnCode: ${error.returnCode.toString(16)}`) - if (error.returnCode === LedgerDeviceLockedError.returnCode) { - throw new LedgerDeviceLockedError('Please unlock your Ledger device.') - } else if (LedgerAppUnavailableError.returnCodes.includes(error.returnCode)) { - throw new LedgerAppUnavailableError() - } - - throw new LedgerError(error.errorMessage) - } - - throw error - } - } - - connect = async () => { - const transport = await TransportNodeHid.create(3000) - - transport.on('disconnect', async () => { - await transport.close() - this.app = undefined - }) - - if (transport.deviceModel) { - this.logger.debug(`${transport.deviceModel.productName} found.`) - } - - const app = new IronfishDkgApp(transport, true) - - // If the app isn't open or the device is locked, this will throw an error. - await app.getVersion() - - this.app = app - - return { app, PATH: this.PATH } - } - - private refreshConnection = async () => { - if (!this.app) { - await this.connect() - } - } - - dkgGetIdentity = async (index: number): Promise => { - this.logger.log('Retrieving identity from ledger device.') - - const response: ResponseIdentity = await this.tryInstruction((app) => - app.dkgGetIdentity(index, false), - ) - - return response.identity - } - - dkgRound1 = async ( - index: number, - identities: string[], - minSigners: number, - ): Promise => { - this.logger.log('Please approve the request on your ledger device.') - - return this.tryInstruction((app) => app.dkgRound1(index, identities, minSigners)) - } - - dkgRound2 = async ( - index: number, - round1PublicPackages: string[], - round1SecretPackage: string, - ): Promise => { - this.logger.log('Please approve the request on your ledger device.') - - return this.tryInstruction((app) => - app.dkgRound2(index, round1PublicPackages, round1SecretPackage), - ) - } - - dkgRound3 = async ( - index: number, - participants: string[], - round1PublicPackages: string[], - round2PublicPackages: string[], - round2SecretPackage: string, - gskBytes: string[], - ): Promise => { - this.logger.log('Please approve the request on your ledger device.') - - return this.tryInstruction((app) => - app.dkgRound3Min( - index, - participants, - round1PublicPackages, - round2PublicPackages, - round2SecretPackage, - gskBytes, - ), - ) - } - - dkgRetrieveKeys = async (): Promise<{ - publicAddress: string - viewKey: string - incomingViewKey: string - outgoingViewKey: string - proofAuthorizingKey: string - }> => { - const responseAddress: KeyResponse = await this.tryInstruction((app) => - app.dkgRetrieveKeys(IronfishKeys.PublicAddress), - ) - - if (!isResponseAddress(responseAddress)) { - throw new Error(`No public address returned.`) - } - - const responseViewKey = await this.tryInstruction((app) => - app.dkgRetrieveKeys(IronfishKeys.ViewKey), - ) - - if (!isResponseViewKey(responseViewKey)) { - throw new Error(`No view key returned.`) - } - - const responsePGK: KeyResponse = await this.tryInstruction((app) => - app.dkgRetrieveKeys(IronfishKeys.ProofGenerationKey), - ) - - if (!isResponseProofGenKey(responsePGK)) { - throw new Error(`No proof authorizing key returned.`) - } - - return { - publicAddress: responseAddress.publicAddress.toString('hex'), - viewKey: responseViewKey.viewKey.toString('hex'), - incomingViewKey: responseViewKey.ivk.toString('hex'), - outgoingViewKey: responseViewKey.ovk.toString('hex'), - proofAuthorizingKey: responsePGK.nsk.toString('hex'), - } - } - - dkgGetPublicPackage = async (): Promise => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - const response = await this.tryInstruction((app) => app.dkgGetPublicPackage()) - - return response.publicPackage - } - - reviewTransaction = async (transaction: string): Promise => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - this.logger.info( - 'Please review and approve the outputs of this transaction on your ledger device.', - ) - - const { hash } = await this.tryInstruction((app) => app.reviewTransaction(transaction)) - - return hash - } - - dkgGetCommitments = async (transactionHash: string): Promise => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - const { commitments } = await this.tryInstruction((app) => - app.dkgGetCommitments(transactionHash), - ) - - return commitments - } - - dkgSign = async ( - randomness: string, - frostSigningPackage: string, - transactionHash: string, - ): Promise => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - const { signature } = await this.tryInstruction((app) => - app.dkgSign(randomness, frostSigningPackage, transactionHash), - ) - - return signature - } - - dkgBackupKeys = async (): Promise => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - this.logger.log('Please approve the request on your ledger device.') - - const { encryptedKeys } = await this.tryInstruction((app) => app.dkgBackupKeys()) - - return encryptedKeys - } - - dkgRestoreKeys = async (encryptedKeys: string): Promise => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - this.logger.log('Please approve the request on your ledger device.') - - await this.tryInstruction((app) => app.dkgRestoreKeys(encryptedKeys)) - } -} - -export class Ledger { - app: IronfishApp | undefined - logger: Logger - PATH = "m/44'/1338'/0" - - constructor(logger?: Logger) { - this.app = undefined - this.logger = logger ? logger : createRootLogger() - } - - connect = async () => { - const transport = await TransportNodeHid.create(3000) - - if (transport.deviceModel) { - this.logger.debug(`${transport.deviceModel.productName} found.`) - } - - const app = new IronfishApp(transport) - - const appInfo = await app.appInfo() - this.logger.debug(appInfo.appName ?? 'no app name') - - if (appInfo.appName !== 'Ironfish') { - this.logger.debug(appInfo.appName ?? 'no app name') - this.logger.debug(appInfo.returnCode.toString(16)) - this.logger.debug(appInfo.errorMessage.toString()) - - // references: - // https://github.com/LedgerHQ/ledger-live/blob/173bb3c84cc855f83ab8dc49362bc381afecc31e/libs/ledgerjs/packages/errors/src/index.ts#L263 - // https://github.com/Zondax/ledger-ironfish/blob/bf43a4b8d403d15138699ee3bb1a3d6dfdb428bc/docs/APDUSPEC.md?plain=1#L25 - if (appInfo.returnCode === 0x5515) { - throw new LedgerError('Please unlock your Ledger device.') - } - - throw new LedgerError('Please open the Iron Fish app on your ledger device.') - } - - if (appInfo.appVersion) { - this.logger.debug(`Ironfish App Version: ${appInfo.appVersion}`) - } - - this.app = app - - return { app, PATH: this.PATH } - } - - getPublicAddress = async () => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - const response: ResponseAddress = await this.app.retrieveKeys( - this.PATH, - IronfishKeys.PublicAddress, - false, - ) - - if (!response.publicAddress) { - this.logger.debug(`No public address returned.`) - this.logger.debug(response.returnCode.toString()) - throw new Error(response.errorMessage) - } - - return response.publicAddress.toString('hex') - } - - importAccount = async () => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - const responseAddress: ResponseAddress = await this.app.retrieveKeys( - this.PATH, - IronfishKeys.PublicAddress, - false, - ) - - if (!responseAddress.publicAddress) { - this.logger.debug(`No public address returned.`) - this.logger.debug(responseAddress.returnCode.toString()) - throw new Error(responseAddress.errorMessage) - } - - this.logger.log('Please confirm the request on your ledger device.') - - const responseViewKey: ResponseViewKey = await this.app.retrieveKeys( - this.PATH, - IronfishKeys.ViewKey, - true, - ) - - if (!responseViewKey.viewKey || !responseViewKey.ovk || !responseViewKey.ivk) { - this.logger.debug(`No view key returned.`) - this.logger.debug(responseViewKey.returnCode.toString()) - throw new Error(responseViewKey.errorMessage) - } - - const responsePGK: ResponseProofGenKey = await this.app.retrieveKeys( - this.PATH, - IronfishKeys.ProofGenerationKey, - false, - ) - - if (!responsePGK.ak || !responsePGK.nsk) { - this.logger.debug(`No proof authorizing key returned.`) - throw new Error(responsePGK.errorMessage) - } - - const accountImport: AccountImport = { - version: ACCOUNT_SCHEMA_VERSION, - name: 'ledger', - viewKey: responseViewKey.viewKey.toString('hex'), - incomingViewKey: responseViewKey.ivk.toString('hex'), - outgoingViewKey: responseViewKey.ovk.toString('hex'), - publicAddress: responseAddress.publicAddress.toString('hex'), - proofAuthorizingKey: responsePGK.nsk.toString('hex'), - spendingKey: null, - createdAt: null, - } - - return accountImport - } - - sign = async (message: string): Promise => { - if (!this.app) { - throw new Error('Connect to Ledger first') - } - - this.logger.log('Please confirm the request on your ledger device.') - - const buffer = Buffer.from(message, 'hex') - - // max size of a transaction is 16kb - if (buffer.length > 16 * 1024) { - throw new Error('Transaction size is too large, must be less than 16kb.') - } - - const response: ResponseSign = await this.app.sign(this.PATH, buffer) - - if (!response.signature) { - this.logger.debug(`No signatures returned.`) - this.logger.debug(response.returnCode.toString()) - throw new Error(response.errorMessage) - } - - return response.signature - } -} - -function isResponseAddress(response: KeyResponse): response is ResponseAddressDkg { - return 'publicAddress' in response -} - -function isResponseViewKey(response: KeyResponse): response is ResponseViewKeyDkg { - return 'viewKey' in response -} - -function isResponseProofGenKey(response: KeyResponse): response is ResponseProofGenKeyDkg { - return 'ak' in response -} - -function isResponseError(error: unknown): error is ResponseError { - return 'errorMessage' in (error as object) && 'returnCode' in (error as object) -} - -export class LedgerError extends Error { - name = this.constructor.name -} - -export class LedgerDeviceLockedError extends LedgerError { - static returnCode = 0x5515 -} - -export class LedgerAppUnavailableError extends LedgerError { - static returnCodes = [ - 0x6d00, // Instruction not supported - 0xffff, // Unknown transport error - 0x6f00, // Technical error - ] - - constructor() { - super( - `Unable to connect to Ironfish app on Ledger. Please check that the device is unlocked and the app is open.`, - ) - } -} - -export async function sendTransactionWithLedger( - client: RpcClient, - raw: RawTransaction, - from: string | undefined, - watch: boolean, - confirm: boolean, - logger?: Logger, -): Promise { - const ledger = new Ledger(logger) - try { - await ledger.connect() - } catch (e) { - if (e instanceof Error) { - Errors.error(e.message) - } else { - throw e - } - } - - const publicKey = (await client.wallet.getAccountPublicKey({ account: from })).content - .publicKey - - const ledgerPublicKey = await ledger.getPublicAddress() - - if (publicKey !== ledgerPublicKey) { - Errors.error( - `The public key on the ledger device does not match the public key of the account '${from}'`, - ) - } - - const buildTransactionResponse = await client.wallet.buildTransaction({ - account: from, - rawTransaction: RawTransactionSerde.serialize(raw).toString('hex'), - }) - - const unsignedTransaction = buildTransactionResponse.content.unsignedTransaction - - const signature = (await ledger.sign(unsignedTransaction)).toString('hex') - - ux.stdout(`\nSignature: ${signature}`) - - const addSignatureResponse = await client.wallet.addSignature({ - unsignedTransaction, - signature, - }) - - const signedTransaction = addSignatureResponse.content.transaction - const bytes = Buffer.from(signedTransaction, 'hex') - - const transaction = new Transaction(bytes) - - ux.stdout(`\nSigned Transaction: ${signedTransaction}`) - ux.stdout(`\nHash: ${transaction.hash().toString('hex')}`) - ux.stdout(`Fee: ${CurrencyUtils.render(transaction.fee(), true)}`) - - await ui.confirmOrQuit('Would you like to broadcast this transaction?', confirm) - - const addTransactionResponse = await client.wallet.addTransaction({ - transaction: signedTransaction, - broadcast: true, - }) - - if (addTransactionResponse.content.accepted === false) { - Errors.error( - `Transaction '${transaction.hash().toString('hex')}' was not accepted into the mempool`, - ) - } - - if (watch) { - ux.stdout('') - - await watchTransaction({ - client, - logger, - account: from, - hash: transaction.hash().toString('hex'), - }) - } -} diff --git a/ironfish-cli/src/utils/multisig/index.ts b/ironfish-cli/src/utils/multisig/index.ts new file mode 100644 index 0000000000..1e6dfb1fea --- /dev/null +++ b/ironfish-cli/src/utils/multisig/index.ts @@ -0,0 +1,4 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +export * from './transaction' diff --git a/ironfish-cli/src/utils/multisig.ts b/ironfish-cli/src/utils/multisig/transaction.ts similarity index 100% rename from ironfish-cli/src/utils/multisig.ts rename to ironfish-cli/src/utils/multisig/transaction.ts diff --git a/ironfish-cli/tsconfig.build.json b/ironfish-cli/tsconfig.build.json new file mode 100644 index 0000000000..f47380057d --- /dev/null +++ b/ironfish-cli/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "lib": ["es2020"], + "outDir": "build", + "rootDir": "./", + "tsBuildInfoFile": "./build/tsconfig.tsbuildinfo" + }, + "include": ["src", "./package.json"], + "exclude": ["src/**/*.test.*"], + "references": [ + { "path": "../ironfish/tsconfig.build.json" }, + ] +} diff --git a/ironfish-cli/tsconfig.json b/ironfish-cli/tsconfig.json index ad0746eaa9..b0454b1a42 100644 --- a/ironfish-cli/tsconfig.json +++ b/ironfish-cli/tsconfig.json @@ -2,12 +2,8 @@ "extends": "../config/tsconfig.base.json", "compilerOptions": { "lib": ["es2020"], - "outDir": "build", - "rootDir": "./", - "tsBuildInfoFile": "./build/tsconfig.tsbuildinfo" + "noEmit": true }, - "include": ["src", "./package.json"], - "references": [ - { "path": "../ironfish" }, - ] + "include": ["src", "package.json"], + "references": [{ "path": "../ironfish/tsconfig.build.json" }] } diff --git a/ironfish-cli/tsconfig.test.json b/ironfish-cli/tsconfig.test.json deleted file mode 100644 index df85e8e540..0000000000 --- a/ironfish-cli/tsconfig.test.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../config/tsconfig.base.json", - "compilerOptions": { - "lib": ["es2020"], - "noEmit": true - }, - "include": [], - "references": [{ "path": "../ironfish" }] -} diff --git a/ironfish/package.json b/ironfish/package.json index 8c68235061..794377a55a 100644 --- a/ironfish/package.json +++ b/ironfish/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/sdk", - "version": "2.7.0", + "version": "2.8.0", "description": "SDK for running and interacting with an Iron Fish node", "author": "Iron Fish (https://ironfish.network)", "main": "build/src/index.js", @@ -52,18 +52,18 @@ "yup": "0.29.3" }, "scripts": { - "build": "tsc -b", - "build:watch": "tsc -b -w", - "build:tests": "tsc -b tsconfig.test.json", - "lint": "tsc -b && tsc -b tsconfig.test.json && eslint --ext .ts,.tsx,.js,.jsx src/", - "lint:fix": "tsc -b && tsc -b tsconfig.test.json && eslint --ext .ts,.tsx,.js,.jsx src/ --fix", - "start": "tsc -b -w", - "test": "tsc -b && tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules RUST_BACKTRACE=1 jest --testTimeout=${JEST_TIMEOUT:-5000}", - "test:slow": "tsc -b && tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules TEST_INIT_RUST=true RUST_BACKTRACE=1 jest --testMatch \"**/*.test.slow.ts\" --testPathIgnorePatterns --testTimeout=${JEST_TIMEOUT:-60000}", - "test:perf": "tsc -b && tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules TEST_INIT_RUST=true jest --testMatch \"**/*.test.perf.ts\" --testPathIgnorePatterns --testTimeout=${JEST_TIMEOUT:-600000} --runInBand", - "test:perf:report": "tsc -b && tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules TEST_INIT_RUST=true GENERATE_TEST_REPORT=true jest --config jest.config.js --testMatch \"**/*.test.perf.ts\" --testPathIgnorePatterns --testTimeout=${JEST_TIMEOUT:-600000} --ci", - "test:coverage:html": "tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns --coverage --coverage-reporters html", - "test:watch": "tsc -b tsconfig.test.json && cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch --coverage false", + "build": "tsc -b tsconfig.build.json", + "build:tests": "tsc -b", + "build:watch": "tsc -b -w tsconfig.build.json", + "lint": "yarn build && yarn build:tests && eslint --ext .ts,.tsx,.js,.jsx src/", + "lint:fix": "yarn build && yarn build:tests && eslint --ext .ts,.tsx,.js,.jsx src/ --fix", + "start": "tsc -b -w tsconfig.build.json", + "test": "yarn build && yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules RUST_BACKTRACE=1 jest --testTimeout=${JEST_TIMEOUT:-5000}", + "test:slow": "yarn build && yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules TEST_INIT_RUST=true RUST_BACKTRACE=1 jest --testMatch \"**/*.test.slow.ts\" --testPathIgnorePatterns --testTimeout=${JEST_TIMEOUT:-60000}", + "test:perf": "yarn build && yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules TEST_INIT_RUST=true jest --testMatch \"**/*.test.perf.ts\" --testPathIgnorePatterns --testTimeout=${JEST_TIMEOUT:-600000} --runInBand", + "test:perf:report": "yarn build && yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules TEST_INIT_RUST=true GENERATE_TEST_REPORT=true jest --config jest.config.js --testMatch \"**/*.test.perf.ts\" --testPathIgnorePatterns --testTimeout=${JEST_TIMEOUT:-600000} --ci", + "test:coverage:html": "yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns --coverage --coverage-reporters html", + "test:watch": "yarn build:tests && cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch --coverage false", "fixtures:regenerate": "find . -name \"__fixtures__\" | xargs rm -rf && NODE_OPTIONS=--experimental-vm-modules JEST_TIMEOUT=1000000000 yarn run test && NODE_OPTIONS=--experimental-vm-modules JEST_TIMEOUT=1000000000 yarn run test:slow && NODE_OPTIONS=--experimental-vm-modules JEST_TIMEOUT=1000000000 yarn run test:perf" }, "devDependencies": { diff --git a/ironfish/src/rpc/routes/chain/followChainStream.ts b/ironfish/src/rpc/routes/chain/followChainStream.ts index 80d0ee63d6..8a48fd0ed7 100644 --- a/ironfish/src/rpc/routes/chain/followChainStream.ts +++ b/ironfish/src/rpc/routes/chain/followChainStream.ts @@ -103,6 +103,7 @@ routes.register = yup @@ -93,6 +97,7 @@ export const RpcMintSchema: yup.ObjectSchema = yup metadata: yup.string().defined(), name: yup.string().defined(), creator: yup.string().defined(), + owner: yup.string().defined(), assetName: yup.string().defined(), }) .defined() diff --git a/ironfish/src/rpc/routes/wallet/createTransaction.ts b/ironfish/src/rpc/routes/wallet/createTransaction.ts index ca99db54ab..b878659bb8 100644 --- a/ironfish/src/rpc/routes/wallet/createTransaction.ts +++ b/ironfish/src/rpc/routes/wallet/createTransaction.ts @@ -236,7 +236,7 @@ routes.register { ), id: asset.id().toString('hex'), creator: asset.creator().toString('hex'), + owner: asset.creator().toString('hex'), assetId: asset.id().toString('hex'), metadata: asset.metadata().toString('hex'), hash: mintTransaction.hash().toString('hex'), diff --git a/ironfish/src/rpc/routes/wallet/mintAsset.ts b/ironfish/src/rpc/routes/wallet/mintAsset.ts index 1aed7dc9f9..aa3297a4b7 100644 --- a/ironfish/src/rpc/routes/wallet/mintAsset.ts +++ b/ironfish/src/rpc/routes/wallet/mintAsset.ts @@ -164,6 +164,7 @@ routes.register( assetName: mint.asset.name().toString('hex'), metadata: mint.asset.metadata().toString('hex'), creator: mint.asset.creator().toString('hex'), + owner: mint.asset.creator().toString('hex'), transferOwnershipTo: mint.transferOwnershipTo?.toString('hex'), }) }, diff --git a/ironfish/src/rpc/routes/wallet/sendTransaction.ts b/ironfish/src/rpc/routes/wallet/sendTransaction.ts index df1c9eca0e..92c8ae6434 100644 --- a/ironfish/src/rpc/routes/wallet/sendTransaction.ts +++ b/ironfish/src/rpc/routes/wallet/sendTransaction.ts @@ -174,7 +174,7 @@ routes.register( assetData, ) const renderedAmount = CurrencyUtils.render(e.amount, false, e.assetId, assetData) - const message = `Insufficient funds: Needed ${renderedAmountNeeded} but have ${renderedAmount} available to spend. Please fund your account and/or wait for any pending transactions to be confirmed.'` + const message = `Insufficient funds: Needed ${renderedAmountNeeded} but have ${renderedAmount} available to spend. Please fund your account and/or wait for any pending transactions to be confirmed.` throw new RpcValidationError(message, 400, RPC_ERROR_CODES.INSUFFICIENT_BALANCE) } throw e diff --git a/ironfish/src/rpc/routes/wallet/serializers.ts b/ironfish/src/rpc/routes/wallet/serializers.ts index c477c27b68..bb2c27b627 100644 --- a/ironfish/src/rpc/routes/wallet/serializers.ts +++ b/ironfish/src/rpc/routes/wallet/serializers.ts @@ -79,6 +79,7 @@ export async function serializeRpcWalletTransaction( metadata: BufferUtils.toHuman(mint.asset.metadata()), name: BufferUtils.toHuman(mint.asset.name()), creator: mint.asset.creator().toString('hex'), + owner: mint.asset.creator().toString('hex'), value: mint.value.toString(), transferOwnershipTo: mint.transferOwnershipTo?.toString('hex'), assetId: mint.asset.id().toString('hex'), diff --git a/ironfish/src/wallet/errors.ts b/ironfish/src/wallet/errors.ts index e0d9c0c16e..6cb3a5a9a2 100644 --- a/ironfish/src/wallet/errors.ts +++ b/ironfish/src/wallet/errors.ts @@ -19,7 +19,7 @@ export class NotEnoughFundsError extends Error { const renderedAmountNeeded = CurrencyUtils.render(amountNeeded, true, this.assetId) const renderedAmount = CurrencyUtils.render(amount) - this.message = `Insufficient funds: Needed ${renderedAmountNeeded} but have ${renderedAmount} available to spend. Please fund your account and/or wait for any pending transactions to be confirmed.'` + this.message = `Insufficient funds: Needed ${renderedAmountNeeded} but have ${renderedAmount} available to spend. Please fund your account and/or wait for any pending transactions to be confirmed.` } } diff --git a/ironfish/tsconfig.test.json b/ironfish/tsconfig.build.json similarity index 60% rename from ironfish/tsconfig.test.json rename to ironfish/tsconfig.build.json index 26971efaa2..1e74446feb 100644 --- a/ironfish/tsconfig.test.json +++ b/ironfish/tsconfig.build.json @@ -1,8 +1,8 @@ { "extends": "../config/tsconfig.base.json", "compilerOptions": { - "noEmit": true, - "tsBuildInfoFile": "./build/tsconfig.tsbuildinfo" + "outDir": "build" }, "include": ["src", "package.json"], + "exclude": ["src/**/*.test.*"], } diff --git a/ironfish/tsconfig.json b/ironfish/tsconfig.json index 1e74446feb..26971efaa2 100644 --- a/ironfish/tsconfig.json +++ b/ironfish/tsconfig.json @@ -1,8 +1,8 @@ { "extends": "../config/tsconfig.base.json", "compilerOptions": { - "outDir": "build" + "noEmit": true, + "tsBuildInfoFile": "./build/tsconfig.tsbuildinfo" }, "include": ["src", "package.json"], - "exclude": ["src/**/*.test.*"], } diff --git a/yarn.lock b/yarn.lock index dc9a6999ee..d993f18f0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1495,16 +1495,6 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@ledgerhq/devices@^8.0.0", "@ledgerhq/devices@^8.4.2": - version "8.4.3" - resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.4.3.tgz#4c296df4dd4af6f1085d728609b6931a640baf86" - integrity sha512-+ih+M27E6cm6DHrmw3GbS3mEaznCyFc0e62VdQux40XK2psgYhL2yBPftM4KCrBYm1UbHqXzqLN+Jb7rNIzsHg== - dependencies: - "@ledgerhq/errors" "^6.19.0" - "@ledgerhq/logs" "^6.12.0" - rxjs "^7.8.1" - semver "^7.3.5" - "@ledgerhq/devices@^8.4.0": version "8.4.0" resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.4.0.tgz#f3a03576d4a53d731bdaa212a00bd0adbfb86fb1" @@ -1515,16 +1505,26 @@ rxjs "^7.8.1" semver "^7.3.5" -"@ledgerhq/errors@^6.12.3", "@ledgerhq/errors@^6.18.0", "@ledgerhq/errors@^6.19.0": - version "6.19.0" - resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.19.0.tgz#ed4f01df3dabfcdeb0b073159d66cb5f2d086243" - integrity sha512-c3Jid7euMSnpHFp8H7iPtsmKDjwbTjlG46YKdw+RpCclsqtBx1uQDlYmcbP1Yv9201kVlUFUhhP4H623k8xzlQ== +"@ledgerhq/devices@^8.4.2": + version "8.4.3" + resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.4.3.tgz#4c296df4dd4af6f1085d728609b6931a640baf86" + integrity sha512-+ih+M27E6cm6DHrmw3GbS3mEaznCyFc0e62VdQux40XK2psgYhL2yBPftM4KCrBYm1UbHqXzqLN+Jb7rNIzsHg== + dependencies: + "@ledgerhq/errors" "^6.19.0" + "@ledgerhq/logs" "^6.12.0" + rxjs "^7.8.1" + semver "^7.3.5" -"@ledgerhq/errors@^6.17.0": +"@ledgerhq/errors@6.17.0", "@ledgerhq/errors@^6.17.0": version "6.17.0" resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.17.0.tgz#0d56361fe6eb7de3b239e661710679f933f1fcca" integrity sha512-xnOVpy/gUUkusEORdr2Qhw3Vd0MGfjyVGgkGR9Ck6FXE26OIdIQ3tNmG5BdZN+gwMMFJJVxxS4/hr0taQfZ43w== +"@ledgerhq/errors@^6.18.0", "@ledgerhq/errors@^6.19.0": + version "6.19.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.19.0.tgz#ed4f01df3dabfcdeb0b073159d66cb5f2d086243" + integrity sha512-c3Jid7euMSnpHFp8H7iPtsmKDjwbTjlG46YKdw+RpCclsqtBx1uQDlYmcbP1Yv9201kVlUFUhhP4H623k8xzlQ== + "@ledgerhq/hw-transport-node-hid-noevents@^6.30.1": version "6.30.1" resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.30.1.tgz#e84854c809dda02bcb74a6d3dcc20b6014b5210d" @@ -1550,15 +1550,6 @@ node-hid "2.1.2" usb "2.9.0" -"@ledgerhq/hw-transport@6.28.1": - version "6.28.1" - resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.28.1.tgz#cb22fe9bc23af4682c30f2aac7fe6f7ab13ed65a" - integrity sha512-RaZe+abn0zBIz82cE9tp7Y7aZkHWWbEaE2yJpfxT8AhFz3fx+BU0kLYzuRN9fmA7vKueNJ1MTVUCY+Ex9/CHSQ== - dependencies: - "@ledgerhq/devices" "^8.0.0" - "@ledgerhq/errors" "^6.12.3" - events "^3.3.0" - "@ledgerhq/hw-transport@6.31.2": version "6.31.2" resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.31.2.tgz#79c95f7928a64a0e3b5bc4ea7b5be04b9f738322" @@ -3911,28 +3902,14 @@ dependencies: argparse "^2.0.1" -"@zondax/ledger-ironfish-dkg@npm:@zondax/ledger-ironfish@0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@zondax/ledger-ironfish/-/ledger-ironfish-0.4.0.tgz#424d213a73688f8ec33035325d45cb0f0d7915da" - integrity sha512-ifZPJl0WKKvTxAZCGRPARRJJv+qssU6PJYZEJTPHe+Vy2GSbcpfwbIzoyLqKI1vlPBQ1InbZYBP5BOmU1zRWnQ== +"@zondax/ledger-ironfish@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@zondax/ledger-ironfish/-/ledger-ironfish-1.0.0.tgz#f1831b44e75d74a372d7bcd4bbb4c5df76731211" + integrity sha512-eWbvTP2pwqiRylWuA8YybcX7V7Iw2zcvYIhQ7cYjyZCI6ruDVO7bEdi/d2Q8Wfkjq9vOadHeoVZYAaVk/ulBMw== dependencies: "@zondax/ledger-js" "^1.0.1" -"@zondax/ledger-ironfish@0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@zondax/ledger-ironfish/-/ledger-ironfish-0.1.2.tgz#2ff93139c706734eb0d6800f743a9e0c2ae5268d" - integrity sha512-a9qnSOHxAf76pMonJBy5jI9oauR2W7WpVu/cCBs151uEW78NeSu4IMHOLGCo8KNiTPzpGwGa/7+1bpzxlQiEng== - dependencies: - "@zondax/ledger-js" "^0.2.1" - -"@zondax/ledger-js@^0.2.1": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@zondax/ledger-js/-/ledger-js-0.2.2.tgz#b334cecaa372a8bfb91ae4fc5dd0d1c52411da4e" - integrity sha512-7wOUlRF2+kRaRU2KSzKb7XjPfScwEg3Cjg6NH/p+ikQLJ9eMkGC45NhSxYn8lixIIk+TgZ4yzTNOzFvF836gQw== - dependencies: - "@ledgerhq/hw-transport" "6.28.1" - -"@zondax/ledger-js@^1.0.1": +"@zondax/ledger-js@1.0.1", "@zondax/ledger-js@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@zondax/ledger-js/-/ledger-js-1.0.1.tgz#a1c51943c5b7d1370cea588b193197234485d196" integrity sha512-9h+aIXyEK+Rdic5Ppsmq+tptDFwPTacG1H6tpZHFdhtBFHYFOLLkKTTmq5rMTv84aAPS1v0tnsF1e2Il6M05Cg==