Skip to content

Commit

Permalink
feat: replace ethers with viem
Browse files Browse the repository at this point in the history
  • Loading branch information
rygine committed Feb 27, 2024
1 parent 6579a0d commit 80769ab
Show file tree
Hide file tree
Showing 11 changed files with 83 additions and 58 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions rollup.config.bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const external = [
'elliptic',
'ethers',
'long',
'viem',
]

const plugins = [
Expand Down
13 changes: 6 additions & 7 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -430,7 +429,7 @@ export default class Client<ContentTypes = any> {
async getUserContact(
peerAddress: string
): Promise<PublicKeyBundle | SignedPublicKeyBundle | undefined> {
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
Expand All @@ -456,7 +455,7 @@ export default class Client<ContentTypes = any> {
): 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<string, keybundle|undefined>
Expand Down Expand Up @@ -501,7 +500,7 @@ export default class Client<ContentTypes = any> {
* 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)
}

Expand Down Expand Up @@ -552,7 +551,7 @@ export default class Client<ContentTypes = any> {
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
Expand All @@ -563,7 +562,7 @@ export default class Client<ContentTypes = any> {
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
}
Expand Down
4 changes: 2 additions & 2 deletions src/conversations/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
concat,
toNanoString,
} from '../utils'
import { utils } from 'ethers'
import Stream from '../Stream'
import Client, {
ListMessagesOptions,
Expand All @@ -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.
Expand Down Expand Up @@ -178,7 +178,7 @@ export class ConversationV1<ContentTypes>
private client: Client<ContentTypes>

constructor(client: Client<ContentTypes>, address: string, createdAt: Date) {
this.peerAddress = utils.getAddress(address)
this.peerAddress = getAddress(address)
this.client = client
this.createdAt = createdAt
}
Expand Down
19 changes: 7 additions & 12 deletions src/crypto/PublicKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
},
})
}
Expand All @@ -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) {
Expand Down
17 changes: 6 additions & 11 deletions src/crypto/Signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 })
Expand Down
36 changes: 36 additions & 0 deletions src/crypto/utils.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 }
}
29 changes: 15 additions & 14 deletions src/keystore/providers/NetworkKeyManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { utils } from 'ethers'
import { Signer } from '../../types/Signer'
import crypto from '../../crypto/crypto'
import {
Expand All @@ -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'
/**
Expand All @@ -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}`
}

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/utils/topic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { utils } from 'ethers'
import { getAddress } from 'viem'

export const buildContentTopic = (name: string): string =>
`/xmtp/0/${name}/proto`
Expand All @@ -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('-')}`)
}
Expand All @@ -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 => {
Expand Down
4 changes: 1 addition & 3 deletions test/conversations/Conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions test/keystore/InMemoryKeystore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' +
Expand All @@ -546,7 +546,7 @@ describe('InMemoryKeystore', () => {
)
bobKeys = new PrivateKeyBundleV1(
privateKey.PrivateKeyBundle.decode(
ethers.utils.arrayify(
toBytes(
'0x0a88030ac001088cd68df7923112220a209057f8d813314a2aae74e6c4c30f909c1c496b6037ce32' +
'a12c613558a8e961681a9201088cd68df7923112440a420a40501ae9b4f75d5bb5bae3ca4ecfda4e' +
'de9edc5a9b7fc2d56dc7325b837957c23235cc3005b46bb9ef485f106404dcf71247097ed5096355' +
Expand Down

0 comments on commit 80769ab

Please sign in to comment.