@metamask/eth-trezor-keyring
Version:
A MetaMask compatible keyring, for trezor hardware wallets
465 lines • 20.9 kB
JavaScript
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var _TrezorKeyring_instances, _TrezorKeyring_getPage, _TrezorKeyring_signTransaction, _TrezorKeyring_normalize, _TrezorKeyring_addressFromIndex, _TrezorKeyring_pathFromAddress;
function $importDefault(module) {
if (module === null || module === void 0 ? void 0 : module.__esModule) {
return module.default;
}
return module;
}
import { TransactionFactory } from "@ethereumjs/tx";
import { publicToAddress, toChecksumAddress } from "@ethereumjs/util";
import { SignTypedDataVersion } from "@metamask/eth-sig-util";
import { add0x, bytesToHex, getChecksumAddress, remove0x } from "@metamask/utils";
import { transformTypedData } from "@trezor/connect-plugin-ethereum";
import $HDKey from "hdkey";
const HDKey = $importDefault($HDKey);
import { handleTrezorTransportError } from "./trezor-error-handler.mjs";
const hdPathString = `m/44'/60'/0'/0`;
const SLIP0044TestnetPath = `m/44'/1'/0'/0`;
const legacyMewPath = `m/44'/60'/0'`;
const ALLOWED_HD_PATHS = {
[hdPathString]: true,
[legacyMewPath]: true,
[SLIP0044TestnetPath]: true,
};
const keyringType = 'Trezor Hardware';
const pathBase = 'm';
const MAX_INDEX = 1000;
const DELAY_BETWEEN_POPUPS = 1000;
export const TREZOR_CONNECT_MANIFEST = {
appName: 'MetaMask',
email: 'support@metamask.io',
appUrl: 'https://metamask.io',
};
async function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if the given transaction is made with ethereumjs-tx or @ethereumjs/tx
*
* Transactions built with older versions of ethereumjs-tx have a
* getChainId method that newer versions do not.
* Older versions are mutable
* while newer versions default to being immutable.
* Expected shape and type
* of data for v, r and s differ (Buffer (old) vs BN (new)).
*
* @param tx
* @returns Returns `true` if tx is an old-style ethereumjs-tx transaction.
*/
function isOldStyleEthereumjsTx(tx) {
return typeof tx.getChainId === 'function';
}
function isAddressValidationError(error) {
return (error instanceof Error &&
[
"signature doesn't match the right address",
'signature doesnt match the right address',
].includes(error.message));
}
export class TrezorKeyring {
constructor({ bridge }) {
_TrezorKeyring_instances.add(this);
this.type = keyringType;
this.accounts = [];
this.hdk = new HDKey();
this.hdPath = hdPathString;
this.page = 0;
this.perPage = 5;
this.unlockedAccount = 0;
this.paths = {};
if (!bridge) {
throw new Error('Bridge is a required dependency for the keyring');
}
this.bridge = bridge;
}
/**
* Gets the model, if known.
* This may be `undefined` if the model hasn't been loaded yet.
*
* @returns
*/
getModel() {
return this.bridge.model;
}
async init() {
return this.bridge.init({
manifest: TREZOR_CONNECT_MANIFEST,
lazyLoad: true,
});
}
async destroy() {
return this.bridge.dispose();
}
async serialize() {
return Promise.resolve({
hdPath: this.hdPath,
accounts: this.accounts.slice(),
page: this.page,
paths: this.paths,
perPage: this.perPage,
unlockedAccount: this.unlockedAccount,
});
}
async deserialize(opts) {
var _a, _b, _c, _d;
this.hdPath = (_a = opts.hdPath) !== null && _a !== void 0 ? _a : hdPathString;
this.accounts = (_b = opts.accounts) !== null && _b !== void 0 ? _b : [];
this.page = (_c = opts.page) !== null && _c !== void 0 ? _c : 0;
this.perPage = (_d = opts.perPage) !== null && _d !== void 0 ? _d : 5;
return Promise.resolve();
}
isUnlocked() {
var _a;
return Boolean((_a = this.hdk) === null || _a === void 0 ? void 0 : _a.publicKey);
}
async unlock() {
var _a, _b;
if (this.isUnlocked()) {
return Promise.resolve('already unlocked');
}
try {
const response = await this.bridge.getPublicKey({
path: this.hdPath,
coin: 'ETH',
});
if (!response.success) {
throw new Error((_b = (_a = response.payload) === null || _a === void 0 ? void 0 : _a.error) !== null && _b !== void 0 ? _b : 'Unknown error');
}
this.hdk.publicKey = Buffer.from(response.payload.publicKey, 'hex');
this.hdk.chainCode = Buffer.from(response.payload.chainCode, 'hex');
return 'just unlocked';
}
catch (error) {
return handleTrezorTransportError(error, 'Failed to unlock Trezor device');
}
}
setAccountToUnlock(index) {
this.unlockedAccount = parseInt(String(index), 10);
}
async addAccounts(numberOfAccounts) {
return new Promise((resolve, reject) => {
this.unlock()
.then((_) => {
const from = this.unlockedAccount;
const to = from + numberOfAccounts;
const newAccounts = [];
for (let i = from; i < to; i++) {
const address = __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_addressFromIndex).call(this, pathBase, i);
if (!this.accounts.includes(address)) {
this.accounts = [...this.accounts, address];
newAccounts.push(address);
}
this.page = 0;
}
resolve(newAccounts);
})
.catch((e) => {
reject(e);
});
});
}
async getFirstPage() {
this.page = 0;
return __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_getPage).call(this, 1);
}
async getNextPage() {
return __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_getPage).call(this, 1);
}
async getPreviousPage() {
return __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_getPage).call(this, -1);
}
async getAccounts() {
return Promise.resolve(this.accounts.slice());
}
removeAccount(address) {
if (!this.accounts.map((a) => a.toLowerCase()).includes(address.toLowerCase())) {
throw new Error(`Address ${address} not found in this keyring`);
}
this.accounts = this.accounts.filter((a) => a.toLowerCase() !== address.toLowerCase());
}
/**
* Signs a transaction using Trezor.
*
* Accepts either an ethereumjs-tx or @ethereumjs/tx transaction, and returns
* the same type.
*
* @param address - Hex string address.
* @param tx - Instance of either new-style or old-style ethereumjs transaction.
* @returns The signed transaction, an instance of either new-style or old-style
* ethereumjs transaction.
*/
async signTransaction(address, tx) {
if (isOldStyleEthereumjsTx(tx)) {
// In this version of ethereumjs-tx we must add the chainId in hex format
// to the initial v value. The chainId must be included in the serialized
// transaction which is only communicated to ethereumjs-tx in this
// value. In newer versions the chainId is communicated via the 'Common'
// object.
return __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_signTransaction).call(this, address,
// @types/ethereumjs-tx and old ethereumjs-tx versions document
// this function return value as Buffer, but the actual
// Transaction._chainId will always be a number.
// See https://github.com/ethereumjs/ethereumjs-tx/blob/v1.3.7/index.js#L126
tx.getChainId(), tx, (payload) => {
tx.v = Buffer.from(payload.v, 'hex');
tx.r = Buffer.from(payload.r, 'hex');
tx.s = Buffer.from(payload.s, 'hex');
return tx;
});
}
return __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_signTransaction).call(this, address, Number(tx.common.chainId()), tx, (payload) => {
// Because tx will be immutable, first get a plain javascript object that
// represents the transaction. Using txData here as it aligns with the
// nomenclature of ethereumjs/tx.
const txData = tx.toJSON();
// The fromTxData utility expects a type to support transactions with a type other than 0
txData.type = tx.type;
// The fromTxData utility expects v,r and s to be hex prefixed
txData.v = add0x(payload.v);
txData.r = add0x(payload.r);
txData.s = add0x(payload.s);
// Adopt the 'common' option from the original transaction and set the
// returned object to be frozen if the original is frozen.
return TransactionFactory.fromTxData(txData, {
common: tx.common,
freeze: Object.isFrozen(tx),
});
});
}
async signMessage(withAccount, data) {
return this.signPersonalMessage(withAccount, data);
}
// For personal_sign, we need to prefix the message:
async signPersonalMessage(withAccount, message) {
var _a, _b;
try {
const status = await this.unlock();
// This is necessary to avoid popup collision
// between the unlock & sign trezor popups
await wait(status === 'just unlocked' ? DELAY_BETWEEN_POPUPS : 0);
const response = await this.bridge.ethereumSignMessage({
path: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_pathFromAddress).call(this, withAccount),
message: remove0x(message),
hex: true,
});
if (!response.success) {
throw new Error((_b = (_a = response.payload) === null || _a === void 0 ? void 0 : _a.error) !== null && _b !== void 0 ? _b : 'Unknown error');
}
if (response.payload.address !== getChecksumAddress(withAccount)) {
throw new Error('signature doesnt match the right address');
}
return `0x${response.payload.signature}`;
}
catch (error) {
// Re-throw address validation errors as plain Errors, not hardware errors
if (isAddressValidationError(error)) {
throw error;
}
return handleTrezorTransportError(error, 'Failed to sign personal message with Trezor device');
}
}
// EIP-712 Sign Typed Data
async signTypedData(address, data, options) {
var _a, _b, _c;
const { version } = options !== null && options !== void 0 ? options : { version: SignTypedDataVersion.V4 };
const dataWithHashes = transformTypedData(data, version === SignTypedDataVersion.V4);
try {
// set default values for signTypedData
// Trezor is stricter than @metamask/eth-sig-util in what it accepts
const { types, message = {}, domain = {}, primaryType,
// snake_case since Trezor uses Protobuf naming conventions here
domain_separator_hash, // eslint-disable-line camelcase
message_hash, // eslint-disable-line camelcase
} = dataWithHashes;
// This is necessary to avoid popup collision
// between the unlock & sign trezor popups
const status = await this.unlock();
await wait(status === 'just unlocked' ? DELAY_BETWEEN_POPUPS : 0);
const response = await this.bridge.ethereumSignTypedData({
path: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_pathFromAddress).call(this, address),
data: {
types: Object.assign(Object.assign({}, types), { EIP712Domain: (_a = types.EIP712Domain) !== null && _a !== void 0 ? _a : [] }),
message,
domain,
primaryType,
},
metamask_v4_compat: true,
// Trezor 1 only supports blindly signing hashes
domain_separator_hash, // eslint-disable-line camelcase
message_hash: message_hash !== null && message_hash !== void 0 ? message_hash : '', // eslint-disable-line camelcase
});
if (!response.success) {
throw new Error((_c = (_b = response.payload) === null || _b === void 0 ? void 0 : _b.error) !== null && _c !== void 0 ? _c : 'Unknown error');
}
if (getChecksumAddress(address) !== response.payload.address) {
throw new Error('signature doesnt match the right address');
}
return response.payload.signature;
}
catch (error) {
// Re-throw address validation errors as plain Errors, not hardware errors
if (isAddressValidationError(error)) {
throw error;
}
return handleTrezorTransportError(error, 'Failed to sign typed data with Trezor device');
}
}
forgetDevice() {
this.accounts = [];
this.hdk = new HDKey();
this.page = 0;
this.unlockedAccount = 0;
this.paths = {};
}
/**
* Set the HD path to be used by the keyring. Only known supported HD paths are allowed.
*
* If the given HD path is already the current HD path, nothing happens. Otherwise the new HD
* path is set, and the wallet state is completely reset.
*
* @throws {Error] Throws if the HD path is not supported.
*
* @param hdPath - The HD path to set.
*/
setHdPath(hdPath) {
if (!ALLOWED_HD_PATHS[hdPath]) {
throw new Error(`The setHdPath method does not support setting HD Path to ${hdPath}`);
}
// Reset HDKey if the path changes
if (this.hdPath !== hdPath) {
this.hdk = new HDKey();
this.accounts = [];
this.page = 0;
this.perPage = 5;
this.unlockedAccount = 0;
this.paths = {};
}
this.hdPath = hdPath;
}
/**
* Get the account index for a given address.
*
* This method first checks the `paths` map, and if not found, derives
* addresses up to MAX_INDEX to find the matching index.
*
* @param address - The account address.
* @returns The account index.
* @throws If the address is not found.
*/
getIndexForAddress(address) {
const checksummedAddress = getChecksumAddress(address);
let index = this.paths[checksummedAddress];
if (typeof index === 'undefined') {
for (let i = 0; i < MAX_INDEX; i++) {
if (checksummedAddress === __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_addressFromIndex).call(this, pathBase, i)) {
index = i;
break;
}
}
}
if (typeof index === 'undefined') {
throw new Error('Unknown address');
}
return index;
}
}
_TrezorKeyring_instances = new WeakSet(), _TrezorKeyring_getPage = async function _TrezorKeyring_getPage(increment) {
this.page += increment;
if (this.page <= 0) {
this.page = 1;
}
return new Promise((resolve, reject) => {
this.unlock()
.then((_) => {
const from = (this.page - 1) * this.perPage;
const to = from + this.perPage;
const accounts = [];
for (let i = from; i < to; i++) {
const address = __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_addressFromIndex).call(this, pathBase, i);
accounts.push({
address,
balance: null,
index: i,
});
this.paths[getChecksumAddress(address)] = i;
}
resolve(accounts);
})
.catch((e) => {
reject(e);
});
});
}, _TrezorKeyring_signTransaction =
/**
*
* @param address - Hex string address.
* @param chainId - Chain ID
* @param tx - Instance of either new-style or old-style ethereumjs transaction.
* @param handleSigning - Converts signed transaction
* to the same new-style or old-style ethereumjs-tx.
* @returns The signed transaction, an instance of either new-style or old-style
* ethereumjs transaction.
*/
async function _TrezorKeyring_signTransaction(address, chainId, tx, handleSigning) {
var _a, _b, _c, _d;
let transaction;
if (isOldStyleEthereumjsTx(tx)) {
// legacy transaction from ethereumjs-tx package has no .toJSON() function,
// so we need to convert to hex-strings manually manually
transaction = {
to: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_normalize).call(this, tx.to),
value: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_normalize).call(this, tx.value),
data: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_normalize).call(this, tx.data),
chainId,
nonce: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_normalize).call(this, tx.nonce),
gasLimit: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_normalize).call(this, tx.gasLimit),
gasPrice: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_normalize).call(this, tx.gasPrice),
};
}
else {
// new-style transaction from @ethereumjs/tx package
// we can just copy tx.toJSON() for everything except chainId, which must be a number
transaction = Object.assign(Object.assign({}, tx.toJSON()), { chainId, to: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_normalize).call(this, Buffer.from((_b = (_a = tx.to) === null || _a === void 0 ? void 0 : _a.bytes) !== null && _b !== void 0 ? _b : [])) });
}
try {
const status = await this.unlock();
await wait(status === 'just unlocked' ? DELAY_BETWEEN_POPUPS : 0);
const response = await this.bridge.ethereumSignTransaction({
path: __classPrivateFieldGet(this, _TrezorKeyring_instances, "m", _TrezorKeyring_pathFromAddress).call(this, address),
transaction,
});
if (response.success) {
const newOrMutatedTx = handleSigning(response.payload);
const addressSignedWith = getChecksumAddress(add0x(newOrMutatedTx.getSenderAddress().toString('hex')));
const correctAddress = getChecksumAddress(address);
if (addressSignedWith !== correctAddress) {
throw new Error("signature doesn't match the right address");
}
return newOrMutatedTx;
}
throw new Error((_d = (_c = response.payload) === null || _c === void 0 ? void 0 : _c.error) !== null && _d !== void 0 ? _d : 'Unknown error');
}
catch (error) {
// Re-throw address validation errors as plain Errors, not hardware errors
if (isAddressValidationError(error)) {
throw error;
}
return handleTrezorTransportError(error, 'Failed to sign transaction with Trezor device');
}
}, _TrezorKeyring_normalize = function _TrezorKeyring_normalize(buf) {
return bytesToHex(buf);
}, _TrezorKeyring_addressFromIndex = function _TrezorKeyring_addressFromIndex(basePath, i) {
const dkey = this.hdk.derive(`${basePath}/${i}`);
const address = bytesToHex(publicToAddress(dkey.publicKey, true));
return toChecksumAddress(address);
}, _TrezorKeyring_pathFromAddress = function _TrezorKeyring_pathFromAddress(address) {
const index = this.getIndexForAddress(address);
return `${this.hdPath}/${index}`;
};
TrezorKeyring.type = keyringType;
//# sourceMappingURL=trezor-keyring.mjs.map