mirror of
https://github.com/luxfi/eth-lattice-keyring.git
synced 2026-10-30 16:58:08 +00:00
841 lines
28 KiB
JavaScript
841 lines
28 KiB
JavaScript
import crypto from 'crypto';
|
|
import EventEmitter from 'events';
|
|
import BN from 'bn.js';
|
|
import * as SDK from 'gridplus-sdk';
|
|
import { TransactionFactory } from '@ethereumjs/tx';
|
|
import * as Util from 'ethereumjs-util';
|
|
import rlp from 'rlp';
|
|
|
|
const keyringType = 'GridPlus Hardware';
|
|
const HARDENED_OFFSET = 0x80000000;
|
|
const PER_PAGE = 5;
|
|
const CLOSE_CODE = -1000;
|
|
const STANDARD_HD_PATH = `m/44'/60'/0'/0/x`;
|
|
const SDK_TIMEOUT = 120000;
|
|
const CONNECT_TIMEOUT = 20000;
|
|
|
|
class LatticeKeyring extends EventEmitter {
|
|
constructor(opts = {}) {
|
|
super();
|
|
this.appName = 'Lux';
|
|
this.type = keyringType;
|
|
this._resetDefaults();
|
|
this.deserialize(opts);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Keyring API (per `https://github.com/MetaMask/eth-simple-keyring`)
|
|
//-------------------------------------------------------------------
|
|
async deserialize(opts = {}) {
|
|
this.hdPath = STANDARD_HD_PATH;
|
|
if (opts.creds) this.creds = opts.creds;
|
|
if (opts.accounts) this.accounts = opts.accounts;
|
|
if (opts.accountIndices) this.accountIndices = opts.accountIndices;
|
|
if (opts.accountOpts) this.accountOpts = opts.accountOpts;
|
|
if (opts.walletUID) this.walletUID = opts.walletUID;
|
|
if (opts.name)
|
|
// Legacy; use is deprecated and appName is more descriptive
|
|
this.appName = opts.name;
|
|
if (opts.appName) this.appName = opts.appName;
|
|
if (opts.network) this.network = opts.network;
|
|
if (opts.page) this.page = opts.page;
|
|
if (opts.sdkState) this.sdkState = opts.sdkState;
|
|
return;
|
|
}
|
|
|
|
setHdPath(hdPath = STANDARD_HD_PATH) {
|
|
this.hdPath = hdPath;
|
|
}
|
|
|
|
async serialize() {
|
|
return {
|
|
creds: this.creds,
|
|
accounts: this.accounts,
|
|
accountIndices: this.accountIndices,
|
|
accountOpts: this.accountOpts,
|
|
walletUID: this.walletUID,
|
|
appName: this.appName,
|
|
name: this.name, // Legacy; use is deprecated
|
|
network: this.network,
|
|
page: this.page,
|
|
hdPath: this.hdPath,
|
|
sdkState: this.sdkSession ? this.sdkSession.getStateData() : null
|
|
};
|
|
}
|
|
|
|
// Deterimine if we have a connection to the Lattice and an existing wallet UID
|
|
// against which to make requests.
|
|
isUnlocked() {
|
|
return !!this._getCurrentWalletUID() && !!this.sdkSession;
|
|
}
|
|
|
|
// Initialize a session with the Lattice1 device using the GridPlus SDK
|
|
// NOTE: `bypassOnStateData=true` allows us to rehydrate a new SDK session without
|
|
// reconnecting to the target Lattice. This is only currently used for signing
|
|
// because it eliminates the need for 2 connection requests and shaves off ~4-6sec.
|
|
// We avoid passing `bypassOnStateData=true` for other calls on `unlock` to avoid
|
|
// possible edge cases related to this new functionality (it's probably fine - just
|
|
// being cautious). In the future we may remove `bypassOnStateData` entirely.
|
|
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
|
|
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
|
|
});
|
|
}
|
|
});
|
|
return this.accounts;
|
|
}
|
|
|
|
// Return the local store of addresses. This gets called when the extension unlocks.
|
|
async getAccounts() {
|
|
return this.accounts ? [...this.accounts] : [];
|
|
}
|
|
|
|
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];
|
|
const signerPath = this._getHDPathIndices(hdPath, addressIdx);
|
|
// 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.');
|
|
}
|
|
// 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
|
|
};
|
|
// Check if we can decode the calldata
|
|
data.decoder = await getCalldataDecoder(tx);
|
|
// Send the request
|
|
signedTx = await this.sdkSession.sign({ data });
|
|
} else {
|
|
// Older firmware versions (<0.15.0) use the legacy signing pathway.
|
|
const data = getLegacyTxReq(tx);
|
|
data.chainId = chainId;
|
|
data.signerPath = signerPath;
|
|
signedTx = await this.sdkSession.sign({ currency: 'ETH', data });
|
|
}
|
|
// Ensure we got a signature back
|
|
if (!signedTx.sig || !signedTx.sig.r || !signedTx.sig.s) {
|
|
throw new Error('No signature returned.');
|
|
}
|
|
// Construct the `v` signature param
|
|
if (signedTx.sig.v === undefined) {
|
|
// V2 signature needs `v` calculated
|
|
v = SDK.Utils.getV(tx, signedTx);
|
|
} else {
|
|
// Legacy signatures have `v` in the response
|
|
v = signedTx.sig.v.length === 0 ? '0' : signedTx.sig.v.toString('hex');
|
|
}
|
|
|
|
// 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 TransactionFactory.fromTxData(txToReturn, {
|
|
common: tx.common,
|
|
freeze: Object.isFrozen(tx)
|
|
});
|
|
}
|
|
|
|
async signPersonalMessage(address, msg) {
|
|
return this.signMessage(address, {
|
|
payload: msg,
|
|
protocol: 'signPersonal'
|
|
});
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
async exportAccount(address) {
|
|
throw new Error('exportAccount not supported by this device');
|
|
}
|
|
|
|
removeAccount(address) {
|
|
this.accounts.forEach((account, i) => {
|
|
if (account.toLowerCase() === address.toLowerCase()) {
|
|
this.accounts.splice(i, 1);
|
|
this.accountIndices.splice(i, 1);
|
|
this.accountOpts.splice(i, 1);
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
async getFirstPage() {
|
|
this.page = 0;
|
|
return this._getPage(0);
|
|
}
|
|
|
|
async getNextPage() {
|
|
return this._getPage(1);
|
|
}
|
|
|
|
async getPreviousPage() {
|
|
return this._getPage(-1);
|
|
}
|
|
|
|
setAccountToUnlock(index) {
|
|
this.unlockedAccount = parseInt(index, 10);
|
|
}
|
|
|
|
forgetDevice() {
|
|
this._resetDefaults();
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Internal methods and interface to SDK
|
|
//-------------------------------------------------------------------
|
|
// Find the account index of the requested address.
|
|
// Note that this is the BIP39 path index, not the index in the address cache.
|
|
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;
|
|
}
|
|
|
|
_getHDPathIndices(hdPath, insertIdx = 0) {
|
|
const path = hdPath.split('/').slice(1);
|
|
const indices = [];
|
|
let usedX = false;
|
|
path.forEach((_idx) => {
|
|
const isHardened = _idx[_idx.length - 1] === "'";
|
|
let idx = isHardened ? HARDENED_OFFSET : 0;
|
|
// If there is an `x` in the path string, we will use it to insert our
|
|
// index. This is useful for e.g. Ledger Live path. Most paths have the
|
|
// changing index as the last one, so having an `x` in the path isn't
|
|
// usually necessary.
|
|
if (_idx.indexOf('x') > -1) {
|
|
idx += insertIdx;
|
|
usedX = true;
|
|
} else if (isHardened) {
|
|
idx += Number(_idx.slice(0, _idx.length - 1));
|
|
} else {
|
|
idx += Number(_idx);
|
|
}
|
|
indices.push(idx);
|
|
});
|
|
// If this path string does not include an `x`, we just append the index
|
|
// to the end of the extracted set
|
|
if (usedX === false) {
|
|
indices.push(insertIdx);
|
|
}
|
|
// Sanity check -- Lattice firmware will throw an error for large paths
|
|
if (indices.length > 5)
|
|
throw new Error('Only HD paths with up to 5 indices are allowed.');
|
|
return indices;
|
|
}
|
|
|
|
_resetDefaults() {
|
|
this.accounts = [];
|
|
this.accountIndices = [];
|
|
this.accountOpts = [];
|
|
this.isLocked = true;
|
|
this.creds = {
|
|
deviceID: null,
|
|
password: null,
|
|
endpoint: null
|
|
};
|
|
this.walletUID = null;
|
|
this.sdkSession = null;
|
|
this.page = 0;
|
|
this.unlockedAccount = 0;
|
|
this.network = null;
|
|
this.hdPath = STANDARD_HD_PATH;
|
|
}
|
|
|
|
async _openConnectorTab(url) {
|
|
try {
|
|
const browserTab = window.open(url);
|
|
// Preferred option for Chromium browsers. This extension runs in a window
|
|
// for Chromium so we can do window-based communication very easily.
|
|
if (browserTab) {
|
|
return { chromium: browserTab };
|
|
} else if (browser && browser.tabs && browser.tabs.create) {
|
|
// FireFox extensions do not run in windows, so it will return `null` from
|
|
// `window.open`. Instead, we need to use the `browser` API to open a tab.
|
|
// We will surveille this tab to see if its URL parameters change, which
|
|
// will indicate that the user has logged in.
|
|
const tab = await browser.tabs.create({ url });
|
|
return { firefox: tab };
|
|
} else {
|
|
throw new Error(
|
|
'Unknown browser context. Cannot open Lattice connector.'
|
|
);
|
|
}
|
|
} catch (err) {
|
|
throw new Error('Failed to open Lattice connector.');
|
|
}
|
|
}
|
|
|
|
async _findTabById(id) {
|
|
const tabs = await browser.tabs.query({});
|
|
return tabs.find((tab) => tab.id === id);
|
|
}
|
|
|
|
_getCreds() {
|
|
return new Promise((resolve, reject) => {
|
|
// We only need to setup if we don't have a deviceID
|
|
if (this._hasCreds()) return resolve();
|
|
// If we are not aware of what Lattice we should be talking to,
|
|
// we need to open a window that lets the user go through the
|
|
// pairing or connection process.
|
|
const name = this.appName ? this.appName : 'Unknown';
|
|
const base = 'https://lattice.gridplus.io';
|
|
const url = `${base}?keyring=${name}&forceLogin=true`;
|
|
let listenInterval;
|
|
|
|
// PostMessage handler
|
|
function receiveMessage(event) {
|
|
// Ensure origin
|
|
if (event.origin !== base) return;
|
|
try {
|
|
// Stop the listener
|
|
clearInterval(listenInterval);
|
|
// Parse and return creds
|
|
const creds = JSON.parse(event.data);
|
|
if (!creds.deviceID || !creds.password)
|
|
return reject(
|
|
new Error('Invalid credentials returned from Lattice.')
|
|
);
|
|
return resolve(creds);
|
|
} catch (err) {
|
|
return reject(err);
|
|
}
|
|
}
|
|
|
|
// Open the tab
|
|
this._openConnectorTab(url).then((conn) => {
|
|
if (conn.chromium) {
|
|
// On a Chromium browser we can just listen for a window message
|
|
window.addEventListener('message', receiveMessage, false);
|
|
// Watch for the open window closing before creds are sent back
|
|
listenInterval = setInterval(() => {
|
|
if (conn.chromium.closed) {
|
|
clearInterval(listenInterval);
|
|
return reject(new Error('Lattice connector closed.'));
|
|
}
|
|
}, 500);
|
|
} else if (conn.firefox) {
|
|
// For Firefox we cannot use `window` in the extension and can't
|
|
// directly communicate with the tabs very easily so we use a
|
|
// workaround: listen for changes to the URL, which will contain
|
|
// the login info.
|
|
// NOTE: This will only work if have `https://lattice.gridplus.io/*`
|
|
// host permissions in your manifest file (and also `activeTab` permission)
|
|
const loginUrlParam = '&loginCache=';
|
|
listenInterval = setInterval(() => {
|
|
this._findTabById(conn.firefox.id).then((tab) => {
|
|
if (!tab || !tab.url) {
|
|
return reject(new Error('Lattice connector closed.'));
|
|
}
|
|
// If the tab we opened contains a new URL param
|
|
const paramLoc = tab.url.indexOf(loginUrlParam);
|
|
if (paramLoc < 0) return;
|
|
const dataLoc = paramLoc + loginUrlParam.length;
|
|
// Stop this interval
|
|
clearInterval(listenInterval);
|
|
try {
|
|
// Parse the login data. It is a stringified JSON object
|
|
// encoded as a base64 string.
|
|
const _creds = Buffer.from(
|
|
tab.url.slice(dataLoc),
|
|
'base64'
|
|
).toString();
|
|
// Close the tab and return the credentials
|
|
browser.tabs.remove(tab.id).then(() => {
|
|
const creds = JSON.parse(_creds);
|
|
if (!creds.deviceID || !creds.password)
|
|
return reject(
|
|
new Error('Invalid credentials returned from Lattice.')
|
|
);
|
|
return resolve(creds);
|
|
});
|
|
} catch (err) {
|
|
return reject(
|
|
'Failed to get login data from Lattice. Please try again.'
|
|
);
|
|
}
|
|
});
|
|
}, 500);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// [re]connect to the Lattice. This should be done frequently to ensure
|
|
// the expected wallet UID is still the one active in the Lattice.
|
|
// This will handle SafeCard insertion/removal events.
|
|
async _connect() {
|
|
try {
|
|
// Attempt to connect with a Lattice using a shorter timeout. If
|
|
// the device is unplugged it will time out and we don't need to wait
|
|
// 2 minutes for that to happen.
|
|
this.sdkSession.timeout = CONNECT_TIMEOUT;
|
|
await this.sdkSession.connect(this.creds.deviceID);
|
|
} finally {
|
|
// Reset to normal timeout no matter what
|
|
this.sdkSession.timeout = SDK_TIMEOUT;
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
*/
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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
|
|
};
|
|
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;
|
|
}
|
|
|
|
async _getPage(increment = 0) {
|
|
try {
|
|
this.page += increment;
|
|
if (this.page < 0) this.page = 0;
|
|
const start = PER_PAGE * this.page;
|
|
// Otherwise unlock the device and fetch more addresses
|
|
await this.unlock();
|
|
const addrs = await this._fetchAddresses(PER_PAGE, start);
|
|
const accounts = addrs.map((address, i) => {
|
|
return {
|
|
address,
|
|
balance: null,
|
|
index: start + i + 1
|
|
};
|
|
});
|
|
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.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
_hasCreds() {
|
|
return (
|
|
this.creds.deviceID !== null &&
|
|
this.creds.password !== null &&
|
|
this.appName
|
|
);
|
|
}
|
|
|
|
_genSessionKey() {
|
|
if (this.name && !this.appName)
|
|
// Migrate from legacy param if needed
|
|
this.appName = this.name;
|
|
if (!this._hasCreds())
|
|
throw new Error('No credentials -- cannot create session key!');
|
|
const buf = Buffer.concat([
|
|
Buffer.from(this.creds.password),
|
|
Buffer.from(this.creds.deviceID),
|
|
Buffer.from(this.appName)
|
|
]);
|
|
return crypto.createHash('sha256').update(buf).digest();
|
|
}
|
|
|
|
// Determine if an HD path has a variable index internal to it.
|
|
// e.g. m/44'/60'/x'/0/0 -> true, while m/44'/60'/0'/0/x -> false
|
|
// This is just a hacky helper to avoid having to recursively call for non-ledger
|
|
// derivation paths. Ledger is SO ANNOYING TO SUPPORT.
|
|
_hdPathHasInternalVarIdx() {
|
|
const path = this.hdPath.split('/').slice(1);
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
if (path[i].indexOf('x') > -1) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_getCurrentWalletUID() {
|
|
if (!this.sdkSession) {
|
|
return null;
|
|
}
|
|
const activeWallet = this.sdkSession.getActiveWallet();
|
|
if (!activeWallet || !activeWallet.uid) {
|
|
return null;
|
|
}
|
|
return activeWallet.uid.toString('hex');
|
|
}
|
|
}
|
|
|
|
// -----
|
|
// HELPERS
|
|
// -----
|
|
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);
|
|
}
|
|
|
|
// We should include calldata decoder information in new requests so that
|
|
// ABI data can be decoded in place (i.e. without loading the definitions
|
|
// ahead of time).
|
|
async function getCalldataDecoder(tx) {
|
|
// If there is no data, we can't decode it, obviously
|
|
if (!tx.data || !tx.data.length || tx.data.length < 4) {
|
|
return null;
|
|
}
|
|
let def, resp;
|
|
const selector = Buffer.from(tx.data.slice(0, 4)).toString('hex');
|
|
// Get the Solidity JSON ABI definitions from Etherscan, if available
|
|
try {
|
|
resp = await httpRequest(
|
|
`https://api.etherscan.io/api?module=contract&action=getabi&address=${tx.to}`
|
|
);
|
|
const abi = JSON.parse(JSON.parse(resp).result);
|
|
def = SDK.Calldata.EVM.parsers.parseSolidityJSONABI(selector, abi);
|
|
return def;
|
|
} catch (err) {
|
|
console.warn('Failed to fetch ABI data from etherscan', err.message);
|
|
}
|
|
// Fallback to checking 4byte
|
|
try {
|
|
resp = await httpRequest(
|
|
`https://www.4byte.directory/api/v1/signatures?hex_signature=0x${selector}`
|
|
);
|
|
const fourByteResults = JSON.parse(resp).results;
|
|
if (fourByteResults.length > 0) {
|
|
console.warn('WARNING: There are multiple results. Using the first one.');
|
|
}
|
|
const canonicalName = fourByteResults[0].text_signature;
|
|
def = SDK.Calldata.EVM.parsers.parseCanonicalName(selector, canonicalName);
|
|
return def;
|
|
} catch (err) {
|
|
console.warn('Failed to fetch data from 4byte', err.message);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Legacy versions of Lattice firmware signed ETH transactions out of
|
|
// a now deprecated pathway. The request data is built by this helper.
|
|
function getLegacyTxReq(tx) {
|
|
let txData;
|
|
try {
|
|
txData = {
|
|
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')}`
|
|
};
|
|
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;
|
|
}
|
|
} catch (err) {
|
|
throw new Error(`Failed to build transaction.`);
|
|
}
|
|
return txData;
|
|
}
|
|
|
|
async function httpRequest(url) {
|
|
const resp = await fetch(url);
|
|
if (resp.ok) {
|
|
return await resp.text();
|
|
} else {
|
|
throw new Error('Failed to make request: ', resp.status);
|
|
}
|
|
}
|
|
|
|
LatticeKeyring.type = keyringType;
|
|
export default LatticeKeyring;
|