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 752fe37
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 40 deletions.
12 changes: 2 additions & 10 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,7 @@ 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
}
}
await ui.ledgerAction(ledger, () => ledger?.connect())
}

const accountName = await this.getAccountName(client, flags.name ?? flags.participant)
Expand Down Expand Up @@ -212,7 +204,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
117 changes: 87 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,56 @@ 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 {
constructor(error?: ResponseError, message?: string) {
super(message ?? error?.errorMessage ?? error?.message)
}
}

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
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'
62 changes: 62 additions & 0 deletions ironfish-cli/src/ui/ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* 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 { PromiseUtils } from '@ironfish/sdk'
import { ux } from '@oclif/core'
import {
Ledger,
LedgerAppNotOpen,
LedgerClaNotSupportedError,
LedgerConnectError,
LedgerDeviceLockedError,
LedgerGPAuthFailed,
LedgerPortIsBusyError,
} from '../ledger'

export async function ledgerAction<TResult>(
ledger: Ledger,
action: () => TResult | Promise<TResult>,
): Promise<TResult> {
const statusRunning = ux.action.running
let statusAdded = false

try {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const result = await ledger.tryInstruction(async () => await action())
ux.action.stop()
return result
} catch (e) {
if (e instanceof LedgerConnectError) {
ux.action.start('Ledger not found, connect ledger')
} else if (e instanceof LedgerDeviceLockedError) {
ux.action.start('Ledger is locked, enter pin code')
} else if (e instanceof LedgerPortIsBusyError) {
ux.action.start('Ledger port is already in use')
} else if (e instanceof LedgerGPAuthFailed) {
ux.action.start('Ledger handshake failed... trying again')
} else if (e instanceof LedgerClaNotSupportedError) {
const appName = ledger.isMultisig ? 'Ironfish DKG' : 'Ironfish'
ux.action.start(`Wrong Ledger app opened. Please open ${appName} APP.`)
} else if (e instanceof LedgerAppNotOpen) {
const appName = ledger.isMultisig ? 'Ironfish DKG' : 'Ironfish'
ux.action.start(`Unlock your Ledger and open the ${appName} APP.`)
} else {
throw e
}

statusAdded = true
await PromiseUtils.sleep(1000)
continue
break
}
}
} finally {
// Don't interrupt an existing status outside of ledgerAction()
if (!statusRunning && statusAdded) {
ux.action.stop()
}
}
}

0 comments on commit 752fe37

Please sign in to comment.