nxqbao / eth-signer-trezor

Ethers signer that derives address and signs transactions using Trezor device.
2 stars 1 forks source link

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

Open gruvin opened 11 months ago

gruvin commented 11 months ago

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: 'gruvin@gmail.com',
  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 commented 10 months ago

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 commented 10 months ago

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