diff --git a/packages/abstract-provider/src.ts/index.ts b/packages/abstract-provider/src.ts/index.ts index 98116c1b2f..c8d479f406 100644 --- a/packages/abstract-provider/src.ts/index.ts +++ b/packages/abstract-provider/src.ts/index.ts @@ -29,6 +29,9 @@ export type TransactionRequest = { type?: number; accessList?: AccessListish; + + maxPriorityFeePerGas?: BigNumberish; + maxFeePerGas?: BigNumberish; } export interface TransactionResponse extends Transaction { @@ -67,6 +70,8 @@ interface _Block { miner: string; extraData: string; + + baseFee?: null | BigNumber; } export interface Block extends _Block { diff --git a/packages/abstract-signer/src.ts/index.ts b/packages/abstract-signer/src.ts/index.ts index 7baa6d9248..659c5757bb 100644 --- a/packages/abstract-signer/src.ts/index.ts +++ b/packages/abstract-signer/src.ts/index.ts @@ -10,7 +10,7 @@ import { version } from "./_version"; const logger = new Logger(version); const allowedTransactionKeys: Array = [ - "accessList", "chainId", "data", "from", "gasLimit", "gasPrice", "nonce", "to", "type", "value" + "accessList", "chainId", "data", "from", "gasLimit", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "to", "type", "value" ]; const forwardErrors = [ @@ -19,6 +19,12 @@ const forwardErrors = [ Logger.errors.REPLACEMENT_UNDERPRICED, ]; +export interface FeeData { + maxFeePerGas: null | BigNumber; + maxPriorityFeePerGas: null | BigNumber; + gasPrice: null | BigNumber; +} + // EIP-712 Typed Data // See: https://eips.ethereum.org/EIPS/eip-712 @@ -139,6 +145,25 @@ export abstract class Signer { return await this.provider.getGasPrice(); } + async getFeeData(): Promise { + this._checkProvider("getFeeStats"); + + const { block, gasPrice } = await resolveProperties({ + block: this.provider.getBlock(-1), + gasPrice: this.provider.getGasPrice() + }); + + let maxFeePerGas = null, maxPriorityFeePerGas = null; + + if (block && block.baseFee) { + maxFeePerGas = block.baseFee.mul(2); + maxPriorityFeePerGas = BigNumber.from("1000000000"); + } + + return { maxFeePerGas, maxPriorityFeePerGas, gasPrice }; + } + + async resolveName(name: string): Promise { this._checkProvider("resolveName"); return await this.provider.resolveName(name); @@ -146,7 +171,6 @@ export abstract class Signer { - // Checks a transaction does not contain invalid keys and if // no "from" is provided, populates it. // - does NOT require a provider @@ -167,6 +191,7 @@ export abstract class Signer { if (tx.from == null) { tx.from = this.getAddress(); + } else { // Make sure any provided address matches this signer tx.from = Promise.all([ @@ -187,6 +212,9 @@ export abstract class Signer { // this Signer. Should be used by sendTransaction but NOT by signTransaction. // By default called from: (overriding these prevents it) // - sendTransaction + // + // Notes: + // - We allow gasPrice for EIP-1559 as long as it matches maxFeePerGas async populateTransaction(transaction: Deferrable): Promise { const tx: Deferrable = await resolveProperties(this.checkTransaction(transaction)) @@ -201,7 +229,93 @@ export abstract class Signer { return address; }); } - if (tx.gasPrice == null) { tx.gasPrice = this.getGasPrice(); } + + if ((tx.type === 2 || tx.type == null) && (tx.maxFeePerGas != null && tx.maxPriorityFeePerGas != null)) { + // Fully-formed EIP-1559 transaction + + // Check the gasPrice == maxFeePerGas + if (tx.gasPrice != null && !BigNumber.from(tx.gasPrice).eq((tx.maxFeePerGas))) { + logger.throwArgumentError("gasPrice/maxFeePerGas mismatch", "transaction", transaction); + } + + tx.type = 2; + + } else if (tx.type === -1 || tx.type === 1) { + // Explicit EIP-2930 or Legacy transaction + + // Do not allow EIP-1559 properties + if (tx.maxFeePerGas != null || tx.maxPriorityFeePerGas != null) { + logger.throwArgumentError(`transaction type ${ tx.type } does not support eip-1559 keys`, "transaction", transaction); + } + + if (tx.gasPrice == null) { tx.gasPrice = this.getGasPrice(); } + tx.type = (tx.accessList ? 1: -1); + + } else { + + // We need to get fee data to determine things + const feeData = await this.getFeeData(); + + if (tx.type == null) { + // We need to auto-detect the intended type of this transaction... + + if (feeData.maxFeePerGas != null && feeData.maxPriorityFeePerGas != null) { + // The network supports EIP-1559! + + if (tx.gasPrice != null && tx.maxFeePerGas == null && tx.maxPriorityFeePerGas == null) { + // Legacy or EIP-2930 transaction, but without its type set + tx.type = (tx.accessList ? 1: -1); + + } else { + // Use EIP-1559; no gas price or one EIP-1559 property was specified + + // Check that gasPrice == maxFeePerGas + if (tx.gasPrice != null) { + // The first condition fails only if gasPrice and maxPriorityFeePerGas + // were specified, which is a weird thing to do + if (tx.maxFeePerGas == null || !BigNumber.from(tx.gasPrice).eq((tx.maxFeePerGas))) { + logger.throwArgumentError("gasPrice/maxFeePerGas mismatch", "transaction", transaction); + } + } + + tx.type = 2; + if (tx.maxFeePerGas == null) { tx.maxFeePerGas = feeData.maxFeePerGas; } + if (tx.maxPriorityFeePerGas == null) { tx.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas; } + } + + } else if (feeData.gasPrice != null) { + // Network doesn't support EIP-1559... + + // ...but they are trying to use EIP-1559 properties + if (tx.maxFeePerGas != null || tx.maxPriorityFeePerGas != null) { + logger.throwError("network does not support EIP-1559", Logger.errors.UNSUPPORTED_OPERATION, { + operation: "populateTransaction" + }); + } + + tx.gasPrice = feeData.gasPrice; + tx.type = (tx.accessList ? 1: -1); + + } else { + // getFeeData has failed us. + logger.throwError("failed to get consistent fee data", Logger.errors.UNSUPPORTED_OPERATION, { + operation: "signer.getFeeData" + }); + } + + } else if (tx.type === 2) { + // Explicitly using EIP-1559 + + // Check gasPrice == maxFeePerGas + if (tx.gasPrice != null && !BigNumber.from(tx.gasPrice).eq((tx.maxFeePerGas))) { + logger.throwArgumentError("gasPrice/maxFeePerGas mismatch", "transaction", transaction); + } + + if (tx.maxFeePerGas == null) { tx.maxFeePerGas = feeData.maxFeePerGas; } + if (tx.maxPriorityFeePerGas == null) { tx.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas; } + } + } + if (tx.nonce == null) { tx.nonce = this.getTransactionCount("pending"); } if (tx.gasLimit == null) { diff --git a/packages/ethers/src.ts/utils.ts b/packages/ethers/src.ts/utils.ts index 00d40836ea..2e813b869a 100644 --- a/packages/ethers/src.ts/utils.ts +++ b/packages/ethers/src.ts/utils.ts @@ -17,7 +17,7 @@ import { checkProperties, deepCopy, defineReadOnly, getStatic, resolveProperties import * as RLP from "@ethersproject/rlp"; import { computePublicKey, recoverPublicKey, SigningKey } from "@ethersproject/signing-key"; import { formatBytes32String, nameprep, parseBytes32String, _toEscapedUtf8String, toUtf8Bytes, toUtf8CodePoints, toUtf8String, Utf8ErrorFuncs } from "@ethersproject/strings"; -import { accessListify, computeAddress, parse as parseTransaction, recoverAddress, serialize as serializeTransaction } from "@ethersproject/transactions"; +import { accessListify, computeAddress, parse as parseTransaction, recoverAddress, serialize as serializeTransaction, TransactionTypes } from "@ethersproject/transactions"; import { commify, formatEther, parseEther, formatUnits, parseUnits } from "@ethersproject/units"; import { verifyMessage, verifyTypedData } from "@ethersproject/wallet"; import { _fetchData, fetchJson, poll } from "@ethersproject/web"; @@ -153,6 +153,7 @@ export { accessListify, parseTransaction, serializeTransaction, + TransactionTypes, getJsonWalletAddress, diff --git a/packages/providers/src.ts/formatter.ts b/packages/providers/src.ts/formatter.ts index a36370bfa6..beefad7596 100644 --- a/packages/providers/src.ts/formatter.ts +++ b/packages/providers/src.ts/formatter.ts @@ -62,7 +62,12 @@ export class Formatter { from: address, - gasPrice: bigNumber, + // either (gasPrice) or (maxPriorityFeePerGas + maxFeePerGas) + // must be set + gasPrice: Formatter.allowNull(bigNumber), + maxPriorityFeePerGas: Formatter.allowNull(bigNumber), + maxFeePerGas: Formatter.allowNull(bigNumber), + gasLimit: bigNumber, to: Formatter.allowNull(address, null), value: bigNumber, @@ -83,6 +88,8 @@ export class Formatter { nonce: Formatter.allowNull(number), gasLimit: Formatter.allowNull(bigNumber), gasPrice: Formatter.allowNull(bigNumber), + maxPriorityFeePerGas: Formatter.allowNull(bigNumber), + maxFeePerGas: Formatter.allowNull(bigNumber), to: Formatter.allowNull(address), value: Formatter.allowNull(bigNumber), data: Formatter.allowNull(strictData), @@ -135,6 +142,8 @@ export class Formatter { extraData: data, transactions: Formatter.allowNull(Formatter.arrayOf(hash)), + + baseFee: Formatter.allowNull(bigNumber) }; formats.blockWithTransactions = shallowCopy(formats.block); @@ -323,6 +332,12 @@ export class Formatter { const result: TransactionResponse = Formatter.check(this.formats.transaction, transaction); + if (result.type === 2) { + if (result.gasPrice == null) { + result.gasPrice = result.maxFeePerGas; + } + } + if (transaction.chainId != null) { let chainId = transaction.chainId; diff --git a/packages/providers/src.ts/json-rpc-provider.ts b/packages/providers/src.ts/json-rpc-provider.ts index cb374a1a82..39b893ccc0 100644 --- a/packages/providers/src.ts/json-rpc-provider.ts +++ b/packages/providers/src.ts/json-rpc-provider.ts @@ -592,7 +592,7 @@ export class JsonRpcProvider extends BaseProvider { const result: { [key: string]: string | AccessList } = {}; // Some nodes (INFURA ropsten; INFURA mainnet is fine) do not like leading zeros. - ["gasLimit", "gasPrice", "type", "nonce", "value"].forEach(function(key) { + ["gasLimit", "gasPrice", "type", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "value"].forEach(function(key) { if ((transaction)[key] == null) { return; } const value = hexValue((transaction)[key]); if (key === "gasLimit") { key = "gas"; } diff --git a/packages/transactions/src.ts/index.ts b/packages/transactions/src.ts/index.ts index f5c433a9c4..4e8cc68bca 100644 --- a/packages/transactions/src.ts/index.ts +++ b/packages/transactions/src.ts/index.ts @@ -23,6 +23,12 @@ export type AccessListish = AccessList | Array<[ string, Array ]> | Record>; +export const TransactionTypes: Record = Object.freeze({ + legacy: -1, + eip2930: 1, + eip1559: 2, +}); + export type UnsignedTransaction = { to?: string; nonce?: number; @@ -36,7 +42,13 @@ export type UnsignedTransaction = { // Typed-Transaction features type?: number | null; + + // EIP-2930; Type 1 & EIP-1559; Type 2 accessList?: AccessListish; + + // EIP-1559; Type 2 + maxPriorityFeePerGas?: BigNumberish; + maxFeePerGas?: BigNumberish; } export interface Transaction { @@ -60,8 +72,12 @@ export interface Transaction { // Typed-Transaction features type?: number | null; - // EIP-2930; Type 1 + // EIP-2930; Type 1 & EIP-1559; Type 2 accessList?: AccessList; + + // EIP-1559; Type 2 + maxPriorityFeePerGas?: BigNumber; + maxFeePerGas?: BigNumber; } /////////////////////////////// @@ -147,6 +163,42 @@ function formatAccessList(value: AccessListish): Array<[ string, Array ] return accessListify(value).map((set) => [ set.address, set.storageKeys ]); } +function _serializeEip1559(transaction: UnsignedTransaction, signature?: SignatureLike): string { + // If there is an explicit gasPrice, make sure it matches the + // EIP-1559 fees; otherwise they may not understand what they + // think they are setting in terms of fee. + if (transaction.gasPrice != null) { + const gasPrice = BigNumber.from(transaction.gasPrice); + const maxFeePerGas = BigNumber.from(transaction.maxFeePerGas || 0); + if (!gasPrice.eq(maxFeePerGas)) { + logger.throwArgumentError("mismatch EIP-1559 gasPrice != maxFeePerGas", "tx", { + gasPrice, maxFeePerGas + }); + } + } + + const fields: any = [ + formatNumber(transaction.chainId || 0, "chainId"), + formatNumber(transaction.nonce || 0, "nonce"), + formatNumber(transaction.maxPriorityFeePerGas || 0, "maxPriorityFeePerGas"), + formatNumber(transaction.maxFeePerGas || 0, "maxFeePerGas"), + formatNumber(transaction.gasLimit || 0, "gasLimit"), + ((transaction.to != null) ? getAddress(transaction.to): "0x"), + formatNumber(transaction.value || 0, "value"), + (transaction.data || "0x"), + (formatAccessList(transaction.accessList || [])) + ]; + + if (signature) { + const sig = splitSignature(signature); + fields.push(formatNumber(sig.recoveryParam, "recoveryParam")); + fields.push(stripZeros(sig.r)); + fields.push(stripZeros(sig.s)); + } + + return hexConcat([ "0x02", RLP.encode(fields)]); +} + function _serializeEip2930(transaction: UnsignedTransaction, signature?: SignatureLike): string { const fields: any = [ formatNumber(transaction.chainId || 0, "chainId"), @@ -252,7 +304,7 @@ function _serialize(transaction: UnsignedTransaction, signature?: SignatureLike) export function serialize(transaction: UnsignedTransaction, signature?: SignatureLike): string { // Legacy and EIP-155 Transactions - if (transaction.type == null) { + if (transaction.type == null || transaction.type === -1) { if (transaction.accessList != null) { logger.throwArgumentError("untyped transactions do not support accessList; include type: 1", "transaction", transaction); } @@ -263,6 +315,8 @@ export function serialize(transaction: UnsignedTransaction, signature?: Signatur switch (transaction.type) { case 1: return _serializeEip2930(transaction, signature); + case 2: + return _serializeEip1559(transaction, signature); default: break; } @@ -273,6 +327,58 @@ export function serialize(transaction: UnsignedTransaction, signature?: Signatur }); } +function _parseEipSignature(tx: Transaction, fields: Array, serialize: (tx: UnsignedTransaction) => string): void { + try { + const recid = handleNumber(fields[0]).toNumber(); + if (recid !== 0 && recid !== 1) { throw new Error("bad recid"); } + tx.v = recid; + } catch (error) { + logger.throwArgumentError("invalid v for transaction type: 1", "v", fields[0]); + } + + tx.r = hexZeroPad(fields[1], 32); + tx.s = hexZeroPad(fields[2], 32); + + try { + const digest = keccak256(serialize(tx)); + tx.from = recoverAddress(digest, { r: tx.r, s: tx.s, recoveryParam: tx.v }); + } catch (error) { + console.log(error); + } +} + +function _parseEip1559(payload: Uint8Array): Transaction { + const transaction = RLP.decode(payload.slice(1)); + + if (transaction.length !== 9 && transaction.length !== 12) { + logger.throwArgumentError("invalid component count for transaction type: 2", "payload", hexlify(payload)); + } + + const maxPriorityFeePerGas = handleNumber(transaction[2]); + const maxFeePerGas = handleNumber(transaction[3]); + const tx: Transaction = { + type: 2, + chainId: handleNumber(transaction[0]).toNumber(), + nonce: handleNumber(transaction[1]).toNumber(), + maxPriorityFeePerGas: maxPriorityFeePerGas, + maxFeePerGas: maxFeePerGas, + gasPrice: maxPriorityFeePerGas.add(maxFeePerGas), + gasLimit: handleNumber(transaction[4]), + to: handleAddress(transaction[5]), + value: handleNumber(transaction[6]), + data: transaction[7], + accessList: accessListify(transaction[8]), + hash: keccak256(payload) + }; + + // Unsigned EIP-1559 Transaction + if (transaction.length === 9) { return tx; } + + _parseEipSignature(tx, transaction.slice(9), _serializeEip2930); + + return null; +} + function _parseEip2930(payload: Uint8Array): Transaction { const transaction = RLP.decode(payload.slice(1)); @@ -290,29 +396,13 @@ function _parseEip2930(payload: Uint8Array): Transaction { value: handleNumber(transaction[5]), data: transaction[6], accessList: accessListify(transaction[7]), + hash: keccak256(payload) }; // Unsigned EIP-2930 Transaction if (transaction.length === 8) { return tx; } - try { - const recid = handleNumber(transaction[8]).toNumber(); - if (recid !== 0 && recid !== 1) { throw new Error("bad recid"); } - tx.v = recid; - } catch (error) { - logger.throwArgumentError("invalid v for transaction type: 1", "v", transaction[8]); - } - - tx.r = hexZeroPad(transaction[9], 32); - tx.s = hexZeroPad(transaction[10], 32); - - try { - const digest = keccak256(_serializeEip2930(tx)); - tx.from = recoverAddress(digest, { r: tx.r, s: tx.s, recoveryParam: tx.v }); - } catch (error) { - console.log(error); - } - tx.hash = keccak256(payload); + _parseEipSignature(tx, transaction.slice(8), _serializeEip2930); return tx; } @@ -397,6 +487,8 @@ export function parse(rawTransaction: BytesLike): Transaction { switch (payload[0]) { case 1: return _parseEip2930(payload); + case 2: + return _parseEip1559(payload); default: break; }