378 lines
12 KiB
Nim
378 lines
12 KiB
Nim
# Nimbus
|
|
# Copyright (c) 2024 Status Research & Development GmbH
|
|
# Licensed under either of
|
|
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
|
|
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
|
|
# at your option.
|
|
# This file may not be copied, modified, or distributed except according to
|
|
# those terms.
|
|
|
|
import
|
|
std/[hashes, algorithm, strutils],
|
|
eth/eip1559,
|
|
stew/keyed_queue,
|
|
stew/endians2,
|
|
results,
|
|
../transaction,
|
|
../common/common,
|
|
../core/eip4844
|
|
|
|
from ./rpc_types import
|
|
Quantity,
|
|
BlockTag,
|
|
BlockIdentifierKind,
|
|
FeeHistoryResult,
|
|
FeeHistoryReward
|
|
|
|
from ./rpc_utils import headerFromTag
|
|
|
|
type
|
|
# ProcessedFees contains the results of a processed block.
|
|
ProcessedFees = ref object
|
|
reward : seq[UInt256]
|
|
baseFee : UInt256
|
|
blobBaseFee : UInt256
|
|
nextBaseFee : UInt256
|
|
nextBlobBaseFee : UInt256
|
|
gasUsedRatio : float64
|
|
blobGasUsedRatio: float64
|
|
|
|
# BlockContent represents a single block for processing
|
|
BlockContent = object
|
|
blockNumber: uint64
|
|
header : BlockHeader
|
|
txs : seq[Transaction]
|
|
receipts : seq[Receipt]
|
|
|
|
CacheKey = object
|
|
number: uint64
|
|
percentiles: seq[byte]
|
|
|
|
# txGasAndReward is sorted in ascending order based on reward
|
|
TxGasAndReward = object
|
|
gasUsed: uint64
|
|
reward : UInt256
|
|
|
|
BlockRange = object
|
|
pendingBlock: Opt[uint64]
|
|
lastBlock: uint64
|
|
blocks: uint64
|
|
|
|
Oracle* = ref object
|
|
com: CommonRef
|
|
maxHeaderHistory: uint64
|
|
maxBlockHistory : uint64
|
|
historyCache : KeyedQueue[CacheKey, ProcessedFees]
|
|
|
|
{.push gcsafe, raises: [].}
|
|
|
|
func new*(_: type Oracle, com: CommonRef): Oracle =
|
|
Oracle(
|
|
com: com,
|
|
maxHeaderHistory: 1024,
|
|
maxBlockHistory: 1024,
|
|
historyCache: KeyedQueue[CacheKey, ProcessedFees].init(),
|
|
)
|
|
|
|
func hash*(x: CacheKey): Hash =
|
|
var h: Hash = 0
|
|
h = h !& hash(x.number)
|
|
h = h !& hash(x.percentiles)
|
|
result = !$h
|
|
|
|
func toBytes(list: openArray[float64]): seq[byte] =
|
|
for x in list:
|
|
result.add(cast[uint64](x).toBytesLE)
|
|
|
|
func calcBaseFee(com: CommonRef, bc: BlockContent): UInt256 =
|
|
if com.isLondonOrLater(bc.blockNumber + 1):
|
|
calcEip1599BaseFee(
|
|
bc.header.gasLimit,
|
|
bc.header.gasUsed,
|
|
bc.header.baseFeePerGas.get(0.u256))
|
|
else:
|
|
0.u256
|
|
|
|
# processBlock takes a blockFees structure with the blockNumber, the header and optionally
|
|
# the block field filled in, retrieves the block from the backend if not present yet and
|
|
# fills in the rest of the fields.
|
|
proc processBlock(oracle: Oracle, bc: BlockContent, percentiles: openArray[float64]): ProcessedFees =
|
|
result = ProcessedFees(
|
|
baseFee: bc.header.baseFeePerGas.get(0.u256),
|
|
blobBaseFee: getBlobBaseFee(bc.header.excessBlobGas.get(0'u64)),
|
|
nextBaseFee: calcBaseFee(oracle.com, bc),
|
|
nextBlobBaseFee: getBlobBaseFee(calcExcessBlobGas(bc.header)),
|
|
gasUsedRatio: float64(bc.header.gasUsed) / float64(bc.header.gasLimit),
|
|
blobGasUsedRatio: float64(bc.header.blobGasUsed.get(0'u64)) / float64(MAX_BLOB_GAS_PER_BLOCK)
|
|
)
|
|
|
|
if percentiles.len == 0:
|
|
# rewards were not requested, return
|
|
return
|
|
|
|
if bc.receipts.len == 0 and bc.txs.len != 0:
|
|
# log.Error("receipts are missing while reward percentiles are requested")
|
|
return
|
|
|
|
result.reward = newSeq[UInt256](percentiles.len)
|
|
if bc.txs.len == 0:
|
|
# return an all zero row if there are no transactions to gather data from
|
|
return
|
|
|
|
var
|
|
sorter = newSeq[TxGasAndReward](bc.txs.len)
|
|
prevUsed = 0.GasInt
|
|
|
|
for i, tx in bc.txs:
|
|
let
|
|
reward = tx.effectiveGasTip(bc.header.baseFeePerGas)
|
|
gasUsed = bc.receipts[i].cumulativeGasUsed - prevUsed
|
|
sorter[i] = TxGasAndReward(
|
|
gasUsed: gasUsed.uint64,
|
|
reward: reward.u256
|
|
)
|
|
prevUsed = bc.receipts[i].cumulativeGasUsed
|
|
|
|
|
|
sorter.sort(proc(a, b: TxGasAndReward): int =
|
|
if a.reward >= b.reward: 1
|
|
else: -1
|
|
)
|
|
|
|
var
|
|
txIndex: int
|
|
sumGasUsed = sorter[0].gasUsed
|
|
|
|
for i, p in percentiles:
|
|
let thresholdGasUsed = uint64(float64(bc.header.gasUsed) * p / 100.0'f64)
|
|
while sumGasUsed < thresholdGasUsed and txIndex < bc.txs.len-1:
|
|
inc txIndex
|
|
sumGasUsed += sorter[txIndex].gasUsed
|
|
|
|
result.reward[i] = sorter[txIndex].reward
|
|
|
|
# resolveBlockRange resolves the specified block range to absolute block numbers while also
|
|
# enforcing backend specific limitations. The pending block and corresponding receipts are
|
|
# also returned if requested and available.
|
|
# Note: an error is only returned if retrieving the head header has failed. If there are no
|
|
# retrievable blocks in the specified range then zero block count is returned with no error.
|
|
proc resolveBlockRange(oracle: Oracle, blockId: BlockTag, numBlocks: uint64): Result[BlockRange, string] =
|
|
# Get the chain's current head.
|
|
let
|
|
headBlock = try:
|
|
oracle.com.db.getCanonicalHead()
|
|
except CatchableError as exc:
|
|
return err(exc.msg)
|
|
head = headBlock.number
|
|
|
|
var
|
|
reqEnd: uint64
|
|
blocks = numBlocks
|
|
pendingBlock: Opt[uint64]
|
|
|
|
if blockId.kind == bidNumber:
|
|
reqEnd = blockId.number.uint64
|
|
# Fail if request block is beyond the chain's current head.
|
|
if head < reqEnd:
|
|
return err("RequestBeyondHead: requested " & $reqEnd & ", head " & $head)
|
|
else:
|
|
# Resolve block tag.
|
|
let tag = blockId.alias.toLowerAscii
|
|
var resolved: BlockHeader
|
|
if tag == "pending":
|
|
try:
|
|
resolved = headerFromTag(oracle.com.db, blockId)
|
|
pendingBlock = Opt.some(resolved.number)
|
|
except CatchableError:
|
|
# Pending block not supported by backend, process only until latest block.
|
|
resolved = headBlock
|
|
# Update total blocks to return to account for this.
|
|
dec blocks
|
|
else:
|
|
try:
|
|
resolved = headerFromTag(oracle.com.db, blockId)
|
|
except CatchableError as exc:
|
|
return err(exc.msg)
|
|
|
|
# Absolute number resolved.
|
|
reqEnd = resolved.number
|
|
|
|
# If there are no blocks to return, short circuit.
|
|
if blocks == 0:
|
|
return ok(BlockRange())
|
|
|
|
# Ensure not trying to retrieve before genesis.
|
|
if reqEnd+1 < blocks:
|
|
blocks = reqEnd + 1
|
|
|
|
ok(BlockRange(
|
|
pendingBlock:pendingBlock,
|
|
lastBlock: reqEnd,
|
|
blocks: blocks,
|
|
))
|
|
|
|
proc getBlockContent(oracle: Oracle,
|
|
blockNumber: uint64,
|
|
blockTag: uint64,
|
|
fullBlock: bool): Result[BlockContent, string] =
|
|
var bc = BlockContent(
|
|
blockNumber: blockNumber
|
|
)
|
|
|
|
let db = oracle.com.db
|
|
try:
|
|
bc.header = db.getBlockHeader(blockNumber.BlockNumber)
|
|
for tx in db.getBlockTransactions(bc.header):
|
|
bc.txs.add tx
|
|
|
|
for rc in db.getReceipts(bc.header.receiptsRoot):
|
|
bc.receipts.add rc
|
|
|
|
return ok(bc)
|
|
except RlpError as exc:
|
|
return err(exc.msg)
|
|
except BlockNotFound as exc:
|
|
return err(exc.msg)
|
|
|
|
type
|
|
OracleResult = object
|
|
reward : seq[seq[UInt256]]
|
|
baseFee : seq[UInt256]
|
|
blobBaseFee : seq[UInt256]
|
|
gasUsedRatio : seq[float64]
|
|
blobGasUsedRatio: seq[float64]
|
|
firstMissing : int
|
|
|
|
func init(_: type OracleResult, blocks: int): OracleResult =
|
|
OracleResult(
|
|
reward : newSeq[seq[UInt256]](blocks),
|
|
baseFee : newSeq[UInt256](blocks+1),
|
|
blobBaseFee : newSeq[UInt256](blocks+1),
|
|
gasUsedRatio : newSeq[float64](blocks),
|
|
blobGasUsedRatio: newSeq[float64](blocks),
|
|
firstMissing : blocks,
|
|
)
|
|
|
|
proc addToResult(res: var OracleResult, i: int, fees: ProcessedFees) =
|
|
if fees.isNil:
|
|
# getting no block and no error means we are requesting into the future
|
|
# (might happen because of a reorg)
|
|
if i < res.firstMissing:
|
|
res.firstMissing = i
|
|
else:
|
|
res.reward[i] = fees.reward
|
|
res.baseFee[i] = fees.baseFee
|
|
res.baseFee[i+1] = fees.nextBaseFee
|
|
res.gasUsedRatio[i] = fees.gasUsedRatio
|
|
res.blobBaseFee[i] = fees.blobBaseFee
|
|
res.blobBaseFee[i+1] = fees.nextBlobBaseFee
|
|
res.blobGasUsedRatio[i] = fees.blobGasUsedRatio
|
|
|
|
|
|
# FeeHistory returns data relevant for fee estimation based on the specified range of blocks.
|
|
# The range can be specified either with absolute block numbers or ending with the latest
|
|
# or pending block. Backends may or may not support gathering data from the pending block
|
|
# or blocks older than a certain age (specified in maxHistory). The first block of the
|
|
# actually processed range is returned to avoid ambiguity when parts of the requested range
|
|
# are not available or when the head has changed during processing this request.
|
|
# Three arrays are returned based on the processed blocks:
|
|
# - reward: the requested percentiles of effective priority fees per gas of transactions in each
|
|
# block, sorted in ascending order and weighted by gas used.
|
|
# - baseFee: base fee per gas in the given block
|
|
# - gasUsedRatio: gasUsed/gasLimit in the given block
|
|
#
|
|
# Note: baseFee includes the next block after the newest of the returned range, because this
|
|
# value can be derived from the newest block.
|
|
proc feeHistory*(oracle: Oracle,
|
|
blocks: uint64,
|
|
unresolvedLastBlock: BlockTag,
|
|
rewardPercentiles: openArray[float64]): Result[FeeHistoryResult, string] =
|
|
|
|
var blocks = blocks
|
|
if blocks < 1:
|
|
# returning with no data and no error means there are no retrievable blocks
|
|
return
|
|
|
|
let maxFeeHistory = if rewardPercentiles.len == 0:
|
|
oracle.maxHeaderHistory
|
|
else:
|
|
oracle.maxBlockHistory
|
|
|
|
if blocks > maxFeeHistory:
|
|
# log.Warn("Sanitizing fee history length", "requested", blocks, "truncated", maxFeeHistory)
|
|
blocks = maxFeeHistory
|
|
|
|
for i, p in rewardPercentiles:
|
|
if p < 0.0 or p > 100.0:
|
|
return err("Invalid percentile: " & $p)
|
|
|
|
if i > 0 and p <= rewardPercentiles[i-1]:
|
|
return err("Invalid percentile: #" & $(i-1) &
|
|
":" & $rewardPercentiles[i-1] & " >= #" & $i & ":" & $p)
|
|
|
|
let br = oracle.resolveBlockRange(unresolvedLastBlock, blocks).valueOr:
|
|
return err(error)
|
|
|
|
let
|
|
oldestBlock = br.lastBlock + 1 - br.blocks
|
|
percentileKey = rewardPercentiles.toBytes
|
|
fullBlock = rewardPercentiles.len != 0
|
|
|
|
var
|
|
next = oldestBlock
|
|
res = OracleResult.init(br.blocks.int)
|
|
|
|
for i in 0..<blocks:
|
|
# Retrieve the next block number to fetch
|
|
let blockNumber = next
|
|
inc next
|
|
if blockNumber > br.lastBlock:
|
|
break
|
|
|
|
if br.pendingBlock.isSome and blockNumber >= br.pendingBlock.get:
|
|
let
|
|
bc = oracle.getBlockContent(blockNumber, br.pendingBlock.get, fullBlock).valueOr:
|
|
return err(error)
|
|
fees = oracle.processBlock(bc, rewardPercentiles)
|
|
res.addToResult((blockNumber - oldestBlock).int, fees)
|
|
else:
|
|
let
|
|
cacheKey = CacheKey(number: blockNumber, percentiles: percentileKey)
|
|
fr = oracle.historyCache.lruFetch(cacheKey)
|
|
|
|
if fr.isOk:
|
|
res.addToResult((blockNumber - oldestBlock).int, fr.get)
|
|
else:
|
|
let bc = oracle.getBlockContent(blockNumber, blockNumber, fullBlock).valueOr:
|
|
return err(error)
|
|
let fees = oracle.processBlock(bc, rewardPercentiles)
|
|
discard oracle.historyCache.lruAppend(cacheKey, fees, 2048)
|
|
# send to results even if empty to guarantee that blocks items are sent in total
|
|
res.addToResult((blockNumber - oldestBlock).int, fees)
|
|
|
|
if res.firstMissing == 0:
|
|
return ok(FeeHistoryResult())
|
|
|
|
var historyResult: FeeHistoryResult
|
|
|
|
if rewardPercentiles.len != 0:
|
|
res.reward.setLen(res.firstMissing)
|
|
historyResult.reward = Opt.some(system.move res.reward)
|
|
else:
|
|
historyResult.reward = Opt.none(seq[FeeHistoryReward])
|
|
|
|
res.baseFee.setLen(res.firstMissing+1)
|
|
res.gasUsedRatio.setLen(res.firstMissing)
|
|
res.blobBaseFee.setLen(res.firstMissing+1)
|
|
res.blobGasUsedRatio.setLen(res.firstMissing)
|
|
|
|
historyResult.oldestBlock = Quantity oldestBlock
|
|
historyResult.baseFeePerGas = system.move(res.baseFee)
|
|
historyResult.baseFeePerBlobGas = system.move(res.blobBaseFee)
|
|
historyResult.gasUsedRatio = system.move(res.gasUsedRatio)
|
|
historyResult.blobGasUsedRatio = system.move(res.blobGasUsedRatio)
|
|
|
|
ok(historyResult)
|
|
|
|
{.pop.}
|