Skip to content

Commit

Permalink
Add UI action to make ledger reliable
Browse files Browse the repository at this point in the history
This UI action will handle many of the failure cases that occur when
running ledger commands. They'll use CLI-UX to inform the user of the
current state, and what the state should be.
  • Loading branch information
NullSoldier committed Oct 3, 2024
1 parent 6ae31e9 commit a813ded
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 105 deletions.
91 changes: 21 additions & 70 deletions ironfish-cli/src/commands/wallet/multisig/dkg/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,6 @@ export class DkgCreateCommand extends IronfishCommand {

if (flags.ledger) {
ledger = new LedgerMultiSigner(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 ?? flags.participant)
Expand Down Expand Up @@ -212,7 +203,7 @@ export class DkgCreateCommand extends IronfishCommand {
const identities = await client.wallet.multisig.getIdentities()

if (ledger) {
const ledgerIdentity = await ledger.dkgGetIdentity(0)
const ledgerIdentity = await ui.ledgerAction(ledger, () => ledger.dkgGetIdentity(0))

const foundIdentity = identities.content.identities.find(
(i) => i.identity === ledgerIdentity.toString('hex'),
Expand Down Expand Up @@ -281,48 +272,6 @@ export class DkgCreateCommand extends IronfishCommand {
return name
}

async getIdentityFromLedger(
ledger: LedgerMultiSigner,
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,
Expand Down Expand Up @@ -413,7 +362,9 @@ export class DkgCreateCommand extends IronfishCommand {
}

// TODO(hughy): determine how to handle multiple identities using index
const { publicPackage, secretPackage } = await ledger.dkgRound1(0, identities, minSigners)
const { publicPackage, secretPackage } = await ui.ledgerAction(ledger, () =>
ledger.dkgRound1(0, identities, minSigners),
)

return {
round1: {
Expand Down Expand Up @@ -503,10 +454,8 @@ export class DkgCreateCommand extends IronfishCommand {
round2: { secretPackage: string; publicPackage: string }
}> {
// TODO(hughy): determine how to handle multiple identities using index
const { publicPackage, secretPackage } = await ledger.dkgRound2(
0,
round1PublicPackages,
round1SecretPackage,
const { publicPackage, secretPackage } = await ui.ledgerAction(ledger, () =>
ledger.dkgRound2(0, round1PublicPackages, round1SecretPackage),
)

return {
Expand Down Expand Up @@ -621,9 +570,9 @@ export class DkgCreateCommand extends IronfishCommand {
.sort((a, b) => a.senderIdentity.localeCompare(b.senderIdentity))

// Extract raw parts from round1 and round2 public packages
const participants = []
const round1FrostPackages = []
const gskBytes = []
const participants: string[] = []
const round1FrostPackages: string[] = []
const gskBytes: string[] = []
for (const pkg of round1PublicPackages) {
// Exclude participant's own identity and round1 public package
if (pkg.identity !== identity) {
Expand All @@ -637,19 +586,21 @@ export class DkgCreateCommand extends IronfishCommand {
const round2FrostPackages = round2PublicPackages.map((pkg) => pkg.frostPackage)

// Perform round3 with Ledger
await ledger.dkgRound3(
0,
participants,
round1FrostPackages,
round2FrostPackages,
round2SecretPackage,
gskBytes,
await ui.ledgerAction(ledger, () =>
ledger.dkgRound3(
0,
participants,
round1FrostPackages,
round2FrostPackages,
round2SecretPackage,
gskBytes,
),
)

// Retrieve all multisig account keys and publicKeyPackage
const dkgKeys = await ledger.dkgRetrieveKeys()
const dkgKeys = await ui.ledgerAction(ledger, () => ledger.dkgRetrieveKeys())

const publicKeyPackage = await ledger.dkgGetPublicPackage()
const publicKeyPackage = await ui.ledgerAction(ledger, () => ledger.dkgGetPublicPackage())

const accountImport = {
...dkgKeys,
Expand Down Expand Up @@ -678,7 +629,7 @@ export class DkgCreateCommand extends IronfishCommand {
this.log('Creating an encrypted backup of multisig keys from your Ledger device...')
this.log()

const encryptedKeys = await ledger.dkgBackupKeys()
const encryptedKeys = await ui.ledgerAction(ledger, () => ledger.dkgBackupKeys())

this.log()
this.log('Encrypted Ledger Multisig Backup:')
Expand Down
120 changes: 90 additions & 30 deletions ironfish-cli/src/ledger/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import IronfishApp, {
ResponseProofGenKey,
ResponseViewKey,
} from '@zondax/ledger-ironfish'
import { ResponseError } from '@zondax/ledger-js'
import { ResponseError, Transport } from '@zondax/ledger-js'

export class Ledger {
app: IronfishApp | undefined
logger: Logger
PATH = "m/44'/1338'/0"
isMultisig: boolean
isConnecting: boolean = false

constructor(isMultisig: boolean, logger?: Logger) {
this.app = undefined
Expand All @@ -24,47 +25,74 @@ export class Ledger {
}

tryInstruction = async <T>(instruction: (app: IronfishApp) => Promise<T>) => {
await this.refreshConnection()
Assert.isNotUndefined(this.app, 'Unable to establish connection with Ledger device')

try {
await this.refreshConnection()

Assert.isNotUndefined(this.app, 'Unable to establish connection with Ledger device')
return await instruction(this.app)
} catch (error: unknown) {
if (isResponseError(error)) {
this.logger.debug(`Ledger ResponseError returnCode: ${error.returnCode.toString(16)}`)
if (LedgerPortIsBusyError.IsError(error)) {
throw new LedgerPortIsBusyError()
} else if (LedgerConnectError.IsError(error)) {
throw new LedgerConnectError()
}

if (error instanceof ResponseError) {
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 LedgerDeviceLockedError(error)
} else if (error.returnCode === LedgerClaNotSupportedError.returnCode) {
throw new LedgerClaNotSupportedError(error)
} else if (error.returnCode === LedgerGPAuthFailed.returnCode) {
throw new LedgerGPAuthFailed(error)
} else if (LedgerAppNotOpen.returnCodes.includes(error.returnCode)) {
throw new LedgerAppNotOpen(error)
}

throw new LedgerError(error.errorMessage)
throw new LedgerError(error.message)
}

throw error
}
}

connect = async () => {
const transport = await TransportNodeHid.create(3000)
if (this.app || this.isConnecting) {
return
}

transport.on('disconnect', async () => {
await transport.close()
this.app = undefined
})
this.isConnecting = true

if (transport.deviceModel) {
this.logger.debug(`${transport.deviceModel.productName} found.`)
}
let transport: Transport | undefined = undefined

const app = new IronfishApp(transport, this.isMultisig)
try {
transport = await TransportNodeHid.create(2000, 2000)

// If the app isn't open or the device is locked, this will throw an error.
await app.getVersion()
transport.on('disconnect', async () => {
await transport?.close()
this.app = undefined
})

this.app = app
if (transport.deviceModel) {
this.logger.debug(`${transport.deviceModel.productName} found.`)
}

return { app, PATH: this.PATH }
const app = new IronfishApp(transport, this.isMultisig)

// 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 }
} catch (e) {
await transport?.close()
throw e
} finally {
this.isConnecting = false
}
}

close = () => {
void this.app?.transport.close()
}

protected refreshConnection = async () => {
Expand All @@ -86,27 +114,59 @@ export function isResponseProofGenKey(response: KeyResponse): response is Respon
return 'ak' in response && 'nsk' in response
}

export 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 {
export class LedgerConnectError extends LedgerError {
static IsError(error: unknown): error is Error {
return (
error instanceof Error &&
'id' in error &&
typeof error['id'] === 'string' &&
error.id === 'ListenTimeout'
)
}
}

export class LedgerPortIsBusyError extends LedgerError {
static IsError(error: unknown): error is Error {
return error instanceof Error && error.message.includes('cannot open device with path')
}
}

export class LedgerResponseError extends LedgerError {
returnCode: number | null

constructor(error?: ResponseError, message?: string) {
super(message ?? error?.errorMessage ?? error?.message)
this.returnCode = error?.returnCode ?? null
}
}

export class LedgerGPAuthFailed extends LedgerResponseError {
static returnCode = 0x6300
}

export class LedgerClaNotSupportedError extends LedgerResponseError {
static returnCode = 0x6e00
}

export class LedgerDeviceLockedError extends LedgerResponseError {
static returnCode = 0x5515
}

export class LedgerAppUnavailableError extends LedgerError {
export class LedgerAppNotOpen extends LedgerResponseError {
static returnCodes = [
0x6d00, // Instruction not supported
0xffff, // Unknown transport error
0x6f00, // Technical error
0x6e01, // App not open
]

constructor() {
constructor(error: ResponseError) {
super(
error,
`Unable to connect to Ironfish app on Ledger. Please check that the device is unlocked and the app is open.`,
)
}
Expand Down
7 changes: 2 additions & 5 deletions ironfish-cli/src/ledger/ledgerMultiSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
KeyResponse,
ResponseDkgRound1,
ResponseDkgRound2,
ResponseIdentity,
} from '@zondax/ledger-ironfish'
import { isResponseAddress, isResponseProofGenKey, isResponseViewKey, Ledger } from './ledger'

Expand All @@ -17,11 +16,9 @@ export class LedgerMultiSigner extends Ledger {
}

dkgGetIdentity = async (index: number): Promise<Buffer> => {
this.logger.log('Retrieving identity from ledger device.')
this.logger.debug('Retrieving identity from ledger device.')

const response: ResponseIdentity = await this.tryInstruction((app) =>
app.dkgGetIdentity(index, false),
)
const response = await this.tryInstruction((app) => app.dkgGetIdentity(index, false))

return response.identity
}
Expand Down
1 change: 1 addition & 0 deletions ironfish-cli/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './prompts'
export * from './retry'
export * from './table'
export * from './wallet'
export * from './ledger'
Loading

0 comments on commit a813ded

Please sign in to comment.