From 80769ab89612d4c54b161ed709060122b7bf2ac5 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Tue, 6 Feb 2024 11:55:56 -0600 Subject: [PATCH] feat: replace `ethers` with `viem` --- package.json | 2 +- rollup.config.bench.js | 1 + src/Client.ts | 13 ++++---- src/conversations/Conversation.ts | 4 +-- src/crypto/PublicKey.ts | 19 ++++------- src/crypto/Signature.ts | 17 ++++------ src/crypto/utils.ts | 36 +++++++++++++++++++++ src/keystore/providers/NetworkKeyManager.ts | 29 +++++++++-------- src/utils/topic.ts | 10 +++--- test/conversations/Conversation.test.ts | 4 +-- test/keystore/InMemoryKeystore.test.ts | 6 ++-- 11 files changed, 83 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index 79451216..0f91434e 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "elliptic": "^6.5.4", "ethers": "^5.7.2", "long": "^5.2.3", - "viem": "^1.21.4" + "viem": "^2.7.6" }, "devDependencies": { "@commitlint/cli": "17.8.1", diff --git a/rollup.config.bench.js b/rollup.config.bench.js index 3b465dea..06d15c1d 100644 --- a/rollup.config.bench.js +++ b/rollup.config.bench.js @@ -13,6 +13,7 @@ const external = [ 'elliptic', 'ethers', 'long', + 'viem', ] const plugins = [ diff --git a/src/Client.ts b/src/Client.ts index cf83e247..4b915728 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -10,7 +10,6 @@ import { EnvelopeMapperWithMessage, EnvelopeWithMessage, } from './utils' -import { utils } from 'ethers' import { Signer } from './types/Signer' import { Conversations } from './conversations' import { ContentTypeText, TextCodec } from './codecs/Text' @@ -44,7 +43,7 @@ import { import { hasMetamaskWithSnaps } from './keystore/snapHelpers' import { packageName, version } from './snapInfo.json' import { ExtractDecodedType } from './types/client' -import type { WalletClient } from 'viem' +import { getAddress, type WalletClient } from 'viem' import { Contacts } from './Contacts' import { KeystoreInterfaces } from './keystore/rpcDefinitions' const { Compression } = proto @@ -430,7 +429,7 @@ export default class Client { async getUserContact( peerAddress: string ): Promise { - peerAddress = utils.getAddress(peerAddress) // EIP55 normalize the address case. + peerAddress = getAddress(peerAddress) // EIP55 normalize the address case. const existingBundle = this.knownPublicKeyBundles.get(peerAddress) if (existingBundle) { return existingBundle @@ -456,7 +455,7 @@ export default class Client { ): Promise<(PublicKeyBundle | SignedPublicKeyBundle | undefined)[]> { // EIP55 normalize all peer addresses const normalizedAddresses = peerAddresses.map((address) => - utils.getAddress(address) + getAddress(address) ) // The logic here is tricky because we need to do a batch query for any uncached bundles, // then interleave back into an ordered array. So we create a map @@ -501,7 +500,7 @@ export default class Client { * Used to force getUserContact fetch contact from the network. */ forgetContact(peerAddress: string) { - peerAddress = utils.getAddress(peerAddress) // EIP55 normalize the address case. + peerAddress = getAddress(peerAddress) // EIP55 normalize the address case. this.knownPublicKeyBundles.delete(peerAddress) } @@ -552,7 +551,7 @@ export default class Client { const rawPeerAddresses: string[] = peerAddress // Try to normalize each of the peer addresses const normalizedPeerAddresses = rawPeerAddresses.map((address) => - utils.getAddress(address) + getAddress(address) ) // The getUserContactsFromNetwork will return false instead of throwing // on invalid envelopes @@ -563,7 +562,7 @@ export default class Client { return contacts.map((contact) => !!contact) } try { - peerAddress = utils.getAddress(peerAddress) // EIP55 normalize the address case. + peerAddress = getAddress(peerAddress) // EIP55 normalize the address case. } catch (e) { return false } diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index a12b4a47..05b7dc10 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -6,7 +6,6 @@ import { concat, toNanoString, } from '../utils' -import { utils } from 'ethers' import Stream from '../Stream' import Client, { ListMessagesOptions, @@ -33,6 +32,7 @@ import { sha256 } from '../crypto/encryption' import { buildDecryptV1Request, getResultOrThrow } from '../utils/keystore' import { ContentTypeText } from '../codecs/Text' import { ConsentState } from '../Contacts' +import { getAddress } from 'viem' /** * Conversation represents either a V1 or V2 conversation with a common set of methods. @@ -178,7 +178,7 @@ export class ConversationV1 private client: Client constructor(client: Client, address: string, createdAt: Date) { - this.peerAddress = utils.getAddress(address) + this.peerAddress = getAddress(address) this.client = client this.createdAt = createdAt } diff --git a/src/crypto/PublicKey.ts b/src/crypto/PublicKey.ts index eeb8da39..2d284e99 100644 --- a/src/crypto/PublicKey.ts +++ b/src/crypto/PublicKey.ts @@ -2,10 +2,10 @@ import { publicKey } from '@xmtp/proto' import * as secp from '@noble/secp256k1' import Long from 'long' import Signature, { WalletSigner } from './Signature' -import { equalBytes, hexToBytes } from './utils' -import { utils } from 'ethers' +import { computeAddress, equalBytes, splitSignature } from './utils' import { Signer } from '../types/Signer' import { sha256 } from './encryption' +import { hashMessage, Hex, hexToBytes } from 'viem' // SECP256k1 public key in uncompressed format with prefix type secp256k1Uncompressed = { @@ -90,7 +90,7 @@ export class UnsignedPublicKey implements publicKey.UnsignedPublicKey { // Derive Ethereum address from this public key. getEthereumAddress(): string { - return utils.computeAddress(this.secp256k1Uncompressed.bytes) + return computeAddress(this.secp256k1Uncompressed.bytes) } // Encode public key into bytes. @@ -256,16 +256,11 @@ export class PublicKey const sigString = await wallet.signMessage( WalletSigner.identitySigRequestText(this.bytesToSign()) ) - const eSig = utils.splitSignature(sigString) - const r = hexToBytes(eSig.r) - const s = hexToBytes(eSig.s) - const sigBytes = new Uint8Array(64) - sigBytes.set(r) - sigBytes.set(s, r.length) + const { bytes, recovery } = splitSignature(sigString as Hex) this.signature = new Signature({ ecdsaCompact: { - bytes: sigBytes, - recovery: eSig.recoveryParam, + bytes, + recovery, }, }) } @@ -278,7 +273,7 @@ export class PublicKey throw new Error('key is not signed') } const digest = hexToBytes( - utils.hashMessage(WalletSigner.identitySigRequestText(this.bytesToSign())) + hashMessage(WalletSigner.identitySigRequestText(this.bytesToSign())) ) const pk = this.signature.getPublicKey(digest) if (!pk) { diff --git a/src/crypto/Signature.ts b/src/crypto/Signature.ts index 906be632..90c66c24 100644 --- a/src/crypto/Signature.ts +++ b/src/crypto/Signature.ts @@ -3,9 +3,9 @@ import Long from 'long' import * as secp from '@noble/secp256k1' import { PublicKey, UnsignedPublicKey, SignedPublicKey } from './PublicKey' import { SignedPrivateKey } from './PrivateKey' -import { utils } from 'ethers' import { Signer } from '../types/Signer' -import { bytesToHex, equalBytes, hexToBytes } from './utils' +import { bytesToHex, equalBytes, hexToBytes, splitSignature } from './utils' +import { Hex, hashMessage } from 'viem' // ECDSA signature with recovery bit. export type ECDSACompactWithRecovery = { @@ -164,7 +164,7 @@ export class WalletSigner implements KeySigner { signature: ECDSACompactWithRecovery ): UnsignedPublicKey | undefined { const digest = hexToBytes( - utils.hashMessage(this.identitySigRequestText(key.bytesToSign())) + hashMessage(this.identitySigRequestText(key.bytesToSign())) ) return ecdsaSignerKey(digest, signature) } @@ -174,16 +174,11 @@ export class WalletSigner implements KeySigner { const sigString = await this.wallet.signMessage( WalletSigner.identitySigRequestText(keyBytes) ) - const eSig = utils.splitSignature(sigString) - const r = hexToBytes(eSig.r) - const s = hexToBytes(eSig.s) - const sigBytes = new Uint8Array(64) - sigBytes.set(r) - sigBytes.set(s, r.length) + const { bytes, recovery } = splitSignature(sigString as Hex) const signature = new Signature({ walletEcdsaCompact: { - bytes: sigBytes, - recovery: eSig.recoveryParam, + bytes, + recovery, }, }) return new SignedPublicKey({ keyBytes, signature }) diff --git a/src/crypto/utils.ts b/src/crypto/utils.ts index 3f2a269c..48ee7047 100644 --- a/src/crypto/utils.ts +++ b/src/crypto/utils.ts @@ -1,4 +1,12 @@ import * as secp from '@noble/secp256k1' +import { + Hex, + getAddress, + hexToSignature, + keccak256, + hexToBytes as viemHexToBytes, + bytesToHex as viemBytesToHex, +} from 'viem' export const bytesToHex = secp.utils.bytesToHex @@ -29,3 +37,31 @@ export function equalBytes(b1: Uint8Array, b2: Uint8Array): boolean { } return true } + +/** + * Compute the Ethereum address from uncompressed PublicKey bytes + */ +export function computeAddress(bytes: Uint8Array) { + const publicKey = viemBytesToHex(bytes.slice(1)) as `0x${string}` + const hash = keccak256(publicKey) + const address = hash.substring(hash.length - 40) + return getAddress(`0x${address}`) +} + +/** + * Split an Ethereum signature hex string into bytes and a recovery bit + */ +export function splitSignature(signature: Hex) { + const eSig = hexToSignature(signature) + const r = viemHexToBytes(eSig.r) + const s = viemHexToBytes(eSig.s) + let v = Number(eSig.v) + if (v === 0 || v === 1) { + v += 27 + } + const recovery = 1 - (v % 2) + const bytes = new Uint8Array(64) + bytes.set(r) + bytes.set(s, r.length) + return { bytes, recovery } +} diff --git a/src/keystore/providers/NetworkKeyManager.ts b/src/keystore/providers/NetworkKeyManager.ts index 351d74ca..4ba8ee39 100644 --- a/src/keystore/providers/NetworkKeyManager.ts +++ b/src/keystore/providers/NetworkKeyManager.ts @@ -1,4 +1,3 @@ -import { utils } from 'ethers' import { Signer } from '../../types/Signer' import crypto from '../../crypto/crypto' import { @@ -14,6 +13,7 @@ import { bytesToHex, hexToBytes } from '../../crypto/utils' import Ciphertext from '../../crypto/Ciphertext' import { privateKey as proto } from '@xmtp/proto' import TopicPersistence from '../persistence/TopicPersistence' +import { getAddress, verifyMessage } from 'viem' const KEY_BUNDLE_NAME = 'key_bundle' /** @@ -39,7 +39,7 @@ export default class NetworkKeyManager { // I think we want to namespace the storage address by wallet // This will allow us to support switching between multiple wallets in the same browser let walletAddress = await this.signer.getAddress() - walletAddress = utils.getAddress(walletAddress) + walletAddress = getAddress(walletAddress) return `${walletAddress}/${name}` } @@ -91,24 +91,23 @@ export default class NetworkKeyManager { if (this.preEnableIdentityCallback) { await this.preEnableIdentityCallback() } - let sig = await wallet.signMessage(input) + const sig = await wallet.signMessage(input) // Check that the signature is correct, was created using the expected // input, and retry if not. This mitigates a bug in interacting with // LedgerLive for iOS, where the previous signature response is // returned in some cases. - let address = utils.verifyMessage(input, sig) - if (address !== walletAddr) { - sig = await wallet.signMessage(input) - console.log('invalid signature, retrying') - - address = utils.verifyMessage(input, sig) - if (address !== walletAddr) { - throw new Error('invalid signature') - } + const valid = verifyMessage({ + address: walletAddr as `0x${string}`, + message: input, + signature: sig as `0x${string}`, + }) + + if (!valid) { + throw new Error('invalid signature') } - const secret = hexToBytes(sig) + const secret = hexToBytes(sig as `0x${string}`) const ciphertext = await encrypt(bytes, secret) return proto.EncryptedPrivateKeyBundle.encode({ v1: { @@ -136,7 +135,9 @@ export default class NetworkKeyManager { await this.preEnableIdentityCallback() } const secret = hexToBytes( - await wallet.signMessage(storageSigRequestText(eBundle.walletPreKey)) + (await wallet.signMessage( + storageSigRequestText(eBundle.walletPreKey) + )) as `0x${string}` ) // Ledger uses the last byte = v=[0,1,...] but Metamask and other wallets generate with diff --git a/src/utils/topic.ts b/src/utils/topic.ts index 98d28006..f706d321 100644 --- a/src/utils/topic.ts +++ b/src/utils/topic.ts @@ -1,4 +1,4 @@ -import { utils } from 'ethers' +import { getAddress } from 'viem' export const buildContentTopic = (name: string): string => `/xmtp/0/${name}/proto` @@ -8,7 +8,7 @@ export const buildDirectMessageTopic = ( recipient: string ): string => { // EIP55 normalize the address case. - const members = [utils.getAddress(sender), utils.getAddress(recipient)] + const members = [getAddress(sender), getAddress(recipient)] members.sort() return buildContentTopic(`dm-${members.join('-')}`) } @@ -19,17 +19,17 @@ export const buildDirectMessageTopicV2 = (randomString: string): string => { export const buildUserContactTopic = (walletAddr: string): string => { // EIP55 normalize the address case. - return buildContentTopic(`contact-${utils.getAddress(walletAddr)}`) + return buildContentTopic(`contact-${getAddress(walletAddr)}`) } export const buildUserIntroTopic = (walletAddr: string): string => { // EIP55 normalize the address case. - return buildContentTopic(`intro-${utils.getAddress(walletAddr)}`) + return buildContentTopic(`intro-${getAddress(walletAddr)}`) } export const buildUserInviteTopic = (walletAddr: string): string => { // EIP55 normalize the address case. - return buildContentTopic(`invite-${utils.getAddress(walletAddr)}`) + return buildContentTopic(`invite-${getAddress(walletAddr)}`) } export const buildUserPrivateStoreTopic = (addrPrefixedKey: string): string => { diff --git a/test/conversations/Conversation.test.ts b/test/conversations/Conversation.test.ts index cbbfc660..9d1d9124 100644 --- a/test/conversations/Conversation.test.ts +++ b/test/conversations/Conversation.test.ts @@ -394,9 +394,7 @@ describe('conversation', () => { }) it('throws when opening a conversation with an unknown address', () => { - expect(alice.conversations.newConversation('0xfoo')).rejects.toThrow( - 'invalid address' - ) + expect(alice.conversations.newConversation('0xfoo')).rejects.toThrow() const validButUnknown = '0x1111111111222222222233333333334444444444' expect( alice.conversations.newConversation(validButUnknown) diff --git a/test/keystore/InMemoryKeystore.test.ts b/test/keystore/InMemoryKeystore.test.ts index c2d514a5..bbee6bdd 100644 --- a/test/keystore/InMemoryKeystore.test.ts +++ b/test/keystore/InMemoryKeystore.test.ts @@ -18,8 +18,8 @@ import { InMemoryPersistence } from '../../src/keystore/persistence' import Token from '../../src/authn/Token' import Long from 'long' import { CreateInviteResponse } from '@xmtp/proto/ts/dist/types/keystore_api/v1/keystore.pb' -import { ethers } from 'ethers' import { assert } from 'vitest' +import { toBytes } from 'viem' describe('InMemoryKeystore', () => { let aliceKeys: PrivateKeyBundleV1 @@ -526,7 +526,7 @@ describe('InMemoryKeystore', () => { it('generates known deterministic topic', async () => { aliceKeys = new PrivateKeyBundleV1( privateKey.PrivateKeyBundle.decode( - ethers.utils.arrayify( + toBytes( '0x0a8a030ac20108c192a3f7923112220a2068d2eb2ef8c50c4916b42ce638c5610e44ff4eb3ecb098' + 'c9dacf032625c72f101a940108c192a3f7923112460a440a40fc9822283078c323c9319c45e60ab4' + '2c65f6e1744ed8c23c52728d456d33422824c98d307e8b1c86a26826578523ba15fe6f04a17fca17' + @@ -546,7 +546,7 @@ describe('InMemoryKeystore', () => { ) bobKeys = new PrivateKeyBundleV1( privateKey.PrivateKeyBundle.decode( - ethers.utils.arrayify( + toBytes( '0x0a88030ac001088cd68df7923112220a209057f8d813314a2aae74e6c4c30f909c1c496b6037ce32' + 'a12c613558a8e961681a9201088cd68df7923112440a420a40501ae9b4f75d5bb5bae3ca4ecfda4e' + 'de9edc5a9b7fc2d56dc7325b837957c23235cc3005b46bb9ef485f106404dcf71247097ed5096355' +