diff --git a/packages/amino/src/addresses.ts b/packages/amino/src/addresses.ts index d1d11528b7..8d561297ed 100644 --- a/packages/amino/src/addresses.ts +++ b/packages/amino/src/addresses.ts @@ -1,10 +1,16 @@ // See https://github.com/tendermint/tendermint/blob/f2ada0a604b4c0763bda2f64fac53d506d3beca7/docs/spec/blockchain/encoding.md#public-key-cryptography -import { ripemd160, sha256 } from "@cosmjs/crypto"; +import { keccak256, ripemd160, Secp256k1, sha256 } from "@cosmjs/crypto"; import { fromBase64, toBech32 } from "@cosmjs/encoding"; import { encodeAminoPubkey } from "./encoding"; -import { isEd25519Pubkey, isMultisigThresholdPubkey, isSecp256k1Pubkey, Pubkey } from "./pubkeys"; +import { + isEd25519Pubkey, + isEthSecp256k1Pubkey, + isMultisigThresholdPubkey, + isSecp256k1Pubkey, + Pubkey, +} from "./pubkeys"; export function rawEd25519PubkeyToRawAddress(pubkeyData: Uint8Array): Uint8Array { if (pubkeyData.length !== 32) { @@ -20,11 +26,24 @@ export function rawSecp256k1PubkeyToRawAddress(pubkeyData: Uint8Array): Uint8Arr return ripemd160(sha256(pubkeyData)); } +export function rawEthSecp256k1PubkeyToRawAddress(pubkeyData: Uint8Array): Uint8Array { + if (pubkeyData.length !== 33) { + throw new Error(`Invalid Secp256k1 pubkey length (compressed): ${pubkeyData.length}`); + } + const uncompressed = Secp256k1.uncompressPubkey(pubkeyData); + const pubkeyWithoutPrefix = uncompressed.slice(1); + const hash = keccak256(pubkeyWithoutPrefix); + return hash.slice(-20); +} + // For secp256k1 this assumes we already have a compressed pubkey. export function pubkeyToRawAddress(pubkey: Pubkey): Uint8Array { if (isSecp256k1Pubkey(pubkey)) { const pubkeyData = fromBase64(pubkey.value); return rawSecp256k1PubkeyToRawAddress(pubkeyData); + } else if (isEthSecp256k1Pubkey(pubkey)) { + const pubkeyData = fromBase64(pubkey.value); + return rawEthSecp256k1PubkeyToRawAddress(pubkeyData); } else if (isEd25519Pubkey(pubkey)) { const pubkeyData = fromBase64(pubkey.value); return rawEd25519PubkeyToRawAddress(pubkeyData); diff --git a/packages/amino/src/encoding.ts b/packages/amino/src/encoding.ts index 07f0df5eda..d4d19c8117 100644 --- a/packages/amino/src/encoding.ts +++ b/packages/amino/src/encoding.ts @@ -4,7 +4,9 @@ import { arrayContentStartsWith } from "@cosmjs/utils"; import { Ed25519Pubkey, + EthSecp256k1Pubkey, isEd25519Pubkey, + isEthSecp256k1Pubkey, isMultisigThresholdPubkey, isSecp256k1Pubkey, MultisigThresholdPubkey, @@ -41,10 +43,25 @@ export function encodeEd25519Pubkey(pubkey: Uint8Array): Ed25519Pubkey { }; } +/** + * Takes a EthSecp256k1 public key as raw bytes and returns the Amino JSON + * representation of it (the type/value wrapper object). + */ +export function encodeEthSecp256k1Pubkey(pubkey: Uint8Array): EthSecp256k1Pubkey { + if (pubkey.length !== 33 || (pubkey[0] !== 0x02 && pubkey[0] !== 0x03)) { + throw new Error("Public key must be compressed secp256k1, i.e. 33 bytes starting with 0x02 or 0x03"); + } + return { + type: pubkeyType.ethsecp256k1, + value: toBase64(pubkey), + }; +} + // As discussed in https://github.com/binance-chain/javascript-sdk/issues/163 // Prefixes listed here: https://github.com/tendermint/tendermint/blob/d419fffe18531317c28c29a292ad7d253f6cafdf/docs/spec/blockchain/encoding.md#public-key-cryptography // Last bytes is varint-encoded length prefix const pubkeyAminoPrefixSecp256k1 = fromHex("eb5ae987" + "21" /* fixed length */); +const pubkeyAminoPrefixEthSecp256k1 = fromHex("5D7423DF" + "21" /* fixed length */); const pubkeyAminoPrefixEd25519 = fromHex("1624de64" + "20" /* fixed length */); const pubkeyAminoPrefixSr25519 = fromHex("0dfb1005" + "20" /* fixed length */); /** See https://github.com/tendermint/tendermint/commit/38b401657e4ad7a7eeb3c30a3cbf512037df3740 */ @@ -207,6 +224,8 @@ export function encodeAminoPubkey(pubkey: Pubkey): Uint8Array { return new Uint8Array([...pubkeyAminoPrefixEd25519, ...fromBase64(pubkey.value)]); } else if (isSecp256k1Pubkey(pubkey)) { return new Uint8Array([...pubkeyAminoPrefixSecp256k1, ...fromBase64(pubkey.value)]); + } else if (isEthSecp256k1Pubkey(pubkey)) { + return new Uint8Array([...pubkeyAminoPrefixEthSecp256k1, ...fromBase64(pubkey.value)]); } else { throw new Error("Unsupported pubkey type"); } diff --git a/packages/amino/src/index.ts b/packages/amino/src/index.ts index 2c8f866a7f..7644cffca2 100644 --- a/packages/amino/src/index.ts +++ b/packages/amino/src/index.ts @@ -2,6 +2,7 @@ export { pubkeyToAddress, pubkeyToRawAddress, rawEd25519PubkeyToRawAddress, + rawEthSecp256k1PubkeyToRawAddress, rawSecp256k1PubkeyToRawAddress, } from "./addresses"; export type { Coin } from "./coins"; @@ -12,6 +13,7 @@ export { encodeAminoPubkey, encodeBech32Pubkey, encodeEd25519Pubkey, + encodeEthSecp256k1Pubkey, encodeSecp256k1Pubkey, } from "./encoding"; export { createMultisigThresholdPubkey } from "./multisig"; @@ -19,6 +21,7 @@ export { omitDefault } from "./omitdefault"; export { makeCosmoshubPath } from "./paths"; export type { Ed25519Pubkey, + EthSecp256k1Pubkey, MultisigThresholdPubkey, Pubkey, Secp256k1Pubkey, @@ -26,6 +29,7 @@ export type { } from "./pubkeys"; export { isEd25519Pubkey, + isEthSecp256k1Pubkey, isMultisigThresholdPubkey, isSecp256k1Pubkey, isSinglePubkey, @@ -37,9 +41,15 @@ export { Secp256k1HdWallet, } from "./secp256k1hdwallet"; export { Secp256k1Wallet } from "./secp256k1wallet"; -export { type StdSignature, decodeSignature, encodeSecp256k1Signature } from "./signature"; +export { + type StdSignature, + decodeSignature, + encodeEthSecp256k1Signature, + encodeSecp256k1Signature, +} from "./signature"; export type { AminoMsg, StdFee, StdSignDoc } from "./signdoc"; export { makeSignDoc, serializeSignDoc } from "./signdoc"; export type { AccountData, Algo, AminoSignResponse, OfflineAminoSigner } from "./signer"; +export { getAminoPubkey, isEthereumSecp256k1Account } from "./signerutils"; export { type StdTx, isStdTx, makeStdTx } from "./stdtx"; export { type KdfConfiguration, executeKdf } from "./wallet"; diff --git a/packages/amino/src/pubkeys.ts b/packages/amino/src/pubkeys.ts index d61bfa3c39..c3215cc357 100644 --- a/packages/amino/src/pubkeys.ts +++ b/packages/amino/src/pubkeys.ts @@ -24,10 +24,21 @@ export function isSecp256k1Pubkey(pubkey: Pubkey): pubkey is Secp256k1Pubkey { return (pubkey as Secp256k1Pubkey).type === "tendermint/PubKeySecp256k1"; } +export interface EthSecp256k1Pubkey extends SinglePubkey { + readonly type: "os/PubKeyEthSecp256k1"; + readonly value: string; +} + +export function isEthSecp256k1Pubkey(pubkey: Pubkey): pubkey is EthSecp256k1Pubkey { + return (pubkey as EthSecp256k1Pubkey).type === "os/PubKeyEthSecp256k1"; +} + export const pubkeyType = { - /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/ed25519/ed25519.go#L22 */ - secp256k1: "tendermint/PubKeySecp256k1" as const, /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/secp256k1/secp256k1.go#L23 */ + secp256k1: "tendermint/PubKeySecp256k1" as const, + /** @see https://github.com/cosmos/evm/blob/v1.0.0-rc2/crypto/ethsecp256k1/ethsecp256k1.go#L35-L36 */ + ethsecp256k1: "os/PubKeyEthSecp256k1" as const, + /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/ed25519/ed25519.go#L22 */ ed25519: "tendermint/PubKeyEd25519" as const, /** @see https://github.com/tendermint/tendermint/blob/v0.33.0/crypto/sr25519/codec.go#L12 */ sr25519: "tendermint/PubKeySr25519" as const, @@ -53,7 +64,12 @@ export interface SinglePubkey extends Pubkey { } export function isSinglePubkey(pubkey: Pubkey): pubkey is SinglePubkey { - const singPubkeyTypes: string[] = [pubkeyType.ed25519, pubkeyType.secp256k1, pubkeyType.sr25519]; + const singPubkeyTypes: string[] = [ + pubkeyType.ed25519, + pubkeyType.secp256k1, + pubkeyType.ethsecp256k1, + pubkeyType.sr25519, + ]; return singPubkeyTypes.includes(pubkey.type); } diff --git a/packages/amino/src/signature.spec.ts b/packages/amino/src/signature.spec.ts index d28c9c885a..b7852c4ffc 100644 --- a/packages/amino/src/signature.spec.ts +++ b/packages/amino/src/signature.spec.ts @@ -1,6 +1,11 @@ import { fromBase64 } from "@cosmjs/encoding"; -import { decodeSignature, encodeSecp256k1Signature, StdSignature } from "./signature"; +import { + decodeSignature, + encodeEthSecp256k1Signature, + encodeSecp256k1Signature, + StdSignature, +} from "./signature"; describe("signature", () => { describe("encodeSecp256k1Signature", () => { @@ -44,7 +49,7 @@ describe("signature", () => { }); }); - describe("decodeSignature", () => { + describe("decodeSecp256k1Signature", () => { it("works for secp256k1", () => { const signature: StdSignature = { pub_key: { @@ -61,4 +66,63 @@ describe("signature", () => { }); }); }); + + describe("encodeEthSecp256k1Signature", () => { + it("encodes a full signature", () => { + const pubkey = fromBase64("AywEwHmedyGF0jQ11+SY/dLGn/QwoN+cf09VWFAfUxUs"); + const signature = fromBase64( + "sGcYUlDfO1PDA/Z9NqUBtgSTdRTJ+AJ8tvgw+5qtXalPgh5XqZj4R2eY1b7RXMU1m5and6aOl7YGpk9cZnESmQ==", + ); + expect(encodeEthSecp256k1Signature(pubkey, signature)).toEqual({ + pub_key: { + type: "os/PubKeyEthSecp256k1", + value: "AywEwHmedyGF0jQ11+SY/dLGn/QwoN+cf09VWFAfUxUs", + }, + signature: "sGcYUlDfO1PDA/Z9NqUBtgSTdRTJ+AJ8tvgw+5qtXalPgh5XqZj4R2eY1b7RXMU1m5and6aOl7YGpk9cZnESmQ==", + }); + }); + + it("throws when getting uncompressed public keys", () => { + const pubkey = fromBase64( + "BE8EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQE7WHpoHoNswYeoFkuYpYSKK4mzFzMV/dB0DVAy4lnNU=", + ); + const signature = fromBase64( + "sGcYUlDfO1PDA/Z9NqUBtgSTdRTJ+AJ8tvgw+5qtXalPgh5XqZj4R2eY1b7RXMU1m5and6aOl7YGpk9cZnESmQ==", + ); + expect(() => encodeEthSecp256k1Signature(pubkey, signature)).toThrowError( + /public key must be compressed secp256k1/i, + ); + }); + + it("throws if signature contains recovery byte", () => { + const pubkey = fromBase64("AywEwHmedyGF0jQ11+SY/dLGn/QwoN+cf09VWFAfUxUs"); + const signature = Uint8Array.from([ + ...fromBase64( + "sGcYUlDfO1PDA/Z9NqUBtgSTdRTJ+AJ8tvgw+5qtXalPgh5XqZj4R2eY1b7RXMU1m5and6aOl7YGpk9cZnESmQ==", + ), + 99, + ]); + expect(() => encodeEthSecp256k1Signature(pubkey, signature)).toThrowError( + /signature must be 64 bytes long/i, + ); + }); + }); + + describe("decodeEthSecp256k1Signature", () => { + it("works for ethsecp256k1", () => { + const signature: StdSignature = { + pub_key: { + type: "os/PubKeyEthSecp256k1", + value: "AywEwHmedyGF0jQ11+SY/dLGn/QwoN+cf09VWFAfUxUs", + }, + signature: "sGcYUlDfO1PDA/Z9NqUBtgSTdRTJ+AJ8tvgw+5qtXalPgh5XqZj4R2eY1b7RXMU1m5and6aOl7YGpk9cZnESmQ==", + }; + expect(decodeSignature(signature)).toEqual({ + pubkey: fromBase64("AywEwHmedyGF0jQ11+SY/dLGn/QwoN+cf09VWFAfUxUs"), + signature: fromBase64( + "sGcYUlDfO1PDA/Z9NqUBtgSTdRTJ+AJ8tvgw+5qtXalPgh5XqZj4R2eY1b7RXMU1m5and6aOl7YGpk9cZnESmQ==", + ), + }); + }); + }); }); diff --git a/packages/amino/src/signature.ts b/packages/amino/src/signature.ts index cea95b014b..7d7bb80ba7 100644 --- a/packages/amino/src/signature.ts +++ b/packages/amino/src/signature.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { fromBase64, toBase64 } from "@cosmjs/encoding"; -import { encodeSecp256k1Pubkey } from "./encoding"; +import { encodeEthSecp256k1Pubkey, encodeSecp256k1Pubkey } from "./encoding"; import { Pubkey, pubkeyType } from "./pubkeys"; export interface StdSignature { @@ -28,6 +28,25 @@ export function encodeSecp256k1Signature(pubkey: Uint8Array, signature: Uint8Arr }; } +/** + * Takes a binary pubkey and signature to create a signature object + * + * @param pubkey a compressed secp256k1 public key + * @param signature a 64 byte fixed length representation of secp256k1 signature components r and s + */ +export function encodeEthSecp256k1Signature(pubkey: Uint8Array, signature: Uint8Array): StdSignature { + if (signature.length !== 64) { + throw new Error( + "Signature must be 64 bytes long. Cosmos SDK uses a 2x32 byte fixed length encoding for the secp256k1 signature integers r and s.", + ); + } + + return { + pub_key: encodeEthSecp256k1Pubkey(pubkey), + signature: toBase64(signature), + }; +} + export function decodeSignature(signature: StdSignature): { readonly pubkey: Uint8Array; readonly signature: Uint8Array; @@ -39,6 +58,11 @@ export function decodeSignature(signature: StdSignature): { pubkey: fromBase64(signature.pub_key.value), signature: fromBase64(signature.signature), }; + case pubkeyType.ethsecp256k1: + return { + pubkey: fromBase64(signature.pub_key.value), + signature: fromBase64(signature.signature), + }; default: throw new Error("Unsupported pubkey type"); } diff --git a/packages/amino/src/signer.ts b/packages/amino/src/signer.ts index 89de5a2763..41c00b92e3 100644 --- a/packages/amino/src/signer.ts +++ b/packages/amino/src/signer.ts @@ -1,7 +1,7 @@ import { StdSignature } from "./signature"; import { StdSignDoc } from "./signdoc"; -export type Algo = "secp256k1" | "ed25519" | "sr25519"; +export type Algo = "secp256k1" | "ed25519" | "sr25519" | "eth_secp256k1" | "ethsecp256k1"; export interface AccountData { /** A printable address (typically bech32 encoded) */ diff --git a/packages/amino/src/signerutils.ts b/packages/amino/src/signerutils.ts new file mode 100644 index 0000000000..028ae4a98f --- /dev/null +++ b/packages/amino/src/signerutils.ts @@ -0,0 +1,40 @@ +/** + * Utility functions for working with signer accounts and algorithm detection. + */ + +import { encodeEthSecp256k1Pubkey, encodeSecp256k1Pubkey } from "./encoding"; +import { EthSecp256k1Pubkey, Secp256k1Pubkey } from "./pubkeys"; +import type { AccountData } from "./signer"; + +/** + * Checks if an account uses Ethereum secp256k1 keys by examining the algorithm name. + * + * Handle Ethereum secp256k1 keys with dual naming convention support: + * Different wallets and chains report Ethereum key algorithms inconsistently: + * - "eth_secp256k1" (with underscore) - de facto standard used by Keplr wallet, CosmJS, some Cosmos SDK chains + * - "ethsecp256k1" (without underscore) - used by Evmos, Cronos, and other EVM-compatible chains + * Both represent the same Ethereum-compatible secp256k1 keys that require keccak256 address derivation + * + * @param account The account data from a signer + * @returns true if the account uses Ethereum secp256k1 keys, false otherwise + */ +export function isEthereumSecp256k1Account(account: AccountData): boolean { + return account.algo === "eth_secp256k1" || account.algo === "ethsecp256k1"; +} + +/** + * Gets the correctly encoded amino pubkey for an account based on its algorithm. + * + * This utility automatically selects the appropriate encoding function based on whether + * the account uses Ethereum secp256k1 keys or standard secp256k1 keys. + * + * @param account The account data from a signer + * @returns The amino-encoded pubkey (EthSecp256k1Pubkey or Secp256k1Pubkey) + */ +export function getAminoPubkey(account: AccountData): EthSecp256k1Pubkey | Secp256k1Pubkey { + if (isEthereumSecp256k1Account(account)) { + return encodeEthSecp256k1Pubkey(account.pubkey); + } else { + return encodeSecp256k1Pubkey(account.pubkey); + } +} diff --git a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts index 5c6f49699d..e87699219c 100644 --- a/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts +++ b/packages/cosmwasm-stargate/src/signingcosmwasmclient.ts @@ -1,4 +1,4 @@ -import { encodeSecp256k1Pubkey, makeSignDoc as makeSignDocAmino } from "@cosmjs/amino"; +import { encodeSecp256k1Pubkey, getAminoPubkey, makeSignDoc as makeSignDocAmino } from "@cosmjs/amino"; import { sha256 } from "@cosmjs/crypto"; import { fromBase64, toHex, toUtf8 } from "@cosmjs/encoding"; import { Int53, Uint53 } from "@cosmjs/math"; @@ -703,7 +703,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { if (!accountFromSigner) { throw new Error("Failed to retrieve account from signer"); } - const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey)); + const pubkey = encodePubkey(getAminoPubkey(accountFromSigner)); const signMode = SignMode.SIGN_MODE_LEGACY_AMINO_JSON; const msgs = messages.map((msg) => this.aminoTypes.toAmino(msg)); const signDoc = makeSignDocAmino(msgs, fee, chainId, memo, accountNumber, sequence, timeoutHeight); @@ -749,7 +749,7 @@ export class SigningCosmWasmClient extends CosmWasmClient { if (!accountFromSigner) { throw new Error("Failed to retrieve account from signer"); } - const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey)); + const pubkey = encodePubkey(getAminoPubkey(accountFromSigner)); const txBody: TxBodyEncodeObject = { typeUrl: "/cosmos.tx.v1beta1.TxBody", value: { diff --git a/packages/proto-signing/src/directethsecp256k1hdwallet.spec.ts b/packages/proto-signing/src/directethsecp256k1hdwallet.spec.ts new file mode 100644 index 0000000000..39e219c00e --- /dev/null +++ b/packages/proto-signing/src/directethsecp256k1hdwallet.spec.ts @@ -0,0 +1,244 @@ +import { coins } from "@cosmjs/amino"; +import { keccak256, Secp256k1, Secp256k1Signature, Slip10RawIndex } from "@cosmjs/crypto"; +import { fromBase64, fromHex } from "@cosmjs/encoding"; + +import { DirectEthSecp256k1HdWallet, extractKdfConfiguration } from "./directethsecp256k1hdwallet"; +import { makeAuthInfoBytes, makeSignBytes, makeSignDoc } from "./signing"; +import { base64Matcher, faucet, testVectors } from "./testutils.spec"; +import { executeKdf, KdfConfiguration } from "./wallet"; + +describe("DirectEthSecp256k1HdWallet", () => { + // m/44'/60'/0'/0/0 + // pubkey: 0322739f397cee44e48eb02773c2d489eb7395bae9756349f16c1294a5a108351b + const defaultMnemonic = "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling"; + const defaultPubkey = fromHex("0322739f397cee44e48eb02773c2d489eb7395bae9756349f16c1294a5a108351b"); + const defaultAddress = "cosmos1f6u96xyamswltlssyyr56fjv5uca9ggya5f28s"; + + describe("fromMnemonic", () => { + it("works", async () => { + const wallet = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + expect(wallet).toBeTruthy(); + expect(wallet.mnemonic).toEqual(defaultMnemonic); + }); + + it("works with options", async () => { + const wallet = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic, { + bip39Password: "password123", + hdPaths: [ + [ + Slip10RawIndex.hardened(44), + Slip10RawIndex.hardened(60), + Slip10RawIndex.hardened(0), + Slip10RawIndex.normal(0), + Slip10RawIndex.normal(123), + ], + ], + prefix: "yolo", + }); + expect(wallet.mnemonic).toEqual(defaultMnemonic); + const [{ pubkey, address }] = await wallet.getAccounts(); + expect(pubkey).not.toEqual(defaultPubkey); + expect(address.slice(0, 4)).toEqual("yolo"); + }); + + it("works with explicitly undefined options", async () => { + const wallet = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic, { + bip39Password: undefined, + hdPaths: undefined, + prefix: undefined, + }); + expect(wallet.mnemonic).toEqual(defaultMnemonic); + const [{ pubkey, address }] = await wallet.getAccounts(); + expect(pubkey).toEqual(defaultPubkey); + expect(address).toEqual(defaultAddress); + }); + }); + + describe("generate", () => { + it("defaults to 12 words", async () => { + const wallet = await DirectEthSecp256k1HdWallet.generate(); + expect(wallet.mnemonic.split(" ").length).toEqual(12); + }); + + it("can use different mnemonic lengths", async () => { + expect((await DirectEthSecp256k1HdWallet.generate(12)).mnemonic.split(" ").length).toEqual(12); + expect((await DirectEthSecp256k1HdWallet.generate(15)).mnemonic.split(" ").length).toEqual(15); + expect((await DirectEthSecp256k1HdWallet.generate(18)).mnemonic.split(" ").length).toEqual(18); + expect((await DirectEthSecp256k1HdWallet.generate(21)).mnemonic.split(" ").length).toEqual(21); + expect((await DirectEthSecp256k1HdWallet.generate(24)).mnemonic.split(" ").length).toEqual(24); + }); + }); + + describe("deserialize", () => { + it("can restore", async () => { + const original = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + const password = "123"; + const serialized = await original.serialize(password); + const deserialized = await DirectEthSecp256k1HdWallet.deserialize(serialized, password); + const accounts = await deserialized.getAccounts(); + + expect(deserialized.mnemonic).toEqual(defaultMnemonic); + expect(accounts).toEqual([ + { + algo: "eth_secp256k1", + address: defaultAddress, + pubkey: defaultPubkey, + }, + ]); + }); + + it("can restore multiple accounts", async () => { + const mnemonic = + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone"; + const prefix = "wasm"; + const accountNumbers = [0, 1, 2, 3, 4]; + const hdPaths = accountNumbers.map((n) => [ + Slip10RawIndex.hardened(44), + Slip10RawIndex.hardened(60), + Slip10RawIndex.hardened(0), + Slip10RawIndex.normal(0), + Slip10RawIndex.normal(n), + ]); + const original = await DirectEthSecp256k1HdWallet.fromMnemonic(mnemonic, { + hdPaths: hdPaths, + prefix: prefix, + }); + const password = "123"; + const serialized = await original.serialize(password); + const deserialized = await DirectEthSecp256k1HdWallet.deserialize(serialized, password); + const accounts = await deserialized.getAccounts(); + + expect(deserialized.mnemonic).toEqual(mnemonic); + expect(accounts.length).toEqual(5); + expect(accounts[0].algo).toEqual("eth_secp256k1"); + }); + }); + + describe("deserializeWithEncryptionKey", () => { + it("can restore", async () => { + const password = "123"; + let serialized: string; + { + const original = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + const anyKdfParams: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 4, + memLimitKib: 3 * 1024, + }, + }; + const encryptionKey = await executeKdf(password, anyKdfParams); + serialized = await original.serializeWithEncryptionKey(encryptionKey, anyKdfParams); + } + + { + const kdfConfiguration = extractKdfConfiguration(serialized); + const encryptionKey = await executeKdf(password, kdfConfiguration); + const deserialized = await DirectEthSecp256k1HdWallet.deserializeWithEncryptionKey( + serialized, + encryptionKey, + ); + expect(deserialized.mnemonic).toEqual(defaultMnemonic); + expect(await deserialized.getAccounts()).toEqual([ + { + algo: "eth_secp256k1", + address: defaultAddress, + pubkey: defaultPubkey, + }, + ]); + } + }); + }); + + describe("getAccounts", () => { + it("resolves to a list of accounts", async () => { + const wallet = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + const accounts = await wallet.getAccounts(); + expect(accounts.length).toEqual(1); + expect(accounts[0]).toEqual({ + address: defaultAddress, + algo: "eth_secp256k1", + pubkey: defaultPubkey, + }); + }); + }); + + describe("signDirect", () => { + it("resolves to valid signature", async () => { + const { accountNumber, sequence, bodyBytes } = testVectors[1].inputs; + const wallet = await DirectEthSecp256k1HdWallet.fromMnemonic(faucet.mnemonic); + const pubkey = { + typeUrl: "/cosmos.crypto.eth.ethsecp256k1.PubKey", + value: fromBase64(faucet.pubkey.value), + }; + const fee = coins(2000, "ucosm"); + const gasLimit = 200000; + const feeGranter = undefined; + const feePayer = undefined; + const chainId = "simd-testing"; + const signDoc = makeSignDoc( + fromHex(bodyBytes), + makeAuthInfoBytes([{ pubkey, sequence }], fee, gasLimit, feeGranter, feePayer), + chainId, + accountNumber, + ); + const signDocBytes = makeSignBytes(signDoc); + const [account] = await wallet.getAccounts(); + const { signature } = await wallet.signDirect(account.address, signDoc); + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), + keccak256(signDocBytes), + account.pubkey, + ); + expect(valid).toEqual(true); + }); + }); + + describe("serialize", () => { + it("can save with password", async () => { + const wallet = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + const serialized = await wallet.serialize("123"); + expect(JSON.parse(serialized)).toEqual({ + type: "directethsecp256k1hdwallet-v1", + kdf: { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 24, + memLimitKib: 12 * 1024, + }, + }, + encryption: { + algorithm: "xchacha20poly1305-ietf", + }, + data: jasmine.stringMatching(base64Matcher), + }); + }); + }); + + describe("serializeWithEncryptionKey", () => { + it("can save with password", async () => { + const wallet = await DirectEthSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + + const key = fromHex("aabb221100aabb332211aabb33221100aabb221100aabb332211aabb33221100"); + const customKdfConfiguration: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 321, + memLimitKib: 11 * 1024, + }, + }; + const serialized = await wallet.serializeWithEncryptionKey(key, customKdfConfiguration); + expect(JSON.parse(serialized)).toEqual({ + type: "directethsecp256k1hdwallet-v1", + kdf: customKdfConfiguration, + encryption: { + algorithm: "xchacha20poly1305-ietf", + }, + data: jasmine.stringMatching(base64Matcher), + }); + }); + }); +}); diff --git a/packages/proto-signing/src/directethsecp256k1hdwallet.ts b/packages/proto-signing/src/directethsecp256k1hdwallet.ts new file mode 100644 index 0000000000..6de5f3dd31 --- /dev/null +++ b/packages/proto-signing/src/directethsecp256k1hdwallet.ts @@ -0,0 +1,372 @@ +import { encodeEthSecp256k1Signature, rawEthSecp256k1PubkeyToRawAddress } from "@cosmjs/amino"; +import { + Bip39, + EnglishMnemonic, + HdPath, + keccak256, + pathToString, + Random, + Secp256k1, + Secp256k1Keypair, + Slip10, + Slip10Curve, + Slip10RawIndex, + stringToPath, +} from "@cosmjs/crypto"; +import { fromBase64, fromUtf8, toBase64, toBech32, toUtf8 } from "@cosmjs/encoding"; +import { assert, isNonNullObject } from "@cosmjs/utils"; +import { SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx"; + +import { AccountData, DirectSignResponse, OfflineDirectSigner } from "./signer"; +import { makeSignBytes } from "./signing"; +import { + decrypt, + encrypt, + EncryptionConfiguration, + executeKdf, + KdfConfiguration, + supportedAlgorithms, +} from "./wallet"; + +interface AccountDataWithPrivkey extends AccountData { + readonly privkey: Uint8Array; +} + +const serializationTypeV1 = "directethsecp256k1hdwallet-v1"; + +/** + * A KDF configuration that is not very strong but can be used on the main thread. + * It takes about 1 second in Node.js 16.0.0 and should have similar runtimes in other modern Wasm hosts. + */ +const basicPasswordHashingOptions: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 24, + memLimitKib: 12 * 1024, + }, +}; + +/** + * This interface describes a JSON object holding the encrypted wallet and the meta data. + * All fields in here must be JSON types. + */ +export interface DirectSecp256k1HdWalletSerialization { + /** A format+version identifier for this serialization format */ + readonly type: string; + /** Information about the key derivation function (i.e. password to encryption key) */ + readonly kdf: KdfConfiguration; + /** Information about the symmetric encryption */ + readonly encryption: EncryptionConfiguration; + /** An instance of Secp256k1HdWalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: string; +} + +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + */ +interface Secp256k1Derivation { + readonly hdPath: HdPath; + readonly prefix: string; +} + +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + * All fields in here must be JSON types. + */ +interface DerivationInfoJson { + readonly hdPath: string; + readonly prefix: string; +} + +function isDerivationJson(thing: unknown): thing is DerivationInfoJson { + if (!isNonNullObject(thing)) return false; + if (typeof (thing as DerivationInfoJson).hdPath !== "string") return false; + if (typeof (thing as DerivationInfoJson).prefix !== "string") return false; + return true; +} + +/** + * The data of a wallet serialization that is encrypted. + * All fields in here must be JSON types. + */ +interface DirectSecp256k1HdWalletData { + readonly mnemonic: string; + readonly accounts: readonly DerivationInfoJson[]; +} + +function extractKdfConfigurationV1(doc: any): KdfConfiguration { + return doc.kdf; +} + +export function extractKdfConfiguration(serialization: string): KdfConfiguration { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + + switch ((root as any).type) { + case serializationTypeV1: + return extractKdfConfigurationV1(root); + default: + throw new Error("Unsupported serialization type"); + } +} + +export interface DirectEthSecp256k1HdWalletOptions { + /** The password to use when deriving a BIP39 seed from a mnemonic. */ + readonly bip39Password: string; + /** The BIP-32/SLIP-10 derivation paths. Defaults to the Cosmos Hub/ATOM path `m/44'/60'/0'/0/0`. */ + readonly hdPaths: readonly HdPath[]; + /** The bech32 address prefix (human readable part). Defaults to "cosmos". */ + readonly prefix: string; +} + +interface DirectEthSecp256k1HdWalletConstructorOptions extends Partial { + readonly seed: Uint8Array; +} + +const defaultOptions: DirectEthSecp256k1HdWalletOptions = { + bip39Password: "", + hdPaths: [ + [ + Slip10RawIndex.hardened(44), + Slip10RawIndex.hardened(60), + Slip10RawIndex.hardened(0), + Slip10RawIndex.normal(0), + Slip10RawIndex.normal(0), + ] as HdPath, + ], + prefix: "cosmos", +}; + +/** A wallet for protobuf based signing using SIGN_MODE_DIRECT */ +export class DirectEthSecp256k1HdWallet implements OfflineDirectSigner { + /** + * Restores a wallet from the given BIP39 mnemonic. + * + * @param mnemonic Any valid English mnemonic. + * @param options An optional `DirectEthSecp256k1HdWalletOptions` object optionally containing a bip39Password, hdPaths, and prefix. + */ + public static async fromMnemonic( + mnemonic: string, + options: Partial = {}, + ): Promise { + const mnemonicChecked = new EnglishMnemonic(mnemonic); + const seed = await Bip39.mnemonicToSeed(mnemonicChecked, options.bip39Password); + return new DirectEthSecp256k1HdWallet(mnemonicChecked, { + ...options, + seed: seed, + }); + } + + /** + * 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 options An optional `DirectSecp256k1HdWalletOptions` object optionally containing a bip39Password, hdPaths, and prefix. + */ + public static async generate( + length: 12 | 15 | 18 | 21 | 24 = 12, + options: Partial = {}, + ): Promise { + const entropyLength = 4 * Math.floor((11 * length) / 33); + const entropy = Random.getBytes(entropyLength); + const mnemonic = Bip39.encode(entropy); + return DirectEthSecp256k1HdWallet.fromMnemonic(mnemonic.toString(), options); + } + + /** + * Restores a wallet from an encrypted serialization. + * + * @param password The user provided password used to generate an encryption key via a KDF. + * This is not normalized internally (see "Unicode normalization" to learn more). + */ + public static async deserialize( + serialization: string, + password: string, + ): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + switch ((root as any).type) { + case serializationTypeV1: + return DirectEthSecp256k1HdWallet.deserializeTypeV1(serialization, password); + default: + throw new Error("Unsupported serialization type"); + } + } + /** + * Restores a wallet from an encrypted serialization. + * + * This is an advanced alternative to calling `deserialize(serialization, password)` directly, which allows + * you to offload the KDF execution to a non-UI thread (e.g. in a WebWorker). + * + * The caller is responsible for ensuring the key was derived with the given KDF configuration. This can be + * done using `extractKdfConfiguration(serialization)` and `executeKdf(password, kdfConfiguration)` from this package. + */ + public static async deserializeWithEncryptionKey( + serialization: string, + encryptionKey: Uint8Array, + ): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + const untypedRoot: any = root; + switch (untypedRoot.type) { + case serializationTypeV1: { + const decryptedBytes = await decrypt( + fromBase64(untypedRoot.data), + encryptionKey, + untypedRoot.encryption, + ); + const decryptedDocument = JSON.parse(fromUtf8(decryptedBytes)); + const { mnemonic, accounts } = decryptedDocument; + assert(typeof mnemonic === "string"); + if (!Array.isArray(accounts)) throw new Error("Property 'accounts' is not an array"); + if (!accounts.every((account) => 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 DirectEthSecp256k1HdWallet.fromMnemonic(mnemonic, { + hdPaths: hdPaths, + prefix: firstPrefix, + }); + } + default: + throw new Error("Unsupported serialization type"); + } + } + + private static async deserializeTypeV1( + serialization: string, + password: string, + ): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + const encryptionKey = await executeKdf(password, (root as any).kdf); + return DirectEthSecp256k1HdWallet.deserializeWithEncryptionKey(serialization, encryptionKey); + } + + /** Base secret */ + private readonly secret: EnglishMnemonic; + /** BIP39 seed */ + private readonly seed: Uint8Array; + /** Derivation instructions */ + private readonly accounts: readonly Secp256k1Derivation[]; + + protected constructor(mnemonic: EnglishMnemonic, options: DirectEthSecp256k1HdWalletConstructorOptions) { + const prefix = options.prefix ?? defaultOptions.prefix; + const hdPaths = options.hdPaths ?? defaultOptions.hdPaths; + this.secret = mnemonic; + this.seed = options.seed; + this.accounts = hdPaths.map((hdPath) => ({ + hdPath: hdPath, + prefix: prefix, + })); + } + + public get mnemonic(): string { + return this.secret.toString(); + } + + public async getAccounts(): Promise { + const accountsWithPrivkeys = await this.getAccountsWithPrivkeys(); + return accountsWithPrivkeys.map(({ algo, pubkey, address }) => ({ + algo: algo, + pubkey: pubkey, + address: address, + })); + } + + public async signDirect(signerAddress: string, signDoc: SignDoc): Promise { + const accounts = await this.getAccountsWithPrivkeys(); + const account = accounts.find(({ address }) => address === signerAddress); + if (account === undefined) { + throw new Error(`Address ${signerAddress} not found in wallet`); + } + const { privkey, pubkey } = account; + const signBytes = makeSignBytes(signDoc); + const hashedMessage = keccak256(signBytes); + + const signature = await Secp256k1.createSignature(hashedMessage, privkey); + const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); + const stdSignature = encodeEthSecp256k1Signature(pubkey, signatureBytes); + return { + signed: signDoc, + signature: stdSignature, + }; + } + + /** + * Generates an encrypted serialization of this wallet. + * + * @param password The user provided password used to generate an encryption key via a KDF. + * This is not normalized internally (see "Unicode normalization" to learn more). + */ + public async serialize(password: string): Promise { + const kdfConfiguration = basicPasswordHashingOptions; + const encryptionKey = await executeKdf(password, kdfConfiguration); + return this.serializeWithEncryptionKey(encryptionKey, kdfConfiguration); + } + + /** + * Generates an encrypted serialization of this wallet. + * + * This is an advanced alternative to calling `serialize(password)` directly, which allows you to + * offload the KDF execution to a non-UI thread (e.g. in a WebWorker). + * + * The caller is responsible for ensuring the key was derived with the given KDF options. If this + * is not the case, the wallet cannot be restored with the original password. + */ + public async serializeWithEncryptionKey( + encryptionKey: Uint8Array, + kdfConfiguration: KdfConfiguration, + ): Promise { + const dataToEncrypt: DirectSecp256k1HdWalletData = { + mnemonic: this.mnemonic, + accounts: this.accounts.map(({ hdPath, prefix }) => ({ + hdPath: pathToString(hdPath), + prefix: prefix, + })), + }; + const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt)); + + const encryptionConfiguration: EncryptionConfiguration = { + algorithm: supportedAlgorithms.xchacha20poly1305Ietf, + }; + const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration); + + const out: DirectSecp256k1HdWalletSerialization = { + type: serializationTypeV1, + kdf: kdfConfiguration, + encryption: encryptionConfiguration, + data: toBase64(encryptedData), + }; + return JSON.stringify(out); + } + + private async getKeyPair(hdPath: HdPath): Promise { + 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 { + return Promise.all( + this.accounts.map(async ({ hdPath, prefix }) => { + const { privkey, pubkey } = await this.getKeyPair(hdPath); + const address = toBech32(prefix, rawEthSecp256k1PubkeyToRawAddress(pubkey)); + return { + algo: "eth_secp256k1" as const, + privkey: privkey, + pubkey: pubkey, + address: address, + }; + }), + ); + } +} diff --git a/packages/proto-signing/src/directethsecp256k1wallet.spec.ts b/packages/proto-signing/src/directethsecp256k1wallet.spec.ts new file mode 100644 index 0000000000..edba9e11d1 --- /dev/null +++ b/packages/proto-signing/src/directethsecp256k1wallet.spec.ts @@ -0,0 +1,64 @@ +import { coins } from "@cosmjs/amino"; +import { keccak256, Secp256k1, Secp256k1Signature } from "@cosmjs/crypto"; +import { fromBase64, fromHex } from "@cosmjs/encoding"; + +import { DirectEthSecp256k1Wallet } from "./directethsecp256k1wallet"; +import { makeAuthInfoBytes, makeSignBytes, makeSignDoc } from "./signing"; +import { testVectors } from "./testutils.spec"; + +describe("DirectEthSecp256k1Wallet", () => { + const defaultPrivkey = fromHex("b8c462d2bb0c1a92edf44f735021f16c270f28ee2c3d1cb49943a5e70a3c763e"); + const defaultAddress = "cosmos1p4ukumnzsyf70dpw8cres7amggpvx9aa5dqdaq"; + const defaultPubkey = fromHex("03f146c27639179e5b67b8646108f48e1a78b146c74939e34afaa5414ad5c93f8a"); + + describe("fromKey", () => { + it("works", async () => { + const signer = await DirectEthSecp256k1Wallet.fromKey(defaultPrivkey); + expect(signer).toBeTruthy(); + }); + }); + + describe("getAccounts", () => { + it("resolves to a list of accounts", async () => { + const signer = await DirectEthSecp256k1Wallet.fromKey(defaultPrivkey); + const accounts = await signer.getAccounts(); + expect(accounts.length).toEqual(1); + expect(accounts[0]).toEqual({ + address: defaultAddress, + algo: "eth_secp256k1", + pubkey: defaultPubkey, + }); + }); + }); + + describe("signDirect", () => { + it("resolves to valid signature", async () => { + const { accountNumber, sequence, bodyBytes } = testVectors[1].inputs; + const wallet = await DirectEthSecp256k1Wallet.fromKey(defaultPrivkey); + const accounts = await wallet.getAccounts(); + const pubkey = { + typeUrl: "/cosmos.crypto.eth.ethsecp256k1.PubKey", + value: accounts[0].pubkey, + }; + const fee = coins(2000, "ucosm"); + const gasLimit = 200000; + const chainId = "simd-testing"; + const feePayer = undefined; + const feeGranter = undefined; + const signDoc = makeSignDoc( + fromHex(bodyBytes), + makeAuthInfoBytes([{ pubkey, sequence }], fee, gasLimit, feeGranter, feePayer), + chainId, + accountNumber, + ); + const signDocBytes = makeSignBytes(signDoc); + const { signature } = await wallet.signDirect(accounts[0].address, signDoc); + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), + keccak256(signDocBytes), + pubkey.value, + ); + expect(valid).toEqual(true); + }); + }); +}); diff --git a/packages/proto-signing/src/directethsecp256k1wallet.ts b/packages/proto-signing/src/directethsecp256k1wallet.ts new file mode 100644 index 0000000000..3474f86196 --- /dev/null +++ b/packages/proto-signing/src/directethsecp256k1wallet.ts @@ -0,0 +1,64 @@ +import { encodeEthSecp256k1Signature, rawEthSecp256k1PubkeyToRawAddress } from "@cosmjs/amino"; +import { keccak256, Secp256k1 } from "@cosmjs/crypto"; +import { toBech32 } from "@cosmjs/encoding"; +import { SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx"; + +import { AccountData, DirectSignResponse, OfflineDirectSigner } from "./signer"; +import { makeSignBytes } from "./signing"; + +/** + * A wallet that holds a single secp256k1 keypair. + * + * If you want to work with BIP39 mnemonics and multiple accounts, use DirectEthSecp256k1HdWallet. + */ +export class DirectEthSecp256k1Wallet implements OfflineDirectSigner { + /** + * Creates a DirectSecp256k1Wallet from the given private key + * + * @param privkey The private key. + * @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos". + */ + public static async fromKey(privkey: Uint8Array, prefix = "cosmos"): Promise { + const uncompressed = (await Secp256k1.makeKeypair(privkey)).pubkey; + return new DirectEthSecp256k1Wallet(privkey, Secp256k1.compressPubkey(uncompressed), prefix); + } + + private readonly pubkey: Uint8Array; + private readonly privkey: Uint8Array; + private readonly prefix: string; + + private constructor(privkey: Uint8Array, pubkey: Uint8Array, prefix: string) { + this.privkey = privkey; + this.pubkey = pubkey; + this.prefix = prefix; + } + + private get address(): string { + return toBech32(this.prefix, rawEthSecp256k1PubkeyToRawAddress(this.pubkey)); + } + + public async getAccounts(): Promise { + return [ + { + algo: "eth_secp256k1", + address: this.address, + pubkey: this.pubkey, + }, + ]; + } + + public async signDirect(address: string, signDoc: SignDoc): Promise { + const signBytes = makeSignBytes(signDoc); + if (address !== this.address) { + throw new Error(`Address ${address} not found in wallet`); + } + const hashedMessage = keccak256(signBytes); + const signature = await Secp256k1.createSignature(hashedMessage, this.privkey); + const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); + const stdSignature = encodeEthSecp256k1Signature(this.pubkey, signatureBytes); + return { + signed: signDoc, + signature: stdSignature, + }; + } +} diff --git a/packages/proto-signing/src/index.ts b/packages/proto-signing/src/index.ts index 96121cc992..1bfecdddbc 100644 --- a/packages/proto-signing/src/index.ts +++ b/packages/proto-signing/src/index.ts @@ -1,5 +1,8 @@ // This type happens to be shared between Amino and Direct sign modes export { type DecodedTxRaw, decodeTxRaw } from "./decode"; +export type { DirectEthSecp256k1HdWalletOptions } from "./directethsecp256k1hdwallet"; +export { DirectEthSecp256k1HdWallet } from "./directethsecp256k1hdwallet"; +export { DirectEthSecp256k1Wallet } from "./directethsecp256k1wallet"; export { type DirectSecp256k1HdWalletOptions, DirectSecp256k1HdWallet, diff --git a/packages/proto-signing/src/pubkey.ts b/packages/proto-signing/src/pubkey.ts index a42f95814f..3317b56019 100644 --- a/packages/proto-signing/src/pubkey.ts +++ b/packages/proto-signing/src/pubkey.ts @@ -1,7 +1,9 @@ import { encodeEd25519Pubkey, + encodeEthSecp256k1Pubkey, encodeSecp256k1Pubkey, isEd25519Pubkey, + isEthSecp256k1Pubkey, isMultisigThresholdPubkey, isSecp256k1Pubkey, MultisigThresholdPubkey, @@ -30,6 +32,21 @@ export function encodePubkey(pubkey: Pubkey): Any { typeUrl: "/cosmos.crypto.secp256k1.PubKey", value: Uint8Array.from(CosmosCryptoSecp256k1Pubkey.encode(pubkeyProto).finish()), }); + } else if (isEthSecp256k1Pubkey(pubkey)) { + // Note: This code block is hacky because we should use the correct EVM proto type + // https://github.com/cosmos/evm/blob/v1.0.0-rc2/proto/cosmos/evm/crypto/v1/ethsecp256k1/keys.proto#L12-L17. + // However, we do not have that available here or in cosmjs-types yet and the classic secp256k1 pubkey has the same structure except for documentation and annotations: + // https://github.com/cosmos/cosmos-sdk/blob/v0.53.4/proto/cosmos/crypto/secp256k1/keys.proto#L14-L30 + // + // Actually this type is so simple we should just have an Anybuf for TS instead of code generation (https://github.com/webmaster128/anybuf) + + const pubkeyProto = CosmosCryptoSecp256k1Pubkey.fromPartial({ + key: fromBase64(pubkey.value), + }); + return Any.fromPartial({ + typeUrl: "/cosmos.evm.crypto.v1.ethsecp256k1.PubKey", + value: Uint8Array.from(CosmosCryptoSecp256k1Pubkey.encode(pubkeyProto).finish()), + }); } else if (isEd25519Pubkey(pubkey)) { const pubkeyProto = CosmosCryptoEd25519Pubkey.fromPartial({ key: fromBase64(pubkey.value), @@ -64,6 +81,10 @@ export function anyToSinglePubkey(pubkey: Any): SinglePubkey { const { key } = CosmosCryptoSecp256k1Pubkey.decode(pubkey.value); return encodeSecp256k1Pubkey(key); } + case "/cosmos.evm.crypto.v1.ethsecp256k1.PubKey": { + const { key } = CosmosCryptoSecp256k1Pubkey.decode(pubkey.value); + return encodeEthSecp256k1Pubkey(key); + } case "/cosmos.crypto.ed25519.PubKey": { const { key } = CosmosCryptoEd25519Pubkey.decode(pubkey.value); return encodeEd25519Pubkey(key); @@ -81,6 +102,7 @@ export function anyToSinglePubkey(pubkey: Any): SinglePubkey { export function decodePubkey(pubkey: Any): Pubkey { switch (pubkey.typeUrl) { case "/cosmos.crypto.secp256k1.PubKey": + case "/cosmos.evm.crypto.v1.ethsecp256k1.PubKey": case "/cosmos.crypto.ed25519.PubKey": { return anyToSinglePubkey(pubkey); } diff --git a/packages/proto-signing/src/signer.ts b/packages/proto-signing/src/signer.ts index 42ff2a8b46..b2ebbf4471 100644 --- a/packages/proto-signing/src/signer.ts +++ b/packages/proto-signing/src/signer.ts @@ -1,7 +1,7 @@ import { OfflineAminoSigner, StdSignature } from "@cosmjs/amino"; import { SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -export type Algo = "secp256k1" | "ed25519" | "sr25519"; +export type Algo = "secp256k1" | "eth_secp256k1" | "ethsecp256k1" | "ed25519" | "sr25519"; export interface AccountData { /** A printable address (typically bech32 encoded) */ readonly address: string; diff --git a/packages/stargate/src/signingstargateclient.ts b/packages/stargate/src/signingstargateclient.ts index 95d77d89c9..5c4dc2013c 100644 --- a/packages/stargate/src/signingstargateclient.ts +++ b/packages/stargate/src/signingstargateclient.ts @@ -1,4 +1,9 @@ -import { encodeSecp256k1Pubkey, makeSignDoc as makeSignDocAmino, StdFee } from "@cosmjs/amino"; +import { + encodeSecp256k1Pubkey, + getAminoPubkey, + makeSignDoc as makeSignDocAmino, + StdFee, +} from "@cosmjs/amino"; import { fromBase64 } from "@cosmjs/encoding"; import { Int53, Uint53 } from "@cosmjs/math"; import { @@ -397,7 +402,7 @@ export class SigningStargateClient extends StargateClient { if (!accountFromSigner) { throw new Error("Failed to retrieve account from signer"); } - const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey)); + const pubkey = encodePubkey(getAminoPubkey(accountFromSigner)); const signMode = SignMode.SIGN_MODE_LEGACY_AMINO_JSON; const msgs = messages.map((msg) => this.aminoTypes.toAmino(msg)); const signDoc = makeSignDocAmino(msgs, fee, chainId, memo, accountNumber, sequence, timeoutHeight); @@ -444,7 +449,7 @@ export class SigningStargateClient extends StargateClient { if (!accountFromSigner) { throw new Error("Failed to retrieve account from signer"); } - const pubkey = encodePubkey(encodeSecp256k1Pubkey(accountFromSigner.pubkey)); + const pubkey = encodePubkey(getAminoPubkey(accountFromSigner)); const txBodyEncodeObject: TxBodyEncodeObject = { typeUrl: "/cosmos.tx.v1beta1.TxBody", value: {