Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace ethers with viem #528

Merged
merged 9 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,16 @@
"browser": "dist/browser/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/index.cjs",
"dist/index.cjs.map",
"dist/index.d.ts",
"dist/index.js",
"dist/index.js.map",
"dist/browser/index.js",
"dist/browser/index.js.map",
"dist/bundler/index.js",
"dist/bundler/index.js.map"
"dist",
"src",
"tsconfig.json"
],
"scripts": {
"autolint": "prettier --write . && eslint --fix .",
"bench": "yarn build:bench && node dist/bench/index.cjs",
"build": "yarn clean:dist && rollup -c",
"build:bench": "rollup -c rollup.config.bench.js",
"build:docs": "yarn clean:docs && mkdir -p tmp && cp README.md tmp/ && sed -i.bak '/badge.svg/d' tmp/README.md && typedoc --excludePrivate --readme tmp/README.md src/index.ts",
"build:docs": "yarn clean:docs && mkdir -p tmp && cp README.md tmp/ && sed -i.bak '/badge.svg/d' tmp/README.md && typedoc",
"clean": "yarn clean:artifacts && yarn clean:dist && yarn clean:docs && yarn clean:deps",
"clean:artifacts": "rm -rf docs tmp package.tgz",
"clean:deps": "rm -rf node_modules",
Expand Down Expand Up @@ -108,9 +102,8 @@
"@xmtp/user-preferences-bindings-wasm": "^0.3.6",
"async-mutex": "^0.4.1",
"elliptic": "^6.5.4",
"ethers": "^5.7.2",
"long": "^5.2.3",
"viem": "^1.21.4"
"viem": "^2.7.8"
},
"devDependencies": {
"@commitlint/cli": "17.8.1",
Expand All @@ -126,6 +119,7 @@
"@types/node": "^18.19.18",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.1",
"@xmtp/rollup-plugin-resolve-extensions": "1.0.1",
"benny": "^3.7.1",
"dd-trace": "^5.5.0",
Expand All @@ -138,6 +132,7 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "^6.1.1",
"ethers": "^5.7.2",
"happy-dom": "^13.6.2",
"husky": "^7.0.4",
"prettier": "^3.2.5",
Expand Down
2 changes: 1 addition & 1 deletion rollup.config.bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ const external = [
'benny',
'crypto',
'elliptic',
'ethers',
'long',
'viem',
]

const plugins = [
Expand Down
1 change: 0 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const external = [
'async-mutex',
'crypto',
'elliptic',
'ethers',
'long',
'viem',
]
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
3 changes: 1 addition & 2 deletions src/authn/LocalAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { authn, signature, publicKey } from '@xmtp/proto'
import AuthData from './AuthData'
import { PrivateKey } from '../crypto'
import { hexToBytes } from '../crypto/utils'
import Token from './Token'
import { keccak256 } from 'viem'
import { hexToBytes, keccak256 } from 'viem'

export default class LocalAuthenticator {
private identityKey: PrivateKey
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, splitSignature } from './utils'
import { Hex, hashMessage, hexToBytes } 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
48 changes: 36 additions & 12 deletions src/crypto/utils.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import * as secp from '@noble/secp256k1'
import {
Hex,
getAddress,
hexToSignature,
keccak256,
hexToBytes,
bytesToHex as viemBytesToHex,
} from 'viem'

export const bytesToHex = secp.utils.bytesToHex

export function hexToBytes(s: string): Uint8Array {
neekolas marked this conversation as resolved.
Show resolved Hide resolved
if (s.startsWith('0x')) {
s = s.slice(2)
}
const bytes = new Uint8Array(s.length / 2)
for (let i = 0; i < bytes.length; i++) {
const j = i * 2
bytes[i] = Number.parseInt(s.slice(j, j + 2), 16)
}
return bytes
}

export function bytesToBase64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64')
}
Expand All @@ -29,3 +25,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 Hex
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 = hexToBytes(eSig.r)
const s = hexToBytes(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 }
}
31 changes: 16 additions & 15 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 @@ -10,10 +9,11 @@ import {
} from '../../crypto'
import type { PreEventCallback } from '../../Client'
import { LocalAuthenticator } from '../../authn'
import { bytesToHex, hexToBytes } from '../../crypto/utils'
import { bytesToHex } from '../../crypto/utils'
import Ciphertext from '../../crypto/Ciphertext'
import { privateKey as proto } from '@xmtp/proto'
import TopicPersistence from '../persistence/TopicPersistence'
import { Hex, getAddress, hexToBytes, 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 Hex,
})

if (!valid) {
throw new Error('invalid signature')
}

const secret = hexToBytes(sig)
const secret = hexToBytes(sig as Hex)
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 Hex
)

// Ledger uses the last byte = v=[0,1,...] but Metamask and other wallets generate with
Expand Down
Loading
Loading