diff --git a/src/chains/ethereum/ethereum/src/blockchain.ts b/src/chains/ethereum/ethereum/src/blockchain.ts index 7ee3f5b76a..b800041589 100644 --- a/src/chains/ethereum/ethereum/src/blockchain.ts +++ b/src/chains/ethereum/ethereum/src/blockchain.ts @@ -50,7 +50,8 @@ import { calculateIntrinsicGas, InternalTransactionReceipt, VmTransaction, - TypedTransaction + TypedTransaction, + serializeForDb } from "@ganache/ethereum-transaction"; import { Block, RuntimeBlock, Snapshots } from "@ganache/ethereum-block"; import { @@ -442,7 +443,7 @@ export default class Blockchain extends Emittery { // TODO: the block has already done most of the work serializing the tx // we should reuse it, if possible // https://github.com/trufflesuite/ganache/issues/4341 - const serialized = tx.serializeForDb(blockHash, blockNumberQ, index); + const serialized = serializeForDb(tx, blockHash, blockNumberQ, index); this.transactions.set(hash, serialized); // save receipt to the database @@ -1113,10 +1114,7 @@ export default class Blockchain extends Emittery { const to = hasToAddress ? new Address(transaction.to.toBuffer()) : null; const common = this.fallback - ? this.fallback.getCommonForBlockNumber( - this.common, - BigInt(transaction.block.header.number.toString()) - ) + ? this.fallback.getCommonForBlock(this.common, transaction.block.header) : this.common; const gasLeft = @@ -1250,10 +1248,7 @@ export default class Blockchain extends Emittery { } as any; const common = this.fallback - ? this.fallback.getCommonForBlockNumber( - this.common, - BigInt(block.header.number.toString()) - ) + ? this.fallback.getCommonForBlock(this.common, block.header) : this.common; // TODO: prefixCodeHashes should eventually be conditional diff --git a/src/chains/ethereum/ethereum/src/data-managers/block-manager.ts b/src/chains/ethereum/ethereum/src/data-managers/block-manager.ts index c2b0665898..1a7e63607b 100644 --- a/src/chains/ethereum/ethereum/src/data-managers/block-manager.ts +++ b/src/chains/ethereum/ethereum/src/data-managers/block-manager.ts @@ -8,6 +8,7 @@ import { BlockRawTransaction, EthereumRawBlock, EthereumRawBlockHeader, + GanacheRawBlock, Head, serialize, WithdrawalRaw @@ -21,7 +22,7 @@ import { } from "@ganache/ethereum-transaction"; import { GanacheLevelUp } from "../database"; import { Ethereum } from "../api-types"; -import { encode } from "@ganache/rlp"; +import { decode, encode } from "@ganache/rlp"; const LATEST_INDEX_KEY = BUFFER_ZERO; @@ -171,10 +172,10 @@ export default class BlockManager extends Manager { if (json == null) { return null; } else { - const common = fallback.getCommonForBlockNumber( - this.#common, - BigInt(json.number) - ); + const common = fallback.getCommonForBlock(this.#common, { + number: BigInt(json.number), + timestamp: BigInt(json.timestamp) + }); return BlockManager.rawFromJSON(json, common); } @@ -226,12 +227,12 @@ export default class BlockManager extends Manager { true ]); if (json) { - const blockNumber = BigInt(json.number); - if (blockNumber <= fallback.blockNumber.toBigInt()) { - const common = fallback.getCommonForBlockNumber( - this.#common, - blockNumber - ); + const number = BigInt(json.number); + if (number <= fallback.blockNumber.toBigInt()) { + const common = fallback.getCommonForBlock(this.#common, { + number, + timestamp: BigInt(json.timestamp) + }); return new Block(BlockManager.rawFromJSON(json, common), common); } } @@ -272,9 +273,14 @@ export default class BlockManager extends Manager { if (fallback) { const block = await this.fromFallback(blockNumber); if (block) { + const header: EthereumRawBlockHeader = + decode(block)[0]; return new Block( block, - fallback.getCommonForBlockNumber(common, blockNumber.toBigInt()) + fallback.getCommonForBlock(common, { + number: blockNumber.toBigInt(), + timestamp: Quantity.toBigInt(header[11]) + }) ); } } @@ -319,10 +325,10 @@ export default class BlockManager extends Manager { { disableCache: true } ); if (json) { - const common = fallback.getCommonForBlockNumber( - this.#common, - BigInt(json.number) - ); + const common = fallback.getCommonForBlock(this.#common, { + number: BigInt(json.number), + timestamp: BigInt(json.timestamp) + }); return new Block(BlockManager.rawFromJSON(json, common), common); } } else { diff --git a/src/chains/ethereum/ethereum/src/data-managers/transaction-manager.ts b/src/chains/ethereum/ethereum/src/data-managers/transaction-manager.ts index 0bea334ca7..11cca8e784 100644 --- a/src/chains/ethereum/ethereum/src/data-managers/transaction-manager.ts +++ b/src/chains/ethereum/ethereum/src/data-managers/transaction-manager.ts @@ -5,12 +5,11 @@ import Blockchain from "../blockchain"; import PromiseQueue from "@ganache/promise-queue"; import type { Common } from "@ethereumjs/common"; import { Data, Quantity } from "@ganache/utils"; -import { Address } from "@ganache/ethereum-address"; import { - GanacheRawExtraTx, TransactionFactory, Transaction, - TypedTransaction + TypedTransaction, + serializeRpcForDb } from "@ganache/ethereum-transaction"; import { GanacheLevelUp } from "../database"; @@ -57,20 +56,7 @@ export default class TransactionManager extends Manager { // fallback's blocknumber because it doesn't exist in our local chain. if (!fallback.isValidForkBlockNumber(blockNumber)) return null; - const extra: GanacheRawExtraTx = [ - Address.toBuffer(tx.from), - Data.toBuffer((tx as any).hash, 32), - blockHash.toBuffer(), - blockNumber.toBuffer(), - index.toBuffer(), - Quantity.toBuffer(tx.gasPrice) - ]; - const common = fallback.getCommonForBlockNumber( - fallback.common, - blockNumber.toBigInt() - ); - const runTx = TransactionFactory.fromRpc(tx, common, extra); - return runTx.serializeForDb(blockHash, blockNumber, index); + return serializeRpcForDb(tx, blockHash, blockNumber, index); }; public async getRaw(transactionHash: Buffer): Promise { diff --git a/src/chains/ethereum/ethereum/src/forking/fork.ts b/src/chains/ethereum/ethereum/src/forking/fork.ts index 054beeca29..9f116f8cea 100644 --- a/src/chains/ethereum/ethereum/src/forking/fork.ts +++ b/src/chains/ethereum/ethereum/src/forking/fork.ts @@ -226,10 +226,11 @@ export class Fork { cacheProm, this.#setCommonFromChain(chainIdPromise) ]); - const common = this.getCommonForBlockNumber( - this.common, - this.blockNumber.toBigInt() - ); + + const common = this.getCommonForBlock(this.common, { + timestamp: BigInt(block.timestamp), + number: BigInt(block.number) + }); this.block = new Block(BlockManager.rawFromJSON(block, common), common); if (!chainOptions.time && minerOptions.timestampIncrement !== "clock") { chainOptions.time = new Date( @@ -238,6 +239,7 @@ export class Fork { 1000 ); } + if (cache) await this.initCache(cache); } private async initCache(cache: PersistentCache) { @@ -278,15 +280,20 @@ export class Fork { /** * If the `blockNumber` is before our `fork.blockNumber`, return a `Common` * instance, applying the rules from the remote chain's `common` via its - * original `chainId`. If the remote chain's `chainId` is now "known", return - * a `Common` with our local `common`'s rules applied, but with the remote - * chain's `chainId`. If the block is greater than or equal to our - * `fork.blockNumber` return `common`. + * original `chainId` (hardforks are applied if they are scheduled on the + * given chain on or after the blocknumber or timestamp of the given `block`). + * If the remote chain's `chainId` is not "known", return a `Common` with our + * local `common`'s rules applied, but with the remote chain's `chainId`. If + * the block is greater than or equal to our `fork.blockNumber` return + * `common`. * @param common - * @param blockNumber - */ - public getCommonForBlockNumber(common: Common, blockNumber: BigInt) { - if (blockNumber <= this.blockNumber.toBigInt()) { + public getCommonForBlock( + common: Common, + block: { number: bigint; timestamp: bigint } + ): Common { + if (block.number <= this.blockNumber.toBigInt()) { // we are at or before our fork block let forkCommon: Common; @@ -295,13 +302,22 @@ export class Fork { let hardfork; // hardforks are iterated from earliest to latest for (const hf of common.hardforks()) { - if (hf.block === null) continue; - if (blockNumber >= BigInt(hf.block)) { - hardfork = hf.name; - } else { - break; + if (hf.timestamp) { + const hfTimestamp = BigInt(hf.timestamp); + if (block.timestamp >= hfTimestamp) { + hardfork = hf.name; + } else { + break; + } + } else if (hf.block) { + if (block.number >= BigInt(hf.block)) { + hardfork = hf.name; + } else { + break; + } } } + forkCommon = new Common({ chain: this.chainId, hardfork }); } else { // we don't know about this chain or hardfork, so just carry on per usual, diff --git a/src/chains/ethereum/ethereum/tests/forking/fork.test.ts b/src/chains/ethereum/ethereum/tests/forking/fork.test.ts new file mode 100644 index 0000000000..009704bf0f --- /dev/null +++ b/src/chains/ethereum/ethereum/tests/forking/fork.test.ts @@ -0,0 +1,130 @@ +import { EthereumOptionsConfig } from "@ganache/ethereum-options"; +import { Fork } from "../../src/forking/fork"; +import { KNOWN_CHAINIDS, Quantity } from "@ganache/utils"; +import { Common } from "@ethereumjs/common/dist/common"; +import ganache from "../../../../../packages/core"; +import Server from "../../../../../packages/core/lib/src/server"; +import assert from "assert"; +import { logging } from "./helpers"; + +describe("Fork", () => { + const port = 9999; + const networkId = 1; + const accounts = []; + const forkOptions = { + fork: { + url: `http://localhost:${port}` + }, + logging + }; + + let remoteServer: Server; + let fork: Fork; + + before(async () => { + remoteServer = ganache.server({ + wallet: { deterministic: true }, + chain: { networkId: networkId }, + logging + }); + await remoteServer.listen(port); + }); + + beforeEach(async () => { + const providerOptions = EthereumOptionsConfig.normalize(forkOptions); + fork = new Fork(providerOptions, accounts); + await fork.initialize(); + }); + + afterEach(async () => { + await fork.close(); + }); + + after(async () => { + await remoteServer.close(); + }); + + describe("getCommonForBlock()", () => { + it("should return a Common for known chainIds", () => { + KNOWN_CHAINIDS.forEach(chainId => { + if (chainId === 42) { + // Skip kovan, because it is no longer supported by ethereumjs. To be + // removed in https://github.com/trufflesuite/ganache/issues/4461 + } else { + assert.doesNotThrow(() => { + const parentCommon = new Common({ chain: chainId }); + + fork.getCommonForBlock(parentCommon, { + number: 0n, + timestamp: 0n + }); + }); + } + }); + }); + + it("should resolve the correct hardfork based on block number for known chainId", () => { + const mainnet = 1; + const mergeBlocknumber = 15537394n; + + // ensure that the "fork" blockNumber is after the "merge" hardfork blockNumber + fork.blockNumber = Quantity.from(mergeBlocknumber + 100n); + fork.chainId = mainnet; + + const parentCommon = new Common({ chain: mainnet }); + const blocknumberToHardfork: [bigint, string][] = [ + [mergeBlocknumber - 1n, "grayGlacier"], + [mergeBlocknumber, "merge"], + [mergeBlocknumber + 1n, "merge"] + ]; + + blocknumberToHardfork.forEach(([number, expectedHardfork]) => { + const common = fork.getCommonForBlock(parentCommon, { + number, + timestamp: 0n + }); + + const hf = common.hardfork(); + + assert.strictEqual( + hf, + expectedHardfork, + `Unexpected hardfork with blocknumber: ${number}` + ); + }); + }); + + it("should resolve the correct hardfork based on timestamp for known chainId", () => { + // we use sepolia because it has shanghai hf scheduled + const sepolia = 11155111; + const shanghaiTimestamp = 1677557088n; + const mergeForkIdTransitionBlockNumber = 1735371n; + + // ensure that the "fork" blockNumber is after the "mergeForkIdTransition" hardfork blockNumber + fork.blockNumber = Quantity.from(mergeForkIdTransitionBlockNumber + 100n); + fork.chainId = sepolia; + + const timstampToHardfork: [bigint, string][] = [ + [shanghaiTimestamp - 1n, "mergeForkIdTransition"], + [shanghaiTimestamp, "shanghai"], + [shanghaiTimestamp + 1n, "shanghai"] + ]; + + const parentCommon = new Common({ chain: sepolia }); + timstampToHardfork.forEach(([timestamp, expectedHardfork]) => { + const common = fork.getCommonForBlock(parentCommon, { + number: mergeForkIdTransitionBlockNumber, + timestamp + }); + + const hf = common.hardfork(); + + assert.strictEqual( + hf, + expectedHardfork, + `Unexpected hardfork with timestamp: ${timestamp}` + ); + }); + }); + }); +}); diff --git a/src/chains/ethereum/transaction/index.ts b/src/chains/ethereum/transaction/index.ts index 212d63b5c9..a1735e115d 100644 --- a/src/chains/ethereum/transaction/index.ts +++ b/src/chains/ethereum/transaction/index.ts @@ -19,3 +19,4 @@ export * from "./src/transaction-receipt"; export * from "./src/transaction-factory"; export * from "./src/transaction-types"; export * from "./src/vm-transaction"; +export * from "./src/transaction-serialization"; diff --git a/src/chains/ethereum/transaction/src/eip1559-fee-market-transaction.ts b/src/chains/ethereum/transaction/src/eip1559-fee-market-transaction.ts index 31f111edfb..3aa6e1c04d 100644 --- a/src/chains/ethereum/transaction/src/eip1559-fee-market-transaction.ts +++ b/src/chains/ethereum/transaction/src/eip1559-fee-market-transaction.ts @@ -241,6 +241,7 @@ export class EIP1559FeeMarketTransaction extends RuntimeTransaction { const maxPriorityFeePerGas = this.maxPriorityFeePerGas.toBigInt(); const a = maxFeePerGas - baseFeePerGas; const tip = a < maxPriorityFeePerGas ? a : maxPriorityFeePerGas; + this.effectiveGasPrice = Quantity.from(baseFeePerGas + tip); } } diff --git a/src/chains/ethereum/transaction/src/rpc-transaction.ts b/src/chains/ethereum/transaction/src/rpc-transaction.ts index e5e933be19..1fee7d2c27 100644 --- a/src/chains/ethereum/transaction/src/rpc-transaction.ts +++ b/src/chains/ethereum/transaction/src/rpc-transaction.ts @@ -26,6 +26,14 @@ export type Transaction = | EIP2930AccessListRpcTransaction | EIP1559FeeMarketRpcTransaction; +export enum TransactionType { + Legacy = 0x0, + EIP2930AccessList = 0x1, + //todo: this should be EIP1559FeeMarket + //https://github.com/trufflesuite/ganache/issues/4462 + EIP1559AccessList = 0x2 +} + export type CallTransaction = Omit & { from?: string }; export type LegacyRpcTransaction = Readonly & { diff --git a/src/chains/ethereum/transaction/src/runtime-transaction.ts b/src/chains/ethereum/transaction/src/runtime-transaction.ts index 88ba7a7a6d..b9c19789b4 100644 --- a/src/chains/ethereum/transaction/src/runtime-transaction.ts +++ b/src/chains/ethereum/transaction/src/runtime-transaction.ts @@ -8,11 +8,9 @@ import { Transaction } from "./rpc-transaction"; import type { Common } from "@ethereumjs/common"; import { GanacheRawExtraTx, TypedRawTransaction } from "./raw"; import type { RunTxResult } from "@ethereumjs/vm"; -import { EncodedPart, encode } from "@ganache/rlp"; import { BaseTransaction } from "./base-transaction"; import { InternalTransactionReceipt } from "./transaction-receipt"; import { Address } from "@ganache/ethereum-address"; -import { encodeWithPrefix } from "./signing"; export const toValidLengthAddress = (address: string, fieldName: string) => { const buffer = Data.toBuffer(address); @@ -98,31 +96,6 @@ export abstract class RuntimeTransaction extends BaseTransaction { */ protected abstract signAndHash(privateKey: Buffer); - public serializeForDb( - blockHash: Data, - blockNumber: Quantity, - transactionIndex: Quantity - ): Buffer { - const legacy = this.raw.length === 9; - // todo(perf):make this work with encodeRange and digest - const txAndExtraData: [TypedRawTransaction, GanacheRawExtraTx] = [ - // todo: this is encoded differently in the tx table than it is in the - // block table. we should migrate the tx table to use the same format as - // the block (`Buffer.concat([type, encode(raw)])`) so that we can avoid - // block it twice for each block save step. - legacy ? this.raw : ([this.type.toBuffer(), ...this.raw] as any), - [ - this.from.toBuffer(), - this.hash.toBuffer(), - blockHash.toBuffer(), - blockNumber.toBuffer(), - transactionIndex.toBuffer(), - this.effectiveGasPrice.toBuffer() - ] - ]; - return encode(txAndExtraData); - } - abstract toJSON(common: Common); /** diff --git a/src/chains/ethereum/transaction/src/transaction-factory.ts b/src/chains/ethereum/transaction/src/transaction-factory.ts index c65908acf7..0ad756a280 100644 --- a/src/chains/ethereum/transaction/src/transaction-factory.ts +++ b/src/chains/ethereum/transaction/src/transaction-factory.ts @@ -5,7 +5,8 @@ import { EIP2930AccessListTransaction } from "./eip2930-access-list-transaction" import { EIP1559FeeMarketRpcTransaction, EIP2930AccessListRpcTransaction, - Transaction + Transaction, + TransactionType } from "./rpc-transaction"; import { EIP1559FeeMarketRawTransaction, @@ -42,12 +43,6 @@ function assertValidTransactionSValue(common: Common, tx: TypedTransaction) { } } -export enum TransactionType { - Legacy = 0x0, - EIP2930AccessList = 0x1, - EIP1559AccessList = 0x2 -} - export class TransactionFactory { public tx: TypedTransaction; constructor(raw: Buffer, common: Common) { @@ -180,6 +175,7 @@ export class TransactionFactory { JsonRpcErrorCode.METHOD_NOT_FOUND ); } + /** * Create a transaction from a `txData` object * diff --git a/src/chains/ethereum/transaction/src/transaction-serialization.ts b/src/chains/ethereum/transaction/src/transaction-serialization.ts new file mode 100644 index 0000000000..c137c4e999 --- /dev/null +++ b/src/chains/ethereum/transaction/src/transaction-serialization.ts @@ -0,0 +1,164 @@ +import { Quantity, Data, JsonRpcErrorCode } from "@ganache/utils"; +import { Address } from "@ganache/ethereum-address"; +import { + EIP1559FeeMarketRawTransaction, + EIP2930AccessListRawTransaction, + GanacheRawExtraTx, + LegacyRawTransaction, + TypedRawTransaction +} from "./raw"; +import { encode } from "@ganache/rlp"; +import { + EIP1559FeeMarketRpcTransaction, + EIP2930AccessListRpcTransaction, + LegacyRpcTransaction, + Transaction, + TransactionType +} from "./rpc-transaction"; +import { AccessLists } from "./access-lists"; +import { CodedError } from "@ganache/ethereum-utils"; + +export function serializeRpcForDb( + tx: Transaction, + blockHash: Data, + blockNumber: Quantity, + transactionIndex: Quantity +): Buffer { + let type: number; + if (!("type" in tx) || tx.type === undefined) { + type = TransactionType.Legacy; + } else { + type = parseInt(tx.type, 16); + } + + const txData = { + raw: rawFromRpc(tx, type), + from: Address.from(tx.from), + hash: Data.from((tx as any).hash, 32), + // this assumes that gasPrice has been set - even for EIP-1559 Fee Market transactions + effectiveGasPrice: Quantity.from(tx.gasPrice), + type: Quantity.from(type) + }; + + return serializeForDb(txData, blockHash, blockNumber, transactionIndex); +} + +export type SerializableTransaction = { + raw: TypedRawTransaction; + from: Address; + hash: Data; + effectiveGasPrice: Quantity; + type: Quantity; +}; + +export function serializeForDb( + tx: SerializableTransaction, + blockHash: Data, + blockNumber: Quantity, + transactionIndex: Quantity +): Buffer { + const legacy = tx.raw.length === 9; + // todo(perf):make this work with encodeRange and digest + const txAndExtraData: [TypedRawTransaction, GanacheRawExtraTx] = [ + // todo: this is encoded differently in the tx table than it is in the + // block table. we should migrate the tx table to use the same format as + // the block (`Buffer.concat([type, encode(raw)])`) so that we can avoid + // block it twice for each block save step. + legacy ? tx.raw : ([tx.type.toBuffer(), ...tx.raw] as any), + [ + tx.from.buf, + tx.hash.toBuffer(), + blockHash.toBuffer(), + blockNumber.toBuffer(), + transactionIndex.toBuffer(), + tx.effectiveGasPrice.toBuffer() + ] + ]; + + return encode(txAndExtraData); +} + +export function rawFromRpc( + txData: LegacyRpcTransaction, + txType: TransactionType.Legacy +): LegacyRawTransaction; +export function rawFromRpc( + txData: EIP2930AccessListRpcTransaction, + txType: TransactionType.EIP1559AccessList +): EIP2930AccessListRawTransaction | LegacyRawTransaction; +export function rawFromRpc( + txData: EIP1559FeeMarketRpcTransaction, + txType: TransactionType.EIP1559AccessList +): EIP1559FeeMarketRawTransaction; +export function rawFromRpc( + txData: Transaction, + txType: TransactionType +): TypedRawTransaction; +export function rawFromRpc( + txData: Transaction, + txType: TransactionType +): TypedRawTransaction { + const chainId = Quantity.toBuffer(txData.chainId); + const nonce = Quantity.toBuffer(txData.nonce); + const gasPrice = Quantity.toBuffer(txData.gasPrice); + const gasLimit = Quantity.toBuffer(txData.gas || txData.gasLimit); + // todo: use Address type + const to = Data.toBuffer(txData.to, 20); + const value = Quantity.toBuffer(txData.value); + const data = Data.toBuffer(txData.data || txData.input); + const v = Data.toBuffer((txData as any).v); + const r = Data.toBuffer((txData as any).r); + const s = Data.toBuffer((txData as any).s); + + // if no access list is provided, we convert to legacy + const targetType = + txType === TransactionType.EIP2930AccessList && + txData.accessList === undefined + ? TransactionType.Legacy + : txType; + + switch (targetType) { + case TransactionType.Legacy: + return [nonce, gasPrice, gasLimit, to, value, data, v, r, s]; + case TransactionType.EIP2930AccessList: + return [ + chainId, + nonce, + gasPrice, + gasLimit, + to, + value, + data, + // accesslists is _always_ set, otherwise it's legacy + txData.accessList + ? AccessLists.getAccessListData(txData.accessList).accessList + : [], + v, + r, + s + ]; + // todo: should this be TransactionType.EIP1559FeeMarket? + case TransactionType.EIP1559AccessList: + return [ + chainId, + nonce, + Quantity.toBuffer(txData.maxPriorityFeePerGas), + Quantity.toBuffer(txData.maxFeePerGas), + gasLimit, + to, + value, + data, + txData.accessList + ? AccessLists.getAccessListData(txData.accessList).accessList + : [], + v, + r, + s + ]; + default: + throw new CodedError( + "Tx instantiation with supplied type not supported", + JsonRpcErrorCode.METHOD_NOT_FOUND + ); + } +} diff --git a/src/chains/ethereum/transaction/tests/transaction-serialization.test.ts b/src/chains/ethereum/transaction/tests/transaction-serialization.test.ts new file mode 100644 index 0000000000..2c72b90975 --- /dev/null +++ b/src/chains/ethereum/transaction/tests/transaction-serialization.test.ts @@ -0,0 +1,428 @@ +import { JsonRpcErrorCode, Quantity } from "@ganache/utils"; +import { + EIP1559FeeMarketRpcTransaction, + EIP2930AccessListRpcTransaction, + Transaction, + TransactionType +} from "../src/rpc-transaction"; +import { + rawFromRpc, + serializeForDb, + serializeRpcForDb +} from "../src/transaction-serialization"; +import assert from "assert"; +import { Data } from "@ganache/utils"; +import { AccessList, AccessListBuffer } from "@ethereumjs/tx"; +import { + EIP1559FeeMarketRawTransaction, + EIP2930AccessListRawTransaction, + LegacyRawTransaction +} from "../src/raw"; +import { Address } from "@ganache/ethereum-address"; + +function assertBufferEqualsString( + actual: Buffer, + expected: string, + message?: string +) { + const expectedBuf = Data.toBuffer(expected); + assert(Buffer.compare(actual, expectedBuf) === 0, message); +} + +function assertAccessListEqualsBuffer( + accessListBuffer: AccessListBuffer, + accessList: AccessList +) { + /* + accessList is AccessListBuffer, which is made up of buffer values arranged as follows: + [ + address, + [ ....slots... ] + ] + */ + assert.strictEqual(accessListBuffer.length, 1); + const [accessListAddress, accessListSlots] = accessListBuffer[0]; + assertBufferEqualsString( + accessListAddress, + accessList[0].address, + "Unexpected accessList address" + ); + + assert.strictEqual(accessListSlots.length, 1); + assertBufferEqualsString( + accessListSlots[0], + accessList[0].storageKeys[0], + "Unexpected accessList slot" + ); +} + +describe("transaction-serialization", () => { + const baseRpcTx = { + from: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + nonce: "0x1", + gas: "0x945", + to: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", + value: "0xde0b6b3a7640000", + data: "0x48656c6c6f20776f726c64" + }; + + const legacyTx = { ...baseRpcTx, gasPrice: "0x945945" }; + const eip2930Tx = { + ...baseRpcTx, + gasPrice: "0x945945", + type: "0x1", + chainId: "0x1" + }; + + const eip1559Tx = { + ...baseRpcTx, + maxPriorityFeePerGas: "0x945945", + maxFeePerGas: "0x549549", + type: "0x1", + chainId: "0x1", + accessList: [ + { + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + storageKeys: [ + "0x0000000000000000000000000000000000000000000000000000000000000001" + ] + } + ] + }; + + const blockHash = Data.from( + "0x7fe24cdff5b4bdcb277616483c850e67d0fd0705a9ac2d89e28a77403a162946", + 32 + ); + const blockNumber = Quantity.from("0xa0f98f"); + const transactionIndex = Quantity.from("0x1"); + const from = Address.from("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"); + const txHash = Data.from( + "0x11af86dbff8c977f7bb2d6d6619c9297814d588d47430c86ac794fafaf2cbbde" + ); + const effectiveGasPrice = Quantity.from("0x945"); + const chainId = Buffer.from("01", "hex"); + + describe("serializeRpcForDb()", () => { + // this just exercizes the serializeRpcForDb() function, which depends on `rawFromRpc` and `serializeForDb` which are both tested below. + it("should serialize a legacy RPC transaction", () => { + const serialized = serializeRpcForDb( + legacyTx, + blockHash, + blockNumber, + transactionIndex + ); + + assert( + Buffer.isBuffer(serialized), + `Expected a Buffer, got ${serialized}` + ); + }); + + it("should serialize an EIP2930 accesslist transaction RPC transaction", () => { + const serialized = serializeRpcForDb( + eip2930Tx as EIP2930AccessListRpcTransaction, + blockHash, + blockNumber, + transactionIndex + ); + + assert( + Buffer.isBuffer(serialized), + `Expected a Buffer, got ${serialized}` + ); + }); + + it("should serialize an EIP1559 fee market transaction RPC transaction", () => { + const serialized = serializeRpcForDb( + eip2930Tx as EIP2930AccessListRpcTransaction, + blockHash, + blockNumber, + transactionIndex + ); + + assert( + Buffer.isBuffer(serialized), + `Expected a Buffer, got ${serialized}` + ); + }); + }); + + describe("serializeForDb()", () => { + // this just exercizes the serializeForDb() function, which depends on RLP `encode()` function. + // the behaviour of both `encode` and this are tested in higher level tests. + const legacyRawTx: LegacyRawTransaction = [ + Buffer.from("01", "hex"), // nonce + Buffer.from("0945", "hex"), // gasPrice + Buffer.from("0101", "hex"), // gas + Buffer.from("90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", "hex"), // to + Buffer.from("0de0b6b3a7640000", "hex"), // value + Buffer.from("48656c6c6f20776f726c64", "hex"), // data + Buffer.from("00", "hex"), // v + Buffer.from( + "7aa7c79312a0f5cc49862f70c2971d6022a7adc29c1509cf22f03ebcf0c31f45", + "hex" + ), // r + Buffer.from( + "113e899c92a3dcf9a1c2dc6f367f02fb398d7b4c777fed451228eda9d6f4f24d", + "hex" + ) // s + ]; + + it("should serialize a legacy transaction", () => { + const serializableTx = { + raw: legacyRawTx, + from, + hash: txHash, + effectiveGasPrice, + type: Quantity.from(TransactionType.Legacy) + }; + + const serialized = serializeForDb( + serializableTx, + blockHash, + blockNumber, + transactionIndex + ); + + assert( + Buffer.isBuffer(serialized), + `Expected a Buffer, got ${serialized}` + ); + }); + + it("should serialize an EIP2940AccessList transaction", () => { + const accessListBuffer = [] as AccessListBuffer; + const raw = [ + chainId, + ...legacyRawTx.slice(0, 6), + accessListBuffer, + ...legacyRawTx.slice(6) + ] as EIP2930AccessListRawTransaction; + const serializableTx = { + raw, + from, + hash: txHash, + effectiveGasPrice, + type: Quantity.from(TransactionType.EIP2930AccessList) + }; + + const serialized = serializeForDb( + serializableTx, + blockHash, + blockNumber, + transactionIndex + ); + + assert( + Buffer.isBuffer(serialized), + `Expected a Buffer, got ${serialized}` + ); + }); + + it("should serialize an EIP1559FeeMarket transaction", () => { + const chainId = Buffer.from("01", "hex"); + const maxFeePerGas = Buffer.from("0945", "hex"); + const accessListBuffer = [] as AccessListBuffer; + const raw = [ + chainId, + ...legacyRawTx.slice(0, 2), + maxFeePerGas, + ...legacyRawTx.slice(2, 6), + accessListBuffer, + ...legacyRawTx.slice(6) + ] as EIP1559FeeMarketRawTransaction; + const serializableTx = { + raw, + from, + hash: txHash, + effectiveGasPrice, + type: Quantity.from(TransactionType.EIP1559AccessList) + }; + + const serialized = serializeForDb( + serializableTx, + blockHash, + blockNumber, + transactionIndex + ); + + assert( + Buffer.isBuffer(serialized), + `Expected a Buffer, got ${serialized}` + ); + }); + }); + + describe("rawFromRpc()", () => { + it("should throw with unsupported type", () => { + const unsupportedType = 10 as TransactionType; + const tx = {} as Transaction; + + assert.throws( + () => { + rawFromRpc(tx, unsupportedType); + }, + { + message: "Tx instantiation with supplied type not supported", + code: JsonRpcErrorCode.METHOD_NOT_FOUND + } + ); + }); + + it("should convert a legacy transaction", () => { + const raw = rawFromRpc(legacyTx, TransactionType.Legacy); + + assert.strictEqual(raw.length, 9); + const [nonce, gasPrice, gasLimit, to, value, data, v, r, s] = raw; + assertBufferEqualsString(nonce, legacyTx.nonce, "Unexpected nonce"); + assertBufferEqualsString( + gasPrice, + legacyTx.gasPrice, + "Unexpected gasLimit" + ); + assertBufferEqualsString(gasLimit, legacyTx.gas, "Unexpected gasLimit"); + assertBufferEqualsString(value, legacyTx.value, "Unexpected value"); + assertBufferEqualsString(data, legacyTx.data, "Unexpected data"); + assertBufferEqualsString(to, legacyTx.to, "Unexpected to"); + assert.strictEqual(v.length, 0); + assert.strictEqual(r.length, 0); + assert.strictEqual(s.length, 0); + }); + + it("should convert an EIP2930 accesslist transaction without accesslists to legacy", () => { + const raw = rawFromRpc( + eip2930Tx as EIP2930AccessListRpcTransaction, + TransactionType.EIP2930AccessList + ); + + assert.strictEqual(raw.length, 9); + const [nonce, gasPrice, gasLimit, to, value, data, v, r, s] = raw; + assertBufferEqualsString(nonce, eip2930Tx.nonce, "Unexpected nonce"); + assertBufferEqualsString( + gasPrice, + eip2930Tx.gasPrice, + "Unexpected gasLimit" + ); + assertBufferEqualsString(gasLimit, eip2930Tx.gas, "Unexpected gasLimit"); + assertBufferEqualsString(value, eip2930Tx.value, "Unexpected value"); + assertBufferEqualsString(data, eip2930Tx.data, "Unexpected data"); + assertBufferEqualsString(to, eip2930Tx.to, "Unexpected to"); + assert.strictEqual(v.length, 0); + assert.strictEqual(r.length, 0); + assert.strictEqual(s.length, 0); + }); + + it("should convert an EIP2930 accesslist transaction", () => { + const eip2930Tx = { + ...baseRpcTx, + gasPrice: "0x945945", + type: "0x1", + chainId: "0x1", + accessList: [ + { + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + storageKeys: [ + "0x0000000000000000000000000000000000000000000000000000000000000001" + ] + } + ] as AccessList + }; + const raw = rawFromRpc( + eip2930Tx as EIP2930AccessListRpcTransaction, + TransactionType.EIP2930AccessList + ); + + assert.strictEqual(raw.length, 11); + const [ + chainId, + nonce, + gasPrice, + gasLimit, + to, + value, + data, + accessList, + v, + r, + s + ] = raw; + + assertBufferEqualsString( + chainId, + eip2930Tx.chainId, + "Unexpected chainId" + ); + assertBufferEqualsString(nonce, eip2930Tx.nonce, "Unexpected nonce"); + assertBufferEqualsString( + gasPrice, + eip2930Tx.gasPrice, + "Unexpected gasLimit" + ); + assertBufferEqualsString(gasLimit, eip2930Tx.gas, "Unexpected gasLimit"); + assertBufferEqualsString(value, eip2930Tx.value, "Unexpected value"); + assertBufferEqualsString(data, eip2930Tx.data, "Unexpected data"); + assertBufferEqualsString(to, eip2930Tx.to, "Unexpected to"); + assert.strictEqual(v.length, 0); + assert.strictEqual(r.length, 0); + assert.strictEqual(s.length, 0); + + assertAccessListEqualsBuffer( + accessList as AccessListBuffer, + eip2930Tx.accessList + ); + }); + + it("should convert an EIP1559 fee market transaction", () => { + const raw = rawFromRpc( + eip1559Tx as EIP1559FeeMarketRpcTransaction, + TransactionType.EIP1559AccessList + ); + + assert.strictEqual(raw.length, 12); + const [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + to, + value, + data, + accessList, + v, + r, + s + ] = raw; + + assertBufferEqualsString( + chainId, + eip1559Tx.chainId, + "Unexpected chainId" + ); + assertBufferEqualsString(nonce, eip1559Tx.nonce, "Unexpected nonce"); + assertBufferEqualsString( + maxFeePerGas, + eip1559Tx.maxFeePerGas, + "Unexpected maxFeePerGas" + ); + assertBufferEqualsString( + maxPriorityFeePerGas, + eip1559Tx.maxPriorityFeePerGas, + "Unexpected maxPriorityFeePerGas" + ); + assertBufferEqualsString(gasLimit, eip1559Tx.gas, "Unexpected gasLimit"); + assertBufferEqualsString(value, eip1559Tx.value, "Unexpected value"); + assertBufferEqualsString(data, eip1559Tx.data, "Unexpected data"); + assertBufferEqualsString(to, eip1559Tx.to, "Unexpected to"); + assert.strictEqual(v.length, 0); + assert.strictEqual(r.length, 0); + assert.strictEqual(s.length, 0); + + assertAccessListEqualsBuffer( + accessList, + eip1559Tx.accessList as AccessList + ); + }); + }); +});