packages/hdwallet-provider/src/index.ts
import {
mnemonicToSeedSync,
validateMnemonic
} from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import * as EthUtil from "ethereumjs-util";
import { Transaction, FeeMarketEIP1559Transaction } from "@ethereumjs/tx";
import Common from "@ethereumjs/common";
import ProviderEngine from "web3-provider-engine";
// @ts-ignore - web3-provider-engine doesn't have declaration files for these subproviders
import FiltersSubprovider from "web3-provider-engine/subproviders/filters";
// @ts-ignore
import NonceSubProvider from "web3-provider-engine/subproviders/nonce-tracker";
// @ts-ignore
import HookedSubprovider from "web3-provider-engine/subproviders/hooked-wallet";
// @ts-ignore
import ProviderSubprovider from "web3-provider-engine/subproviders/provider";
// @ts-ignore
import RpcProvider from "web3-provider-engine/subproviders/rpc";
// @ts-ignore
import WebsocketProvider from "web3-provider-engine/subproviders/websocket";
import Url from "url";
import type {
JSONRPCRequestPayload,
JSONRPCResponsePayload
} from "ethereum-protocol";
import type { ConstructorArguments } from "./constructor/ConstructorArguments";
import { getOptions } from "./constructor/getOptions";
import { getPrivateKeys } from "./constructor/getPrivateKeys";
import { getMnemonic } from "./constructor/getMnemonic";
import type { ChainId, ChainSettings, Hardfork } from "./constructor/types";
import { signTypedData, SignTypedDataVersion } from "@metamask/eth-sig-util";
import {
createAccountGeneratorFromSeedAndPath,
uncompressedPublicKeyToAddress
} from "@truffle/hdwallet";
// Important: do not use debug module. Reason: https://github.com/trufflesuite/truffle/issues/2374#issuecomment-536109086
// This line shares nonce state across multiple provider instances. Necessary
// because within truffle the wallet is repeatedly newed if it's declared in the config within a
// function, resetting nonce from tx to tx. An instance can opt out
// of this behavior by passing `shareNonce=false` to the constructor.
// See issue #65 for more
const singletonNonceSubProvider = new NonceSubProvider();
class HDWalletProvider {
private walletHdpath: string;
#wallets: { [address: string]: Buffer };
#addresses: string[];
private chainId?: ChainId;
private chainSettings: ChainSettings;
private hardfork: Hardfork;
private initialized: Promise<void>;
public engine: ProviderEngine;
constructor(...args: ConstructorArguments) {
const {
provider,
url,
providerOrUrl,
addressIndex = 0,
numberOfAddresses = 10,
shareNonce = true,
derivationPath = `m/44'/60'/0'/0/`,
pollingInterval = 4000,
chainId,
chainSettings = {},
// what's left is either a mnemonic or a list of private keys
...signingAuthority
} = getOptions(...args);
const mnemonic = getMnemonic(signingAuthority);
const privateKeys = getPrivateKeys(signingAuthority);
this.walletHdpath = derivationPath;
this.#wallets = {};
this.#addresses = [];
this.chainSettings = chainSettings;
this.engine = new ProviderEngine({
pollingInterval
});
let providerToUse;
if (HDWalletProvider.isValidProvider(provider)) {
providerToUse = provider;
} else if (HDWalletProvider.isValidProvider(url)) {
providerToUse = url;
} else {
providerToUse = providerOrUrl;
}
if (!HDWalletProvider.isValidProvider(providerToUse)) {
throw new Error(
[
`No provider or an invalid provider was specified: '${providerToUse}'`,
"Please specify a valid provider or URL, using the http, https, " +
"ws, or wss protocol.",
""
].join("\n")
);
}
if (mnemonic && mnemonic.phrase) {
this.checkBIP39Mnemonic({
...mnemonic,
addressIndex,
numberOfAddresses
});
} else if (privateKeys) {
const options = Object.assign({}, { privateKeys }, { addressIndex });
this.ethUtilValidation(options);
} // no need to handle else case here, since matchesNewOptions() covers it
if (this.#addresses.length === 0) {
throw new Error(
`Could not create addresses from your mnemonic or private key(s). ` +
`Please check that your inputs are correct.`
);
}
const tmpAccounts = this.#addresses;
const tmpWallets = this.#wallets;
// if user supplied the chain id, use that - otherwise fetch it
if (
typeof chainId !== "undefined" ||
(chainSettings && typeof chainSettings.chainId !== "undefined")
) {
this.chainId = chainId || chainSettings.chainId;
this.initialized = Promise.resolve();
} else {
this.initialized = this.initialize();
}
// EIP155 compliant transactions are enabled for hardforks later
// than or equal to "spurious dragon"
this.hardfork =
chainSettings && chainSettings.hardfork
? chainSettings.hardfork
: "london";
const self = this;
this.engine.addProvider(
new HookedSubprovider({
getAccounts(cb: any) {
cb(null, tmpAccounts);
},
getPrivateKey(address: string, cb: any) {
if (!tmpWallets[address]) {
cb("Account not found");
return;
} else {
cb(null, tmpWallets[address].toString("hex"));
}
},
async signTransaction(txParams: any, cb: any) {
await self.initialized;
// we need to rename the 'gas' field
txParams.gasLimit = txParams.gas;
delete txParams.gas;
let pkey;
const from = txParams.from.toLowerCase();
if (tmpWallets[from]) {
pkey = tmpWallets[from];
} else {
cb("Account not found");
return;
}
const chain = self.chainId;
const KNOWN_CHAIN_IDS = new Set([1, 3, 4, 5, 42]);
let txOptions;
if (typeof chain !== "undefined" && KNOWN_CHAIN_IDS.has(chain)) {
txOptions = {
common: new Common({ chain, hardfork: self.hardfork })
};
} else if (typeof chain !== "undefined") {
txOptions = {
common: Common.forCustomChain(
1,
{
name: "custom chain",
chainId: chain
},
self.hardfork
)
};
}
// Taken from https://github.com/ethers-io/ethers.js/blob/2a7ce0e72a1e0c9469e10392b0329e75e341cf18/packages/abstract-signer/src.ts/index.ts#L215
const hasEip1559 =
txParams.maxFeePerGas !== undefined ||
txParams.maxPriorityFeePerGas !== undefined;
const tx = hasEip1559
? FeeMarketEIP1559Transaction.fromTxData(txParams, txOptions)
: Transaction.fromTxData(txParams, txOptions);
const signedTx = tx.sign(pkey as Buffer);
const rawTx = `0x${signedTx.serialize().toString("hex")}`;
cb(null, rawTx);
},
signMessage({ data, from }: any, cb: any) {
const dataIfExists = data;
if (!dataIfExists) {
cb("No data to sign");
return;
}
if (!tmpWallets[from]) {
cb("Account not found");
return;
}
let pkey = tmpWallets[from];
const dataBuff = EthUtil.toBuffer(dataIfExists);
const msgHashBuff = EthUtil.hashPersonalMessage(dataBuff);
const sig = EthUtil.ecsign(msgHashBuff, pkey);
const rpcSig = EthUtil.toRpcSig(sig.v, sig.r, sig.s);
cb(null, rpcSig);
},
signPersonalMessage(...args: any[]) {
this.signMessage(...args);
},
signTypedMessage(
{ data, from }: { data: string; from: string },
cb: any
) {
if (!data) {
cb("No data to sign");
return;
}
// convert address to lowercase in case it is in checksum format
const fromAddress = from.toLowerCase();
if (!tmpWallets[fromAddress]) {
cb("Account not found");
return;
}
const signature = signTypedData({
data: JSON.parse(data),
privateKey: tmpWallets[fromAddress],
version: SignTypedDataVersion.V4
});
cb(null, signature);
}
})
);
!shareNonce
? this.engine.addProvider(new NonceSubProvider())
: this.engine.addProvider(singletonNonceSubProvider);
this.engine.addProvider(new FiltersSubprovider());
if (typeof providerToUse === "string") {
const url = providerToUse;
const providerProtocol = (
Url.parse(url).protocol || "http:"
).toLowerCase();
switch (providerProtocol) {
case "ws:":
case "wss:":
this.engine.addProvider(new WebsocketProvider({ rpcUrl: url }));
break;
default:
this.engine.addProvider(new RpcProvider({ rpcUrl: url }));
}
} else {
this.engine.addProvider(new ProviderSubprovider(providerToUse));
}
// Required by the provider engine.
this.engine.start();
}
private initialize(): Promise<void> {
return new Promise((resolve, reject) => {
this.engine.sendAsync(
{
jsonrpc: "2.0",
id: Date.now(),
method: "eth_chainId",
params: []
},
// @ts-ignore - the type doesn't take into account the possibility
// that response.error could be a thing
(error: any, response: JSONRPCResponsePayload & { error?: any }) => {
if (error) {
reject(error);
return;
} else if (response.error) {
reject(response.error);
return;
}
if (isNaN(parseInt(response.result, 16))) {
const message =
"When requesting the chain id from the node, it" +
`returned the malformed result ${response.result}.`;
throw new Error(message);
}
this.chainId = parseInt(response.result, 16);
resolve();
}
);
});
}
// private helper to check if given mnemonic uses BIP39 passphrase protection
private checkBIP39Mnemonic({
addressIndex,
numberOfAddresses,
phrase,
password
}: {
addressIndex: number;
numberOfAddresses: number;
phrase: string;
password?: string;
}) {
if (!validateMnemonic(phrase, wordlist)) {
throw new Error("Mnemonic invalid or undefined");
}
const hdwallet = createAccountGeneratorFromSeedAndPath(
mnemonicToSeedSync(phrase, password),
this.walletHdpath.replace(/\/$/, "").split("/")
);
// crank the addresses out
for (let i = addressIndex; i < addressIndex + numberOfAddresses; i++) {
const wallet = hdwallet(i);
const addr = `0x${Buffer.from(
uncompressedPublicKeyToAddress(wallet.publicKey)
).toString("hex")}`;
this.#addresses.push(addr);
this.#wallets[addr] = wallet.privateKey;
}
}
// private helper leveraging ethUtils to populate wallets/addresses
private ethUtilValidation({
addressIndex,
privateKeys
}: {
addressIndex: number;
privateKeys: string[];
}) {
// crank the addresses out
for (let i = addressIndex; i < privateKeys.length; i++) {
const privateKey = Buffer.from(privateKeys[i].replace("0x", ""), "hex");
if (EthUtil.isValidPrivate(privateKey)) {
const wallet = EthUtil.privateToAddress(privateKey);
const address = `0x${wallet.toString("hex")}`;
this.#addresses.push(address);
this.#wallets[address] = privateKey;
}
}
}
public send(
payload: JSONRPCRequestPayload,
// @ts-ignore we patch this method so it doesn't conform to type
callback: (error: null | Error, response: JSONRPCResponsePayload) => void
): void {
this.initialized.then(() => {
this.engine.sendAsync(payload, callback);
});
}
public sendAsync(
payload: JSONRPCRequestPayload,
callback: (error: null | Error, response: JSONRPCResponsePayload) => void
): void {
this.initialized.then(() => {
this.engine.sendAsync(payload, callback);
});
}
public getAddress(idx?: number): string {
if (!idx) {
return this.#addresses[0];
} else {
return this.#addresses[idx];
}
}
public getAddresses(): string[] {
return this.#addresses;
}
public static isValidProvider(provider: any): boolean {
if (!provider) return false;
if (typeof provider === "string") {
const validProtocols = ["http:", "https:", "ws:", "wss:"];
const url = Url.parse(provider.toLowerCase());
return !!(validProtocols.includes(url.protocol || "") && url.slashes);
} else if ("request" in provider) {
// provider is an 1193 provider
return true;
} else if ("send" in provider) {
// provider is a "legacy" provider
return true;
}
return false;
}
}
export = HDWalletProvider;