Merge pull request #438 from CosmWasm/broadcast-ledger-signed

Broadcast transactions signed with Ledger
This commit is contained in:
Simon Warta 2020-09-30 15:19:28 +02:00 committed by GitHub
commit 97fd24fc17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 267 additions and 35 deletions

View File

@ -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

View File

@ -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();

View File

@ -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"

View File

@ -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<LaunchpadLedger> {
// 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<string> {
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<Uint8Array> {
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<Uint8Array> {
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<void> {
if (this.connectedApp) {
await this.connectedApp.transport.close();
this.connectedApp = null;
}
}
private async ensureConnected(timeout = defaultInteractionTimeout): Promise<void> {
// 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<Transport> {
// 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<string> {
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;
}

View File

@ -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,
);
});
});

View File

@ -52,4 +52,8 @@ export class LedgerSigner implements OfflineSigner {
signature: encodeSecp256k1Signature(accountForAddress.pubkey, signature),
};
}
public async disconnect(): Promise<void> {
return this.ledger.disconnect();
}
}

View File

@ -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",
};

View File

@ -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<LaunchpadLedger>;
getCosmosAppVersion(): Promise<string>;
getPubkey(hdPath?: HdPath): Promise<Uint8Array>;
getPubkeys(): Promise<readonly Uint8Array[]>;
getCosmosAddress(pubkey?: Uint8Array): Promise<string>;
sign(message: Uint8Array, hdPath?: HdPath): Promise<Uint8Array>;
disconnect(): Promise<void>;
private ensureConnected;
private createTransport;
private verifyAppMode;
private getOpenAppName;

View File

@ -8,4 +8,5 @@ export declare class LedgerSigner implements OfflineSigner {
constructor(options?: LaunchpadLedgerOptions);
getAccounts(): Promise<readonly AccountData[]>;
sign(signerAddress: string, signDoc: StdSignDoc): Promise<SignResponse>;
disconnect(): Promise<void>;
}