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

I hacked together an ethers 6 version (if you're interested) #6

Open
gruvin opened this issue Nov 15, 2023 · 3 comments
Open

I hacked together an ethers 6 version (if you're interested) #6

gruvin opened this issue Nov 15, 2023 · 3 comments

Comments

@gruvin
Copy link

gruvin commented Nov 15, 2023

Thanks for your work on this repo! :-)

I'm not exactly a pro programmer but it is nice to be able to ditch BigNumber and just use JS BigInt's nowadays thanks to to ethers.js v6. (I believe the web3.js crew have moved to this also. Don't quote me.)

No expectations. take it or leave it. I was just messing about and not actually writing anything with it (yet). Also, too lazy to fork and PR :P

/*
  ether v6 com[atible version of https://www.npmjs.com/package/@nxqbao/eth-signer-trezor
*/

import {
  ethers,
  AbstractSigner,
  Provider,
  Signature,
  Transaction,
  TypedDataDomain,
  TypedDataField,
  TransactionRequest,
  computeAddress,
  getAddress,
  hexlify,
  toNumber,
  toQuantity,
  resolveProperties,
} from 'ethers'
import TrezorConnect, {
  Response,
  Unsuccessful,
  EthereumSignTransaction,
} from '@trezor/connect';
import { HDNodeResponse } from '@trezor/connect/lib/types/api/getPublicKey';
import { transformTypedData } from "@trezor/connect-plugin-ethereum";
import { ConnectError } from './error';
import HDkey from 'hdkey';

const manifest = {
  email: '[email protected]',
  appUrl: 'https://hmterm.gruvin.me/'
}

const config = {
  manifest,
  popup: false,
  webusb: false,
  debug: false,
  lazyLoad: false
  // env: "node"
}

const HD_WALLET_PATH_BASE = `m`
const DEFAULT_HD_PATH_STRING = "m/44'/60'/0'/0" // TODO: handle <chainId>
const DEFAULT_SESSION_NAME = 'trezor-signer'

async function handleResponse<T>(p: Response<T>) {
  const response = await p;

  if (response.success) {
    return response.payload;
  }

  throw {
    message: (response as Unsuccessful).payload.error,
    code: (response as Unsuccessful).payload.code
  }
}

export class TrezorSigner extends AbstractSigner {
  private _derivePath: string
  private _address?: string | undefined

  private _isInitialized: boolean
  private _isLoggedIn: boolean
  private _isPrepared: boolean

  private _sessionName: string
  private _hdk: HDkey
  private _pathTable: { [key: string]: any }

  readonly _reqIndex?: string | number
  readonly _reqAddress?: string

  constructor(
    provider?: Provider,
    derivePath?: string,
    index?: number,
    address?: string,
    sessionName?: string
  ) {
    super(provider);

    if (index && address) {
      throw new Error("Specify account by either wallet index or address. Default index is 0.")
    }

    if (!index && !address) {
      index = 0;
    }

    this._reqIndex = index
    this._reqAddress = address

    this._sessionName = sessionName || DEFAULT_SESSION_NAME;
    this._derivePath = derivePath || DEFAULT_HD_PATH_STRING;
    this._hdk = new HDkey();
    this._isInitialized = false
    this._isLoggedIn = false
    this._isPrepared = false
    this._pathTable = {}
  }

  public async prepare(): Promise<any> {
    if (this._isPrepared) { return }

    this._isPrepared = true;

    await this.init();
    await this.login();
    await this.getAccountsFromDevice()

    if (this._reqAddress !== undefined) {
      this._address = this._reqAddress
      this._derivePath = this.pathFromAddress(this._reqAddress)
    }

    if (this._reqIndex !== undefined) {
      this._derivePath = this.concatWalletPath(this._reqIndex)
      this._address = this.addressFromIndex(HD_WALLET_PATH_BASE, this._reqIndex)
    }
  }

  public async init(): Promise<any> {
    if (this._isInitialized) { return }

    console.log("Init trezor...")
    this._isInitialized = true;
    return TrezorConnect.init(config)
  }

  public async login(): Promise<any> {
    if (this._isLoggedIn) { return }

    console.log("Login to trezor...")
    this._isLoggedIn = true;

    // TODO: change to random handshake info
    const loginInfo = await TrezorConnect.requestLogin({
      challengeHidden: "0123456789abcdef",
      challengeVisual: `Login to ${this._sessionName}`
    })

    return loginInfo
  }

  private async getAccountsFromDevice(fromIndex: number = 0, toIndex: number = 10): Promise<any> {
    if (toIndex < 0 || fromIndex < 0) {
      throw new Error('Invalid from and to')
    }
    await this.setHdKey()

    const result: string[] = []
    for (let i = fromIndex; i < toIndex; i++) {
      const address = this.addressFromIndex(HD_WALLET_PATH_BASE, i)
      result.push(address.toLowerCase());
      this._pathTable[getAddress(address)] = i
    }

    return result
  }

  private async setHdKey(): Promise<any> {
    if (this._hdk.publicKey && this._hdk.chainCode) { return }
    const result = await this.getDerivePublicKey()
    this._hdk.publicKey = Buffer.from(result.publicKey, 'hex')
    this._hdk.chainCode = Buffer.from(result.chainCode, 'hex')
    return this._hdk
  }

  private async getDerivePublicKey(): Promise<HDNodeResponse> {
    return new Promise(async (resolve, reject) => {
      const response = await TrezorConnect.getPublicKey({ path: this._derivePath })
      if (response.success) resolve(response.payload)
      else reject(response.payload.error)
    })
  }

  public async getAddress(): Promise<string> {
    if (!this._address) {
      const result = await this.makeRequest(() => (TrezorConnect.ethereumGetAddress({
        path: this._derivePath
      })))
      this._address = result.address ? ethers.getAddress(result.address) : ''
    }

    return this._address;
  }

  public async signMessage(message: string | Uint8Array): Promise<string> {
    const _message = (message instanceof Uint8Array) ? hexlify(message) : message
    const result = await this.makeRequest(() => TrezorConnect.ethereumSignMessage({
      path: this._derivePath,
      message: _message
    }))

    return result.signature
  }

  public async signTransaction(transaction: TransactionRequest): Promise<string> {
    const tx = new Transaction()

    // TODO: handle tx.type
    // EIP-1559; Type 2
    if (tx.maxPriorityFeePerGas) tx.maxPriorityFeePerGas = tx.maxPriorityFeePerGas
    if (tx.maxFeePerGas) tx.maxFeePerGas = tx.maxFeePerGas

    const trezorTx: EthereumSignTransaction = {
      path: this._derivePath,
      transaction: {
        to: (tx.to || '0x').toString(),
        value: toQuantity(tx.value || 0),
        gasPrice: toQuantity(tx.gasPrice || 0),
        gasLimit: toQuantity(tx.gasLimit || 0),
        nonce: toQuantity(tx.nonce || 0),
        data: hexlify(tx.data || '0x'),
        chainId: toNumber(tx.chainId || 0),
      }
    }

    tx.signature = Signature.from(await this.makeRequest(() => TrezorConnect.ethereumSignTransaction(trezorTx), 1))

    return tx.serialized
  }

  public connect(provider: Provider): TrezorSigner {
    return new TrezorSigner(provider, this._derivePath)
  }

  // TODO: This could probably all be done more easily using ethers::TypedDataEncoder
  public async signTypedData(
    domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any>
  ): Promise<string> {
    const domainProps: { [key: string]: any } = resolveProperties(domain)
    const EIP712Domain: TypedDataField[] = [];
    const domainPropertyTypes = ['string', 'uint256', 'bytes32', 'address', 'string']
    const domainProperties = ['name', 'chainId', 'salt', 'verifyingContract', 'version']
    domainProperties.forEach((property, index) => {
      if (domainProps[property]) {
        EIP712Domain.push({
          type: domainPropertyTypes[index],
          name: property
        })
      }
    })
    const eip712Data = {
      domain,
      types: {
        EIP712Domain,
        ...types
      },
      message: value,
      primaryType: Object.keys(types)[0]
    } as Parameters<typeof transformTypedData>[0]
    console.log("EIP712 Data: ", JSON.stringify(eip712Data, null, 4))
    const { domain_separator_hash, message_hash } = transformTypedData(eip712Data, true)
    console.log("Domain separator hash: ", domain_separator_hash)
    console.log("Message hash: ", message_hash)

    const result = await this.makeRequest(() => TrezorConnect.ethereumSignTypedData({
      path: this._derivePath,
      metamask_v4_compat: true,
      data: eip712Data,
      domain_separator_hash,
      message_hash: (message_hash === null ? undefined : message_hash)
    }));
    return result.signature;
  }

  private addressFromIndex(pathBase: string, index: number | string): string {
    const derivedKey = this._hdk.derive(`${pathBase}/${index}`)
    const address = computeAddress(hexlify(derivedKey.publicKey))
    return getAddress(address)
  }

  private pathFromAddress(address: string): string {
    const checksummedAddress = getAddress(address)
    let index = this._pathTable[checksummedAddress]
    if (typeof index === 'undefined') {
      for (let i = 0; i < 1000; i++) {
        if (checksummedAddress === this.addressFromIndex(HD_WALLET_PATH_BASE, i)) {
          index = i
          break
        }
      }
    }

    if (typeof index === 'undefined') {
      throw new Error('Unknown address in trezor');
    }
    return this.concatWalletPath(index);
  }

  private concatWalletPath(index: string | number) {
    return `${this._derivePath}/${index.toString(10)}`
  }

  private async makeRequest<T>(fn: () => Response<T>, retries = 20) {
    try {
      await this.prepare()

      const result = await handleResponse(fn());
      return result
    } catch (e: unknown) {
      if (retries === 0) {
        throw new Error('Trezor unreachable, please try again')
      }

      const err = e as ConnectError

      if (err.code === 'Device_CallInProgress') {
        return new Promise<T>(resolve => {
          setTimeout(() => {
            console.warn('request conflict, trying again in 400ms', err)
            resolve(this.makeRequest(fn, retries - 1))
          }, 400)
        })
      } else {
        throw err
      }
    }
  }
}
@nxqbao
Copy link
Owner

nxqbao commented Dec 4, 2023

Could you please make a PR regarding the change? If there is not much major changes, I can include it in the next patch release.

@gruvin
Copy link
Author

gruvin commented Dec 4, 2023

I would consider it a major change, since it deletes webjs package and replaces it with etherjs, including all associated function calls.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants
@gruvin @nxqbao and others