eth-lattice-keyring
Advanced tools
Comparing version 0.5.0 to 0.6.0
945
index.js
const crypto = require('crypto'); | ||
const EventEmitter = require('events').EventEmitter; | ||
const BN = require('bignumber.js'); | ||
const BN = require('bn.js'); | ||
const SDK = require('gridplus-sdk'); | ||
@@ -8,2 +8,3 @@ const EthTx = require('@ethereumjs/tx'); | ||
const Util = require('ethereumjs-util'); | ||
const secp256k1 = require('secp256k1'); | ||
const keyringType = 'Lattice Hardware'; | ||
@@ -28,3 +29,3 @@ const HARDENED_OFFSET = 0x80000000; | ||
//------------------------------------------------------------------- | ||
deserialize (opts = {}) { | ||
async deserialize (opts = {}) { | ||
if (opts.hdPath) | ||
@@ -52,3 +53,3 @@ this.hdPath = opts.hdPath; | ||
this.sdkState = opts.sdkState; | ||
return Promise.resolve() | ||
return; | ||
} | ||
@@ -60,4 +61,4 @@ | ||
serialize() { | ||
return Promise.resolve({ | ||
async serialize() { | ||
return { | ||
creds: this.creds, | ||
@@ -76,3 +77,3 @@ accounts: this.accounts, | ||
null | ||
}) | ||
}; | ||
} | ||
@@ -93,259 +94,249 @@ | ||
// being cautious). In the future we may remove `bypassOnStateData` entirely. | ||
unlock(bypassOnStateData=false) { | ||
return new Promise((resolve, reject) => { | ||
// Force compatability. `this.accountOpts` were added after other | ||
// state params and must be synced in order for this keyring to function. | ||
if ((!this.accountOpts) || | ||
(this.accounts.length > 0 && this.accountOpts.length != this.accounts.length)) | ||
{ | ||
this.forgetDevice(); | ||
return reject(new Error( | ||
'You can now add multiple Lattice and SafeCard accounts at the same time! ' + | ||
'Your accounts have been cleared. Please press Continue to add them back in.' | ||
)); | ||
} | ||
if (this.isUnlocked() && !this.forceReconnect) { | ||
return resolve('Unlocked'); | ||
} | ||
this._getCreds() | ||
.then((creds) => { | ||
if (creds) { | ||
this.creds.deviceID = creds.deviceID; | ||
this.creds.password = creds.password; | ||
this.creds.endpoint = creds.endpoint || null; | ||
} | ||
return this._initSession(); | ||
}) | ||
.then((includedStateData) => { | ||
// If state data was provided and if we are authorized to | ||
// bypass reconnecting, we can exit here. | ||
if (includedStateData && bypassOnStateData) { | ||
return resolve('Unlocked'); | ||
} | ||
return this._connect(); | ||
}) | ||
.then(() => { | ||
return resolve('Unlocked'); | ||
}) | ||
.catch((err) => { | ||
return reject(new Error(err)); | ||
}) | ||
}) | ||
async unlock (bypassOnStateData = false) { | ||
// Force compatability. `this.accountOpts` were added after other | ||
// state params and must be synced in order for this keyring to function. | ||
if ( | ||
!this.accountOpts || | ||
(this.accounts.length > 0 && | ||
this.accountOpts.length != this.accounts.length) | ||
) { | ||
this.forgetDevice(); | ||
throw new Error( | ||
"You can now add multiple Lattice and SafeCard accounts at the same time! " + | ||
"Your accounts have been cleared. Please press Continue to add them back in." | ||
); | ||
} | ||
if (this.isUnlocked()) { | ||
return "Unlocked"; | ||
} | ||
const creds = await this._getCreds(); | ||
if (creds) { | ||
this.creds.deviceID = creds.deviceID; | ||
this.creds.password = creds.password; | ||
this.creds.endpoint = creds.endpoint || null; | ||
} | ||
const includedStateData = await this._initSession(); | ||
// If state data was provided and if we are authorized to | ||
// bypass reconnecting, we can exit here. | ||
if (includedStateData && bypassOnStateData) { | ||
return "Unlocked"; | ||
} | ||
await this._connect(); | ||
return "Unlocked"; | ||
} | ||
// Add addresses to the local store and return the full result | ||
addAccounts(n=1) { | ||
return new Promise((resolve, reject) => { | ||
if (n === CLOSE_CODE) { | ||
// Special case: use a code to forget the device. | ||
// (This function is overloaded due to constraints upstream) | ||
this.forgetDevice(); | ||
return resolve([]); | ||
} else if (n <= 0) { | ||
// Avoid non-positive numbers. | ||
return reject('Number of accounts to add must be a positive number.'); | ||
} else { | ||
// Normal behavior: establish the connection and fetch addresses. | ||
this.unlock() | ||
.then(() => { | ||
return this._fetchAddresses(n, this.unlockedAccount) | ||
async addAccounts(n=1) { | ||
if (n === CLOSE_CODE) { | ||
// Special case: use a code to forget the device. | ||
// (This function is overloaded due to constraints upstream) | ||
this.forgetDevice(); | ||
return []; | ||
} else if (n <= 0) { | ||
// Avoid non-positive numbers. | ||
throw new Error( | ||
'Number of accounts to add must be a positive number.' | ||
); | ||
} | ||
// Normal behavior: establish the connection and fetch addresses. | ||
await this.unlock() | ||
const addrs = await this._fetchAddresses(n, this.unlockedAccount); | ||
const walletUID = this._getCurrentWalletUID(); | ||
if (!walletUID) { | ||
// We should not add accounts that do not have wallet UIDs. | ||
// Something went wrong and needs to be retried. | ||
await this._connect(); | ||
throw new Error('No active wallet found in Lattice. Please retry.'); | ||
} | ||
// Add these indices | ||
addrs.forEach((addr, i) => { | ||
let alreadySaved = false; | ||
for (let j = 0; j < this.accounts.length; j++) { | ||
if ((this.accounts[j] === addr) && | ||
(this.accountOpts[j].walletUID === walletUID) && | ||
(this.accountOpts[j].hdPath === this.hdPath)) | ||
alreadySaved = true; | ||
} | ||
if (!alreadySaved) { | ||
this.accounts.push(addr); | ||
this.accountIndices.push(this.unlockedAccount+i); | ||
this.accountOpts.push({ | ||
walletUID, | ||
hdPath: this.hdPath, | ||
}) | ||
.then((addrs) => { | ||
const walletUID = this._getCurrentWalletUID(); | ||
// Add these indices | ||
addrs.forEach((addr, i) => { | ||
let alreadySaved = false; | ||
for (let j = 0; j < this.accounts.length; j++) { | ||
if ((this.accounts[j] === addr) && | ||
(this.accountOpts[j].walletUID === walletUID) && | ||
(this.accountOpts[j].hdPath === this.hdPath)) | ||
alreadySaved = true; | ||
} | ||
if (!alreadySaved) { | ||
this.accounts.push(addr); | ||
this.accountIndices.push(this.unlockedAccount+i); | ||
this.accountOpts.push({ | ||
walletUID, | ||
hdPath: this.hdPath, | ||
}) | ||
} | ||
}) | ||
return resolve(this.accounts); | ||
}) | ||
.catch((err) => { | ||
return reject(new Error(err)); | ||
}) | ||
} | ||
}) | ||
return this.accounts; | ||
} | ||
// Return the local store of addresses. This gets called when the extension unlocks. | ||
getAccounts() { | ||
return Promise.resolve(this.accounts ? this.accounts.slice() : [].slice()); | ||
async getAccounts() { | ||
return this.accounts ? [...this.accounts] : []; | ||
} | ||
signTransaction (address, tx) { | ||
return new Promise((resolve, reject) => { | ||
this._findSignerIdx(address) | ||
.then((accountIdx) => { | ||
try { | ||
// Build the Lattice request data and make request | ||
// We expect `tx` to be an `ethereumjs-tx` object, meaning all fields are bufferized | ||
// To ensure everything plays nicely with gridplus-sdk, we convert everything to hex strings | ||
const addressIdx = this.accountIndices[accountIdx]; | ||
const addressParentPath = this.accountOpts[accountIdx].hdPath; | ||
const txData = { | ||
chainId: `0x${this._getEthereumJsChainId(tx).toString('hex')}` || 1, | ||
nonce: `0x${tx.nonce.toString('hex')}` || 0, | ||
gasLimit: `0x${tx.gasLimit.toString('hex')}`, | ||
to: !!tx.to ? tx.to.toString('hex') : null, // null for contract deployments | ||
value: `0x${tx.value.toString('hex')}`, | ||
data: tx.data.length === 0 ? null : `0x${tx.data.toString('hex')}`, | ||
signerPath: this._getHDPathIndices(addressParentPath, addressIdx), | ||
} | ||
switch (tx._type) { | ||
case 2: // eip1559 | ||
if ((tx.maxPriorityFeePerGas === null || tx.maxFeePerGas === null) || | ||
(tx.maxPriorityFeePerGas === undefined || tx.maxFeePerGas === undefined)) | ||
throw new Error('`maxPriorityFeePerGas` and `maxFeePerGas` must be included for EIP1559 transactions.'); | ||
txData.maxPriorityFeePerGas = `0x${tx.maxPriorityFeePerGas.toString('hex')}`; | ||
txData.maxFeePerGas = `0x${tx.maxFeePerGas.toString('hex')}`; | ||
txData.accessList = tx.accessList || []; | ||
txData.type = 2; | ||
break; | ||
case 1: // eip2930 | ||
txData.accessList = tx.accessList || []; | ||
txData.gasPrice = `0x${tx.gasPrice.toString('hex')}`; | ||
txData.type = 1; | ||
break; | ||
default: // legacy | ||
txData.gasPrice = `0x${tx.gasPrice.toString('hex')}`; | ||
txData.type = null; | ||
break; | ||
} | ||
// Lattice firmware v0.11.0 implemented EIP1559 and EIP2930 so for previous verisons | ||
// we need to overwrite relevant params and revert to legacy type. | ||
// Note: `this.sdkSession.fwVersion is of format [fix, minor, major, reserved] | ||
const forceLegacyTx = this.sdkSession.fwVersion[2] < 1 && | ||
this.sdkSession.fwVersion[1] < 11; | ||
if (forceLegacyTx && txData.type === 2) { | ||
txData.gasPrice = txData.maxFeePerGas; | ||
txData.revertToLegacy = true; | ||
delete txData.type; | ||
delete txData.maxFeePerGas; | ||
delete txData.maxPriorityFeePerGas; | ||
delete txData.accessList; | ||
} else if (forceLegacyTx && txData.type === 1) { | ||
txData.revertToLegacy = true; | ||
delete txData.type; | ||
delete txData.accessList; | ||
} | ||
// Get the signature | ||
return this._signTxData(txData) | ||
} catch (err) { | ||
throw new Error(`Failed to build transaction.`) | ||
async signTransaction (address, tx) { | ||
let signedTx, v; | ||
// We will be adding a signature to hydration data for a new | ||
// transaction object since the sig data is not mutable. | ||
// Setup `txToReturn` data and start adding to it. | ||
const txToReturn = tx.toJSON(); | ||
txToReturn.type = tx._type || null; | ||
// Setup info related to signer account | ||
const accountIdx = await this._findSignerIdx(address); | ||
const chainId = getTxChainId(tx).toNumber(); | ||
const fwVersion = this.sdkSession.getFwVersion(); | ||
const addressIdx = this.accountIndices[accountIdx]; | ||
const { hdPath } = this.accountOpts[accountIdx]; | ||
// Build the signing request | ||
if (fwVersion.major > 0 || fwVersion.minor >= 15) { | ||
// Newer firmware versions support an easier pathway | ||
const data = { | ||
// Legacy transactions return tx params. Newer transactions | ||
// return the raw, serialized transaction | ||
payload: tx._type ? | ||
tx.getMessageToSign(false) : | ||
rlp.encode(tx.getMessageToSign(false)), | ||
curveType: SDK.Constants.SIGNING.CURVES.SECP256K1, | ||
hashType: SDK.Constants.SIGNING.HASHES.KECCAK256, | ||
encodingType: SDK.Constants.SIGNING.ENCODINGS.EVM, | ||
signerPath: this._getHDPathIndices(hdPath, addressIdx), | ||
}; | ||
signedTx = await this.sdkSession.sign({ data }); | ||
v = getV(tx, signedTx); | ||
} else { | ||
// Older firmware versions (<0.15.0) use the legacy signing pathway. | ||
let txData; | ||
try { | ||
txData = { | ||
chainId, | ||
nonce: `0x${tx.nonce.toString('hex')}` || 0, | ||
gasLimit: `0x${tx.gasLimit.toString('hex')}`, | ||
to: !!tx.to ? tx.to.toString('hex') : null, // null for contract deployments | ||
value: `0x${tx.value.toString('hex')}`, | ||
data: tx.data.length === 0 ? null : `0x${tx.data.toString('hex')}`, | ||
signerPath: this._getHDPathIndices(hdPath, addressIdx), | ||
} | ||
}) | ||
.then((signedTx) => { | ||
// Add the sig params. `signedTx = { sig: { v, r, s }, tx, txHash}` | ||
if (!signedTx.sig || !signedTx.sig.v || !signedTx.sig.r || !signedTx.sig.s) | ||
return reject(new Error('No signature returned.')); | ||
const txToReturn = tx.toJSON(); | ||
const v = signedTx.sig.v.length === 0 ? '0' : signedTx.sig.v.toString('hex') | ||
txToReturn.r = Util.addHexPrefix(signedTx.sig.r.toString('hex')); | ||
txToReturn.s = Util.addHexPrefix(signedTx.sig.s.toString('hex')); | ||
txToReturn.v = Util.addHexPrefix(v); | ||
if (signedTx.revertToLegacy === true) { | ||
// If firmware does not support an EIP1559/2930 transaction we revert to legacy | ||
txToReturn.type = 0; | ||
txToReturn.gasPrice = signedTx.gasPrice; | ||
} else { | ||
// Otherwise relay the tx type | ||
txToReturn.type = signedTx.type; | ||
switch (tx._type) { | ||
case 2: // eip1559 | ||
if ((tx.maxPriorityFeePerGas === null || tx.maxFeePerGas === null) || | ||
(tx.maxPriorityFeePerGas === undefined || tx.maxFeePerGas === undefined)) | ||
throw new Error('`maxPriorityFeePerGas` and `maxFeePerGas` must be included for EIP1559 transactions.'); | ||
txData.maxPriorityFeePerGas = `0x${tx.maxPriorityFeePerGas.toString('hex')}`; | ||
txData.maxFeePerGas = `0x${tx.maxFeePerGas.toString('hex')}`; | ||
txData.accessList = tx.accessList || []; | ||
txData.type = 2; | ||
break; | ||
case 1: // eip2930 | ||
txData.accessList = tx.accessList || []; | ||
txData.gasPrice = `0x${tx.gasPrice.toString('hex')}`; | ||
txData.type = 1; | ||
break; | ||
default: // legacy | ||
txData.gasPrice = `0x${tx.gasPrice.toString('hex')}`; | ||
txData.type = null; | ||
break; | ||
} | ||
// Lattice firmware v0.11.0 implemented EIP1559 and EIP2930 | ||
// We should throw an error if we cannot support this. | ||
if (fwVersion.major === 0 && fwVersion.minor <= 11 && txData.type) { | ||
throw new Error('Please update Lattice firmware.'); | ||
} | ||
} catch (err) { | ||
throw new Error(`Failed to build transaction.`) | ||
} | ||
// Get the signature | ||
signedTx = await this.sdkSession.sign({ currency: 'ETH', data: txData }); | ||
// Build the tx for export | ||
let validatingTx; | ||
const _chainId = `0x${this._getEthereumJsChainId(tx).toString('hex')}`; | ||
const chainId = new BN(_chainId).toNumber(); | ||
const customNetwork = Common.forCustomChain('mainnet', { | ||
name: 'notMainnet', | ||
networkId: chainId, | ||
chainId: chainId, | ||
}, 'london') | ||
// Add the sig params. `signedTx = { sig: { v, r, s }, tx, txHash}` | ||
if (!signedTx.sig || !signedTx.sig.r || !signedTx.sig.s) { | ||
throw new Error('No signature returned.'); | ||
} | ||
if (signedTx.sig.v === undefined) { | ||
// V2 signature needs `v` calculated | ||
v = getV(tx, signedTx); | ||
} else { | ||
// Legacy signatures have `v` in the response | ||
v = signedTx.sig.v.length === 0 ? '0' : signedTx.sig.v.toString('hex') | ||
} | ||
} | ||
validatingTx = EthTx.TransactionFactory.fromTxData(txToReturn, { | ||
common: customNetwork, freeze: Object.isFrozen(tx) | ||
}) | ||
return resolve(validatingTx); | ||
}) | ||
.catch((err) => { | ||
return reject(new Error(err)); | ||
}) | ||
// Pack the signature into the return object | ||
txToReturn.r = Util.addHexPrefix(signedTx.sig.r.toString('hex')); | ||
txToReturn.s = Util.addHexPrefix(signedTx.sig.s.toString('hex')); | ||
txToReturn.v = Util.addHexPrefix(v); | ||
// Make sure the active wallet is correct to avoid returning | ||
// a signature from an unexpected signer. | ||
const foundIdx = await this._accountIdxInCurrentWallet(address); | ||
if (foundIdx === null) { | ||
throw new Error( | ||
'Wrong account. Please change your Lattice wallet or ' + | ||
'switch to an account on your current active wallet.' | ||
); | ||
} | ||
return EthTx.TransactionFactory.fromTxData(txToReturn, { | ||
common: tx.common, freeze: Object.isFrozen(tx) | ||
}) | ||
} | ||
signPersonalMessage(address, msg) { | ||
async signPersonalMessage(address, msg) { | ||
return this.signMessage(address, { payload: msg, protocol: 'signPersonal' }); | ||
} | ||
signTypedData(address, msg, opts) { | ||
if (opts.version && (opts.version !== 'V4' && opts.version !== 'V3')) | ||
throw new Error(`Only signTypedData V3 and V4 messages (EIP712) are supported. Got version ${opts.version}`); | ||
async signTypedData(address, msg, opts) { | ||
if (opts.version && (opts.version !== 'V4' && opts.version !== 'V3')) { | ||
throw new Error( | ||
`Only signTypedData V3 and V4 messages (EIP712) are supported. Got version ${opts.version}` | ||
); | ||
} | ||
return this.signMessage(address, { payload: msg, protocol: 'eip712' }) | ||
} | ||
signMessage(address, msg) { | ||
return new Promise((resolve, reject) => { | ||
this._findSignerIdx(address) | ||
.then((accountIdx) => { | ||
let { payload, protocol } = msg; | ||
// If the message is not an object we assume it is a legacy signPersonal request | ||
if (!payload || !protocol) { | ||
payload = msg; | ||
protocol = 'signPersonal'; | ||
} | ||
const addressIdx = this.accountIndices[accountIdx]; | ||
const addressParentPath = this.accountOpts[accountIdx].hdPath; | ||
const req = { | ||
currency: 'ETH_MSG', | ||
data: { | ||
protocol, | ||
payload, | ||
signerPath: this._getHDPathIndices(addressParentPath, addressIdx), | ||
} | ||
} | ||
this.sdkSession.sign(req, (err, res) => { | ||
if (err) { | ||
return reject(new Error(err)); | ||
} | ||
if (!this._syncCurrentWalletUID()) { | ||
return reject('No active wallet.'); | ||
} | ||
if (!res.sig) { | ||
return reject(new Error('No signature returned')); | ||
} | ||
// Convert the `v` to a number. It should convert to 0 or 1 | ||
try { | ||
let v = res.sig.v.toString('hex'); | ||
if (v.length < 2) { | ||
v = `0${v}`; | ||
} | ||
return resolve(`0x${res.sig.r}${res.sig.s}${v}`); | ||
} catch (err) { | ||
return reject(new Error('Invalid signature format returned.')) | ||
} | ||
}) | ||
}) | ||
.catch((err) => { | ||
return reject(new Error(err)); | ||
}) | ||
}) | ||
async signMessage (address, msg) { | ||
const accountIdx = await this._findSignerIdx(address); | ||
let { payload, protocol } = msg; | ||
// If the message is not an object we assume it is a legacy signPersonal request | ||
if (!payload || !protocol) { | ||
payload = msg; | ||
protocol = "signPersonal"; | ||
} | ||
const addressIdx = this.accountIndices[accountIdx]; | ||
const addressParentPath = this.accountOpts[accountIdx].hdPath; | ||
const req = { | ||
currency: "ETH_MSG", | ||
data: { | ||
protocol, | ||
payload, | ||
signerPath: this._getHDPathIndices(addressParentPath, addressIdx), | ||
}, | ||
}; | ||
const res = await this.sdkSession.sign(req); | ||
if (!res.sig) { | ||
throw new Error("No signature returned"); | ||
} | ||
// Convert the `v` to a number. It should convert to 0 or 1 | ||
let v; | ||
try { | ||
v = res.sig.v.toString("hex"); | ||
if (v.length < 2) { | ||
v = `0${v}`; | ||
} | ||
} catch (err) { | ||
throw new Error("Invalid signature format returned."); | ||
} | ||
// Make sure the active wallet is correct to avoid returning | ||
// a signature from an unexpected signer. | ||
const foundIdx = await this._accountIdxInCurrentWallet(address); | ||
if (foundIdx === null) { | ||
throw new Error( | ||
'Wrong account. Please change your Lattice wallet or ' + | ||
'switch to an account on your current active wallet.' | ||
); | ||
} | ||
// Return the sig string | ||
return `0x${res.sig.r}${res.sig.s}${v}`; | ||
} | ||
exportAccount(address) { | ||
return Promise.reject(Error('exportAccount not supported by this device')) | ||
async exportAccount(address) { | ||
throw new Error('exportAccount not supported by this device'); | ||
} | ||
@@ -364,10 +355,3 @@ | ||
getFirstPage() { | ||
// This function gets called after the user has connected to the Lattice. | ||
// Update a state variable to force opening of the Lattice manager window. | ||
// If we don't do this, MetaMask will automatically start requesting addresses, | ||
// even if the device is not reachable. | ||
// This way the user can close the window and connect accounts from other | ||
// wallets instead of being forced into selecting Lattice accounts | ||
this.forceReconnect = true; | ||
async getFirstPage() { | ||
this.page = 0; | ||
@@ -377,7 +361,7 @@ return this._getPage(0); | ||
getNextPage () { | ||
async getNextPage () { | ||
return this._getPage(1); | ||
} | ||
getPreviousPage () { | ||
async getPreviousPage () { | ||
return this._getPage(-1); | ||
@@ -399,30 +383,58 @@ } | ||
// Note that this is the BIP39 path index, not the index in the address cache. | ||
_findSignerIdx(address) { | ||
return new Promise((resolve, reject) => { | ||
// Unlock and get the wallet UID. We will bypass the reconnection | ||
// step if we are able to rehydrate an SDK session with state data. | ||
this.unlock(true) | ||
.then(() => { | ||
return this._ensureCurrentWalletUID(); | ||
}) | ||
.then(() => { | ||
return this.getAccounts(); | ||
}) | ||
.then((addrs) => { | ||
// Find the signer in our current set of accounts | ||
// If we can't find it, return an error | ||
let accountIdx = null; | ||
addrs.forEach((addr, i) => { | ||
if (address.toLowerCase() === addr.toLowerCase()) | ||
accountIdx = i; | ||
}) | ||
if (accountIdx === null) { | ||
return reject('Signer not present'); | ||
} | ||
return resolve(accountIdx); | ||
}) | ||
.catch((err) => { | ||
return reject(err); | ||
}) | ||
async _findSignerIdx (address) { | ||
// Take note if this was already unlocked | ||
const wasUnlocked = this.isUnlocked(); | ||
// Unlock and get the wallet UID. We will bypass the reconnection | ||
// step if we are able to rehydrate an SDK session with state data. | ||
await this.unlock(true); | ||
let accountIdx = await this._accountIdxInCurrentWallet(address); | ||
if (accountIdx !== null) { | ||
return accountIdx; | ||
} | ||
// If this was unlocked already, the `this.unlock` call did not sync | ||
// data with the Lattice. We should force a sync by reconnecting. | ||
if (wasUnlocked) { | ||
await this._connect(); | ||
// Check the new wallet and see if there is a match | ||
accountIdx = await this._accountIdxInCurrentWallet(address); | ||
if (accountIdx !== null) { | ||
return accountIdx; | ||
} | ||
} | ||
// If we could not find a match, exit here | ||
throw new Error( | ||
"Account not found in active Lattice wallet. Please switch." | ||
); | ||
} | ||
async _accountIdxInCurrentWallet(address) { | ||
// Get the wallet UID associated with the signer and make sure | ||
// the Lattice has that as its active wallet before continuing. | ||
const accountIdx = await this._findAccountByAddress(address); | ||
const { walletUID } = this.accountOpts[accountIdx]; | ||
// Get the last updated SDK wallet UID | ||
const activeWallet = this.sdkSession.getActiveWallet(); | ||
if (!activeWallet) { | ||
this._connect(); | ||
throw new Error("No active wallet in Lattice."); | ||
} | ||
const activeUID = activeWallet.uid.toString("hex"); | ||
// If this is already the active wallet we don't need to make a request | ||
if (walletUID.toString("hex") === activeUID) { | ||
return accountIdx; | ||
} | ||
return null; | ||
} | ||
async _findAccountByAddress(address) { | ||
const addrs = await this.getAccounts(); | ||
let accountIdx = -1; | ||
addrs.forEach((addr, i) => { | ||
if (address.toLowerCase() === addr.toLowerCase()) | ||
accountIdx = i; | ||
}) | ||
if (accountIdx < 0) { | ||
throw new Error('Signer not present'); | ||
} | ||
return accountIdx; | ||
} | ||
@@ -480,4 +492,4 @@ | ||
_openConnectorTab(url) { | ||
return new Promise((resolve, reject) => { | ||
async _openConnectorTab(url) { | ||
try { | ||
const browserTab = window.open(url); | ||
@@ -487,3 +499,3 @@ // Preferred option for Chromium browsers. This extension runs in a window | ||
if (browserTab) { | ||
return resolve({ chromium: browserTab }); | ||
return { chromium: browserTab }; | ||
} else if (browser && browser.tabs && browser.tabs.create) { | ||
@@ -494,31 +506,20 @@ // FireFox extensions do not run in windows, so it will return `null` from | ||
// will indicate that the user has logged in. | ||
browser.tabs.create({url}) | ||
.then((tab) => { | ||
return resolve({ firefox: tab }); | ||
}) | ||
.catch((err) => { | ||
return reject(new Error('Failed to open Lattice connector.')) | ||
}) | ||
const tab = await browser.tabs.create({url}) | ||
return { firefox: tab }; | ||
} else { | ||
return reject(new Error('Unknown browser context. Cannot open Lattice connector.')) | ||
throw new Error('Unknown browser context. Cannot open Lattice connector.'); | ||
} | ||
}) | ||
} catch (err) { | ||
throw new Error('Failed to open Lattice connector.'); | ||
} | ||
} | ||
_findTabById(id) { | ||
return new Promise((resolve, reject) => { | ||
browser.tabs.query({}) | ||
.then((tabs) => { | ||
tabs.forEach((tab) => { | ||
if (tab.id === id) { | ||
return resolve(tab); | ||
} | ||
}) | ||
return resolve(null); | ||
}) | ||
.catch((err) => { | ||
return reject(err); | ||
}) | ||
async _findTabById(id) { | ||
const tabs = browser.tabs.query({}); | ||
tabs.forEach((tab) => { | ||
if (tab.id === id) { | ||
return tab; | ||
} | ||
}) | ||
return null; | ||
} | ||
@@ -529,6 +530,4 @@ | ||
// We only need to setup if we don't have a deviceID | ||
if (this._hasCreds() && !this.forceReconnect) | ||
if (this._hasCreds()) | ||
return resolve(); | ||
// Cancel the force reconnect, if applicable | ||
this.forceReconnect = false; | ||
// If we are not aware of what Lattice we should be talking to, | ||
@@ -619,4 +618,4 @@ // we need to open a window that lets the user go through the | ||
// This will handle SafeCard insertion/removal events. | ||
_connect() { | ||
return new Promise((resolve, reject) => { | ||
async _connect () { | ||
try { | ||
// Attempt to connect with a Lattice using a shorter timeout. If | ||
@@ -626,126 +625,78 @@ // the device is unplugged it will time out and we don't need to wait | ||
this.sdkSession.timeout = CONNECT_TIMEOUT; | ||
this.sdkSession.connect(this.creds.deviceID, (err) => { | ||
this.sdkSession.timeout = SDK_TIMEOUT; | ||
if (err) { | ||
return reject(err); | ||
} | ||
if (!this._syncCurrentWalletUID()) { | ||
return reject('No active wallet.'); | ||
} | ||
return resolve(); | ||
}); | ||
}) | ||
await this.sdkSession.connect(this.creds.deviceID) | ||
} finally { | ||
// Reset to normal timeout no matter what | ||
this.sdkSession.timeout = SDK_TIMEOUT; | ||
} | ||
} | ||
_initSession() { | ||
return new Promise((resolve, reject) => { | ||
if (this.isUnlocked()) { | ||
return resolve(); | ||
async _initSession() { | ||
if (this.isUnlocked()) { | ||
return; | ||
} | ||
let url = 'https://signing.gridpl.us'; | ||
if (this.creds.endpoint) | ||
url = this.creds.endpoint | ||
let setupData = { | ||
name: this.appName, | ||
baseUrl: url, | ||
timeout: SDK_TIMEOUT, | ||
privKey: this._genSessionKey(), | ||
network: this.network, | ||
skipRetryOnWrongWallet: true, | ||
}; | ||
/* | ||
NOTE: We need state to actually be synced by MetaMask or we can't | ||
use this. See: https://github.com/MetaMask/KeyringController/issues/130 | ||
if (this.sdkState) { | ||
// If we have state data we can fully rehydrate the session. | ||
setupData = { | ||
stateData: this.sdkState, | ||
skipRetryOnWrongWallet: true, | ||
} | ||
try { | ||
let url = 'https://signing.gridpl.us'; | ||
if (this.creds.endpoint) | ||
url = this.creds.endpoint | ||
let setupData; | ||
if (this.sdkState) { | ||
// If we have state data we can fully rehydrate the session. | ||
setupData = { | ||
stateData: this.sdkState | ||
} | ||
} else { | ||
// If we have no state data, we need to create a session. | ||
// Its state will be saved once the connection is established. | ||
setupData = { | ||
name: this.appName, | ||
baseUrl: url, | ||
timeout: SDK_TIMEOUT, | ||
privKey: this._genSessionKey(), | ||
network: this.network, | ||
} | ||
} | ||
this.sdkSession = new SDK.Client(setupData); | ||
// Return a boolean indicating whether we provided state data. | ||
// If we have, we can skip `connect`. | ||
return resolve(!!setupData.stateData); | ||
} catch (err) { | ||
return reject(err); | ||
} | ||
}) | ||
} | ||
*/ | ||
this.sdkSession = new SDK.Client(setupData); | ||
// Return a boolean indicating whether we provided state data. | ||
// If we have, we can skip `connect`. | ||
return !!setupData.stateData; | ||
} | ||
_fetchAddresses(n=1, i=0, recursedAddrs=[]) { | ||
return new Promise((resolve, reject) => { | ||
if (!this.isUnlocked()) { | ||
return reject('No connection to Lattice. Cannot fetch addresses.') | ||
} | ||
this.__fetchAddresses(n, i, (err, addrs) => { | ||
if (err) { | ||
return reject(err); | ||
} | ||
return resolve(addrs); | ||
}) | ||
}) | ||
async _fetchAddresses(n=1, i=0, recursedAddrs=[]) { | ||
if (!this.isUnlocked()) { | ||
throw new Error('No connection to Lattice. Cannot fetch addresses.') | ||
} | ||
return this.__fetchAddresses(n, i); | ||
} | ||
__fetchAddresses(n=1, i=0, cb, recursedAddrs=[]) { | ||
// Determine if we need to do a recursive call here. We prefer not to | ||
// because they will be much slower, but Ledger paths require it since | ||
// they are non-standard. | ||
if (n === 0) | ||
return cb(null, recursedAddrs); | ||
const shouldRecurse = this._hdPathHasInternalVarIdx(); | ||
async __fetchAddresses(n=1, i=0, recursedAddrs=[]) { | ||
// Determine if we need to do a recursive call here. We prefer not to | ||
// because they will be much slower, but Ledger paths require it since | ||
// they are non-standard. | ||
if (n === 0) { | ||
return recursedAddrs; | ||
} | ||
const shouldRecurse = this._hdPathHasInternalVarIdx(); | ||
// Make the request to get the requested address | ||
const addrData = { | ||
currency: 'ETH', | ||
startPath: this._getHDPathIndices(this.hdPath, i), | ||
n: shouldRecurse ? 1 : n, | ||
skipCache: true, | ||
}; | ||
this.sdkSession.getAddresses(addrData, (err, addrs) => { | ||
if (err) | ||
return cb(err); | ||
if (!this._syncCurrentWalletUID()) { | ||
return cb(new Error('No active wallet.')); | ||
} | ||
// Sanity check -- if this returned 0 addresses, handle the error | ||
if (addrs.length < 1) { | ||
return cb(new Error('No addresses returned')); | ||
} | ||
// Return the addresses we fetched *without* updating state | ||
if (shouldRecurse) { | ||
return this.__fetchAddresses(n-1, i+1, cb, recursedAddrs.concat(addrs)); | ||
} else { | ||
return cb(null, addrs); | ||
} | ||
}) | ||
// Make the request to get the requested address | ||
const addrData = { | ||
currency: 'ETH', | ||
startPath: this._getHDPathIndices(this.hdPath, i), | ||
n: shouldRecurse ? 1 : n, | ||
}; | ||
const addrs = await this.sdkSession.getAddresses(addrData); | ||
// Sanity check -- if this returned 0 addresses, handle the error | ||
if (addrs.length < 1) { | ||
throw new Error('No addresses returned'); | ||
} | ||
// Return the addresses we fetched *without* updating state | ||
if (shouldRecurse) { | ||
return await this.__fetchAddresses(n-1, i+1, recursedAddrs.concat(addrs)); | ||
} | ||
return addrs; | ||
} | ||
_signTxData(txData) { | ||
return new Promise((resolve, reject) => { | ||
this.sdkSession.sign({ currency: 'ETH', data: txData }, (err, res) => { | ||
if (err) { | ||
return reject(err); | ||
} | ||
if (!this._syncCurrentWalletUID()) { | ||
return reject('No active wallet.'); | ||
} | ||
if (!res.tx) { | ||
return reject(new Error('No transaction payload returned.')); | ||
} | ||
// Here we catch an edge case where the requester is asking for an EIP1559 | ||
// transaction but firmware is not updated to support it. We fallback to legacy. | ||
res.type = txData.type; | ||
if (txData.revertToLegacy) { | ||
res.revertToLegacy = true; | ||
res.gasPrice = txData.gasPrice; | ||
} | ||
// Return the signed tx | ||
return resolve(res) | ||
}) | ||
}) | ||
} | ||
_getPage(increment=0) { | ||
return new Promise((resolve, reject) => { | ||
async _getPage(increment=0) { | ||
try { | ||
this.page += increment; | ||
@@ -756,21 +707,30 @@ if (this.page < 0) | ||
// Otherwise unlock the device and fetch more addresses | ||
this.unlock() | ||
.then(() => { | ||
return this._fetchAddresses(PER_PAGE, start) | ||
}) | ||
.then((addrs) => { | ||
const accounts = [] | ||
addrs.forEach((address, i) => { | ||
accounts.push({ | ||
address, | ||
balance: null, | ||
index: start + i, | ||
}) | ||
}) | ||
return resolve(accounts) | ||
}) | ||
.catch((err) => { | ||
return reject(err); | ||
}) | ||
}) | ||
await this.unlock() | ||
const addrs = await this._fetchAddresses(PER_PAGE, start) | ||
const accounts = addrs.map((address, i) => { | ||
return { | ||
address, | ||
balance: null, | ||
index: start + i, | ||
}; | ||
}); | ||
return accounts; | ||
} catch (err) { | ||
// This will get hit for a few reasons. Here are two possibilities: | ||
// 1. The user has a SafeCard inserted, but not unlocked | ||
// 2. The user fetched a page for a different wallet, then switched | ||
// interface on the device | ||
// In either event we should try to resync the wallet and if that | ||
// fails throw an error | ||
try { | ||
await this._connect(); | ||
const accounts = await this._getPage(0); | ||
return accounts; | ||
} catch (err) { | ||
throw new Error( | ||
'Failed to get accounts. Please forget the device and try again. ' + | ||
'Make sure you do not have a locked SafeCard inserted.' | ||
); | ||
} | ||
} | ||
} | ||
@@ -808,27 +768,3 @@ | ||
// Get the chainId for whatever object this is. | ||
// Returns a hex string without the 0x prefix | ||
_getEthereumJsChainId(tx) { | ||
if (typeof tx.getChainId === 'function') | ||
return tx.getChainId(); | ||
else if (tx.common && typeof tx.common.chainIdBN === 'function') | ||
return tx.common.chainIdBN().toString(16); | ||
else if (typeof tx.chainId === 'number') | ||
return tx.chainId.toString(16); | ||
else if (typeof tx.chainId === 'string') | ||
return tx.chainId; | ||
return '1'; | ||
} | ||
_getCurrentWalletUID() { | ||
return this.walletUID || null; | ||
} | ||
// The SDK has an auto-retry mechanism for all requests if a "wrong wallet" | ||
// error gets thrown. In such a case, the SDK will re-connect to the device to | ||
// find the new wallet UID and will then save that UID to memory and will retry | ||
// the original request with that new wallet UID. | ||
// Therefore, we should sync the walletUID with `this.walletUID` after each | ||
// SDK request. This is a synchronous and fast operation. | ||
_syncCurrentWalletUID() { | ||
if (!this.sdkSession) { | ||
@@ -841,32 +777,63 @@ return null; | ||
} | ||
const newUID = activeWallet.uid.toString('hex'); | ||
// If we fetched a walletUID that does not match our current one, | ||
// reset accounts and update the known UID | ||
if (newUID != this.walletUID) { | ||
this.walletUID = newUID; | ||
} | ||
return this.walletUID; | ||
return activeWallet.uid.toString('hex'); | ||
} | ||
} | ||
// Make sure we have an SDK connection and, therefore, an active wallet UID. | ||
// If we do not, force an unlock, which adds ~5 seconds to the request. | ||
_ensureCurrentWalletUID() { | ||
return new Promise((resolve, reject) => { | ||
if (!!this._getCurrentWalletUID()) { | ||
return resolve(); | ||
} | ||
this.unlock() | ||
.then(() => { | ||
if (!!this._getCurrentWalletUID()) { | ||
return resolve() | ||
} else { | ||
return reject('Could not access Lattice wallet. Please re-connect.') | ||
} | ||
}) | ||
.catch((err) => { | ||
return reject(err); | ||
}) | ||
}) | ||
// Get the `v` component of the signature. This is calculating using | ||
// secp256k1's ecdsa recover. The format of `v` returned depends | ||
// on the type of transaction (restrictions come from the | ||
// @ethereumjs/tx object). | ||
// * Type 1 and 2 transactions both only require a [0,1] `v` value | ||
// because EIP155 is no longer needed for those protocols (since | ||
// chainId is a parameter in the request). | ||
// See: https://github.com/ethereumjs/ethereumjs-monorepo/ | ||
// blob/7330c4ace495903748c606a47a8b993812504db8/packages/ | ||
// tx/src/eip1559Transaction.ts#L226 | ||
// * Type 0 transactions (legacy) require conversion to EIP155-style `v` | ||
// See: https://github.com/ethereumjs/ethereumjs-monorepo/ | ||
// blob/v4.0.0/packages/tx/src/legacyTransaction.ts#L133 | ||
// * If the chain does not support EIP155, we simply add 27 to recovery. | ||
// In practice I don't think we will ever see this type of tx. | ||
function getV (tx, resp) { | ||
const hash = tx.getMessageToSign(true); | ||
const rs = new Uint8Array(Buffer.concat([ resp.sig.r, resp.sig.s ])) | ||
const pubkey = new Uint8Array(resp.pubkey); | ||
const recovery0 = secp256k1.ecdsaRecover(rs, 0, hash, false); | ||
const recovery1 = secp256k1.ecdsaRecover(rs, 1, hash, false); | ||
const pubkeyStr = Buffer.from(pubkey).toString('hex'); | ||
const recovery0Str = Buffer.from(recovery0).toString('hex'); | ||
const recovery1Str = Buffer.from(recovery1).toString('hex'); | ||
let recovery; | ||
if (pubkeyStr === recovery0Str) { | ||
recovery = 0; | ||
} else if (pubkeyStr === recovery1Str) { | ||
recovery = 1; | ||
} else { | ||
return null; | ||
} | ||
// Newer transaction types can include the recovery directly | ||
if (tx._type) { | ||
return recovery.toString(16) | ||
} | ||
// Check for EIP155 support. In practice, virtually every transaction | ||
// should have EIP155 support since that hardfork happened in 2016... | ||
const chainId = getTxChainId(tx); | ||
if ( | ||
!tx.supports(EthTx.Capability.EIP155ReplayProtection) && | ||
(!tx.common || !tx.common.gteHardfork('spuriousDragon')) | ||
) { | ||
return new BN(recovery).addn(27).toString('hex'); | ||
} | ||
// EIP155 replay protection is included in the `v` param | ||
// and uses the chainId value. | ||
return chainId.muln(2).addn(35).addn(recovery).toString('hex'); | ||
} | ||
function getTxChainId (tx) { | ||
if (tx && tx.common && typeof tx.common.chainIdBN === 'function') { | ||
return tx.common.chainIdBN(); | ||
} else if (tx && tx.chainId) { | ||
return new BN(tx.chainId); | ||
} | ||
return new BN(1); | ||
} | ||
@@ -873,0 +840,0 @@ |
{ | ||
"name": "eth-lattice-keyring", | ||
"version": "0.5.0", | ||
"version": "0.6.0", | ||
"description": "Keyring for connecting to the Lattice1 hardware wallet", | ||
@@ -20,8 +20,10 @@ "main": "index.js", | ||
"dependencies": { | ||
"@ethereumjs/common": "^2.4.0", | ||
"@ethereumjs/tx": "^3.1.1", | ||
"bignumber.js": "^9.0.1", | ||
"@ethereumjs/common": "^2.6.2", | ||
"@ethereumjs/tx": "^3.5.0", | ||
"bn.js": "^5.2.0", | ||
"ethereumjs-util": "^7.0.10", | ||
"gridplus-sdk": "^1.0.0" | ||
"gridplus-sdk": "^1.1.5", | ||
"rlp": "^3.0.0", | ||
"secp256k1": "4.0.2" | ||
} | ||
} |
31417
7
777
+ Addedbn.js@^5.2.0
+ Addedrlp@^3.0.0
+ Addedsecp256k1@4.0.2
- Removedbignumber.js@^9.0.1
- Removedelliptic@6.6.1(transitive)
- Removednode-addon-api@5.1.0(transitive)
- Removedsecp256k1@4.0.4(transitive)
Updated@ethereumjs/common@^2.6.2
Updated@ethereumjs/tx@^3.5.0
Updatedgridplus-sdk@^1.1.5