amino: Add support for multiple accounts to Secp256k1HdWallet

This commit is contained in:
willclarktech 2021-04-20 14:12:41 +02:00
parent 5a15b108e6
commit 5c39bc9a5a
No known key found for this signature in database
GPG Key ID: 551A86E2E398ADF7
2 changed files with 80 additions and 63 deletions

View File

@ -25,12 +25,13 @@ describe("Secp256k1HdWallet", () => {
it("works with options", async () => { it("works with options", async () => {
const wallet = await Secp256k1HdWallet.fromMnemonic(defaultMnemonic, { const wallet = await Secp256k1HdWallet.fromMnemonic(defaultMnemonic, {
bip39Password: "password123", bip39Password: "password123",
hdPath: makeCosmoshubPath(123), hdPaths: [makeCosmoshubPath(123)],
prefix: "yolo", prefix: "yolo",
}); });
expect(wallet.mnemonic).toEqual(defaultMnemonic); expect(wallet.mnemonic).toEqual(defaultMnemonic);
expect((wallet as any).pubkey).not.toEqual(defaultPubkey); const [account] = await wallet.getAccounts();
expect((wallet as any).address.slice(0, 4)).toEqual("yolo"); expect(account.pubkey).not.toEqual(defaultPubkey);
expect(account.address.slice(0, 4)).toEqual("yolo");
}); });
}); });

View File

