Merge pull request #726 from cosmos/support-offline-siging

Allow SigningStargateClient to work offline
This commit is contained in:
Simon Warta 2021-03-24 15:13:50 +01:00 committed by GitHub
commit f8fafd17b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 144 additions and 96 deletions

View File

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

View File

@ -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,102 @@ 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);
const signatures = new Map<string, Uint8Array>([
[address0, signature0],
[address1, signature1],
[address2, signature2],
[address3, signature3],
[address4, signature4],
]);
const signedTx = makeMultisignedTx(
multisigPubkey,
multisigAccount.sequence,
fee,
bodyBytes,
signatures,
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;
}),
);
// ensure signature is valid
const result = await client0.broadcastTx(Uint8Array.from(TxRaw.encode(signedTx).finish()));
assertIsBroadcastTxSuccess(result);
// 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<string, Uint8Array>([
[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);
}
});
});
});

View File

@ -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<SigningStargateClient> {
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,

View File

@ -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<StargateClient> {
@ -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<string> {
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<number> {
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<Account | null> {
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<Account | null> {
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<Block> {
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<Coin | null> {
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<readonly Coin[]> {
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<BroadcastTxResponse> {
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<readonly IndexedTx[]> {
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,