@nomicfoundation/hardhat-ethers
Version:
Hardhat plugin for ethers
429 lines (365 loc) • 12.4 kB
text/typescript
import type {
Authorization,
AuthorizationRequest,
BlockTag,
TransactionRequest,
} from "ethers";
import {
assertArgument,
computeAddress,
ethers,
getAddress,
hexlify,
resolveAddress,
toUtf8Bytes,
TransactionLike,
TypedDataEncoder,
Wallet,
} from "ethers";
import {
HardhatNetworkAccountConfig,
HardhatNetworkAccountsConfig,
HttpNetworkAccountsConfig,
} from "hardhat/types/config";
import { derivePrivateKeys } from "hardhat/internal/core/providers/util";
import { HardhatEthersProvider } from "./internal/hardhat-ethers-provider";
import {
copyRequest,
getRpcTransaction,
resolveProperties,
} from "./internal/ethers-utils";
import { HardhatEthersError, NotImplementedError } from "./internal/errors";
export class HardhatEthersSigner implements ethers.Signer {
private readonly _accounts:
| HttpNetworkAccountsConfig
| HardhatNetworkAccountsConfig;
private _cachedPrivateKey: string | undefined;
public readonly address: string;
public readonly provider: ethers.JsonRpcProvider | HardhatEthersProvider;
public static async create(provider: HardhatEthersProvider, address: string) {
const hre = await import("hardhat");
// depending on the config, we set a fixed gas limit for all transactions
let gasLimit: number | undefined;
if (hre.network.name === "hardhat") {
// If we are connected to the in-process hardhat network and the config
// has a fixed number as the gas config, we use that.
// Hardhat core already sets this value to the block gas limit when the
// user doesn't specify a number.
if (hre.network.config.gas !== "auto") {
gasLimit = hre.network.config.gas;
}
} else if (hre.network.name === "localhost") {
const configuredGasLimit = hre.config.networks.localhost.gas;
if (configuredGasLimit !== "auto") {
// if the resolved gas config is a number, we use that
gasLimit = configuredGasLimit;
} else {
// if the resolved gas config is "auto", we need to check that
// the user config is undefined, because that's the default value;
// otherwise explicitly setting the gas to "auto" would have no effect
if (hre.userConfig.networks?.localhost?.gas === undefined) {
// finally, we check if we are connected to a hardhat network
let isHardhatNetwork = false;
try {
await hre.network.provider.send("hardhat_metadata");
isHardhatNetwork = true;
} catch {}
if (isHardhatNetwork) {
// WARNING: this assumes that the hardhat node is being run in the
// same project which might be wrong
gasLimit = hre.config.networks.hardhat.blockGasLimit;
}
}
}
}
return new HardhatEthersSigner(
address,
provider,
hre.network.config.accounts,
gasLimit
);
}
private constructor(
address: string,
_provider: ethers.JsonRpcProvider | HardhatEthersProvider,
accounts: HttpNetworkAccountsConfig | HardhatNetworkAccountsConfig,
private readonly _gasLimit?: number
) {
this.address = getAddress(address);
this.provider = _provider;
this._accounts = accounts;
}
public connect(
provider: ethers.JsonRpcProvider | HardhatEthersProvider
): ethers.Signer {
return new HardhatEthersSigner(this.address, provider, this._accounts);
}
public async authorize(auth: AuthorizationRequest): Promise<Authorization> {
const privateKey = this._getPrivateKey();
if (privateKey === undefined) {
throw new HardhatEthersError(
`No private key found for address ${this.address}`
);
}
const wallet = new Wallet(privateKey, this.provider);
return wallet.authorize(auth);
}
public async populateAuthorization(
_auth: AuthorizationRequest
): Promise<AuthorizationRequest> {
const auth = { ..._auth };
// Add a chain ID if not explicitly set to 0
if (auth.chainId === null || auth.chainId === undefined) {
auth.chainId = (await this.provider.getNetwork()).chainId;
}
if (auth.nonce === null || auth.nonce === undefined) {
auth.nonce = await this.getNonce();
}
return auth;
}
public getNonce(blockTag?: BlockTag | undefined): Promise<number> {
return this.provider.getTransactionCount(this.address, blockTag);
}
public populateCall(
tx: TransactionRequest
): Promise<ethers.TransactionLike<string>> {
return populate(this, tx);
}
public populateTransaction(
tx: TransactionRequest
): Promise<ethers.TransactionLike<string>> {
return this.populateCall(tx);
}
public async estimateGas(tx: TransactionRequest): Promise<bigint> {
return this.provider.estimateGas(await this.populateCall(tx));
}
public async call(tx: TransactionRequest): Promise<string> {
return this.provider.call(await this.populateCall(tx));
}
public resolveName(name: string): Promise<string | null> {
return this.provider.resolveName(name);
}
public async signTransaction(_tx: TransactionRequest): Promise<string> {
// TODO if we split the signer for the in-process and json-rpc networks,
// we can enable this method when using the in-process network or when the
// json-rpc network has a private key
throw new NotImplementedError("HardhatEthersSigner.signTransaction");
}
public async sendTransaction(
tx: TransactionRequest
): Promise<ethers.TransactionResponse> {
// This cannot be mined any earlier than any recent block
const blockNumber = await this.provider.getBlockNumber();
// Send the transaction
const hash = await this._sendUncheckedTransaction(tx);
// Unfortunately, JSON-RPC only provides and opaque transaction hash
// for a response, and we need the actual transaction, so we poll
// for it; it should show up very quickly
return new Promise((resolve) => {
const timeouts = [1000, 100];
const checkTx = async () => {
// Try getting the transaction
const txPolled = await this.provider.getTransaction(hash);
if (txPolled !== null) {
resolve(txPolled.replaceableTransaction(blockNumber));
return;
}
// Wait another 4 seconds
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
checkTx();
}, timeouts.pop() ?? 4000);
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
checkTx();
});
}
public signMessage(message: string | Uint8Array): Promise<string> {
const resolvedMessage =
typeof message === "string" ? toUtf8Bytes(message) : message;
return this.provider.send("personal_sign", [
hexlify(resolvedMessage),
this.address.toLowerCase(),
]);
}
public async signTypedData(
domain: ethers.TypedDataDomain,
types: Record<string, ethers.TypedDataField[]>,
value: Record<string, any>
): Promise<string> {
const copiedValue = deepCopy(value);
// Populate any ENS names (in-place)
const populated = await TypedDataEncoder.resolveNames(
domain,
types,
copiedValue,
async (v: string) => {
return v;
}
);
return this.provider.send("eth_signTypedData_v4", [
this.address.toLowerCase(),
JSON.stringify(
TypedDataEncoder.getPayload(populated.domain, types, populated.value),
(_k, v) => {
if (typeof v === "bigint") {
return v.toString();
}
return v;
}
),
]);
}
public async getAddress(): Promise<string> {
return this.address;
}
public toJSON() {
return `<SignerWithAddress ${this.address}>`;
}
private _getPrivateKey(): string | undefined {
if (this._cachedPrivateKey === undefined) {
const privateKeys = this._getPrivateKeys();
const privateKey = privateKeys.find(
(key) => computeAddress(key) === this.address
);
this._cachedPrivateKey = privateKey;
}
return this._cachedPrivateKey;
}
private _getPrivateKeys(): string[] {
if (this._accounts === "remote") {
throw new HardhatEthersError(
`Tried to obtain a private key, but the network is configured to use remote accounts`
);
}
if (Array.isArray(this._accounts)) {
if (typeof this._accounts[0] === "string") {
return this._accounts as string[];
}
return (this._accounts as HardhatNetworkAccountConfig[]).map(
(acc) => acc.privateKey
);
}
if ("mnemonic" in this._accounts) {
return derivePrivateKeys(
this._accounts.mnemonic,
this._accounts.path,
this._accounts.initialIndex,
this._accounts.count,
this._accounts.passphrase
).map((pk) => `0x${pk.toString("hex")}`);
}
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new HardhatEthersError("Assertion error: unsupported accounts type");
}
private async _sendUncheckedTransaction(
tx: TransactionRequest
): Promise<string> {
const resolvedTx = deepCopy(tx);
const promises: Array<Promise<void>> = [];
// Make sure the from matches the sender
if (resolvedTx.from !== null && resolvedTx.from !== undefined) {
const _from = resolvedTx.from;
promises.push(
(async () => {
const from = await resolveAddress(_from, this.provider);
assertArgument(
from !== null &&
from !== undefined &&
from.toLowerCase() === this.address.toLowerCase(),
"from address mismatch",
"transaction",
tx
);
resolvedTx.from = from;
})()
);
} else {
resolvedTx.from = this.address;
}
if (resolvedTx.gasLimit === null || resolvedTx.gasLimit === undefined) {
if (this._gasLimit !== undefined) {
resolvedTx.gasLimit = this._gasLimit;
} else {
promises.push(
(async () => {
resolvedTx.gasLimit = await this.provider.estimateGas({
...resolvedTx,
from: this.address,
});
})()
);
}
}
// The address may be an ENS name or Addressable
if (resolvedTx.to !== null && resolvedTx.to !== undefined) {
const _to = resolvedTx.to;
promises.push(
(async () => {
resolvedTx.to = await resolveAddress(_to, this.provider);
})()
);
}
// Wait until all of our properties are filled in
if (promises.length > 0) {
await Promise.all(promises);
}
const hexTx = getRpcTransaction(resolvedTx);
return this.provider.send("eth_sendTransaction", [hexTx]);
}
}
// exported as an alias to make migration easier
export { HardhatEthersSigner as SignerWithAddress };
async function populate(
signer: ethers.Signer,
tx: TransactionRequest
): Promise<TransactionLike<string>> {
const pop: any = copyRequest(tx);
if (pop.to !== null && pop.to !== undefined) {
pop.to = resolveAddress(pop.to, signer);
}
if (pop.from !== null && pop.from !== undefined) {
const from = pop.from;
pop.from = Promise.all([
signer.getAddress(),
resolveAddress(from, signer),
]).then(([address, resolvedFrom]) => {
assertArgument(
address.toLowerCase() === resolvedFrom.toLowerCase(),
"transaction from mismatch",
"tx.from",
resolvedFrom
);
return address;
});
} else {
pop.from = signer.getAddress();
}
return resolveProperties(pop);
}
const Primitive = "bigint,boolean,function,number,string,symbol".split(/,/g);
function deepCopy<T = any>(value: T): T {
if (
value === null ||
value === undefined ||
Primitive.indexOf(typeof value) >= 0
) {
return value;
}
// Keep any Addressable
if (typeof (value as any).getAddress === "function") {
return value;
}
if (Array.isArray(value)) {
return (value as any).map(deepCopy);
}
if (typeof value === "object") {
return Object.keys(value).reduce((accum, key) => {
accum[key] = (value as any)[key];
return accum;
}, {} as any);
}
throw new HardhatEthersError(
`Assertion error: ${value as any} (${typeof value})`
);
}