diff --git a/ironfish-cli/package.json b/ironfish-cli/package.json index 0e143eec4c..cfff8cf806 100644 --- a/ironfish-cli/package.json +++ b/ironfish-cli/package.json @@ -1,6 +1,6 @@ { "name": "ironfish", - "version": "2.6.0", + "version": "2.7.0", "description": "CLI for running and interacting with an Iron Fish node", "author": "Iron Fish (https://ironfish.network)", "main": "build/src/index.js", @@ -59,8 +59,8 @@ "oclif:version": "oclif readme && git add README.md" }, "dependencies": { - "@ironfish/rust-nodejs": "2.6.0", - "@ironfish/sdk": "2.6.0", + "@ironfish/rust-nodejs": "2.7.0", + "@ironfish/sdk": "2.7.0", "@ledgerhq/hw-transport-node-hid": "6.29.1", "@oclif/core": "4.0.11", "@oclif/plugin-help": "6.2.5", @@ -69,6 +69,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", "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 879839afc2..bcce482551 100644 --- a/ironfish-cli/src/commands/wallet/chainport/send.ts +++ b/ironfish-cli/src/commands/wallet/chainport/send.ts @@ -2,10 +2,10 @@ * 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 { Asset } from '@ironfish/rust-nodejs' import { CreateTransactionRequest, CurrencyUtils, + MAINNET, RawTransaction, RawTransactionSerde, RpcAsset, @@ -84,8 +84,8 @@ export class BridgeCommand extends IronfishCommand { const networkId = (await client.chain.getNetworkInfo()).content.networkId - if (networkId !== TESTNET.id) { - this.error(`Chainport transactions are only available on testnet.`) + if (networkId !== TESTNET.id && networkId !== MAINNET.id) { + this.error(`Chainport transactions are only available on testnet and mainnet.`) } if (!flags.offline) { @@ -184,22 +184,31 @@ export class BridgeCommand extends IronfishCommand { const tokens = await fetchChainportVerifiedTokens(networkId) - if (assetId == null) { + const tokenNames = tokens.map( + (t, index) => `${index + 1}. ${t.name} (${t.symbol}) - ${t.web3_address}`, + ) + + if (!assetId) { const asset = await ui.assetPrompt(client, from, { action: 'send', showNativeAsset: true, showNonCreatorAsset: true, - showSingleAssetChoice: false, + showSingleAssetChoice: true, filter: (asset) => { return tokens.some((t) => t.web3_address === asset.id) }, }) - assetId = asset?.id - - if (!assetId) { - assetId = Asset.nativeId().toString('hex') + if (!asset) { + this.logger.error( + `No supported Chainport asset found for this account. Here are the supported tokens: \n\n${tokenNames.join( + '\n', + )}\n`, + ) + this.exit(1) } + + assetId = asset.id } const asset: ChainportVerifiedToken | undefined = tokens.find( @@ -207,15 +216,12 @@ export class BridgeCommand extends IronfishCommand { ) if (!asset) { - const names = tokens.map( - (t, index) => `${index + 1}. ${t.name} (${t.symbol}) - ${t.web3_address}`, - ) - - this.error( - `Asset ${assetId} not supported by Chainport. Here are the supported tokens: \n\n${names.join( + this.logger.error( + `Asset ${assetId} not supported by Chainport. Here are the supported tokens: \n\n${tokenNames.join( '\n', )}\n`, ) + this.exit(1) } const targetNetworks = asset.target_networks diff --git a/ironfish-cli/src/commands/wallet/decrypt.ts b/ironfish-cli/src/commands/wallet/decrypt.ts index 177fc07ef6..8e512cecc7 100644 --- a/ironfish-cli/src/commands/wallet/decrypt.ts +++ b/ironfish-cli/src/commands/wallet/decrypt.ts @@ -8,8 +8,6 @@ import { RemoteFlags } from '../../flags' import { inputPrompt } from '../../ui' export class DecryptCommand extends IronfishCommand { - static hidden = true - static description = 'decrypt accounts in the wallet' static flags = { diff --git a/ironfish-cli/src/commands/wallet/encrypt.ts b/ironfish-cli/src/commands/wallet/encrypt.ts index 2573ceb8c1..018febb7ba 100644 --- a/ironfish-cli/src/commands/wallet/encrypt.ts +++ b/ironfish-cli/src/commands/wallet/encrypt.ts @@ -8,8 +8,6 @@ import { RemoteFlags } from '../../flags' import { inputPrompt } from '../../ui' export class EncryptCommand extends IronfishCommand { - static hidden = true - static description = 'encrypt accounts in the wallet' static flags = { diff --git a/ironfish-cli/src/commands/wallet/import.ts b/ironfish-cli/src/commands/wallet/import.ts index 4ff92863f9..7c8e54badf 100644 --- a/ironfish-cli/src/commands/wallet/import.ts +++ b/ironfish-cli/src/commands/wallet/import.ts @@ -1,18 +1,14 @@ /* 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 { - AccountFormat, - encodeAccountImport, - RPC_ERROR_CODES, - RpcRequestError, -} from '@ironfish/sdk' +import { AccountFormat, encodeAccountImport } from '@ironfish/sdk' import { Args, Flags, ux } from '@oclif/core' import { IronfishCommand } from '../../command' import { RemoteFlags } from '../../flags' import { checkWalletUnlocked, inputPrompt } from '../../ui' import { importFile, importPipe, longPrompt } from '../../ui/longPrompt' -import { Ledger } from '../../utils/ledger' +import { importAccount } from '../../utils' +import { Ledger, LedgerError } from '../../utils/ledger' export class ImportCommand extends IronfishCommand { static description = `import an account` @@ -102,49 +98,15 @@ export class ImportCommand extends IronfishCommand { flags.name = name } - let result - - while (!result) { - try { - result = await client.wallet.importAccount({ - account, - rescan: flags.rescan, - name: flags.name, - createdAt: flags.createdAt, - }) - } catch (e) { - if ( - e instanceof RpcRequestError && - (e.code === RPC_ERROR_CODES.DUPLICATE_ACCOUNT_NAME.toString() || - e.code === RPC_ERROR_CODES.IMPORT_ACCOUNT_NAME_REQUIRED.toString() || - e.code === RPC_ERROR_CODES.DUPLICATE_IDENTITY_NAME.toString()) - ) { - const message = 'Enter a name for the account' - - if (e.code === RPC_ERROR_CODES.DUPLICATE_ACCOUNT_NAME.toString()) { - this.log() - this.log(e.codeMessage) - } - - if (e.code === RPC_ERROR_CODES.DUPLICATE_IDENTITY_NAME.toString()) { - this.log() - this.log(e.codeMessage) - } - - const name = await inputPrompt(message, true) - if (name === flags.name) { - this.error(`Entered the same name: '${name}'`) - } - - flags.name = name - continue - } - - throw e - } - } + const { name, isDefaultAccount } = await importAccount( + client, + account, + this.logger, + flags.name, + flags.createdAt, + flags.rescan, + ) - const { name, isDefaultAccount } = result.content this.log(`Account ${name} imported.`) if (isDefaultAccount) { @@ -161,8 +123,9 @@ export class ImportCommand extends IronfishCommand { const account = await ledger.importAccount() return encodeAccountImport(account, AccountFormat.Base64Json) } catch (e) { - if (e instanceof Error) { - this.error(e.message) + if (e instanceof LedgerError) { + this.logger.error(e.message + '\n') + this.exit(1) } else { this.error('Unknown error while importing account from ledger device.') } diff --git a/ironfish-cli/src/commands/wallet/lock.ts b/ironfish-cli/src/commands/wallet/lock.ts index 3fc5ce43a1..763f8c8f0c 100644 --- a/ironfish-cli/src/commands/wallet/lock.ts +++ b/ironfish-cli/src/commands/wallet/lock.ts @@ -6,8 +6,6 @@ import { IronfishCommand } from '../../command' import { RemoteFlags } from '../../flags' export class LockCommand extends IronfishCommand { - static hidden = true - static description = 'lock accounts in the wallet' static flags = { diff --git a/ironfish-cli/src/commands/wallet/mint.ts b/ironfish-cli/src/commands/wallet/mint.ts index 9981524248..bcea04220d 100644 --- a/ironfish-cli/src/commands/wallet/mint.ts +++ b/ironfish-cli/src/commands/wallet/mint.ts @@ -23,6 +23,7 @@ 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 { @@ -96,12 +97,22 @@ This will create tokens and increase supply for a given asset.` transferOwnershipTo: Flags.string({ description: 'The public address of the account to transfer ownership of this asset to.', }), + transferTo: Flags.string({ + description: 'transfer all newly minted coins to this public address', + }), + transferToMemo: Flags.string({ + description: 'The memo of transfer when using transferTo', + }), 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', exclusive: ['rawTransaction'], }), + ledger: Flags.boolean({ + default: false, + description: 'Mint a transaction using a Ledger device', + }), } async start(): Promise { @@ -140,7 +151,7 @@ This will create tokens and increase supply for a given asset.` name = await ui.inputPrompt('Enter the name for the new asset', true) } - if (!metadata) { + if (metadata == null) { metadata = await ui.inputPrompt('Enter metadata for the new asset') } @@ -213,6 +224,12 @@ This will create tokens and increase supply for a given asset.` } } + if (flags.transferTo) { + if (!isValidPublicAddress(flags.transferTo)) { + this.error('transferTo must be a valid public address') + } + } + let expiration = flags.expiration if ((flags.rawTransaction || flags.unsignedTransaction) && expiration === undefined) { expiration = await promptExpiration({ logger: this.logger, client: client }) @@ -223,25 +240,34 @@ This will create tokens and increase supply for a given asset.` this.exit(1) } + const mint = { + // Only provide the asset id if we are not minting an asset for the first time + ...(assetData != null ? { assetId } : {}), + name: name, + metadata: metadata, + value: CurrencyUtils.encode(amount), + transferOwnershipTo: flags.transferOwnershipTo, + } + const params: CreateTransactionRequest = { account, outputs: [], - mints: [ - { - // Only provide the asset id if we are not minting an asset for the first time - ...(assetData != null ? { assetId } : {}), - name, - metadata, - value: CurrencyUtils.encode(amount), - transferOwnershipTo: flags.transferOwnershipTo, - }, - ], + mints: [mint], fee: flags.fee ? CurrencyUtils.encode(flags.fee) : null, feeRate: flags.feeRate ? CurrencyUtils.encode(flags.feeRate) : null, expiration: expiration, confirmations: flags.confirmations, } + if (flags.transferTo) { + params.outputs.push({ + publicAddress: flags.transferTo, + amount: mint.value, + assetId: assetId, + memo: flags.transferToMemo, + }) + } + let raw: RawTransaction if (params.fee === null && params.feeRate === null) { raw = await selectFee({ @@ -282,10 +308,23 @@ This will create tokens and increase supply for a given asset.` name, metadata, flags.transferOwnershipTo, + flags.transferTo, flags.confirm, assetData, ) + if (flags.ledger) { + await sendTransactionWithLedger( + client, + raw, + account, + flags.watch, + flags.confirm, + this.logger, + ) + this.exit(0) + } + ux.action.start('Sending the transaction') const response = await client.wallet.postTransaction({ @@ -324,6 +363,7 @@ This will create tokens and increase supply for a given asset.` Value: renderedValue, Fee: renderedFee, Hash: transaction.hash().toString('hex'), + 'Transfered To': flags.transferTo ? flags.transferTo : undefined, }), ) @@ -356,6 +396,7 @@ This will create tokens and increase supply for a given asset.` name?: string, metadata?: string, transferOwnershipTo?: string, + transferTo?: string, confirm?: boolean, assetData?: RpcAsset, ): Promise { @@ -376,6 +417,17 @@ This will create tokens and increase supply for a given asset.` `Fee: ${renderedFee}`, ] + if (transferTo) { + confirmMessage.push( + `\nAll ${CurrencyUtils.render( + amount, + false, + assetId, + assetData?.verification, + )} of this asset will be transferred to ${transferTo}.`, + ) + } + if (transferOwnershipTo) { confirmMessage.push( `Ownership of this asset will be transferred to ${transferOwnershipTo}. The current account will no longer have any permission to mint or modify this asset. This cannot be undone.`, diff --git a/ironfish-cli/src/commands/wallet/multisig/account/participants.ts b/ironfish-cli/src/commands/wallet/multisig/account/participants.ts index 1c7cba014f..3896f54087 100644 --- a/ironfish-cli/src/commands/wallet/multisig/account/participants.ts +++ b/ironfish-cli/src/commands/wallet/multisig/account/participants.ts @@ -23,11 +23,29 @@ export class MultisigAccountParticipants extends IronfishCommand { const client = await this.connectRpc() await ui.checkWalletUnlocked(client) - const response = await client.wallet.multisig.getAccountIdentities({ - account: flags.account, - }) + const accountIdentities = ( + await client.wallet.multisig.getAccountIdentities({ + account: flags.account, + }) + ).content.identities - for (const identity of response.content.identities) { + const participants = (await client.wallet.multisig.getIdentities()).content.identities + + const matchingIdentities = participants.filter((identity) => + accountIdentities.includes(identity.identity), + ) + + let participant: string | undefined + if (matchingIdentities.length === 1) { + participant = matchingIdentities[0].identity + this.log(`Your identity:\n${participant}`) + this.log('\nOther participating identities:') + } + + for (const identity of accountIdentities) { + if (participant && participant === identity) { + continue + } this.log(identity) } } diff --git a/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts b/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts index 8f3a3614c1..a78872b9fa 100644 --- a/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/commitment/create.ts @@ -1,11 +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 { UnsignedTransaction } from '@ironfish/sdk' +import { multisig } from '@ironfish/rust-nodejs' +import { RpcClient, UnsignedTransaction } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' import * as ui from '../../../../ui' +import { LedgerDkg } from '../../../../utils/ledger' import { MultisigTransactionJson } from '../../../../utils/multisig' import { renderUnsignedTransactionDetails } from '../../../../utils/transaction' @@ -36,6 +38,10 @@ export class CreateSigningCommitmentCommand extends IronfishCommand { path: Flags.string({ description: 'Path to a JSON file containing multisig transaction data', }), + ledger: Flags.boolean({ + default: false, + description: 'Create signing commitment using a Ledger device', + }), } async start(): Promise { @@ -47,6 +53,11 @@ export class CreateSigningCommitmentCommand extends IronfishCommand { const client = await this.connectRpc() await ui.checkWalletUnlocked(client) + let participantName = flags.account + if (!participantName) { + participantName = await ui.multisigSecretPrompt(client) + } + let identities = options.identity if (!identities || identities.length < 2) { const input = await ui.longPrompt( @@ -77,14 +88,24 @@ export class CreateSigningCommitmentCommand extends IronfishCommand { await renderUnsignedTransactionDetails( client, unsignedTransaction, - flags.account, + participantName, this.logger, ) await ui.confirmOrQuit('Confirm signing commitment creation', flags.confirm) + if (flags.ledger) { + await this.createSigningCommitmentWithLedger( + client, + participantName, + unsignedTransaction, + identities, + ) + return + } + const response = await client.wallet.multisig.createSigningCommitment({ - account: flags.account, + account: participantName, unsignedTransaction: unsignedTransactionInput, signers: identities.map((identity) => ({ identity })), }) @@ -96,4 +117,45 @@ export class CreateSigningCommitmentCommand extends IronfishCommand { this.log('Next step:') this.log('Send the commitment to the multisig account coordinator.') } + + async createSigningCommitmentWithLedger( + client: RpcClient, + participantName: string, + unsignedTransaction: UnsignedTransaction, + signers: string[], + ): 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 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 signingCommitment = multisig.SigningCommitment.fromRaw( + identity, + rawCommitments, + transactionHash, + signers, + ) + + this.log('\nCommitment:\n') + this.log(signingCommitment.serialize().toString('hex')) + + this.log() + this.log('Next step:') + this.log('Send the commitment to the multisig account coordinator.') + } } diff --git a/ironfish-cli/src/commands/wallet/multisig/dealer/create.ts b/ironfish-cli/src/commands/wallet/multisig/dealer/create.ts index 095ebc0ed7..e56e8eeda0 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dealer/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dealer/create.ts @@ -1,8 +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/. */ -import { ACCOUNT_SCHEMA_VERSION, JsonEncoder, RpcClient } from '@ironfish/sdk' -import { AccountImport } from '@ironfish/sdk/src/wallet/exporter' +import { ACCOUNT_SCHEMA_VERSION, AccountImport, JsonEncoder, RpcClient } from '@ironfish/sdk' import { Flags, ux } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' diff --git a/ironfish-cli/src/commands/wallet/multisig/dkg/create.ts b/ironfish-cli/src/commands/wallet/multisig/dkg/create.ts new file mode 100644 index 0000000000..12377e2ead --- /dev/null +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/create.ts @@ -0,0 +1,570 @@ +/* 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 { + deserializePublicPackage, + deserializeRound2CombinedPublicPackage, +} from '@ironfish/rust-nodejs' +import { + ACCOUNT_SCHEMA_VERSION, + AccountFormat, + Assert, + encodeAccountImport, + RpcClient, +} from '@ironfish/sdk' +import { Flags } from '@oclif/core' +import { IronfishCommand } from '../../../../command' +import { RemoteFlags } from '../../../../flags' +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' + + static flags = { + ...RemoteFlags, + participant: Flags.string({ + char: 'n', + description: 'The name of the secret to use for encryption during DKG', + }), + name: Flags.string({ + char: 'a', + description: 'The name to set for multisig account to be created', + }), + ledger: Flags.boolean({ + default: false, + description: 'Perform operation with a ledger device', + }), + createdAt: Flags.integer({ + description: + "Block sequence to begin scanning from for the created account. Uses node's chain head by default", + }), + } + + async start(): Promise { + const { flags } = await this.parse(DkgCreateCommand) + const client = await this.connectRpc() + await ui.checkWalletUnlocked(client) + + let ledger: LedgerDkg | 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 + } + } + } + + const accountName = await this.getAccountName(client, flags.name) + + let accountCreatedAt = flags.createdAt + if (!accountCreatedAt) { + const statusResponse = await client.node.getStatus() + 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) + + this.log(`Identity for ${participantName}: \n${identity} \n`) + + const { round1, totalParticipants } = await ui.retryStep( + async () => { + return this.performRound1(client, participantName, identity, ledger) + }, + 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============================================') + + this.log('\nShare your Round 1 Public Package with other participants.') + + const { round2: round2Result, round1PublicPackages } = await ui.retryStep( + async () => { + return this.performRound2(client, 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, + accountName, + participantName, + round2Result, + round1PublicPackages, + totalParticipants, + ledger, + accountCreatedAt, + ) + }, + this.logger, + true, + ) + + this.log('Multisig account created successfully using DKG!') + } + + private async getParticipant(client: RpcClient, participantName?: string) { + const identities = (await client.wallet.multisig.getIdentities()).content.identities + + if (participantName) { + const foundIdentity = identities.find((i) => i.name === participantName) + if (!foundIdentity) { + throw new Error(`Participant with name ${participantName} not found`) + } + + return { + name: foundIdentity.name, + identity: foundIdentity.identity, + } + } + + const name = await ui.inputPrompt('Enter the name of the participant', true) + const foundIdentity = identities.find((i) => i.name === name) + + if (foundIdentity) { + this.log('Found an identity with the same name') + + return { + ...foundIdentity, + } + } + + const identity = (await client.wallet.multisig.createParticipant({ name })).content.identity + + return { + name, + identity, + } + } + + 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) + } + + const accounts = (await client.wallet.getAccounts()).content.accounts + + 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) + } + + return name + } + + 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) + + const allIdentities = (await client.wallet.multisig.getIdentities()).content.identities + + const foundIdentity = allIdentities.find((i) => i.identity === identity.toString('hex')) + + if (foundIdentity) { + this.log(`Identity already exists with name: ${foundIdentity.name}`) + + return { + name: foundIdentity.name, + identity: identity.toString('hex'), + } + } + + name = await ui.inputPrompt('Enter a name for the identity', 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) + } + + await client.wallet.multisig.importParticipant({ + name, + identity: identity.toString('hex'), + }) + + return { + name, + identity: identity.toString('hex'), + } + } + + async createParticipant( + client: RpcClient, + name: string, + ): Promise<{ + name: string + identity: string + }> { + const identity = (await client.wallet.multisig.createParticipant({ name })).content.identity + return { + name, + identity, + } + } + + async performRound1WithLedger( + ledger: LedgerDkg, + client: RpcClient, + participantName: string, + identities: string[], + minSigners: number, + ): Promise<{ + round1: { secretPackage: string; publicPackage: string } + }> { + const identityResponse = await client.wallet.multisig.getIdentity({ name: participantName }) + const identity = identityResponse.content.identity + + if (!identities.includes(identity)) { + identities.push(identity) + } + + // TODO(hughy): determine how to handle multiple identities using index + const { publicPackage, secretPackage } = await ledger.dkgRound1(0, identities, minSigners) + + return { + round1: { + secretPackage: secretPackage.toString('hex'), + publicPackage: publicPackage.toString('hex'), + }, + } + } + + async performRound1( + client: RpcClient, + participantName: string, + currentIdentity: string, + ledger: LedgerDkg | 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') + } + + 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) `, + ) + const identities = await ui.collectStrings('Participant Identity', totalParticipants - 1, { + additionalStrings: [currentIdentity], + errorOnDuplicate: true, + }) + + const minSigners = await ui.inputNumberPrompt( + this.logger, + 'Enter the number of minimum signers', + { required: true, integer: true }, + ) + + if (minSigners < 2 || minSigners > totalParticipants) { + throw new Error( + 'Minimum number of signers must be between 2 and the total number of participants', + ) + } + + if (ledger) { + const result = await this.performRound1WithLedger( + ledger, + client, + participantName, + identities, + minSigners, + ) + + return { + ...result, + totalParticipants, + } + } + + this.log('\nPerforming DKG Round 1...') + const response = await client.wallet.multisig.dkg.round1({ + participantName, + participants: identities.map((identity) => ({ identity })), + minSigners, + }) + + return { + round1: { + secretPackage: response.content.round1SecretPackage, + publicPackage: response.content.round1PublicPackage, + }, + totalParticipants, + } + } + + async performRound2WithLedger( + ledger: LedgerDkg, + 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, + ) + + return { + round2: { + secretPackage: secretPackage.toString('hex'), + publicPackage: publicPackage.toString('hex'), + }, + } + } + + async performRound2( + client: RpcClient, + participantName: string, + round1Result: { secretPackage: string; publicPackage: string }, + totalParticipants: number, + ledger: LedgerDkg | undefined, + ): Promise<{ + round2: { secretPackage: string; publicPackage: string } + round1PublicPackages: string[] + }> { + this.log(`\nEnter ${totalParticipants - 1} Round 1 Public Packages (excluding yours) `) + + const round1PublicPackages = await ui.collectStrings( + 'Round 1 Public Package', + totalParticipants - 1, + { + additionalStrings: [round1Result.publicPackage], + errorOnDuplicate: true, + }, + ) + + this.log('\nPerforming DKG Round 2...') + + if (ledger) { + const result = await this.performRound2WithLedger( + ledger, + round1PublicPackages, + round1Result.secretPackage, + ) + return { + ...result, + round1PublicPackages, + } + } + + const response = await client.wallet.multisig.dkg.round2({ + participantName, + round1SecretPackage: round1Result.secretPackage, + round1PublicPackages, + }) + + return { + round2: { + secretPackage: response.content.round2SecretPackage, + publicPackage: response.content.round2PublicPackage, + }, + round1PublicPackages, + } + } + + async performRound3WithLedger( + ledger: LedgerDkg, + client: RpcClient, + accountName: string, + participantName: string, + round1PublicPackagesStr: string[], + round2PublicPackagesStr: string[], + round2SecretPackage: string, + accountCreatedAt?: number, + ): Promise { + const identityResponse = await client.wallet.multisig.getIdentity({ name: participantName }) + const identity = identityResponse.content.identity + + // Sort packages by identity + const round1PublicPackages = round1PublicPackagesStr + .map(deserializePublicPackage) + .sort((a, b) => a.identity.localeCompare(b.identity)) + + // Filter out packages not intended for participant and sort by sender identity + const round2CombinedPublicPackages = round2PublicPackagesStr.map( + deserializeRound2CombinedPublicPackage, + ) + const round2PublicPackages = round2CombinedPublicPackages + .flatMap((combined) => + combined.packages.filter((pkg) => pkg.recipientIdentity === identity), + ) + .sort((a, b) => a.senderIdentity.localeCompare(b.senderIdentity)) + + // Extract raw parts from round1 and round2 public packages + const participants = [] + const round1FrostPackages = [] + const gskBytes = [] + for (const pkg of round1PublicPackages) { + // Exclude participant's own identity and round1 public package + if (pkg.identity !== identity) { + participants.push(pkg.identity) + round1FrostPackages.push(pkg.frostPackage) + } + + gskBytes.push(pkg.groupSecretKeyShardEncrypted) + } + + const round2FrostPackages = round2PublicPackages.map((pkg) => pkg.frostPackage) + + // Perform round3 with Ledger + await ledger.dkgRound3( + 0, + participants, + round1FrostPackages, + round2FrostPackages, + round2SecretPackage, + gskBytes, + ) + + // Retrieve all multisig account keys and publicKeyPackage + const dkgKeys = await ledger.dkgRetrieveKeys() + + const publicKeyPackage = await ledger.dkgGetPublicPackage() + + const accountImport = { + ...dkgKeys, + multisigKeys: { + publicKeyPackage: publicKeyPackage.toString('hex'), + identity, + }, + version: ACCOUNT_SCHEMA_VERSION, + name: accountName, + createdAt: null, + spendingKey: null, + } + + // Import multisig account + const response = await client.wallet.importAccount({ + account: encodeAccountImport(accountImport, AccountFormat.Base64Json), + createdAt: accountCreatedAt, + }) + + this.log() + 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, + accountName: string, + participantName: string, + round2Result: { secretPackage: string; publicPackage: string }, + round1PublicPackages: string[], + totalParticipants: number, + ledger: LedgerDkg | undefined, + accountCreatedAt?: number, + ): Promise { + this.log(`\nEnter ${totalParticipants - 1} Round 2 Public Packages (excluding yours) `) + + const round2PublicPackages = await ui.collectStrings( + 'Round 2 Public Package', + totalParticipants - 1, + { + additionalStrings: [round2Result.publicPackage], + errorOnDuplicate: true, + }, + ) + + if (ledger) { + await this.performRound3WithLedger( + ledger, + client, + accountName, + participantName, + round1PublicPackages, + round2PublicPackages, + round2Result.secretPackage, + accountCreatedAt, + ) + return + } + + const response = await client.wallet.multisig.dkg.round3({ + participantName: participantName, + accountName: accountName, + round2SecretPackage: round2Result.secretPackage, + round1PublicPackages, + round2PublicPackages, + }) + + 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 b26085cf0c..8b8c9d195c 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dkg/round1.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/round1.ts @@ -1,11 +1,12 @@ /* 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 { RpcClient } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' import * as ui from '../../../../ui' -import { Ledger } from '../../../../utils/ledger' +import { LedgerDkg } from '../../../../utils/ledger' export class DkgRound1Command extends IronfishCommand { static description = 'Perform round1 of the DKG protocol for multisig account creation' @@ -71,7 +72,7 @@ export class DkgRound1Command extends IronfishCommand { } if (flags.ledger) { - await this.performRound1WithLedger() + await this.performRound1WithLedger(client, participantName, identities, minSigners) return } @@ -93,8 +94,13 @@ export class DkgRound1Command extends IronfishCommand { this.log('Send the round 1 public package to each participant') } - async performRound1WithLedger(): Promise { - const ledger = new Ledger(this.logger) + async performRound1WithLedger( + client: RpcClient, + participantName: string, + identities: string[], + minSigners: number, + ): Promise { + const ledger = new LedgerDkg(this.logger) try { await ledger.connect() } catch (e) { @@ -104,5 +110,26 @@ export class DkgRound1Command extends IronfishCommand { throw e } } + + const identityResponse = await client.wallet.multisig.getIdentity({ name: participantName }) + const identity = identityResponse.content.identity + + if (!identities.includes(identity)) { + identities.push(identity) + } + + // TODO(hughy): determine how to handle multiple identities using index + const { publicPackage, secretPackage } = await ledger.dkgRound1(0, identities, minSigners) + + this.log('\nRound 1 Encrypted Secret Package:\n') + this.log(secretPackage.toString('hex')) + this.log() + + this.log('\nRound 1 Public Package:\n') + this.log(publicPackage.toString('hex')) + this.log() + + this.log('Next step:') + this.log('Send the round 1 public package to each participant') } } diff --git a/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts b/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts index d2b5b4027b..f7f569bcf0 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/round2.ts @@ -5,7 +5,7 @@ import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' import * as ui from '../../../../ui' -import { Ledger } from '../../../../utils/ledger' +import { LedgerDkg } from '../../../../utils/ledger' export class DkgRound2Command extends IronfishCommand { static description = 'Perform round2 of the DKG protocol for multisig account creation' @@ -70,7 +70,7 @@ export class DkgRound2Command extends IronfishCommand { round1PublicPackages = round1PublicPackages.map((i) => i.trim()) if (flags.ledger) { - await this.performRound2WithLedger() + await this.performRound2WithLedger(round1PublicPackages, round1SecretPackage) return } @@ -93,8 +93,11 @@ export class DkgRound2Command extends IronfishCommand { this.log('Send the round 2 public package to each participant') } - async performRound2WithLedger(): Promise { - const ledger = new Ledger(this.logger) + async performRound2WithLedger( + round1PublicPackages: string[], + round1SecretPackage: string, + ): Promise { + const ledger = new LedgerDkg(this.logger) try { await ledger.connect() } catch (e) { @@ -104,5 +107,24 @@ export class DkgRound2Command extends IronfishCommand { throw e } } + + // TODO(hughy): determine how to handle multiple identities using index + const { publicPackage, secretPackage } = await ledger.dkgRound2( + 0, + round1PublicPackages, + round1SecretPackage, + ) + + this.log('\nRound 2 Encrypted Secret Package:\n') + this.log(secretPackage.toString('hex')) + this.log() + + this.log('\nRound 2 Public Package:\n') + this.log(publicPackage.toString('hex')) + this.log() + + this.log() + this.log('Next step:') + this.log('Send the round 2 public package to each participant') } } diff --git a/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts b/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts index 12034755fd..7d4bc8083a 100644 --- a/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts +++ b/ironfish-cli/src/commands/wallet/multisig/dkg/round3.ts @@ -1,11 +1,22 @@ /* 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 { + deserializePublicPackage, + deserializeRound2CombinedPublicPackage, +} from '@ironfish/rust-nodejs' +import { + ACCOUNT_SCHEMA_VERSION, + AccountFormat, + encodeAccountImport, + RpcClient, +} from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' import * as ui from '../../../../ui' -import { Ledger } from '../../../../utils/ledger' +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' @@ -42,6 +53,10 @@ export class DkgRound3Command extends IronfishCommand { description: 'Perform operation with a ledger device', hidden: true, }), + createdAt: Flags.integer({ + description: + "Block sequence to begin scanning from for the created account. Uses node's chain head by default.", + }), } async start(): Promise { @@ -106,8 +121,21 @@ export class DkgRound3Command extends IronfishCommand { } round2PublicPackages = round2PublicPackages.map((i) => i.trim()) + let accountCreatedAt = flags.createdAt + if (!accountCreatedAt) { + const statusResponse = await client.node.getStatus() + accountCreatedAt = statusResponse.content.blockchain.head.sequence + } + if (flags.ledger) { - await this.performRound3WithLedger() + await this.performRound3WithLedger( + client, + participantName, + round1PublicPackages, + round2PublicPackages, + round2SecretPackage, + accountCreatedAt, + ) return } @@ -117,6 +145,7 @@ export class DkgRound3Command extends IronfishCommand { round2SecretPackage, round1PublicPackages, round2PublicPackages, + accountCreatedAt, }) this.log() @@ -125,8 +154,15 @@ export class DkgRound3Command extends IronfishCommand { ) } - async performRound3WithLedger(): Promise { - const ledger = new Ledger(this.logger) + async performRound3WithLedger( + client: RpcClient, + participantName: string, + round1PublicPackagesStr: string[], + round2PublicPackagesStr: string[], + round2SecretPackage: string, + accountCreatedAt?: number, + ): Promise { + const ledger = new LedgerDkg(this.logger) try { await ledger.connect() } catch (e) { @@ -136,5 +172,93 @@ export class DkgRound3Command extends IronfishCommand { throw e } } + + const identityResponse = await client.wallet.multisig.getIdentity({ name: participantName }) + const identity = identityResponse.content.identity + + // Sort packages by identity + const round1PublicPackages = round1PublicPackagesStr + .map(deserializePublicPackage) + .sort((a, b) => a.identity.localeCompare(b.identity)) + + // Filter out packages not intended for participant and sort by sender identity + const round2CombinedPublicPackages = round2PublicPackagesStr.map( + deserializeRound2CombinedPublicPackage, + ) + const round2PublicPackages = round2CombinedPublicPackages + .flatMap((combined) => + combined.packages.filter((pkg) => pkg.recipientIdentity === identity), + ) + .sort((a, b) => a.senderIdentity.localeCompare(b.senderIdentity)) + + // Extract raw parts from round1 and round2 public packages + const participants = [] + const round1FrostPackages = [] + const gskBytes = [] + for (const pkg of round1PublicPackages) { + // Exclude participant's own identity and round1 public package + if (pkg.identity !== identity) { + participants.push(pkg.identity) + round1FrostPackages.push(pkg.frostPackage) + } + + gskBytes.push(pkg.groupSecretKeyShardEncrypted) + } + + const round2FrostPackages = round2PublicPackages.map((pkg) => pkg.frostPackage) + + // Perform round3 with Ledger + await ledger.dkgRound3( + 0, + participants, + round1FrostPackages, + round2FrostPackages, + round2SecretPackage, + gskBytes, + ) + + // Retrieve all multisig account keys and publicKeyPackage + const dkgKeys = await ledger.dkgRetrieveKeys() + + const publicKeyPackage = await ledger.dkgGetPublicPackage() + + const accountImport = { + ...dkgKeys, + multisigKeys: { + publicKeyPackage: publicKeyPackage.toString('hex'), + identity, + }, + version: ACCOUNT_SCHEMA_VERSION, + name: participantName, + spendingKey: null, + createdAt: null, + } + + // Import multisig account + const { name } = await importAccount( + client, + encodeAccountImport(accountImport, AccountFormat.Base64Json), + this.logger, + participantName, + accountCreatedAt, + ) + + this.log() + this.log(`Account ${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.', + ) } } diff --git a/ironfish-cli/src/commands/wallet/multisig/ledger/backup.ts b/ironfish-cli/src/commands/wallet/multisig/ledger/backup.ts new file mode 100644 index 0000000000..a2b26435b5 --- /dev/null +++ b/ironfish-cli/src/commands/wallet/multisig/ledger/backup.ts @@ -0,0 +1,33 @@ +/* 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 { IronfishCommand } from '../../../../command' +import { LedgerDkg } from '../../../../utils/ledger' + +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 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.', + ) + } +} diff --git a/ironfish-cli/src/commands/wallet/multisig/ledger/import.ts b/ironfish-cli/src/commands/wallet/multisig/ledger/import.ts new file mode 100644 index 0000000000..1641e382c5 --- /dev/null +++ b/ironfish-cli/src/commands/wallet/multisig/ledger/import.ts @@ -0,0 +1,72 @@ +/* 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, AccountFormat, encodeAccountImport } from '@ironfish/sdk' +import { Flags } from '@oclif/core' +import { IronfishCommand } from '../../../../command' +import { RemoteFlags } from '../../../../flags' +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` + + static flags = { + ...RemoteFlags, + name: Flags.string({ + description: 'Name to use for the account', + char: 'n', + }), + createdAt: Flags.integer({ + description: 'Block sequence to begin scanning from for the imported account', + }), + } + + async start(): Promise { + const { flags } = await this.parse(MultisigLedgerImport) + + const client = await this.connectRpc() + await ui.checkWalletUnlocked(client) + + const name = flags.name ?? (await ui.inputPrompt('Enter a name for the account', 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 identity = await ledger.dkgGetIdentity(0) + const dkgKeys = await ledger.dkgRetrieveKeys() + const publicKeyPackage = await ledger.dkgGetPublicPackage() + + const accountImport = { + ...dkgKeys, + multisigKeys: { + publicKeyPackage: publicKeyPackage.toString('hex'), + identity: identity.toString('hex'), + }, + version: ACCOUNT_SCHEMA_VERSION, + name, + spendingKey: null, + createdAt: null, + } + + const { name: accountName } = await importAccount( + client, + encodeAccountImport(accountImport, AccountFormat.Base64Json), + this.logger, + name, + flags.createdAt, + ) + + this.log() + this.log(`Account ${accountName} imported with public address: ${dkgKeys.publicAddress}`) + } +} diff --git a/ironfish-cli/src/commands/wallet/multisig/ledger/restore.ts b/ironfish-cli/src/commands/wallet/multisig/ledger/restore.ts new file mode 100644 index 0000000000..cad4c91e16 --- /dev/null +++ b/ironfish-cli/src/commands/wallet/multisig/ledger/restore.ts @@ -0,0 +1,45 @@ +/* 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 { Args } from '@oclif/core' +import { IronfishCommand } from '../../../../command' +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` + + static args = { + backup: Args.string({ + required: false, + description: 'Encrypted multisig key backup from your Ledger device', + }), + } + + async start(): Promise { + const { args } = await this.parse(MultisigLedgerRestore) + + let encryptedKeys = args.backup + if (!encryptedKeys) { + encryptedKeys = await ui.longPrompt( + 'Enter the encrypted multisig key backup to restore to your Ledger device', + ) + } + + const ledger = new LedgerDkg(this.logger) + try { + await ledger.connect() + } catch (e) { + if (e instanceof Error) { + this.error(e.message) + } else { + throw e + } + } + + await 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 1621686631..3630fcb43c 100644 --- a/ironfish-cli/src/commands/wallet/multisig/participant/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/participant/create.ts @@ -6,6 +6,7 @@ import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' import * as ui from '../../../../ui' +import { LedgerDkg } from '../../../../utils/ledger' export class MultisigIdentityCreate extends IronfishCommand { static description = `Create a multisig participant identity` @@ -16,6 +17,11 @@ export class MultisigIdentityCreate extends IronfishCommand { char: 'n', description: 'Name to associate with the identity', }), + ledger: Flags.boolean({ + default: false, + description: 'Perform operation with a ledger device', + hidden: true, + }), } async start(): Promise { @@ -29,14 +35,27 @@ export class MultisigIdentityCreate extends IronfishCommand { name = await ui.inputPrompt('Enter a name for the identity', true) } + let identity + if (flags.ledger) { + identity = await this.getIdentityFromLedger() + } + let response while (!response) { try { - response = await client.wallet.multisig.createParticipant({ name }) + if (identity) { + response = await client.wallet.multisig.importParticipant({ + name, + identity: identity.toString('hex'), + }) + } else { + response = await client.wallet.multisig.createParticipant({ name }) + } } catch (e) { if ( e instanceof RpcRequestError && - e.code === RPC_ERROR_CODES.DUPLICATE_ACCOUNT_NAME.toString() + (e.code === RPC_ERROR_CODES.DUPLICATE_ACCOUNT_NAME.toString() || + e.code === RPC_ERROR_CODES.DUPLICATE_IDENTITY_NAME.toString()) ) { this.log() this.log(e.codeMessage) @@ -50,4 +69,20 @@ export class MultisigIdentityCreate extends IronfishCommand { this.log('Identity:') this.log(response.content.identity) } + + async getIdentityFromLedger(): Promise { + const ledger = new LedgerDkg(this.logger) + try { + await ledger.connect() + } catch (e) { + if (e instanceof Error) { + this.error(e.message) + } else { + throw e + } + } + + // TODO(hughy): support multiple identities using index + return ledger.dkgGetIdentity(0) + } } diff --git a/ironfish-cli/src/commands/wallet/multisig/sign.ts b/ironfish-cli/src/commands/wallet/multisig/sign.ts new file mode 100644 index 0000000000..4f75574b05 --- /dev/null +++ b/ironfish-cli/src/commands/wallet/multisig/sign.ts @@ -0,0 +1,389 @@ +/* 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 { multisig } from '@ironfish/rust-nodejs' +import { + CurrencyUtils, + Identity, + RpcClient, + Transaction, + UnsignedTransaction, +} from '@ironfish/sdk' +import { Flags, ux } from '@oclif/core' +import { IronfishCommand } from '../../../command' +import { RemoteFlags } from '../../../flags' +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. +// there is a possibility that the account and participant have different names. + +type MultisigParticipant = { + name: string + identity: Identity + hasSecret: boolean +} + +export class SignMultisigTransactionCommand extends IronfishCommand { + static description = 'Interactive command sign a transaction with a multisig account' + + static flags = { + ...RemoteFlags, + unsignedTransaction: Flags.string({ + char: 'u', + description: 'The unsigned transaction that needs to be signed', + }), + account: Flags.string({ + char: 'a', + description: 'Name of the account to use for signing the transaction', + }), + ledger: Flags.boolean({ + default: false, + description: 'Perform operation with a ledger device', + }), + } + + async start(): Promise { + const { flags } = await this.parse(SignMultisigTransactionCommand) + const client = await this.connectRpc() + await ui.checkWalletUnlocked(client) + + let ledger: LedgerDkg | 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 + } + } + } + + let multisigAccountName: string + if (!flags.account) { + multisigAccountName = await ui.accountPrompt(client) + } else { + multisigAccountName = flags.account + const account = (await client.wallet.getAccounts()).content.accounts.find( + (a) => a === multisigAccountName, + ) + if (!account) { + this.error(`Account ${multisigAccountName} not found`) + } + } + + const accountIdentities = ( + await client.wallet.multisig.getAccountIdentities({ account: multisigAccountName }) + ).content.identities + const participants = (await client.wallet.multisig.getIdentities()).content.identities + + const matchingIdentities = participants.filter((identity) => + accountIdentities.includes(identity.identity), + ) + + if (matchingIdentities.length === 0) { + this.error(`No matching identities found for account ${multisigAccountName}`) + } + + let participant: MultisigParticipant + + if (matchingIdentities.length === 1) { + participant = matchingIdentities[0] + } else { + participant = await ui.listPrompt( + 'Select identity for signing', + matchingIdentities, + (i) => i.name, + ) + } + + 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, + ) + + const { commitment, identities } = await ui.retryStep( + async () => { + return this.performCreateSigningCommitment( + client, + multisigAccountName, + participant, + unsignedTransaction, + unsignedTransactionInput, + ledger, + ) + }, + this.logger, + 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, + multisigAccountName, + commitment, + identities, + unsignedTransaction, + ) + }, this.logger) + + this.log('\n============================================') + this.log('\nSigning Package:') + this.log(signingPackage) + this.log('\n============================================') + + const signatureShare = await ui.retryStep( + () => + this.performCreateSignatureShare( + client, + multisigAccountName, + participant, + signingPackage, + unsignedTransaction, + ledger, + ), + this.logger, + 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, + multisigAccountName, + signingPackage, + signatureShare, + identities.length, + ), + this.logger, + ) + + this.log('Mutlisignature sign process completed!') + } + + private async performAggregateSignatures( + client: RpcClient, + accountName: string, + signingPackage: string, + signatureShare: string, + totalParticipants: number, + ): Promise { + 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, + }) + + const broadcast = await ui.confirmPrompt('Do you want to broadcast the transaction?') + const watch = await ui.confirmPrompt('Do you want to watch the transaction?') + + ux.action.start('Signing the multisig transaction') + + const response = await client.wallet.multisig.aggregateSignatureShares({ + account: accountName, + broadcast, + signingPackage, + signatureShares, + }) + + const bytes = Buffer.from(response.content.transaction, 'hex') + const transaction = new Transaction(bytes) + + ux.action.stop() + + if (broadcast && response.content.accepted === false) { + this.warn( + `Transaction '${transaction.hash().toString('hex')}' was not accepted into the mempool`, + ) + } + + if (broadcast && response.content.broadcasted === false) { + this.warn(`Transaction '${transaction.hash().toString('hex')}' failed to broadcast`) + } + + this.log(`Transaction: ${response.content.transaction}`) + this.log(`Hash: ${transaction.hash().toString('hex')}`) + this.log(`Fee: ${CurrencyUtils.render(transaction.fee(), true)}`) + + if (watch) { + this.log('') + + await watchTransaction({ + client, + logger: this.logger, + account: accountName, + hash: transaction.hash().toString('hex'), + }) + } + } + + private async performCreateSignatureShare( + client: RpcClient, + accountName: string, + identity: MultisigParticipant, + signingPackageString: string, + unsignedTransaction: UnsignedTransaction, + ledger: LedgerDkg | undefined, + ): Promise { + 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'), + ) + + signatureShare = multisig.SignatureShare.fromFrost( + frostSignatureShare, + Buffer.from(identity.identity, 'hex'), + ) + .serialize() + .toString('hex') + } else { + signatureShare = ( + await client.wallet.multisig.createSignatureShare({ + account: accountName, + signingPackage: signingPackageString, + }) + ).content.signatureShare + } + + return signatureShare + } + + private async performAggregateCommitments( + client: RpcClient, + accountName: string, + commitment: string, + identities: string[], + unsignedTransaction: UnsignedTransaction, + ) { + this.log( + `Enter ${identities.length - 1} commitments of the participants (excluding your own)`, + ) + + const commitments = await ui.collectStrings('Commitment', identities.length - 1, { + additionalStrings: [commitment], + errorOnDuplicate: true, + }) + + const signingPackageResponse = await client.wallet.multisig.createSigningPackage({ + account: accountName, + unsignedTransaction: unsignedTransaction.serialize().toString('hex'), + commitments, + }) + + return signingPackageResponse.content.signingPackage + } + + private async performCreateSigningCommitment( + client: RpcClient, + accountName: string, + participant: MultisigParticipant, + unsignedTransaction: UnsignedTransaction, + unsignedTransactionInput: string, + ledger: LedgerDkg | undefined, + ) { + 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) + + if (totalParticipants < 2) { + this.error('Minimum number of participants must be at least 2') + } + + this.log( + `Enter ${totalParticipants - 1} identities of the participants (excluding your own)`, + ) + + const identities = await ui.collectStrings('Participant Identity', totalParticipants - 1, { + additionalStrings: [participant.identity], + errorOnDuplicate: true, + }) + + let commitment + + if (ledger) { + await ledger.reviewTransaction(unsignedTransaction.serialize().toString('hex')) + + commitment = await this.createSigningCommitmentWithLedger( + ledger, + participant, + unsignedTransaction.hash(), + identities, + ) + } else { + commitment = ( + await client.wallet.multisig.createSigningCommitment({ + account: accountName, + unsignedTransaction: unsignedTransactionInput, + signers: identities.map((identity) => ({ identity })), + }) + ).content.commitment + } + + return { + commitment, + identities, + } + } + + async createSigningCommitmentWithLedger( + ledger: LedgerDkg, + participant: MultisigParticipant, + transactionHash: Buffer, + signers: string[], + ): Promise { + const rawCommitments = await ledger.dkgGetCommitments(transactionHash.toString('hex')) + + const sigingCommitment = multisig.SigningCommitment.fromRaw( + participant.identity, + rawCommitments, + transactionHash, + signers, + ) + + return sigingCommitment.serialize().toString('hex') + } +} diff --git a/ironfish-cli/src/commands/wallet/multisig/signature/create.ts b/ironfish-cli/src/commands/wallet/multisig/signature/create.ts index a156964794..db52317a67 100644 --- a/ironfish-cli/src/commands/wallet/multisig/signature/create.ts +++ b/ironfish-cli/src/commands/wallet/multisig/signature/create.ts @@ -2,11 +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 { multisig } from '@ironfish/rust-nodejs' -import { UnsignedTransaction } from '@ironfish/sdk' +import { RpcClient, UnsignedTransaction } from '@ironfish/sdk' import { Flags } from '@oclif/core' import { IronfishCommand } from '../../../../command' import { RemoteFlags } from '../../../../flags' import * as ui from '../../../../ui' +import { LedgerDkg } from '../../../../utils/ledger' import { MultisigTransactionJson } from '../../../../utils/multisig' import { renderUnsignedTransactionDetails } from '../../../../utils/transaction' @@ -30,6 +31,10 @@ export class CreateSignatureShareCommand extends IronfishCommand { path: Flags.string({ description: 'Path to a JSON file containing multisig transaction data', }), + ledger: Flags.boolean({ + default: false, + description: 'Create signature share using a Ledger device', + }), } async start(): Promise { @@ -41,6 +46,11 @@ export class CreateSignatureShareCommand extends IronfishCommand { const client = await this.connectRpc() await ui.checkWalletUnlocked(client) + let participantName = flags.account + if (!participantName) { + participantName = await ui.multisigSecretPrompt(client) + } + let signingPackageString = options.signingPackage if (!signingPackageString) { signingPackageString = await ui.longPrompt('Enter the signing package') @@ -56,7 +66,7 @@ export class CreateSignatureShareCommand extends IronfishCommand { await renderUnsignedTransactionDetails( client, unsignedTransaction, - flags.account, + participantName, this.logger, ) @@ -64,8 +74,18 @@ export class CreateSignatureShareCommand extends IronfishCommand { await ui.confirmOrQuit('Confirm new signature share creation') } + if (flags.ledger) { + await this.createSignatureShareWithLedger( + client, + participantName, + unsignedTransaction, + signingPackage.frostSigningPackage().toString('hex'), + ) + return + } + const signatureShareResponse = await client.wallet.multisig.createSignatureShare({ - account: flags.account, + account: participantName, signingPackage: signingPackageString, }) @@ -93,4 +113,50 @@ export class CreateSignatureShareCommand extends IronfishCommand { this.log(signer.toString('hex')) } } + + async createSignatureShareWithLedger( + client: RpcClient, + participantName: string, + unsignedTransaction: UnsignedTransaction, + frostSigningPackage: string, + ): 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 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 signatureShare = multisig.SignatureShare.fromFrost( + frostSignatureShare, + Buffer.from(identity, 'hex'), + ) + + this.log() + this.log('Signature Share:') + this.log(signatureShare.serialize().toString('hex')) + + this.log() + this.log('Next step:') + this.log( + 'Send the signature to the coordinator. They will aggregate the signatures from all participants and sign the transaction.', + ) + } } diff --git a/ironfish-cli/src/commands/wallet/send.ts b/ironfish-cli/src/commands/wallet/send.ts index 66107f8355..4c7efe5231 100644 --- a/ironfish-cli/src/commands/wallet/send.ts +++ b/ironfish-cli/src/commands/wallet/send.ts @@ -8,7 +8,6 @@ import { isValidPublicAddress, RawTransaction, RawTransactionSerde, - RpcClient, TimeUtils, Transaction, } from '@ironfish/sdk' @@ -21,7 +20,7 @@ import { promptCurrency } from '../../utils/currency' import { promptExpiration } from '../../utils/expiration' import { getExplorer } from '../../utils/explorer' import { selectFee } from '../../utils/fees' -import { Ledger } from '../../utils/ledger' +import { sendTransactionWithLedger } from '../../utils/ledger' import { getSpendPostTimeInMs, updateSpendPostTimeInMs } from '../../utils/spendPostTime' import { displayTransactionSummary, @@ -259,7 +258,14 @@ export class Send extends IronfishCommand { } if (flags.ledger) { - await this.sendTransactionWithLedger(client, raw, from, flags.watch, flags.confirm) + await sendTransactionWithLedger( + client, + raw, + from, + flags.watch, + flags.confirm, + this.logger, + ) this.exit(0) } @@ -351,83 +357,4 @@ export class Send extends IronfishCommand { }) } } - - private async sendTransactionWithLedger( - client: RpcClient, - raw: RawTransaction, - from: string | undefined, - watch: boolean, - confirm: boolean, - ): Promise { - const ledger = new Ledger(this.logger) - try { - await ledger.connect() - } catch (e) { - if (e instanceof Error) { - this.error(e.message) - } else { - throw e - } - } - - const publicKey = (await client.wallet.getAccountPublicKey({ account: from })).content - .publicKey - - const ledgerPublicKey = await ledger.getPublicAddress() - - if (publicKey !== ledgerPublicKey) { - this.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') - - this.log(`\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) - - this.log(`\nSigned Transaction: ${signedTransaction}`) - this.log(`\nHash: ${transaction.hash().toString('hex')}`) - this.log(`Fee: ${CurrencyUtils.render(transaction.fee(), true)}`) - - await ui.confirmOrQuit('', confirm) - - const addTransactionResponse = await client.wallet.addTransaction({ - transaction: signedTransaction, - broadcast: true, - }) - - if (addTransactionResponse.content.accepted === false) { - this.error( - `Transaction '${transaction.hash().toString('hex')}' was not accepted into the mempool`, - ) - } - - if (watch) { - this.log('') - - await watchTransaction({ - client, - logger: this.logger, - account: from, - hash: transaction.hash().toString('hex'), - }) - } - } } diff --git a/ironfish-cli/src/commands/wallet/unlock.ts b/ironfish-cli/src/commands/wallet/unlock.ts index bf874f11c6..1326df62ff 100644 --- a/ironfish-cli/src/commands/wallet/unlock.ts +++ b/ironfish-cli/src/commands/wallet/unlock.ts @@ -8,8 +8,6 @@ import { RemoteFlags } from '../../flags' import { inputPrompt } from '../../ui' export class UnlockCommand extends IronfishCommand { - static hidden = true - static description = 'unlock accounts in the wallet' static flags = { diff --git a/ironfish-cli/src/ui/index.ts b/ironfish-cli/src/ui/index.ts index 9ae9ad0931..25752ab000 100644 --- a/ironfish-cli/src/ui/index.ts +++ b/ironfish-cli/src/ui/index.ts @@ -8,5 +8,6 @@ export * from './longPrompt' export * from './progressBar' export * from './prompt' export * from './prompts' +export * from './retry' export * from './table' export * from './wallet' diff --git a/ironfish-cli/src/ui/prompt.ts b/ironfish-cli/src/ui/prompt.ts index f670e28044..5685bfdd05 100644 --- a/ironfish-cli/src/ui/prompt.ts +++ b/ironfish-cli/src/ui/prompt.ts @@ -2,8 +2,40 @@ * 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 { ux } from '@oclif/core' import inquirer from 'inquirer' +import { longPrompt } from './longPrompt' + +export async function collectStrings( + itemName: string, + itemAmount: number, + options?: { + additionalStrings: string[] + errorOnDuplicate: boolean + }, +): Promise { + const array = [] + + for (let i = 0; i < itemAmount; i++) { + const input = await longPrompt(`${itemName} #${i + 1}`, { required: true }) + array.push(input) + } + + const additionalStrings = options?.additionalStrings || [] + + const strings = [...array, ...additionalStrings] + + if (options?.errorOnDuplicate) { + const withoutDuplicates = [...new Set(strings)] + + if (withoutDuplicates.length !== strings.length) { + throw new Error(`Duplicate ${itemName} found in the list`) + } + } + + return strings +} async function _inputPrompt(message: string, options?: { password: boolean }): Promise { const result: { prompt: string } = await inquirer.prompt({ @@ -14,6 +46,47 @@ async function _inputPrompt(message: string, options?: { password: boolean }): P return result.prompt.trim() } +export async function inputNumberPrompt( + logger: Logger, + message: string, + options: { + required?: boolean + integer?: boolean + }, +): Promise { + const validateNumber = (input: string): number => { + const num = Number(input) + + if (isNaN(num)) { + throw new Error('Input must be a number') + } + + if (options.integer && num % 1 !== 0) { + throw new Error('Input must be an integer') + } + + return num + } + + if (options.required) { + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const userInput = await _inputPrompt(message) + return validateNumber(userInput) + } catch (e) { + if (e instanceof Error) { + logger.error(e.message) + } else { + logger.error('An error occurred. Please try again.') + } + } + } + } + + return validateNumber(await _inputPrompt(message)) +} + export async function inputPrompt( message: string, required: boolean = false, diff --git a/ironfish-cli/src/ui/prompts.ts b/ironfish-cli/src/ui/prompts.ts index 8c2fead622..fff33c80ce 100644 --- a/ironfish-cli/src/ui/prompts.ts +++ b/ironfish-cli/src/ui/prompts.ts @@ -73,6 +73,11 @@ export async function assetPrompt( ) } + const filter = options.filter + if (filter) { + balances = balances.filter((balance) => filter(assetLookup[balance.assetId])) + } + if (balances.length === 0) { return undefined } @@ -85,11 +90,6 @@ export async function assetPrompt( } } - const filter = options.filter - if (filter) { - balances = balances.filter((balance) => filter(assetLookup[balance.assetId])) - } - // Show verified assets at top of the list balances = balances.sort((asset1, asset2) => { const verified1 = assetLookup[asset1.assetId].verification.status === 'verified' diff --git a/ironfish-cli/src/ui/retry.ts b/ironfish-cli/src/ui/retry.ts new file mode 100644 index 0000000000..da206c5fb8 --- /dev/null +++ b/ironfish-cli/src/ui/retry.ts @@ -0,0 +1,32 @@ +/* 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' + +export async function retryStep( + stepFunction: () => Promise, + logger: Logger, + askToRetry: boolean = false, + maxRetries: number = 10, +): Promise { + // eslint-disable-next-line no-constant-condition + let retries = 0 + while (retries < maxRetries) { + try { + const result = await stepFunction() + return result + } catch (error) { + logger.log(`An Error Occurred: ${(error as Error).message}`) + if (askToRetry) { + const continueResponse = await confirmPrompt('Do you want to retry this step?') + if (!continueResponse) { + throw new Error('User chose to not continue') + } + } + } + retries++ + } + + throw new Error('Max retries reached') +} diff --git a/ironfish-cli/src/utils/account.ts b/ironfish-cli/src/utils/account.ts index a867d86707..2f0f20dbb4 100644 --- a/ironfish-cli/src/utils/account.ts +++ b/ironfish-cli/src/utils/account.ts @@ -2,8 +2,15 @@ * 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 { RpcClient } from '@ironfish/sdk' +import { + ImportResponse, + Logger, + RPC_ERROR_CODES, + RpcClient, + RpcRequestError, +} from '@ironfish/sdk' import * as ui from '../ui' +import { inputPrompt } from '../ui' export async function useAccount( client: RpcClient, @@ -27,3 +34,57 @@ export async function useAccount( return ui.accountPrompt(client, message) } + +export async function importAccount( + client: RpcClient, + account: string, + logger: Logger, + accountName?: string, + createdAt?: number, + rescan?: boolean, +): Promise { + let name = accountName + + let result + while (!result) { + try { + result = await client.wallet.importAccount({ + account, + name, + rescan, + createdAt, + }) + } catch (e) { + if ( + e instanceof RpcRequestError && + (e.code === RPC_ERROR_CODES.DUPLICATE_ACCOUNT_NAME.toString() || + e.code === RPC_ERROR_CODES.IMPORT_ACCOUNT_NAME_REQUIRED.toString() || + e.code === RPC_ERROR_CODES.DUPLICATE_IDENTITY_NAME.toString()) + ) { + const message = 'Enter a name for the account' + + if (e.code === RPC_ERROR_CODES.DUPLICATE_ACCOUNT_NAME.toString()) { + logger.info('') + logger.info(e.codeMessage) + } + + if (e.code === RPC_ERROR_CODES.DUPLICATE_IDENTITY_NAME.toString()) { + logger.info('') + logger.info(e.codeMessage) + } + + const inputName = await inputPrompt(message, true) + if (inputName === name) { + throw new Error(`Entered the same name: '${name}'`) + } + + name = inputName + continue + } + + throw e + } + } + + return result.content +} diff --git a/ironfish-cli/src/utils/chainport/config.ts b/ironfish-cli/src/utils/chainport/config.ts index 015d0556a9..b8405e283f 100644 --- a/ironfish-cli/src/utils/chainport/config.ts +++ b/ironfish-cli/src/utils/chainport/config.ts @@ -2,7 +2,7 @@ * 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 { TESTNET } from '@ironfish/sdk' +import { MAINNET, TESTNET } from '@ironfish/sdk' const config = { [TESTNET.id]: { @@ -17,7 +17,18 @@ const config = { '06102d319ab7e77b914a1bd135577f3e266fd82a3e537a02db281421ed8b3d13', ]), }, -} // MAINNET support to follow + [MAINNET.id]: { + chainportId: 22, + endpoint: 'https://api.chainport.io', + outgoingAddresses: new Set([ + '576ffdcc27e11d81f5180d3dc5690294941170d492b2d9503c39130b1f180405', + '7ac2d6a59e19e66e590d014af013cd5611dc146e631fa2aedf0ee3ed1237eebe', + ]), + incomingAddresses: new Set([ + '1216302193e8f1ad020f458b54a163039403d803e98673c6a85e59b5f4a1a900', + ]), + }, +} export const isNetworkSupportedByChainport = (networkId: number) => { return !!config[networkId] diff --git a/ironfish-cli/src/utils/chainport/requests.ts b/ironfish-cli/src/utils/chainport/requests.ts index d538ab338b..11124fe83c 100644 --- a/ironfish-cli/src/utils/chainport/requests.ts +++ b/ironfish-cli/src/utils/chainport/requests.ts @@ -1,6 +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/. */ +import { MAINNET } from '@ironfish/sdk' import axios from 'axios' import { getConfig } from './config' import { @@ -37,7 +38,12 @@ export const fetchChainportVerifiedTokens = async ( networkId: number, ): Promise => { const config = getConfig(networkId) - const url = `${config.endpoint}/token/list?network_name=IRONFISH` + let url + if (networkId === MAINNET.id) { + url = `${config.endpoint}/token/list?network_name=IRONFISH` + } else { + url = `${config.endpoint}/token_list?network_name=IRONFISH` + } return (await makeChainportRequest<{ verified_tokens: ChainportVerifiedToken[] }>(url)) .verified_tokens diff --git a/ironfish-cli/src/utils/ledger.ts b/ironfish-cli/src/utils/ledger.ts index fe472383ec..106051fab3 100644 --- a/ironfish-cli/src/utils/ledger.ts +++ b/ironfish-cli/src/utils/ledger.ts @@ -1,9 +1,20 @@ /* 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 { createRootLogger, Logger } from '@ironfish/sdk' -import { AccountImport } from '@ironfish/sdk/src/wallet/exporter' +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, @@ -11,6 +22,248 @@ import IronfishApp, { 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 @@ -23,7 +276,7 @@ export class Ledger { } connect = async () => { - const transport = await TransportNodeHid.create(3000, 3000) + const transport = await TransportNodeHid.create(3000) if (transport.deviceModel) { this.logger.debug(`${transport.deviceModel.productName} found.`) @@ -43,10 +296,10 @@ export class Ledger { // 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 Error('Please unlock your Ledger device.') + throw new LedgerError('Please unlock your Ledger device.') } - throw new Error('Please open the Iron Fish app on your ledger device.') + throw new LedgerError('Please open the Iron Fish app on your ledger device.') } if (appInfo.appVersion) { @@ -121,7 +374,7 @@ export class Ledger { } const accountImport: AccountImport = { - version: 4, // ACCOUNT_SCHEMA_VERSION as of 2024-05 + version: ACCOUNT_SCHEMA_VERSION, name: 'ledger', viewKey: responseViewKey.viewKey.toString('hex'), incomingViewKey: responseViewKey.ivk.toString('hex'), @@ -160,3 +413,121 @@ export class Ledger { 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/transaction.ts b/ironfish-cli/src/utils/transaction.ts index b60e4ddccf..b87aef8900 100644 --- a/ironfish-cli/src/utils/transaction.ts +++ b/ironfish-cli/src/utils/transaction.ts @@ -5,11 +5,13 @@ import { Asset } from '@ironfish/rust-nodejs' import { assetMetadataWithDefaults, + BurnDescription, createRootLogger, CurrencyUtils, GetTransactionNotesResponse, GetUnsignedTransactionNotesResponse, Logger, + MintDescription, PromiseUtils, RawTransaction, RpcAsset, @@ -19,11 +21,9 @@ import { TransactionStatus, UnsignedTransaction, } from '@ironfish/sdk' -import { BurnDescription } from '@ironfish/sdk/src/primitives/burnDescription' -import { MintDescription } from '@ironfish/sdk/src/primitives/mintDescription' import { ux } from '@oclif/core' import { ProgressBar, ProgressBarPresets } from '../ui' -import { getAssetsByIDs, getAssetVerificationByIds } from './asset' +import { getAssetVerificationByIds } from './asset' export class TransactionTimer { private progressBar: ProgressBar | undefined @@ -167,7 +167,7 @@ async function _renderTransactionDetails( logger = logger ?? createRootLogger() const assetIds = collectAssetIds(mints, burns, notes) - const assetLookup = await getAssetsByIDs(client, assetIds, account, undefined) + const assetLookup = await getAssetVerificationByIds(client, assetIds, account, undefined) if (mints.length > 0) { logger.log('') @@ -185,7 +185,7 @@ async function _renderTransactionDetails( mint.value, false, mint.asset.id().toString('hex'), - assetLookup[mint.asset.id().toString('hex')].verification, + assetLookup[mint.asset.id().toString('hex')], ) logger.log(`Asset ID: ${mint.asset.id().toString('hex')}`) logger.log(`Name: ${mint.asset.name().toString('utf8')}`) @@ -218,7 +218,7 @@ async function _renderTransactionDetails( burn.value, false, burn.assetId.toString('hex'), - assetLookup[burn.assetId.toString('hex')].verification, + assetLookup[burn.assetId.toString('hex')], ) logger.log(`Asset ID: ${burn.assetId.toString('hex')}`) logger.log(`Amount: ${renderedAmount}`) @@ -243,13 +243,20 @@ async function _renderTransactionDetails( } logger.log('') + const verifiedAssetMetadata = assetLookup[note.assetId] + const renderedAmount = CurrencyUtils.render( note.value, true, note.assetId, - assetLookup[note.assetId].verification, + verifiedAssetMetadata, ) logger.log(`Amount: ${renderedAmount}`) + + if (verifiedAssetMetadata?.symbol) { + logger.log(`Asset ID: ${note.assetId}`) + } + logger.log(`Memo: ${note.memo}`) logger.log(`Recipient: ${note.owner}`) logger.log(`Sender: ${note.sender}`) @@ -267,13 +274,20 @@ async function _renderTransactionDetails( } logger.log('') + const verifiedAssetMetadata = assetLookup[note.assetId] + const renderedAmount = CurrencyUtils.render( note.value, true, note.assetId, - assetLookup[note.assetId].verification, + verifiedAssetMetadata, ) logger.log(`Amount: ${renderedAmount}`) + + if (verifiedAssetMetadata?.symbol) { + logger.log(`Asset ID: ${note.assetId}`) + } + logger.log(`Memo: ${note.memo}`) logger.log(`Recipient: ${note.owner}`) logger.log(`Sender: ${note.sender}`) diff --git a/ironfish-rust-nodejs/index.d.ts b/ironfish-rust-nodejs/index.d.ts index eb4ed16352..a53f89fa9e 100644 --- a/ironfish-rust-nodejs/index.d.ts +++ b/ironfish-rust-nodejs/index.d.ts @@ -337,6 +337,7 @@ export namespace multisig { static fromFrost(frostSignatureShare: Buffer, identity: Buffer): NativeSignatureShare identity(): Buffer frostSignatureShare(): Buffer + serialize(): Buffer } export class ParticipantSecret { constructor(jsBytes: Buffer) diff --git a/ironfish-rust-nodejs/npm/darwin-arm64/package.json b/ironfish-rust-nodejs/npm/darwin-arm64/package.json index eeddf59ab5..0c09d5c874 100644 --- a/ironfish-rust-nodejs/npm/darwin-arm64/package.json +++ b/ironfish-rust-nodejs/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs-darwin-arm64", - "version": "2.6.0", + "version": "2.7.0", "os": [ "darwin" ], diff --git a/ironfish-rust-nodejs/npm/darwin-x64/package.json b/ironfish-rust-nodejs/npm/darwin-x64/package.json index d34397a1f7..7485c7d570 100644 --- a/ironfish-rust-nodejs/npm/darwin-x64/package.json +++ b/ironfish-rust-nodejs/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs-darwin-x64", - "version": "2.6.0", + "version": "2.7.0", "os": [ "darwin" ], diff --git a/ironfish-rust-nodejs/npm/linux-arm64-gnu/package.json b/ironfish-rust-nodejs/npm/linux-arm64-gnu/package.json index 9a3ab7c0f6..2a6630ddcf 100644 --- a/ironfish-rust-nodejs/npm/linux-arm64-gnu/package.json +++ b/ironfish-rust-nodejs/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs-linux-arm64-gnu", - "version": "2.6.0", + "version": "2.7.0", "os": [ "linux" ], diff --git a/ironfish-rust-nodejs/npm/linux-arm64-musl/package.json b/ironfish-rust-nodejs/npm/linux-arm64-musl/package.json index fabe279e18..e8e3dd548e 100644 --- a/ironfish-rust-nodejs/npm/linux-arm64-musl/package.json +++ b/ironfish-rust-nodejs/npm/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs-linux-arm64-musl", - "version": "2.6.0", + "version": "2.7.0", "os": [ "linux" ], diff --git a/ironfish-rust-nodejs/npm/linux-x64-gnu/package.json b/ironfish-rust-nodejs/npm/linux-x64-gnu/package.json index 0041a8bae1..3df3885f9a 100644 --- a/ironfish-rust-nodejs/npm/linux-x64-gnu/package.json +++ b/ironfish-rust-nodejs/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs-linux-x64-gnu", - "version": "2.6.0", + "version": "2.7.0", "os": [ "linux" ], diff --git a/ironfish-rust-nodejs/npm/linux-x64-musl/package.json b/ironfish-rust-nodejs/npm/linux-x64-musl/package.json index 0f10be1936..8fe9f2d953 100644 --- a/ironfish-rust-nodejs/npm/linux-x64-musl/package.json +++ b/ironfish-rust-nodejs/npm/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs-linux-x64-musl", - "version": "2.6.0", + "version": "2.7.0", "os": [ "linux" ], diff --git a/ironfish-rust-nodejs/npm/win32-x64-msvc/package.json b/ironfish-rust-nodejs/npm/win32-x64-msvc/package.json index 2a7e344884..f3809d4733 100644 --- a/ironfish-rust-nodejs/npm/win32-x64-msvc/package.json +++ b/ironfish-rust-nodejs/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs-win32-x64-msvc", - "version": "2.6.0", + "version": "2.7.0", "os": [ "win32" ], diff --git a/ironfish-rust-nodejs/package.json b/ironfish-rust-nodejs/package.json index 4b61d3bad9..11b3fa9a29 100644 --- a/ironfish-rust-nodejs/package.json +++ b/ironfish-rust-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/rust-nodejs", - "version": "2.6.0", + "version": "2.7.0", "description": "Node.js bindings for Rust code required by the Iron Fish SDK", "main": "index.js", "types": "index.d.ts", diff --git a/ironfish-rust-nodejs/src/multisig.rs b/ironfish-rust-nodejs/src/multisig.rs index dde0171e5f..52f8ca989f 100644 --- a/ironfish-rust-nodejs/src/multisig.rs +++ b/ironfish-rust-nodejs/src/multisig.rs @@ -190,6 +190,11 @@ impl NativeSignatureShare { .as_slice(), ) } + + #[napi] + pub fn serialize(&self) -> Buffer { + Buffer::from(self.signature_share.serialize().as_slice()) + } } #[napi(namespace = "multisig")] diff --git a/ironfish/package.json b/ironfish/package.json index 2e3e59653b..8c68235061 100644 --- a/ironfish/package.json +++ b/ironfish/package.json @@ -1,6 +1,6 @@ { "name": "@ironfish/sdk", - "version": "2.6.0", + "version": "2.7.0", "description": "SDK for running and interacting with an Iron Fish node", "author": "Iron Fish (https://ironfish.network)", "main": "build/src/index.js", @@ -22,7 +22,7 @@ "dependencies": { "@ethersproject/bignumber": "5.7.0", "@fast-csv/format": "4.3.5", - "@ironfish/rust-nodejs": "2.6.0", + "@ironfish/rust-nodejs": "2.7.0", "@napi-rs/blake-hash": "1.3.3", "axios": "1.7.2", "bech32": "2.0.0", diff --git a/ironfish/src/assets/assetsVerificationApi.test.ts b/ironfish/src/assets/assetsVerificationApi.test.ts index bf177144bc..ffc87745f2 100644 --- a/ironfish/src/assets/assetsVerificationApi.test.ts +++ b/ironfish/src/assets/assetsVerificationApi.test.ts @@ -3,7 +3,10 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import nock from 'nock' import { NodeFileProvider } from '../fileSystems' -import { AssetsVerificationApi } from './assetsVerificationApi' +import { + AssetsVerificationApi, + getDefaultAssetVerificationEndpoint, +} from './assetsVerificationApi' const assetData1 = { identifier: '0123', @@ -239,3 +242,26 @@ describe('Assets Verification API Client', () => { }) }) }) + +describe('getDefaultAssetVerificationEndpoint', () => { + it('returns the testnet url with the testnet id', () => { + expect(getDefaultAssetVerificationEndpoint(0)).toEqual( + 'https://testnet.api.ironfish.network/assets/verified_metadata', + ) + }) + + it('returns the regular url with any other id', () => { + expect(getDefaultAssetVerificationEndpoint(1)).toEqual( + 'https://api.ironfish.network/assets/verified_metadata', + ) + expect(getDefaultAssetVerificationEndpoint(10)).toEqual( + 'https://api.ironfish.network/assets/verified_metadata', + ) + }) + + it('returns the regular url with no id', () => { + expect(getDefaultAssetVerificationEndpoint()).toEqual( + 'https://api.ironfish.network/assets/verified_metadata', + ) + }) +}) diff --git a/ironfish/src/assets/assetsVerificationApi.ts b/ironfish/src/assets/assetsVerificationApi.ts index 491e980ea7..32835f8b35 100644 --- a/ironfish/src/assets/assetsVerificationApi.ts +++ b/ironfish/src/assets/assetsVerificationApi.ts @@ -79,8 +79,8 @@ export class AssetsVerificationApi { readonly url: string - constructor(options: { files: FileSystem; url?: string; timeout?: number }) { - this.url = options?.url || 'https://api.ironfish.network/assets/verified_metadata' + constructor(options: { files: FileSystem; url: string; timeout?: number }) { + this.url = options.url this.timeout = options?.timeout ?? 30 * 1000 // 30 seconds this.adapter = isFileUrl(this.url) ? axiosFileAdapter(options.files) @@ -130,6 +130,14 @@ export class AssetsVerificationApi { } } +export function getDefaultAssetVerificationEndpoint(networkId?: number): string { + if (networkId === 0) { + return 'https://testnet.api.ironfish.network/assets/verified_metadata' + } + + return 'https://api.ironfish.network/assets/verified_metadata' +} + const isFileUrl = (url: string): boolean => { const parsedUrl = new URL(url) return parsedUrl.protocol === 'file:' diff --git a/ironfish/src/assets/assetsVerifier.test.ts b/ironfish/src/assets/assetsVerifier.test.ts index 83b182d16a..8eb23e6363 100644 --- a/ironfish/src/assets/assetsVerifier.test.ts +++ b/ironfish/src/assets/assetsVerifier.test.ts @@ -7,6 +7,8 @@ import { VerifiedAssetsCacheStore } from '../fileStores/verifiedAssets' import { NodeFileProvider } from '../fileSystems' import { AssetsVerifier } from './assetsVerifier' +const apiUrl = 'https://example.com/endpoint' + /* eslint-disable jest/no-standalone-expect */ /* eslint-disable @typescript-eslint/no-explicit-any */ const assetData1 = { @@ -58,7 +60,7 @@ describe('AssetsVerifier', () => { }) it('does not refresh when not started', () => { - const assetsVerifier = new AssetsVerifier({ files }) + const assetsVerifier = new AssetsVerifier({ files, apiUrl }) const refresh = jest.spyOn(assetsVerifier as any, 'refresh') jest.runOnlyPendingTimers() @@ -191,7 +193,7 @@ describe('AssetsVerifier', () => { describe('verify', () => { it("returns 'unknown' when not started", () => { - const assetsVerifier = new AssetsVerifier({ files }) + const assetsVerifier = new AssetsVerifier({ files, apiUrl }) expect(assetsVerifier.verify('0123')).toStrictEqual({ status: 'unknown' }) expect(assetsVerifier.verify('4567')).toStrictEqual({ status: 'unknown' }) diff --git a/ironfish/src/assets/assetsVerifier.ts b/ironfish/src/assets/assetsVerifier.ts index 9f40b6f9a0..d7e08c4735 100644 --- a/ironfish/src/assets/assetsVerifier.ts +++ b/ironfish/src/assets/assetsVerifier.ts @@ -35,13 +35,13 @@ export class AssetsVerifier { constructor(options: { files: FileSystem - apiUrl?: string + apiUrl: string cache?: VerifiedAssetsCacheStore logger?: Logger }) { - this.logger = options?.logger ?? createRootLogger() - this.api = new AssetsVerificationApi({ url: options?.apiUrl, files: options.files }) - this.cache = options?.cache + this.logger = options.logger ?? createRootLogger() + this.api = new AssetsVerificationApi({ url: options.apiUrl, files: options.files }) + this.cache = options.cache this.started = false if (this.cache?.config?.apiUrl === this.api.url) { diff --git a/ironfish/src/devUtils/index.ts b/ironfish/src/devUtils/index.ts new file mode 100644 index 0000000000..951be1475c --- /dev/null +++ b/ironfish/src/devUtils/index.ts @@ -0,0 +1,8 @@ +/* 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/. */ + +// The devUtils module contains exportable utilities for testing and +// development. This module must NOT import any test libraries, fixtures, mocks, +// or other dev dependencies. +export * from './witness' diff --git a/ironfish/src/testUtilities/witness.ts b/ironfish/src/devUtils/witness.ts similarity index 100% rename from ironfish/src/testUtilities/witness.ts rename to ironfish/src/devUtils/witness.ts diff --git a/ironfish/src/index.ts b/ironfish/src/index.ts index 1ee9e55d5b..a8da92f77e 100644 --- a/ironfish/src/index.ts +++ b/ironfish/src/index.ts @@ -28,3 +28,4 @@ export * from './package' export * from './platform' export * from './primitives' export { getFeeRate } from './memPool' +export * as devUtils from './devUtils' diff --git a/ironfish/src/multisig.test.slow.ts b/ironfish/src/multisig.test.slow.ts index f07eaa92c6..c21d7dad40 100644 --- a/ironfish/src/multisig.test.slow.ts +++ b/ironfish/src/multisig.test.slow.ts @@ -3,9 +3,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { Asset, multisig, Note as NativeNote, verifyTransactions } from '@ironfish/rust-nodejs' +import { makeFakeWitness } from './devUtils' import { Note, RawTransaction } from './primitives' import { Transaction, TransactionVersion } from './primitives/transaction' -import { makeFakeWitness } from './testUtilities' describe('multisig', () => { describe('dkg', () => { diff --git a/ironfish/src/node.ts b/ironfish/src/node.ts index bdd70abd05..34e411e9a9 100644 --- a/ironfish/src/node.ts +++ b/ironfish/src/node.ts @@ -3,7 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { BoxKeyPair, FishHashContext } from '@ironfish/rust-nodejs' import { v4 as uuid } from 'uuid' -import { AssetsVerifier } from './assets' +import { AssetsVerifier, getDefaultAssetVerificationEndpoint } from './assets' import { Blockchain } from './blockchain' import { BlockHasher } from './blockHasher' import { @@ -223,16 +223,6 @@ export class FullNode { const peerStore = new PeerStore(files, dataDir) await peerStore.load() - const verifiedAssetsCache = new VerifiedAssetsCacheStore(files, dataDir) - await verifiedAssetsCache.load() - - const assetsVerifier = new AssetsVerifier({ - files, - apiUrl: config.get('assetVerificationApi'), - cache: verifiedAssetsCache, - logger, - }) - const numWorkers = calculateWorkers(config.get('nodeWorkers'), config.get('nodeWorkersMax')) const workerPool = new WorkerPool({ logger, metrics, numWorkers }) @@ -249,6 +239,17 @@ export class FullNode { const network = new Network(networkDefinition) + const verifiedAssetsCache = new VerifiedAssetsCacheStore(files, dataDir) + await verifiedAssetsCache.load() + + const assetsVerifier = new AssetsVerifier({ + files, + apiUrl: + config.get('assetVerificationApi') || getDefaultAssetVerificationEndpoint(network.id), + cache: verifiedAssetsCache, + logger, + }) + if (!config.isSet('bootstrapNodes')) { config.setOverride('bootstrapNodes', network.bootstrapNodes) } diff --git a/ironfish/src/primitives/index.ts b/ironfish/src/primitives/index.ts index 658cf72313..50e79ca522 100644 --- a/ironfish/src/primitives/index.ts +++ b/ironfish/src/primitives/index.ts @@ -10,3 +10,5 @@ export { Target } from './target' export { Transaction } from './transaction' export { RawTransaction, RawTransactionSerde } from './rawTransaction' export { UnsignedTransaction } from './unsignedTransaction' +export { MintDescription } from './mintDescription' +export { BurnDescription } from './burnDescription' diff --git a/ironfish/src/primitives/rawTransaction.test.slow.ts b/ironfish/src/primitives/rawTransaction.test.slow.ts index cf7e19128f..faf298d2b6 100644 --- a/ironfish/src/primitives/rawTransaction.test.slow.ts +++ b/ironfish/src/primitives/rawTransaction.test.slow.ts @@ -2,7 +2,8 @@ * 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 { Asset, generateKey, Note as NativeNote } from '@ironfish/rust-nodejs' -import { makeFakeWitness, useAccountFixture, useTxFixture } from '../testUtilities' +import { makeFakeWitness } from '../devUtils' +import { useAccountFixture, useTxFixture } from '../testUtilities' import { createNodeTest } from '../testUtilities/nodeTest' import { SpendingAccount } from '../wallet' import { Note } from './note' diff --git a/ironfish/src/primitives/rawTransaction.test.ts b/ironfish/src/primitives/rawTransaction.test.ts index e29ebc9ee4..bf08438d47 100644 --- a/ironfish/src/primitives/rawTransaction.test.ts +++ b/ironfish/src/primitives/rawTransaction.test.ts @@ -4,8 +4,8 @@ import { Asset, generateKey, Note as NativeNote } from '@ironfish/rust-nodejs' import { BufferMap } from 'buffer-map' import { Assert } from '../assert' +import { makeFakeWitness } from '../devUtils' import { IsNoteWitnessEqual } from '../merkletree/witness' -import { makeFakeWitness } from '../testUtilities' import { useAccountFixture, useMinerBlockFixture, diff --git a/ironfish/src/primitives/unsignedTransaction.ts b/ironfish/src/primitives/unsignedTransaction.ts index df644f472b..86a2b50fda 100644 --- a/ironfish/src/primitives/unsignedTransaction.ts +++ b/ironfish/src/primitives/unsignedTransaction.ts @@ -164,4 +164,16 @@ export class UnsignedTransaction { return result } + + hash(): Buffer { + const hash = this.takeReference().hash() + this.returnReference() + return hash + } + + publicKeyRandomness(): string { + const publicKeyRandomness = this.takeReference().publicKeyRandomness() + this.returnReference() + return publicKeyRandomness + } } diff --git a/ironfish/src/rpc/adapters/errors.ts b/ironfish/src/rpc/adapters/errors.ts index 5835d90c0d..31898515de 100644 --- a/ironfish/src/rpc/adapters/errors.ts +++ b/ironfish/src/rpc/adapters/errors.ts @@ -11,6 +11,7 @@ export enum RPC_ERROR_CODES { INSUFFICIENT_BALANCE = 'insufficient-balance', UNAUTHENTICATED = 'unauthenticated', NOT_FOUND = 'not-found', + IDENTITY_NOT_FOUND = 'identity-not-found', DUPLICATE_ACCOUNT_NAME = 'duplicate-account-name', DUPLICATE_IDENTITY_NAME = 'duplicate-identity-name', IMPORT_ACCOUNT_NAME_REQUIRED = 'import-account-name-required', diff --git a/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.test.ts b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.test.ts index 9fbba85f09..f6d3068881 100644 --- a/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.test.ts +++ b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.test.ts @@ -52,6 +52,8 @@ describe('Route multisig/dkg/round3', () => { ), ) + const accountCreatedAt = 2 + // Perform DKG round 3 const round3Responses = await Promise.all( participantNames.map((participantName, index) => @@ -61,6 +63,7 @@ describe('Route multisig/dkg/round3', () => { round2SecretPackage: round2Packages[index].content.round2SecretPackage, round1PublicPackages: round1Packages.map((pkg) => pkg.content.round1PublicPackage), round2PublicPackages: round2Packages.map((pkg) => pkg.content.round2PublicPackage), + accountCreatedAt, }), ), ) @@ -98,6 +101,13 @@ describe('Route multisig/dkg/round3', () => { .sort() expect(knownIdentities).toStrictEqual(expectedIdentities) } + + // Check that all imported accounts have createdAt sequence set + for (const accountName of accountNames) { + const account = routeTest.wallet.getAccountByName(accountName) + Assert.isNotNull(account) + expect(account.createdAt?.sequence).toEqual(accountCreatedAt) + } }) it('should fail if not all round 1 packages are passed as an input', async () => { diff --git a/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.ts b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.ts index 0912b324db..448ec7fe44 100644 --- a/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.ts +++ b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.ts @@ -16,6 +16,7 @@ export type DkgRound3Request = { round1PublicPackages: Array round2PublicPackages: Array accountName?: string + accountCreatedAt?: number } export type DkgRound3Response = { @@ -30,6 +31,7 @@ export const DkgRound3RequestSchema: yup.ObjectSchema = yup round1PublicPackages: yup.array().of(yup.string().defined()).defined(), round2PublicPackages: yup.array().of(yup.string().defined()).defined(), accountName: yup.string().optional(), + accountCreatedAt: yup.number().optional(), }) .defined() @@ -92,7 +94,9 @@ routes.register( }, } - const account = await node.wallet.importAccount(accountImport) + const account = await node.wallet.importAccount(accountImport, { + createdAt: request.data.accountCreatedAt, + }) await node.wallet.skipRescan(account) request.end({ diff --git a/ironfish/src/rpc/routes/wallet/multisig/getIdentity.ts b/ironfish/src/rpc/routes/wallet/multisig/getIdentity.ts index 881b559970..5c98a4f83f 100644 --- a/ironfish/src/rpc/routes/wallet/multisig/getIdentity.ts +++ b/ironfish/src/rpc/routes/wallet/multisig/getIdentity.ts @@ -2,7 +2,7 @@ * 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 { RpcValidationError } from '../../../adapters/errors' +import { RPC_ERROR_CODES, RpcValidationError } from '../../../adapters/errors' import { ApiNamespace } from '../../namespaces' import { routes } from '../../router' import { AssertHasRpcContext } from '../../rpcContext' @@ -37,7 +37,11 @@ routes.register( const identity = await context.wallet.walletDb.getMultisigIdentityByName(name) if (identity === undefined) { - throw new RpcValidationError(`No identity found with name ${name}`, 404) + throw new RpcValidationError( + `No identity found with name ${name}`, + 404, + RPC_ERROR_CODES.IDENTITY_NOT_FOUND, + ) } request.end({ identity: identity.toString('hex') }) diff --git a/ironfish/src/rpc/routes/wallet/multisig/importParticipant.ts b/ironfish/src/rpc/routes/wallet/multisig/importParticipant.ts index 0e1de10590..19b1f48909 100644 --- a/ironfish/src/rpc/routes/wallet/multisig/importParticipant.ts +++ b/ironfish/src/rpc/routes/wallet/multisig/importParticipant.ts @@ -45,13 +45,12 @@ routes.register