diff --git a/packages/abstract-provider/src.ts/index.ts b/packages/abstract-provider/src.ts/index.ts index 0777bb8bd..cbe5527f4 100644 --- a/packages/abstract-provider/src.ts/index.ts +++ b/packages/abstract-provider/src.ts/index.ts @@ -4,7 +4,7 @@ import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; import { BytesLike, isHexString } from "@ethersproject/bytes"; import { Network } from "@ethersproject/networks"; import { Deferrable, Description, defineReadOnly } from "@ethersproject/properties"; -import { Transaction } from "@ethersproject/transactions"; +import { AccessListish, Transaction } from "@ethersproject/transactions"; import { OnceBlockable } from "@ethersproject/web"; import { Logger } from "@ethersproject/logger"; @@ -26,6 +26,9 @@ export type TransactionRequest = { data?: BytesLike, value?: BigNumberish, chainId?: number + + type?: number; + accessList?: AccessListish; } export interface TransactionResponse extends Transaction { diff --git a/packages/abstract-signer/src.ts/index.ts b/packages/abstract-signer/src.ts/index.ts index 9a77e64cf..7baa6d924 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 = [ - "chainId", "data", "from", "gasLimit", "gasPrice", "nonce", "to", "value" + "accessList", "chainId", "data", "from", "gasLimit", "gasPrice", "nonce", "to", "type", "value" ]; const forwardErrors = [ diff --git a/packages/bytes/src.ts/index.ts b/packages/bytes/src.ts/index.ts index 8f90d68d9..877cb4f7c 100644 --- a/packages/bytes/src.ts/index.ts +++ b/packages/bytes/src.ts/index.ts @@ -390,6 +390,8 @@ export function splitSignature(signature: SignatureLike): Signature { if (result.recoveryParam == null) { if (result.v == null) { logger.throwArgumentError("signature missing v and recoveryParam", "signature", signature); + } else if (result.v === 0 || result.v === 1) { + result.recoveryParam = result.v; } else { result.recoveryParam = 1 - (result.v % 2); } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9f526eec1..cd697831f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -9,7 +9,8 @@ "@ethersproject/bytes": "^5.0.9", "@ethersproject/constants": "^5.0.8", "@ethersproject/logger": "^5.0.8", - "@ethersproject/properties": "^5.0.7" + "@ethersproject/properties": "^5.0.7", + "@ethersproject/transactions": "^5.0.11" }, "description": "Contract abstraction meta-class for ethers.", "ethereum": "donations.ethers.eth", diff --git a/packages/contracts/src.ts/index.ts b/packages/contracts/src.ts/index.ts index cbce2af49..a13388e2f 100644 --- a/packages/contracts/src.ts/index.ts +++ b/packages/contracts/src.ts/index.ts @@ -7,7 +7,7 @@ import { getAddress, getContractAddress } from "@ethersproject/address"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; import { arrayify, BytesLike, concat, hexlify, isBytes, isHexString } from "@ethersproject/bytes"; import { Deferrable, defineReadOnly, deepCopy, getStatic, resolveProperties, shallowCopy } from "@ethersproject/properties"; -// @TOOD remove dependences transactions +import { AccessList, accessListify, AccessListish } from "@ethersproject/transactions"; import { Logger } from "@ethersproject/logger"; import { version } from "./_version"; @@ -18,6 +18,8 @@ export interface Overrides { gasLimit?: BigNumberish | Promise; gasPrice?: BigNumberish | Promise; nonce?: BigNumberish | Promise; + type?: number; + accessList?: AccessListish; }; export interface PayableOverrides extends Overrides { @@ -45,6 +47,9 @@ export interface PopulatedTransaction { data?: string; value?: BigNumber; chainId?: number; + + type?: number; + accessList?: AccessList; }; export type EventFilter = { @@ -94,7 +99,8 @@ export interface ContractTransaction extends TransactionResponse { /////////////////////////////// const allowedTransactionKeys: { [ key: string ]: boolean } = { - chainId: true, data: true, from: true, gasLimit: true, gasPrice:true, nonce: true, to: true, value: true + chainId: true, data: true, from: true, gasLimit: true, gasPrice:true, nonce: true, to: true, value: true, + type: true, accessList: true, } async function resolveName(resolver: Signer | Provider, nameOrPromise: string | Promise): Promise { @@ -212,6 +218,9 @@ async function populateTransaction(contract: Contract, fragment: FunctionFragmen if (ro.gasPrice != null) { tx.gasPrice = BigNumber.from(ro.gasPrice); } if (ro.from != null) { tx.from = ro.from; } + if (ro.type != null) { tx.type = ro.type; } + if (ro.accessList != null) { tx.accessList = accessListify(ro.accessList); } + // If there was no "gasLimit" override, but the ABI specifies a default, use it if (tx.gasLimit == null && fragment.gas != null) { // Conmpute the intrinisic gas cost for this transaction @@ -247,6 +256,9 @@ async function populateTransaction(contract: Contract, fragment: FunctionFragmen delete overrides.from; delete overrides.value; + delete overrides.type; + delete overrides.accessList; + // Make sure there are no stray overrides, which may indicate a // typo or using an unsupported key. const leftovers = Object.keys(overrides).filter((key) => ((overrides)[key] != null)); diff --git a/packages/networks/src.ts/index.ts b/packages/networks/src.ts/index.ts index 8a61526e3..33e6c2bd3 100644 --- a/packages/networks/src.ts/index.ts +++ b/packages/networks/src.ts/index.ts @@ -39,14 +39,30 @@ function ethDefaultProvider(network: string | Network): Renetworkable { } if (providers.AlchemyProvider) { + // These networks are currently faulty on Alchemy as their + // network does not handle the Berlin hardfork, which is + // live on these ones. + // @TODO: This goes away once AlchemyAPI has upgraded their nodes + const skip = [ "goerli", "ropsten", "rinkeby" ]; try { - providerList.push(new providers.AlchemyProvider(network, options.alchemy)); + const provider = new providers.AlchemyProvider(network, options.alchemy); + if (provider.network && skip.indexOf(provider.network.name) === -1) { + providerList.push(provider); + } } catch(error) { } } if (providers.PocketProvider) { + // These networks are currently faulty on Alchemy as their + // network does not handle the Berlin hardfork, which is + // live on these ones. + // @TODO: This goes away once Pocket has upgraded their nodes + const skip = [ "goerli", "ropsten", "rinkeby" ]; try { - providerList.push(new providers.PocketProvider(network)); + const provider = new providers.PocketProvider(network); + if (provider.network && skip.indexOf(provider.network.name) === -1) { + providerList.push(provider); + } } catch(error) { } } diff --git a/packages/providers/src.ts/base-provider.ts b/packages/providers/src.ts/base-provider.ts index 388718f3f..a320a3324 100644 --- a/packages/providers/src.ts/base-provider.ts +++ b/packages/providers/src.ts/base-provider.ts @@ -1122,6 +1122,15 @@ export class BaseProvider extends Provider implements EnsProvider { tx[key] = Promise.resolve(values[key]).then((v) => (v ? BigNumber.from(v): null)); }); + ["type"].forEach((key) => { + if (values[key] == null) { return; } + tx[key] = Promise.resolve(values[key]).then((v) => ((v != null) ? v: null)); + }); + + if (values.accessList) { + tx.accessList = this.formatter.accessList(values.accessList); + } + ["data"].forEach((key) => { if (values[key] == null) { return; } tx[key] = Promise.resolve(values[key]).then((v) => (v ? hexlify(v): null)); @@ -1175,6 +1184,7 @@ export class BaseProvider extends Provider implements EnsProvider { const params = await resolveProperties({ transaction: this._getTransactionRequest(transaction) }); + const result = await this.perform("estimateGas", params); try { return BigNumber.from(result); diff --git a/packages/providers/src.ts/etherscan-provider.ts b/packages/providers/src.ts/etherscan-provider.ts index 870f5c88d..56e6b611d 100644 --- a/packages/providers/src.ts/etherscan-provider.ts +++ b/packages/providers/src.ts/etherscan-provider.ts @@ -20,10 +20,14 @@ function getTransactionPostData(transaction: TransactionRequest): Record = { }; for (let key in transaction) { if ((transaction)[key] == null) { continue; } - let value = hexlify((transaction)[key]); + let value = (transaction)[key]; // Quantity-types require no leading zero, unless 0 - if (({ gasLimit: true, gasPrice: true, nonce: true, value: true })[key]) { - value = hexValue(value); + if (({ type: true, gasLimit: true, gasPrice: true, nonce: true, value: true })[key]) { + value = hexValue(hexlify(value)); + } else if (key === "accessList") { + value = value; + } else { + value = hexlify(value); } result[key] = value; } @@ -293,6 +297,13 @@ export class EtherscanProvider extends BaseProvider{ case "call": { + if (params.transaction.type != null) { + logger.throwError("Etherscan does not currently support Berlin", Logger.errors.UNSUPPORTED_OPERATION, { + operation: "call", + transaction: params.transaction + }); + } + if (params.blockTag !== "latest") { throw new Error("EtherscanProvider does not support blockTag for call"); } @@ -310,6 +321,13 @@ export class EtherscanProvider extends BaseProvider{ } case "estimateGas": { + if (params.transaction.type != null) { + logger.throwError("Etherscan does not currently support Berlin", Logger.errors.UNSUPPORTED_OPERATION, { + operation: "estimateGas", + transaction: params.transaction + }); + } + const postData = getTransactionPostData(params.transaction); postData.module = "proxy"; postData.action = "eth_estimateGas"; diff --git a/packages/providers/src.ts/formatter.ts b/packages/providers/src.ts/formatter.ts index fbac3096e..a36370bfa 100644 --- a/packages/providers/src.ts/formatter.ts +++ b/packages/providers/src.ts/formatter.ts @@ -6,7 +6,7 @@ import { BigNumber } from "@ethersproject/bignumber"; import { hexDataLength, hexDataSlice, hexValue, hexZeroPad, isHexString } from "@ethersproject/bytes"; import { AddressZero } from "@ethersproject/constants"; import { shallowCopy } from "@ethersproject/properties"; -import { parse as parseTransaction } from "@ethersproject/transactions"; +import { AccessList, accessListify, parse as parseTransaction } from "@ethersproject/transactions"; import { Logger } from "@ethersproject/logger"; import { version } from "./_version"; @@ -51,6 +51,9 @@ export class Formatter { formats.transaction = { hash: hash, + type: Formatter.allowNull(number, null), + accessList: Formatter.allowNull(this.accessList.bind(this), null), + blockHash: Formatter.allowNull(hash, null), blockNumber: Formatter.allowNull(number, null), transactionIndex: Formatter.allowNull(number, null), @@ -83,6 +86,8 @@ export class Formatter { to: Formatter.allowNull(address), value: Formatter.allowNull(bigNumber), data: Formatter.allowNull(strictData), + type: Formatter.allowNull(number), + accessList: Formatter.allowNull(this.accessList.bind(this), null), }; formats.receiptLog = { @@ -162,6 +167,10 @@ export class Formatter { return formats; } + accessList(accessList: Array): AccessList { + return accessListify(accessList || []); + } + // Requires a BigNumberish that is within the IEEE754 safe integer range; returns a number // Strict! Used on input. number(number: any): number { @@ -308,28 +317,9 @@ export class Formatter { transaction.creates = this.contractAddress(transaction); } - // @TODO: use transaction.serialize? Have to add support for including v, r, and s... - /* - if (!transaction.raw) { - - // Very loose providers (e.g. TestRPC) do not provide a signature or raw - if (transaction.v && transaction.r && transaction.s) { - let raw = [ - stripZeros(hexlify(transaction.nonce)), - stripZeros(hexlify(transaction.gasPrice)), - stripZeros(hexlify(transaction.gasLimit)), - (transaction.to || "0x"), - stripZeros(hexlify(transaction.value || "0x")), - hexlify(transaction.data || "0x"), - stripZeros(hexlify(transaction.v || "0x")), - stripZeros(hexlify(transaction.r)), - stripZeros(hexlify(transaction.s)), - ]; - - transaction.raw = rlpEncode(raw); - } + if (transaction.type === 1 && transaction.accessList == null) { + transaction.accessList = [ ]; } - */ const result: TransactionResponse = Formatter.check(this.formats.transaction, transaction); diff --git a/packages/providers/src.ts/json-rpc-provider.ts b/packages/providers/src.ts/json-rpc-provider.ts index d606e8434..512518d10 100644 --- a/packages/providers/src.ts/json-rpc-provider.ts +++ b/packages/providers/src.ts/json-rpc-provider.ts @@ -10,6 +10,7 @@ import { _TypedDataEncoder } from "@ethersproject/hash"; import { Network, Networkish } from "@ethersproject/networks"; import { checkProperties, deepCopy, Deferrable, defineReadOnly, getStatic, resolveProperties, shallowCopy } from "@ethersproject/properties"; import { toUtf8Bytes } from "@ethersproject/strings"; +import { AccessList, accessListify } from "@ethersproject/transactions"; import { ConnectionInfo, fetchJson, poll } from "@ethersproject/web"; import { Logger } from "@ethersproject/logger"; @@ -264,7 +265,8 @@ class UncheckedJsonRpcSigner extends JsonRpcSigner { } const allowedTransactionKeys: { [ key: string ]: boolean } = { - chainId: true, data: true, gasLimit: true, gasPrice:true, nonce: true, to: true, value: true + chainId: true, data: true, gasLimit: true, gasPrice:true, nonce: true, to: true, value: true, + type: true, accessList: true } export class JsonRpcProvider extends BaseProvider { @@ -529,7 +531,7 @@ export class JsonRpcProvider extends BaseProvider { // before this is called // @TODO: This will likely be removed in future versions and prepareRequest // will be the preferred method for this. - static hexlifyTransaction(transaction: TransactionRequest, allowExtra?: { [key: string]: boolean }): { [key: string]: string } { + static hexlifyTransaction(transaction: TransactionRequest, allowExtra?: { [key: string]: boolean }): { [key: string]: string | AccessList } { // Check only allowed properties are given const allowed = shallowCopy(allowedTransactionKeys); if (allowExtra) { @@ -537,12 +539,13 @@ export class JsonRpcProvider extends BaseProvider { if (allowExtra[key]) { allowed[key] = true; } } } + checkProperties(transaction, allowed); - const result: { [key: string]: string } = {}; + const result: { [key: string]: string | AccessList } = {}; // Some nodes (INFURA ropsten; INFURA mainnet is fine) do not like leading zeros. - ["gasLimit", "gasPrice", "nonce", "value"].forEach(function(key) { + ["gasLimit", "gasPrice", "type", "nonce", "value"].forEach(function(key) { if ((transaction)[key] == null) { return; } const value = hexValue((transaction)[key]); if (key === "gasLimit") { key = "gas"; } @@ -554,6 +557,10 @@ export class JsonRpcProvider extends BaseProvider { result[key] = hexlify((transaction)[key]); }); + if ((transaction).accessList) { + result["accessList"] = accessListify((transaction).accessList); + } + return result; } } diff --git a/packages/tests/src.ts/test-providers.ts b/packages/tests/src.ts/test-providers.ts index ffdc84f2a..ae17bad39 100644 --- a/packages/tests/src.ts/test-providers.ts +++ b/packages/tests/src.ts/test-providers.ts @@ -247,6 +247,58 @@ const blockchainData: { [ network: string ]: TestCases } = { }, ], transactions: [ + { + hash: "0x48bff7b0e603200118a672f7c622ab7d555a28f98938edb8318803eed7ea7395", + type: 1, + accessList: [ + { + address: "0x0000000000000000000000000000000000000000", + storageKeys: [] + } + ], + blockHash: "0x378e24bcd568bd24cf1f54d38f13f038ee28d89e82af4f2a0d79c1f88dcd8aac", + blockNumber: 9812343, + from: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + gasPrice: bnify("0x65cf89a0"), + gasLimit: bnify("0x5b68"), + to: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + value: bnify("0"), + nonce: 13, + data: "0x", + r: "0x9659cba42376dbea1433cd6afc9c8ffa38dbeff5408ffdca0ebde6207281a3ec", + s: "0x27efbab3e6ed30b088ce0a50533364778e101c9e52acf318daec131da64e7758", + v: 0, + creates: null, + chainId: 3 + }, + { + hash: "0x1675a417e728fd3562d628d06955ef35b913573d9e417eb4e6a209998499c9d3", + type: 1, + accessList: [ + { + address: "0x0000000000000000000000000000000000000000", + storageKeys: [ + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "0x0000000000111111111122222222223333333333444444444455555555556666", + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + ] + } + ], + blockHash: "0x7565688256f5801768237993b47ca0608796b3ace0c4b8b6e623c6092bef14b8", + blockNumber: 9812365, + from: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + gasPrice: bnify("0x65cf89a0"), + gasLimit: bnify("0x71ac"), + to: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + value: bnify("0"), + nonce: 14, + data: "0x", + r: "0xb0646756f89817d70cdb40aa2ae8b5f43ef65d0926dcf71a7dca5280c93763df", + s: "0x4d32dbd9a44a2c5639b8434b823938202f75b0a8459f3fcd9f37b2495b7a66a6", + v: 0, + creates: null, + chainId: 3 + } ], transactionReceipts: [ { @@ -630,6 +682,9 @@ Object.keys(blockchainData).forEach((network) => { tests.transactions.forEach((test) => { addObjectTest(`fetches transaction ${ test.hash }`, async (provider: ethers.providers.Provider) => { const tx = await provider.getTransaction(test.hash); +//console.log("TX"); +//console.dir(test, { depth: null }) +//console.dir(tx, { depth: null }) // This changes with every block assert.equal(typeof(tx.confirmations), "number", "confirmations is a number"); @@ -640,7 +695,12 @@ Object.keys(blockchainData).forEach((network) => { return tx; }, test, (provider: string, network: string, test: TestDescription) => { - return (provider === "EtherscanProvider"); + if (network === "ropsten" && (provider === "AlchemyProvider" || provider === "PocketProvider")) { + console.log(`Skipping ${ provider }; incomplete Berlin support`); + return true; + } + + return false; //(provider === "EtherscanProvider"); }); }); @@ -721,6 +781,9 @@ testFunctions.push({ extras: [ "funding" ], // We need funding to the funWallet timeout: 300, // 5 minutes networks: [ "ropsten" ], // Only test on Ropsten + checkSkip: (provider: string, network: string, test: TestDescription) => { + return (provider === "PocketProvider"); + }, execute: async (provider: ethers.providers.Provider) => { const wallet = fundWallet.connect(provider); @@ -741,6 +804,41 @@ testFunctions.push({ } }); +testFunctions.push({ + name: "sends an EIP-2930 transaction", + extras: [ "funding" ], // We need funding to the funWallet + timeout: 300, // 5 minutes + networks: [ "ropsten" ], // Only test on Ropsten + checkSkip: (provider: string, network: string, test: TestDescription) => { + return (provider === "PocketProvider" || provider === "EtherscanProvider" || provider === "AlchemyProvider"); + }, + execute: async (provider: ethers.providers.Provider) => { + const wallet = fundWallet.connect(provider); + + const addr = "0x8210357f377E901f18E45294e86a2A32215Cc3C9"; + + const b0 = await provider.getBalance(wallet.address); + assert.ok(b0.gt(ethers.constants.Zero), "balance is non-zero"); + + const tx = await wallet.sendTransaction({ + type: 1, + accessList: { + "0x8ba1f109551bD432803012645Ac136ddd64DBA72": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000042", + ] + }, + to: addr, + value: 123 + }); + + await tx.wait(); + + const b1 = await provider.getBalance(wallet.address); + assert.ok(b0.gt(b1), "balance is decreased"); + } +}); + describe("Test Provider Methods", function() { let fundReceipt: Promise = null; const faucet = "0x8210357f377E901f18E45294e86a2A32215Cc3C9"; @@ -821,6 +919,9 @@ describe("Test Provider Methods", function() { } catch (attemptError) { console.log(`*** Failed attempt ${ attempt + 1 }: ${ attemptError.message }`); error = attemptError; + + // On failure, wait 5s + await waiter(5000); } } throw error; diff --git a/packages/tests/src.ts/test-utils.ts b/packages/tests/src.ts/test-utils.ts index d6641db56..fe25bcf97 100644 --- a/packages/tests/src.ts/test-utils.ts +++ b/packages/tests/src.ts/test-utils.ts @@ -787,3 +787,128 @@ describe("EIP-712", function() { }); }); +/* +type EIP2930Test = { + hash: string, + data: +}; +*/ + +function _deepEquals(a: any, b: any, path: string): string { + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return `{ path }:!isArray(b)`; + } + if (a.length !== b.length) { + return `{ path }:a.length[${ a.length }]!=b.length[${ b.length }]`; + } + for (let i = 0; i < a.length; i++) { + const reason = _deepEquals(a[i], b[i], `${ path }:${ i }`); + if (reason != null) { return reason; } + } + return null; + } + + if (a.eq) { + if (!b.eq) { return `${ path }:typeof(b)!=BigNumber`; } + return a.eq(b) ? null: `${ path }:!a.eq(b)`; + } + + if (a != null && typeof(a) === "object") { + if (b != null && typeof(b) !== "object") { return `${ path }:typeof(b)!=object`; } + const keys = Object.keys(a), otherKeys = Object.keys(b); + keys.sort(); + otherKeys.sort(); + if (keys.length !== otherKeys.length) { return `${ path }:keys(a)[${ keys.join(",") }]!=keys(b)[${ otherKeys.join(",") }]`; } + for (const key in a) { + const reason = _deepEquals(a[key], b[key], `${ path }:${ key }`); + if (reason != null) { return reason; } + } + return null; + } + + if (a !== b) { return `${ path }[${ a } != ${ b }]`; } + + return null; +} + +function deepEquals(a: any, b: any): string { + return _deepEquals(a, b, ""); +} + +describe("EIP-2930", function() { + + const Tests = [ + { + hash: "0x48bff7b0e603200118a672f7c622ab7d555a28f98938edb8318803eed7ea7395", + data: "0x01f87c030d8465cf89a0825b689432162f3581e88a5f62e8a61892b42c46e2c18f7b8080d7d6940000000000000000000000000000000000000000c080a09659cba42376dbea1433cd6afc9c8ffa38dbeff5408ffdca0ebde6207281a3eca027efbab3e6ed30b088ce0a50533364778e101c9e52acf318daec131da64e7758", + preimage: "0x01f839030d8465cf89a0825b689432162f3581e88a5f62e8a61892b42c46e2c18f7b8080d7d6940000000000000000000000000000000000000000c0", + tx: { + hash: "0x48bff7b0e603200118a672f7c622ab7d555a28f98938edb8318803eed7ea7395", + type: 1, + chainId: 3, + nonce: 13, + gasPrice: ethers.BigNumber.from("0x65cf89a0"), + gasLimit: ethers.BigNumber.from("0x5b68"), + to: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + value: ethers.BigNumber.from("0"), + data: "0x", + accessList: [ + { + address: "0x0000000000000000000000000000000000000000", + storageKeys: [] + } + ], + v: 0, + r: "0x9659cba42376dbea1433cd6afc9c8ffa38dbeff5408ffdca0ebde6207281a3ec", + s: "0x27efbab3e6ed30b088ce0a50533364778e101c9e52acf318daec131da64e7758", + from: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + } + }, + { + hash: "0x1675a417e728fd3562d628d06955ef35b913573d9e417eb4e6a209998499c9d3", + data: "0x01f8e2030e8465cf89a08271ac9432162f3581e88a5f62e8a61892b42c46e2c18f7b8080f87cf87a940000000000000000000000000000000000000000f863a0deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefa00000000000111111111122222222223333333333444444444455555555556666a0deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef80a0b0646756f89817d70cdb40aa2ae8b5f43ef65d0926dcf71a7dca5280c93763dfa04d32dbd9a44a2c5639b8434b823938202f75b0a8459f3fcd9f37b2495b7a66a6", + preimage: "0x01f89f030e8465cf89a08271ac9432162f3581e88a5f62e8a61892b42c46e2c18f7b8080f87cf87a940000000000000000000000000000000000000000f863a0deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefa00000000000111111111122222222223333333333444444444455555555556666a0deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + tx: { + hash: "0x1675a417e728fd3562d628d06955ef35b913573d9e417eb4e6a209998499c9d3", + type: 1, + chainId: 3, + nonce: 14, + gasPrice: ethers.BigNumber.from("0x65cf89a0"), + gasLimit: ethers.BigNumber.from("0x71ac"), + to: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + value: ethers.BigNumber.from("0"), + data: "0x", + accessList: [ + { + address: "0x0000000000000000000000000000000000000000", + storageKeys: [ + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "0x0000000000111111111122222222223333333333444444444455555555556666", + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + ] + } + ], + v: 0, + r: "0xb0646756f89817d70cdb40aa2ae8b5f43ef65d0926dcf71a7dca5280c93763df", + s: "0x4d32dbd9a44a2c5639b8434b823938202f75b0a8459f3fcd9f37b2495b7a66a6", + from: "0x32162F3581E88a5f62e8A61892B42C46E2c18f7b", + } + }, + ]; + + Tests.forEach((test) => { + it(`tx:${ test.hash }`, function() { + const tx = ethers.utils.parseTransaction(test.data); + assert.equal(tx.hash, test.hash); + const reason = deepEquals(tx, test.tx); + assert.ok(reason == null, reason); + + const preimageData = ethers.utils.serializeTransaction((test.tx)); + assert.equal(preimageData, test.preimage); + + const data = ethers.utils.serializeTransaction((test.tx), test.tx); + assert.equal(data, test.data); + }); + }); +}); diff --git a/packages/transactions/src.ts/index.ts b/packages/transactions/src.ts/index.ts index e5da08297..f3e355651 100644 --- a/packages/transactions/src.ts/index.ts +++ b/packages/transactions/src.ts/index.ts @@ -2,7 +2,7 @@ import { getAddress } from "@ethersproject/address"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; -import { arrayify, BytesLike, DataOptions, hexDataSlice, hexlify, hexZeroPad, isBytesLike, SignatureLike, splitSignature, stripZeros, } from "@ethersproject/bytes"; +import { arrayify, BytesLike, DataOptions, hexConcat, hexDataLength, hexDataSlice, hexlify, hexZeroPad, isBytesLike, SignatureLike, splitSignature, stripZeros, } from "@ethersproject/bytes"; import { Zero } from "@ethersproject/constants"; import { keccak256 } from "@ethersproject/keccak256"; import { checkProperties } from "@ethersproject/properties"; @@ -16,6 +16,13 @@ const logger = new Logger(version); /////////////////////////////// // Exported Types +export type AccessList = Array<{ address: string, storageKeys: Array }>; + +// Input allows flexibility in describing an access list +export type AccessListish = AccessList | + Array<[ string, Array ]> | + Record>; + export type UnsignedTransaction = { to?: string; nonce?: number; @@ -26,11 +33,14 @@ export type UnsignedTransaction = { data?: BytesLike; value?: BigNumberish; chainId?: number; + + // Typed-Transaction features + type?: number | null; + accessList?: AccessListish; } export interface Transaction { hash?: string; - type?: number | null; to?: string; from?: string; @@ -46,6 +56,12 @@ export interface Transaction { r?: string; s?: string; v?: number; + + // Typed-Transaction features + type?: number | null; + + // EIP-2930; Type 1 + accessList?: AccessList; } /////////////////////////////// @@ -60,6 +76,7 @@ function handleNumber(value: string): BigNumber { return BigNumber.from(value); } +// Legacy Transaction Fields const transactionFields = [ { name: "nonce", maxLength: 32, numeric: true }, { name: "gasPrice", maxLength: 32, numeric: true }, @@ -82,8 +99,78 @@ export function recoverAddress(digest: BytesLike, signature: SignatureLike): str return computeAddress(recoverPublicKey(arrayify(digest), signature)); } +function formatNumber(value: BigNumberish, name: string): Uint8Array { + const result = stripZeros(BigNumber.from(value).toHexString()); + if (result.length > 32) { + logger.throwArgumentError("invalid length for " + name, ("transaction:" + name), value); + } + return result; +} -export function serialize(transaction: UnsignedTransaction, signature?: SignatureLike): string { +function accessSetify(addr: string, storageKeys: Array): { address: string,storageKeys: Array } { + return { + address: getAddress(addr), + storageKeys: (storageKeys || []).map((storageKey, index) => { + if (hexDataLength(storageKey) !== 32) { + logger.throwArgumentError("invalid access list storageKey", `accessList[${ addr }:${ index }]`, storageKey) + } + return storageKey.toLowerCase(); + }) + }; +} + +export function accessListify(value: AccessListish): AccessList { + if (Array.isArray(value)) { + return (] | { address: string, storageKeys: Array}>>value).map((set, index) => { + if (Array.isArray(set)) { + if (set.length > 2) { + logger.throwArgumentError("access list expected to be [ address, storageKeys[] ]", `value[${ index }]`, set); + } + return accessSetify(set[0], set[1]) + } + return accessSetify(set.address, set.storageKeys); + }); + } + + const result: Array<{ address: string, storageKeys: Array }> = Object.keys(value).map((addr) => { + const storageKeys: Record = value[addr].reduce((accum, storageKey) => { + accum[storageKey] = true; + return accum; + }, >{ }); + return accessSetify(addr, Object.keys(storageKeys).sort()) + }); + result.sort((a, b) => (a.address.localeCompare(b.address))); + return result; +} + +function formatAccessList(value: AccessListish): Array<[ string, Array ]> { + return accessListify(value).map((set) => [ set.address, set.storageKeys ]); +} + +function _serializeEip2930(transaction: UnsignedTransaction, signature?: SignatureLike): string { + const fields: any = [ + formatNumber(transaction.chainId || 0, "chainId"), + formatNumber(transaction.nonce || 0, "nonce"), + formatNumber(transaction.gasPrice || 0, "gasPrice"), + 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([ "0x01", RLP.encode(fields)]); +} + +// Legacy Transactions and EIP-155 +function _serialize(transaction: UnsignedTransaction, signature?: SignatureLike): string { checkProperties(transaction, allowedTransactionKeys); const raw: Array = []; @@ -163,6 +250,69 @@ export function serialize(transaction: UnsignedTransaction, signature?: Signatur return RLP.encode(raw); } +export function serialize(transaction: UnsignedTransaction, signature?: SignatureLike): string { + // Legacy and EIP-155 Transactions + if (transaction.type == null) { return _serialize(transaction, signature); } + + // Typed Transactions (EIP-2718) + switch (transaction.type) { + case 1: + return _serializeEip2930(transaction, signature); + default: + break; + } + + return logger.throwError(`unsupported transaction type: ${ transaction.type }`, Logger.errors.UNSUPPORTED_OPERATION, { + operation: "serializeTransaction", + transactionType: transaction.type + }); +} + +function _parseEip2930(payload: Uint8Array): Transaction { + const transaction = RLP.decode(payload.slice(1)); + + if (transaction.length !== 8 && transaction.length !== 11) { + logger.throwArgumentError("invalid component count for transaction type: 1", "payload", hexlify(payload)); + } + + const tx: Transaction = { + type: 1, + chainId: handleNumber(transaction[0]).toNumber(), + nonce: handleNumber(transaction[1]).toNumber(), + gasPrice: handleNumber(transaction[2]), + gasLimit: handleNumber(transaction[3]), + to: handleAddress(transaction[4]), + value: handleNumber(transaction[5]), + data: transaction[6], + accessList: accessListify(transaction[7]), + }; + + // 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); + + return tx; +} + +// Legacy Transactions and EIP-155 function _parse(rawTransaction: Uint8Array): Transaction { const transaction = RLP.decode(rawTransaction); @@ -231,9 +381,21 @@ function _parse(rawTransaction: Uint8Array): Transaction { return tx; } + export function parse(rawTransaction: BytesLike): Transaction { const payload = arrayify(rawTransaction); + + // Legacy and EIP-155 Transactions if (payload[0] > 0x7f) { return _parse(payload); } + + // Typed Transaction (EIP-2718) + switch (payload[0]) { + case 1: + return _parseEip2930(payload); + default: + break; + } + return logger.throwError(`unsupported transaction type: ${ payload[0] }`, Logger.errors.UNSUPPORTED_OPERATION, { operation: "parseTransaction", transactionType: payload[0]