Merge pull request #726 from cosmos/support-offline-siging
Allow SigningStargateClient to work offline
This commit is contained in:
commit
f8fafd17b6
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user