unify evm call for both json-rpc and graphql

also fixes rpcEstimateGas for both of json-rpc and graphql
This commit is contained in:
jangko 2021-10-26 22:18:08 +07:00
parent baf508f6ae
commit 960539df81
No known key found for this signature in database
GPG Key ID: 31702AE10541E6B9
9 changed files with 232 additions and 147 deletions

View File

@ -68,4 +68,6 @@ const
## standard secp256k1 curve.
65
DEFAULT_RPC_GAS_CAP* = 50_000_000.GasInt
# End

View File

@ -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)

View File

@ -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]:

View File

@ -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.

View File

@ -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)

View File

@ -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).

View File

@ -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(

View File

@ -457,7 +457,7 @@
"call":{
"__typename":"CallResult",
"data":"0x",
"gasUsed":0,
"gasUsed":21000,
"status":1
}
}

View File

@ -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