diff --git a/packages/launchpad-ledger/README.md b/packages/launchpad-ledger/README.md index 671258cf..b1c736ad 100644 --- a/packages/launchpad-ledger/README.md +++ b/packages/launchpad-ledger/README.md @@ -53,6 +53,25 @@ http://localhost:8000/demo. Then follow the instructions on that page. +## Runnng tests + +The tests in this package require a Ledger device +[initialized with the mnemonic](https://support.ledger.com/hc/en-us/articles/360005434914) +from +[scripts/wasmd/README.md#preset-accounts](https://github.com/CosmWasm/cosmjs/blob/master/scripts/wasmd/README.md#preset-accounts) +(see "Ledger: accounts for Ledger based demos and tests") with an installed +"Cosmos (ATOM)" app. The device must be connected via USB, unlocked and the +Cosmos app must be opened. The tests require the user to manually approve +transactions. Start a local wasmd blockchain as described in +[scripts/wasmd/README.md](https://github.com/CosmWasm/cosmjs/blob/master/scripts/wasmd/README.md) +and execute: + +```sh +export LEDGER_ENABLED=1 +export WASMD_ENABLED=1 +yarn test +``` + ## License This package is part of the cosmjs repository, licensed under the Apache License diff --git a/packages/launchpad-ledger/jasmine-testrunner.js b/packages/launchpad-ledger/jasmine-testrunner.js new file mode 100755 index 00000000..7a17962e --- /dev/null +++ b/packages/launchpad-ledger/jasmine-testrunner.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +/* eslint-disable @typescript-eslint/naming-convention */ +require("source-map-support").install(); +const defaultSpecReporterConfig = require("../../jasmine-spec-reporter.config.json"); + +// setup Jasmine +const Jasmine = require("jasmine"); +const jasmine = new Jasmine(); +jasmine.loadConfig({ + spec_dir: "build", + spec_files: ["**/*.spec.js"], + helpers: [], + random: false, + seed: null, + stopSpecOnExpectationFailure: false, +}); +jasmine.jasmine.DEFAULT_TIMEOUT_INTERVAL = 15 * 1000; + +// setup reporter +const { SpecReporter } = require("jasmine-spec-reporter"); +const reporter = new SpecReporter({ + ...defaultSpecReporterConfig, + spec: { + ...defaultSpecReporterConfig.spec, + displaySuccessful: !process.argv.includes("--quiet"), + }, +}); + +// initialize and execute +jasmine.env.clearReporters(); +jasmine.addReporter(reporter); +jasmine.execute(); diff --git a/packages/launchpad-ledger/package.json b/packages/launchpad-ledger/package.json index 2e882555..022644b0 100644 --- a/packages/launchpad-ledger/package.json +++ b/packages/launchpad-ledger/package.json @@ -28,13 +28,15 @@ "format-text": "prettier --write --prose-wrap always --print-width 80 \"./*.md\"", "lint": "eslint --max-warnings 0 \"**/*.{js,ts}\"", "lint-fix": "eslint --max-warnings 0 \"**/*.{js,ts}\" --fix", - "move-types": "shx rm -rf ./types/* && shx mv build/types/* ./types && rm -rf ./types/testdata && shx rm -rf ./types/demo", + "premove-types": "shx rm -rf ./build/types/demo", + "move-types": "shx rm -r ./types/* && shx mv build/types/* ./types && rm -rf ./types/testdata && shx rm -f ./types/*.spec.d.ts", "format-types": "prettier --write --loglevel warn \"./types/**/*.d.ts\"", "prebuild": "shx rm -rf ./build", "build": "tsc", "postbuild": "yarn move-types && yarn format-types", "build-or-skip": "[ -n \"$SKIP_BUILD\" ] || yarn build", - "test": "echo 'Please check README for information on how to manually run the demo'", + "test-node": "node jasmine-testrunner.js", + "test": "yarn build-or-skip && yarn test-node", "demo-node": "yarn build-or-skip && node ./demo/node.js", "coverage": "nyc --reporter=text --reporter=lcov yarn test --quiet", "pack-web": "yarn build-or-skip && webpack --mode development --config webpack.demo.config.js" diff --git a/packages/launchpad-ledger/src/launchpadledger.ts b/packages/launchpad-ledger/src/launchpadledger.ts index 316c9fb1..fc74738f 100644 --- a/packages/launchpad-ledger/src/launchpadledger.ts +++ b/packages/launchpad-ledger/src/launchpadledger.ts @@ -18,6 +18,12 @@ export interface LedgerAppErrorResponse { } /* eslint-enable */ +interface ConnectedApp { + /** The transport used by the app */ + readonly transport: Transport; + readonly app: CosmosApp; +} + const defaultInteractionTimeout = 120; // seconds to wait for user action on Ledger, currently is always limited to 60 const requiredCosmosAppVersion = "1.5.3"; @@ -53,7 +59,7 @@ export class LaunchpadLedger { private readonly testModeAllowed: boolean; private readonly hdPaths: readonly HdPath[]; private readonly prefix: string; - private cosmosApp: CosmosApp | null; + private connectedApp: ConnectedApp | null; public readonly platform: string; public readonly userAgent: string | null; @@ -70,7 +76,7 @@ export class LaunchpadLedger { this.testModeAllowed = testModeAllowed; this.hdPaths = hdPaths; this.prefix = prefix; - this.cosmosApp = null; + this.connectedApp = null; try { this.platform = navigator.platform; @@ -81,28 +87,11 @@ export class LaunchpadLedger { } } - public async connect(timeout = defaultInteractionTimeout): Promise { - // assume good connection if connected once - if (this.cosmosApp) { - return this; - } - - if (this.platform !== "node") { - verifyBrowserIsSupported(this.platform, this.userAgent); - } - - const transport = await this.createTransport(timeout * 1000); - this.cosmosApp = new CosmosApp(transport); - - await this.verifyDeviceIsReady(); - return this; - } - public async getCosmosAppVersion(): Promise { - await this.connect(); - assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + await this.ensureConnected(); + assert(this.connectedApp, "Cosmos Ledger App is not connected"); - const response = await this.cosmosApp.getVersion(); + const response = await this.connectedApp.app.getVersion(); this.handleLedgerErrors(response); // eslint-disable-next-line @typescript-eslint/naming-convention const { major, minor, patch, test_mode: testMode } = response as VersionResponse; @@ -111,12 +100,12 @@ export class LaunchpadLedger { } public async getPubkey(hdPath?: HdPath): Promise { - await this.connect(); - assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + await this.ensureConnected(); + assert(this.connectedApp, "Cosmos Ledger App is not connected"); const hdPathToUse = hdPath || this.hdPaths[0]; // ledger-cosmos-js hardens the first three indices - const response = await this.cosmosApp.publicKey(unharden(hdPathToUse)); + const response = await this.connectedApp.app.publicKey(unharden(hdPathToUse)); this.handleLedgerErrors(response); return Uint8Array.from((response as PublicKeyResponse).compressed_pk); } @@ -135,16 +124,42 @@ export class LaunchpadLedger { } public async sign(message: Uint8Array, hdPath?: HdPath): Promise { - await this.connect(); - assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + await this.ensureConnected(); + assert(this.connectedApp, "Cosmos Ledger App is not connected"); const hdPathToUse = hdPath || this.hdPaths[0]; // ledger-cosmos-js hardens the first three indices - const response = await this.cosmosApp.sign(unharden(hdPathToUse), fromUtf8(message)); + const response = await this.connectedApp.app.sign(unharden(hdPathToUse), fromUtf8(message)); this.handleLedgerErrors(response, "Transaction signing request was rejected by the user"); return Secp256k1Signature.fromDer((response as SignResponse).signature).toFixedLength(); } + public async disconnect(): Promise { + if (this.connectedApp) { + await this.connectedApp.transport.close(); + this.connectedApp = null; + } + } + + private async ensureConnected(timeout = defaultInteractionTimeout): Promise { + // assume good connection if connected once + if (this.connectedApp) { + return; + } + + if (this.platform !== "node") { + verifyBrowserIsSupported(this.platform, this.userAgent); + } + + const transport = await this.createTransport(timeout * 1000); + this.connectedApp = { + transport: transport, + app: new CosmosApp(transport), + }; + + await this.verifyDeviceIsReady(); + } + private async createTransport(timeout: number): Promise { // HACK: Use a variable to get webpack to ignore this const nodeJsTransportPackageName = "@ledgerhq/hw-transport-node-hid"; @@ -189,10 +204,10 @@ export class LaunchpadLedger { } private async getOpenAppName(): Promise { - await this.connect(); - assert(this.cosmosApp, "Cosmos Ledger App is not connected"); + await this.ensureConnected(); + assert(this.connectedApp, "Cosmos Ledger App is not connected"); - const response = await this.cosmosApp.appInfo(); + const response = await this.connectedApp.app.appInfo(); this.handleLedgerErrors(response); return (response as AppInfoResponse).appName; } diff --git a/packages/launchpad-ledger/src/ledgersigner.spec.ts b/packages/launchpad-ledger/src/ledgersigner.spec.ts new file mode 100644 index 00000000..9ab4addf --- /dev/null +++ b/packages/launchpad-ledger/src/ledgersigner.spec.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; +import { fromBase64 } from "@cosmjs/encoding"; +import { + coins, + isBroadcastTxSuccess, + makeCosmoshubPath, + makeSignDoc, + Msg, + serializeSignDoc, + SigningCosmosClient, + StdFee, +} from "@cosmjs/launchpad"; +import { assert, sleep } from "@cosmjs/utils"; + +import { LedgerSigner } from "./ledgersigner"; +import { pendingWithoutLedger, pendingWithoutWasmd, wasmd } from "./testutils.spec"; + +const interactiveTimeout = 120_000; + +describe("LedgerSigner", () => { + const defaultChainId = "testing"; + const defaultFee: StdFee = { + amount: coins(100, "ucosm"), + gas: "250", + }; + const defaultMemo = "Some memo"; + const defaultSequence = "0"; + const defaultAccountNumber = "42"; + const defaultRecipient = "cosmos1p6xs63q4g7np99ttv5nd3yzkt8n4qxa47w8aea"; + + describe("getAccount", () => { + it("works", async () => { + pendingWithoutLedger(); + const signer = new LedgerSigner({ + testModeAllowed: true, + hdPaths: [makeCosmoshubPath(0), makeCosmoshubPath(1), makeCosmoshubPath(10)], + }); + + const accounts = await signer.getAccounts(); + expect(accounts.length).toEqual(3); + expect(accounts).toEqual([ + { + address: "cosmos1p6xs63q4g7np99ttv5nd3yzkt8n4qxa47w8aea", + algo: "secp256k1", + pubkey: fromBase64("A66JoCNaNSXDsyj4qW7JgqXPTz5rOnfE6EKEArf4jJEK"), + }, + { + address: "cosmos1meeu3jl268txxytwmmrsljk8rawh6n2majstn2", + algo: "secp256k1", + pubkey: fromBase64("AtvmGuZvEN3NwL05BQdxl3XygUf+Vl/930fhFMt1HTyU"), + }, + { + address: "cosmos1f3pws3ztnp3s4nn5zxqdrl9vlqv5avkqmlrus4", + algo: "secp256k1", + pubkey: fromBase64("A2ZnLEcbpyjS30H5UF1vezq29aBcT9oo5EARATIW9Cpj"), + }, + ]); + + await signer.disconnect(); + }); + }); + + describe("sign", () => { + afterEach(async () => { + // It seems the Ledger device needs a bit of time to recover + await sleep(500); + }); + + it( + "returns valid signature", + async () => { + pendingWithoutLedger(); + const signer = new LedgerSigner({ + testModeAllowed: true, + hdPaths: [makeCosmoshubPath(0), makeCosmoshubPath(1), makeCosmoshubPath(10)], + }); + + const [fistAccount] = await signer.getAccounts(); + + const msgs: readonly Msg[] = [ + { + type: "cosmos-sdk/MsgSend", + value: { + amount: coins(1234567, "ucosm"), + from_address: fistAccount.address, + to_address: defaultRecipient, + }, + }, + ]; + const signDoc = makeSignDoc( + msgs, + defaultFee, + defaultChainId, + defaultMemo, + defaultAccountNumber, + defaultSequence, + ); + const { signed, signature } = await signer.sign(fistAccount.address, signDoc); + expect(signed).toEqual(signDoc); + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), + new Sha256(serializeSignDoc(signed)).digest(), + fistAccount.pubkey, + ); + expect(valid).toEqual(true); + + await signer.disconnect(); + }, + interactiveTimeout, + ); + + it( + "creates signature accepted by launchpad backend", + async () => { + pendingWithoutLedger(); + pendingWithoutWasmd(); + const signer = new LedgerSigner({ + testModeAllowed: true, + hdPaths: [makeCosmoshubPath(0), makeCosmoshubPath(1), makeCosmoshubPath(10)], + }); + const [fistAccount] = await signer.getAccounts(); + + const client = new SigningCosmosClient(wasmd.endpoint, fistAccount.address, signer); + const result = await client.sendTokens(defaultRecipient, coins(1234567, "ucosm")); + assert(isBroadcastTxSuccess(result)); + + await signer.disconnect(); + }, + interactiveTimeout, + ); + }); +}); diff --git a/packages/launchpad-ledger/src/ledgersigner.ts b/packages/launchpad-ledger/src/ledgersigner.ts index 224a55e7..8c863165 100644 --- a/packages/launchpad-ledger/src/ledgersigner.ts +++ b/packages/launchpad-ledger/src/ledgersigner.ts @@ -52,4 +52,8 @@ export class LedgerSigner implements OfflineSigner { signature: encodeSecp256k1Signature(accountForAddress.pubkey, signature), }; } + + public async disconnect(): Promise { + return this.ledger.disconnect(); + } } diff --git a/packages/launchpad-ledger/src/testutils.spec.ts b/packages/launchpad-ledger/src/testutils.spec.ts new file mode 100644 index 00000000..d9b011b3 --- /dev/null +++ b/packages/launchpad-ledger/src/testutils.spec.ts @@ -0,0 +1,24 @@ +export function ledgerEnabled(): boolean { + return !!process.env.LEDGER_ENABLED; +} + +export function pendingWithoutLedger(): void { + if (!ledgerEnabled()) { + return pending("Set LEDGER_ENABLED to enable Wasmd based tests"); + } +} + +export function wasmdEnabled(): boolean { + return !!process.env.WASMD_ENABLED; +} + +export function pendingWithoutWasmd(): void { + if (!wasmdEnabled()) { + return pending("Set WASMD_ENABLED to enable Wasmd based tests"); + } +} + +export const wasmd = { + endpoint: "http://localhost:1317", + chainId: "testing", +}; diff --git a/packages/launchpad-ledger/types/launchpadledger.d.ts b/packages/launchpad-ledger/types/launchpadledger.d.ts index 2cc42f18..6e729254 100644 --- a/packages/launchpad-ledger/types/launchpadledger.d.ts +++ b/packages/launchpad-ledger/types/launchpadledger.d.ts @@ -12,16 +12,17 @@ export declare class LaunchpadLedger { private readonly testModeAllowed; private readonly hdPaths; private readonly prefix; - private cosmosApp; + private connectedApp; readonly platform: string; readonly userAgent: string | null; constructor(options?: LaunchpadLedgerOptions); - connect(timeout?: number): Promise; getCosmosAppVersion(): Promise; getPubkey(hdPath?: HdPath): Promise; getPubkeys(): Promise; getCosmosAddress(pubkey?: Uint8Array): Promise; sign(message: Uint8Array, hdPath?: HdPath): Promise; + disconnect(): Promise; + private ensureConnected; private createTransport; private verifyAppMode; private getOpenAppName; diff --git a/packages/launchpad-ledger/types/ledgersigner.d.ts b/packages/launchpad-ledger/types/ledgersigner.d.ts index 6ddfee8b..8a81c649 100644 --- a/packages/launchpad-ledger/types/ledgersigner.d.ts +++ b/packages/launchpad-ledger/types/ledgersigner.d.ts @@ -8,4 +8,5 @@ export declare class LedgerSigner implements OfflineSigner { constructor(options?: LaunchpadLedgerOptions); getAccounts(): Promise; sign(signerAddress: string, signDoc: StdSignDoc): Promise; + disconnect(): Promise; }