Merge pull request #137 from emeraldpay/feat/fee-estimation

This commit is contained in:
Igor Artamonov
2021-12-09 16:35:13 -05:00
committed by GitHub
27 changed files with 1486 additions and 19 deletions

View File

@@ -31,7 +31,7 @@ cglib-nodep = "cglib:cglib-nodep:3.3.0"
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
emerald-api = "io.emeraldpay:emerald-api:0.10.0"
emerald-api = "io.emeraldpay:emerald-api:0.11.0"
equals-verifier = "nl.jqno.equalsverifier:equalsverifier:3.3"

View File

@@ -10,6 +10,11 @@ service Blockchain {
rpc GetBalance (BalanceRequest) returns (stream AddressBalance) {}
/**
* Fee Estimation service. The server tries to estimate a fair fee based on the last N blocks.
*/
rpc EstimateFee (EstimateFeeRequest) returns (EstimateFeeResponse) {}
rpc NativeCall (NativeCallRequest) returns (stream NativeCallReplyItem) {}
rpc NativeSubscribe (NativeSubscribeRequest) returns (stream NativeSubscribeReplyItem) {}
@@ -172,4 +177,80 @@ message NotSelector {
message ExistsSelector {
string name = 1;
}
/**
* Request for Fee Estimation Service
*/
message EstimateFeeRequest {
// Target chain
ChainRef chain = 1;
// The way how the fee should be estimated
FeeEstimationMode mode = 2;
// How many blocks the server is supposed to use to estimate current fee. Note that the server may use value, depending on configuration
uint32 blocks = 3;
}
/**
* Responset for Fee Estimation Service
*/
message EstimateFeeResponse {
// May return different struct, depending on the blockchain
oneof fee_type {
// Standard Ethereum Fee, supported by majority of forks and by Ethereum Mainnet before EIP-1559
EthereumStdFees ethereumStd = 1;
// Ethereum Fee for EIP-1559 compatible forks
EthereumExtFees ethereumExtended = 2;
// Standard Bitcoin Fee
BitcoinStdFees bitcoinStd = 3;
}
}
/**
* The mode of how the fee must be estimated
*/
enum FeeEstimationMode {
INVALID = 0;
// Average over last transaction in each block
AVG_LAST = 1;
// Average over transaction 5th from the end in each block
AVG_T5 = 2;
// Average over transaction 20th from the end in each block
AVG_T20 = 3;
// Average over transaction 50th from the end in each block
AVG_T50 = 4;
// Minimal fee that would be accepted by every last block
MIN_ALWAYS = 5;
// Average over transaction in the middle of each block
AVG_MIDDLE = 6;
// Average over transaction in head of each block. Note that for Bitcoin it doesn't count COINBASE tx as top tx.
AVG_TOP = 7;
}
/**
* Standard Ethereum Fee, supported by majority of forks and by Ethereum Mainnet before EIP-1559
*/
message EthereumStdFees {
// Fee value in Wei
string fee = 1;
}
/**
* Ethereum Fee for EIP-1559 compatible forks
*/
message EthereumExtFees {
// Estimated fee that would be actually paid. I.e. it's the Base Fee + Priority Fee
string expect = 1;
// Priority Fee in Wei
string priority = 2;
// Max Fee value in Wei. Note that it only indicated current preference and actual Max may be significantly lower, depending on the usage scenario.
string max = 3;
}
/**
* Standard Bitcoin Fee
*/
message BitcoinStdFees {
// Fee in Satoshi per Kilobyte. Note that the actual fee calculation MUST divide it by 1024 at the last step to get a fair fee.
string satPerKb = 1;
}

View File

@@ -50,6 +50,7 @@ class AccessHandlerGrpc(
"NativeSubscribe" -> processNativeSubscribe(call, headers, next)
"Describe" -> processDescribe(call, headers, next)
"SubscribeStatus" -> processStatus(call, headers, next)
"EstimateFee" -> processEstimateFee(call, headers, next)
else -> {
log.warn("unsupported method `{}`", method)
next.startCall(call, headers)
@@ -158,6 +159,18 @@ class AccessHandlerGrpc(
)
}
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processEstimateFee(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
return process(
call, headers, next,
EventsBuilder.EstimateFee() as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
open class StdCallListener<Req, EB : EventsBuilder.RequestReply<*, Req, *>>(
val next: ServerCall.Listener<Req>,
val builder: EB

View File

@@ -136,6 +136,14 @@ class Events {
val request: StreamRequestDetails
) : ChainBase(blockchain, "Status", id, Channel.GRPC)
@JsonInclude(JsonInclude.Include.NON_NULL)
class EstimateFee(
blockchain: Chain,
id: UUID,
val request: StreamRequestDetails,
val estimateFee: EstimateFeeDetails
) : ChainBase(blockchain, "EstimateFee", id, Channel.GRPC)
data class StreamRequestDetails(
val id: UUID,
val start: Instant,
@@ -180,4 +188,9 @@ class Events {
val asset: String,
val address: String
)
data class EstimateFeeDetails(
val mode: String,
val blocks: Int
)
}

View File

@@ -452,4 +452,34 @@ class EventsBuilder {
)
}
}
class EstimateFee :
Base<EstimateFee>(),
RequestReply<Events.EstimateFee, BlockchainOuterClass.EstimateFeeRequest, BlockchainOuterClass.EstimateFeeResponse> {
private var mode: String = "UNKNOWN"
private var blocks: Int = 0
override fun getT(): EstimateFee {
return this
}
override fun onRequest(msg: BlockchainOuterClass.EstimateFeeRequest) {
this.chain = Chain.byId(msg.chain.number)
this.mode = msg.mode.name
this.blocks = msg.blocks
}
override fun onReply(msg: BlockchainOuterClass.EstimateFeeResponse): Events.EstimateFee {
return Events.EstimateFee(
blockchain = chain,
request = requestDetails,
id = UUID.randomUUID(),
estimateFee = Events.EstimateFeeDetails(
mode = mode,
blocks = blocks
)
)
}
}
}

View File

@@ -43,7 +43,8 @@ class BlockchainRpc(
@Autowired private val trackTx: List<TrackTx>,
@Autowired private val trackAddress: List<TrackAddress>,
@Autowired private val describe: Describe,
@Autowired private val subscribeStatus: SubscribeStatus
@Autowired private val subscribeStatus: SubscribeStatus,
@Autowired private val estimateFee: EstimateFee
) : ReactorBlockchainGrpc.BlockchainImplBase() {
private val log = LoggerFactory.getLogger(BlockchainRpc::class.java)
@@ -165,6 +166,26 @@ class BlockchainRpc(
}
}
override fun estimateFee(request: Mono<BlockchainOuterClass.EstimateFeeRequest>): Mono<BlockchainOuterClass.EstimateFeeResponse> {
return request
.flatMap {
val chain = Chain.byId(it.chainValue)
val metrics = chainMetrics.get(chain)
metrics.estimateFeeMetric.increment()
val startTime = System.currentTimeMillis()
estimateFee.estimateFee(it).doFinally {
metrics.estimateFeeRespMetric.record(
System.currentTimeMillis() - startTime,
TimeUnit.MILLISECONDS
)
}
}
.doOnError { t ->
log.error("Internal error during Fee Estimation", t)
errorMetric.increment()
}
}
override fun describe(request: Mono<BlockchainOuterClass.DescribeRequest>): Mono<BlockchainOuterClass.DescribeResponse> {
describeMetric.increment()
return describe.describe(request)
@@ -224,5 +245,14 @@ class BlockchainRpc(
.tag("chain", chain.chainCode)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
val estimateFeeMetric = Counter.builder("request.grpc.request")
.tag("type", "estimateFee")
.tag("chain", chain.chainCode)
.register(Metrics.globalRegistry)
val estimateFeeRespMetric = Timer.builder("request.grpc.response")
.tag("type", "estimateFee")
.tag("chain", chain.chainCode)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
}
}

View File

@@ -0,0 +1,38 @@
package io.emeraldpay.dshackle.rpc
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.dshackle.upstream.ChainFees
import io.emeraldpay.dshackle.upstream.MultistreamHolder
import io.emeraldpay.grpc.Chain
import io.grpc.Status
import io.grpc.StatusException
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
@Service
class EstimateFee(
@Autowired private val multistreamHolder: MultistreamHolder
) {
companion object {
private val log = LoggerFactory.getLogger(EstimateFee::class.java)
}
fun estimateFee(req: BlockchainOuterClass.EstimateFeeRequest): Mono<BlockchainOuterClass.EstimateFeeResponse> {
val chain = Chain.byId(req.chainValue)
val up = multistreamHolder.getUpstream(chain) ?: return Mono.error(
StatusException(
Status.UNAVAILABLE.withDescription("BLOCKCHAIN UNAVAILABLE: ${req.chainValue}")
)
)
val mode = ChainFees.extractMode(req) ?: return Mono.error(
StatusException(
Status.UNAVAILABLE.withDescription("UNSUPPORTED MODE: ${req.mode.number}")
)
)
return up.getFeeEstimation()
.estimate(mode, req.blocks)
}
}

View File

@@ -0,0 +1,131 @@
package io.emeraldpay.dshackle.upstream
import com.github.benmanes.caffeine.cache.Caffeine
import io.emeraldpay.api.proto.BlockchainOuterClass
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
import java.util.EnumMap
import java.util.function.Function
abstract class AbstractChainFees<F, B, TR, T>(
private val heightLimit: Int,
private val upstreams: Multistream,
extractTx: (B) -> List<TR>?
) : ChainFees {
companion object {
private val log = LoggerFactory.getLogger(AbstractChainFees::class.java)
}
private val txSource = EnumMap<ChainFees.Mode, TxAt<B, TR>>(ChainFees.Mode::class.java)
init {
txSource[ChainFees.Mode.AVG_TOP] = TxAtTop(extractTx)
txSource[ChainFees.Mode.MIN_ALWAYS] = TxAtBottom(extractTx)
txSource[ChainFees.Mode.AVG_MIDDLE] = TxAtMiddle(extractTx)
txSource[ChainFees.Mode.AVG_LAST] = TxAtBottom(extractTx)
txSource[ChainFees.Mode.AVG_T5] = TxAtPos(extractTx, 5)
txSource[ChainFees.Mode.AVG_T20] = TxAtPos(extractTx, 20)
txSource[ChainFees.Mode.AVG_T50] = TxAtPos(extractTx, 50)
}
override fun estimate(mode: ChainFees.Mode, blocks: Int): Mono<BlockchainOuterClass.EstimateFeeResponse> {
return usingBlocks(blocks)
.flatMap { readFeesAt(it, mode) }
.transform(feeAggregation(mode))
.next()
.map(getResponseBuilder())
}
// ---
private val feeCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(60))
.build<Pair<Long, ChainFees.Mode>, F>()
fun usingBlocks(exp: Int): Flux<Long> {
val useBlocks = exp.coerceAtMost(heightLimit).coerceAtLeast(1)
val height = upstreams.getHead().getCurrentHeight()
?: return Mono.fromCallable { log.warn("Upstream is not ready. No current height") }.thenMany(Mono.empty()) // TODO or throw an exception to build a gRPC error?
val startBlock: Int = height.toInt() - useBlocks + 1
if (startBlock < 0) {
log.warn("Blockchain doesn't have enough blocks. Height: $height")
return Flux.empty()
}
return Flux.range(startBlock, useBlocks).map { it.toLong() }
}
fun readFeesAt(height: Long, mode: ChainFees.Mode): Mono<F> {
val current = feeCache.getIfPresent(Pair(height, mode))
if (current != null) {
return Mono.just(current)
}
val txSelector = txSourceFor(mode)
return readFeesAt(height, txSelector).doOnNext {
// TODO it may be EMPTY for some blocks (ex. a no tx block), so nothing gets cached and goes to do the same call each time. so do cache empty values to avoid useless requests
feeCache.put(Pair(height, mode), it!!)
}
}
open fun txSourceFor(mode: ChainFees.Mode): TxAt<B, TR> {
return txSource[mode] ?: throw IllegalStateException("No TS Source for mode $mode")
}
abstract fun readFeesAt(height: Long, selector: TxAt<B, TR>): Mono<F>
abstract fun feeAggregation(mode: ChainFees.Mode): Function<Flux<F>, Mono<F>>
abstract fun getResponseBuilder(): Function<F, BlockchainOuterClass.EstimateFeeResponse>
abstract class TxAt<B, TR>(private val extractTx: Function<B, List<TR>?>) {
fun get(block: B): TR? {
val txes = extractTx.apply(block) ?: return null
return get(txes)
}
abstract fun get(transactions: List<TR>): TR?
}
class TxAtPos<B, TR>(extractTx: Function<B, List<TR>?>, private val pos: Int) : TxAt<B, TR>(extractTx) {
override fun get(transactions: List<TR>): TR? {
val index = pos.coerceAtMost(transactions.size - 1)
if (index < 0) {
return null
}
return transactions[transactions.size - index - 1]
}
}
class TxAtTop<B, TR>(extractTx: Function<B, List<TR>?>) : TxAt<B, TR>(extractTx) {
override fun get(transactions: List<TR>): TR? {
if (transactions.isEmpty()) {
return null
}
return transactions[0]
}
}
class TxAtBottom<B, TR>(extractTx: Function<B, List<TR>?>) : TxAt<B, TR>(extractTx) {
override fun get(transactions: List<TR>): TR? {
if (transactions.isEmpty()) {
return null
}
return transactions.last()
}
}
class TxAtMiddle<B, TR>(extractTx: Function<B, List<TR>?>) : TxAt<B, TR>(extractTx) {
override fun get(transactions: List<TR>): TR? {
if (transactions.isEmpty()) {
return null
}
if (transactions.size == 1) {
return transactions[0]
}
return transactions[transactions.size / 2]
}
}
}

View File

@@ -0,0 +1,35 @@
package io.emeraldpay.dshackle.upstream
import io.emeraldpay.api.proto.BlockchainOuterClass
import reactor.core.publisher.Mono
interface ChainFees {
companion object {
fun extractMode(req: BlockchainOuterClass.EstimateFeeRequest): Mode? {
return when (req.mode!!) {
BlockchainOuterClass.FeeEstimationMode.INVALID -> null
BlockchainOuterClass.FeeEstimationMode.AVG_LAST -> Mode.AVG_LAST
BlockchainOuterClass.FeeEstimationMode.AVG_T5 -> Mode.AVG_T5
BlockchainOuterClass.FeeEstimationMode.AVG_T20 -> Mode.AVG_T20
BlockchainOuterClass.FeeEstimationMode.AVG_T50 -> Mode.AVG_T50
BlockchainOuterClass.FeeEstimationMode.MIN_ALWAYS -> Mode.MIN_ALWAYS
BlockchainOuterClass.FeeEstimationMode.AVG_MIDDLE -> Mode.AVG_MIDDLE
BlockchainOuterClass.FeeEstimationMode.AVG_TOP -> Mode.AVG_TOP
BlockchainOuterClass.FeeEstimationMode.UNRECOGNIZED -> null
}
}
}
fun estimate(mode: Mode, blocks: Int): Mono<BlockchainOuterClass.EstimateFeeResponse>
enum class Mode {
AVG_LAST,
AVG_T5,
AVG_T20,
AVG_T50,
MIN_ALWAYS,
AVG_MIDDLE,
AVG_TOP
}
}

View File

@@ -155,6 +155,8 @@ abstract class Multistream(
.switchIfEmpty(Mono.error(Exception("No API available for $chain")))
}
abstract fun getFeeEstimation(): ChainFees
/**
* Finds an API that leverages caches and other optimizations/transformations of the request.
*/

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import java.math.BigDecimal
import java.math.BigInteger
class BitcoinConst {
companion object {
val COIN: BigInteger = BigInteger.TEN.pow(8)
val COIN_DEC: BigDecimal = COIN.toBigDecimal()
}
}

View File

@@ -0,0 +1,146 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.dshackle.upstream.AbstractChainFees
import io.emeraldpay.dshackle.upstream.ChainFees
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.math.BigDecimal
import java.util.function.Function
class BitcoinFees(
upstreams: BitcoinMultistream,
private val reader: BitcoinReader,
heightLimit: Int,
) : AbstractChainFees<BitcoinFees.TxFee, Map<String, Any>, String, Map<String, Any>>(heightLimit, upstreams, extractTx), ChainFees {
companion object {
private val log = LoggerFactory.getLogger(BitcoinFees::class.java)
private val extractTx = { block: Map<String, Any> ->
block["tx"]
?.let { it as List<String> }
// drop first tx which is a miner reward
?.let { if (it.isEmpty()) it else it.drop(1) }
?: emptyList()
}
}
fun calculateFee(tx: Map<String, Any>): Mono<Long> {
val outAmount = extractVOuts(tx).reduce { acc, l -> (acc ?: 0L) + (l ?: 0L) } ?: 0
val vinsData = tx.get("vin")?.let { it as List<Map<String, Any>> } ?: return Mono.empty()
return Flux.fromIterable(vinsData)
.flatMap {
val txid = it["txid"] as String?
val vout = (it["vout"] as Number?)?.toInt()
if (txid == null || vout == null) {
Mono.empty()
} else {
getTxOutAmount(txid, vout)
}
}
.reduce { t, u -> t + u }
.map { inAmount ->
(inAmount - outAmount).coerceAtLeast(0)
}
}
fun extractSize(tx: Map<String, Any>): Int {
val size: Number = if (tx.containsKey("vsize")) {
tx["vsize"] as Number
} else if (tx.containsKey("size")) {
tx["size"] as Number
} else {
0
}
return size.toInt()
}
fun getTxOutAmount(txid: String, vout: Int): Mono<Long> {
return reader.getTx(txid)
.switchIfEmpty(
Mono.fromCallable { log.warn("No tx $txid") }
.then(Mono.empty())
)
.flatMap {
extractVOuts(it).let {
if (vout < it.size) {
Mono.justOrEmpty(it[vout])
} else {
Mono.empty()
}
}
}
}
fun extractVOuts(tx: Map<String, Any>): List<Long?> {
val voutsData = tx.get("vout")?.let { it as List<Map<String, Any>> } ?: return emptyList()
return voutsData.map {
val amount = it["value"] ?: return@map null
BigDecimal(amount.toString())
.multiply(BitcoinConst.COIN_DEC)
.longValueExact()
}
}
override fun readFeesAt(height: Long, selector: TxAt<Map<String, Any>, String>): Mono<TxFee> {
return reader.getBlock(height)
.flatMap { block ->
Mono.justOrEmpty(selector.get(block))
.flatMap { txid -> reader.getTx(txid!!) }
.flatMap { tx ->
calculateFee(tx)
.map { fee ->
TxFee(1, fee * 1024 / extractSize(tx))
}
}
}
}
override fun feeAggregation(mode: ChainFees.Mode): Function<Flux<TxFee>, Mono<TxFee>> {
if (mode == ChainFees.Mode.MIN_ALWAYS) {
return Function { src ->
src.reduce { a, b ->
if (a.fee > b.fee) a else b
}
}
}
return Function { src ->
src.reduce { a, b ->
TxFee(a.count + b.count, a.fee + b.fee)
}
}
}
override fun getResponseBuilder(): Function<TxFee, BlockchainOuterClass.EstimateFeeResponse> {
return Function {
val fee = (it.fee / it.count).coerceAtLeast(1)
BlockchainOuterClass.EstimateFeeResponse.newBuilder()
.setBitcoinStd(
BlockchainOuterClass.BitcoinStdFees.newBuilder()
.setSatPerKb(fee.toString())
)
.build()
}
}
// -------
data class TxFee(val count: Int, val fee: Long)
}

View File

@@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.ChainFees
import io.emeraldpay.dshackle.upstream.EmptyHead
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.MergedHead
@@ -49,6 +50,7 @@ open class BitcoinMultistream(
private var reader = BitcoinReader(this, head, esplora)
private var addressActiveCheck: AddressActiveCheck? = null
private var xpubAddresses: XpubAddresses? = null
private val feeEstimation = BitcoinFees(this, reader, 6)
override fun init() {
if (upstreams.size > 0) {
@@ -57,6 +59,10 @@ open class BitcoinMultistream(
super.init()
}
override fun getFeeEstimation(): ChainFees {
return feeEstimation
}
open fun getXpubAddresses(): XpubAddresses? {
return xpubAddresses
}

View File

@@ -55,6 +55,11 @@ open class BitcoinReader(
return castedRead(JsonRpcRequest("getblock", listOf(hash)), Map::class.java).cast()
}
open fun getBlock(height: Long): Mono<Map<String, Any>> {
return castedRead(JsonRpcRequest("getblockhash", listOf(height)), String::class.java)
.flatMap(this@BitcoinReader::getBlock)
}
open fun getTx(txid: String): Mono<Map<String, Any>> {
return castedRead(JsonRpcRequest("getrawtransaction", listOf(txid, true)), Map::class.java).cast()
}

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.upstream.AbstractChainFees
import io.emeraldpay.dshackle.upstream.ChainFees
import io.emeraldpay.etherjar.domain.Wei
import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.util.function.Tuples
import java.util.function.Function
abstract class EthereumFees(
upstreams: EthereumMultistream,
private val reader: EthereumReader,
heightLimit: Int,
) : AbstractChainFees<EthereumFees.EthereumFee, BlockJson<TransactionRefJson>, TransactionRefJson, TransactionJson>(heightLimit, upstreams, extractTx), ChainFees {
companion object {
private val log = LoggerFactory.getLogger(EthereumFees::class.java)
private val extractTx = { block: BlockJson<TransactionRefJson> ->
block.transactions
}
}
abstract fun extractFee(block: BlockJson<TransactionRefJson>, tx: TransactionJson): EthereumFee
override fun readFeesAt(height: Long, selector: TxAt<BlockJson<TransactionRefJson>, TransactionRefJson>): Mono<EthereumFee> {
return reader.blocksByHeightParsed().read(height)
.flatMap { block ->
Mono.justOrEmpty(selector.get(block))
.cast(TransactionRefJson::class.java)
.flatMap { reader.txByHash().read(it.hash) }
.map { tx -> extractFee(block, tx) }
}
}
override fun feeAggregation(mode: ChainFees.Mode): Function<Flux<EthereumFee>, Mono<EthereumFee>> {
if (mode == ChainFees.Mode.MIN_ALWAYS) {
return Function { src ->
src.reduce { a, b ->
EthereumFee(
a.max.coerceAtLeast(b.max),
a.priority.coerceAtLeast(b.priority),
a.paid.coerceAtLeast(b.paid),
Wei.ZERO
)
}
}
}
return Function { src ->
src.map { Tuples.of(1, it) }
.reduce { a, b ->
Tuples.of(a.t1 + b.t1, a.t2.plus(b.t2))
}.map {
EthereumFee(it.t2.max / it.t1, it.t2.priority / it.t1, it.t2.paid / it.t1, it.t2.base / it.t1)
}
}
}
// ---
data class EthereumFee(val max: Wei, val priority: Wei, val paid: Wei, val base: Wei) {
fun plus(o: EthereumFee): EthereumFee {
return EthereumFee(max + o.max, priority + o.priority, paid + o.paid, base + o.base)
}
}
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.etherjar.domain.Wei
import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import org.slf4j.LoggerFactory
import java.util.function.Function
class EthereumLegacyFees(upstreams: EthereumMultistream, reader: EthereumReader, heightLimit: Int) :
EthereumFees(upstreams, reader, heightLimit) {
companion object {
private val log = LoggerFactory.getLogger(EthereumLegacyFees::class.java)
}
private val toGrpc: Function<EthereumFee, BlockchainOuterClass.EstimateFeeResponse> = Function {
BlockchainOuterClass.EstimateFeeResponse.newBuilder()
.setEthereumExtended(
BlockchainOuterClass.EthereumExtFees.newBuilder()
.setMax(it.paid.amount.toString())
.setPriority(it.priority.amount.toString())
)
.build()
}
override fun extractFee(block: BlockJson<TransactionRefJson>, tx: TransactionJson): EthereumFee {
return EthereumFee(tx.gasPrice, tx.gasPrice, tx.gasPrice, Wei.ZERO)
}
override fun getResponseBuilder(): Function<EthereumFee, BlockchainOuterClass.EstimateFeeResponse> {
return toGrpc
}
}

View File

@@ -19,6 +19,7 @@ package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.ChainFees
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.MergedHead
import io.emeraldpay.dshackle.upstream.Multistream
@@ -46,6 +47,12 @@ open class EthereumMultistream(
private val reader: EthereumReader = EthereumReader(this, this.caches, getMethodsFactory())
private val subscribe = EthereumSubscribe(this)
private val supportsEIP1559 = when (chain) {
Chain.ETHEREUM, Chain.TESTNET_ROPSTEN, Chain.TESTNET_GOERLI, Chain.TESTNET_RINKEBY -> true
else -> false
}
private val feeEstimation = if (supportsEIP1559) EthereumPriorityFees(this, reader, 256)
else EthereumLegacyFees(this, reader, 256)
init {
this.init()
@@ -133,4 +140,8 @@ open class EthereumMultistream(
open fun getSubscribe(): EthereumSubscribe {
return subscribe
}
override fun getFeeEstimation(): ChainFees {
return feeEstimation
}
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.etherjar.domain.Wei
import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import org.slf4j.LoggerFactory
import java.util.function.Function
class EthereumPriorityFees(upstreams: EthereumMultistream, reader: EthereumReader, heightLimit: Int) :
EthereumFees(upstreams, reader, heightLimit) {
companion object {
private val log = LoggerFactory.getLogger(EthereumPriorityFees::class.java)
}
private val toGrpc: Function<EthereumFee, BlockchainOuterClass.EstimateFeeResponse> =
Function {
BlockchainOuterClass.EstimateFeeResponse.newBuilder()
.setEthereumExtended(
BlockchainOuterClass.EthereumExtFees.newBuilder()
.setMax(it.max.amount.toString())
.setPriority(it.priority.amount.toString())
.setExpect(it.paid.amount.toString())
)
.build()
}
override fun extractFee(block: BlockJson<TransactionRefJson>, tx: TransactionJson): EthereumFee {
val baseFee = block.baseFeePerGas ?: Wei.ZERO
if (tx.type == 2) {
// an EIP-1559 Transaction provides Max and Priority fee
val paid = (baseFee + tx.maxPriorityFeePerGas).coerceAtMost(tx.maxFeePerGas)
return EthereumFee(tx.maxFeePerGas, tx.maxPriorityFeePerGas, paid, baseFee)
}
return EthereumFee(tx.gasPrice, (tx.gasPrice - baseFee).coerceAtLeast(Wei.ZERO), tx.gasPrice, baseFee)
}
override fun getResponseBuilder(): Function<EthereumFee, BlockchainOuterClass.EstimateFeeResponse> {
return toGrpc
}
}

View File

@@ -125,7 +125,14 @@ open class EthereumReader(
)
}
fun txByHash(): Reader<TransactionId, TransactionJson> {
open fun blocksByHeightParsed(): Reader<Long, BlockJson<TransactionRefJson>> {
return TransformingReader(
blocksByHeightAsCont(),
extractBlock
)
}
open fun txByHash(): Reader<TransactionId, TransactionJson> {
return TransformingReader(
CompoundReader(
RekeyingReader(txHashToId, caches.getTxByHash()),

View File

@@ -26,7 +26,6 @@ import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.grpc.stub.StreamObserver
import io.emeraldpay.etherjar.rpc.RpcResponseError
import io.emeraldpay.etherjar.rpc.json.ResponseJson
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator
import io.netty.buffer.ByteBufInputStream
@@ -57,7 +56,7 @@ import java.util.function.BiFunction
import java.util.function.Consumer
import java.util.function.Predicate
class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
class ApiReaderMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
private static final Logger log = LoggerFactory.getLogger(this)
List<PredefinedResponse> predefined = []
@@ -66,15 +65,15 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
String id = "default"
AtomicInteger calls = new AtomicInteger(0)
EthereumApiMock() {
ApiReaderMock() {
}
EthereumApiMock answerOnce(@NotNull String method, List<Object> params, Object result) {
ApiReaderMock answerOnce(@NotNull String method, List<Object> params, Object result) {
return answer(method, params, result, 1)
}
EthereumApiMock answer(@NotNull String method, List<Object> params, Object result,
Integer limit = null, Throwable exception = null) {
ApiReaderMock answer(@NotNull String method, List<Object> params, Object result,
Integer limit = null, Throwable exception = null) {
predefined << new PredefinedResponse(method: method, params: params, result: result, limit: limit, exception: exception)
return this
}
@@ -169,7 +168,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
}
class WebsocketApi {
private final EthereumApiMock api
private final ApiReaderMock api
private Sinks.Many<JsonRpcResponse> responses = Sinks
.many()
@@ -182,7 +181,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
private WebsocketOutboundMock outbound
private WebsocketInboundMock inbound
WebsocketApi(EthereumApiMock api) {
WebsocketApi(ApiReaderMock api) {
this.api = api
outbound = new WebsocketOutboundMock(api, responses)
inbound = new WebsocketInboundMock(responses.asFlux(), jsonResponses.asFlux())
@@ -260,10 +259,10 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
class WebsocketOutboundMock implements WebsocketOutbound {
private final EthereumApiMock api
private final ApiReaderMock api
private final Sinks.Many<JsonRpcResponse> responses
WebsocketOutboundMock(EthereumApiMock api, Sinks.Many<JsonRpcResponse> responses) {
WebsocketOutboundMock(ApiReaderMock api, Sinks.Many<JsonRpcResponse> responses) {
this.api = api
this.responses = responses
}

View File

@@ -26,7 +26,6 @@ import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.reader.EmptyReader
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.Multistream
import io.emeraldpay.dshackle.upstream.calls.CallMethods
import io.emeraldpay.dshackle.upstream.calls.DirectCallMethods
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.dshackle.upstream.ethereum.EthereumUpstream
@@ -39,15 +38,12 @@ import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.logging.LoggingMeterRegistry
import org.apache.commons.lang3.StringUtils
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalUnit
class TestingCommons {
static EthereumApiMock api() {
return new EthereumApiMock()
static ApiReaderMock api() {
return new ApiReaderMock()
}
static EthereumUpstreamMock upstream() {

View File

@@ -0,0 +1,253 @@
package io.emeraldpay.dshackle.upstream
import kotlin.jvm.functions.Function1
import org.jetbrains.annotations.NotNull
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import spock.lang.Specification
import java.time.Duration
import java.util.function.Function
class AbstractChainFeesSpec extends Specification {
Function<List<String>, List<String>> extractTx = { it }
def "Iterates N last blocks"() {
setup:
def ups = Mock(Multistream) {
1 * getHead() >> Mock(Head) {
1 * it.getCurrentHeight() >> 234
}
}
def fees = new TestChainFees(5, ups, Stub(Function1))
when:
def act = fees.usingBlocks(3).collectList().block(Duration.ofSeconds(1)).toSorted()
then:
act == [232L, 233L, 234L]
}
def "Limits iteration to configured"() {
setup:
def ups = Mock(Multistream) {
1 * getHead() >> Mock(Head) {
1 * it.getCurrentHeight() >> 234
}
}
def fees = new TestChainFees(3, ups, Stub(Function1))
when:
def act = fees.usingBlocks(5).collectList().block(Duration.ofSeconds(1)).toSorted()
then:
act == [232L, 233L, 234L]
}
def "TxAtPos 0 for empty list"() {
setup:
def txAt = new AbstractChainFees.TxAtPos<List<String>, String>(extractTx, 0)
when:
def act = txAt.get([])
then:
act == null
}
def "TxAtPos 0 for single item list"() {
setup:
def txAt = new AbstractChainFees.TxAtPos<List<String>, String>(extractTx, 0)
when:
def act = txAt.get(["t_1"])
then:
act == "t_1"
}
def "TxAtPos 0 for many item list"() {
setup:
def txAt = new AbstractChainFees.TxAtPos<List<String>, String>(extractTx, 0)
when:
def act = txAt.get(["t_1", "t_2", "t_3"])
then:
act == "t_3"
}
def "TxAtPos 1 for many item list"() {
setup:
def txAt = new AbstractChainFees.TxAtPos<List<String>, String>(extractTx, 1)
when:
def act = txAt.get(["t_1", "t_2", "t_3"])
then:
act == "t_2"
}
def "TxAtPos 2 for many item list"() {
setup:
def txAt = new AbstractChainFees.TxAtPos<List<String>, String>(extractTx, 2)
when:
def act = txAt.get(["t_1", "t_2", "t_3"])
then:
act == "t_1"
}
def "TxAtPos 5 for 3 item list"() {
setup:
def txAt = new AbstractChainFees.TxAtPos<List<String>, String>(extractTx, 5)
when:
def act = txAt.get(["t_1", "t_2", "t_3"])
then:
act == "t_1"
}
def "TxAtBottom for empty list"() {
setup:
def txAt = new AbstractChainFees.TxAtBottom<List<String>, String>(extractTx)
when:
def act = txAt.get([])
then:
act == null
}
def "TxAtBottom for single item list"() {
setup:
def txAt = new AbstractChainFees.TxAtBottom<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1"])
then:
act == "t_1"
}
def "TxAtBottom for multi item list"() {
setup:
def txAt = new AbstractChainFees.TxAtBottom<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1", "t_2", "t_3"])
then:
act == "t_3"
}
def "TxAtTop for empty list"() {
setup:
def txAt = new AbstractChainFees.TxAtTop<List<String>, String>(extractTx)
when:
def act = txAt.get([])
then:
act == null
}
def "TxAtTop for single item list"() {
setup:
def txAt = new AbstractChainFees.TxAtTop<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1"])
then:
act == "t_1"
}
def "TxAtTop for multi item list"() {
setup:
def txAt = new AbstractChainFees.TxAtTop<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1", "t_2", "t_3"])
then:
act == "t_1"
}
def "TxAtMiddle for empty list"() {
setup:
def txAt = new AbstractChainFees.TxAtMiddle<List<String>, String>(extractTx)
when:
def act = txAt.get([])
then:
act == null
}
def "TxAtMiddle for single item list"() {
setup:
def txAt = new AbstractChainFees.TxAtMiddle<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1"])
then:
act == "t_1"
}
def "TxAtMiddle for 3 item list"() {
setup:
def txAt = new AbstractChainFees.TxAtMiddle<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1", "t_2", "t_3"])
then:
act == "t_2"
}
def "TxAtMiddle for 4 item list"() {
setup:
def txAt = new AbstractChainFees.TxAtMiddle<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1", "t_2", "t_3", "t_4"])
then:
act == "t_2" || act == "t_3"
}
def "TxAtMiddle for 5 item list"() {
setup:
def txAt = new AbstractChainFees.TxAtMiddle<List<String>, String>(extractTx)
when:
def act = txAt.get(["t_1", "t_2", "t_3", "t_4", "t_5"])
then:
act == "t_3"
}
class TestChainFees extends AbstractChainFees {
TestChainFees(int heightLimit, @NotNull Multistream upstreams, @NotNull Function1 extractTx) {
super(heightLimit, upstreams, extractTx)
}
@Override
Mono readFeesAt(long height, @NotNull TxAt selector) {
return null
}
@Override
Function<Flux, Mono> feeAggregation(@NotNull Mode mode) {
return null
}
@Override
Function getResponseBuilder() {
return null
}
}
}

View File

@@ -225,5 +225,10 @@ class MultistreamSpec extends Specification {
public <T extends Upstream> T cast(Class<T> selfType) {
return this
}
@Override
ChainFees getFeeEstimation() {
return null
}
}
}

View File

@@ -0,0 +1,207 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.upstream.ChainFees
import io.emeraldpay.dshackle.upstream.Head
import reactor.core.publisher.Mono
import spock.lang.Specification
import java.time.Duration
class BitcoinFeesSpec extends Specification {
def block3 = [
hash: "3300000000033327aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506",
tx : [
"d5a67d8f99ad05ece282bc8714da55cbd2266d9d7bae98fd5dda3b55d1696fb8",
"e17eca6b595da9c1c7b883fe505184d7ff586315d5b8a0c476198f0d49a75ad3",
"1735dfef80bcfdc443943dcbc8d26a4133da87c5c81d9e2a1e0a0e5f59d1ef96",
"c83c99e50946a6d30bc344a24401ad0ff0598425d73a5f74d6545e15ae588321",
"0d4f85f840f09f317d0b202725acee868b2e969078bb6a6b89fc3e0bc1216c6a",
"4d56b1a0992a3fa3132083ce29e203165ef4768f028d98a738da79dd26ff91e2",
]
]
def block2 = [
hash: "2200000000022227aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506",
tx : [
"87cc7d37851af41da60a5d14e40983d0a6750ffcdf1f2ae1291bfc1e712f0712",
"3bde80635c62e81ba86d68a1c78ac47b1a02d8ef334fdf6a7b6e8acc0a475f20",
"2f4f413c07ac072918e12fcac4afd8d30a76dfe49ac68d46d582c3a130772734",
"f29ecfa5b692cfc46b1eae29416a35782657f0bde2ae44acde882e477e8624d8",
]
]
def tx4D5 = [
hash : "4d56b1a0992a3fa3132083ce29e203165ef4768f028d98a738da79dd26ff91e2",
size : 223,
vsize: 142,
vin : [
[txid: "3bde80635c62e81ba86d68a1c78ac47b1a02d8ef334fdf6a7b6e8acc0a475f20", vout: 1]
],
vout : [
["value": 0.00029163, "n": 0],
["value": 0.00144221, "n": 1]
]
]
def txF29 = [
hash : "f29ecfa5b692cfc46b1eae29416a35782657f0bde2ae44acde882e477e8624d8",
size : 2900,
vsize: 1366,
vin : [
[txid: "37e19fad6c3e5fafc431ee731428452dc01f56b0bc4d9fe876dcee58384934ec", "vout": 0],
[txid: "3da079b10451ba1cde42d9a0f485e79151e5d4216930561227be5471921af17f", "vout": 0]
],
vout : [
["value": 1.20100000, "n": 0],
["value": 0.00210984, "n": 1]
]
]
def tx3BD = [
hash: "3bde80635c62e81ba86d68a1c78ac47b1a02d8ef334fdf6a7b6e8acc0a475f20",
size: 292,
vin : [
],
vout: [
["value": 0.00400000, "n": 0],
["value": 0.00200000, "n": 1],
]
]
def tx37E = [
hash : "37e19fad6c3e5fafc431ee731428452dc01f56b0bc4d9fe876dcee58384934ec",
size : 292,
vsize: 200,
vin : [
],
vout : [
["value": 1.00000000, "n": 0],
]
]
def tx3DA = [
hash : "3da079b10451ba1cde42d9a0f485e79151e5d4216930561227be5471921af17f",
size : 297,
vsize: 201,
vin : [
],
vout : [
["value": 0.21000000, "n": 0],
]
]
def "fetch tx amount"() {
setup:
def reader = Mock(BitcoinReader) {
1 * it.getTx("4d56b1a0992a3fa3132083ce29e203165ef4768f028d98a738da79dd26ff91e2") >> Mono.just(tx4D5)
}
def fees = new BitcoinFees(Stub(BitcoinMultistream), reader, 3)
when:
def amount = fees.getTxOutAmount("4d56b1a0992a3fa3132083ce29e203165ef4768f028d98a738da79dd26ff91e2", 0)
.block(Duration.ofSeconds(1))
then:
amount == 29163
}
def "extract amounts"() {
setup:
def fees = new BitcoinFees(Stub(BitcoinMultistream), Stub(BitcoinReader), 3)
when:
def act = fees.extractVOuts(txF29)
then:
act == [120100000L, 210984L]
}
def "extract vsize when available"() {
setup:
def fees = new BitcoinFees(Stub(BitcoinMultistream), Stub(BitcoinReader), 3)
when:
def act = fees.extractSize(tx4D5)
then:
act == 142
}
def "extract size when vsize unavailable"() {
setup:
def fees = new BitcoinFees(Stub(BitcoinMultistream), Stub(BitcoinReader), 3)
when:
def act = fees.extractSize(tx3BD)
then:
act == 292
}
def "calculates fee"() {
setup:
def reader = Mock(BitcoinReader) {
1 * it.getTx("3bde80635c62e81ba86d68a1c78ac47b1a02d8ef334fdf6a7b6e8acc0a475f20") >> Mono.just(tx3BD)
}
def fees = new BitcoinFees(Stub(BitcoinMultistream), reader, 3)
when:
def act = fees.calculateFee(tx4D5).block(Duration.ofSeconds(1))
then:
act == 200000 - (29163 + 144221)
}
def "calculates fee with multiple inputs"() {
setup:
def reader = Mock(BitcoinReader) {
1 * it.getTx("37e19fad6c3e5fafc431ee731428452dc01f56b0bc4d9fe876dcee58384934ec") >> Mono.just(tx37E)
1 * it.getTx("3da079b10451ba1cde42d9a0f485e79151e5d4216930561227be5471921af17f") >> Mono.just(tx3DA)
}
def fees = new BitcoinFees(Stub(BitcoinMultistream), reader, 3)
when:
def act = fees.calculateFee(txF29).block(Duration.ofSeconds(1))
then:
act == (100000000 + 21000000) - (120100000 + 210984)
}
def "get average bottom fee"() {
setup:
def ups = Mock(BitcoinMultistream) {
_ * getHead() >> Mock(Head) {
_ * getCurrentHeight() >> 100
}
}
def reader = Mock(BitcoinReader) {
1 * it.getBlock(100) >> Mono.just(block3)
1 * it.getBlock(99) >> Mono.just(block2)
// last txes on those blocks
1 * it.getTx("4d56b1a0992a3fa3132083ce29e203165ef4768f028d98a738da79dd26ff91e2") >> Mono.just(tx4D5)
1 * it.getTx("f29ecfa5b692cfc46b1eae29416a35782657f0bde2ae44acde882e477e8624d8") >> Mono.just(txF29)
// their inputs
1 * it.getTx("3bde80635c62e81ba86d68a1c78ac47b1a02d8ef334fdf6a7b6e8acc0a475f20") >> Mono.just(tx3BD)
1 * it.getTx("37e19fad6c3e5fafc431ee731428452dc01f56b0bc4d9fe876dcee58384934ec") >> Mono.just(tx37E)
1 * it.getTx("3da079b10451ba1cde42d9a0f485e79151e5d4216930561227be5471921af17f") >> Mono.just(tx3DA)
}
def fees = new BitcoinFees(ups, reader, 3)
when:
def act = fees.estimate(ChainFees.Mode.AVG_LAST, 2).block(Duration.ofSeconds(1))
then:
act.hasBitcoinStd()
// first tx fee: 187.43661971830985915493
// second tx fee: 504.40409956076134699854
// average is 345
// but if we calculate original fees per KB it's 354222
act.bitcoinStd.satPerKb == "354222"
}
}

View File

@@ -0,0 +1,47 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.test.ApiReaderMock
import io.emeraldpay.dshackle.upstream.Head
import reactor.core.publisher.Mono
import spock.lang.Specification
import java.time.Duration
class BitcoinReaderSpec extends Specification {
def "gets block by height"() {
setup:
def block = [
hash: "000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506",
tx : []
]
def api = new ApiReaderMock()
api.answerOnce("getblockhash", [100000], "000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506")
api.answerOnce("getblock", ["000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506"], block)
def ups = Mock(BitcoinMultistream) {
_ * it.getDirectApi(_) >> Mono.just(api)
}
def reader = new BitcoinReader(ups, Stub(Head), null)
when:
def act = reader.getBlock(100000).block(Duration.ofSeconds(1))
then:
act == block
}
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.etherjar.domain.Wei
import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionJson
import spock.lang.Specification
class EthereumLegacyFeesSpec extends Specification {
def "Extract fee from"() {
setup:
def block = new BlockJson()
// 0x75cc01873a9818bf426a8b23d83450bf18530a822fd4fe9e86a416a5554176a6
def tx = new TransactionJson().tap {
it.gasPrice = Wei.ofUnits(8, Wei.Unit.GWEI)
}
def fees = new EthereumLegacyFees(Stub(EthereumMultistream), Stub(EthereumReader), 10)
when:
def act = fees.extractFee(block, tx)
then:
act.priority == Wei.ofUnits(8, Wei.Unit.GWEI)
act.max == Wei.ofUnits(8, Wei.Unit.GWEI)
act.base == Wei.ZERO
}
}

View File

@@ -0,0 +1,150 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.ChainFees
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.etherjar.domain.TransactionId
import io.emeraldpay.etherjar.domain.Wei
import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import spock.lang.Specification
import java.time.Duration
class EthereumPriorityFeesSpec extends Specification {
def "Extract fee from EIP1559 tx"() {
setup:
// 13756007
def block = new BlockJson().tap {
it.baseFeePerGas = new Wei(104197355513)
}
// 0x5da50f35a51e56ecd4313417b1c30f9c088222f3f8763701effe14f3dd18b6cc
def tx = new TransactionJson().tap {
it.type = 2
it.maxFeePerGas = Wei.ofUnits(999, Wei.Unit.GWEI)
it.maxPriorityFeePerGas = Wei.ofUnits(5.0001, Wei.Unit.GWEI)
}
def fees = new EthereumPriorityFees(Stub(EthereumMultistream), Stub(EthereumReader), 10)
when:
def act = fees.extractFee(block, tx)
then:
act.priority == Wei.ofUnits(5.0001, Wei.Unit.GWEI)
act.max == Wei.ofUnits(999, Wei.Unit.GWEI)
act.base == new Wei(104197355513)
}
def "Extract fee from legacy tx"() {
setup:
// 13756007
def block = new BlockJson().tap {
it.baseFeePerGas = new Wei(104197355513)
}
// 0x1f507982bef0f11a8304287d41f228b5f1dda1114a446ee781c3d95ef4a7b891
def tx = new TransactionJson().tap {
it.type = 0
// 109.564020111 Gwei
it.gasPrice = Wei.from("0x198286458f")
}
def fees = new EthereumPriorityFees(Stub(EthereumMultistream), Stub(EthereumReader), 10)
when:
def act = fees.extractFee(block, tx)
then:
// as difference between base minimum and actually paid
act.priority == Wei.ofUnits(5.366664598, Wei.Unit.GWEI)
act.max == Wei.from("0x198286458f")
act.base == new Wei(104197355513)
}
def "Calculates average fee"() {
setup:
def inputs = [
new EthereumFees.EthereumFee(Wei.ofEthers(1), Wei.ofEthers(0.5), Wei.ofEthers(0.75), Wei.ofEthers(0.5)),
new EthereumFees.EthereumFee(Wei.ofEthers(0.75), Wei.ofEthers(0.5), Wei.ofEthers(0.75), Wei.ofEthers(0.5)),
new EthereumFees.EthereumFee(Wei.ofEthers(0.6), Wei.ofEthers(0.2), Wei.ofEthers(0.6), Wei.ofEthers(0.5)),
]
def fees = new EthereumPriorityFees(Stub(EthereumMultistream), Stub(EthereumReader), 10)
when:
def act = Flux.fromIterable(inputs)
.transform(fees.feeAggregation(ChainFees.Mode.AVG_LAST))
.next().block(Duration.ofSeconds(1))
then:
act.priority == Wei.ofEthers(0.4) // 0.5 + 0.5 + 0.2
act.paid == Wei.ofEthers(0.7) // 0.75 + 0.75 + 0.6
}
def "Estimate"() {
setup:
def block1 = new BlockJson().tap {
it.baseFeePerGas = new Wei(92633661632)
it.transactions = [
new TransactionRefJson(TransactionId.from("0x00000000fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")),
new TransactionRefJson(TransactionId.from("0x11111111fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")),
new TransactionRefJson(TransactionId.from("0x22222222fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")),
]
}
def block2 = new BlockJson().tap {
it.baseFeePerGas = new Wei(104197355513)
it.transactions = [
new TransactionRefJson(TransactionId.from("0x33333333fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")),
new TransactionRefJson(TransactionId.from("0x44444444fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")),
new TransactionRefJson(TransactionId.from("0x55555555fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")),
]
}
def tx1 = new TransactionJson().tap {
it.type = 2
it.maxFeePerGas = Wei.ofUnits(150, Wei.Unit.GWEI)
it.maxPriorityFeePerGas = Wei.ofUnits(3, Wei.Unit.GWEI)
}
def tx2 = new TransactionJson().tap {
it.type = 2
it.maxFeePerGas = Wei.ofUnits(200, Wei.Unit.GWEI)
it.maxPriorityFeePerGas = Wei.ofUnits(6, Wei.Unit.GWEI)
}
def ups = Mock(EthereumMultistream) {
1 * getHead() >> Mock(Head) {
1 * getCurrentHeight() >> 13756007
}
}
def reader = Mock(EthereumReader) {
_ * it.blocksByHeightParsed() >> Mock(Reader) {
1 * it.read(13756006) >> Mono.just(block1)
1 * it.read(13756007) >> Mono.just(block2)
}
_ * it.txByHash() >> Mock(Reader) {
1 * it.read(TransactionId.from("0x22222222fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")) >> Mono.just(tx1)
1 * it.read(TransactionId.from("0x55555555fad596cad644b785a8a74f6580ceec9ae13c8aa174f819c0223b8c77")) >> Mono.just(tx2)
}
}
def fees = new EthereumPriorityFees(ups, reader, 10)
when:
def act = fees.estimate(ChainFees.Mode.AVG_LAST, 2).block(Duration.ofSeconds(1))
then:
act.hasEthereumExtended()
act.ethereumExtended.priority == "4500000000"
act.ethereumExtended.max == "175000000000"
act.ethereumExtended.expect == "102915508572"
}
}