diff --git a/src/chains/ethereum/ethereum/src/api.ts b/src/chains/ethereum/ethereum/src/api.ts index 9c775714c7..6488b17212 100644 --- a/src/chains/ethereum/ethereum/src/api.ts +++ b/src/chains/ethereum/ethereum/src/api.ts @@ -58,14 +58,13 @@ type TransactionSimulationTransaction = Ethereum.Transaction & { traceTypes?: string[]; }; -type TraceType = "full" | "call" | "none"; -type GasEstimateType = "full" | "call-depth" | "none"; -type TransactionSimulationArgs = { +type TraceType = boolean; +type TransactionSimulationArgs = { transactions: TransactionSimulationTransaction[]; overrides?: Ethereum.Call.Overrides; - block?: QUANTITY | Ethereum.Tag; trace?: TraceType; - gasEstimation?: GasEstimateType; + estimateGas?: Estimate; + continueOnFailure?: boolean; }; type Log = [address: Address, topics: DATA[], data: DATA]; @@ -90,27 +89,73 @@ type StateChange = { codeHash: Data; }; }; -type GasBreakdown = { +export type GasBreakdown = { + /** + * The total amount of gas used by the transaction. + */ + total: Quantity; + + /** + * Total gas used minus the refund. This is what etherscan reports as `Gas Usage`. + */ + actual: Quantity; + + /** + * The amount of gas refunded to the sender. + */ + refund: Quantity; + + /** + * The amount of gas the EVM requires before it would attempt to run the + * transaction. + */ intrinsic: Quantity; + + /** + * The amount of gas used by the transaction's actual execution. + */ execution: Quantity; - refund: Quantity; - actualCost: Quantity; + + /** + * The minimum amount of gas required to run the transaction. + */ + estimate: Estimate extends true ? Quantity : undefined; }; -type TraceEntry = { +export type TraceEntry = { + /** + * The opcode of the trace entry. + * Currently limited to opcodes for CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, JUMP, JUMPI + * + */ opcode: Data; - type: string; - from: Address; - to: Address; - value: Quantity; - input: Data; + /** + * The name of the opcode (CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, JUMP, JUMPI) + */ + name: string; pc: number; - target?: string; - decodedInput?: []; + /** + * Decoded function signature (via 4byte directory) + */ + signature?: string; +} & (CALLTraceEntry | JUMPTraceEntry | {}); // {} because CREATE and CREATE2 materialize as just the base TraceEntry + +type CALLTraceEntry = { + from?: Address; + to?: Address; + value?: Quantity; + data?: Data; + args?: { type: string; value: Quantity | Data }[]; +}; + +type JUMPTraceEntry = { + destination: Quantity; + condition?: Quantity; }; -type TransactionSimulationResult = { + +type TransactionSimulationResult = { returnValue: Data; - gas: GasBreakdown; + gas: GasBreakdown; logs: Log[]; storageChanges: StorageChange[]; stateChanges: StateChange[]; @@ -119,10 +164,9 @@ type TransactionSimulationResult = { gasEstimate?: Quantity; }; -type InternalTransactionSimulationResult = { +export type InternalTransactionSimulationResult = { result: any; - gasBreakdown: any; - gasEstimate?: bigint; + gas: GasBreakdown; storageChanges: { address: Address; key: Buffer; @@ -133,27 +177,22 @@ type InternalTransactionSimulationResult = { Buffer, [[Buffer, Buffer, Buffer, Buffer], [Buffer, Buffer, Buffer, Buffer]] >; - trace?: { - opcode: Buffer; - pc: number; - type: string; - stack: Buffer[]; - }[]; + trace?: TraceEntry[]; }; -async function simulateTransaction( +async function simulateTransactions( blockchain: Blockchain, options: EthereumInternalOptions, transactions: Ethereum.Call.Transaction[], - blockNumber: QUANTITY | Ethereum.Tag = Tag.latest, + blockNumber: QUANTITY | Ethereum.Tag, overrides: Ethereum.Call.Overrides = {}, - includeTrace: boolean = false, - includeGasEstimate: boolean = false -): Promise { + includeTrace: boolean, + includeGasEstimate: Estimate, + continueOnFailure: boolean +): Promise[]> { const blocks = blockchain.blocks; const parentBlock = await blocks.get(blockNumber); const parentHeader = parentBlock.header; - // EVMResult const simulationBlockNumber = parentHeader.number.toBigInt() + 1n; const common = blockchain.fallback ? blockchain.fallback.getCommonForBlockNumber( @@ -161,9 +200,33 @@ async function simulateTransaction( simulationBlockNumber ) : blockchain.common; + // TODO: why do we do this? can we not? common.setHardfork("shanghai"); - let cummulativeGas = 0n; + let cumulativeGas = 0n; + + const incr = + typeof options.miner.timestampIncrement === "string" + ? 12n + : options.miner.timestampIncrement.toBigInt(); + + const baseFeePerGasBigInt = Block.calcNextBaseFee(parentBlock); + const timestamp = Quantity.from(parentHeader.timestamp.toBigInt() + incr); + + const block = new RuntimeBlock( + common, + Quantity.from(simulationBlockNumber), + parentBlock.hash(), + blockchain.coinbase, + Quantity.Zero, // we'll fill this with cumulativeGas in later + parentHeader.gasUsed, + timestamp, + Quantity.Zero, //options.miner.difficulty, + parentHeader.totalDifficulty, + blockchain.getMixHash(parentHeader.parentHash.toBuffer()), + baseFeePerGasBigInt, + KECCAK256_RLP + ); const simulationTransactions = transactions.map(transaction => { let txGas: Quantity; @@ -242,7 +305,7 @@ async function simulateTransaction( transaction.value == null ? null : Quantity.from(transaction.value); // add this transaction's gas to the block gas - cummulativeGas += txGas.toBigInt(); + cumulativeGas += txGas.toBigInt(); const simulatedTransaction = { gas: txGas, @@ -251,44 +314,23 @@ async function simulateTransaction( gasPrice, value, data, - block: undefined + block }; return simulatedTransaction; }); - const incr = - typeof options.miner.timestampIncrement === "string" - ? 12n - : options.miner.timestampIncrement.toBigInt(); - - // todo: calculate baseFeePerGas - const baseFeePerGasBigInt = parentBlock.header.baseFeePerGas.toBigInt(); - const timestamp = Quantity.from(parentHeader.timestamp.toBigInt() + incr); - - const block = new RuntimeBlock( - common, - Quantity.from(simulationBlockNumber), - parentBlock.hash(), - blockchain.coinbase, - Quantity.from(cummulativeGas), - parentHeader.gasUsed, - timestamp, - Quantity.Zero, //options.miner.difficulty, - parentHeader.totalDifficulty, - blockchain.getMixHash(parentHeader.parentHash.toBuffer()), - baseFeePerGasBigInt, - KECCAK256_RLP - ); + block.header.gasLimit = cumulativeGas; - const results = blockchain.simulateTransactions( + const results = await blockchain.simulateTransactions( common, simulationTransactions, block, parentBlock, overrides, includeTrace, - includeGasEstimate + includeGasEstimate, + continueOnFailure ); return results; @@ -3021,38 +3063,33 @@ export default class EthereumApi implements Api { * @param {TransactionSimulationArgs} args * @returns Promise */ - async evm_simulateTransactions( - args: TransactionSimulationArgs - ): Promise { - // todo: need to be able to pass in multiple transactions - const transactions = args.transactions; - const blockNumber = args.block || "latest"; - - const overrides = args.overrides; - const includeTrace = args.trace === "full" || args.trace === "call"; - const includeGasEstimation = - args.gasEstimation === "full" || args.gasEstimation === "call-depth"; + @assertArgLength(1, 2) + async evm_simulateTransactions( + { + overrides, + transactions, + trace, + estimateGas, + continueOnFailure + }: TransactionSimulationArgs, + blockNumber: QUANTITY | Ethereum.Tag = Tag.latest + ): Promise[]> { + const includeTrace = trace === true; - //@ts-ignore - const simulatedTransactionResults = await simulateTransaction( + const simulatedTransactionResults = await simulateTransactions( this.#blockchain, this.#options, transactions, blockNumber, overrides, includeTrace, - includeGasEstimation + //@ts-ignore + estimateGas === true, + continueOnFailure === true ); return simulatedTransactionResults.map( - ({ - trace, - gasBreakdown, - result, - storageChanges, - stateChanges, - gasEstimate - }) => { + ({ trace, gas, result, storageChanges, stateChanges }) => { const parsedStorageChanges = storageChanges.map(change => ({ key: Data.from(change.key), address: Address.from(change.address.buf), @@ -3081,12 +3118,6 @@ export default class EthereumApi implements Api { } const returnValue = Data.from(result.returnValue || "0x"); - const gas = { - intrinsic: Quantity.from(gasBreakdown.intrinsicGas), - execution: Quantity.from(gasBreakdown.executionGas), - refund: Quantity.from(gasBreakdown.refund), - actualCost: Quantity.from(gasBreakdown.actualGasCost) - }; const logs = result.logs?.map(([addr, topics, data]) => ({ address: Data.from(addr), topics: topics?.map(t => Data.from(t)), @@ -3100,32 +3131,12 @@ export default class EthereumApi implements Api { error, returnValue, gas, - gasEstimate: gasEstimate ? Quantity.from(gasEstimate) : undefined, logs, //todo: populate receipts receipts: undefined, storageChanges: parsedStorageChanges, stateChanges: parsedStateChanges, - trace: includeTrace - ? trace.map((t: any) => { - return { - opcode: Data.from(t.opcode), - type: t.type, - from: Address.from(t.from), - to: Address.from(t.to), - target: t.target, - value: - t.value === undefined ? undefined : Quantity.from(t.value), - input: Data.from(t.input), - decodedInput: t.decodedInput?.map(({ type, value }) => ({ - type, - // todo: some values will be Quantity rather - value: Data.from(value) - })), - pc: t.pc - }; - }) - : undefined + trace: includeTrace ? trace : undefined }; } ); @@ -3189,7 +3200,7 @@ export default class EthereumApi implements Api { ): Promise { //cos I've broken it real good //@ts-ignore - const { result } = await simulateTransaction( + const { result } = await simulateTransactions( this.#blockchain, this.#options, //@ts-ignore diff --git a/src/chains/ethereum/ethereum/src/blockchain.ts b/src/chains/ethereum/ethereum/src/blockchain.ts index e2d05c5e8c..e36ab96947 100644 --- a/src/chains/ethereum/ethereum/src/blockchain.ts +++ b/src/chains/ethereum/ethereum/src/blockchain.ts @@ -81,6 +81,11 @@ import { TrieDB } from "./trie-db"; import { Trie } from "@ethereumjs/trie"; import { Interpreter, RunState } from "@ethereumjs/evm/dist/interpreter"; import { GasTracer } from "./helpers/gas"; +import { + GasBreakdown, + InternalTransactionSimulationResult, + TraceEntry +} from "./api"; const mclInitPromise = mcl.init(mcl.BLS12_381).then(() => { mcl.setMapToMode(mcl.IRTF); // set the right map mode; otherwise mapToG2 will return wrong values. @@ -96,13 +101,17 @@ const opcode = { CALLCODE: 0xf2, DELEGATECALL: 0xf4, STATICCALL: 0xfa, + CREATE: 0xf0, + CREATE2: 0xf5, 0x55: "SSTORE", 0x56: "JUMP", 0x57: "JUMPI", 0xf1: "CALL", 0xf2: "CALLCODE", 0xf4: "DELEGATECALL", - 0xfa: "STATICCALL" + 0xfa: "STATICCALL", + 0xf0: "CREATE", + 0xf5: "CREATE2" }; export enum Status { @@ -1123,14 +1132,15 @@ export default class Blockchain extends Emittery { } } - public async simulateTransactions( + public async simulateTransactions( common: Common, transactions: SimulationTransaction[], runtimeBlock: RuntimeBlock, parentBlock: Block, overrides: CallOverrides, includeTrace: boolean, - includeGasEstimate: boolean + includeGasEstimate: Estimate, + continueOnFailure: boolean ) { const stateTrie = this.trie.copy(false); stateTrie.setContext( @@ -1171,333 +1181,376 @@ export default class Blockchain extends Emittery { const runningEncodedAccounts = {}; const runningRawStorageSlots = {}; - const results = new Array(transactions.length); + const results: InternalTransactionSimulationResult[] = []; + for (let i = 0; i < transactions.length; i++) { - const transaction = transactions[i]; - const trace = []; - const storageChanges: { - address: Address; - key: Buffer; - before: Buffer; - after: Buffer; - }[] = []; - const stateChanges = new Map< - Buffer, - [[Buffer, Buffer, Buffer, Buffer], [Buffer, Buffer, Buffer, Buffer]] - >(); - - const touchedStorage = new Map(); - - const data = transaction.data; - // subtract out the transaction's base fee from the gas limit before - // simulating the tx, because `runCall` doesn't account for raw gas costs. - const hasToAddress = transaction.to != null; - const to = hasToAddress ? new Address(transaction.to.toBuffer()) : null; - - const intrinsicGas = calculateIntrinsicGas(data, hasToAddress, common); - let gasLeft = transaction.gas.toBigInt() - intrinsicGas; - - if (gasLeft >= 0n) { - const caller = transaction.from.toBuffer(); - const callerAddress = new Address(caller); - - if (common.isActivatedEIP(2929)) { - const eei = vm.eei; - // handle Berlin hardfork warm storage reads - warmPrecompiles(eei); - - eei.addWarmedAddress(caller); - if (to) { - eei.addWarmedAddress(to.buf); - } + try { + const transaction = transactions[i]; + const trace: TraceEntry[] = []; + const storageChanges: { + address: Address; + key: Buffer; + before: Buffer; + after: Buffer; + }[] = []; + const stateChanges = new Map< + Buffer, + [[Buffer, Buffer, Buffer, Buffer], [Buffer, Buffer, Buffer, Buffer]] + >(); + + const touchedStorage = new Map(); + + const data = transaction.data; + // subtract out the transaction's base fee from the gas limit before + // simulating the tx, because `runCall` doesn't account for raw gas costs. + const hasToAddress = transaction.to != null; + const to = hasToAddress ? new Address(transaction.to.toBuffer()) : null; + + const intrinsicGas = calculateIntrinsicGas(data, hasToAddress, common); + const gasLeft = transaction.gas.toBigInt() - intrinsicGas; + + let result: InternalTransactionSimulationResult; + + if (gasLeft >= 0n) { + const caller = transaction.from.toBuffer(); + const callerAddress = new Address(caller); + + if (common.isActivatedEIP(2929)) { + const eei = vm.eei; + // handle Berlin hardfork warm storage reads + warmPrecompiles(eei); + + eei.addWarmedAddress(caller); + if (to) { + eei.addWarmedAddress(to.buf); + } - // shanghai hardfork requires that we warm the coinbase address - if (common.isActivatedEIP(3651)) { - eei.addWarmedAddress(runtimeBlock.header.coinbase.buf); + // shanghai hardfork requires that we warm the coinbase address + if (common.isActivatedEIP(3651)) { + eei.addWarmedAddress(runtimeBlock.header.coinbase.buf); + } } - } - // we need to update the balance and nonce of the sender _before_ - // we run this transaction so that things that rely on these values - // are correct (like contract creation!). - const fromAccount = await vm.eei.getAccount(callerAddress); - - // todo: re previous comment, incrementing the nonce here results in a double - // incremented nonce in the result :/ Need to validate whether this is required. - //fromAccount.nonce += 1n; - const intrinsicTxCost = intrinsicGas * transaction.gasPrice.toBigInt(); - //todo: does the execution gas get subtracted from the balance? - const startBalance = fromAccount.balance; - // TODO: should we throw if insufficient funds? - fromAccount.balance = - intrinsicTxCost > startBalance ? 0n : startBalance - intrinsicTxCost; - await vm.eei.putAccount(callerAddress, fromAccount); - - const stepHandler = async (interpreter: Interpreter) => { - const runState = (interpreter as any)._runState as RunState; - const { opCode, stack, env, programCounter } = runState; - const codeAddress = env.codeAddress; - - if (opCode === opcode.SSTORE) { - const stackLength = stack.length; - const keyBigInt = stack._store[stackLength - 1]; - const valueBigInt = stack._store[stackLength - 2]; - - const keyString = keyBigInt.toString(); - - const addressString = codeAddress.buf.toString("utf8"); - let touchedAddressStorage = touchedStorage[addressString]; - if (touchedAddressStorage === undefined) { - touchedAddressStorage = touchedStorage[addressString] = {}; + // we need to update the balance and nonce of the sender _before_ + // we run this transaction so that things that rely on these values + // are correct (like contract creation!). + const fromAccount = await vm.eei.getAccount(callerAddress); + + // todo: re previous comment, incrementing the nonce here results in a double + // incremented nonce in the result :/ Need to validate whether this is required. + //fromAccount.nonce += 1n; + const intrinsicTxCost = + intrinsicGas * transaction.gasPrice.toBigInt(); + //todo: does the execution gas get subtracted from the balance? + const startBalance = fromAccount.balance; + // TODO: should we throw if insufficient funds? + fromAccount.balance = + intrinsicTxCost > startBalance + ? 0n + : startBalance - intrinsicTxCost; + await vm.eei.putAccount(callerAddress, fromAccount); + + const stepHandler = async (interpreter: Interpreter) => { + const runState = (interpreter as any)._runState as RunState; + const { opCode, stack, env } = runState; + const codeAddress = env.codeAddress; + + if (opCode === opcode.SSTORE) { + const stackLength = stack.length; + const keyBigInt = stack._store[stackLength - 1]; + const valueBigInt = stack._store[stackLength - 2]; + + const keyString = keyBigInt.toString(); + + const addressString = codeAddress.buf.toString("utf8"); + let touchedAddressStorage = touchedStorage[addressString]; + if (touchedAddressStorage === undefined) { + touchedAddressStorage = touchedStorage[addressString] = {}; + } + touchedAddressStorage[keyString] = [ + codeAddress, + keyBigInt, + valueBigInt + ]; + } else if (includeTrace) { + const traceElement = this.makeTraceElement(runState); + if (traceElement) { + trace.push(traceElement); + } } - touchedAddressStorage[keyString] = [ - codeAddress, - keyBigInt, - valueBigInt - ]; - } else if ( - includeTrace && - (opCode === opcode.CALL || - opCode === opcode.CALLCODE || - opCode === opcode.DELEGATECALL || - opCode === opcode.STATICCALL) - ) { - // done: drop non-call opcodes from the trace - // done: decompose the stack to parameter values - // todo: build call stack and recompute gas left and return value - // use the call stack to compute 63/64th rules - // implement additional edgecases for gas estimation - // It'd be nice to show call heirarchy, either with nested calls or similar - - let inLength, inOffset, value, toAddr; - if (opCode === opcode.CALL || opCode === opcode.CALLCODE) { - [inLength, inOffset, value, toAddr] = stack._store.slice(-5, -1); - } else { - [inLength, inOffset, toAddr] = stack._store.slice(-4, -1); + }; + + // `onRunStep` is shared with the gas tracer, so we need to play nice + // hack: fix it sometime! + const evm: any = vm.evm; + const oldRunStep = evm.onRunStep; + evm.onRunStep = (...args: any) => { + oldRunStep && oldRunStep.apply(evm, args); + stepHandler.apply(evm, args); + }; + + const runCallArgs = { + caller: callerAddress, + data: transaction.data && transaction.data.toBuffer(), + gasPrice: transaction.gasPrice.toBigInt(), + gasLimit: gasLeft, + to, + value: + transaction.value == null ? 0n : transaction.value.toBigInt(), + block: runtimeBlock as any + }; + const evmResult = await vm.evm.runCall(runCallArgs); + + // todo: this is always going to pull the "before" from before _all_ simulations + // in order for this to be correct, we need to check all previously simulated transactions + // (or store them in a running set of "current") + const afterCache = vm.stateManager["_cache"]["_cache"] as any; // OrderedMap + + const end = afterCache.end(); + for (const it = afterCache.begin(); !it.equals(end); it.next()) { + const i = it.pointer; + const addressStr = i[0]; + + let beforeEncoded = runningEncodedAccounts[addressStr]; + let addressBuf: Buffer; + if (beforeEncoded === undefined) { + // we haven't changed this account in a previous simulation, need to get the original account + addressBuf = Buffer.from(addressStr, "hex"); + beforeEncoded = await beforeStateManager._trie.get(addressBuf); } - const dataLength = Number(inLength); - const data = - dataLength === 0 ? BUFFER_EMPTY : Buffer.allocUnsafe(dataLength); - if (dataLength > 0) { - const dataOffset = Number(inOffset); - - runState.memory._store.copy( - data, - 0, - dataOffset, - dataLength + dataOffset - ); + const afterEncoded = i[1].val; + if (!beforeEncoded.equals(afterEncoded)) { + // the account has changed + + runningEncodedAccounts[addressStr] = afterEncoded; + + const before = decode(beforeEncoded); + const after = decode(afterEncoded); + + stateChanges.set(addressBuf || Buffer.from(addressStr, "hex"), [ + before, + after + ]); } - const to = bigIntToBuffer(toAddr); - const functionSelector = - data.length >= 4 ? data.readUIntBE(0, 4) : 0; - const target = fourBytes.get(functionSelector); - - let decodedInput; - if (target) { - const parameters = target - .slice(target.indexOf("(") + 1, target.length - 1) - .split(","); - if (parameters.length > 0 && parameters[0] !== "") { - try { - const decoded = rawDecode(parameters, data.subarray(4)); - decodedInput = Array(parameters.length); - for (let i = 0; i < parameters.length; i++) { - const type = parameters[i]; - const rawValue = decoded[i]; - let value: Buffer; - if (Buffer.isBuffer(rawValue)) { - value = rawValue; - } else { - switch (typeof rawValue) { - case "string": - value = Buffer.from(rawValue, "hex"); - break; - case "bigint": - value = bigIntToBuffer(rawValue); - break; - default: - value = Buffer.from(rawValue.toString(16), "hex"); - break; - } - } - - decodedInput[i] = { - type, - value - }; - } - } catch (er) { - console.error( - er, - parameters, - Data.from(data.subarray(4)), - typeof value - ); - } + } + + for (const addr in touchedStorage) { + let storageTrie: Trie; + + const storage = touchedStorage[addr] as TouchedStorage; + for (const keyStr in storage) { + const [address, key, valueAfter] = storage[keyStr] as [ + Address, + bigint, + bigint + ]; + if (storageTrie === undefined) { + // HACK: only the fork trie has a `getStorageTrie` method. + // i don't know why + storageTrie = beforeStateManager.getStorageTrie + ? await beforeStateManager.getStorageTrie(address.buf) + : await beforeStateManager._getStorageTrie(address as any); + } + const keyBuf = key === 0n ? BUFFER_32_ZERO : bigIntToBuffer(key); + + const addressSlotKey = addr + keyStr; + const before = + runningRawStorageSlots[addressSlotKey] || + decode(await storageTrie.get(keyBuf)); + + const after = bigIntToBuffer(valueAfter); + + runningRawStorageSlots[addressSlotKey] = after; + if (!before.equals(after)) { + storageChanges.push({ + address, + key: keyBuf, + before, + after + }); } } - trace.push({ - opcode: Buffer.from([opCode]), - type: opcode[opCode], - from: codeAddress.buf, - to, - gas: 0n, - gasUsed: 0n, - value: value, - input: data, - target, - decodedInput, - pc: programCounter - }); } - }; - // `onRunStep` is shared with the gas tracer, so we need to play nice - // hack: fix it sometime! - const evm: any = vm.evm; - const oldRunStep = evm.onRunStep; - evm.onRunStep = (...args: any) => { - oldRunStep && oldRunStep.apply(evm, args); - stepHandler.apply(evm, args); - }; + const totalGasSpent = + intrinsicGas + evmResult.execResult.executionGasUsed; + const maxRefund = totalGasSpent / 5n; + const actualRefund = + evmResult.execResult.gasRefund > maxRefund + ? maxRefund + : evmResult.execResult.gasRefund; - const runCallArgs = { - caller: callerAddress, - data: transaction.data && transaction.data.toBuffer(), - gasPrice: transaction.gasPrice.toBigInt(), - gasLimit: gasLeft, - to, - value: transaction.value == null ? 0n : transaction.value.toBigInt(), - block: runtimeBlock as any - }; - const result = await vm.evm.runCall(runCallArgs); - - // todo: this is always going to pull the "before" from before _all_ simulations - // in order for this to be correct, we need to check all previously simulated transactions - // (or store them in a running set of "current") - const afterCache = vm.stateManager["_cache"]["_cache"] as any; // OrderedMap - - const end = afterCache.end(); - for (const it = afterCache.begin(); !it.equals(end); it.next()) { - const i = it.pointer; - const addressStr = i[0]; - - let beforeEncoded = runningEncodedAccounts[addressStr]; - let addressBuf: Buffer; - if (beforeEncoded === undefined) { - // we haven't changed this account in a previous simulation, need to get the original account - addressBuf = Buffer.from(addressStr, "hex"); - beforeEncoded = await beforeStateManager._trie.get(addressBuf); - } - const afterEncoded = i[1].val; - if (!beforeEncoded.equals(afterEncoded)) { - // the account has changed + const gasBreakdown: GasBreakdown = { + total: Quantity.from(totalGasSpent), - runningEncodedAccounts[addressStr] = afterEncoded; + actual: Quantity.from(totalGasSpent - actualRefund), + refund: Quantity.from(actualRefund), - const before = decode(beforeEncoded); - const after = decode(afterEncoded); + intrinsic: Quantity.from(intrinsicGas), + execution: Quantity.from(evmResult.execResult.executionGasUsed), - stateChanges.set(addressBuf || Buffer.from(addressStr, "hex"), [ - before, - after - ]); - } + // @ts-ignore + estimate: gasTracer + ? Quantity.from(gasTracer.computeGasLimit() + intrinsicGas) + : undefined + }; + + result = { + result: evmResult.execResult, + gas: gasBreakdown, + storageChanges, + stateChanges, + trace + }; + } else { + result = { + result: { + runState: { programCounter: 0 }, + exceptionError: new VmError(ERROR.OUT_OF_GAS), + returnValue: BUFFER_EMPTY + }, + gas: { + actual: Quantity.Zero, + refund: Quantity.Zero, + + intrinsic: Quantity.from(intrinsicGas), + execution: Quantity.Zero, + + // @ts-ignore + estimate: includeGasEstimate ? Quantity.Zero : undefined + }, + storageChanges, + stateChanges, + trace + }; } - for (const addr in touchedStorage) { - let storageTrie: Trie; - - const storage = touchedStorage[addr] as TouchedStorage; - for (const keyStr in storage) { - const [address, key, valueAfter] = storage[keyStr] as [ - Address, - bigint, - bigint - ]; - if (storageTrie === undefined) { - // HACK: only the fork trie has a `getStorageTrie` method. - // i don't know why - storageTrie = beforeStateManager.getStorageTrie - ? await beforeStateManager.getStorageTrie(address.buf) - : await beforeStateManager._getStorageTrie(address as any); - } - const keyBuf = key === 0n ? BUFFER_32_ZERO : bigIntToBuffer(key); + results.push(result); - const addressSlotKey = addr + keyStr; - const before = - runningRawStorageSlots[addressSlotKey] || - decode(await storageTrie.get(keyBuf)); + // if we should _not_ continue on failue, and this transaction failed, + // break out of the loop + if (!continueOnFailure && result.result.exceptionError) break; + } finally { + gasTracer && gasTracer.reset(); + vm.eei.clearOriginalStorageCache(); + vm.eei.clearWarmedAccounts(); + await vm.eei.cleanupTouchedAccounts(); + } + } - const after = bigIntToBuffer(valueAfter); + return results; + } - runningRawStorageSlots[addressSlotKey] = after; - if (!before.equals(after)) { - storageChanges.push({ - address, - key: keyBuf, - before, - after - }); + private makeTraceElement(runState: RunState): TraceEntry | undefined { + // It'd be nice to show call heirarchy, either with nested calls or similar + const { opCode, env, programCounter, stack } = runState; + switch (opCode) { + case opcode.CALL: + case opcode.STATICCALL: + case opcode.CALLCODE: + case opcode.DELEGATECALL: + let inLength: bigint, inOffset: bigint, value: bigint, toAddr: bigint; + if (opCode === opcode.CALL || opCode === opcode.CALLCODE) { + [inLength, inOffset, value, toAddr] = stack._store.slice(-5, -1); + } else { + [inLength, inOffset, toAddr] = stack._store.slice(-4, -1); + } + const dataLength = Number(inLength); + const data = + dataLength === 0 ? BUFFER_EMPTY : Buffer.allocUnsafe(dataLength); + if (dataLength > 0) { + const dataOffset = Number(inOffset); + + runState.memory._store.copy( + data, + 0, + dataOffset, + dataLength + dataOffset + ); + } + const to = bigIntToBuffer(toAddr); + const functionSelector = data.length >= 4 ? data.readUIntBE(0, 4) : 0; + const signature = fourBytes.get(functionSelector); + + let args: { type: string; value: Quantity | Data }[]; + if (signature) { + const parameters = signature + .slice(signature.indexOf("(") + 1, signature.length - 1) + .split(","); + if (parameters.length > 0 && parameters[0] !== "") { + try { + const decoded = rawDecode(parameters, data.subarray(4)); + args = Array(parameters.length) as any; + for (let i = 0; i < parameters.length; i++) { + const type = parameters[i]; + const rawValue = decoded[i]; + let value: Data | Quantity; + if (Buffer.isBuffer(rawValue)) { + value = Data.from(rawValue); + } else { + switch (typeof rawValue) { + case "string": + value = Data.from(Buffer.from(rawValue, "hex")); + break; + case "bigint": + value = Quantity.from(bigIntToBuffer(rawValue)); + break; + default: + value = Data.from( + Buffer.from(rawValue.toString(16), "hex") + ); + break; + } + } + + args[i] = { + type, + value + }; + } + } catch (er) { + console.error( + er, + parameters, + Data.from(data.subarray(4)), + typeof value + ); } } } - - const totalGasSpent = intrinsicGas + result.execResult.executionGasUsed; - const maxRefund = totalGasSpent / 5n; - const actualRefund = - result.execResult.gasRefund > maxRefund - ? maxRefund - : result.execResult.gasRefund; - - const gasBreakdown = { - intrinsicGas, - executionGas: result.execResult.executionGasUsed, - refund: actualRefund, - actualGasCost: totalGasSpent - actualRefund + return { + opcode: Data.from(Buffer.from([opCode])), + name: opcode[opCode], + from: Address.from(env.codeAddress.buf), + to: Address.from(to), + signature, + value: value === undefined ? undefined : Quantity.from(value), + data: Data.from(data), + args, + pc: programCounter }; - - let gasEstimate: bigint | undefined; - if (gasTracer) { - gasEstimate = gasTracer.computeGasLimit() + intrinsicGas; - } - - results[i] = { - result: result.execResult, - gasBreakdown, - storageChanges, - stateChanges, - trace, - gasEstimate + case opcode.CREATE: + case opcode.CREATE2: + return { + opcode: Data.from(Buffer.from([opCode])), + name: opcode[opCode], + pc: programCounter }; - } else { - results[i] = { - result: { - runState: { programCounter: 0 }, - exceptionError: new VmError(ERROR.OUT_OF_GAS), - returnValue: BUFFER_EMPTY - }, - gasBreakdown: { - intrinsicGas, - executionGas: 0n, - refund: 0n, - actualGasCost: 0n - }, - storageChanges, - stateChanges, - trace, - gasEstimate: 0n + case opcode.JUMP: + case opcode.JUMPI: + const destination = Quantity.from(stack._store[stack.length - 1]); + const condition = + opCode === opcode.JUMPI + ? Quantity.from(stack._store[stack.length - 2]) + : undefined; + return { + opcode: Data.from(Buffer.from([opCode])), + name: opcode[opCode], + destination, + condition, + pc: programCounter }; - } - - gasTracer && gasTracer.reset(); - vm.eei.clearOriginalStorageCache(); - vm.eei.clearWarmedAccounts(); - await vm.eei.cleanupTouchedAccounts(); } - - return results; } /** 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 7e3a4e405c..3ff5f55424 100644 --- a/src/chains/ethereum/ethereum/src/data-managers/block-manager.ts +++ b/src/chains/ethereum/ethereum/src/data-managers/block-manager.ts @@ -1,6 +1,6 @@ import Manager from "./manager"; import { Tag, QUANTITY } from "@ganache/ethereum-utils"; -import { Quantity, Data, BUFFER_ZERO } from "@ganache/utils"; +import { Quantity, Data, BUFFER_ZERO, unref } from "@ganache/utils"; import type { Common } from "@ethereumjs/common"; import Blockchain from "../blockchain"; import { @@ -57,6 +57,33 @@ export default class BlockManager extends Manager { ) { const bm = new BlockManager(blockchain, common, blockIndexes, base); await bm.updateTaggedBlocks(); + if (blockchain.fallback) { + // hack: a hack to ensure `latest` is kept up to date. + // this just polls for `latest` every 7 seconds + unref( + setInterval(async () => { + const json = await blockchain.fallback.request( + "eth_getBlockByNumber", + ["latest", true], + { disableCache: true } + ); + if (json == null) { + return null; + } else { + const common = blockchain.fallback.getCommonForBlockNumber( + bm.#common, + BigInt(json.number) + ); + console.log("latest is now", parseInt(json.number)); + + bm.latest = new Block( + BlockManager.rawFromJSON(json, common), + common + ); + } + }, 7000) + ); + } return bm; } diff --git a/src/chains/ethereum/ethereum/src/forking/fork.ts b/src/chains/ethereum/ethereum/src/forking/fork.ts index bb38d29be6..8bc4517579 100644 --- a/src/chains/ethereum/ethereum/src/forking/fork.ts +++ b/src/chains/ethereum/ethereum/src/forking/fork.ts @@ -262,7 +262,10 @@ export class Fork { } public isValidForkBlockNumber(blockNumber: Quantity) { - return blockNumber.toBigInt() <= this.blockNumber.toBigInt(); + // TODO: this is a temporary fix for using ganache for remote transaction + // simulations. it breaks lots of things for normal ganache usage. + return true; + // return blockNumber.toBigInt() <= this.blockNumber.toBigInt(); } public selectValidForkBlockNumber(blockNumber: Quantity) { @@ -281,8 +284,14 @@ export class Fork { * @param common - * @param blockNumber - */ - public getCommonForBlockNumber(common: Common, blockNumber: BigInt) { - if (blockNumber <= this.blockNumber.toBigInt()) { + public getCommonForBlockNumber( + common: Common, + blockNumber: BigInt, + allowFuture = false + ) { + // if we are allowed to get a future hardfork block, then we should try to + // get a common for hardforks that will be activate at those block numbers + if (blockNumber <= this.blockNumber.toBigInt() || allowFuture) { // we are at or before our fork block let forkCommon: Common; @@ -310,6 +319,7 @@ export class Fork { { baseChain: 1 } ); } + (forkCommon as any).on = () => {}; return forkCommon; } else { return common; diff --git a/src/chains/ethereum/ethereum/src/forking/handlers/base-handler.ts b/src/chains/ethereum/ethereum/src/forking/handlers/base-handler.ts index eeb5afa091..3d1ea418a0 100644 --- a/src/chains/ethereum/ethereum/src/forking/handlers/base-handler.ts +++ b/src/chains/ethereum/ethereum/src/forking/handlers/base-handler.ts @@ -196,7 +196,8 @@ export class BaseHandler { if (hasOwn(response, "result")) { // cache non-error responses only - this.valueCache.set(key, raw); + // don't set the cache when "latest" is requested. + if (!key.includes("latest")) this.valueCache.set(key, raw); if (!options.disableCache && this.persistentCache) { // swallow errors for the persistentCache, since it's not vital that // it always works diff --git a/src/chains/ethereum/ethereum/src/forking/handlers/http-handler.ts b/src/chains/ethereum/ethereum/src/forking/handlers/http-handler.ts index 25118446d4..1f9382410d 100644 --- a/src/chains/ethereum/ethereum/src/forking/handlers/http-handler.ts +++ b/src/chains/ethereum/ethereum/src/forking/handlers/http-handler.ts @@ -10,10 +10,19 @@ import { BaseHandler } from "./base-handler"; import { Handler } from "../types"; import Deferred from "../deferred"; -const net = require("net"); -// work around a node v20 bug: https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 -if (net.setDefaultAutoSelectFamily) { - net.setDefaultAutoSelectFamily(false); +// Work around a node v20.0.0, v20.1.0, and v20.1.2 bug. The issue was fixed +// in v20.3.0. +// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 +// Safe to remove once support for Node v20 is dropped. +if ( + // webpack will replace process.env.IS_BROWSER with a boolean + !process.env.IS_BROWSER && + process.versions && + // check for `node` in case we want to use this in deno/bun/etc + process.versions.node && + process.versions.node.match(/20\.[0-2]\.0/) +) { + require("net").setDefaultAutoSelectFamily(false); } const { JSONRPC_PREFIX } = BaseHandler; diff --git a/src/chains/ethereum/ethereum/src/helpers/gas.ts b/src/chains/ethereum/ethereum/src/helpers/gas.ts index adc124d19f..b4bc777752 100644 --- a/src/chains/ethereum/ethereum/src/helpers/gas.ts +++ b/src/chains/ethereum/ethereum/src/helpers/gas.ts @@ -1,7 +1,5 @@ import { Interpreter } from "@ethereumjs/evm/dist/interpreter"; -import { precompiles } from "@ethereumjs/evm/dist/precompiles"; import { VM } from "@ethereumjs/vm"; -import { BUFFER_ZERO, Quantity } from "@ganache/utils"; // gas exactimation: // There are opcodes, the CALLs and CREATE (CREATE2? i dunno) that must // "withhold" 1/64th of gasLeft from the gasLimit of these internal CALL/CREATE. @@ -80,7 +78,7 @@ const SSTORE = 0x55; const CALL = 0xf1; const CALLCODE = 0xf2; -type Node = { +type Op = { /** * `code` is required for distinguishing SSTORE opcodes for special rules */ @@ -91,8 +89,8 @@ type Node = { */ minimum: bigint; stipend: bigint; - children: Node[]; - parent: Node | null; + children: Op[]; + parent: Op | null; name: string; }; @@ -113,9 +111,9 @@ function createNode( cost: bigint, minimum: bigint, stipend: bigint, - parent: Node | null, + parent: Op | null, name: string -): Node { +): Op { return { children: [], cost, @@ -140,9 +138,9 @@ function appendNewCallNode( cost: bigint, minimum: bigint, stipend: bigint, - parent: Node, + parent: Op, name: string -): Node { +): Op { const newNode = createNode(code, cost, minimum, stipend, parent, name); parent.children.push(newNode); return newNode; @@ -153,7 +151,7 @@ function appendNewCallNode( * * @param node The node to get the last child of. */ -function getLastChild({ children }: Node): Node | undefined { +function getLastChild({ children }: Op): Op | undefined { return children[children.length - 1]; } @@ -172,7 +170,7 @@ function max(a: bigint, b: bigint) { * * @param node The node to compute the gas costs of. */ -function _computeGas(node: Node) { +function _computeGas(node: Op) { const { children, cost, minimum, stipend } = node; if (children.length === 0) { return { @@ -239,19 +237,19 @@ function _computeGas(node: Node) { // which is: // `currentGasLeft = (availableGasLeft * 64) / 63 // See: https://www.wolframalpha.com/input?i=x+-+%28x%2F64%29+%3D+y - const sixtyFloorths = computeAllButOneSixtyFourth(totalMinimum); + const sixtyFourSixtyFourths = reverseAllButOneSixtyFloorth(totalMinimum); // the minimum gas required for this node and it's children, is the // children's minimum gas, plus withheld (1/64th) gas, plus the minimum gas // to execute this node. return { cost: totalCost + cost, - minimum: sixtyFloorths + minimum + minimum: sixtyFourSixtyFourths + minimum }; } } -function computeAllButOneSixtyFourth(minimumGas: bigint) { +function reverseAllButOneSixtyFloorth(minimumGas: bigint) { // it's possible for the minimum gas to be zero, without this check we'll // end up returning -1n, which is, um, wrong. if (minimumGas === 0n) return 0n; @@ -266,9 +264,9 @@ function computeAllButOneSixtyFourth(minimumGas: bigint) { // Because of 1/64th flooring there is precision loss when we want to // reverse it. This means there are sometimes two numbers that resolve // to the same "all but 1/64th" number. We should always pick the smaller - // of the two, since they both will compute the same "all by 1/64th" + // of the two, since they both will compute the same "all but 1/64th" // anyway. - // Two example `gasLeft`s that will result in the same "all by 1/64th" + // Two example `gasLeft`s that will result in the same "all but 1/64th" // number are `1023` and `1024`. The "all but 1/64th" number is `1008`. //https://www.wolframalpha.com/input?i=x+-+%E2%8C%8A%28x%2F64%29%E2%8C%8B+%3D+1008 return allButOneSixtyFourths % 64n === 0n @@ -276,60 +274,117 @@ function computeAllButOneSixtyFourth(minimumGas: bigint) { : allButOneSixtyFourths; } +function processNode( + op: Op, + results: Map +) { + if (op.children.length === 0) { + const cost = max(op.cost - op.stipend, 0n); + results.set(op, { + cost, + minimum: op.minimum + }); + } else { + // At any point, `totalMinimum` represents the total minimum gas required to + // execute the ops in `this.children` up to that point. This op's cost + // will be considered at the end. + // + // As we iterate over a child, `totalMinimum` becomes the total minimum gas + // required to execute the ops in `this.children` up to and including that + // op. At this point, `totalMinimum` may be greater than (`totalCost` + + // `minimum`) if previous ops had a signficant `minimum > cost` overhead. + // e.g.: + // + // Given ops: + // { cost: 10, minimum: 10 }, + // { cost: 10, minimum: 50 }, + // { cost: 10, minimum: 30 }, + // + // Before executing the third op: + // + // `totalCost` = 20 + // `this.minimum` = 30 + // `totalMinimum` = 60 + // + // therefore, + // `totalMinimum` := max(totalCost + minimum, totalMinimum) + // := max(20 + 30, 60) = 60 + + let totalMinimum = 0n; + let totalCost = 0n; + + for (const child of op.children) { + const { cost: childCost, minimum } = results.get(child); + totalMinimum = max(totalCost + minimum, totalMinimum); + // we need to carry the _actual_ cost forward, as that is what we spend, + // and is needed to calculate subsequent ops + totalCost += childCost; + } + + // This op's `stipend` is available (in its entirety - 1/64th is not + // "withheld") to its children (in the op's call frame). Therefore, the + // `totalMinimum` is decremented by the `stipend` amount. The stipend is not + // allowed to reduce the `totalMinimum` to < 0, because the actual _final_ + // `totalMinimum` must be at least this op's `minimum` value. The + // `totalCost` is also decremented by the `stipend` amount, because the + // `stipend` is "free gas". No worries if the `totalCost` becomes < 0, + // because the the `totalMinimum` will ensure that `gasLeft` never + // decrements below 0. + if (op.stipend !== 0n) { + totalMinimum = max(0n, totalMinimum - op.stipend); + totalCost -= op.stipend; + } + + // sixtyFourSixtyFourths represents the amount of gas required before the + // CALL. It is the amount of gas before 63/64ths is calculated. It is not + // neccessarily the amount of gas that is passed to the CALL, i.e., + // `call(gas(), ...)`, as the developer could have specified a their own + // gas amount. If the gas amount the dev passed to the CALL is less than + // totalMinimum (63/64ths of `sixtyFourSixtyFourths`) then we need to use + // that number instead. + // This is number is not possible to compute in every case, but we can + // guess at it by looking at the difference between `totalMinimum` and the + // `gasLeft` at the time of the `CALL`, and the gas the CALL itself actually + // received. think: `delegatecall(sub(gas(), 10000), ...)` + // If the CALL received `n` gas less less than the actual gas left at the + // time, we can assume the contract did something like: + // `delegatecall(sub(preCallGasLimit, n), ...)` + const sixtyFourSixtyFourths = reverseAllButOneSixtyFloorth(totalMinimum); + + // the minimum gas required for this op and its children, is the + // children's minimum gas, plus withheld (1/64th) gas, plus the minimum gas + // to execute this op. + results.set(op, { + cost: totalCost + op.cost, + minimum: sixtyFourSixtyFourths + op.minimum + }); + } +} + /** * Computes the actual and required gas costs of the node. * * @param root The root node to compute the gas costs of. */ -function computeGasIt(root: Node) { +function computeGasIt(root: Op) { // Initialize stack with root node const stack: { - node: Node; - parentResult?: { cost: bigint; minimum: bigint }; - }[] = [{ node: root }]; - const results: Map = new Map(); + op: Op; + result?: { cost: bigint; minimum: bigint }; + }[] = [{ op: root }]; + const results: Map = new Map(); while (stack.length > 0) { - const { node, parentResult } = stack[stack.length - 1]; + const node = stack[stack.length - 1]; - if (node.children.length > 0 && !parentResult) { + if (node.op.children.length > 0 && !node.result) { // Push all children to stack, marking the parent as having been processed - stack[stack.length - 1].parentResult = { cost: 0n, minimum: 0n }; - for (const child of node.children) { - stack.push({ node: child }); - } + node.result = { cost: 0n, minimum: 0n }; + for (const child of node.op.children) stack.push({ op: child }); } else { // Process node stack.pop(); - if (node.children.length === 0) { - const cost = max(node.cost - node.stipend, 0n); - results.set(node, { - cost, - minimum: node.minimum - }); - } else { - let totalMinimum = 0n; - let totalCost = 0n; - - for (const child of node.children) { - const { cost: childCost, minimum } = results.get(child); - totalMinimum = max(totalCost + minimum, totalMinimum); - // we need to carry the _actual_ cost forward, as that is what we spend - totalCost += childCost; - } - - if (node.stipend !== 0n) { - totalMinimum = max(0n, totalMinimum - node.stipend); - totalCost -= node.stipend; - } - - const allButOneSixtyFourths = computeAllButOneSixtyFourth(totalMinimum); - - results.set(node, { - cost: totalCost + node.cost, - minimum: allButOneSixtyFourths + node.minimum - }); - } + processNode(node.op, results); } } @@ -337,8 +392,8 @@ function computeGasIt(root: Node) { } export class GasTracer { - root: Node; - node: Node; + root: Op; + node: Op; depth: number; constructor(private readonly vm: VM) { this.node = this.root = createNode(-1, 0n, 0n, undefined, null, "ROOT"); @@ -387,6 +442,8 @@ export class GasTracer { // 1. a depth increasing opcode that updates `this.node` to `this.node`'s last child. // 2. a depth decreasing opcode that updates `this.node` to `this.node`'s `parent`. // In otherwords: `depth` and `node` don't need to change. + // Note: `callNode` could be `undefined` in cases like when someone sends + // a transactions with data to an account that doesn't have code. appendNewCallNode(-1, gasUsed, gasUsed, 0n, callNode, `PRECOMPILE`); } @@ -453,7 +510,7 @@ export class GasTracer { // over-written (we need it to be a single node to ensure that it's // stipend is applied correctly). (actually if it has no children, we // can probably calculate it's minimum, cost, and stipend, but will - // need to handle it's stipend correctly) + // need to handle its stipend correctly) previousSibling.stipend === 0n ) { previousSibling.minimum = previousSibling.cost += fee; @@ -489,7 +546,7 @@ export class GasTracer { this.depth = depth; } - // add the new node `this.node`'s call tree + // add the new node to `this.node`'s call tree appendNewCallNode(opcode, fee, minimum, stipend, this.node, name); } diff --git a/src/chains/ethereum/ethereum/tests/gas/gas.test.ts b/src/chains/ethereum/ethereum/tests/gas/gas.test.ts index cba26396fc..5da14ae89f 100644 --- a/src/chains/ethereum/ethereum/tests/gas/gas.test.ts +++ b/src/chains/ethereum/ethereum/tests/gas/gas.test.ts @@ -62,7 +62,7 @@ contract Storage { params: [ { transactions: [tx], - gasEstimation: "call-depth" + estimateGas: true } ] }; diff --git a/src/packages/ganache/npm-shrinkwrap.json b/src/packages/ganache/npm-shrinkwrap.json index 71808fbf37..57d5283b04 100644 --- a/src/packages/ganache/npm-shrinkwrap.json +++ b/src/packages/ganache/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "ganache", - "version": "7.8.0-transaction-simulation", + "version": "7.8.0-tx-sim.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ganache", - "version": "7.8.0-transaction-simulation", + "version": "7.8.0-tx-sim.0", "license": "MIT", "dependencies": { "@trufflesuite/bigint-buffer": "1.1.10", diff --git a/src/packages/ganache/package.json b/src/packages/ganache/package.json index 0256ae34b2..100dacc7f8 100644 --- a/src/packages/ganache/package.json +++ b/src/packages/ganache/package.json @@ -1,6 +1,6 @@ { "name": "ganache", - "version": "7.8.0-transaction-simulation", + "version": "7.8.0-tx-sim.2", "description": "A library and cli to create a local blockchain for fast Ethereum development.", "author": "David Murdoch", "homepage": "https://github.com/trufflesuite/ganache/tree/develop/src/packages/ganache#readme", diff --git a/src/packages/sim/app.js b/src/packages/sim/app.js index 35b90c1f1f..159eaa4c28 100644 --- a/src/packages/sim/app.js +++ b/src/packages/sim/app.js @@ -48,7 +48,7 @@ document.addEventListener('DOMContentLoaded', () => { const value = element.value.trim(); if (value) { if ("SELECT" === element.tagName) { - tx[element.name] = value; + tx[element.name] = value === "true"; } else { if (element.getAttribute("pattern")) { tx[element.name] = value.toLowerCase().startsWith("0x") ? value : "0x" + parseInt(value).toString(16); @@ -65,7 +65,7 @@ document.addEventListener('DOMContentLoaded', () => { const value = element.value.trim(); if (value) { if ("SELECT" === element.tagName) { - json.params[0][element.name] = value; + json.params[0][element.name] = value === "true"; } else { if (element.getAttribute("pattern")) { if (element.name === "block" && value.toLowerCase() === "latest") { @@ -92,17 +92,19 @@ document.addEventListener('DOMContentLoaded', () => { // `evm_simulateTransactions` call: transactions.addEventListener('change', formatJson); advancedOptions.addEventListener('change', formatJson); - let pre = ""; + const preFetchCache = new Set(); advancedOptions.addEventListener('change', async () => { // prefetch when advanced options change try { const jsonRPC = JSON.stringify(JSON.parse(requestElement.dataset.json)); - if (jsonRPC === pre) return; - pre = jsonRPC; + if (preFetchCache.has(jsonRPC)) return; + preFetchCache.add(jsonRPC); + console.log("prefetch"); + await fetch('/simulate', { method: 'POST', headers: { - 'Content-Type': 'applicaiton/json', + 'Content-Type': 'application/json', }, body: jsonRPC, }); @@ -114,6 +116,8 @@ document.addEventListener('DOMContentLoaded', () => { const responseElement = document.getElementById("responseBody"); document.querySelector("form").addEventListener('submit', async (event) => { + preFetchCache.clear(); + event.preventDefault(); // disable the submit button: document.querySelector("form button").disabled = true; @@ -126,7 +130,7 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch('/simulate', { method: 'POST', headers: { - 'Content-Type': 'applicaiton/json', + 'Content-Type': 'application/json', }, body: JSON.stringify(jsonRPC), }); diff --git a/src/packages/sim/docs.md b/src/packages/sim/docs.md new file mode 100644 index 0000000000..75d19bec1c --- /dev/null +++ b/src/packages/sim/docs.md @@ -0,0 +1,316 @@ +# Simulate Transactions Documentation + +The RPC method is named `evm_simulateTransactions`. + +Send requests just as you would any Ethereum JSON-RPC 2.0 request. + +RPC requests temporarily return a top level `durationMs` property (alongside id, jsonrpc, and result/error) which represents the amount of time the request took to process in milliseconds. You can use this value to better understand how RTT to the RPC host will affect users in different areas of the world. + +## TypeScript Interface + +Also see [types.ts](./types.ts). + +```typescript +/** + * 0x prefixed hex pairs, e.g., `0x0123` + */ +export type DATA = string; + +/** + * 0x prefixed 20 byte ethereum address + */ +export type ADDRESS = DATA; + +/** + * 0x-prefixed compact hex string, e.g., `0x123` + */ +export type QUANTITY = string; + +/** + * Request params for sending an `evm_simulateTransactions` RPC request. + */ +export type SimulationRequestParams = [ + { + /** + * The transactions you want to simulate, in order. + */ + transactions: Transaction[]; + /** + * `true` to compute and return a gas estimate, otherwise `false`. + * The gas estimate returned here uses the `gas` limit set in each transaction. + * This means that if the transaction runs out of gas at the user-specified + * limit the gas estimate will reflect the amount of gas required to get + * to the point at which it ran out of gas. + * default is `false` + */ + estimateGas?: boolean; + /** + * `true` to return a transaction CALL* trace, otherwise `false`. + * default is `false` + */ + trace?: boolean; + + /** + * `true` to run all transactions even if preceding transactions fail. + * default is `false` + */ + continueOnFailure?: boolean; + + overrides: { + [address: ADDRESS]: StateOverride; + }; + }, + /** + * The block number the transaction should be simulated on top of. This is how + * `eth_call` works, but differs from how Tenderly works. + */ + QUANTITY | "latest" +]; + +/** + * Identical to the `eth_call` overrides argument + */ +export type StateOverride = Partial< + ( + | { state: { [slot: DATA]: DATA } } + | { stateDiff: { [slot: DATA]: DATA } } + ) & { + code: DATA; + nonce: QUANTITY; + balance: QUANTITY; + } +>; + +/** + * The transaction to simulate. + */ +export type Transaction = { + from: ADDRESS; + to?: ADDRESS; + /** + * This value is also used as an upper limit for gas estimations. + */ + gas?: QUANTITY; + gasPrice?: QUANTITY; + value?: QUANTITY; + data?: DATA; +}; + +/** + * The response to an `evm_simulateTransactions` RPC request. + */ +export type SimulationResponse = SimulationResult[]; + +export type SimulationResult = { + error?: { + code: number; + message: string; + }; + returnValue?: DATA; + gas: GasBreakdown; + logs: Log[]; + storageChanges: StorageChange[]; + stateChanges: StateChange[]; + receipts?: DATA[]; + /** + * The trace of the transaction. This is only returned if `trace` is set to true. + */ + trace?: TraceEntry[]; +}; + +export type Log = [address: ADDRESS, topics: DATA[], data: DATA]; + +export type GasBreakdown = { + /** + * The total amount of gas used by the transaction. + */ + total: QUANTITY; + + /** + * Total gas used minus the refund. This is what etherscan reports as `Gas Usage`. + */ + actual: QUANTITY; + + /** + * The amount of gas refunded to the sender. + */ + refund: QUANTITY; + + /** + * The amount of gas the EVM requires before it would attempt to run the + * transaction. + */ + intrinsic: QUANTITY; + + /** + * The amount of gas used by the transaction's actual execution. + */ + execution: QUANTITY; + + /** + * The minimum amount of gas required to run the transaction. This is only returned if `estimateGas` is set to true. + */ + estimate?: QUANTITY; +}; + +type StorageChange = { + key: DATA; + address: ADDRESS; + before: DATA; + after: DATA; +}; + +type StateChange = { + address: ADDRESS; + from: { + nonce: QUANTITY; + balance: QUANTITY; + storageRoot: DATA; + codeHash: DATA; + }; + to: { + nonce: QUANTITY; + balance: QUANTITY; + storageRoot: DATA; + codeHash: DATA; + }; +}; + +export type TraceEntry = { + /** + * The opcode of the trace entry. + * Currently limited to opcodes for CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, JUMP, JUMPI + * + */ + opcode: Data; + /** + * The name of the opcode (CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, JUMP, JUMPI) + */ + name: string; + pc: number; + /** + * Decoded function signature (via 4byte directory) + */ + signature?: string; +} & (CALLTraceEntry | JUMPTraceEntry | {}); // {} because CREATE and CREATE2 materialize as just the base TraceEntry + +export type CALLTraceEntry = { + from?: Address; + to?: Address; + value?: Quantity; + data?: Data; + args?: { type: string; value: Quantity | Data }[]; +}; + +export type JUMPTraceEntry = { + destination: Quantity; + condition?: Quantity; +}; +``` + +## Example Usage + +```typescript +/** + * Sends a JSON-RPC request to the `evm_simulateTransactions` endpoint. + * + * @param {SimulationRequestParams} params - The request parameters. + * @param {string} endpoint - The endpoint URL. + * @returns {Promise} - The response from the endpoint. + */ +async function sendSimulationRequest( + params: SimulationRequestParams, + endpoint: string +): Promise { + const data = { + jsonrpc: "2.0", + method: "evm_simulateTransactions", + params: params, + id: 1 + }; + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + }); + + const json = await response.json(); + return json.result as SimulationResponse; + } catch (error) { + throw error; + } +} + +/** + * Logs information about gas usage and transaction success. + * + * @param {SimulationResponse} response - The simulation response. + */ +function logGasUsageAndSuccess(response: SimulationResponse) { + response.forEach((result: SimulationResult, index: number) => { + console.log(`Transaction ${index + 1} Gas Usage:`); + console.log("Total:", BigInt(result.gas.total).toLocaleString()); + console.log("Actual:", BigInt(result.gas.actual).toLocaleString()); + console.log("Refund:", BigInt(result.gas.refund).toLocaleString()); + console.log("Intrinsic:", BigInt(result.gas.intrinsic).toLocaleString()); + console.log("Execution:", BigInt(result.gas.execution).toLocaleString()); + if (result.gas.estimate) { + console.log("Estimate:", BigInt(result.gas.estimate).toLocaleString()); + } + + console.log( + `Transaction ${index + 1} Status:`, + !result.error ? "Successful" : "Failed" + ); + if (result.error) { + console.log("Failure Reason:", result.error.message); + } + console.log("---"); + }); + + console.log("Total Gas Usage:"); + const totalGas = response.reduce((acc: bigint, result: SimulationResult) => { + const gasUsed = BigInt(result.gas.actual); + return acc + gasUsed; + }, 0n); + console.log(totalGas.toLocaleString()); + console.log("---"); +} + +// Example usage: +const endpoint = "https://example.com/jsonrpc"; // Replace with your actual Ganache simulator endpoint URL +const simulationParams = [ + { + transactions: [ + { + from: "0xd7c2b5c77f0ba843d863e1ed488d40472de53ec9", + to: "0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", + gas: "0x8000000000000000", + data: "0x095ea7b300000000000000000000000082e0b8cdd80af5930c4452c684e71c861148ec8a000000000000000000000000000000000000000000000002817b497c9ca44000" + }, + { + from: "0xd7c2b5c77f0ba843d863e1ed488d40472de53ec9", + to: "0x82e0b8cdd80af5930c4452c684e71c861148ec8a", + gas: "0x8000000000000000", + data: "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000007d1afa7b718fb893db30a3abc0cfc608aacfebb0000000000000000000000000000000000000000000000002817b497c9ca4400000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000b6c6966694164617074657200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000005d3675d698a3dd53e3457951e1debef717a29a720000000000000000000000005d3675d698a3dd53e3457951e1debef717a29a7200000000000000000000000000000000000000000000000000000000000000890000000000000000000000007d1afa7b718fb893db30a3abc0cfc608aacfebb000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000027bde5e48a43b22000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000059ceb33f8691e00000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de5152000000000000000000000000000000000000000000000000000000000000009033619a2d20e0f32c0ab1598bd7c2b5c77f0ba843d863e1ed488d40472de53ec9000000897d1afa7b718fb893db30a3abc0cfc608aacfebb000000000000000027bde5e48a43b220000000000000000025b84aa3e4b74b24600000000000000000000000000000000000000000000000000000000000000000000000022b1cbb8d98a01a3b71d034bb899775a76eb1cc200000000000000000000000000000000" + } + ], + estimateGas: true, + trace: true + }, + "0x10ab97e" // block number to simulate on top of +] as SimulationRequestParams; + +sendSimulationRequest(simulationParams, endpoint) + .then(response => { + console.log("Simulation Response:", response); + logGasUsageAndSuccess(response); + }) + .catch(error => { + console.error("Error:", error); + }); +``` diff --git a/src/packages/sim/index.html b/src/packages/sim/index.html index d10f82b859..b9c4449a30 100644 --- a/src/packages/sim/index.html +++ b/src/packages/sim/index.html @@ -47,13 +47,21 @@ ganache logo

Ganache

+
+ +

Transactions Simulator

-
+
@@ -159,6 +167,7 @@

Transactions Simulator

id="gasLimit" name="gasLimit" required + placeholder="50000000" value="50000000" pattern="^[0-9]+$|^(0x[a-fA-F0-9]+)$" /> @@ -221,7 +230,9 @@

Transactions Simulator

The string "latest" or a block number as an - integer or 0x prefixed hex. + integer or 0x prefixed hex. The transasctions will + be simulated in the block immediately following + the specified block number.
@@ -230,13 +241,14 @@

Transactions Simulator

id="blockNumber" name="block" required + placeholder="latest" value="latest" pattern="^latest$|^[0-9]+$|^(0x[a-fA-F0-9]+)$" />
- + How precise should the returned gas estimation be? @@ -245,15 +257,19 @@

Transactions Simulator

@@ -272,9 +288,32 @@

Transactions Simulator

name="trace" required > - - - + + + + +
+
+ +
+ + + + Should the simulation continue if a transaction + fails? + + +
+
diff --git a/src/packages/sim/index.ts b/src/packages/sim/index.ts index 1f192487db..8599943411 100644 --- a/src/packages/sim/index.ts +++ b/src/packages/sim/index.ts @@ -1,5 +1,9 @@ import http from "http"; import fs from "fs"; +let remote = true; +const hostname = remote ? "3.140.186.190" : "localhost"; +const port = remote ? 8080 : 8545; + const index = fs.readFileSync(__dirname + "/index.html"); const results = fs.readFileSync(__dirname + "/results.html"); const css = fs.readFileSync(__dirname + "/main.css"); @@ -9,8 +13,6 @@ const jsonview = fs.readFileSync(__dirname + "/jsonview.js"); const chevron = fs.readFileSync(__dirname + "/chevron.svg"); const ganache = fs.readFileSync(__dirname + "/ganache.svg"); -const port = 3000; - const server = http.createServer((req, res) => { if (req.method === "GET" && req.url === "/") { res.writeHead(200, { "Content-Type": "text/html" }); @@ -41,10 +43,10 @@ const server = http.createServer((req, res) => { // send the POST request to the simulation server // we just take the body from the request and send it to the simulation server // and then return the result directly to the user: - let remote = false; + const options = { - hostname: remote ? "3.140.186.190" : "localhost", - port: remote ? 8080 : 8545, + hostname: hostname, + port: port, path: "/", method: "POST", headers: { @@ -79,6 +81,6 @@ const server = http.createServer((req, res) => { } }); -server.listen(port, () => { - console.log(`Server is running on http://localhost:${port}`); +server.listen(9009, () => { + console.log(`Server is running on http://localhost:${9009}`); }); diff --git a/src/packages/sim/main.css b/src/packages/sim/main.css index 503d6e559d..c4f992c0a6 100644 --- a/src/packages/sim/main.css +++ b/src/packages/sim/main.css @@ -80,7 +80,7 @@ button.run-button { margin: 1rem auto; position: relative; } -.content { +.content.split { grid-gap: 2rem; display: grid; gap: 2rem; diff --git a/src/packages/sim/package.json b/src/packages/sim/package.json index 0919a3a029..ed10be8173 100644 --- a/src/packages/sim/package.json +++ b/src/packages/sim/package.json @@ -26,9 +26,7 @@ }, "scripts": { "start": "ts-node index.ts", - "tsc": "tsc --build", - "test": "nyc npm run mocha", - "mocha": "cross-env TS_NODE_FILES=true mocha --exit --check-leaks --throw-deprecation --trace-warnings --require ts-node/register 'tests/**/*.test.ts'" + "tsc": "tsc --build" }, "bugs": { "url": "https://github.com/trufflesuite/ganache/issues" @@ -49,10 +47,6 @@ "truffle" ], "devDependencies": { - "@types/mocha": "9.0.0", - "cross-env": "7.0.3", - "mocha": "9.1.3", - "nyc": "15.1.0", "ts-node": "10.9.1", "typescript": "4.7.4" } diff --git a/src/packages/sim/tsconfig.json b/src/packages/sim/tsconfig.json index 2f07ebea64..de99d04e0f 100644 --- a/src/packages/sim/tsconfig.json +++ b/src/packages/sim/tsconfig.json @@ -1,11 +1,24 @@ { - "extends": "../../tsconfig-base.json", "compilerOptions": { + "alwaysStrict": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true, + "module": "CommonJS", + "esModuleInterop": true, + "target": "ES2020", + "moduleResolution": "node", + "noErrorTruncation": true, + "sourceMap": true, + "strict": true, + /** `noImplicitAny: false` and `strictNullChecks: false` are temporary during initial rapid development. */ + "strictNullChecks": false, + "noImplicitAny": false, + "newLine": "lf", + "lib": ["ES2020"], + "experimentalDecorators": true, "outDir": "lib", "declarationDir": "typings" }, - "include": [ - "src", - "index.ts" - ] + "include": ["src", "index.ts"] } diff --git a/src/packages/sim/types.ts b/src/packages/sim/types.ts new file mode 100644 index 0000000000..2d81c8a23a --- /dev/null +++ b/src/packages/sim/types.ts @@ -0,0 +1,195 @@ +/** + * 0x prefixed hex pairs, e.g., `0x0123` + */ +export type DATA = string; + +/** + * 0x prefixed 20 byte ethereum address + */ +export type ADDRESS = DATA; + +/** + * 0x-prefixed compact hex string, e.g., `0x123` + */ +export type QUANTITY = string; + +/** + * Request params for sending an `evm_simulateTransactions` RPC request. + */ +export type SimulationRequestParams = [ + { + /** + * The transactions you want to simulate, in order. + */ + transactions: Transaction[]; + /** + * `true` to compute and return a gas estimate, otherwise `false`. + * The gas estimate returned here uses the `gas` limit set in each transaction. + * This means that if the transaction runs out of gas at the user-specified + * limit the gas estimate will reflect the amount of gas required to get + * to the point at which it ran out of gas. + * default is `false` + */ + estimateGas?: boolean; + /** + * `true` to return a transaction CALL* trace, otherwise `false`. + * default is `false` + */ + trace?: boolean; + + /** + * `true` to run all transactions even if preceding transactions fail. + * default is `false` + */ + continueOnFailure?: boolean; + + overrides: { + [address: ADDRESS]: StateOverride; + }; + }, + /** + * The block number the transaction should be simulated on top of. This is how + * `eth_call` works, but differs from how Tenderly works. + */ + QUANTITY | "latest" +]; + +/** + * Identical to the `eth_call` overrides argument + */ +export type StateOverride = Partial< + ( + | { state: { [slot: DATA]: DATA } } + | { stateDiff: { [slot: DATA]: DATA } } + ) & { + code: DATA; + nonce: QUANTITY; + balance: QUANTITY; + } +>; + +/** + * The transaction to simulate. + */ +export type Transaction = { + from: ADDRESS; + to?: ADDRESS; + /** + * This value is also used as an upper limit for gas estimations. + */ + gas?: QUANTITY; + gasPrice?: QUANTITY; + value?: QUANTITY; + data?: DATA; +}; + +/** + * The response to an `evm_simulateTransactions` RPC request. + */ +export type SimulationResponse = SimulationResult[]; + +export type SimulationResult = { + error?: { + code: number; + message: string; + }; + returnValue?: DATA; + gas: GasBreakdown; + logs: Log[]; + storageChanges: StorageChange[]; + stateChanges: StateChange[]; + receipts?: DATA[]; + /** + * The trace of the transaction. This is only returned if `trace` is set to true. + */ + trace?: TraceEntry[]; +}; + +export type Log = [address: ADDRESS, topics: DATA[], data: DATA]; + +export type GasBreakdown = { + /** + * The total amount of gas used by the transaction. + */ + total: QUANTITY; + + /** + * Total gas used minus the refund. This is what etherscan reports as `Gas Usage`. + */ + actual: QUANTITY; + + /** + * The amount of gas refunded to the sender. + */ + refund: QUANTITY; + + /** + * The amount of gas the EVM requires before it would attempt to run the + * transaction. + */ + intrinsic: QUANTITY; + + /** + * The amount of gas used by the transaction's actual execution. + */ + execution: QUANTITY; + + /** + * The minimum amount of gas required to run the transaction. This is only returned if `estimateGas` is set to true. + */ + estimate?: QUANTITY; +}; + +type StorageChange = { + key: DATA; + address: ADDRESS; + before: DATA; + after: DATA; +}; + +type StateChange = { + address: ADDRESS; + from: { + nonce: QUANTITY; + balance: QUANTITY; + storageRoot: DATA; + codeHash: DATA; + }; + to: { + nonce: QUANTITY; + balance: QUANTITY; + storageRoot: DATA; + codeHash: DATA; + }; +}; + +export type TraceEntry = { + /** + * The opcode of the trace entry. + * Currently limited to opcodes for CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, JUMP, JUMPI + * + */ + opcode: DATA; + /** + * The name of the opcode (CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, JUMP, JUMPI) + */ + name: string; + pc: number; + /** + * Decoded function signature (via 4byte directory) + */ + signature?: string; +} & (CALLTraceEntry | JUMPTraceEntry | {}); // {} because CREATE and CREATE2 materialize as just the base TraceEntry + +export type CALLTraceEntry = { + from?: ADDRESS; + to?: ADDRESS; + value?: QUANTITY; + data?: DATA; + args?: { type: string; value: QUANTITY | DATA }[]; +}; + +export type JUMPTraceEntry = { + destination: QUANTITY; + condition?: QUANTITY; +};