diff --git a/connectors/index.ts b/connectors/index.ts index fbf2ce4..402f87b 100644 --- a/connectors/index.ts +++ b/connectors/index.ts @@ -3,7 +3,8 @@ import { InjectedConnector } from "@web3-react/injected-connector"; import { WalletConnectConnector } from "@web3-react/walletconnect-connector"; import { PortisConnector } from "@web3-react/portis-connector"; import { WalletLinkConnector } from "@web3-react/walletlink-connector"; -import { LedgerConnector } from "@web3-react/ledger-connector"; +// import { LedgerConnector } from "@web3-react/ledger-connector"; +import { LedgerConnector } from "./ledger-connector"; import INSTADAPP_LOGO_URL from "~/assets/logo/instadapp-logo-icon.svg?inline"; @@ -48,8 +49,17 @@ export { gnosisSafe }; const POLLING_INTERVAL = 12000; +export enum LedgerDerivationPath { + "Legacy" = "44'/60'/0'/x", + "LedgerLive" = "44'/60'/x'/0/0" +} + export const ledger = new LedgerConnector({ chainId: 1, url: `https://mainnet.infura.io/v3/${process.env.INFURA_ID}`, - pollingInterval: POLLING_INTERVAL + requestTimeoutMs: 60, + pollingInterval: POLLING_INTERVAL, + baseDerivationPath: "44'/60'/x'/0/0", + accountsOffset: 0, + accountsLength: 1 }); diff --git a/connectors/ledger-connector.ts b/connectors/ledger-connector.ts new file mode 100644 index 0000000..c5d4dff --- /dev/null +++ b/connectors/ledger-connector.ts @@ -0,0 +1,102 @@ +// https://github.com/aave/aave-ui/blob/e0602e3560b2aabf86e8afd29db20c0ee8c249fc/src/libs/web3-data-provider/web3-providers/connectors/ledger-connector.ts + +import { ConnectorUpdate } from '@web3-react/types'; +import { AbstractConnector } from '@web3-react/abstract-connector'; +import Web3ProviderEngine from 'web3-provider-engine'; +import { RPCSubprovider } from '@0x/subproviders/lib/src/subproviders/rpc_subprovider'; // https://github.com/0xProject/0x-monorepo/issues/1400 +import createLedgerSubprovider from './ledger-subprovider'; +import TransportU2F from '@ledgerhq/hw-transport-u2f'; +import webUsbTransport from '@ledgerhq/hw-transport-webusb'; +import type Transport from '@ledgerhq/hw-transport'; + +interface LedgerConnectorArguments { + chainId: number; + url: string; + pollingInterval?: number; + requestTimeoutMs?: number; + baseDerivationPath?: string; + accountsOffset?: number; + accountsLength: number; +} + +const getTransport = async (): Promise => { + if (await webUsbTransport.isSupported()) { + return await webUsbTransport.create(); + } + return await TransportU2F.create(); +}; + +export class LedgerConnector extends AbstractConnector { + private readonly chainId: number; + private readonly url: string; + private readonly pollingInterval?: number; + private readonly requestTimeoutMs?: number; + private readonly baseDerivationPath?: string; + private readonly accountsOffset?: number; + private readonly accountsLength: number; + + private provider: any; + + constructor({ + chainId, + url, + pollingInterval, + requestTimeoutMs, + baseDerivationPath, + accountsOffset = 0, + accountsLength = 1, + }: LedgerConnectorArguments) { + super({ supportedChainIds: [chainId] }); + + this.chainId = chainId; + this.url = url; + this.requestTimeoutMs = requestTimeoutMs; + this.baseDerivationPath = baseDerivationPath; + this.pollingInterval = pollingInterval; + this.accountsOffset = accountsOffset; + this.accountsLength = accountsLength; + } + + public async activate(): Promise { + if (!this.provider) { + const engine = new Web3ProviderEngine({ pollingInterval: this.pollingInterval }); + const ledgerProvider = await createLedgerSubprovider(getTransport, { + networkId: this.chainId, + paths: this.baseDerivationPath ? [this.baseDerivationPath] : undefined, + accountsLength: this.accountsLength, + accountsOffset: this.accountsOffset, + }); + engine.addProvider(ledgerProvider); + engine.addProvider(new RPCSubprovider(this.url, this.requestTimeoutMs)); + this.provider = engine; + this.provider.start(); + } + const account = await this.getAccount(); + return { provider: this.provider, chainId: this.chainId, account }; + } + + public async getProvider(): Promise { + return this.provider; + } + + public async getChainId(): Promise { + return this.chainId; + } + + public async getAccount(): Promise { + return new Promise((resolve, reject) => { + this.provider._providers[0].getAccounts(function (error: any, result: string[]) { + if (error) { + return reject(error); + } + return resolve(result[0]); + }); + }); + } + + public deactivate() { + if (this.provider) { + this.provider.stop(); + } + } +} \ No newline at end of file diff --git a/connectors/ledger-subprovider.ts b/connectors/ledger-subprovider.ts new file mode 100644 index 0000000..d55c758 --- /dev/null +++ b/connectors/ledger-subprovider.ts @@ -0,0 +1,138 @@ +import AppEth from '@ledgerhq/hw-app-eth'; +import type Transport from '@ledgerhq/hw-transport'; +import HookedWalletSubprovider from 'web3-provider-engine/subproviders/hooked-wallet'; +import stripHexPrefix from 'strip-hex-prefix'; +import { Transaction as EthereumTx } from 'ethereumjs-tx'; + +function makeError(msg: string, id: string) { + const err: any = new Error(msg); + err.id = id; + return err; +} + +/** + */ +type SubproviderOptions = { + // refer to https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md + networkId: number; + // derivation path schemes (with a x in the path) + paths?: string[]; + // should use actively validate on the device + askConfirm?: boolean; + // number of accounts to derivate + accountsLength?: number; + // offset index to use to start derivating the accounts + accountsOffset?: number; +}; + +const defaultOptions = { + networkId: 1, // mainnet + paths: ["44'/60'/x'/0/0", "44'/60'/0'/x"], // ledger live derivation path + askConfirm: false, + accountsLength: 1, + accountsOffset: 0, +}; + +/** + * Create a HookedWalletSubprovider for Ledger devices. + */ +export default async function createLedgerSubprovider( + getTransport: () => Promise, + options?: SubproviderOptions +) { + if (options && 'path' in options) { + throw new Error( + "@ledgerhq/web3-subprovider: path options was replaced by paths. example: paths: [\"44'/60'/x'/0/0\"]" + ); + } + const { networkId, paths, askConfirm, accountsLength, accountsOffset } = { + ...defaultOptions, + ...options, + }; + + if (!paths.length) { + throw new Error('paths must not be empty'); + } + + const addressToPathMap: any = {}; + const transport = await getTransport(); + + async function getAccounts() { + const eth = new AppEth(transport); + const addresses: any = {}; + for (let i = accountsOffset; i < accountsOffset + accountsLength; i++) { + const x = Math.floor(i / paths.length); + const pathIndex = i - paths.length * x; + const path = paths[pathIndex].replace('x', String(x)); + const address = await eth.getAddress(path, askConfirm, false); + addresses[path] = address.address; + addressToPathMap[address.address.toLowerCase()] = path; + } + return addresses; + } + + async function signPersonalMessage(msgData: any) { + const path = addressToPathMap[msgData.from.toLowerCase()]; + if (!path) throw new Error("address unknown '" + msgData.from + "'"); + const eth = new AppEth(transport); + const result = await eth.signPersonalMessage(path, stripHexPrefix(msgData.data)); + const v = parseInt(result.v, 10) - 27; + let vHex = v.toString(16); + if (vHex.length < 2) { + vHex = `0${v}`; + } + return `0x${result.r}${result.s}${vHex}`; + } + + async function signTransaction(txData: any) { + const path = addressToPathMap[txData.from.toLowerCase()]; + if (!path) throw new Error("address unknown '" + txData.from + "'"); + const eth = new AppEth(transport); + const tx = new EthereumTx(txData, { chain: networkId }); + + // Set the EIP155 bits + tx.raw[6] = Buffer.from([networkId]); // v + tx.raw[7] = Buffer.from([]); // r + tx.raw[8] = Buffer.from([]); // s + + // Pass hex-rlp to ledger for signing + const result = await eth.signTransaction(path, tx.serialize().toString('hex')); + + // Store signature in transaction + tx.v = Buffer.from(result.v, 'hex'); + tx.r = Buffer.from(result.r, 'hex'); + tx.s = Buffer.from(result.s, 'hex'); + + // EIP155: v should be chain_id * 2 + {35, 36} + const signedChainId = Math.floor((tx.v[0] - 35) / 2); + const validChainId = networkId & 0xff; // FIXME this is to fixed a current workaround that app don't support > 0xff + if (signedChainId !== validChainId) { + throw makeError( + 'Invalid networkId signature returned. Expected: ' + networkId + ', Got: ' + signedChainId, + 'InvalidNetworkId' + ); + } + + return `0x${tx.serialize().toString('hex')}`; + } + + const subprovider = new HookedWalletSubprovider({ + getAccounts: (callback: (err: any, res: any) => void) => { + getAccounts() + .then((res) => callback(null, Object.values(res))) + .catch((err) => callback(err, null)); + }, + signPersonalMessage: (txData: any, callback: (err: any, res: any) => void) => { + signPersonalMessage(txData) + .then((res) => callback(null, res)) + .catch((err) => callback(err, null)); + }, + signTransaction: (txData: any, callback: (err: any, res: any) => void) => { + signTransaction(txData) + .then((res) => callback(null, res)) + .catch((err) => callback(err, null)); + }, + }); + + return subprovider; +} \ No newline at end of file diff --git a/package.json b/package.json index 2118810..5709b8d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@gnosis.pm/safe-apps-web3-react": "^0.6.2", "@gnosis.pm/safe-apps-web3modal": "^2.0.0", "@instadapp/vue-web3": "^0.3.0", + "@ledgerhq/hw-transport-webusb": "^6.6.0", "@nuxtjs/axios": "^5.13.6", "@nuxtjs/composition-api": "^0.27.0", "@portis/web3": "^4.0.5", diff --git a/yarn.lock b/yarn.lock index b21507e..412be7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1349,11 +1349,26 @@ "@ledgerhq/logs" "^4.72.0" rxjs "^6.5.3" +"@ledgerhq/devices@^6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-6.3.0.tgz#7ee59614198882311d1805912e368451527d05b2" + integrity sha512-DmVxqMAf3FhkpKjkbBCFVJ5DmesfplujeCLzFwO/zF5VGuwY7xxPqeSxlpusXJkqhEq+DbFzIDRWJYDf7rtXqg== + dependencies: + "@ledgerhq/errors" "^6.2.0" + "@ledgerhq/logs" "^6.2.0" + rxjs "6" + semver "^7.3.5" + "@ledgerhq/errors@^4.78.0": version "4.78.0" resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-4.78.0.tgz#23daf3af54d03b1bda3e616002b555da1bdb705a" integrity sha512-FX6zHZeiNtegBvXabK6M5dJ+8OV8kQGGaGtuXDeK/Ss5EmG4Ltxc6Lnhe8hiHpm9pCHtktOsnUVL7IFBdHhYUg== +"@ledgerhq/errors@^6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.2.0.tgz#7dc2b3bf6bdedccdaa1b97dccacfa912c4fc22f8" + integrity sha512-eO03x8HJmG60WtlrMuahigW/rwywFdcGzCnihta/MjkM8BD9A660cKVkyIuheCcpaB7UV/r+QsRl9abHbjjaag== + "@ledgerhq/hw-app-eth@^4.3.0": version "4.78.0" resolved "https://registry.yarnpkg.com/@ledgerhq/hw-app-eth/-/hw-app-eth-4.78.0.tgz#fbd7ffe7f371d0c32a53f38c5149ab8d13514297" @@ -1395,6 +1410,16 @@ "@ledgerhq/hw-transport" "^4.24.0" u2f-api "0.2.7" +"@ledgerhq/hw-transport-webusb@^6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-webusb/-/hw-transport-webusb-6.6.0.tgz#9be0d9f32ccee27f754bc50b35d9a703ebcc4d36" + integrity sha512-ovY2IzbmAFsVgym7ftLU4c36Me5DHuM41rdI7XipMRd1COvnYNiutqAMym2RLk5IS2LRPAqTWiWgt86Oy6DGvQ== + dependencies: + "@ledgerhq/devices" "^6.3.0" + "@ledgerhq/errors" "^6.2.0" + "@ledgerhq/hw-transport" "^6.3.0" + "@ledgerhq/logs" "^6.2.0" + "@ledgerhq/hw-transport@^4.24.0", "@ledgerhq/hw-transport@^4.78.0": version "4.78.0" resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-4.78.0.tgz#714786658e1f2fbc0569e06e2abf8d15d310d931" @@ -1404,11 +1429,25 @@ "@ledgerhq/errors" "^4.78.0" events "^3.0.0" +"@ledgerhq/hw-transport@^6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.3.0.tgz#4fc966b1a68c991c0a6b5384841f99c4f8304ce9" + integrity sha512-kdnVrgmxrFtKaRdkoaQBEa02RXgLzEBiooYbxA65BGSJig3PGWDS9LrqNpzLTZM1RQlivd9NLBmfwU2ze4chWA== + dependencies: + "@ledgerhq/devices" "^6.3.0" + "@ledgerhq/errors" "^6.2.0" + events "^3.3.0" + "@ledgerhq/logs@^4.72.0": version "4.72.0" resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-4.72.0.tgz#43df23af013ad1135407e5cf33ca6e4c4c7708d5" integrity sha512-o+TYF8vBcyySRsb2kqBDv/KMeme8a2nwWoG+lAWzbDmWfb2/MrVWYCVYDYvjXdSoI/Cujqy1i0gIDrkdxa9chA== +"@ledgerhq/logs@^6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-6.2.0.tgz#9fb2d6f1811316697f7b3cc14607f6c608912419" + integrity sha512-SLyFyD7ElMhgKWPYedFGCT/ilcbGPgL5hXXYHxOM79Fs5fWi0zaUpt5oGqGMsOAAFaMa9/rbun0pokzPhEFz8A== + "@metamask/safe-event-emitter@2.0.0", "@metamask/safe-event-emitter@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@metamask/safe-event-emitter/-/safe-event-emitter-2.0.0.tgz#af577b477c683fad17c619a78208cede06f9605c" @@ -12328,7 +12367,7 @@ rustbn.js@~0.2.0: resolved "https://registry.yarnpkg.com/rustbn.js/-/rustbn.js-0.2.0.tgz#8082cb886e707155fd1cb6f23bd591ab8d55d0ca" integrity sha512-4VlvkRUuCJvr2J6Y0ImW7NvTCriMi7ErOAqWk1y69vAdoNIzCF3yPmgeNzx+RQTLEDFq5sHfscn1MwHxP9hNfA== -rxjs@^6.5.3, rxjs@^6.6.0, rxjs@^6.6.3: +rxjs@6, rxjs@^6.5.3, rxjs@^6.6.0, rxjs@^6.6.3: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==