From 772142e156970291d2abdc29a78c26275bb88234 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 31 May 2024 15:46:55 +0300 Subject: [PATCH] Check gas price (#494) * add gas validation option * gas price config for chains * check gas price * add tests * fix txt * make gas price dls * fix config --- docs/reference-configuration.adoc | 5 ++ .../dshackle/foundation/ChainOptions.kt | 4 ++ .../dshackle/foundation/ChainOptionsReader.kt | 3 + .../org/drpc/chainsconfig/ChainsConfig.kt | 32 +++++++-- .../drpc/chainsconfig/ChainsConfigReader.kt | 4 +- foundation/src/main/resources/chains.yaml | 2 + .../ethereum/EthereumUpstreamValidator.kt | 28 +++++++- .../config/UpstreamsConfigReaderSpec.groovy | 2 +- .../EthereumUpstreamValidatorSpec.groovy | 72 ++++++++++++++++--- 9 files changed, 133 insertions(+), 19 deletions(-) diff --git a/docs/reference-configuration.adoc b/docs/reference-configuration.adoc index 91b38c229..d5ddabf9c 100644 --- a/docs/reference-configuration.adoc +++ b/docs/reference-configuration.adoc @@ -778,6 +778,11 @@ If the Upstream is in _syncing_ state then the Dshackle doesn't use it for call | Enable/Disable the call limit validation. Size of call limit is defined by `call-limit` option. If it's enabled configuration parameter for chain `call-limit-contract` is required. +| `validate-gas-price` +| boolean +| `true` +| Enable/Disable the gas price validation. If it's enabled, the Dshackle will check the gas price of the upstream and will not use it if it's too high. + | `call-limit-size` | number | `1000000` diff --git a/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptions.kt b/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptions.kt index 4d58d4ad2..bbb9b7b8f 100644 --- a/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptions.kt +++ b/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptions.kt @@ -13,6 +13,7 @@ class ChainOptions { val minPeers: Int, val validateSyncing: Boolean, val validateCallLimit: Boolean, + val validateGasPrice: Boolean, val validateChain: Boolean, val callLimitSize: Int, ) @@ -30,6 +31,7 @@ class ChainOptions { var providesBalance: Boolean? = null, var validatePeers: Boolean? = null, var validateCallLimit: Boolean? = null, + var validateGasPrice: Boolean? = null, var minPeers: Int? = null, var validateSyncing: Boolean? = null, var validateChain: Boolean? = null, @@ -56,6 +58,7 @@ class ChainOptions { copy.providesBalance = overwrites.providesBalance ?: this.providesBalance copy.validateSyncing = overwrites.validateSyncing ?: this.validateSyncing copy.validateCallLimit = overwrites.validateCallLimit ?: this.validateCallLimit + copy.validateGasPrice = overwrites.validateGasPrice ?: this.validateGasPrice copy.timeout = overwrites.timeout ?: this.timeout copy.validateChain = overwrites.validateChain ?: this.validateChain copy.disableUpstreamValidation = @@ -75,6 +78,7 @@ class ChainOptions { this.minPeers ?: 1, this.validateSyncing ?: true, this.validateCallLimit ?: true, + this.validateGasPrice ?: true, this.validateChain ?: true, this.callLimitSize ?: 1_000_000, ) diff --git a/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptionsReader.kt b/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptionsReader.kt index 8b88647d3..2e1460087 100644 --- a/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptionsReader.kt +++ b/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptionsReader.kt @@ -25,6 +25,9 @@ class ChainOptionsReader : YamlConfigReader() { getValueAsBool(values, "validate-call-limit")?.let { options.validateCallLimit = it } + getValueAsBool(values, "validate-gas-price")?.let { + options.validateGasPrice = it + } getValueAsBool(values, "validate-chain")?.let { options.validateChain = it } diff --git a/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt b/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt index 9cca90b0e..e3950c783 100644 --- a/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt +++ b/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt @@ -12,11 +12,29 @@ data class ChainsConfig(private val chains: List) : Iterable { return chains.iterator() } + companion object { @JvmStatic fun default(): ChainsConfig = ChainsConfig(emptyList()) } + class GasPriceCondition(private val condition: String) { + fun check(value: Long): Boolean { + val (op, valueStr) = condition.split(" ") + return when (op) { + "ne" -> value != valueStr.toLong() + "eq" -> value == valueStr.toLong() + "gt" -> value > valueStr.toLong() + "lt" -> value < valueStr.toLong() + "ge" -> value >= valueStr.toLong() + "le" -> value <= valueStr.toLong() + else -> throw IllegalArgumentException("Unsupported condition: $condition") + } + } + + fun rules() = condition + } + data class ChainConfig( val expectedBlockTime: Duration, val syncingLagSize: Int, @@ -30,7 +48,8 @@ data class ChainsConfig(private val chains: List) : Iterable) : Iterable { + if (!options.validateGasPrice || config.gasPriceCondition == null) { + return Mono.just(ValidateUpstreamSettingsResult.UPSTREAM_VALID) + } + return upstream.getIngressReader() + .read(ChainRequest("eth_gasPrice", ListParams())) + .flatMap(ChainResponse::requireStringResult) + .map { result -> + val actualGasPrice = result.substring(2).toLong(16) + if (!config.gasPriceCondition!!.check(actualGasPrice)) { + log.warn( + "Node ${upstream.getId()} has gasPrice $actualGasPrice, " + + "but it is not equal to the required ${config.gasPriceCondition!!.rules()}", + ) + ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR + } else { + ValidateUpstreamSettingsResult.UPSTREAM_VALID + } + } + .onErrorResume { err -> + log.warn("Error during gasPrice validation", err) + Mono.just(ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR) + } + } + private fun chainId(): Mono { return upstream.getIngressReader() .read(ChainRequest("eth_chainId", ListParams())) diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy index 619e21c8d..4c5df8ef5 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy @@ -636,7 +636,7 @@ class UpstreamsConfigReaderSpec extends Specification { def options = partialOptions.buildOptions() then: options == new ChainOptions.Options( - false, false, 30, Duration.ofSeconds(60), null, true, 1, true, true, true, 1_000_000 + false, false, 30, Duration.ofSeconds(60), null, true, 1, true, true, true, true, 1_000_000 ) } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidatorSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidatorSpec.groovy index f3a7d805c..249409d39 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidatorSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidatorSpec.groovy @@ -36,6 +36,7 @@ import spock.lang.Specification import java.time.Duration +import static io.emeraldpay.dshackle.Chain.BSC__MAINNET import static io.emeraldpay.dshackle.Chain.ETHEREUM__MAINNET import static io.emeraldpay.dshackle.Chain.OPTIMISM__MAINNET import static io.emeraldpay.dshackle.upstream.UpstreamAvailability.* @@ -55,16 +56,16 @@ class EthereumUpstreamValidatorSpec extends Specification { expect: validator.resolve(Tuples.of(sync, peers)) == exp where: - exp | sync | peers - OK | OK | OK - IMMATURE | OK | IMMATURE - UNAVAILABLE | OK | UNAVAILABLE - SYNCING | SYNCING | OK - SYNCING | SYNCING | IMMATURE - UNAVAILABLE | SYNCING | UNAVAILABLE - UNAVAILABLE | UNAVAILABLE | OK - UNAVAILABLE | UNAVAILABLE | IMMATURE - UNAVAILABLE | UNAVAILABLE | UNAVAILABLE + exp | sync | peers + OK | OK | OK + IMMATURE | OK | IMMATURE + UNAVAILABLE | OK | UNAVAILABLE + SYNCING | SYNCING | OK + SYNCING | SYNCING | IMMATURE + UNAVAILABLE | SYNCING | UNAVAILABLE + UNAVAILABLE | UNAVAILABLE | OK + UNAVAILABLE | UNAVAILABLE | IMMATURE + UNAVAILABLE | UNAVAILABLE | UNAVAILABLE } def "Doesnt check eth_syncing when disabled"() { @@ -112,7 +113,7 @@ class EthereumUpstreamValidatorSpec extends Specification { Mono.just(new ChainResponse('false'.getBytes(), null)) ] } - 2 * getHead() >> Mock(Head) {head -> + 2 * getHead() >> Mock(Head) { head -> 1 * head.onSyncingNode(true) 1 * head.onSyncingNode(false) } @@ -276,6 +277,7 @@ class EthereumUpstreamValidatorSpec extends Specification { def options = ChainOptions.PartialOptions.getDefaults().tap { it.validateCallLimit = false it.validateChain = false + it.validateGasPrice = false }.buildOptions() def up = Mock(Upstream) { 2 * getIngressReader() >> @@ -297,6 +299,7 @@ class EthereumUpstreamValidatorSpec extends Specification { setup: def options = ChainOptions.PartialOptions.getDefaults().tap { it.validateChain = false + it.validateGasPrice = false }.buildOptions() def up = Mock(Upstream) { 3 * getIngressReader() >> Mock(Reader) { @@ -321,6 +324,7 @@ class EthereumUpstreamValidatorSpec extends Specification { setup: def options = ChainOptions.PartialOptions.getDefaults().tap { it.validateChain = false + it.validateGasPrice = false }.buildOptions() def up = Mock(Upstream) { 3 * getIngressReader() >> Mock(Reader) { @@ -341,6 +345,52 @@ class EthereumUpstreamValidatorSpec extends Specification { act == UPSTREAM_SETTINGS_ERROR } + def "Upstream is valid if gas price is equal to expected"() { + setup: + def options = ChainOptions.PartialOptions.getDefaults().tap { + it.validateChain = false + it.validateCallLimit = false + }.buildOptions() + def conf = ChainConfig.defaultWithGasPriceCondition("ne 3000000000") + def up = Mock(Upstream) { + 3 * getIngressReader() >> + Mock(Reader) { + 1 * read(new ChainRequest("eth_gasPrice", new ListParams())) >> Mono.just(new ChainResponse('"0x3b9aca00"'.getBytes(), null)) + 1 * read(new ChainRequest("eth_blockNumber", new ListParams())) >> Mono.just(new ChainResponse('"0x10ff9be"'.getBytes(), null)) + 1 * read(new ChainRequest("eth_getBlockByNumber", new ListParams(["0x10fd2ae", false]))) >> + Mono.just(new ChainResponse('"result"'.getBytes(), null)) + } + } + def validator = new EthereumUpstreamValidator(BSC__MAINNET, up, options, conf) + when: + def act = validator.validateUpstreamSettingsOnStartup() + then: + act == UPSTREAM_VALID + } + + def "Upstream is NOT valid if gas price is different from expected"() { + setup: + def options = ChainOptions.PartialOptions.getDefaults().tap { + it.validateChain = false + it.validateCallLimit = false + }.buildOptions() + def conf = ChainConfig.defaultWithGasPriceCondition("eq 1000000000") + def up = Mock(Upstream) { + 3 * getIngressReader() >> + Mock(Reader) { + 1 * read(new ChainRequest("eth_gasPrice", new ListParams())) >> Mono.just(new ChainResponse('"0xb2d05e00"'.getBytes(), null)) + 1 * read(new ChainRequest("eth_blockNumber", new ListParams())) >> Mono.just(new ChainResponse('"0x10ff9be"'.getBytes(), null)) + 1 * read(new ChainRequest("eth_getBlockByNumber", new ListParams(["0x10fd2ae", false]))) >> + Mono.just(new ChainResponse('"result"'.getBytes(), null)) + } + } + def validator = new EthereumUpstreamValidator(BSC__MAINNET, up, options, conf) + when: + def act = validator.validateUpstreamSettingsOnStartup() + then: + act == UPSTREAM_FATAL_SETTINGS_ERROR + } + def "Upstream is valid if chain settings are valid"() { setup: def options = ChainOptions.PartialOptions.getDefaults().tap {