Refactor txpool: reduce complexity (#2542)

This commit is contained in:
andri lim 2024-08-06 16:12:56 +07:00 committed by GitHub
parent 63d13182c1
commit ec118a438a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 13 additions and 491 deletions

View File

@ -425,7 +425,6 @@ export
TxItemStatus,
TxPoolFlags,
TxPoolRef,
TxTabsGasTotals,
TxTabsItemsCount,
results,
tx_desc.startDate,
@ -644,10 +643,6 @@ proc assembleBlock*(
blk: blk,
blobsBundle: blobsBundleOpt)
func gasTotals*(xp: TxPoolRef): TxTabsGasTotals =
## Getter, retrieves the current gas limit totals per bucket.
xp.txDB.gasTotals
func flags*(xp: TxPoolRef): set[TxPoolFlags] =
## Getter, retrieves strategy symbols for how to process items and buckets.
xp.pFlags

View File

@ -16,11 +16,9 @@ import
std/[times],
../../common/common,
./tx_chain,
./tx_info,
./tx_item,
./tx_tabs,
./tx_tabs/tx_sender, # for verify()
eth/keys
./tx_tabs/tx_sender
{.push raises: [].}
@ -162,53 +160,6 @@ func `pFlags=`*(xp: TxPoolRef; val: set[TxPoolFlags]) =
## Install a set of algorithm strategy symbols for labelling items as`packed`
xp.param.flags = val
# ------------------------------------------------------------------------------
# Public functions, heplers (debugging only)
# ------------------------------------------------------------------------------
proc verify*(xp: TxPoolRef): Result[void,TxInfo]
{.gcsafe, raises: [CatchableError].} =
## Verify descriptor and subsequent data structures.
block:
let rc = xp.txDB.verify
if rc.isErr:
return rc
# verify consecutive nonces per sender
var
initOk = false
lastSender: EthAddress
lastNonce: AccountNonce
lastSublist: TxSenderSchedRef
for (_,nonceList) in xp.txDB.incAccount:
for item in nonceList.incNonce:
if not initOk or lastSender != item.sender:
initOk = true
lastSender = item.sender
lastNonce = item.tx.nonce
lastSublist = xp.txDB.bySender.eq(item.sender).value.data
elif lastNonce + 1 == item.tx.nonce:
lastNonce = item.tx.nonce
else:
return err(txInfoVfyNonceChain)
# verify bucket boundary conditions
case item.status:
of txItemPending:
discard
of txItemStaged:
if lastSublist.eq(txItemPending).eq(item.tx.nonce - 1).isOk:
return err(txInfoVfyNonceChain)
of txItemPacked:
if lastSublist.eq(txItemPending).eq(item.tx.nonce - 1).isOk:
return err(txInfoVfyNonceChain)
if lastSublist.eq(txItemStaged).eq(item.tx.nonce - 1).isOk:
return err(txInfoVfyNonceChain)
ok()
# ------------------------------------------------------------------------------
# End
# ------------------------------------------------------------------------------

View File

@ -20,12 +20,12 @@ import
./tx_item,
./tx_tabs/[tx_sender, tx_rank, tx_status],
eth/[common, keys],
stew/[keyed_queue, keyed_queue/kq_debug, sorted_set],
stew/[keyed_queue, sorted_set],
results
export
# bySender/byStatus index operations
sub, eq, ge, gt, le, len, lt, nItems, gasLimits
sub, eq, ge, gt, le, len, lt, nItems
type
TxTabsItemsCount* = tuple
@ -33,9 +33,6 @@ type
total: int ## excluding rejects
disposed: int ## waste basket
TxTabsGasTotals* = tuple
pending, staged, packed: GasInt ## sum => total
TxTabsRef* = ref object ##\
## Base descriptor
maxRejects: int ##\
@ -284,11 +281,6 @@ proc nItems*(xp: TxTabsRef): TxTabsItemsCount =
result.total = xp.byItemID.len
result.disposed = xp.byRejects.len
proc gasTotals*(xp: TxTabsRef): TxTabsGasTotals =
result.pending = xp.byStatus.eq(txItemPending).gasLimits
result.staged = xp.byStatus.eq(txItemStaged).gasLimits
result.packed = xp.byStatus.eq(txItemPacked).gasLimits
# ------------------------------------------------------------------------------
# Public iterators, `TxRank` > `(EthAddress,TxStatusNonceRef)`
# ------------------------------------------------------------------------------
@ -345,41 +337,6 @@ iterator packingOrderAccounts*(xp: TxTabsRef; bucket: TxItemStatus):
for (account,nonceList) in xp.decAccount(bucket):
yield (account,nonceList)
# ------------------------------------------------------------------------------
# Public iterators, `TxRank` > `(EthAddress,TxSenderNonceRef)`
# ------------------------------------------------------------------------------
iterator incAccount*(xp: TxTabsRef;
fromRank = TxRank.low): (EthAddress,TxSenderNonceRef)
{.gcsafe,raises: [KeyError].} =
## Variant of `incAccount()` without bucket restriction.
var rcRank = xp.byRank.ge(fromRank)
while rcRank.isOk:
let (rank, addrList) = (rcRank.value.key, rcRank.value.data)
# Try all sender adresses found
for account in addrList.keys:
yield (account, xp.bySender.eq(account).sub.value.data)
# Get next ranked address list (top down index walk)
rcRank = xp.byRank.gt(rank) # potenially modified database
iterator decAccount*(xp: TxTabsRef;
fromRank = TxRank.high): (EthAddress,TxSenderNonceRef)
{.gcsafe,raises: [KeyError].} =
## Variant of `decAccount()` without bucket restriction.
var rcRank = xp.byRank.le(fromRank)
while rcRank.isOk:
let (rank, addrList) = (rcRank.value.key, rcRank.value.data)
# Try all sender adresses found
for account in addrList.keys:
yield (account, xp.bySender.eq(account).sub.value.data)
# Get next ranked address list (top down index walk)
rcRank = xp.byRank.lt(rank) # potenially modified database
# -----------------------------------------------------------------------------
# Public second stage iterators: nonce-ordered item lists.
# -----------------------------------------------------------------------------
@ -404,84 +361,6 @@ iterator incNonce*(nonceList: TxStatusNonceRef;
yield item
rc = nonceList.gt(nonce) # potenially modified database
#[
# There is currently no use for nonce count down traversal
iterator decNonce*(nonceList: TxSenderNonceRef;
nonceFrom = AccountNonce.high): TxItemRef
{.gcsafe, raises: [KeyError].} =
## Similar to `incNonce()` but visiting items in reverse order.
var rc = nonceList.le(nonceFrom)
while rc.isOk:
let (nonce, item) = (rc.value.key, rc.value.data)
yield item
rc = nonceList.lt(nonce) # potenially modified database
iterator decNonce*(nonceList: TxStatusNonceRef;
nonceFrom = AccountNonce.high): TxItemRef =
## Variant of `decNonce()` for the `TxStatusNonceRef` list.
var rc = nonceList.le(nonceFrom)
while rc.isOk:
let (nonce, item) = (rc.value.key, rc.value.data)
yield item
rc = nonceList.lt(nonce) # potenially modified database
]#
# ------------------------------------------------------------------------------
# Public functions, debugging
# ------------------------------------------------------------------------------
proc verify*(xp: TxTabsRef): Result[void,TxInfo]
{.gcsafe, raises: [CatchableError].} =
## Verify descriptor and subsequent data structures.
block:
let rc = xp.bySender.verify
if rc.isErr:
return rc
block:
let rc = xp.byItemID.verify
if rc.isErr:
return err(txInfoVfyItemIdList)
block:
let rc = xp.byRejects.verify
if rc.isErr:
return err(txInfoVfyRejectsList)
block:
let rc = xp.byStatus.verify
if rc.isErr:
return rc
block:
let rc = xp.byRank.verify
if rc.isErr:
return rc
for status in TxItemStatus:
var
statusCount = 0
statusAllGas = 0.GasInt
for (account,nonceList) in xp.incAccount(status):
let bySenderStatusList = xp.bySender.eq(account).eq(status)
statusAllGas += bySenderStatusList.gasLimits
statusCount += bySenderStatusList.nItems
if bySenderStatusList.nItems != nonceList.nItems:
return err(txInfoVfyStatusSenderTotal)
if xp.byStatus.eq(status).nItems != statusCount:
return err(txInfoVfyStatusSenderTotal)
if xp.byStatus.eq(status).gasLimits != statusAllGas:
return err(txInfoVfyStatusSenderGasLimits)
if xp.byItemID.len != xp.bySender.nItems:
return err(txInfoVfySenderTotal)
if xp.byItemID.len != xp.byStatus.nItems:
return err(txInfoVfyStatusTotal)
if xp.bySender.len != xp.byRank.nItems:
return err(txInfoVfyRankTotal)
ok()
# ------------------------------------------------------------------------------
# End
# ------------------------------------------------------------------------------

View File

@ -16,7 +16,6 @@
import
std/[tables],
../tx_info,
eth/common,
stew/[sorted_set],
results
@ -111,36 +110,6 @@ proc delete*(rt: var TxRankTab; sender: EthAddress): bool
rt.addrTab.del(sender)
return true
proc verify*(rt: var TxRankTab): Result[void,TxInfo]
{.gcsafe,raises: [CatchableError].} =
var
seen: Table[EthAddress,TxRank]
rc = rt.rankList.ge(TxRank.low)
while rc.isOk:
let (key, addrTab) = (rc.value.key, rc.value.data)
rc = rt.rankList.gt(key)
for (sender,rank) in addrTab.pairs:
if key != rank:
return err(txInfoVfyRankAddrMismatch)
if not rt.addrTab.hasKey(sender):
return err(txInfoVfyRankReverseLookup)
if rank != rt.addrTab[sender]:
return err(txInfoVfyRankReverseMismatch)
if seen.hasKey(sender):
return err(txInfoVfyRankDuplicateAddr)
seen[sender] = rank
if seen.len != rt.addrTab.len:
return err(txInfoVfyReverseZombies)
ok()
# ------------------------------------------------------------------------------
# Public functions: `TxRank` > `EthAddress`
# ------------------------------------------------------------------------------

View File

@ -15,10 +15,9 @@
##
import
../tx_info,
../tx_item,
eth/common,
stew/[keyed_queue, keyed_queue/kq_debug, sorted_set],
stew/[keyed_queue, sorted_set],
results,
../../eip4844
@ -27,7 +26,7 @@ type
## Sub-list ordered by `AccountNonce` values containing transaction\
## item lists.
gasLimits: GasInt ## Accumulated gas limits
profit: float64 ## Aggregated `effectiveGasTip*gasLimit` values
profit: GasInt ## Aggregated `effectiveGasTip*gasLimit` values
nonceList: SortedSet[AccountNonce,TxItemRef]
TxSenderSchedRef* = ref object ##\
@ -74,19 +73,6 @@ proc nActive(rq: TxSenderSchedRef): int =
if not rq.statusList[status].isNil:
result.inc
func differs(a, b: float64): bool =
## Syntactic sugar, crude comparator for large integer values a and b coded
## as `float64`. This function is mainly provided for the `verify()` function.
# note that later NIM compilers also provide `almostEqual()`
const
epsilon = 1.0e+15'f64 # just arbitrary, something small
let
x = max(a, b)
y = min(a, b)
z = if x == 0: 1'f64 else: x # 1f64 covers the case x == y == 0.0
epsilon < (x - y) / z
func toSenderSchedule(status: TxItemStatus): TxSenderSchedule =
case status
of txItemPending:
@ -109,23 +95,21 @@ proc getRank(schedData: TxSenderSchedRef): int64 =
if gasLimits <= 0:
return int64.low
let profit = maxProfit / gasLimits.float64
let profit = maxProfit div gasLimits
# Beware of under/overflow
if profit < int64.low.float64:
return int64.low
if int64.high.float64 < profit:
if int64.high.GasInt < profit:
return int64.high
profit.int64
proc maxProfit(item: TxItemRef; baseFee: GasInt): float64 =
proc maxProfit(item: TxItemRef; baseFee: GasInt): GasInt =
## Profit calculator
item.tx.gasLimit.float64 * item.tx.effectiveGasTip(baseFee).float64 + item.tx.getTotalBlobGas.float64
item.tx.gasLimit * item.tx.effectiveGasTip(baseFee) + item.tx.getTotalBlobGas
proc recalcProfit(nonceData: TxSenderNonceRef; baseFee: GasInt) =
## Re-calculate profit value depending on `baseFee`
nonceData.profit = 0.0
nonceData.profit = 0
var rc = nonceData.nonceList.ge(AccountNonce.low)
while rc.isOk:
let item = rc.value.data
@ -252,116 +236,6 @@ proc delete*(gt: var TxSenderTab; item: TxItemRef): bool
inx.statusNonce.profit -= tip
return true
proc verify*(gt: var TxSenderTab): Result[void,TxInfo]
{.gcsafe,raises: [CatchableError].} =
## Walk `EthAddress` > `TxSenderLocus` > `AccountNonce` > items
block:
let rc = gt.addrList.verify
if rc.isErr:
return err(txInfoVfySenderRbTree)
var totalCount = 0
for p in gt.addrList.nextPairs:
let schedData = p.data
#var addrCount = 0 -- notused
# at least one of status lists must be available
if schedData.nActive == 0:
return err(txInfoVfySenderLeafEmpty)
if schedData.allList.isNil:
return err(txInfoVfySenderLeafEmpty)
# status list
# ----------------------------------------------------------------
var
statusCount = 0
statusGas = 0.GasInt
statusProfit = 0.0
for status in TxItemStatus:
let statusData = schedData.statusList[status]
if not statusData.isNil:
block:
let rc = statusData.nonceList.verify
if rc.isErr:
return err(txInfoVfySenderRbTree)
var
rcNonce = statusData.nonceList.ge(AccountNonce.low)
bucketProfit = 0.0
while rcNonce.isOk:
let (nonceKey, item) = (rcNonce.value.key, rcNonce.value.data)
rcNonce = statusData.nonceList.gt(nonceKey)
statusGas += item.tx.gasLimit
statusCount.inc
bucketProfit += item.maxProfit(gt.baseFee)
statusProfit += bucketProfit
if differs(statusData.profit, bucketProfit):
echo "*** verify (1) ", statusData.profit," != ", bucketProfit
return err(txInfoVfySenderProfits)
# verify that `recalcProfit()` works
statusData.recalcProfit(gt.baseFee)
if differs(statusData.profit, bucketProfit):
echo "*** verify (2) ", statusData.profit," != ", bucketProfit
return err(txInfoVfySenderProfits)
# allList
# ----------------------------------------------------------------
var
allCount = 0
allGas = 0.GasInt
allProfit = 0.0
block:
var allData = schedData.allList
block:
let rc = allData.nonceList.verify
if rc.isErr:
return err(txInfoVfySenderRbTree)
var rcNonce = allData.nonceList.ge(AccountNonce.low)
while rcNonce.isOk:
let (nonceKey, item) = (rcNonce.value.key, rcNonce.value.data)
rcNonce = allData.nonceList.gt(nonceKey)
allProfit += item.maxProfit(gt.baseFee)
allGas += item.tx.gasLimit
allCount.inc
if differs(allData.profit, allProfit):
echo "*** verify (3) ", allData.profit," != ", allProfit
return err(txInfoVfySenderProfits)
# verify that `recalcProfit()` works
allData.recalcProfit(gt.baseFee)
if differs(allData.profit, allProfit):
echo "*** verify (4) ", allData.profit," != ", allProfit
return err(txInfoVfySenderProfits)
if differs(allProfit, statusProfit):
echo "*** verify (5) ", allProfit," != ", statusProfit
return err(txInfoVfySenderProfits)
if allGas != statusGas:
return err(txInfoVfySenderTotal)
if statusCount != schedData.size:
return err(txInfoVfySenderTotal)
if allCount != schedData.size:
return err(txInfoVfySenderTotal)
totalCount += allCount
# end while
if totalCount != gt.size:
return err(txInfoVfySenderTotal)
ok()
# ------------------------------------------------------------------------------
# Public getters
# ------------------------------------------------------------------------------
@ -508,38 +382,6 @@ proc nItems*(rc: SortedSetResult[TxSenderSchedule,TxSenderNonceRef]): int =
return rc.value.data.nItems
0
proc gasLimits*(nonceData: TxSenderNonceRef): GasInt =
## Getter, aggregated valued of `gasLimit` for all items in the
## argument list.
nonceData.gasLimits
proc gasLimits*(rc: SortedSetResult[TxSenderSchedule,TxSenderNonceRef]):
GasInt =
## Getter variant of `gasLimits()`, returns `0` if `rc.isErr`
## evaluates `true`.
if rc.isOk:
return rc.value.data.gasLimits
0
proc maxProfit*(nonceData: TxSenderNonceRef): float64 =
## Getter, maximum profit value for the current item list. This is the
## aggregated value of `item.effectiveGasTip(baseFee) * item.gasLimit`
## over all items in the argument list `nonceData`. Note that this value
## is typically pretty large and sort of rounded due to the resolution
## of the `float64` data type.
nonceData.profit
proc maxProfit*(rc: SortedSetResult[TxSenderSchedule,TxSenderNonceRef]):
float64 =
## Variant of `profit()`
## evaluates `true`.
if rc.isOk:
return rc.value.data.profit
float64.low
proc eq*(nonceData: TxSenderNonceRef; nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
nonceData.nonceList.eq(nonce)
@ -575,30 +417,6 @@ proc gt*(rc: SortedSetResult[TxSenderSchedule,TxSenderNonceRef];
return rc.value.data.gt(nonce)
err(rc.error)
proc le*(nonceData: TxSenderNonceRef; nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
nonceData.nonceList.le(nonce)
proc le*(rc: SortedSetResult[TxSenderSchedule,TxSenderNonceRef];
nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
if rc.isOk:
return rc.value.data.le(nonce)
err(rc.error)
proc lt*(nonceData: TxSenderNonceRef; nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
nonceData.nonceList.lt(nonce)
proc lt*(rc: SortedSetResult[TxSenderSchedule,TxSenderNonceRef];
nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
if rc.isOk:
return rc.value.data.lt(nonce)
err(rc.error)
# ------------------------------------------------------------------------------
# Public iterators
# ------------------------------------------------------------------------------

View File

@ -13,10 +13,9 @@
##
import
../tx_info,
../tx_item,
eth/common,
stew/[keyed_queue, keyed_queue/kq_debug, sorted_set],
stew/[keyed_queue, sorted_set],
results
{.push raises: [].}
@ -30,7 +29,6 @@ type
## Per address table. This table is provided as a keyed queue so deletion\
## while traversing is supported and predictable.
size: int ## Total number of items
gasLimits: GasInt ## Accumulated gas limits
addrList: KeyedQueue[EthAddress,TxStatusNonceRef]
TxStatusTab* = object ##\
@ -121,7 +119,6 @@ proc insert*(sq: var TxStatusTab; item: TxItemRef): bool
let inx = rc.value
sq.size.inc
inx.addrData.size.inc
inx.addrData.gasLimits += item.tx.gasLimit
return true
@ -133,7 +130,6 @@ proc delete*(sq: var TxStatusTab; item: TxItemRef): bool
sq.size.dec
inx.addrData.size.dec
inx.addrData.gasLimits -= item.tx.gasLimit
discard inx.nonceData.nonceList.delete(item.tx.nonce)
if inx.nonceData.nonceList.len == 0:
@ -144,53 +140,6 @@ proc delete*(sq: var TxStatusTab; item: TxItemRef): bool
return true
proc verify*(sq: var TxStatusTab): Result[void,TxInfo]
{.gcsafe,raises: [CatchableError].} =
## walk `TxItemStatus` > `EthAddress` > `AccountNonce`
var totalCount = 0
for status in TxItemStatus:
let addrData = sq.statusList[status]
if not addrData.isNil:
block:
let rc = addrData.addrList.verify
if rc.isErr:
return err(txInfoVfyStatusSenderList)
var
addrCount = 0
gasLimits = 0.GasInt
for p in addrData.addrList.nextPairs:
# let (addrKey, nonceData) = (p.key, p.data) -- notused
let nonceData = p.data
block:
let rc = nonceData.nonceList.verify
if rc.isErr:
return err(txInfoVfyStatusNonceList)
var rcNonce = nonceData.nonceList.ge(AccountNonce.low)
while rcNonce.isOk:
let (nonceKey, item) = (rcNonce.value.key, rcNonce.value.data)
rcNonce = nonceData.nonceList.gt(nonceKey)
gasLimits += item.tx.gasLimit
addrCount.inc
if addrCount != addrData.size:
return err(txInfoVfyStatusTotal)
if gasLimits != addrData.gasLimits:
return err(txInfoVfyStatusGasLimits)
totalCount += addrCount
# end while
if totalCount != sq.size:
return err(txInfoVfyStatusTotal)
ok()
# ------------------------------------------------------------------------------
# Public array ops -- `TxItemStatus` (level 0)
# ------------------------------------------------------------------------------
@ -222,17 +171,6 @@ proc nItems*(rc: SortedSetResult[TxItemStatus,TxStatusSenderRef]): int =
return rc.value.data.nItems
0
proc gasLimits*(addrData: TxStatusSenderRef): GasInt =
## Getter, accumulated `gasLimit` values
addrData.gasLimits
proc gasLimits*(rc: SortedSetResult[TxItemStatus,TxStatusSenderRef]): GasInt =
if rc.isOk:
return rc.value.data.gasLimits
0
proc eq*(addrData: TxStatusSenderRef; sender: EthAddress):
SortedSetResult[EthAddress,TxStatusNonceRef]
{.gcsafe,raises: [KeyError].} =
@ -297,28 +235,6 @@ proc gt*(rc: SortedSetResult[EthAddress,TxStatusNonceRef]; nonce: AccountNonce):
return rc.value.data.gt(nonce)
err(rc.error)
proc le*(nonceData: TxStatusNonceRef; nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
nonceData.nonceList.le(nonce)
proc le*(rc: SortedSetResult[EthAddress,TxStatusNonceRef]; nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
if rc.isOk:
return rc.value.data.le(nonce)
err(rc.error)
proc lt*(nonceData: TxStatusNonceRef; nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
nonceData.nonceList.lt(nonce)
proc lt*(rc: SortedSetResult[EthAddress,TxStatusNonceRef]; nonce: AccountNonce):
SortedSetResult[AccountNonce,TxItemRef] =
if rc.isOk:
return rc.value.data.lt(nonce)
err(rc.error)
# ------------------------------------------------------------------------------
# End
# ------------------------------------------------------------------------------

View File

@ -222,7 +222,7 @@ proc classifyValidatePacked*(xp: TxPoolRef;
baseFee = xp.chain.baseFee.uint64.u256
fork = xp.chain.nextFork
gasLimit = xp.chain.gasLimit
tx = item.tx.eip1559TxNormalization(xp.chain.baseFee.GasInt)
tx = item.tx.eip1559TxNormalization(xp.chain.baseFee)
excessBlobGas = calcExcessBlobGas(vmState.parent)
roDB.validateTransaction(

View File

@ -62,7 +62,7 @@ proc runTx(pst: TxPackerStateRef; item: TxItemRef): GasInt =
## `gasUsed` after executing the transaction.
let
baseFee = pst.xp.chain.baseFee
tx = item.tx.eip1559TxNormalization(baseFee.GasInt)
tx = item.tx.eip1559TxNormalization(baseFee)
let gasUsed = tx.txCallEvm(item.sender, pst.xp.chain.vmState)
pst.cleanState = false

View File

@ -28,7 +28,6 @@ export
tx_chain.vmState,
tx_desc.chain,
tx_desc.txDB,
tx_desc.verify,
tx_packer.packerVmExec,
tx_recover.recoverItem,
tx_tabs.TxTabsRef,
@ -36,7 +35,6 @@ export
tx_tabs.dispose,
tx_tabs.eq,
tx_tabs.flushRejects,
tx_tabs.gasLimits,
tx_tabs.ge,
tx_tabs.gt,
tx_tabs.incAccount,
@ -47,7 +45,6 @@ export
tx_tabs.nItems,
tx_tabs.reassign,
tx_tabs.reject,
tx_tabs.verify,
undumpBlocksGz
const
@ -165,9 +162,6 @@ proc pp*(txs: openArray[Transaction]; pfxLen: int): string =
proc pp*(w: TxTabsItemsCount): string =
&"{w.pending}/{w.staged}/{w.packed}:{w.total}/{w.disposed}"
proc pp*(w: TxTabsGasTotals): string =
&"{w.pending}/{w.staged}/{w.packed}"
# ------------------------------------------------------------------------------
# Public functions, other
# ------------------------------------------------------------------------------