Merge pull request #438 from CosmWasm/broadcast-ledger-signed
Broadcast transactions signed with Ledger
This commit is contained in:
commit
97fd24fc17
@ -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
|
||||
|
||||
33
packages/launchpad-ledger/jasmine-testrunner.js
Executable file
33
packages/launchpad-ledger/jasmine-testrunner.js
Executable 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();
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
133
packages/launchpad-ledger/src/ledgersigner.spec.ts
Normal file
133
packages/launchpad-ledger/src/ledgersigner.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -52,4 +52,8 @@ export class LedgerSigner implements OfflineSigner {
|
||||
signature: encodeSecp256k1Signature(accountForAddress.pubkey, signature),
|
||||
};
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
return this.ledger.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
24
packages/launchpad-ledger/src/testutils.spec.ts
Normal file
24
packages/launchpad-ledger/src/testutils.spec.ts
Normal 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",
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user