@ -5,6 +5,7 @@ import {
pathToString, pathToString,
Random, Random,
Secp256k1, Secp256k1,
Secp256k1Keypair,
sha256, sha256,
Slip10, Slip10,
Slip10Curve, Slip10Curve,
@ -27,6 +28,10 @@ import {
supportedAlgorithms, supportedAlgorithms,
} from "./wallet"; } from "./wallet";
interface AccountDataWithPrivkey extends AccountData {
readonly privkey: Uint8Array;
}
const serializationTypeV1 = "secp256k1wallet-v1"; const serializationTypeV1 = "secp256k1wallet-v1";
/** /**
@ -110,15 +115,19 @@ interface DerivationInfo {
export interface Secp256k1HdWalletOptions { export interface Secp256k1HdWalletOptions {
/** The password to use when deriving a BIP39 seed from a mnemonic. */ /** The password to use when deriving a BIP39 seed from a mnemonic. */
readonly bip39Password: string; readonly bip39Password: string;
/** The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. */ /** The BIP-32/SLIP-10 derivation paths. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. */
readonly hdPath: HdPath; readonly hdPaths: readonly HdPath[];
/** The bech32 address prefix (human readable part). Defaults to "cosmos". */ /** The bech32 address prefix (human readable part). Defaults to "cosmos". */
readonly prefix: string; readonly prefix: string;
} }
interface Secp256k1HdWalletConstructorOptions extends Partial<Secp256k1HdWalletOptions> {
readonly seed: Uint8Array;
}
const defaultOptions: Secp256k1HdWalletOptions = { const defaultOptions: Secp256k1HdWalletOptions = {
bip39Password: "", bip39Password: "",
hdPath: makeCosmoshubPath(0), hdPaths: [makeCosmoshubPath(0)],
prefix: "cosmos", prefix: "cosmos",
}; };
@ -127,45 +136,34 @@ export class Secp256k1HdWallet implements OfflineAminoSigner {
* Restores a wallet from the given BIP39 mnemonic. * Restores a wallet from the given BIP39 mnemonic.
* *
* @param mnemonic Any valid English mnemonic. * @param mnemonic Any valid English mnemonic.
* @param options An optional `Secp256k1HdWalletOptions` object optionally containing a bip39Password, hdPath, and prefix. * @param options An optional `Secp256k1HdWalletOptions` object optionally containing a bip39Password, hdPaths, and prefix.
*/ */
public static async fromMnemonic( public static async fromMnemonic(
mnemonic: string, mnemonic: string,
options: Partial<Secp256k1HdWalletOptions> = {}, options: Partial<Secp256k1HdWalletOptions> = {},
): Promise<Secp256k1HdWallet> { ): Promise<Secp256k1HdWallet> {
const { bip39Password, hdPath, prefix } = {
...defaultOptions,
...options,
};
const mnemonicChecked = new EnglishMnemonic(mnemonic); const mnemonicChecked = new EnglishMnemonic(mnemonic);
const seed = await Bip39.mnemonicToSeed(mnemonicChecked, bip39Password); const seed = await Bip39.mnemonicToSeed(mnemonicChecked, options.bip39Password);
const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, hdPath); return new Secp256k1HdWallet(mnemonicChecked, {
const uncompressed = (await Secp256k1.makeKeypair(privkey)).pubkey; ...options,
return new Secp256k1HdWallet( seed: seed,
mnemonicChecked, });
hdPath,
privkey,
Secp256k1.compressPubkey(uncompressed),
prefix,
);
} }
/** /**
* Generates a new wallet with a BIP39 mnemonic of the given length. * Generates a new wallet with a BIP39 mnemonic of the given length.
* *
* @param length The number of words in the mnemonic (12, 15, 18, 21 or 24). * @param length The number of words in the mnemonic (12, 15, 18, 21 or 24).
* @param hdPath The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. * @param options An optional `Secp256k1HdWalletOptions` object optionally containing a bip39Password, hdPaths, and prefix.
* @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos".
*/ */
public static async generate( public static async generate(
length: 12 | 15 | 18 | 21 | 24 = 12, length: 12 | 15 | 18 | 21 | 24 = 12,
hdPath: HdPath = makeCosmoshubPath(0), options: Partial<Secp256k1HdWalletOptions> = {},
prefix = "cosmos",
): Promise<Secp256k1HdWallet> { ): Promise<Secp256k1HdWallet> {
const entropyLength = 4 * Math.floor((11 * length) / 33); const entropyLength = 4 * Math.floor((11 * length) / 33);
const entropy = Random.getBytes(entropyLength); const entropy = Random.getBytes(entropyLength);
const mnemonic = Bip39.encode(entropy); const mnemonic = Bip39.encode(entropy);
return Secp256k1HdWallet.fromMnemonic(mnemonic.toString(), { hdPath: hdPath, prefix: prefix }); return Secp256k1HdWallet.fromMnemonic(mnemonic.toString(), options);
} }
/** /**
@ -212,12 +210,17 @@ export class Secp256k1HdWallet implements OfflineAminoSigner {
const { mnemonic, accounts } = decryptedDocument; const { mnemonic, accounts } = decryptedDocument;
assert(typeof mnemonic === "string"); assert(typeof mnemonic === "string");
if (!Array.isArray(accounts)) throw new Error("Property 'accounts' is not an array"); if (!Array.isArray(accounts)) throw new Error("Property 'accounts' is not an array");
if (accounts.length !== 1) throw new Error("Property 'accounts' only supports one entry"); if (!accounts.every((account) => isDerivationJson(account))) {
const account = accounts[0]; throw new Error("Account is not in the correct format.");
if (!isDerivationJson(account)) throw new Error("Account is not in the correct format."); }
const firstPrefix = accounts[0].prefix;
if (!accounts.every(({ prefix }) => prefix === firstPrefix)) {
throw new Error("Accounts do not all have the same prefix");
}
const hdPaths = accounts.map(({ hdPath }) => stringToPath(hdPath));
return Secp256k1HdWallet.fromMnemonic(mnemonic, { return Secp256k1HdWallet.fromMnemonic(mnemonic, {
hdPath: stringToPath(account.hdPath), hdPaths: hdPaths,
prefix: account.prefix, prefix: firstPrefix,
}); });
} }
default: default:
@ -237,58 +240,47 @@ export class Secp256k1HdWallet implements OfflineAminoSigner {
/** Base secret */ /** Base secret */
private readonly secret: EnglishMnemonic; private readonly secret: EnglishMnemonic;
/** BIP39 seed */
private readonly seed: Uint8Array;
/** Derivation instruction */ /** Derivation instruction */
private readonly accounts: readonly DerivationInfo[]; private readonly accounts: readonly DerivationInfo[];
/** Derived data */
private readonly pubkey: Uint8Array;
private readonly privkey: Uint8Array;
protected constructor( protected constructor(mnemonic: EnglishMnemonic, options: Secp256k1HdWalletConstructorOptions) {
mnemonic: EnglishMnemonic, const { seed, hdPaths, prefix } = { ...defaultOptions, ...options };
hdPath: HdPath,
privkey: Uint8Array,
pubkey: Uint8Array,
prefix: string,
) {
this.secret = mnemonic; this.secret = mnemonic;
this.accounts = [ this.seed = seed;
{ this.accounts = hdPaths.map((hdPath) => ({
hdPath: hdPath, hdPath: hdPath,
prefix: prefix, prefix: prefix,
}, }));
];
this.privkey = privkey;
this.pubkey = pubkey;
} }
public get mnemonic(): string { public get mnemonic(): string {
return this.secret.toString(); return this.secret.toString();
} }
private get address(): string {
return Bech32.encode(this.accounts[0].prefix, rawSecp256k1PubkeyToRawAddress(this.pubkey));
}
public async getAccounts(): Promise<readonly AccountData[]> { public async getAccounts(): Promise<readonly AccountData[]> {
return [ const accountsWithPrivkeys = await this.getAccountsWithPrivkeys();
{ return accountsWithPrivkeys.map(({ algo, pubkey, address }) => ({
algo: "secp256k1", algo: algo,
address: this.address, pubkey: pubkey,
pubkey: this.pubkey, address: address,
}, }));
];
} }
public async signAmino(signerAddress: string, signDoc: StdSignDoc): Promise<AminoSignResponse> { public async signAmino(signerAddress: string, signDoc: StdSignDoc): Promise<AminoSignResponse> {
if (signerAddress !== this.address) { const accounts = await this.getAccountsWithPrivkeys();
const account = accounts.find(({ address }) => address === signerAddress);
if (account === undefined) {
throw new Error(`Address ${signerAddress} not found in wallet`); throw new Error(`Address ${signerAddress} not found in wallet`);
} }
const { privkey, pubkey } = account;
const message = sha256(serializeSignDoc(signDoc)); const message = sha256(serializeSignDoc(signDoc));
const signature = await Secp256k1.createSignature(message, this.privkey); const signature = await Secp256k1.createSignature(message, privkey);
const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]);
return { return {
signed: signDoc, signed: signDoc,
signature: encodeSecp256k1Signature(this.pubkey, signatureBytes), signature: encodeSecp256k1Signature(pubkey, signatureBytes),
}; };
} }
@ -341,4 +333,28 @@ export class Secp256k1HdWallet implements OfflineAminoSigner {
}; };
return JSON.stringify(out); return JSON.stringify(out);
} }
private async getKeyPair(hdPath: HdPath): Promise<Secp256k1Keypair> {
const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, this.seed, hdPath);
const { pubkey } = await Secp256k1.makeKeypair(privkey);
return {
privkey: privkey,
pubkey: Secp256k1.compressPubkey(pubkey),
};
}
private async getAccountsWithPrivkeys(): Promise<readonly AccountDataWithPrivkey[]> {
return Promise.all(
this.accounts.map(async ({ hdPath, prefix }) => {
const { privkey, pubkey } = await this.getKeyPair(hdPath);
const address = Bech32.encode(prefix, rawSecp256k1PubkeyToRawAddress(pubkey));
return {
algo: "secp256k1" as const,
privkey: privkey,
pubkey: pubkey,
address: address,
};
}),
);
}
} }