diff --git a/nimbus/constants.nim b/nimbus/constants.nim index 4ef37f725..aff513dff 100644 --- a/nimbus/constants.nim +++ b/nimbus/constants.nim @@ -68,4 +68,6 @@ const ## standard secp256k1 curve. 65 + DEFAULT_RPC_GAS_CAP* = 50_000_000.GasInt + # End diff --git a/nimbus/graphql/ethapi.nim b/nimbus/graphql/ethapi.nim index f798f35c9..355060117 100644 --- a/nimbus/graphql/ethapi.nim +++ b/nimbus/graphql/ethapi.nim @@ -15,8 +15,8 @@ import graphql, graphql/graphql as context, graphql/common/types, graphql/httpserver, graphql/instruments/query_complexity, - ../db/[db_chain, state_db], ../utils, - ../transaction, ../rpc/rpc_utils, ../vm_state, ../config, + ../db/[db_chain, state_db], ../rpc/rpc_utils, + ".."/[utils, transaction, vm_state, config, constants], ../transaction/call_evm from eth/p2p import EthereumNode @@ -901,44 +901,64 @@ proc blockAccount(ud: RootRef, params: Args, parent: Node): RespResult {.apiPrag let address = hexToByteArray[20](params[0].val.stringVal) ctx.accountNode(h.header, address) -proc toCallData(n: Node): (RpcCallData, bool) = - # phew, probably need to use macro here :) - var cd: RpcCallData - var gasLimit = false - if n[0][1].kind != nkEmpty: - cd.source = hextoByteArray[20](n[0][1].stringVal) +const + fFrom = 0 + fTo = 1 + fGasLimit = 2 + fGasPrice = 3 + fMaxFee = 4 + fMaxPriorityFee = 5 + fValue = 6 + fData = 7 - if n[1][1].kind != nkEmpty: - cd.to = hextoByteArray[20](n[1][1].stringVal) - else: - cd.contractCreation = true +template isSome(n: Node, field: int): bool = + # [0] is the field's name node + # [1] is the field's value node + n[field][1].kind != nkEmpty - if n[2][1].kind != nkEmpty: - cd.gas = parseU64(n[2][1]).GasInt - gasLimit = true - else: - # TODO: this is globalGasCap in geth - cd.gas = GasInt(high(uint64) div 2) +template fieldString(n: Node, field: int): string = + n[field][1].stringVal - if n[3][1].kind != nkEmpty: - let gasPrice = parse(n[3][1].stringVal, UInt256, radix = 16) - cd.gasPrice = gasPrice.truncate(GasInt) +template optionalAddress(dstField: untyped, n: Node, field: int) = + if isSome(n, field): + var address: EthAddress + hexToByteArray(fieldString(n, field), address) + dstField = some(address) - if n[4][1].kind != nkEmpty: - cd.value = parse(n[4][1].stringVal, UInt256, radix = 16) +template optionalGasInt(dstField: untyped, n: Node, field: int) = + if isSome(n, field): + dstField = some(parseU64(n[field][1]).GasInt) - if n[5][1].kind != nkEmpty: - cd.data = hexToSeqByte(n[5][1].stringVal) +template optionalGasHex(dstField: untyped, n: Node, field: int) = + if isSome(n, field): + let gas = parse(fieldString(n, field), UInt256, radix = 16) + dstField = some(gas.truncate(GasInt)) - (cd, gasLimit) +template optionalHexU256(dstField: untyped, n: Node, field: int) = + if isSome(n, field): + dstField = some(parse(fieldString(n, field), UInt256, radix = 16)) + +template optionalBytes(dstField: untyped, n: Node, field: int) = + if isSome(n, field): + dstField = hexToSeqByte(fieldString(n, field)) + +proc toCallData(n: Node): RpcCallData = + optionalAddress(result.source, n, fFrom) + optionalAddress(result.to, n, fTo) + optionalGasInt(result.gasLimit, n, fGasLimit) + optionalGasHex(result.gasPrice, n, fGasPrice) + optionalGasHex(result.maxFee, n, fMaxFee) + optionalGasHex(result.maxPriorityFee, n, fMaxPriorityFee) + optionalHexU256(result.value, n, fValue) + optionalBytes(result.data, n, fData) proc makeCall(ctx: GraphqlContextRef, callData: RpcCallData, header: BlockHeader, chainDB: BaseChainDB): RespResult = - let (outputHex, gasUsed, isError) = rpcMakeCall(callData, header, chainDB) + let res = rpcCallEvm(callData, header, chainDB) var map = respMap(ctx.ids[ethCallResult]) - map["data"] = resp("0x" & outputHex) - map["gasUsed"] = longNode(gasUsed).get() - map["status"] = longNode(if isError: 0 else: 1).get() + map["data"] = resp("0x" & res.output.toHex) + map["gasUsed"] = longNode(res.gasUsed).get() + map["status"] = longNode(if res.isError: 0 else: 1).get() ok(map) proc blockCall(ud: RootRef, params: Args, parent: Node): RespResult {.apiPragma.} = @@ -946,7 +966,7 @@ proc blockCall(ud: RootRef, params: Args, parent: Node): RespResult {.apiPragma. let h = HeaderNode(parent) let param = params[0].val try: - let (callData, gasLimit) = toCallData(param) + let callData = toCallData(param) ctx.makeCall(callData, h.header, ctx.chainDB) except Exception as em: err("call error: " & em.msg) @@ -956,8 +976,9 @@ proc blockEstimateGas(ud: RootRef, params: Args, parent: Node): RespResult {.api let h = HeaderNode(parent) let param = params[0].val try: - let (callData, gasLimit) = toCallData(param) - let gasUsed = rpcEstimateGas(callData, h.header, ctx.chainDB, gasLimit) + let callData = toCallData(param) + # TODO: DEFAULT_RPC_GAS_CAP should configurable + let gasUsed = rpcEstimateGas(callData, h.header, ctx.chainDB, DEFAULT_RPC_GAS_CAP) longNode(gasUsed) except Exception as em: err("estimateGas error: " & em.msg) diff --git a/nimbus/rpc/p2p.nim b/nimbus/rpc/p2p.nim index 4a0e51e7d..18e9d4679 100644 --- a/nimbus/rpc/p2p.nim +++ b/nimbus/rpc/p2p.nim @@ -266,8 +266,9 @@ proc setupEthRpc*(node: EthereumNode, ctx: EthContext, chain: BaseChainDB , serv ## Returns the return value of executed contract. let header = headerFromTag(chain, quantityTag) - callData = callData(call, true, chain) - result = rpcDoCall(callData, header, chain) + callData = callData(call) + res = rpcCallEvm(callData, header, chain) + result = hexDataStr(res.output) server.rpc("eth_estimateGas") do(call: EthCall, quantityTag: string) -> HexQuantityStr: ## Generates and returns an estimate of how much gas is necessary to allow the transaction to complete. @@ -279,8 +280,9 @@ proc setupEthRpc*(node: EthereumNode, ctx: EthContext, chain: BaseChainDB , serv ## Returns the amount of gas used. let header = chain.headerFromTag(quantityTag) - callData = callData(call, false, chain) - gasUsed = rpcEstimateGas(callData, header, chain, call.gas.isSome) + callData = callData(call) + # TODO: DEFAULT_RPC_GAS_CAP should configurable + gasUsed = rpcEstimateGas(callData, header, chain, DEFAULT_RPC_GAS_CAP) result = encodeQuantity(gasUsed.uint64) server.rpc("eth_getBlockByHash") do(data: EthHashStr, fullTransactions: bool) -> Option[BlockObject]: diff --git a/nimbus/rpc/rpc_types.nim b/nimbus/rpc/rpc_types.nim index ba9104aea..025287789 100644 --- a/nimbus/rpc/rpc_types.nim +++ b/nimbus/rpc/rpc_types.nim @@ -33,8 +33,10 @@ type # Parameter from user source*: Option[EthAddressStr] # (optional) The address the transaction is send from. to*: Option[EthAddressStr] # (optional in eth_estimateGas, not in eth_call) The address the transaction is directed to. - gas*: Option[HexQuantityStr]# (optional) Integer of the gas provided for the transaction execution. eth_call consumes zero gas, but this parameter may be needed by some executions. + gas*: Option[HexQuantityStr] # (optional) Integer of the gas provided for the transaction execution. eth_call consumes zero gas, but this parameter may be needed by some executions. gasPrice*: Option[HexQuantityStr]# (optional) Integer of the gasPrice used for each paid gas. + maxFeePerGas*: Option[HexQuantityStr] # (optional) MaxFeePerGas is the maximum fee per gas offered, in wei. + maxPriorityFeePerGas*: Option[HexQuantityStr] # (optional) MaxPriorityFeePerGas is the maximum miner tip per gas offered, in wei. value*: Option[HexQuantityStr] # (optional) Integer of the value sent with this transaction. data*: Option[EthHashStr] # (optional) Hash of the method signature and encoded parameters. For details see Ethereum Contract ABI. diff --git a/nimbus/rpc/rpc_utils.nim b/nimbus/rpc/rpc_utils.nim index 23984364f..23607f228 100644 --- a/nimbus/rpc/rpc_utils.nim +++ b/nimbus/rpc/rpc_utils.nim @@ -87,32 +87,31 @@ proc unsignedTx*(tx: TxSend, chain: BaseChainDB, defaultNonce: AccountNonce): Tr result.payload = hexToSeqByte(tx.data.string) -proc callData*(call: EthCall, callMode: bool = true, chain: BaseChainDB): RpcCallData = - if call.source.isSome: - result.source = toAddress(call.source.get) +template optionalAddress(src, dst: untyped) = + if src.isSome: + dst = some(toAddress(src.get)) - if call.to.isSome: - result.to = toAddress(call.to.get) - else: - if callMode: - raise newException(ValueError, "call.to required for eth_call operation") - else: - result.contractCreation = true +template optionalGas(src, dst: untyped) = + if src.isSome: + dst = some(hexToInt(src.get.string, GasInt)) - if call.gas.isSome: - result.gas = hexToInt(call.gas.get.string, GasInt) +template optionalU256(src, dst: untyped) = + if src.isSome: + dst = some(UInt256.fromHex(src.get.string)) - if call.gasPrice.isSome: - result.gasPrice = hexToInt(call.gasPrice.get.string, GasInt) - else: - if not callMode: - result.gasPrice = calculateMedianGasPrice(chain) - - if call.value.isSome: - result.value = UInt256.fromHex(call.value.get.string) - - if call.data.isSome: - result.data = hexToSeqByte(call.data.get.string) +template optionalBytes(src, dst: untyped) = + if src.isSome: + dst = hexToSeqByte(src.get.string) + +proc callData*(call: EthCall): RpcCallData = + optionalAddress(call.source, result.source) + optionalAddress(call.to, result.to) + optionalGas(call.gas, result.gasLimit) + optionalGas(call.gasPrice, result.gasPrice) + optionalGas(call.maxFeePerGas, result.maxFee) + optionalGas(call.maxPriorityFeePerGas, result.maxPriorityFee) + optionalU256(call.value, result.value) + optionalBytes(call.data, result.data) proc populateTransactionObject*(tx: Transaction, header: BlockHeader, txIndex: int): TransactionObject = result.blockHash = some(header.hash) diff --git a/nimbus/transaction/call_common.nim b/nimbus/transaction/call_common.nim index cc76b36e0..d5cccb756 100644 --- a/nimbus/transaction/call_common.nim +++ b/nimbus/transaction/call_common.nim @@ -62,7 +62,7 @@ proc hostToComputationMessage*(msg: EvmcMessage): Message = flags: if msg.isStatic: emvcStatic else: emvcNoFlags ) -func intrinsicGas(call: CallParams, fork: Fork): GasInt {.inline.} = +func intrinsicGas*(call: CallParams, fork: Fork): GasInt {.inline.} = # Compute the baseline gas cost for this transaction. This is the amount # of gas needed to send this transaction (but that is not actually used # for computation). diff --git a/nimbus/transaction/call_evm.nim b/nimbus/transaction/call_evm.nim index cc6e113f7..5c389810d 100644 --- a/nimbus/transaction/call_evm.nim +++ b/nimbus/transaction/call_evm.nim @@ -7,109 +7,165 @@ # at your option. This file may not be copied, modified, or distributed except according to those terms. import - eth/common/eth_types, stint, options, stew/byteutils, - ".."/[vm_types, vm_state, vm_gas_costs, forks], + eth/common/eth_types, stint, options, stew/byteutils, chronicles, + ".."/[vm_types, vm_state, vm_gas_costs, forks, constants], ".."/[db/db_chain, db/accounts_cache, transaction], eth/trie/db, ".."/[chain_config, rpc/hexstrings], ./call_common type RpcCallData* = object - source*: EthAddress - to*: EthAddress - gas*: GasInt - gasPrice*: GasInt - value*: UInt256 - data*: seq[byte] - contractCreation*: bool + source* : Option[EthAddress] + to* : Option[EthAddress] + gasLimit* : Option[GasInt] + gasPrice* : Option[GasInt] + maxFee* : Option[GasInt] + maxPriorityFee*: Option[GasInt] + value* : Option[UInt256] + data* : seq[byte] + accessList* : AccessList -proc rpcRunComputation(vmState: BaseVMState, rpc: RpcCallData, - gasLimit: GasInt, forkOverride = none(Fork), - forEstimateGas: bool = false): CallResult = - return runComputation(CallParams( +proc toCallParams(vmState: BaseVMState, cd: RpcCallData, + globalGasCap: GasInt, baseFee: Option[Uint256], + forkOverride = none(Fork)): CallParams = + + # Reject invalid combinations of pre- and post-1559 fee styles + if cd.gasPrice.isSome and (cd.maxFee.isSome or cd.maxPriorityFee.isSome): + raise newException(ValueError, "both gasPrice and (maxFeePerGas or maxPriorityFeePerGas) specified") + + # Set default gas & gas price if none were set + var gasLimit = globalGasCap + if gasLimit == 0: + gasLimit = GasInt(high(uint64) div 2) + + if cd.gasLimit.isSome: + gasLimit = cd.gasLimit.get() + + if globalGasCap != 0 and globalGasCap < gasLimit: + warn "Caller gas above allowance, capping", requested = gasLimit, cap = globalGasCap + gasLimit = globalGasCap + + var gasPrice = cd.gasPrice.get(0.GasInt) + if baseFee.isSome: + # A basefee is provided, necessitating EIP-1559-type execution + let maxPriorityFee = cd.maxPriorityFee.get(0.GasInt) + let maxFee = cd.maxFee.get(0.GasInt) + + # Backfill the legacy gasPrice for EVM execution, unless we're all zeroes + if maxPriorityFee > 0 or maxFee > 0: + let baseFee = baseFee.get().truncate(GasInt) + let priorityFee = min(maxPriorityFee, maxFee - baseFee) + gasPrice = priorityFee + baseFee + + CallParams( vmState: vmState, forkOverride: forkOverride, - gasPrice: rpc.gasPrice, + sender: cd.source.get(ZERO_ADDRESS), + to: cd.to.get(ZERO_ADDRESS), + isCreate: cd.to.isNone, gasLimit: gasLimit, - sender: rpc.source, - to: rpc.to, - isCreate: rpc.contractCreation, - value: rpc.value, - input: rpc.data, - # This matches historical behaviour. It might be that not all these steps - # should be disabled for RPC/GraphQL `call`. But until we investigate what - # RPC/GraphQL clients are expecting, keep the same behaviour. - noIntrinsic: not forEstimateGas, # Don't charge intrinsic gas. - noAccessList: not forEstimateGas, # Don't initialise EIP-2929 access list. - noGasCharge: not forEstimateGas, # Don't charge sender account for gas. - noRefund: not forEstimateGas # Don't apply gas refund/burn rule. - )) + gasPrice: gasPrice, + value: cd.value.get(0.u256), + input: cd.data, + accessList: cd.accessList + ) -proc rpcDoCall*(call: RpcCallData, header: BlockHeader, chain: BaseChainDB): HexDataStr = - # TODO: handle revert and error - # TODO: handle contract ABI - # we use current header stateRoot, unlike block validation - # which use previous block stateRoot - # TODO: ^ Check it's correct to use current header stateRoot, not parent - let vmState = newBaseVMState(chain.stateDB, header, chain) - let callResult = rpcRunComputation(vmState, call, call.gas) - return hexDataStr(callResult.output) +proc rpcCallEvm*(call: RpcCallData, header: BlockHeader, chainDB: BaseChainDB): CallResult = + const globalGasCap = 0 # TODO: globalGasCap should configurable by user + let stateDB = AccountsCache.init(chainDB.db, header.stateRoot) + let vmState = newBaseVMState(stateDB, header, chainDB) + let params = toCallParams(vmState, call, globalGasCap, header.fee) -proc rpcMakeCall*(call: RpcCallData, header: BlockHeader, chain: BaseChainDB): (string, GasInt, bool) = - # TODO: handle revert - let vmState = newBaseVMState(chain.stateDB, header, chain) - let callResult = rpcRunComputation(vmState, call, call.gas) - return (callResult.output.toHex, callResult.gasUsed, callResult.isError) + var dbTx = chainDB.db.beginTransaction() + defer: dbTx.dispose() # always dispose state changes -func rpcIntrinsicGas(call: RpcCallData, fork: Fork): GasInt = - var intrinsicGas = call.data.intrinsicGas(fork) - if call.contractCreation: - intrinsicGas = intrinsicGas + gasFees[fork][GasTXCreate] - return intrinsicGas + runComputation(params) -func rpcValidateCall(call: RpcCallData, vmState: BaseVMState, gasLimit: GasInt, - fork: Fork): bool = - # This behaviour matches `validateTransaction`, used by `processTransaction`. - if vmState.cumulativeGasUsed + gasLimit > vmState.blockHeader.gasLimit: - return false - let balance = vmState.readOnlyStateDB.getBalance(call.source) - let gasCost = gasLimit.u256 * call.gasPrice.u256 - if gasCost > balance or call.value > balance - gasCost: - return false - let intrinsicGas = rpcIntrinsicGas(call, fork) - if intrinsicGas > gasLimit: - return false - return true +proc rpcEstimateGas*(cd: RpcCallData, header: BlockHeader, chainDB: BaseChainDB, gasCap: GasInt): GasInt = + # Binary search the gas requirement, as it may be higher than the amount used + let stateDB = AccountsCache.init(chainDB.db, header.stateRoot) + let vmState = newBaseVMState(stateDB, header, chainDB) + let fork = chainDB.config.toFork(header.blockNumber) + let txGas = gasFees[fork][GasTransaction] # txGas always 21000, use constants? + var params = toCallParams(vmState, cd, gasCap, header.fee) -proc rpcEstimateGas*(call: RpcCallData, header: BlockHeader, chain: BaseChainDB, haveGasLimit: bool): GasInt = - # TODO: handle revert and error var - # we use current header stateRoot, unlike block validation - # which use previous block stateRoot - vmState = newBaseVMState(chain.stateDB, header, chain) - fork = toFork(chain.config, header.blockNumber) - gasLimit = if haveGasLimit: call.gas else: header.gasLimit - vmState.cumulativeGasUsed + lo : GasInt = txGas - 1 + hi : GasInt = cd.gasLimit.get(0.GasInt) + cap: GasInt - # Nimbus `estimateGas` has historically checked against remaining gas in the - # current block, balance in the sender account (even if the sender is default - # account 0x00), and other limits, and returned 0 as the gas estimate if any - # checks failed. This behaviour came from how it used `processTransaction` - # which calls `validateTransaction`. For now, keep this behaviour the same. - # Compare this code with `validateTransaction`. - # - # TODO: This historically differs from `rpcDoCall` and `rpcMakeCall`. There - # are other differences in rpc_utils.nim `callData` too. Are the different - # behaviours intended, and is 0 the correct return value to mean "not enough - # gas to start"? Probably not. - if not rpcValidateCall(call, vmState, gasLimit, fork): - return 0 + var dbTx = chainDB.db.beginTransaction() + defer: dbTx.dispose() # always dispose state changes - # Use a db transaction to save and restore the state of the database. - var dbTx = chain.db.beginTransaction() - defer: dbTx.dispose() + # Determine the highest gas limit can be used during the estimation. + if hi < txGas: + # block's gasLimit act as the gas ceiling + hi = header.gasLimit - let callResult = rpcRunComputation(vmState, call, gasLimit, some(fork), true) - return callResult.gasUsed + # Normalize the max fee per gas the call is willing to spend. + var feeCap = cd.gasPrice.get(0.GasInt) + if cd.gasPrice.isSome and (cd.maxFee.isSome or cd.maxPriorityFee.isSome): + raise newException(ValueError, "both gasPrice and (maxFeePerGas or maxPriorityFeePerGas) specified") + elif cd.maxFee.isSome: + feeCap = cd.maxFee.get + + # Recap the highest gas limit with account's available balance. + if feeCap > 0: + if cd.source.isNone: + raise newException(ValueError, "`from` can't be null") + + let balance = vmState.readOnlyStateDB.getBalance(cd.source.get) + var available = balance + if cd.value.isSome: + let value = cd.value.get + if value > available: + raise newException(ValueError, "insufficient funds for transfer") + available -= value + + let allowance = available div feeCap.u256 + # If the allowance is larger than maximum GasInt, skip checking + if allowance < high(GasInt).u256 and hi > allowance.truncate(GasInt): + let transfer = cd.value.get(0.u256) + warn "Gas estimation capped by limited funds", original=hi, balance, + sent=transfer, maxFeePerGas=feeCap, fundable=allowance + hi = allowance.truncate(GasInt) + + # Recap the highest gas allowance with specified gasCap. + if gasCap != 0 and hi > gasCap: + warn "Caller gas above allowance, capping", requested=hi, cap=gasCap + hi = gasCap + + cap = hi + let intrinsicGas = intrinsicGas(params, fork) + + # Create a helper to check if a gas allowance results in an executable transaction + proc executable(gasLimit: GasInt): bool = + if intrinsicGas > gasLimit: + # Special case, raise gas limit + return true + + params.gasLimit = gasLimit + # TODO: bail out on consensus error similar to validateTransaction + runComputation(params).isError + + # Execute the binary search and hone in on an executable gas limit + while lo+1 < hi: + let mid = (hi + lo) div 2 + let failed = executable(mid) + if failed: + lo = mid + else: + hi = mid + + # Reject the transaction as invalid if it still fails at the highest allowance + if hi == cap: + let failed = executable(hi) + if failed: + # TODO: provide more descriptive EVM error beside out of gas + # e.g. revert and other EVM errors + raise newException(ValueError, "gas required exceeds allowance " & $cap) + + hi proc txCallEvm*(tx: Transaction, sender: EthAddress, vmState: BaseVMState, fork: Fork): GasInt = var call = CallParams( diff --git a/tests/graphql/queries.toml b/tests/graphql/queries.toml index 5b55170f3..19a332a94 100644 --- a/tests/graphql/queries.toml +++ b/tests/graphql/queries.toml @@ -457,7 +457,7 @@ "call":{ "__typename":"CallResult", "data":"0x", - "gasUsed":0, + "gasUsed":21000, "status":1 } } diff --git a/tests/test_rpc.nim b/tests/test_rpc.nim index 9dc3b383b..80811400a 100644 --- a/tests/test_rpc.nim +++ b/tests/test_rpc.nim @@ -94,6 +94,9 @@ proc setupEnv(chainDB: BaseChainDB, signer, ks2: EthAddress, ctx: EthContext): T timeStamp = date.toTime difficulty = calcDifficulty(chainDB.config, timeStamp, parent) + # call persist() before we get the rootHash + vmState.stateDB.persist() + var header = BlockHeader( parentHash : parentHash, #coinbase*: EthAddress