From 40b1daf381830d5718cb2fbf6cfe95ac90435f9b Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 24 Mar 2021 13:30:14 +0100 Subject: [PATCH 1/3] Allow SigningStargateClient to work offline --- CHANGELOG.md | 2 + .../stargate/src/signingstargateclient.ts | 20 +++++++- packages/stargate/src/stargateclient.ts | 48 +++++++++++++------ 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20708cff..877e7d3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ and this project adheres to signed transactions without broadcasting them directly. The new type `SignerData` can be passed into `.sign` to skip querying account number, sequence and chain ID. +- @cosmjs/stargate: Add constructor `SigningStargateClient.offline` which does + not connect to Tendermint. This allows offline signing. ### Changed diff --git a/packages/stargate/src/signingstargateclient.ts b/packages/stargate/src/signingstargateclient.ts index c505b1f9..c16d5a2d 100644 --- a/packages/stargate/src/signingstargateclient.ts +++ b/packages/stargate/src/signingstargateclient.ts @@ -139,8 +139,24 @@ export class SigningStargateClient extends StargateClient { return new SigningStargateClient(tmClient, signer, options); } + /** + * Creates a client in offline mode. + * + * This should only be used in niche cases where you know exactly what you're doing, + * e.g. when building an offline signing application. + * + * When you try to use online functionality with such a signer, an + * exception will be raised. + */ + public static async offline( + signer: OfflineSigner, + options: SigningStargateClientOptions = {}, + ): Promise { + return new SigningStargateClient(undefined, signer, options); + } + private constructor( - tmClient: Tendermint34Client, + tmClient: Tendermint34Client | undefined, signer: OfflineSigner, options: SigningStargateClientOptions, ) { @@ -193,7 +209,7 @@ export class SigningStargateClient extends StargateClient { * * You can pass signer data (account number, sequence and chain ID) explicitly instead of querying them * from the chain. This is needed when signing for a multisig account, but it also allows for offline signing - * (which probably fails right now because in other places SigningStargateClient assumes you are online). + * (See the SigningStargateClient.offline constructor). */ public async sign( signerAddress: string, diff --git a/packages/stargate/src/stargateclient.ts b/packages/stargate/src/stargateclient.ts index be1426d6..6ba5914a 100644 --- a/packages/stargate/src/stargateclient.ts +++ b/packages/stargate/src/stargateclient.ts @@ -90,8 +90,8 @@ export interface PrivateStargateClient { } export class StargateClient { - private readonly tmClient: Tendermint34Client; - private readonly queryClient: QueryClient & AuthExtension & BankExtension; + private readonly tmClient: Tendermint34Client | undefined; + private readonly queryClient: (QueryClient & AuthExtension & BankExtension) | undefined; private chainId: string | undefined; public static async connect(endpoint: string): Promise { @@ -99,14 +99,32 @@ export class StargateClient { return new StargateClient(tmClient); } - protected constructor(tmClient: Tendermint34Client) { - this.tmClient = tmClient; - this.queryClient = QueryClient.withExtensions(tmClient, setupAuthExtension, setupBankExtension); + protected constructor(tmClient: Tendermint34Client | undefined) { + if (tmClient) { + this.tmClient = tmClient; + this.queryClient = QueryClient.withExtensions(tmClient, setupAuthExtension, setupBankExtension); + } + } + + protected forceGetTmClient(): Tendermint34Client { + if (!this.tmClient) { + throw new Error( + "Tendermint client not available. You cannot use online functionality in offline mode.", + ); + } + return this.tmClient; + } + + protected forceGetQueryClient(): QueryClient & AuthExtension & BankExtension { + if (!this.queryClient) { + throw new Error("Query client not available. You cannot use online functionality in offline mode."); + } + return this.queryClient; } public async getChainId(): Promise { if (!this.chainId) { - const response = await this.tmClient.status(); + const response = await this.forceGetTmClient().status(); const chainId = response.nodeInfo.network; if (!chainId) throw new Error("Chain ID must not be empty"); this.chainId = chainId; @@ -116,20 +134,20 @@ export class StargateClient { } public async getHeight(): Promise { - const status = await this.tmClient.status(); + const status = await this.forceGetTmClient().status(); return status.syncInfo.latestBlockHeight; } // this is nice to display data to the user, but is slower public async getAccount(searchAddress: string): Promise { - const account = await this.queryClient.auth.account(searchAddress); + const account = await this.forceGetQueryClient().auth.account(searchAddress); return account ? accountFromAny(account) : null; } // if we just need to get the sequence for signing a transaction, let's make this faster // (no need to wait a block before submitting) public async getAccountUnverified(searchAddress: string): Promise { - const account = await this.queryClient.auth.unverified.account(searchAddress); + const account = await this.forceGetQueryClient().auth.unverified.account(searchAddress); return account ? accountFromAny(account) : null; } @@ -146,7 +164,7 @@ export class StargateClient { } public async getBlock(height?: number): Promise { - const response = await this.tmClient.block(height); + const response = await this.forceGetTmClient().block(height); return { id: toHex(response.blockId.hash).toUpperCase(), header: { @@ -163,7 +181,7 @@ export class StargateClient { } public async getBalance(address: string, searchDenom: string): Promise { - const balance = await this.queryClient.bank.balance(address, searchDenom); + const balance = await this.forceGetQueryClient().bank.balance(address, searchDenom); return balance ? coinFromProto(balance) : null; } @@ -174,7 +192,7 @@ export class StargateClient { * proofs from such a method. */ public async getAllBalancesUnverified(address: string): Promise { - const balances = await this.queryClient.bank.unverified.allBalances(address); + const balances = await this.forceGetQueryClient().bank.unverified.allBalances(address); return balances.map(coinFromProto); } @@ -222,11 +240,11 @@ export class StargateClient { } public disconnect(): void { - this.tmClient.disconnect(); + if (this.tmClient) this.tmClient.disconnect(); } public async broadcastTx(tx: Uint8Array): Promise { - const response = await this.tmClient.broadcastTxCommit({ tx }); + const response = await this.forceGetTmClient().broadcastTxCommit({ tx }); if (broadcastTxCommitSuccess(response)) { return { height: response.height, @@ -253,7 +271,7 @@ export class StargateClient { } private async txsQuery(query: string): Promise { - const results = await this.tmClient.txSearchAll({ query: query }); + const results = await this.forceGetTmClient().txSearchAll({ query: query }); return results.txs.map((tx) => { return { height: tx.height, From 6d361e2c88a278499579f10a252f5d43cb02d41b Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 24 Mar 2021 14:08:49 +0100 Subject: [PATCH 2/3] Restructure makeMultisignedTx test to use offline signers --- packages/stargate/src/multisignature.spec.ts | 246 +++++++++++++------ 1 file changed, 167 insertions(+), 79 deletions(-) diff --git a/packages/stargate/src/multisignature.spec.ts b/packages/stargate/src/multisignature.spec.ts index 1d4a090f..42695155 100644 --- a/packages/stargate/src/multisignature.spec.ts +++ b/packages/stargate/src/multisignature.spec.ts @@ -6,7 +6,7 @@ import { MsgSend } from "./codec/cosmos/bank/v1beta1/tx"; import { TxRaw } from "./codec/cosmos/tx/v1beta1/tx"; import { makeCompactBitArray, makeMultisignedTx } from "./multisignature"; import { SignerData, SigningStargateClient } from "./signingstargateclient"; -import { assertIsBroadcastTxSuccess } from "./stargateclient"; +import { assertIsBroadcastTxSuccess, StargateClient } from "./stargateclient"; import { faucet, pendingWithoutSimapp, simapp } from "./testutils.spec"; describe("multisignature", () => { @@ -165,90 +165,178 @@ describe("multisignature", () => { describe("makeMultisignedTx", () => { it("works", async () => { pendingWithoutSimapp(); - const wallet0 = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); - const wallet1 = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); - const wallet2 = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(2)); - const wallet3 = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(3)); - const wallet4 = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(4)); - const pubkey0 = encodeSecp256k1Pubkey((await wallet0.getAccounts())[0].pubkey); - const pubkey1 = encodeSecp256k1Pubkey((await wallet1.getAccounts())[0].pubkey); - const pubkey2 = encodeSecp256k1Pubkey((await wallet2.getAccounts())[0].pubkey); - const pubkey3 = encodeSecp256k1Pubkey((await wallet3.getAccounts())[0].pubkey); - const pubkey4 = encodeSecp256k1Pubkey((await wallet4.getAccounts())[0].pubkey); - const address0 = (await wallet0.getAccounts())[0].address; - const address1 = (await wallet1.getAccounts())[0].address; - const address2 = (await wallet2.getAccounts())[0].address; - const address3 = (await wallet3.getAccounts())[0].address; - const address4 = (await wallet4.getAccounts())[0].address; - const multisigPubkey = createMultisigThresholdPubkey([pubkey0, pubkey1, pubkey2, pubkey3, pubkey4], 2); - const multisigAddress = pubkeyToAddress(multisigPubkey, "cosmos"); - expect(multisigAddress).toEqual("cosmos1h90ml36rcu7yegwduzgzderj2jmq49hcpfclw9"); + const multisigAccountAddress = "cosmos1h90ml36rcu7yegwduzgzderj2jmq49hcpfclw9"; - const client0 = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet0); - const client1 = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet1); - const client2 = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet2); - const client3 = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet3); - const client4 = await SigningStargateClient.connectWithSigner(simapp.tendermintUrl, wallet4); + // On the composer's machine signing instructions are created. + // The composer does not need to be one of the signers. + const signingInstruction = await (async () => { + const client = await StargateClient.connect(simapp.tendermintUrl); + const accountOnChain = await client.getAccount(multisigAccountAddress); + assert(accountOnChain, "Account does not exist on chain"); - const msgSend: MsgSend = { - fromAddress: multisigAddress, - toAddress: "cosmos19rvl6ja9h0erq9dc2xxfdzypc739ej8k5esnhg", - amount: coins(1234, "ucosm"), - }; - const msg = { - typeUrl: "/cosmos.bank.v1beta1.MsgSend", - value: msgSend, - }; - const gasLimit = 200000; - const fee = { - amount: coins(2000, "ucosm"), - gas: gasLimit.toString(), - }; - const memo = "Use your tokens wisely"; + const msgSend: MsgSend = { + fromAddress: multisigAccountAddress, + toAddress: "cosmos19rvl6ja9h0erq9dc2xxfdzypc739ej8k5esnhg", + amount: coins(1234, "ucosm"), + }; + const msg = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: msgSend, + }; + const gasLimit = 200000; + const fee = { + amount: coins(2000, "ucosm"), + gas: gasLimit.toString(), + }; - const multisigAccount = await client0.getAccount(multisigAddress); - assert(multisigAccount, "Account does not exist on chain"); - const signerData: SignerData = { - accountNumber: multisigAccount.accountNumber, - sequence: multisigAccount.sequence, - chainId: await client0.getChainId(), - }; + return { + accountNumber: accountOnChain.accountNumber, + sequence: accountOnChain.sequence, + chainId: await client.getChainId(), + msgs: [msg], + fee: fee, + memo: "Use your tokens wisely", + }; + })(); - const { - bodyBytes, - signatures: [signature0], - } = await client0.sign(faucet.address0, [msg], fee, memo, signerData); - const { - signatures: [signature1], - } = await client1.sign(faucet.address1, [msg], fee, memo, signerData); - const { - signatures: [signature2], - } = await client2.sign(faucet.address2, [msg], fee, memo, signerData); - const { - signatures: [signature3], - } = await client3.sign(faucet.address3, [msg], fee, memo, signerData); - const { - signatures: [signature4], - } = await client4.sign(faucet.address4, [msg], fee, memo, signerData); + // Signing environment 0 + const [pubkey0, signature0, bodyBytes] = await (async () => { + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); + const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); + const address = (await wallet.getAccounts())[0].address; + const signingClient = await SigningStargateClient.offline(wallet); + const signerData: SignerData = { + accountNumber: signingInstruction.accountNumber, + sequence: signingInstruction.sequence, + chainId: signingInstruction.chainId, + }; + const { bodyBytes: bb, signatures } = await signingClient.sign( + address, + signingInstruction.msgs, + signingInstruction.fee, + signingInstruction.memo, + signerData, + ); + return [pubkey, signatures[0], bb] as const; + })(); - const signatures = new Map([ - [address0, signature0], - [address1, signature1], - [address2, signature2], - [address3, signature3], - [address4, signature4], - ]); - const signedTx = makeMultisignedTx( - multisigPubkey, - multisigAccount.sequence, - fee, - bodyBytes, - signatures, - ); + // Signing environment 1 + const [pubkey1, signature1] = await (async () => { + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); + const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); + const address = (await wallet.getAccounts())[0].address; + const signingClient = await SigningStargateClient.offline(wallet); + const signerData: SignerData = { + accountNumber: signingInstruction.accountNumber, + sequence: signingInstruction.sequence, + chainId: signingInstruction.chainId, + }; + const { signatures } = await signingClient.sign( + address, + signingInstruction.msgs, + signingInstruction.fee, + signingInstruction.memo, + signerData, + ); + return [pubkey, signatures[0]] as const; + })(); - // ensure signature is valid - const result = await client0.broadcastTx(Uint8Array.from(TxRaw.encode(signedTx).finish())); - assertIsBroadcastTxSuccess(result); + // Signing environment 2 + const [pubkey2, signature2] = await (async () => { + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(2)); + const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); + const address = (await wallet.getAccounts())[0].address; + const signingClient = await SigningStargateClient.offline(wallet); + const signerData: SignerData = { + accountNumber: signingInstruction.accountNumber, + sequence: signingInstruction.sequence, + chainId: signingInstruction.chainId, + }; + const { signatures } = await signingClient.sign( + address, + signingInstruction.msgs, + signingInstruction.fee, + signingInstruction.memo, + signerData, + ); + return [pubkey, signatures[0]] as const; + })(); + + // Signing environment 3 + const [pubkey3, signature3] = await (async () => { + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(3)); + const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); + const address = (await wallet.getAccounts())[0].address; + const signingClient = await SigningStargateClient.offline(wallet); + const signerData: SignerData = { + accountNumber: signingInstruction.accountNumber, + sequence: signingInstruction.sequence, + chainId: signingInstruction.chainId, + }; + const { signatures } = await signingClient.sign( + address, + signingInstruction.msgs, + signingInstruction.fee, + signingInstruction.memo, + signerData, + ); + return [pubkey, signatures[0]] as const; + })(); + + // Signing environment 4 + const [pubkey4, signature4] = await (async () => { + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(4)); + const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); + const address = (await wallet.getAccounts())[0].address; + const signingClient = await SigningStargateClient.offline(wallet); + const signerData: SignerData = { + accountNumber: signingInstruction.accountNumber, + sequence: signingInstruction.sequence, + chainId: signingInstruction.chainId, + }; + const { signatures } = await signingClient.sign( + address, + signingInstruction.msgs, + signingInstruction.fee, + signingInstruction.memo, + signerData, + ); + return [pubkey, signatures[0]] as const; + })(); + + // From here on, no private keys are required anymore. Any anonymous entity + // can collect, assemble and broadcast. + { + const multisigPubkey = createMultisigThresholdPubkey( + [pubkey0, pubkey1, pubkey2, pubkey3, pubkey4], + 2, + ); + expect(pubkeyToAddress(multisigPubkey, "cosmos")).toEqual(multisigAccountAddress); + + const address0 = pubkeyToAddress(pubkey0, "cosmos"); + const address1 = pubkeyToAddress(pubkey1, "cosmos"); + const address2 = pubkeyToAddress(pubkey2, "cosmos"); + const address3 = pubkeyToAddress(pubkey3, "cosmos"); + const address4 = pubkeyToAddress(pubkey4, "cosmos"); + + const broadcaster = await StargateClient.connect(simapp.tendermintUrl); + const signedTx = makeMultisignedTx( + multisigPubkey, + signingInstruction.sequence, + signingInstruction.fee, + bodyBytes, + new Map([ + [address0, signature0], + [address1, signature1], + [address2, signature2], + [address3, signature3], + [address4, signature4], + ]), + ); + // ensure signature is valid + const result = await broadcaster.broadcastTx(Uint8Array.from(TxRaw.encode(signedTx).finish())); + assertIsBroadcastTxSuccess(result); + } }); }); }); From fc103ea31e8e6a28b3aebba4ada5193e169bdb13 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 24 Mar 2021 14:14:48 +0100 Subject: [PATCH 3/3] Use map for signing environments --- packages/stargate/src/multisignature.spec.ts | 132 ++++--------------- 1 file changed, 28 insertions(+), 104 deletions(-) diff --git a/packages/stargate/src/multisignature.spec.ts b/packages/stargate/src/multisignature.spec.ts index 42695155..d6859597 100644 --- a/packages/stargate/src/multisignature.spec.ts +++ b/packages/stargate/src/multisignature.spec.ts @@ -199,110 +199,34 @@ describe("multisignature", () => { }; })(); - // Signing environment 0 - const [pubkey0, signature0, bodyBytes] = await (async () => { - const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(0)); - const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); - const address = (await wallet.getAccounts())[0].address; - const signingClient = await SigningStargateClient.offline(wallet); - const signerData: SignerData = { - accountNumber: signingInstruction.accountNumber, - sequence: signingInstruction.sequence, - chainId: signingInstruction.chainId, - }; - const { bodyBytes: bb, signatures } = await signingClient.sign( - address, - signingInstruction.msgs, - signingInstruction.fee, - signingInstruction.memo, - signerData, - ); - return [pubkey, signatures[0], bb] as const; - })(); - - // Signing environment 1 - const [pubkey1, signature1] = await (async () => { - const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); - const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); - const address = (await wallet.getAccounts())[0].address; - const signingClient = await SigningStargateClient.offline(wallet); - const signerData: SignerData = { - accountNumber: signingInstruction.accountNumber, - sequence: signingInstruction.sequence, - chainId: signingInstruction.chainId, - }; - const { signatures } = await signingClient.sign( - address, - signingInstruction.msgs, - signingInstruction.fee, - signingInstruction.memo, - signerData, - ); - return [pubkey, signatures[0]] as const; - })(); - - // Signing environment 2 - const [pubkey2, signature2] = await (async () => { - const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(2)); - const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); - const address = (await wallet.getAccounts())[0].address; - const signingClient = await SigningStargateClient.offline(wallet); - const signerData: SignerData = { - accountNumber: signingInstruction.accountNumber, - sequence: signingInstruction.sequence, - chainId: signingInstruction.chainId, - }; - const { signatures } = await signingClient.sign( - address, - signingInstruction.msgs, - signingInstruction.fee, - signingInstruction.memo, - signerData, - ); - return [pubkey, signatures[0]] as const; - })(); - - // Signing environment 3 - const [pubkey3, signature3] = await (async () => { - const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(3)); - const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); - const address = (await wallet.getAccounts())[0].address; - const signingClient = await SigningStargateClient.offline(wallet); - const signerData: SignerData = { - accountNumber: signingInstruction.accountNumber, - sequence: signingInstruction.sequence, - chainId: signingInstruction.chainId, - }; - const { signatures } = await signingClient.sign( - address, - signingInstruction.msgs, - signingInstruction.fee, - signingInstruction.memo, - signerData, - ); - return [pubkey, signatures[0]] as const; - })(); - - // Signing environment 4 - const [pubkey4, signature4] = await (async () => { - const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(4)); - const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); - const address = (await wallet.getAccounts())[0].address; - const signingClient = await SigningStargateClient.offline(wallet); - const signerData: SignerData = { - accountNumber: signingInstruction.accountNumber, - sequence: signingInstruction.sequence, - chainId: signingInstruction.chainId, - }; - const { signatures } = await signingClient.sign( - address, - signingInstruction.msgs, - signingInstruction.fee, - signingInstruction.memo, - signerData, - ); - return [pubkey, signatures[0]] as const; - })(); + const [ + [pubkey0, signature0, bodyBytes], + [pubkey1, signature1], + [pubkey2, signature2], + [pubkey3, signature3], + [pubkey4, signature4], + ] = await Promise.all( + [0, 1, 2, 3, 4].map(async (i) => { + // Signing environment + const wallet = await Secp256k1HdWallet.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(i)); + const pubkey = encodeSecp256k1Pubkey((await wallet.getAccounts())[0].pubkey); + const address = (await wallet.getAccounts())[0].address; + const signingClient = await SigningStargateClient.offline(wallet); + const signerData: SignerData = { + accountNumber: signingInstruction.accountNumber, + sequence: signingInstruction.sequence, + chainId: signingInstruction.chainId, + }; + const { bodyBytes: bb, signatures } = await signingClient.sign( + address, + signingInstruction.msgs, + signingInstruction.fee, + signingInstruction.memo, + signerData, + ); + return [pubkey, signatures[0], bb] as const; + }), + ); // From here on, no private keys are required anymore. Any anonymous entity // can collect, assemble and broadcast.