From 33a7824215380eb4386c769b762177e344bbd0ef Mon Sep 17 00:00:00 2001 From: willclarktech Date: Wed, 9 Sep 2020 18:17:18 +0200 Subject: [PATCH] launchpad-ledger: Add LaunchpadLedger class --- packages/launchpad-ledger/src/index.ts | 1 + .../launchpad-ledger/src/launchpadledger.ts | 230 ++++++++++++++++++ packages/launchpad-ledger/types/index.d.ts | 1 + .../types/launchpadledger.d.ts | 29 +++ 4 files changed, 261 insertions(+) create mode 100644 packages/launchpad-ledger/src/index.ts create mode 100644 packages/launchpad-ledger/src/launchpadledger.ts create mode 100644 packages/launchpad-ledger/types/index.d.ts create mode 100644 packages/launchpad-ledger/types/launchpadledger.d.ts diff --git a/packages/launchpad-ledger/src/index.ts b/packages/launchpad-ledger/src/index.ts new file mode 100644 index 00000000..0ca77769 --- /dev/null +++ b/packages/launchpad-ledger/src/index.ts @@ -0,0 +1 @@ +export { LaunchpadLedger } from "./launchpadledger"; diff --git a/packages/launchpad-ledger/src/launchpadledger.ts b/packages/launchpad-ledger/src/launchpadledger.ts new file mode 100644 index 00000000..3f9f4216 --- /dev/null +++ b/packages/launchpad-ledger/src/launchpadledger.ts @@ -0,0 +1,230 @@ +import { Secp256k1Signature } from "@cosmjs/crypto"; +import { fromUtf8 } from "@cosmjs/encoding"; +import { assert } from "@cosmjs/utils"; +import Transport from "@ledgerhq/hw-transport"; +import TransportWebUsb from "@ledgerhq/hw-transport-webusb"; +import CosmosApp, { + AppInfoResponse, + PublicKeyResponse, + SignResponse, + VersionResponse, +} from "ledger-cosmos-js"; +import semver from "semver"; + +const defaultInteractionTimeout = 120; // seconds to wait for user action on Ledger, currently is always limited to 60 +const requiredCosmosAppVersion = "1.5.3"; + +function isWindows(platform: string): boolean { + return platform.indexOf("Win") > -1; +} + +function verifyBrowserIsSupported(platform: string, userAgent: string): void { + if (isWindows(platform)) { + throw new Error("Windows is not currently supported."); + } + + const ua = userAgent.toLowerCase(); + const isChrome = /chrome|crios/.test(ua) && !/edge|opr\//.test(ua); + if (!isChrome) { + throw new Error("Your browser does not support Ledger devices."); + } +} + +async function createTransport(timeout: number): Promise { + try { + const transport = await TransportWebUsb.create(timeout * 1000); + return transport; + } catch (error) { + const trimmedErrorMessage = error.message.trim(); + if (trimmedErrorMessage.startsWith("No WebUSB interface found for your Ledger device")) { + throw new Error( + "Could not connect to a Ledger device. Please use Ledger Live to upgrade the Ledger firmware to version 1.5.5 or later.", + ); + } + if (trimmedErrorMessage.startsWith("Unable to claim interface")) { + throw new Error("Could not access Ledger device. Is it being used in another tab?"); + } + if (trimmedErrorMessage.startsWith("Not supported")) { + throw new Error( + "Your browser does not seem to support WebUSB yet. Try updating it to the latest version.", + ); + } + if (trimmedErrorMessage.startsWith("No device selected")) { + throw new Error( + "You did not select a Ledger device. If you did not see your Ledger, check if the Ledger is plugged in and unlocked.", + ); + } + + throw error; + } +} + +const cosmosHdPath = [44, 118, 0, 0, 0]; +const cosmosBech32Prefix = "cosmos"; + +export class LaunchpadLedger { + private readonly testModeAllowed: boolean; + private readonly hdPath: number[]; + private readonly prefix: string; + private cosmosApp: CosmosApp | null; + public readonly platform: string; + public readonly userAgent: string; + + constructor( + { testModeAllowed }: { testModeAllowed: boolean } = { testModeAllowed: false }, + hdPath: number[] = cosmosHdPath, + prefix: string = cosmosBech32Prefix, + ) { + this.testModeAllowed = testModeAllowed; + this.hdPath = hdPath; + this.prefix = prefix; + this.cosmosApp = null; + this.platform = navigator.platform; + this.userAgent = navigator.userAgent; + } + + // // quickly test connection and compatibility with the LaunchpadLedger device throwing away the connection + // async testDevice(): Promise { + // // poll device with low timeout to check if the device is connected + // const secondsTimeout = 3; // a lower value always timeouts + // await this.connect(secondsTimeout); + // this.cosmosApp = null; + + // return this; + // } + + async connect(timeout = defaultInteractionTimeout): Promise { + // assume good connection if connected once + if (this.cosmosApp) { + return this; + } + + verifyBrowserIsSupported(this.platform, this.userAgent); + + const transport = await createTransport(timeout * 1000); + this.cosmosApp = new CosmosApp(transport); + + await this.verifyDeviceIsReady(); + return this; + } + + async getCosmosAppVersion(): Promise { + await this.connect(); + assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + + const response = await this.cosmosApp.getVersion(); + this.handleLedgerErrors(response); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { major, minor, patch, test_mode: testMode } = response as VersionResponse; + this.verifyAppMode(testMode); + return `${major}.${minor}.${patch}`; + } + + async getPubKey(): Promise { + await this.connect(); + assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + + const response = await this.cosmosApp.publicKey(this.hdPath); + this.handleLedgerErrors(response); + return (response as PublicKeyResponse).compressed_pk; + } + + async getCosmosAddress(): Promise { + const pubKey = await this.getPubKey(); + return CosmosApp.getBech32FromPK(this.prefix, pubKey); + } + + // async verifyLedgerAddress(): Promise { + // await this.connect(); + // assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + + // const response = await this.cosmosApp.showAddressAndPubKey(this.hdPath, this.bech32Prefix); + // this.handleLedgerErrors(response, { + // rejectionMessage: "Displayed address was rejected by the user", + // }); + // } + + async sign(message: Uint8Array): Promise { + await this.connect(); + assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + + const response = await this.cosmosApp.sign(this.hdPath, fromUtf8(message)); + this.handleLedgerErrors(response, { + rejectionMessage: "Transaction signing request was rejected by the user", + }); + const parsedSignature = Secp256k1Signature.fromDer((response as SignResponse).signature); + return Uint8Array.from([...parsedSignature.r(), ...parsedSignature.s()]); + } + + private verifyAppMode(testMode: boolean): void { + if (testMode && !this.testModeAllowed) { + throw new Error(`DANGER: The Cosmos Ledger app is in test mode and should not be used on mainnet!`); + } + } + + private async getOpenAppName(): Promise { + await this.connect(); + assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + + const response = await this.cosmosApp.appInfo(); + this.handleLedgerErrors(response); + return (response as AppInfoResponse).appName; + } + + private async verifyAppVersion(): Promise { + const version = await this.getCosmosAppVersion(); + if (!semver.gte(version, requiredCosmosAppVersion)) { + throw new Error("Outdated version: Please update Cosmos Ledger App to the latest version."); + } + } + + private async verifyCosmosAppIsOpen(): Promise { + const appName = await this.getOpenAppName(); + + if (appName.toLowerCase() === `dashboard`) { + throw new Error(`Please open the Cosmos Ledger app on your Ledger device.`); + } + if (appName.toLowerCase() !== `cosmos`) { + throw new Error(`Please close ${appName} and open the Cosmos Ledger app on your Ledger device.`); + } + } + + private async verifyDeviceIsReady(): Promise { + await this.verifyAppVersion(); + await this.verifyCosmosAppIsOpen(); + } + + /* eslint-disable @typescript-eslint/naming-convention */ + private handleLedgerErrors( + { + error_message: errorMessage, + device_locked: deviceLocked = false, + }: { error_message: string; device_locked?: boolean }, + { rejectionMessage = "Request was rejected by the user" } = {}, + ): void { + if (deviceLocked) { + throw new Error("Ledger’s screensaver mode is on"); + } + switch (errorMessage) { + case "U2F: Timeout": + throw new Error("Connection timed out. Please try again."); + case "Cosmos app does not seem to be open": + throw new Error("Cosmos app is not open"); + case "Command not allowed": + throw new Error("Transaction rejected"); + case "Transaction rejected": + throw new Error(rejectionMessage); + case "Unknown Status Code: 26628": + throw new Error("Ledger’s screensaver mode is on"); + case "Instruction not supported": + throw new Error( + `Your Cosmos Ledger App is not up to date. Please update to version ${requiredCosmosAppVersion}.`, + ); + case "No errors": + break; + default: + throw new Error(`Ledger Native Error: ${errorMessage}`); + } + } + /* eslint-enable */ +} diff --git a/packages/launchpad-ledger/types/index.d.ts b/packages/launchpad-ledger/types/index.d.ts new file mode 100644 index 00000000..0ca77769 --- /dev/null +++ b/packages/launchpad-ledger/types/index.d.ts @@ -0,0 +1 @@ +export { LaunchpadLedger } from "./launchpadledger"; diff --git a/packages/launchpad-ledger/types/launchpadledger.d.ts b/packages/launchpad-ledger/types/launchpadledger.d.ts new file mode 100644 index 00000000..323bcecc --- /dev/null +++ b/packages/launchpad-ledger/types/launchpadledger.d.ts @@ -0,0 +1,29 @@ +/// +export declare class LaunchpadLedger { + private readonly testModeAllowed; + private readonly hdPath; + private readonly prefix; + private cosmosApp; + readonly platform: string; + readonly userAgent: string; + constructor( + { + testModeAllowed, + }?: { + testModeAllowed: boolean; + }, + hdPath?: number[], + prefix?: string, + ); + connect(timeout?: number): Promise; + getCosmosAppVersion(): Promise; + getPubKey(): Promise; + getCosmosAddress(): Promise; + sign(message: Uint8Array): Promise; + private verifyAppMode; + private getOpenAppName; + private verifyAppVersion; + private verifyCosmosAppIsOpen; + private verifyDeviceIsReady; + private handleLedgerErrors; +}