From ea7e8ef07cac4f85f1541f68eb58feada8805236 Mon Sep 17 00:00:00 2001 From: EugeneDrpc Date: Mon, 1 Dec 2025 16:12:58 +0100 Subject: [PATCH 001/108] public submodule update (#747) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 878ce981c..23390f78b 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 878ce981c7a0f724226969f92001225213123ebc +Subproject commit 23390f78bdd1e8fa7d18b1b5c7aa138a217e442f From 080d92a6c1bbdc55d33b74e5031ea8d11b278232 Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Tue, 2 Dec 2025 14:26:52 +0300 Subject: [PATCH 002/108] update public (#748) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 23390f78b..502ae7044 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 23390f78bdd1e8fa7d18b1b5c7aa138a217e442f +Subproject commit 502ae70443a5adb0672d01f8af2a0a3d09348188 From 487105570b0f7785e96463afdd13d4584b84c50d Mon Sep 17 00:00:00 2001 From: Mike Yarosh Date: Tue, 2 Dec 2025 15:08:21 +0300 Subject: [PATCH 003/108] Add fetch-tags: true. --- .github/workflows/publish.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index d7df5fb45..27dc8abfc 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -12,6 +12,7 @@ jobs: with: ref: ${{ github.ref }} submodules: recursive + fetch-tags: true - name: Set up JDK uses: actions/setup-java@v4 with: @@ -32,6 +33,7 @@ jobs: with: ref: ${{ github.ref }} submodules: recursive + fetch-tags: true - name: Set up JDK uses: actions/setup-java@v4 with: From 2d4be14062a071f80ac9429347b79669e81f24c1 Mon Sep 17 00:00:00 2001 From: Mike Yarosh Date: Tue, 2 Dec 2025 15:14:50 +0300 Subject: [PATCH 004/108] Set fetch-depth: 0. --- .github/workflows/publish.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 27dc8abfc..b0e896220 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -13,6 +13,7 @@ jobs: ref: ${{ github.ref }} submodules: recursive fetch-tags: true + fetch-depth: 0 - name: Set up JDK uses: actions/setup-java@v4 with: @@ -34,6 +35,7 @@ jobs: ref: ${{ github.ref }} submodules: recursive fetch-tags: true + fetch-depth: 0 - name: Set up JDK uses: actions/setup-java@v4 with: From e1ea28748e2a518f71b2485709309f75fde13ef6 Mon Sep 17 00:00:00 2001 From: Mike Yarosh Date: Tue, 2 Dec 2025 15:31:24 +0300 Subject: [PATCH 005/108] Remove fetch tuning. --- .github/workflows/publish.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b0e896220..d7df5fb45 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -12,8 +12,6 @@ jobs: with: ref: ${{ github.ref }} submodules: recursive - fetch-tags: true - fetch-depth: 0 - name: Set up JDK uses: actions/setup-java@v4 with: @@ -34,8 +32,6 @@ jobs: with: ref: ${{ github.ref }} submodules: recursive - fetch-tags: true - fetch-depth: 0 - name: Set up JDK uses: actions/setup-java@v4 with: From 19ba1a8851e3748dd4a4f4e31ccf9fcac23b0931 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Wed, 3 Dec 2025 19:24:31 +0200 Subject: [PATCH 006/108] Add Shibarium Bor bridge RPC methods to allowlist and selector (#749) * Add Shibarium Bor bridge RPC methods to allowlist and selector * Limit Bor receipts-by-block to Shibarium only * revert EthereumCallSelector.kt --------- Co-authored-by: a10zn8 --- .../upstream/calls/DefaultEthereumMethods.kt | 22 +++++++++++------- .../calls/DefaultEthereumMethodsSpec.groovy | 23 +++++++++++++++---- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt index ed1a3c98c..86f37a54d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt @@ -611,6 +611,18 @@ class DefaultEthereumMethods( "zks_sendRawTransactionWithDetailedOutput", ) + private val borMethods = listOf( + "bor_getAuthor", + "bor_getCurrentValidators", + "bor_getCurrentProposer", + "bor_getRootHash", + "bor_getSigners", + "bor_getSignersAtHash", + "bor_getSnapshot", + "bor_getSnapshotAtHash", + "eth_getRootHash", + ) + private val allowedMethods: List init { @@ -664,14 +676,8 @@ class DefaultEthereumMethods( "rollup_gasPrices", "eth_getBlockRange", ) - Chain.POLYGON__MAINNET -> listOf( - "bor_getAuthor", - "bor_getCurrentValidators", - "bor_getCurrentProposer", - "bor_getRootHash", - "bor_getSignersAtHash", - "eth_getRootHash", - ) + Chain.POLYGON__MAINNET, Chain.POLYGON__AMOY -> borMethods + Chain.SHIBARIUM__MAINNET -> borMethods + listOf("eth_getTransactionReceiptsByBlock") Chain.POLYGON_ZKEVM__MAINNET, Chain.POLYGON_ZKEVM__CARDONA -> listOf( "zkevm_consolidatedBlockNumber", diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethodsSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethodsSpec.groovy index b672cac25..7ffa434a4 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethodsSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethodsSpec.groovy @@ -58,11 +58,24 @@ class DefaultEthereumMethodsSpec extends Specification { where: chain | methods Chain.POLYGON__MAINNET | ["bor_getAuthor", - "bor_getCurrentValidators", - "bor_getCurrentProposer", - "bor_getRootHash", - "bor_getSignersAtHash", - "eth_getRootHash"] + "bor_getCurrentValidators", + "bor_getCurrentProposer", + "bor_getRootHash", + "bor_getSigners", + "bor_getSignersAtHash", + "bor_getSnapshot", + "bor_getSnapshotAtHash", + "eth_getRootHash"] + Chain.SHIBARIUM__MAINNET | ["bor_getAuthor", + "bor_getCurrentValidators", + "bor_getCurrentProposer", + "bor_getRootHash", + "bor_getSigners", + "bor_getSignersAtHash", + "bor_getSnapshot", + "bor_getSnapshotAtHash", + "eth_getRootHash", + "eth_getTransactionReceiptsByBlock"] Chain.OPTIMISM__MAINNET | ["rollup_gasPrices"] } From 34b9676bb3da84d08bebacd17fd654d3dee0f4e3 Mon Sep 17 00:00:00 2001 From: msizov Date: Thu, 4 Dec 2025 16:45:15 +0700 Subject: [PATCH 007/108] remove cache for hyperliquid (#750) --- .../kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt index 6e4569a17..14eb18571 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt @@ -91,7 +91,13 @@ open class CachesFactory( private fun initCache(chain: Chain): Caches { val caches = Caches.newBuilder() - if (chain == Chain.ZIRCUIT__MAINNET || chain == Chain.ZIRCUIT__TESTNET) { + val disableCacheNetworks = setOf( + Chain.ZIRCUIT__MAINNET, + Chain.ZIRCUIT__TESTNET, + Chain.HYPERLIQUID__MAINNET, + Chain.HYPERLIQUID__TESTNET, + ) + if (chain in disableCacheNetworks) { caches.setCacheEnabled(false) } redis?.let { redis -> From f0ae40a3ac4ac65c8a68f5ad96906e69e024f1cf Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Thu, 4 Dec 2025 13:06:15 +0300 Subject: [PATCH 008/108] add blobs methods (#751) --- .../dshackle/upstream/calls/DefaultBeaconChainMethods.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt index 1fa3ff225..8391f3d6a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt @@ -29,6 +29,7 @@ class DefaultBeaconChainMethods : CallMethods { getMethod("/eth/v1/beacon/blocks/*/root"), getMethod("/eth/v1/beacon/blocks/*/attestations"), getMethod("/eth/v1/beacon/blob_sidecars/*"), + getMethod("/eth/v1/beacon/blobs/*"), postMethod("/eth/v1/beacon/rewards/sync_committee/*"), getMethod("/eth/v1/beacon/deposit_snapshot"), getMethod("/eth/v1/beacon/rewards/blocks/*"), From dbbbdefdfdfa7ce5c2a02d929443699288e747b5 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Mon, 8 Dec 2025 20:28:29 +0400 Subject: [PATCH 009/108] Fix optimism state bound (#752) --- .../ethereum/EthereumLowerBoundProofDetector.kt | 14 +++++++++++++- .../ethereum/EthereumLowerBoundStateDetector.kt | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt index 7f4f175a5..6a0a3db66 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt @@ -32,7 +32,19 @@ class EthereumLowerBoundProofDetector( ) } - private val recursiveLowerBound = RecursiveLowerBound(upstream, LowerBoundType.PROOF, NO_PROOF_ERRORS, lowerBounds, commonErrorPatterns) + private val recursiveLowerBound = RecursiveLowerBound( + upstream, + LowerBoundType.PROOF, + NO_PROOF_ERRORS, + lowerBounds, + commonErrorPatterns.plus( + setOf( + Regex("block #\\d not found"), + Regex("state at block #\\d is pruned"), + Regex("historical state .+ is not available"), + ), + ), + ) override fun period(): Long { return 3 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt index 9cfb5c50c..c0d669fe1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt @@ -25,6 +25,7 @@ class EthereumLowerBoundStateDetector( setOf( Regex("block #\\d not found"), Regex("state at block #\\d is pruned"), + Regex("historical state .+ is not available"), ), ), ) From 0f42f3406104b31247a359e9f46919a9e6fb0f0c Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 9 Dec 2025 15:12:42 +0400 Subject: [PATCH 010/108] Update pub (#753) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 502ae7044..2379525f3 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 502ae70443a5adb0672d01f8af2a0a3d09348188 +Subproject commit 2379525f3e9c642ca1c8c86b8cbecd246668ad36 From 17751ffb0c7201efac8a42613951a7bd00726c82 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 9 Dec 2025 17:23:36 +0400 Subject: [PATCH 011/108] New monad method (#754) --- .../dshackle/upstream/calls/DefaultEthereumMethods.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt index 86f37a54d..ae9109338 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt @@ -728,6 +728,10 @@ class DefaultEthereumMethods( "buildTransaction", ) + Chain.MONAD__MAINNET, Chain.MONAD__TESTNET -> listOf( + "eth_sendRawTransactionSync", + ) + Chain.FILECOIN__MAINNET, Chain.FILECOIN__CALIBRATION_TESTNET -> filecoinMethods Chain.SEI__MAINNET, Chain.SEI__TESTNET, Chain.SEI__DEVNET -> seiMethods From 4e8487bc89fbc31cf83f9a400f096e51c4b0224c Mon Sep 17 00:00:00 2001 From: Alex Varin Date: Tue, 16 Dec 2025 13:26:48 +0500 Subject: [PATCH 012/108] add NEAR_PROTOCOL_CONFIG defauld method (#757) --- .../io/emeraldpay/dshackle/upstream/calls/DefaultNearMethods.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultNearMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultNearMethods.kt index bf39da1f3..89a279ed8 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultNearMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultNearMethods.kt @@ -20,6 +20,7 @@ class DefaultNearMethods : CallMethods { "tx", "EXPERIMENTAL_tx_status", "EXPERIMENTAL_receipt", + "EXPERIMENTAL_protocol_config", ) private val add = setOf( From 4f2959a75f10eee099955133047f5429dfe1d0ae Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Wed, 17 Dec 2025 16:38:12 +0400 Subject: [PATCH 013/108] Perf improvements (#758) --- src/main/kotlin/io/emeraldpay/dshackle/Global.kt | 3 +++ .../dshackle/config/context/SchedulersConfig.kt | 10 ++++++++++ .../configure/GenericConnectorFactoryCreator.kt | 4 ++++ .../configure/RestConnectorFactoryCreator.kt | 4 ++++ .../dshackle/upstream/BasicHttpFactory.kt | 6 ++++-- .../upstream/ethereum/WsConnectionFactory.kt | 3 ++- .../upstream/ethereum/WsConnectionImpl.kt | 8 +++++--- .../upstream/restclient/RestHttpReader.kt | 8 +++++--- .../upstream/rpcclient/JsonRpcHttpReader.kt | 15 +++++++++++---- .../emeraldpay/dshackle/test/ApiReaderMock.groovy | 10 +++++++++- .../ethereum/WsConnectionImplRealSpec.groovy | 1 + .../upstream/ethereum/WsConnectionImplSpec.groovy | 9 ++++++--- .../rpcclient/JsonRpcHttpReaderSpec.groovy | 7 ++++--- .../GenericConnectorFactoryCreatorTest.kt | 3 +++ 14 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Global.kt b/src/main/kotlin/io/emeraldpay/dshackle/Global.kt index 8d5ee45f7..a9775dbb8 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Global.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Global.kt @@ -35,6 +35,7 @@ import io.emeraldpay.dshackle.upstream.ethereum.domain.TransactionId import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.TransactionIdSerializer import io.emeraldpay.dshackle.upstream.ton.TonMasterchainInfo import io.emeraldpay.dshackle.upstream.ton.TonMasterchainInfoDeserializer +import reactor.netty.resources.LoopResources import java.math.BigInteger import java.text.SimpleDateFormat import java.util.Locale @@ -46,6 +47,8 @@ class Global { companion object { + val wsLoops: LoopResources = LoopResources.create("reactor-ws") + val nullValue: ByteArray = "null".toByteArray() var metricsExtended = false diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt index 4ad9a48e1..f3ad28a27 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt @@ -76,6 +76,16 @@ open class SchedulersConfig { return makeScheduler("auth-scheduler", 4, monitoringConfig) } + @Bean + open fun httpScheduler(monitoringConfig: MonitoringConfig): Scheduler { + return makeScheduler("http-scheduler", 30, monitoringConfig) + } + + @Bean + open fun eventsScheduler(monitoringConfig: MonitoringConfig): Scheduler { + return makeScheduler("ws-events-scheduler", 30, monitoringConfig) + } + private fun makeScheduler(name: String, size: Int, monitoringConfig: MonitoringConfig): Scheduler { return Schedulers.fromExecutorService(makePool(name, size, monitoringConfig)) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt index 8dc32db52..f85aa7425 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt @@ -26,6 +26,8 @@ open class GenericConnectorFactoryCreator( private val wsScheduler: Scheduler, private val headLivenessScheduler: Scheduler, private val monitoringCfg: MonitoringConfig, + private val httpScheduler: Scheduler, + private val eventsScheduler: Scheduler, ) : ConnectorFactoryCreator { protected val log = LoggerFactory.getLogger(this::class.java) @@ -75,6 +77,7 @@ open class GenericConnectorFactoryCreator( conn.basicAuth, tls, monitoringCfg.nettyMetricsConfig.enabled, + httpScheduler, ) } } @@ -92,6 +95,7 @@ open class GenericConnectorFactoryCreator( endpoint.url, endpoint.origin ?: URI("http://localhost"), wsScheduler, + eventsScheduler, ).apply { config = endpoint basicAuth = endpoint.basicAuth diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt index 321c8eeb9..7a58eebe8 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt @@ -22,6 +22,8 @@ class RestConnectorFactoryCreator( private val headScheduler: Scheduler, private val headLivenessScheduler: Scheduler, monitoringCfg: MonitoringConfig, + httpScheduler: Scheduler, + eventsScheduler: Scheduler, ) : GenericConnectorFactoryCreator( fileResolver, Schedulers.single(), @@ -29,6 +31,8 @@ class RestConnectorFactoryCreator( Schedulers.single(), headLivenessScheduler, monitoringCfg, + httpScheduler, + eventsScheduler, ) { override fun createConnectorFactory( id: String, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt index 7db34b1ee..5a9b90b60 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt @@ -10,6 +10,7 @@ import io.micrometer.core.instrument.Metrics import io.micrometer.core.instrument.Tag import io.micrometer.core.instrument.Timer import org.slf4j.LoggerFactory +import reactor.core.scheduler.Scheduler class BasicHttpFactory( private val url: String, @@ -18,6 +19,7 @@ class BasicHttpFactory( private val basicAuth: AuthConfig.ClientBasicAuth?, private val tls: ByteArray?, private val nettyMetricsEnabled: Boolean, + private val httpScheduler: Scheduler, ) : HttpFactory { private val log = LoggerFactory.getLogger(this::class.java) @@ -44,8 +46,8 @@ class BasicHttpFactory( ) if (chain.type.apiType == ApiType.REST) { - return RestHttpReader(url, maxConnections, queueSize, metrics, basicAuth, tls) + return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls) } - return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, basicAuth, tls) + return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt index 4ef0914c2..6179ac33d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt @@ -17,6 +17,7 @@ open class WsConnectionFactory( private val uri: URI, private val origin: URI, private val scheduler: Scheduler, + private val eventsScheduler: Scheduler, ) { var basicAuth: AuthConfig.ClientBasicAuth? = null @@ -45,7 +46,7 @@ open class WsConnectionFactory( } open fun createWsConnection(connIndex: Int = 0): WsConnection = - WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler).also { ws -> + WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler, eventsScheduler).also { ws -> config?.frameSize?.let { ws.frameSize = it } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt index d9c354332..02aae5825 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt @@ -29,7 +29,6 @@ import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcWsMessage import io.emeraldpay.dshackle.upstream.rpcclient.ResponseWSParser import io.micrometer.core.instrument.Metrics -import io.netty.buffer.ByteBufInputStream import io.netty.handler.codec.http.HttpHeaderNames import io.netty.resolver.DefaultAddressResolverGroup import org.reactivestreams.Publisher @@ -66,6 +65,7 @@ open class WsConnectionImpl( private val basicAuth: AuthConfig.ClientBasicAuth?, private val requestMetrics: RequestMetrics?, private val scheduler: Scheduler, + private val eventsScheduler: Scheduler, ) : AutoCloseable, WsConnection, Cloneable { companion object { @@ -193,6 +193,7 @@ open class WsConnectionImpl( log.info("Connecting to WebSocket: $uri") connection?.dispose() connection = HttpClient.create() + .runOn(Global.wsLoops) .resolver(DefaultAddressResolverGroup.INSTANCE) .doOnDisconnected { disconnects.tryEmitNext(Instant.now()) @@ -251,8 +252,9 @@ open class WsConnectionImpl( var read = false val consumer = inbound .aggregateFrames(msgSizeLimit) - .receiveFrames() - .map { ByteBufInputStream(it.content()).readAllBytes() } + .receive() + .asByteArray() + .publishOn(eventsScheduler) .filter { it.isNotEmpty() } .flatMap { try { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt index 1b0a2b247..782769608 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt @@ -17,6 +17,7 @@ import io.netty.handler.codec.http.HttpMethod import org.apache.commons.lang3.time.StopWatch import reactor.core.publisher.Flux import reactor.core.publisher.Mono +import reactor.core.scheduler.Scheduler import reactor.kotlin.core.publisher.switchIfEmpty import java.util.concurrent.TimeUnit @@ -25,6 +26,7 @@ class RestHttpReader( maxConnections: Int, queueSize: Int, metrics: RequestMetrics, + private val httpScheduler: Scheduler, basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, ) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth) { @@ -86,7 +88,7 @@ class RestHttpReader( response.response { header, bytes -> val statusCode = header.status().code() - bytes.aggregate().asByteArray().map { + bytes.aggregate().asByteArray().publishOn(httpScheduler).map { AggregateResponse(it, statusCode) }.switchIfEmpty { Mono.just(AggregateResponse(ByteArray(0), statusCode)) @@ -95,13 +97,13 @@ class RestHttpReader( } else { response.responseConnection { t, u -> if (t.status().code() != 200) { - u.inbound().receive().aggregate().asByteArray() + u.inbound().receive().aggregate().asByteArray().publishOn(httpScheduler) .map { AggregateResponse(it, t.status().code()) } } else { Mono.just( StreamResponse( Flux.concat( - u.inbound().receive().asByteArray() + u.inbound().receive().asByteArray().publishOn(httpScheduler) .map { Chunk(it, false) }, Mono.just(Chunk(ByteArray(0), true)), ), diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt index bdf09ad9d..4de3c0656 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt @@ -29,6 +29,7 @@ import io.emeraldpay.dshackle.upstream.stream.StreamResponse import io.netty.buffer.Unpooled import org.apache.commons.lang3.time.StopWatch import reactor.core.publisher.Mono +import reactor.core.scheduler.Scheduler import java.util.concurrent.TimeUnit import java.util.function.Function @@ -40,6 +41,7 @@ class JsonRpcHttpReader( maxConnections: Int, queueSize: Int, metrics: RequestMetrics, + private val httpScheduler: Scheduler, basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, ) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth) { @@ -59,15 +61,20 @@ class JsonRpcHttpReader( response.response { header, bytes -> val statusCode = header.status().code() - bytes.aggregate().asByteArray().map { - AggregateResponse(it, statusCode) - } + bytes.aggregate().asByteArray() + .publishOn(httpScheduler) + .map { + AggregateResponse(it, statusCode) + } }.single() } else { response.responseConnection { t, u -> streamParser.streamParse( t.status().code(), - u.inbound().receive().asByteArray(), + u.inbound() + .receive() + .asByteArray() + .publishOn(httpScheduler), ) }.single() } diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy index 89b0fec72..4a4067e6d 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy @@ -227,7 +227,15 @@ class ApiReaderMock implements Reader { @Override ByteBufFlux receive() { - throw new UnsupportedOperationException() + return ByteBufFlux + .fromString( + Flux.merge( + jsonResponses, + responses.map { + Global.objectMapper.writeValueAsString(it) + } + ) + ) } @Override diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplRealSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplRealSpec.groovy index 969b65fcd..d2fa73086 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplRealSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplRealSpec.groovy @@ -43,6 +43,7 @@ class WsConnectionImplRealSpec extends Specification { Chain.ETHEREUM__MAINNET, "ws://localhost:${port}".toURI(), "http://localhost:${port}".toURI(), + Schedulers.boundedElastic(), Schedulers.boundedElastic() ) ).create(upstream).getConnection() diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplSpec.groovy index eeff55eee..18601baec 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImplSpec.groovy @@ -46,7 +46,8 @@ class WsConnectionImplSpec extends Specification { Chain.ETHEREUM__MAINNET, new URI("http://localhost"), new URI("http://localhost"), - Schedulers.boundedElastic() + Schedulers.boundedElastic(), + Schedulers.boundedElastic(), ) ) def apiMock = TestingCommons.api() @@ -81,7 +82,8 @@ class WsConnectionImplSpec extends Specification { Chain.ETHEREUM__MAINNET, new URI("http://localhost"), new URI("http://localhost"), - Schedulers.boundedElastic() + Schedulers.boundedElastic(), + Schedulers.boundedElastic(), ) ) def apiMock = TestingCommons.api() @@ -114,7 +116,8 @@ class WsConnectionImplSpec extends Specification { Chain.ETHEREUM__MAINNET, new URI("http://localhost"), new URI("http://localhost"), - Schedulers.boundedElastic() + Schedulers.boundedElastic(), + Schedulers.boundedElastic(), ) ) def apiMock = TestingCommons.api() diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy index 8589b780c..977c3ec1c 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy @@ -27,6 +27,7 @@ import org.mockserver.integration.ClientAndServer import org.mockserver.model.HttpRequest import org.mockserver.model.HttpResponse import org.springframework.util.SocketUtils +import reactor.core.scheduler.Schedulers import spock.lang.Specification import java.time.Duration @@ -52,7 +53,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Make a request"() { setup: - JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics,null, null) + JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(),null, null) def resp = '{' + ' "jsonrpc": "2.0",' + ' "result": "0x98de45",' + @@ -73,7 +74,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Produces RPC Exception on error status code"() { setup: - def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, null, null) + def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null) mockServer.when( HttpRequest.request() @@ -97,7 +98,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Tries to extract message if HTTP error if it still contains a JSON RPC message"() { setup: - def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, null, null) + def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null) mockServer.when( HttpRequest.request() diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnectorFactoryCreatorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnectorFactoryCreatorTest.kt index d79b7e1e3..b23271067 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnectorFactoryCreatorTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnectorFactoryCreatorTest.kt @@ -19,6 +19,7 @@ import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito.mockConstruction import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers.immediate import java.io.File import java.net.URI @@ -39,6 +40,8 @@ class GenericConnectorFactoryCreatorTest { immediate(), immediate(), MonitoringConfig.default(), + Schedulers.boundedElastic(), + Schedulers.boundedElastic(), ) var args: List<*>? = null From 77ae38e138c99df7aadd650647a45ee254b85f69 Mon Sep 17 00:00:00 2001 From: msizov Date: Thu, 18 Dec 2025 14:13:06 +0700 Subject: [PATCH 014/108] add eth_baseFee to avax (#760) --- .../dshackle/upstream/calls/DefaultEthereumMethods.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt index ae9109338..1476391ad 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt @@ -234,6 +234,10 @@ class DefaultEthereumMethods( "eth_chainId", ) + private val avalancheMethods = listOf( + "eth_baseFee", + ) + private val filecoinMethods = listOf( "Filecoin.ChainBlockstoreInfo", "Filecoin.ChainExport", @@ -734,6 +738,8 @@ class DefaultEthereumMethods( Chain.FILECOIN__MAINNET, Chain.FILECOIN__CALIBRATION_TESTNET -> filecoinMethods + Chain.AVALANCHE__MAINNET, Chain.AVALANCHE__FUJI -> avalancheMethods + Chain.SEI__MAINNET, Chain.SEI__TESTNET, Chain.SEI__DEVNET -> seiMethods Chain.CRONOS_ZKEVM__MAINNET, Chain.CRONOS_ZKEVM__TESTNET -> zxkSyncMethods + "zk_estimateFee" From 757febc0b539f38a2d5d3354050e2341d378ac91 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 18 Dec 2025 11:30:18 +0300 Subject: [PATCH 015/108] Forward HTTP response headers from upstream to gRPC client for Beacon Chain (#759) Added response_headers field to NativeCallReplyItem proto. Implemented chain-specific header filtering via getResponseHeadersToForward(). BeaconChain forwards: Eth-Consensus-Version, Eth-Consensus-Finalized, Eth-Execution-Optimistic, Eth-Execution-Payload-Blinded/Value, Eth-Consensus-Block-Value. --- .gitignore | 3 +- emerald-grpc | 2 +- gradle/libs.versions.toml | 2 +- .../dshackle/quorum/QuorumRequestReader.kt | 4 +- .../dshackle/reader/BroadcastReader.kt | 1 + .../dshackle/reader/RequestReaderFactory.kt | 3 +- .../io/emeraldpay/dshackle/rpc/NativeCall.kt | 44 +++++++++++++------ .../dshackle/upstream/BasicHttpFactory.kt | 2 +- .../dshackle/upstream/ChainResponse.kt | 16 +++++-- .../beaconchain/BeaconChainSpecific.kt | 9 ++++ .../calls/DefaultBeaconChainMethods.kt | 8 +++- .../upstream/generic/ChainSpecific.kt | 6 +++ .../upstream/restclient/RestHttpReader.kt | 27 +++++++++--- .../dshackle/upstream/stream/Responses.kt | 2 + 14 files changed, 98 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 5bf3ce9c1..25ca0b167 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ Test*.kt http-client.env.json .DS_Store -mise.toml \ No newline at end of file +mise.toml +.claude/ \ No newline at end of file diff --git a/emerald-grpc b/emerald-grpc index 1698b6f9e..f5c4aac81 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 1698b6f9e332548fe263098f877cd8e08c9963f8 +Subproject commit f5c4aac8196db84b708149ed3c5f92b103682a0a diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ca6212b5..928227fc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] detekt = "1.23.1" groovy = "4.0.15" -protoc = "4.29.2" +protoc = "4.33.2" jackson = "2.11.0" grpc = "1.57.0" reactive-grpc = "1.2.0" diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt index d64a9f1c8..e5aa53606 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt @@ -135,7 +135,7 @@ class QuorumRequestReader( .map { quorum -> val response = quorum.getResponse()!! // TODO find actual quorum number - Result(response.getResult(), quorum.getSignature(), 1, resolvedBy(), response.stream) + Result(response.getResult(), quorum.getSignature(), 1, resolvedBy(), response.stream, response.responseHeaders) } .switchIfEmpty(defaultResult) } @@ -236,7 +236,7 @@ class QuorumRequestReader( val cause = getCause(method) ?: return Mono.error(RpcException(1, "No response for method $method", getFullCause())) if (cause.shouldReturnNull) { Mono.just( - Result(Global.nullValue, null, 1, emptyList(), null), + Result(Global.nullValue, null, 1, emptyList(), null, emptyMap()), ) } else { Mono.error(RpcException(1, "No response for method $method. Cause - ${cause.cause}")) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt index 490952d38..72302139e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt @@ -64,6 +64,7 @@ class BroadcastReader( upstreams.size, upsData, null, + quorum.getResponse()!!.responseHeaders, ) Mono.just(res) } else { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt index bdb11fc50..23492e085 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt @@ -49,12 +49,13 @@ abstract class RequestReader( null } - class Result( + class Result @JvmOverloads constructor( val value: ByteArray, val signature: ResponseSigner.Signature?, val quorum: Int, val resolvedUpstreamData: List, val stream: Flux?, + val responseHeaders: Map = emptyMap(), ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt index fd9e35ca8..24bf55cbf 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt @@ -143,7 +143,7 @@ open class NativeCall( Flux.concat( Mono.just(firstChunk) .map { - val result = buildStreamResult(it, callResult.id) + val result = buildStreamResult(it, callResult.id, callResult.responseHeaders) if (callResult.upstreamSettingsData.isNotEmpty()) { getUpstreamIdsAndVersions(callResult.upstreamSettingsData) .let { idsAndVersions -> @@ -160,13 +160,22 @@ open class NativeCall( } } - private fun buildStreamResult(chunk: Chunk, id: Int): BlockchainOuterClass.NativeCallReplyItem.Builder { - return BlockchainOuterClass.NativeCallReplyItem.newBuilder() + private fun buildStreamResult(chunk: Chunk, id: Int, headers: Map = emptyMap()): BlockchainOuterClass.NativeCallReplyItem.Builder { + val builder = BlockchainOuterClass.NativeCallReplyItem.newBuilder() .setSucceed(true) .setFinalChunk(chunk.finalChunk) .setChunked(true) .setPayload(ByteString.copyFrom(chunk.chunkData)) .setId(id) + headers.forEach { (key, value) -> + builder.addResponseHeaders( + BlockchainOuterClass.KeyValue.newBuilder() + .setKey(key) + .setValue(value) + .build(), + ) + } + return builder } private fun completeSpan(callResult: CallResult, requestCount: Int) { @@ -263,6 +272,14 @@ open class NativeCall( .setType(it.type.toProtoFinalizationType()) .build() } + it.responseHeaders.forEach { (key, value) -> + result.addResponseHeaders( + BlockchainOuterClass.KeyValue.newBuilder() + .setKey(key) + .setValue(value) + .build(), + ) + } return result.build() } @@ -460,9 +477,9 @@ open class NativeCall( } else { ctx.upstream.getId() } - CallResult.ok(ctx.id, ctx.nonce, result, signer.sign(ctx.nonce, result, source), resolvedUpstreamData, ctx, it.finalization) + CallResult.ok(ctx.id, ctx.nonce, result, signer.sign(ctx.nonce, result, source), resolvedUpstreamData, ctx, it.finalization, it.responseHeaders) } else { - CallResult.ok(ctx.id, null, result, null, resolvedUpstreamData, ctx, it.finalization) + CallResult.ok(ctx.id, null, result, null, resolvedUpstreamData, ctx, it.finalization, it.responseHeaders) } } }.switchIfEmpty( @@ -505,7 +522,7 @@ open class NativeCall( callResult(ctx, it, resolvedUpstreamData) } } else { - CallResult.ok(ctx.id, ctx.nonce, ByteArray(0), it.signature, resolvedUpstreamData, ctx, it.stream) + CallResult.ok(ctx.id, ctx.nonce, ByteArray(0), it.signature, resolvedUpstreamData, ctx, it.stream, it.responseHeaders) } } .onErrorResume { t -> @@ -534,7 +551,7 @@ open class NativeCall( ): CallResult { val bytes = ctx.resultDecorator.processResult(it) validateResult(bytes, "remote", ctx) - return CallResult.ok(ctx.id, ctx.nonce, bytes, it.signature, resolvedUpstreamData, ctx) + return CallResult.ok(ctx.id, ctx.nonce, bytes, it.signature, resolvedUpstreamData, ctx, it.responseHeaders) } private fun callRippleResult( @@ -828,6 +845,7 @@ open class NativeCall( val ctx: ValidCallContext?, val stream: Flux? = null, val finalization: FinalizationData? = null, + val responseHeaders: Map = emptyMap(), ) { constructor( @@ -840,16 +858,16 @@ open class NativeCall( ) : this(id, nonce, result, callError, signature, callError?.upstreamSettingsData ?: emptyList(), ctx) companion object { - fun ok(id: Int, nonce: Long?, result: ByteArray, signature: ResponseSigner.Signature?, upstreamSettingsData: List, ctx: ValidCallContext?): CallResult { - return CallResult(id, nonce, result, null, signature, upstreamSettingsData, ctx) + fun ok(id: Int, nonce: Long?, result: ByteArray, signature: ResponseSigner.Signature?, upstreamSettingsData: List, ctx: ValidCallContext?, responseHeaders: Map = emptyMap()): CallResult { + return CallResult(id, nonce, result, null, signature, upstreamSettingsData, ctx, null, null, responseHeaders) } - fun ok(id: Int, nonce: Long?, result: ByteArray, signature: ResponseSigner.Signature?, upstreamSettingsData: List, ctx: ValidCallContext?, final: FinalizationData?): CallResult { - return CallResult(id, nonce, result, null, signature, upstreamSettingsData, ctx, null, final) + fun ok(id: Int, nonce: Long?, result: ByteArray, signature: ResponseSigner.Signature?, upstreamSettingsData: List, ctx: ValidCallContext?, final: FinalizationData?, responseHeaders: Map = emptyMap()): CallResult { + return CallResult(id, nonce, result, null, signature, upstreamSettingsData, ctx, null, final, responseHeaders) } - fun ok(id: Int, nonce: Long?, result: ByteArray, signature: ResponseSigner.Signature?, upstreamSettingsData: List, ctx: ValidCallContext?, stream: Flux?): CallResult { - return CallResult(id, nonce, result, null, signature, upstreamSettingsData, ctx, stream) + fun ok(id: Int, nonce: Long?, result: ByteArray, signature: ResponseSigner.Signature?, upstreamSettingsData: List, ctx: ValidCallContext?, stream: Flux?, responseHeaders: Map = emptyMap()): CallResult { + return CallResult(id, nonce, result, null, signature, upstreamSettingsData, ctx, stream, null, responseHeaders) } fun fail(id: Int, nonce: Long?, error: CallError, ctx: ValidCallContext?): CallResult { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt index 5a9b90b60..e9ccad094 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt @@ -46,7 +46,7 @@ class BasicHttpFactory( ) if (chain.type.apiType == ApiType.REST) { - return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls) + return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, chain, basicAuth, tls) } return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainResponse.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainResponse.kt index d73bc8652..6e49836b8 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainResponse.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainResponse.kt @@ -35,17 +35,25 @@ class ChainResponse @JvmOverloads constructor( val providedSignature: ResponseSigner.Signature? = null, val resolvedUpstreamData: List = emptyList(), val finalization: FinalizationData? = null, + val responseHeaders: Map = emptyMap(), ) { constructor(stream: Flux, id: Int) : - this(null, null, NumberId(id.toLong()), stream, null, emptyList(), null) + this(null, null, NumberId(id.toLong()), stream, null, emptyList(), null, emptyMap()) + + constructor(stream: Flux, id: Int, responseHeaders: Map) : + this(null, null, NumberId(id.toLong()), stream, null, emptyList(), null, responseHeaders) constructor(result: ByteArray?, error: ChainCallError?) : this(result, error, NumberId(0), null, null) + constructor(result: ByteArray?, error: ChainCallError?, responseHeaders: Map) : + this(result, error, NumberId(0), null, null, emptyList(), null, responseHeaders) + constructor(result: ByteArray?, error: ChainCallError?, resolvedUpstreamData: List) : - this(result, error, NumberId(0), null, null, resolvedUpstreamData, null) + this(result, error, NumberId(0), null, null, resolvedUpstreamData, null, emptyMap()) + constructor(result: ByteArray?, resolvedUpstreamData: List, finalization: FinalizationData) : - this(result, null, NumberId(0), null, null, resolvedUpstreamData, finalization) + this(result, null, NumberId(0), null, null, resolvedUpstreamData, finalization, emptyMap()) companion object { private val NULL_VALUE = "null".toByteArray() @@ -139,7 +147,7 @@ class ChainResponse @JvmOverloads constructor( } fun copyWithId(id: Id): ChainResponse { - return ChainResponse(result, error, id, stream, providedSignature, resolvedUpstreamData) + return ChainResponse(result, error, id, stream, providedSignature, resolvedUpstreamData, finalization, responseHeaders) } override fun equals(other: Any?): Boolean { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt index b7f335242..319f93075 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt @@ -121,6 +121,15 @@ object BeaconChainSpecific : AbstractPollChainSpecific() { override fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService { return BeaconChainLowerBoundService(chain, upstream) } + + override fun getResponseHeadersToForward(): List = listOf( + "Eth-Consensus-Version", + "Eth-Consensus-Finalized", + "Eth-Execution-Optimistic", + "Eth-Execution-Payload-Blinded", + "Eth-Execution-Payload-Value", + "Eth-Consensus-Block-Value", + ) } data class BeaconChainBlockHeader( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt index 8391f3d6a..3e0d137b5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBeaconChainMethods.kt @@ -117,7 +117,13 @@ class DefaultBeaconChainMethods : CallMethods { } override fun isCallable(method: String): Boolean { - return allowedMethods.contains(method) + if (allowedMethods.contains(method)) { + return true + } + // Check wildcard patterns (e.g., GET#/eth/v1/beacon/headers/* matches GET#/eth/v1/beacon/headers/head) + return allowedMethods.any { pattern -> + pattern.contains("*") && method.matches(pattern.replace("*", "[^/]+").toRegex()) + } } override fun getSupportedMethods(): Set { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt index fed279b89..d211f4fcf 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt @@ -101,6 +101,12 @@ interface ChainSpecific { fun callSelector(caches: Caches): CallSelector? fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService + + /** + * List of HTTP response header names to forward from upstream to client. + * Override in chain-specific implementations to specify relevant headers. + */ + fun getResponseHeadersToForward(): List = emptyList() } object ChainSpecificRegistry { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt index 782769608..b78ab8ec1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt @@ -1,11 +1,13 @@ package io.emeraldpay.dshackle.upstream.restclient +import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.config.AuthConfig import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.HttpReader import io.emeraldpay.dshackle.upstream.RequestMetrics +import io.emeraldpay.dshackle.upstream.generic.ChainSpecificRegistry import io.emeraldpay.dshackle.upstream.rpcclient.ResponseRpcParser import io.emeraldpay.dshackle.upstream.rpcclient.RestParams import io.emeraldpay.dshackle.upstream.stream.AggregateResponse @@ -19,6 +21,7 @@ import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.core.scheduler.Scheduler import reactor.kotlin.core.publisher.switchIfEmpty +import reactor.netty.http.client.HttpClientResponse import java.util.concurrent.TimeUnit class RestHttpReader( @@ -27,12 +30,20 @@ class RestHttpReader( queueSize: Int, metrics: RequestMetrics, private val httpScheduler: Scheduler, + private val chain: Chain, basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, ) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth) { private val parser = ResponseRpcParser() private val requestParser = RestRequestParser + private val headersToForward = ChainSpecificRegistry.resolve(chain).getResponseHeadersToForward() + + private fun extractResponseHeaders(header: HttpClientResponse): Map { + return headersToForward + .mapNotNull { name -> header.responseHeaders().get(name)?.let { name to it } } + .toMap() + } override fun internalRead(key: ChainRequest): Mono { val startTime = StopWatch() @@ -50,13 +61,13 @@ class RestHttpReader( } .handle { it, sink -> when (it) { - is StreamResponse -> sink.next(ChainResponse(it.stream, key.id)) + is StreamResponse -> sink.next(ChainResponse(it.stream, key.id, it.headers)) is AggregateResponse -> { if (it.code != 200) { val error = parser.readError(Global.objectMapper.createParser(it.response)) - sink.next(ChainResponse(null, error)) + sink.next(ChainResponse(null, error, it.headers)) } else { - sink.next(ChainResponse(it.response, null)) + sink.next(ChainResponse(it.response, null, it.headers)) } } else -> sink.error(IllegalStateException("Wrong response type")) @@ -87,18 +98,21 @@ class RestHttpReader( return if (!key.isStreamed) { response.response { header, bytes -> val statusCode = header.status().code() + val responseHeaders = extractResponseHeaders(header) bytes.aggregate().asByteArray().publishOn(httpScheduler).map { - AggregateResponse(it, statusCode) + AggregateResponse(it, statusCode, responseHeaders) }.switchIfEmpty { - Mono.just(AggregateResponse(ByteArray(0), statusCode)) + Mono.just(AggregateResponse(ByteArray(0), statusCode, responseHeaders)) } }.single() } else { response.responseConnection { t, u -> + val responseHeaders = extractResponseHeaders(t) + if (t.status().code() != 200) { u.inbound().receive().aggregate().asByteArray().publishOn(httpScheduler) - .map { AggregateResponse(it, t.status().code()) } + .map { AggregateResponse(it, t.status().code(), responseHeaders) } } else { Mono.just( StreamResponse( @@ -107,6 +121,7 @@ class RestHttpReader( .map { Chunk(it, false) }, Mono.just(Chunk(ByteArray(0), true)), ), + responseHeaders, ), ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/stream/Responses.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/stream/Responses.kt index 1b2e7a4f0..621c3619a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/stream/Responses.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/stream/Responses.kt @@ -35,11 +35,13 @@ data class SingleResponse( data class StreamResponse( val stream: Flux, + val headers: Map = emptyMap(), ) : Response() data class AggregateResponse( val response: ByteArray, val code: Int, + val headers: Map = emptyMap(), ) : Response() { override fun equals(other: Any?): Boolean { if (this === other) return true From 3e845bcdf0b676349d5b5f4ab08851f81d24227b Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 18 Dec 2025 20:01:37 +0800 Subject: [PATCH 016/108] Support '<' and '>' operations for client routing on key (#755) --- Makefile | 3 + build.gradle | 1 + emerald-grpc | 2 +- gradle/libs.versions.toml | 2 + .../dshackle/upstream/MatchesResponse.kt | 8 ++ .../emeraldpay/dshackle/upstream/Selector.kt | 111 +++++++++++++++++- 6 files changed, 124 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index c0d84241c..bc1bac9bb 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,9 @@ all: build-foundation build-main build-foundation: cd foundation && ../gradlew build publishToMavenLocal +run-main: + ./gradlew run + build-main: ./gradlew build diff --git a/build.gradle b/build.gradle index 85c025873..91b5926f3 100644 --- a/build.gradle +++ b/build.gradle @@ -97,6 +97,7 @@ dependencies { implementation libs.javax.annotations implementation libs.auth0.jwt + implementation libs.semver4j testImplementation libs.cglib.nodep testImplementation libs.spockframework.core diff --git a/emerald-grpc b/emerald-grpc index f5c4aac81..f18804408 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit f5c4aac8196db84b708149ed3c5f92b103682a0a +Subproject commit f18804408ff4876db82b4106d05ac4e76b8170ef diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 928227fc9..85b152c6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -122,6 +122,8 @@ auth0-jwt = "com.auth0:java-jwt:4.4.0" mockito-inline = "org.mockito:mockito-inline:4.0.0" +semver4j = "com.vdurmont:semver4j:3.1.0" + [bundles] apache-commons = ["commons-io", "apache-commons-lang3", "apache-commons-collections4", "apache-commons-math3"] grpc = ["grpc-protobuf", "grpc-stub", "grpc-netty", "grpc-proto-util", "grpc-services"] diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/MatchesResponse.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/MatchesResponse.kt index f8026ce08..471fb5561 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/MatchesResponse.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/MatchesResponse.kt @@ -25,6 +25,7 @@ sealed class MatchesResponse { this.allResponses .filter { it !is Success } .joinToString("; ") { it.getCause()!! } + is NotMatchedResponse -> "Not matched - ${response.getCause()}" is SameNodeResponse -> "Upstream does not have hash ${this.upstreamHash}" is LowerHeightResponse -> { @@ -34,6 +35,8 @@ sealed class MatchesResponse { "Upstream lower height ${this.predictedHeight} of type ${this.boundType} is greater than ${this.lowerHeight}" } } + + is RangeVersionResponse -> "Upstream version is not within the range ${this.minVersion}-${this.maxVersion}" else -> null } @@ -100,5 +103,10 @@ sealed class MatchesResponse { val upstreamHash: Short, ) : MatchesResponse() + data class RangeVersionResponse( + val minVersion: String, + val maxVersion: String, + ) : MatchesResponse() + object AvailabilityResponse : MatchesResponse() } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt index f79bda828..0fb9fbad3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt @@ -16,6 +16,7 @@ */ package io.emeraldpay.dshackle.upstream +import com.vdurmont.semver4j.Semver import io.emeraldpay.api.proto.BlockchainOuterClass import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.upstream.MatchesResponse.AvailabilityResponse @@ -25,6 +26,7 @@ import io.emeraldpay.dshackle.upstream.MatchesResponse.GrpcResponse import io.emeraldpay.dshackle.upstream.MatchesResponse.HeightResponse import io.emeraldpay.dshackle.upstream.MatchesResponse.LowerHeightResponse import io.emeraldpay.dshackle.upstream.MatchesResponse.NotMatchedResponse +import io.emeraldpay.dshackle.upstream.MatchesResponse.RangeVersionResponse import io.emeraldpay.dshackle.upstream.MatchesResponse.SameNodeResponse import io.emeraldpay.dshackle.upstream.MatchesResponse.SlotHeightResponse import io.emeraldpay.dshackle.upstream.MatchesResponse.Success @@ -35,7 +37,6 @@ import org.apache.commons.lang3.StringUtils import java.util.Collections class Selector { - companion object { @JvmStatic @@ -55,6 +56,7 @@ class Selector { } else { Number(selector.height) } + BlockchainOuterClass.HeightSelector.HeightOrNumberCase.NUMBER -> Number(selector.number) BlockchainOuterClass.HeightSelector.HeightOrNumberCase.TAG -> when (selector.tag) { BlockchainOuterClass.BlockTag.SAFE -> Safe @@ -63,10 +65,12 @@ class Selector { BlockchainOuterClass.BlockTag.FINALIZED -> Finalized else -> null } + else -> null } } } + class Number(val num: Long) : HeightNumberOrTag() object Pending : HeightNumberOrTag() object Latest : HeightNumberOrTag() @@ -97,12 +101,14 @@ class Selector { it.hasSlotHeightSelector() -> { SlotMatcher(it.slotHeightSelector.slotHeight) } + it.hasHeightSelector() -> { when (val selector = HeightNumberOrTag.fromHeightSelector(it.heightSelector)) { is HeightNumberOrTag.Number -> HeightMatcher(selector.num) else -> empty } } + it.hasLowerHeightSelector() -> { if (it.lowerHeightSelector.height > 0) { LowerHeightMatcher( @@ -115,6 +121,7 @@ class Selector { empty } } + else -> empty } }.run { @@ -126,7 +133,8 @@ class Selector { private fun getSort(selectors: List): Sort { selectors.forEach { selector -> if (selector.hasHeightSelector()) { - val heightSort = HeightNumberOrTag.fromHeightSelector(selector.heightSelector)?.getSort() ?: Sort.default + val heightSort = + HeightNumberOrTag.fromHeightSelector(selector.heightSelector)?.getSort() ?: Sort.default if (heightSort != Sort.default) { return heightSort } @@ -159,6 +167,7 @@ class Selector { anyLabel } } + req.hasAndSelector() -> AndMatcher( Collections.unmodifiableCollection( req.andSelector.selectorsList.map { @@ -168,6 +177,7 @@ class Selector { }, ), ) + req.hasOrSelector() -> OrMatcher( Collections.unmodifiableCollection( req.orSelector.selectorsList.map { @@ -177,8 +187,29 @@ class Selector { }, ), ) + req.hasNotSelector() -> NotMatcher(convertToMatcher(req.notSelector.selector)) req.hasExistsSelector() -> ExistsMatcher(req.existsSelector.name) + + req.hasMinVersionSelector() || req.hasMaxVersionSelector() -> { + val min = if (req.hasMinVersionSelector()) { + req.minVersionSelector.version + } else { + "" + } + val max = if (req.hasMaxVersionSelector()) { + req.maxVersionSelector.version + } else { + "" + } + + if (min.isEmpty() && max.isEmpty()) { + anyLabel + } else { + RangeVersionMatcher(min, max) + } + } + else -> anyLabel } } @@ -693,4 +724,80 @@ class Selector { override fun toString(): String = "Matcher: ${describeInternal()}" } + + class RangeVersionMatcher( + private val minRawVersion: String, + private val maxRawVersion: String, + ) : LabelSelectorMatcher() { + + override fun matchesWithCause(labels: UpstreamsConfig.Labels): MatchesResponse { + val actualRawVersion = labels["client_version"] + ?: return RangeVersionResponse(minRawVersion, maxRawVersion) + + val actualSemver = runCatching { + Semver(actualRawVersion.removePrefix("v"), Semver.SemverType.STRICT) + }.getOrElse { + return RangeVersionResponse(minRawVersion, maxRawVersion) + } + + val greaterOk = + if (minRawVersion.isEmpty()) { + true + } else { + runCatching { + val min = Semver(minRawVersion.removePrefix("v"), Semver.SemverType.STRICT) + actualSemver.isGreaterThan(min) + }.getOrElse { + false + } + } + val lessOk = + if (maxRawVersion.isEmpty()) { + true + } else { + runCatching { + val max = Semver(maxRawVersion.removePrefix("v"), Semver.SemverType.STRICT) + actualSemver.isLowerThan(max) + }.getOrElse { + false + } + } + + return if (greaterOk && lessOk) { + Success + } else { + RangeVersionResponse(minRawVersion, maxRawVersion) + } + } + + override fun asProto(): BlockchainOuterClass.Selector = + BlockchainOuterClass.Selector.newBuilder().setAndSelector( + BlockchainOuterClass.AndSelector.newBuilder().apply { + if (minRawVersion.isNotEmpty()) { + addSelectors( + BlockchainOuterClass.Selector.newBuilder().setMinVersionSelector( + BlockchainOuterClass.MinVersionSelector.newBuilder() + .setVersion(minRawVersion) + .build(), + ), + ) + } + if (maxRawVersion.isNotEmpty()) { + addSelectors( + BlockchainOuterClass.Selector.newBuilder().setMaxVersionSelector( + BlockchainOuterClass.MaxVersionSelector.newBuilder() + .setVersion(maxRawVersion) + .build(), + ), + ) + } + }, + ).build() + + override fun describeInternal(): String = + "version range from '$minRawVersion' to '$maxRawVersion'" + + override fun toString(): String = + "Matcher: ${describeInternal()}" + } } From b944aa8ac60e3138d92781482243c0bb7dcd2f64 Mon Sep 17 00:00:00 2001 From: Vyacheslav Date: Thu, 18 Dec 2025 20:19:02 +0200 Subject: [PATCH 017/108] add custom headers as upstream settings (#761) * add custom headers * add docs --- docs/04-upstream-config.adoc | 35 ++++++ .../dshackle/config/UpstreamsConfig.kt | 1 + .../dshackle/config/UpstreamsConfigReader.kt | 9 ++ .../configure/BitcoinUpstreamCreator.kt | 2 +- .../configure/ConnectorFactoryCreator.kt | 7 +- .../GenericConnectorFactoryCreator.kt | 14 ++- .../configure/GenericUpstreamCreator.kt | 1 + .../configure/RestConnectorFactoryCreator.kt | 5 +- .../dshackle/upstream/BasicHttpFactory.kt | 5 +- .../dshackle/upstream/HttpReader.kt | 10 ++ .../upstream/ethereum/WsConnectionFactory.kt | 3 +- .../upstream/ethereum/WsConnectionImpl.kt | 4 + .../upstream/restclient/RestHttpReader.kt | 3 +- .../upstream/rpcclient/JsonRpcHttpReader.kt | 3 +- .../rpcclient/JsonRpcHttpReaderSpec.groovy | 7 +- .../config/UpstreamsConfigReaderTest.kt | 103 ++++++++++++++++++ 16 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt diff --git a/docs/04-upstream-config.adoc b/docs/04-upstream-config.adoc index 053cff6dc..7caa6f746 100644 --- a/docs/04-upstream-config.adoc +++ b/docs/04-upstream-config.adoc @@ -174,6 +174,41 @@ In case of rpc and ws connection we can specify different modes of works togethe You can specify this modes through `connector-mode` parameter in connection config. +=== Custom Headers + +Dshackle allows you to add custom HTTP headers to all requests sent to an upstream. +This is useful when connecting to providers that require API keys, authentication tokens, or other custom headers. + +Custom headers are configured at the upstream level and will be added to: + +- All HTTP JSON RPC requests +- WebSocket connection handshakes (initial HTTP upgrade request) + +NOTE: Custom headers are supported for all connection types except gRPC connections. + +==== Configuration + +Custom headers are specified using the `custom-headers` parameter in your upstream configuration: + +[source,yaml] +---- +version: v1 + +upstreams: + - id: my-ethereum-node + chain: ethereum + custom-headers: + X-API-Key: "your-api-key-here" + X-Custom-Header: "custom-value" + Authorization: "Bearer your-token" + connection: + generic: + rpc: + url: "https://api.example.com/rpc" + ws: + url: "wss://api.example.com/ws" +---- + === Bitcoin Methods .By default an ethereum upstream allows call to the following JSON RPC methods: diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt index f113f8864..f1b51ec84 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt @@ -41,6 +41,7 @@ data class UpstreamsConfig( var methods: Methods? = null, var methodGroups: MethodGroups? = null, var role: UpstreamRole = UpstreamRole.PRIMARY, + var customHeaders: Map = emptyMap(), ) { @Suppress("UNCHECKED_CAST") diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt index 982a1b5ce..7e16342ad 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt @@ -300,6 +300,15 @@ class UpstreamsConfigReader( } } } + if (hasAny(upNode, "custom-headers")) { + getMapping(upNode, "custom-headers")?.let { headers -> + val headersMap = headers.value + .map { it.keyNode.valueAsString() to it.valueNode.valueAsString() } + .filter { StringUtils.isNotBlank(it.first) && StringUtils.isNotBlank(it.second) } + .associate { it.first!!.trim() to it.second!!.trim() } + upstream.customHeaders = headersMap + } + } } private fun readUpstreamGrpc( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt index 9925aa615..305650a46 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt @@ -38,7 +38,7 @@ class BitcoinUpstreamCreator( ): UpstreamCreationData { val config = upstreamsConfig.cast(UpstreamsConfig.BitcoinConnection::class.java) val conn = config.connection!! - val httpFactory = genericConnectorFactoryCreator.buildHttpFactory(conn.rpc) + val httpFactory = genericConnectorFactoryCreator.buildHttpFactory(conn.rpc, customHeaders = config.customHeaders) if (httpFactory == null) { log.warn("Upstream doesn't have API configuration") return UpstreamCreationData.default() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/ConnectorFactoryCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/ConnectorFactoryCreator.kt index d71cc2976..570a46348 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/ConnectorFactoryCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/ConnectorFactoryCreator.kt @@ -19,9 +19,14 @@ interface ConnectorFactoryCreator { forkChoice: ForkChoice, blockValidator: BlockValidator, chainsConf: ChainsConfig.ChainConfig, + customHeaders: Map = emptyMap(), ): ConnectorFactory? - fun buildHttpFactory(conn: UpstreamsConfig.HttpEndpoint?, urls: ArrayList? = null): HttpFactory? + fun buildHttpFactory( + conn: UpstreamsConfig.HttpEndpoint?, + urls: ArrayList? = null, + customHeaders: Map = emptyMap(), + ): HttpFactory? } @Component diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt index f85aa7425..ee46c29c4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt @@ -38,10 +38,11 @@ open class GenericConnectorFactoryCreator( forkChoice: ForkChoice, blockValidator: BlockValidator, chainsConf: ChainsConfig.ChainConfig, + customHeaders: Map, ): ConnectorFactory? { val urls = ArrayList() - val wsFactoryApi = buildWsFactory(id, chain, conn, urls) - val httpFactory = buildHttpFactory(conn.rpc, urls) + val wsFactoryApi = buildWsFactory(id, chain, conn, urls, customHeaders) + val httpFactory = buildHttpFactory(conn.rpc, urls, customHeaders) log.info("Using ${chain.chainName} upstream, at ${urls.joinToString()}") val connectorFactory = GenericConnectorFactory( @@ -62,7 +63,11 @@ open class GenericConnectorFactoryCreator( return connectorFactory } - override fun buildHttpFactory(conn: UpstreamsConfig.HttpEndpoint?, urls: ArrayList?): HttpFactory? { + override fun buildHttpFactory( + conn: UpstreamsConfig.HttpEndpoint?, + urls: ArrayList?, + customHeaders: Map, + ): HttpFactory? { return conn?.let { endpoint -> val tls = conn.tls?.let { tls -> tls.ca?.let { ca -> @@ -78,6 +83,7 @@ open class GenericConnectorFactoryCreator( tls, monitoringCfg.nettyMetricsConfig.enabled, httpScheduler, + customHeaders, ) } } @@ -87,6 +93,7 @@ open class GenericConnectorFactoryCreator( chain: Chain, conn: UpstreamsConfig.RpcConnection, urls: ArrayList? = null, + customHeaders: Map = emptyMap(), ): WsConnectionPoolFactory? { return conn.ws?.let { endpoint -> val wsConnectionFactory = WsConnectionFactory( @@ -99,6 +106,7 @@ open class GenericConnectorFactoryCreator( ).apply { config = endpoint basicAuth = endpoint.basicAuth + this.customHeaders = customHeaders } val wsApi = WsConnectionPoolFactory( id, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt index a813ea678..e38bcca2e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt @@ -64,6 +64,7 @@ open class GenericUpstreamCreator( NoChoiceWithPriorityForkChoice(nodeRating, config.id!!), BlockValidator.ALWAYS_VALID, chainConfig, + config.customHeaders, ) ?: return UpstreamCreationData.default() val hashUrl = connection.let { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt index 7a58eebe8..19cfc7691 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/RestConnectorFactoryCreator.kt @@ -41,10 +41,11 @@ class RestConnectorFactoryCreator( forkChoice: ForkChoice, blockValidator: BlockValidator, chainsConf: ChainsConfig.ChainConfig, + customHeaders: Map, ): ConnectorFactory? { val urls = ArrayList() - val httpFactory = buildHttpFactory(conn.rpc, urls) - val tonV3HttpFactory = buildHttpFactory(conn.getEndpointByTag("ton_v3")?.rpc, urls) + val httpFactory = buildHttpFactory(conn.rpc, urls, customHeaders) + val tonV3HttpFactory = buildHttpFactory(conn.getEndpointByTag("ton_v3")?.rpc, urls, customHeaders) val upstreamHttpFactory = if (httpFactory != null && chain.type == BlockchainType.TON) { TonCompoundHttpFactory(httpFactory, tonV3HttpFactory) } else { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt index e9ccad094..fcb4c9f24 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt @@ -20,6 +20,7 @@ class BasicHttpFactory( private val tls: ByteArray?, private val nettyMetricsEnabled: Boolean, private val httpScheduler: Scheduler, + private val customHeaders: Map = emptyMap(), ) : HttpFactory { private val log = LoggerFactory.getLogger(this::class.java) @@ -46,8 +47,8 @@ class BasicHttpFactory( ) if (chain.type.apiType == ApiType.REST) { - return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, chain, basicAuth, tls) + return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, chain, basicAuth, tls, customHeaders) } - return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls) + return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls, customHeaders) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt index c0ec9d0f4..b944ac3d2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt @@ -27,6 +27,7 @@ abstract class HttpReader( protected val metrics: RequestMetrics?, basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, + customHeaders: Map = emptyMap(), ) : ChainReader { constructor() : this("", 1500, 1000, null) @@ -65,6 +66,15 @@ abstract class HttpReader( build = build.headers(headers) } + if (customHeaders.isNotEmpty()) { + val headers = Consumer { h: HttpHeaders -> + customHeaders.forEach { (key, value) -> + h.add(key, value) + } + } + build = build.headers(headers) + } + tlsCAAuth?.let { auth -> val cf = CertificateFactory.getInstance("X.509") val cert = cf.generateCertificate(ByteArrayInputStream(auth)) as X509Certificate diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt index 6179ac33d..526950bd8 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt @@ -22,6 +22,7 @@ open class WsConnectionFactory( var basicAuth: AuthConfig.ClientBasicAuth? = null var config: UpstreamsConfig.WsEndpoint? = null + var customHeaders: Map = emptyMap() private fun metrics(connIndex: Int): RequestMetrics { val metricsTags = listOf( @@ -46,7 +47,7 @@ open class WsConnectionFactory( } open fun createWsConnection(connIndex: Int = 0): WsConnection = - WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler, eventsScheduler).also { ws -> + WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler, eventsScheduler, customHeaders).also { ws -> config?.frameSize?.let { ws.frameSize = it } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt index 02aae5825..f7bbfef7c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt @@ -66,6 +66,7 @@ open class WsConnectionImpl( private val requestMetrics: RequestMetrics?, private val scheduler: Scheduler, private val eventsScheduler: Scheduler, + private val customHeaders: Map = emptyMap(), ) : AutoCloseable, WsConnection, Cloneable { companion object { @@ -226,6 +227,9 @@ open class WsConnectionImpl( val base64password = Base64.getEncoder().encodeToString(tmp.toByteArray()) headers.add(HttpHeaderNames.AUTHORIZATION, "Basic $base64password") } + customHeaders.forEach { (key, value) -> + headers.add(key, value) + } } .let { if (uri.scheme == "wss") it.secure() else it diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt index b78ab8ec1..ebd3694fd 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt @@ -33,7 +33,8 @@ class RestHttpReader( private val chain: Chain, basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, -) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth) { + customHeaders: Map = emptyMap(), +) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders) { private val parser = ResponseRpcParser() private val requestParser = RestRequestParser diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt index 4de3c0656..dce03d80d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt @@ -44,7 +44,8 @@ class JsonRpcHttpReader( private val httpScheduler: Scheduler, basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, -) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth) { + customHeaders: Map = emptyMap(), +) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders) { private val parser = ResponseRpcParser() private val streamParser = JsonRpcStreamParser() diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy index 977c3ec1c..d14ec38d0 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy @@ -53,7 +53,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Make a request"() { setup: - JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(),null, null) + JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(),null, null, [:]) def resp = '{' + ' "jsonrpc": "2.0",' + ' "result": "0x98de45",' + @@ -74,8 +74,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Produces RPC Exception on error status code"() { setup: - def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null) - + def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) mockServer.when( HttpRequest.request() ).respond( @@ -98,7 +97,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Tries to extract message if HTTP error if it still contains a JSON RPC message"() { setup: - def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null) + def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) mockServer.when( HttpRequest.request() diff --git a/src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt new file mode 100644 index 000000000..ccd273ff7 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt @@ -0,0 +1,103 @@ +package io.emeraldpay.dshackle.config + +import io.emeraldpay.dshackle.FileResolver +import io.emeraldpay.dshackle.foundation.ChainOptionsReader +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class UpstreamsConfigReaderTest { + + @Test + fun `should parse customHeaders from YAML`() { + val yaml = """ + version: v1 + upstreams: + - id: test-upstream + chain: ethereum + custom-headers: + X-Custom-Header: "custom-value" + Authorization: "Bearer token" + X-Another-Header: "another-value" + connection: + ethereum: + rpc: + url: "http://localhost:8545" + """.trimIndent() + + val reader = UpstreamsConfigReader( + FileResolver(File(".")), + ChainOptionsReader(), + ) + + val config = reader.readInternal(yaml.byteInputStream()) + + assertNotNull(config) + assertEquals(1, config.upstreams.size) + + val upstream = config.upstreams[0] + assertEquals("test-upstream", upstream.id) + assertEquals(3, upstream.customHeaders.size) + assertEquals("custom-value", upstream.customHeaders["X-Custom-Header"]) + assertEquals("Bearer token", upstream.customHeaders["Authorization"]) + assertEquals("another-value", upstream.customHeaders["X-Another-Header"]) + } + + @Test + fun `should work without customHeaders`() { + val yaml = """ + version: v1 + upstreams: + - id: test-upstream + chain: ethereum + connection: + ethereum: + rpc: + url: "http://localhost:8545" + """.trimIndent() + + val reader = UpstreamsConfigReader( + FileResolver(File(".")), + ChainOptionsReader(), + ) + + val config = reader.readInternal(yaml.byteInputStream()) + + assertNotNull(config) + assertEquals(1, config.upstreams.size) + + val upstream = config.upstreams[0] + assertEquals("test-upstream", upstream.id) + assertTrue(upstream.customHeaders.isEmpty()) + } + + @Test + fun `should trim header names and values`() { + val yaml = """ + version: v1 + upstreams: + - id: test-upstream + chain: ethereum + custom-headers: + " X-Header ": " value " + connection: + ethereum: + rpc: + url: "http://localhost:8545" + """.trimIndent() + + val reader = UpstreamsConfigReader( + FileResolver(File(".")), + ChainOptionsReader(), + ) + + val config = reader.readInternal(yaml.byteInputStream()) + + assertNotNull(config) + val upstream = config.upstreams[0] + assertEquals(1, upstream.customHeaders.size) + assertEquals("value", upstream.customHeaders["X-Header"]) + } +} From d76ab326242e4284c1eec5f3a173645bf2ed48a1 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 23 Dec 2025 13:59:01 +0400 Subject: [PATCH 018/108] Pass an error as is (#763) --- emerald-grpc | 2 +- .../kotlin/io/emeraldpay/dshackle/Global.kt | 12 ++++++++ .../io/emeraldpay/dshackle/rpc/NativeCall.kt | 28 ++++++++++++++++++- .../dshackle/upstream/ChainCallError.kt | 25 ++++++++++++++++- .../upstream/rpcclient/ResponseParser.kt | 9 ++++-- .../rpcclient/stream/JsonRpcStreamParser.kt | 4 ++- .../rpcclient/JsonRpcErrorSpec.groovy | 8 ------ 7 files changed, 74 insertions(+), 14 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index f18804408..7afb478e6 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit f18804408ff4876db82b4106d05ac4e76b8170ef +Subproject commit 7afb478e66eb1609f03d5a7667b249e2da73932d diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Global.kt b/src/main/kotlin/io/emeraldpay/dshackle/Global.kt index a9775dbb8..f156558c9 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Global.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Global.kt @@ -78,6 +78,18 @@ class Global { } } + fun getErrorValueAsIs(errorResp: ByteArray): ByteArray? { + return runCatching { + Global.objectMapper.readTree(errorResp) + ?.get("error") + ?.let { + Global.objectMapper.writeValueAsBytes(it) + } + }.getOrElse { + null + } + } + private fun isSolana(chain: Chain): Boolean { return chain == Chain.SOLANA__MAINNET || chain == Chain.SOLANA__DEVNET || chain == Chain.SOLANA__TESTNET } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt index 24bf55cbf..31ba610d0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt @@ -254,6 +254,9 @@ open class NativeCall( error.data?.let { data -> result.setErrorData(data) } + if (error.errorAsIs != null) { + result.errorAsIs = ByteString.copyFrom(error.errorAsIs) + } } } else { result.payload = ByteString.copyFrom(it.result) @@ -801,6 +804,7 @@ open class NativeCall( val upstreamError: ChainCallError?, val data: String?, val upstreamSettingsData: List = emptyList(), + val errorAsIs: ByteArray? = null, ) { companion object { @@ -818,7 +822,7 @@ open class NativeCall( } fun from(t: Throwable): CallError { return when (t) { - is ChainException -> CallError(t.error.code, t.error.message, t.error, getDataAsSting(t.error.details), t.upstreamSettingsData) + is ChainException -> CallError(t.error.code, t.error.message, t.error, getDataAsSting(t.error.details), t.upstreamSettingsData, t.error.errorAsIs) is RpcException -> CallError(t.code, t.rpcMessage, null, getDataAsSting(t.details)) is CallFailure -> CallError(t.id, t.reason.message ?: "Upstream Error", null, null) else -> { @@ -833,6 +837,28 @@ open class NativeCall( } } } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CallError) return false + + if (id != other.id) return false + if (message != other.message) return false + if (upstreamError != other.upstreamError) return false + if (data != other.data) return false + if (upstreamSettingsData != other.upstreamSettingsData) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + message.hashCode() + result = 31 * result + (upstreamError?.hashCode() ?: 0) + result = 31 * result + (data?.hashCode() ?: 0) + result = 31 * result + upstreamSettingsData.hashCode() + return result + } } open class CallResult @JvmOverloads constructor( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainCallError.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainCallError.kt index bcca80bed..1c7f180f7 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainCallError.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ChainCallError.kt @@ -17,7 +17,12 @@ package io.emeraldpay.dshackle.upstream import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException -data class ChainCallError(val code: Int, val message: String, val details: Any?) { +data class ChainCallError( + val code: Int, + val message: String, + val details: Any?, + val errorAsIs: ByteArray? = null, +) { constructor(code: Int, message: String) : this(code, message, null) @@ -39,4 +44,22 @@ data class ChainCallError(val code: Int, val message: String, val details: Any?) fun asException(id: ChainResponse.Id?, upstreamSettingsData: List): ChainException { return ChainException(id ?: ChainResponse.NumberId(-1), this, upstreamSettingsData, false) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ChainCallError) return false + + if (code != other.code) return false + if (message != other.message) return false + if (details != other.details) return false + + return true + } + + override fun hashCode(): Int { + var result = code + result = 31 * result + message.hashCode() + result = 31 * result + (details?.hashCode() ?: 0) + return result + } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt index b51ab4380..e68f45f35 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt @@ -97,7 +97,8 @@ abstract class ResponseParser { state.copy(result = result) } } else if (field == "error") { - val err = readError(parser) + val errorValue = Global.getErrorValueAsIs(json) + val err = readError(parser, errorValue) if (err != null) { return state.copy(error = err) } @@ -154,6 +155,10 @@ abstract class ResponseParser { } fun readError(parser: JsonParser): ChainCallError? { + return readError(parser, null) + } + + fun readError(parser: JsonParser, errorAsIs: ByteArray?): ChainCallError? { var code = 0 var message = "" var details: Any? = null @@ -204,7 +209,7 @@ abstract class ResponseParser { } } } - return ChainCallError(code, message, details) + return ChainCallError(code, message, details, errorAsIs) } data class Preparsed( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/stream/JsonRpcStreamParser.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/stream/JsonRpcStreamParser.kt index dd9f262f1..25587a241 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/stream/JsonRpcStreamParser.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/stream/JsonRpcStreamParser.kt @@ -3,6 +3,7 @@ package io.emeraldpay.dshackle.upstream.rpcclient.stream import com.fasterxml.jackson.core.JsonFactory import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken +import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.upstream.ChainCallError import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError import io.emeraldpay.dshackle.upstream.rpcclient.ResponseRpcParser @@ -233,7 +234,8 @@ class JsonRpcStreamParser( return response } } else if (parser.currentName == "error") { - return SingleResponse(response?.result, responseRpcParser.readError(parser)) + val errorValue = Global.getErrorValueAsIs(firstBytes) + return SingleResponse(response?.result, responseRpcParser.readError(parser, errorValue)) } } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcErrorSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcErrorSpec.groovy index 1c2a7deb5..ad81f8b1b 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcErrorSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcErrorSpec.groovy @@ -2,7 +2,6 @@ package io.emeraldpay.dshackle.upstream.rpcclient import io.emeraldpay.dshackle.upstream.ChainCallError import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException -import nl.jqno.equalsverifier.EqualsVerifier import spock.lang.Specification class JsonRpcErrorSpec extends Specification { @@ -24,11 +23,4 @@ class JsonRpcErrorSpec extends Specification { act.message == "test test" act.details == "foo bar" } - - def "Equals"() { - when: - def v = EqualsVerifier.forClass(ChainCallError) - then: - v.verify() - } } From 9d4f4a185e80aa510aeb975b3396bbee0da517e2 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 24 Dec 2025 11:59:13 +0300 Subject: [PATCH 019/108] add tempo tesnet and update xlayer testnet (#764) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 7afb478e6..bb18b3dcd 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 7afb478e66eb1609f03d5a7667b249e2da73932d +Subproject commit bb18b3dcd8bea2b989ace8d857e28b7ced7a5281 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 2379525f3..e64a77eec 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 2379525f3e9c642ca1c8c86b8cbecd246668ad36 +Subproject commit e64a77eeccce71b8b5de21e56f3ce840acb76de8 From 6193499d582c5425b5fb205f5cec3db3c2b69c3d Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Wed, 24 Dec 2025 14:24:08 +0400 Subject: [PATCH 020/108] Fix ripple tx broadcast (#765) --- .../dshackle/quorum/JsonBroadcastQuorum.kt | 54 +++++++++++++++++++ .../upstream/calls/DefaultRippleMethods.kt | 4 +- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/quorum/JsonBroadcastQuorum.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/JsonBroadcastQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/JsonBroadcastQuorum.kt new file mode 100644 index 000000000..31a50fc1e --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/JsonBroadcastQuorum.kt @@ -0,0 +1,54 @@ +package io.emeraldpay.dshackle.quorum + +import com.fasterxml.jackson.databind.JsonNode +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner + +open class JsonBroadcastQuorum() : CallQuorum, ValueAwareQuorum(JsonNode::class.java) { + + private var result: ChainResponse? = null + private var txResponse: JsonNode? = null + private var sig: ResponseSigner.Signature? = null + + override fun isResolved(): Boolean { + return result != null + } + + override fun isFailed(): Boolean { + return result == null + } + + override fun getResponse(): ChainResponse? { + return result + } + + override fun getSignature(): ResponseSigner.Signature? { + return sig + } + + override fun recordValue( + response: ChainResponse, + responseValue: JsonNode?, + signature: ResponseSigner.Signature?, + upstream: Upstream, + ) { + if (txResponse == null && responseValue != null) { + txResponse = responseValue + sig = signature + result = response + } + } + + override fun recordError( + errorMessage: String?, + signature: ResponseSigner.Signature?, + upstream: Upstream, + ) { + resolvers.add(upstream) + } + + override fun toString(): String { + return "Quorum: Json Broadcast to upstreams" + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultRippleMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultRippleMethods.kt index abbb57470..ed2947707 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultRippleMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultRippleMethods.kt @@ -1,8 +1,8 @@ package io.emeraldpay.dshackle.upstream.calls import io.emeraldpay.dshackle.quorum.AlwaysQuorum -import io.emeraldpay.dshackle.quorum.BroadcastQuorum import io.emeraldpay.dshackle.quorum.CallQuorum +import io.emeraldpay.dshackle.quorum.JsonBroadcastQuorum import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException class DefaultRippleMethods : CallMethods { @@ -66,7 +66,7 @@ class DefaultRippleMethods : CallMethods { override fun createQuorumFor(method: String): CallQuorum { if (add.contains(method)) { - return BroadcastQuorum() + return JsonBroadcastQuorum() } return AlwaysQuorum() } From c5e14364624500cf3c2d9972b111497e6ef84d8f Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Tue, 30 Dec 2025 02:33:27 +0200 Subject: [PATCH 021/108] make version range inclusive in selector (#766) --- src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt index 0fb9fbad3..b42dbf490 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Selector.kt @@ -746,7 +746,7 @@ class Selector { } else { runCatching { val min = Semver(minRawVersion.removePrefix("v"), Semver.SemverType.STRICT) - actualSemver.isGreaterThan(min) + actualSemver.isGreaterThanOrEqualTo(min) }.getOrElse { false } @@ -757,7 +757,7 @@ class Selector { } else { runCatching { val max = Semver(maxRawVersion.removePrefix("v"), Semver.SemverType.STRICT) - actualSemver.isLowerThan(max) + actualSemver.isLowerThanOrEqualTo(max) }.getOrElse { false } From 536c663b2aea60588cbf24a5ce512e41b8a3b2ae Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 6 Jan 2026 16:32:03 +0400 Subject: [PATCH 022/108] Update dependencies (#767) --- build.gradle | 7 +- foundation/build.gradle | 5 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- gradle/libs.versions.toml | 71 ++++----- gradle/wrapper/gradle-wrapper.properties | 2 +- .../io/emeraldpay/dshackle/GrpcServer.kt | 24 ++- .../io/emeraldpay/dshackle/ProxyStarter.kt | 20 +-- .../kotlin/io/emeraldpay/dshackle/Starter.kt | 22 ++- .../kotlin/io/emeraldpay/dshackle/TlsSetup.kt | 3 +- .../dshackle/cache/CachesFactory.kt | 5 +- .../dshackle/cache/CurrentBlockCache.kt | 2 +- .../dshackle/cache/OnBlockRedisCache.kt | 2 +- .../dshackle/cache/OnTxRedisCache.kt | 2 +- .../dshackle/commons/DurableFlux.kt | 6 +- .../dshackle/commons/DynamicMergeFlux.kt | 2 +- .../dshackle/commons/SharedFluxHolder.kt | 4 +- .../dshackle/config/AuthConfigReader.kt | 1 - .../dshackle/config/TokensConfig.kt | 2 +- .../config/context/MultistreamsConfig.kt | 6 +- .../config/context/SchedulersConfig.kt | 69 ++++----- .../config/context/TraceConfiguration.kt | 25 +++- .../config/hot/AutoReloadbleConfig.kt | 9 +- .../config/hot/CompatibleVersionsRules.kt | 8 +- .../dshackle/config/spans/Export.kt | 37 ----- .../config/spans/ProviderSpanHandler.kt | 84 ----------- .../config/spans/ZipkinSSLCustomizer.kt | 90 ------------ .../dshackle/monitoring/HealthCheckSetup.kt | 17 +-- .../dshackle/monitoring/MonitoringSetup.kt | 9 +- .../monitoring/accesslog/AccessHandlerGrpc.kt | 3 +- .../monitoring/accesslog/AccessHandlerHttp.kt | 5 +- .../monitoring/accesslog/AccessLogWriter.kt | 2 +- .../emeraldpay/dshackle/proxy/HttpHandler.kt | 2 +- .../emeraldpay/dshackle/proxy/ProxyServer.kt | 5 +- .../dshackle/quorum/QuorumRequestReader.kt | 12 +- .../dshackle/reader/BroadcastReader.kt | 11 +- .../dshackle/reader/CompoundReader.kt | 2 +- .../emeraldpay/dshackle/reader/EmptyReader.kt | 2 +- .../io/emeraldpay/dshackle/reader/Reader.kt | 2 +- .../dshackle/reader/RekeyingReader.kt | 2 +- .../dshackle/reader/RequestReaderFactory.kt | 6 +- .../dshackle/reader/SpannedReader.kt | 58 -------- .../dshackle/reader/TransformingReader.kt | 2 +- .../emeraldpay/dshackle/rpc/BlockchainRpc.kt | 12 +- .../io/emeraldpay/dshackle/rpc/Describe.kt | 5 +- .../io/emeraldpay/dshackle/rpc/NativeCall.kt | 67 +-------- .../dshackle/rpc/NativeSubscribe.kt | 5 +- .../io/emeraldpay/dshackle/rpc/StreamHead.kt | 3 +- .../upstream/CurrentMultistreamHolder.kt | 2 +- .../dshackle/upstream/IngressSubscription.kt | 2 +- .../upstream/NoIngressSubscription.kt | 2 +- .../dshackle/upstream/SubscriptionConnect.kt | 2 +- .../upstream/UpstreamSettingsDetector.kt | 2 +- .../dshackle/upstream/UpstreamValidator.kt | 2 +- .../beaconchain/BeaconChainSpecific.kt | 8 +- .../upstream/bitcoin/BitcoinReader.kt | 2 +- .../upstream/cosmos/CosmosChainSpecific.kt | 28 ++-- .../ethereum/EthereumCachingReader.kt | 30 ++-- .../ethereum/EthereumChainSpecific.kt | 5 +- .../upstream/ethereum/EthereumDirectReader.kt | 3 - .../EthereumUpstreamSettingsDetector.kt | 2 +- .../EthereumWsIngressSubscription.kt | 2 +- .../upstream/generic/AbstractChainSpecific.kt | 3 +- .../upstream/generic/ChainSpecific.kt | 3 +- .../generic/GenericIngressSubscription.kt | 4 +- .../upstream/grpc/GrpcUpstreamCreator.kt | 6 +- .../upstream/kadena/KadenaChainSpecific.kt | 8 +- .../kadena/KadenaUpstreamSettingsDetector.kt | 2 +- .../upstream/near/NearChainSpecific.kt | 20 +-- .../near/NearUpstreamSettingsDetector.kt | 4 +- .../polkadot/PolkadotChainSpecific.kt | 22 +-- .../upstream/ripple/RippleChainSpecific.kt | 137 +++++++++--------- .../signature/ResponseSignerFactory.kt | 31 ++-- .../upstream/solana/SolanaChainSpecific.kt | 16 +- .../solana/SolanaUpstreamSettingsDetector.kt | 2 +- .../starknet/StarknetChainSpecific.kt | 12 +- .../StarknetUpstreamSettingsDetector.kt | 2 +- src/main/resources/application.yml | 21 +-- src/main/resources/logback-spring.xml | 2 +- .../quorum/QuorumRequestReaderSpec.groovy | 21 ++- .../reader/BroadcastReaderSpec.groovy | 11 +- .../dshackle/rpc/NativeCallSpec.groovy | 3 +- .../dshackle/rpc/NativeSubscribeSpec.groovy | 4 - .../test/MultistreamHolderMock.groovy | 5 +- .../dshackle/test/TestingCommons.groovy | 10 +- .../dshackle/test/TracerMock.groovy | 64 -------- .../dshackle/upstream/MultistreamSpec.groovy | 12 +- .../upstream/bitcoin/EsploraClientSpec.groovy | 13 +- .../ethereum/EthereumCachingReaderSpec.groovy | 35 +++-- .../ethereum/EthereumLocalReaderSpec.groovy | 2 - .../rpcclient/JsonRpcHttpReaderSpec.groovy | 11 +- .../upstream/signature/EcdsaSignerSpec.groovy | 4 +- .../ResponseSignerFactorySpec.groovy | 4 +- .../io/emeraldpay/dshackle/IntegrationTest.kt | 5 - .../config/reload/ReloadConfigTest.kt | 3 +- .../reader/RequestReaderFactoryTest.kt | 4 - .../emeraldpay/dshackle/rpc/NativeCallTest.kt | 4 - 96 files changed, 433 insertions(+), 911 deletions(-) delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/spans/Export.kt delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/spans/ProviderSpanHandler.kt delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/spans/ZipkinSSLCustomizer.kt delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/reader/SpannedReader.kt delete mode 100644 src/test/groovy/io/emeraldpay/dshackle/test/TracerMock.groovy diff --git a/build.gradle b/build.gradle index 91b5926f3..cdcfd32cb 100644 --- a/build.gradle +++ b/build.gradle @@ -72,19 +72,16 @@ dependencies { implementation(libs.grpc.proto.util) implementation libs.micrometer.registry.prometheus + implementation libs.micrometer.ctx.prop implementation libs.lettuce.core - implementation(libs.spring.cloud.starter.sleuth) { - exclude module: 'spring-security-rsa' - } implementation libs.brave.instrumentation.grpc + implementation libs.brave.ctx.slf4j implementation libs.logstash.encoder implementation libs.janino - implementation libs.spring.cloud.sleuth.zipkin - implementation libs.bitcoinj implementation libs.snake.yaml diff --git a/foundation/build.gradle b/foundation/build.gradle index 5828bb866..61e9ca555 100644 --- a/foundation/build.gradle +++ b/foundation/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.jetbrains.kotlin.jvm' version '1.9.25' + id 'org.jetbrains.kotlin.jvm' version '2.3.0' id 'maven-publish' } @@ -12,7 +12,8 @@ group = 'dshackle' dependencies { implementation 'org.yaml:snakeyaml:1.24' - testImplementation 'org.junit.jupiter:junit-jupiter:5.9.1' + testImplementation 'org.junit.jupiter:junit-jupiter:6.0.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } test { diff --git a/foundation/gradle/wrapper/gradle-wrapper.properties b/foundation/gradle/wrapper/gradle-wrapper.properties index a59520664..63e0e8303 100644 --- a/foundation/gradle/wrapper/gradle-wrapper.properties +++ b/foundation/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85b152c6e..294d7f6ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,22 +1,20 @@ [versions] -detekt = "1.23.1" -groovy = "4.0.15" +groovy = "5.0.3" protoc = "4.33.2" -jackson = "2.11.0" -grpc = "1.57.0" -reactive-grpc = "1.2.0" -spring-boot = "2.6.15" -spring-security = "5.5.3" -reactor = "3.4.32" -netty = "4.1.110.Final" -netty-tcnative = "2.0.66.Final" -kotlin = "1.9.25" -httpcomponents = "4.5.8" +jackson = "2.20.1" +grpc = "1.78.0" +spring-boot = "4.0.1" +reactor = "3.8.1" +netty = "4.2.9.Final" +netty-tcnative = "2.0.74.Final" +kotlin = "2.3.0" +httpcomponents = "5.5.1" [libraries] apache-commons-lang3 = "org.apache.commons:commons-lang3:3.9" apache-commons-collections4 = "org.apache.commons:commons-collections4:4.3" apache-commons-math3 = "org.apache.commons:commons-math3:3.6.1" +commons-codec = "commons-codec:commons-codec:1.20.0" bitcoinj = "org.bitcoinj:bitcoinj-core:0.15.8" @@ -33,21 +31,19 @@ commons-io = "commons-io:commons-io:2.6" cglib-nodep = "cglib:cglib-nodep:3.3.0" -detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } - equals-verifier = "nl.jqno.equalsverifier:equalsverifier:3.10.1" groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } grpc-proto-util = { module = "com.google.protobuf:protobuf-java-util", version.ref = "protoc" } grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" } +grpc-inprocess = { module = "io.grpc:grpc-inprocess", version.ref = "grpc" } grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } grpc-netty = { module = "io.grpc:grpc-netty", version.ref = "grpc" } grpc-testing = { module = "io.grpc:grpc-testing", version.ref = "grpc" } grpc-services = { module = "io.grpc:grpc-services", version.ref = "grpc" } -httpcomponents-httpmime = { module = "org.apache.httpcomponents:httpmime", version.ref = "httpcomponents" } -httpcomponents-httpclient = { module = "org.apache.httpcomponents:httpclient", version.ref = "httpcomponents" } +httpcomponents-httpclient = { module = "org.apache.httpcomponents.client5:httpclient5", version.ref = "httpcomponents" } jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jackson" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } @@ -65,11 +61,12 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = lettuce-core = "io.lettuce:lettuce-core:5.2.2.RELEASE" -micrometer-registry-prometheus = "io.micrometer:micrometer-registry-prometheus:1.10.0" +micrometer-registry-prometheus = "io.micrometer:micrometer-registry-prometheus:1.16.0" +micrometer-ctx-prop = "io.micrometer:context-propagation:1.2.0" -mockserver-netty = "org.mock-server:mockserver-netty:5.11.2" +mockserver-netty = "org.mock-server:mockserver-netty:5.15.0" -mockito-kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0" +mockito-kotlin = "org.mockito.kotlin:mockito-kotlin:6.1.0" netty-common = { module = "io.netty:netty-common", version.ref = "netty" } netty-transport = { module = "io.netty:netty-transport", version.ref = "netty" } @@ -90,8 +87,9 @@ zeromq = "org.zeromq:jeromq:0.5.2" objgenesis = "org.objenesis:objenesis:3.1" reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" } -reactor-netty = { module = "io.projectreactor.netty:reactor-netty", version = "1.1.10" } -reactor-extra = { module = "io.projectreactor.addons:reactor-extra", version = "3.5.1" } +reactor-micrometer = { module = "io.projectreactor:reactor-core-micrometer", version.ref = "reactor" } +reactor-netty = { module = "io.projectreactor.netty:reactor-netty", version = "1.3.1" } +reactor-extra = { module = "io.projectreactor.addons:reactor-extra", version = "3.6.0" } reactor-kotlin = { module = "io.projectreactor.kotlin:reactor-kotlin-extensions", version = "1.1.7" } reactor-test = { module = "io.projectreactor:reactor-test", version.ref = "reactor" } @@ -99,24 +97,20 @@ reactor-grpc-stub = "com.salesforce.servicelibs:reactor-grpc-stub:1.2.0" snake-yaml = "org.yaml:snakeyaml:1.24" -spockframework-core = "org.spockframework:spock-core:2.3-groovy-4.0" +spockframework-core = "org.spockframework:spock-core:2.4-groovy-5.0" spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" } -spring-security-core = { module = "org.springframework.security:spring-security-core", version.ref = "spring-security" } -spring-security-web = { module = "org.springframework.security:spring-security-web", version.ref = "spring-security" } -spring-security-config = { module = "org.springframework.security:spring-security-config", version.ref = "spring-security" } +spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } testcontainers = "org.testcontainers:testcontainers:1.21.3" testcontainers-ganache = "io.github.ganchix:testcontainers-java-module-ganache:0.0.4" -junit-jupiter = "org.junit.jupiter:junit-jupiter:5.9.1" assertj = "org.assertj:assertj-core:3.23.1" -spring-cloud-starter-sleuth = "org.springframework.cloud:spring-cloud-starter-sleuth:3.1.6" -spring-cloud-sleuth-zipkin = "org.springframework.cloud:spring-cloud-sleuth-zipkin:3.1.6" -brave-instrumentation-grpc = "io.zipkin.brave:brave-instrumentation-grpc:5.15.0" +brave-instrumentation-grpc = "io.zipkin.brave:brave-instrumentation-grpc:6.3.0" +brave-ctx-slf4j = "io.zipkin.brave:brave-context-slf4j:6.3.0" auth0-jwt = "com.auth0:java-jwt:4.4.0" @@ -125,24 +119,23 @@ mockito-inline = "org.mockito:mockito-inline:4.0.0" semver4j = "com.vdurmont:semver4j:3.1.0" [bundles] -apache-commons = ["commons-io", "apache-commons-lang3", "apache-commons-collections4", "apache-commons-math3"] -grpc = ["grpc-protobuf", "grpc-stub", "grpc-netty", "grpc-proto-util", "grpc-services"] -httpcomponents = ["httpcomponents-httpmime", "httpcomponents-httpclient"] +apache-commons = ["commons-io", "apache-commons-lang3", "apache-commons-collections4", "apache-commons-math3", "commons-codec"] +grpc = ["grpc-protobuf", "grpc-stub", "grpc-netty", "grpc-proto-util", "grpc-services", "grpc-inprocess"] +httpcomponents = ["httpcomponents-httpclient"] jackson = ["jackson-core", "jackson-databind", "jackson-datatype-jdk8", "jackson-datatype-jsr310", "jackson-module-kotlin", "jackson-yaml"] kotlin = ["kotlin-stdlib-jdk8", "kotlin-reflect"] netty = ["netty-common", "netty-transport", "netty-handler-core", "netty-handler-proxy", "netty-resolver-core", "netty-resolver-dns", "netty-codec-core", "netty-codec-http", "netty-codec-http2", "netty-buffer", "netty-tcnative-core"] -reactor = ["reactor-core", "reactor-netty", "reactor-extra", "reactor-kotlin"] -spring-framework = ["spring-boot-starter", "spring-security-core", "spring-security-web", "spring-security-config"] +reactor = ["reactor-core", "reactor-netty", "reactor-extra", "reactor-kotlin", "reactor-micrometer"] +spring-framework = ["spring-boot-starter", "spring-boot-starter-actuator"] testcontainers = ["testcontainers", "testcontainers-ganache"] -junit = ["junit-jupiter", "assertj"] +junit = ["assertj"] bouncycastle = ["bouncycastle-pkix", "bouncycastle-prov"] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -jib = { id = "com.google.cloud.tools.jib", version = "3.4.3" } -spring = { id = "org.springframework.boot", version = "2.6.0" } +jib = { id = "com.google.cloud.tools.jib", version = "3.5.2" } +spring = { id = "org.springframework.boot", version = "4.0.1" } git = { id = "com.palantir.git-version", version = "0.12.3" } -protobuf = { id = "com.google.protobuf", version = "0.9.1" } +protobuf = { id = "com.google.protobuf", version = "0.9.6" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "11.6.0" } -detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a59520664..63e0e8303 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt b/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt index 3dd03cb78..e144620b1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt @@ -25,10 +25,11 @@ import io.grpc.ServerCall import io.grpc.ServerCallHandler import io.grpc.ServerInterceptor import io.grpc.netty.NettyServerBuilder -import io.grpc.protobuf.services.ProtoReflectionService +import io.grpc.protobuf.services.ProtoReflectionServiceV1 import io.micrometer.core.instrument.Metrics -import io.micrometer.core.instrument.Tag import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.scheduling.concurrent.CustomizableThreadFactory @@ -36,8 +37,6 @@ import org.springframework.stereotype.Service import java.net.InetSocketAddress import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import javax.annotation.PostConstruct -import javax.annotation.PreDestroy @Service open class GrpcServer( @@ -103,21 +102,16 @@ open class GrpcServer( serverBuilder.addService(it) } - serverBuilder.addService(ProtoReflectionService.newInstance()) + serverBuilder.addService(ProtoReflectionServiceV1.newInstance()) val pool = Executors.newFixedThreadPool(20, CustomizableThreadFactory("fixed-grpc-")) serverBuilder.executor( - if (mainConfig.monitoring.enableExtended) { - ExecutorServiceMetrics.monitor( - Metrics.globalRegistry, - pool, - "fixed-grpc-executor", - Tag.of("reactor_scheduler_id", "_"), - ) - } else { - pool - }, + ExecutorServiceMetrics.monitor( + Metrics.globalRegistry, + pool, + "fixed-grpc-executor", + ), ) val server = serverBuilder.build() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/ProxyStarter.kt b/src/main/kotlin/io/emeraldpay/dshackle/ProxyStarter.kt index baa81121a..84d367a33 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/ProxyStarter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/ProxyStarter.kt @@ -17,32 +17,28 @@ package io.emeraldpay.dshackle import io.emeraldpay.dshackle.config.MainConfig -import io.emeraldpay.dshackle.monitoring.MonitoringSetup import io.emeraldpay.dshackle.monitoring.accesslog.AccessHandlerHttp import io.emeraldpay.dshackle.proxy.ProxyServer import io.emeraldpay.dshackle.proxy.ReadRpcJson import io.emeraldpay.dshackle.proxy.WriteRpcJson import io.emeraldpay.dshackle.rpc.NativeCall import io.emeraldpay.dshackle.rpc.NativeSubscribe +import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service -import javax.annotation.PostConstruct /** * Starts HTTP proxy endpoint, if configured */ @Service class ProxyStarter( - @Autowired private val mainConfig: MainConfig, - @Autowired private val readRpcJson: ReadRpcJson, - @Autowired private val writeRpcJson: WriteRpcJson, - @Autowired private val nativeCall: NativeCall, - @Autowired private val nativeSubscribe: NativeSubscribe, - @Autowired private val tlsSetup: TlsSetup, - @Autowired private val accessHandlerHttp: AccessHandlerHttp, - // depend on Monitoring, declared here just to ensure it's properly initialized before the Proxy - @Autowired private val monitoringSetup: MonitoringSetup, + private val mainConfig: MainConfig, + private val readRpcJson: ReadRpcJson, + private val writeRpcJson: WriteRpcJson, + private val nativeCall: NativeCall, + private val nativeSubscribe: NativeSubscribe, + private val tlsSetup: TlsSetup, + private val accessHandlerHttp: AccessHandlerHttp, ) { companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt b/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt index d63abfb57..f1d00c8a1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt @@ -16,6 +16,10 @@ */ package io.emeraldpay.dshackle +import io.micrometer.context.ContextRegistry +import io.micrometer.context.integration.Slf4jThreadLocalAccessor +import io.micrometer.core.instrument.Metrics +import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics import io.netty.handler.ssl.OpenSsl import org.slf4j.LoggerFactory import org.springframework.boot.ResourceBanner @@ -23,6 +27,9 @@ import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.core.io.ClassPathResource import org.springframework.core.io.support.ResourcePropertySource +import reactor.core.Scannable +import reactor.core.Scannable.Attr +import reactor.core.publisher.Hooks import reactor.core.scheduler.Schedulers @SpringBootApplication(scanBasePackages = ["io.emeraldpay.dshackle"]) @@ -31,9 +38,22 @@ open class Starter private val log = LoggerFactory.getLogger(Starter::class.java) fun main(args: Array) { + ContextRegistry.getInstance().registerThreadLocalAccessor(Slf4jThreadLocalAccessor()) + Hooks.enableAutomaticContextPropagation() OpenSsl.ensureAvailability() - Schedulers.enableMetrics() + // add metrics for internal reactor schedulers + Schedulers.addExecutorServiceDecorator("key") { scheduler, execService -> + val schedulerName = Scannable.from(scheduler).scanOrDefault(Attr.NAME, scheduler.javaClass.name) + if (schedulerName.contains("single") || schedulerName.contains("parallel")) { + ExecutorServiceMetrics.monitor( + Metrics.globalRegistry, + execService, + schedulerName, + ) + } + execService + } HeapDumpCreator.init() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/TlsSetup.kt b/src/main/kotlin/io/emeraldpay/dshackle/TlsSetup.kt index 2a16d6cac..f3dd0ce7b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/TlsSetup.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/TlsSetup.kt @@ -24,13 +24,12 @@ import io.netty.handler.ssl.SslContextBuilder import io.netty.handler.ssl.SslProvider import org.apache.commons.lang3.StringUtils import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import java.security.cert.CertificateFactory @Service open class TlsSetup( - @Autowired val fileResolver: FileResolver, + val fileResolver: FileResolver, ) { companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt index 14eb18571..d88e6d40f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/cache/CachesFactory.kt @@ -24,16 +24,15 @@ import io.lettuce.core.api.StatefulRedisConnection import io.lettuce.core.codec.ByteArrayCodec import io.lettuce.core.codec.RedisCodec import io.lettuce.core.codec.StringCodec +import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository import java.util.EnumMap -import javax.annotation.PostConstruct import kotlin.system.exitProcess @Repository open class CachesFactory( - @Autowired private val cacheConfig: CacheConfig, + private val cacheConfig: CacheConfig, ) { companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/cache/CurrentBlockCache.kt b/src/main/kotlin/io/emeraldpay/dshackle/cache/CurrentBlockCache.kt index c1bf3b178..ecac4f591 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/cache/CurrentBlockCache.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/cache/CurrentBlockCache.kt @@ -5,7 +5,7 @@ import reactor.core.publisher.Mono import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference -class CurrentBlockCache : Reader { +class CurrentBlockCache : Reader { private val cache = AtomicReference(ConcurrentHashMap()) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/cache/OnBlockRedisCache.kt b/src/main/kotlin/io/emeraldpay/dshackle/cache/OnBlockRedisCache.kt index 23e88b76c..7ecf370b6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/cache/OnBlockRedisCache.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/cache/OnBlockRedisCache.kt @@ -29,7 +29,7 @@ import java.time.Instant import java.util.concurrent.TimeUnit import kotlin.math.min -abstract class OnBlockRedisCache( +abstract class OnBlockRedisCache( private val redis: RedisReactiveCommands, private val chain: Chain, private val valueType: ValueContainer.ValueType, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/cache/OnTxRedisCache.kt b/src/main/kotlin/io/emeraldpay/dshackle/cache/OnTxRedisCache.kt index 8ed45cd0a..538305d31 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/cache/OnTxRedisCache.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/cache/OnTxRedisCache.kt @@ -29,7 +29,7 @@ import java.time.Instant import java.util.concurrent.TimeUnit import kotlin.math.min -abstract class OnTxRedisCache( +abstract class OnTxRedisCache( private val redis: RedisReactiveCommands, private val chain: Chain, private val valueType: CachesProto.ValueContainer.ValueType, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/commons/DurableFlux.kt b/src/main/kotlin/io/emeraldpay/dshackle/commons/DurableFlux.kt index 3505ecee8..6d227164b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/commons/DurableFlux.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/commons/DurableFlux.kt @@ -13,7 +13,7 @@ import java.time.Duration /** * A flux holder that reconnects to it on failure taking into account a back off strategy */ -class DurableFlux( +class DurableFlux( private val provider: () -> Flux, private val errorBackOff: BackOff, private val log: Logger, @@ -55,7 +55,7 @@ class DurableFlux( } } - class Builder { + class Builder { private var provider: (() -> Flux)? = null @@ -63,7 +63,7 @@ class DurableFlux( protected var log: Logger = DurableFlux.defaultLog @Suppress("UNCHECKED_CAST") - fun using(provider: () -> Flux): Builder { + fun using(provider: () -> Flux): Builder { this.provider = provider as () -> Flux return this as Builder } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/commons/DynamicMergeFlux.kt b/src/main/kotlin/io/emeraldpay/dshackle/commons/DynamicMergeFlux.kt index f11432602..1ea611edb 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/commons/DynamicMergeFlux.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/commons/DynamicMergeFlux.kt @@ -6,7 +6,7 @@ import reactor.core.publisher.Sinks import reactor.core.scheduler.Scheduler import java.util.concurrent.ConcurrentHashMap -class DynamicMergeFlux(private val scheduler: Scheduler) { +class DynamicMergeFlux(private val scheduler: Scheduler) { private val merge = Sinks.many().multicast().directBestEffort() private val sources = ConcurrentHashMap() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/commons/SharedFluxHolder.kt b/src/main/kotlin/io/emeraldpay/dshackle/commons/SharedFluxHolder.kt index 1dd0f386f..59d155e25 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/commons/SharedFluxHolder.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/commons/SharedFluxHolder.kt @@ -11,7 +11,7 @@ import kotlin.concurrent.write * A flux holder that that creates it only if requested. Keeps it for the following calls, so all the following calls will * reuse it. Forgets as soon as it completes/cancelled, so it will be recreated again if needed. */ -class SharedFluxHolder( +class SharedFluxHolder( /** * Provider for the flux. Note that it can be called multiple times but only one is used at the same time. * I.e., if there is a few calls because of a thread-race only one is kept. @@ -66,7 +66,7 @@ class SharedFluxHolder( } } - data class Holder( + data class Holder( val flux: Flux, val id: Long, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt index d886c0fd4..7545904c5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt @@ -91,7 +91,6 @@ class AuthConfigReader : YamlConfigReader() { auth.clientCAs.add(it) } getList(clientNode, "cas")?.let { - println(it) it.value?.let { crt -> auth.clientCAs.addAll(crt.map { v -> v.value }) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt index d89138ac2..988070605 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt @@ -38,7 +38,7 @@ class TokensConfig( id.isNullOrBlank() -> "id" blockchain == null -> "blockchain" name.isNullOrBlank() -> "name" - type == null -> type + type == null -> "type" address.isNullOrBlank() -> "address" blockchain != null && (blockchain!!.type == BlockchainType.ETHEREUM) && diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/context/MultistreamsConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/context/MultistreamsConfig.kt index 7e22c9288..37780b5de 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/context/MultistreamsConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/context/MultistreamsConfig.kt @@ -10,7 +10,6 @@ import io.emeraldpay.dshackle.upstream.generic.ChainSpecificRegistry import io.emeraldpay.dshackle.upstream.generic.GenericMultistream import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.config.ConfigurableListableBeanFactory -import org.springframework.cloud.sleuth.Tracer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import reactor.core.scheduler.Scheduler @@ -26,7 +25,6 @@ open class MultistreamsConfig(val beanFactory: ConfigurableListableBeanFactory) headScheduler: Scheduler, @Qualifier("subScheduler") subScheduler: Scheduler, - tracer: Tracer, multistreamEventsScheduler: Scheduler, ): List { return Chain.entries @@ -40,7 +38,6 @@ open class MultistreamsConfig(val beanFactory: ConfigurableListableBeanFactory) cachesFactory, headScheduler, subScheduler, - tracer, multistreamEventsScheduler, ) } @@ -52,7 +49,6 @@ open class MultistreamsConfig(val beanFactory: ConfigurableListableBeanFactory) cachesFactory: CachesFactory, headScheduler: Scheduler, subScheduler: Scheduler, - tracer: Tracer, multistreamEventsScheduler: Scheduler, ): Multistream { val name = "multi-$chain" @@ -65,7 +61,7 @@ open class MultistreamsConfig(val beanFactory: ConfigurableListableBeanFactory) CopyOnWriteArrayList(), caches, headScheduler, - cs.makeCachingReaderBuilder(tracer), + cs.makeCachingReaderBuilder(), cs::localReaderBuilder, cs.subscriptionBuilder(subScheduler), ).also { register(it, name) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt index f3ad28a27..4c5ca2bf2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/context/SchedulersConfig.kt @@ -1,12 +1,11 @@ package io.emeraldpay.dshackle.config.context -import io.emeraldpay.dshackle.config.MonitoringConfig import io.micrometer.core.instrument.Metrics -import io.micrometer.core.instrument.Tag import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.DependsOn import org.springframework.scheduling.concurrent.CustomizableThreadFactory import reactor.core.scheduler.Scheduler import reactor.core.scheduler.Schedulers @@ -17,6 +16,7 @@ import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @Configuration +@DependsOn("monitoringSetup") open class SchedulersConfig { companion object { @@ -32,65 +32,65 @@ open class SchedulersConfig { } @Bean - open fun rpcScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("blockchain-rpc-scheduler", 20, monitoringConfig) + open fun rpcScheduler(): Scheduler { + return makeScheduler("blockchain-rpc-scheduler", 20) } @Bean - open fun headScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("head-scheduler", 4, monitoringConfig) + open fun headScheduler(): Scheduler { + return makeScheduler("head-scheduler", 4) } @Bean - open fun subScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("sub-scheduler", 4, monitoringConfig) + open fun subScheduler(): Scheduler { + return makeScheduler("sub-scheduler", 4) } @Bean - open fun multistreamEventsScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("events-scheduler", 4, monitoringConfig) + open fun multistreamEventsScheduler(): Scheduler { + return makeScheduler("events-scheduler", 4) } @Bean - open fun wsConnectionResubscribeScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("ws-connection-resubscribe-scheduler", 2, monitoringConfig) + open fun wsConnectionResubscribeScheduler(): Scheduler { + return makeScheduler("ws-connection-resubscribe-scheduler", 2) } @Bean - open fun wsScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("ws-scheduler", 4, monitoringConfig) + open fun wsScheduler(): Scheduler { + return makeScheduler("ws-scheduler", 4) } @Bean - open fun headLivenessScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("head-liveness-scheduler", 4, monitoringConfig) + open fun headLivenessScheduler(): Scheduler { + return makeScheduler("head-liveness-scheduler", 4) } @Bean - open fun grpcChannelExecutor(monitoringConfig: MonitoringConfig): Executor { - return makePool("grpc-client-channel", 10, monitoringConfig) + open fun grpcChannelExecutor(): Executor { + return makePool("grpc-client-channel", 10) } @Bean - open fun authScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("auth-scheduler", 4, monitoringConfig) + open fun authScheduler(): Scheduler { + return makeScheduler("auth-scheduler", 4) } @Bean - open fun httpScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("http-scheduler", 30, monitoringConfig) + open fun httpScheduler(): Scheduler { + return makeScheduler("http-scheduler", 30) } @Bean - open fun eventsScheduler(monitoringConfig: MonitoringConfig): Scheduler { - return makeScheduler("ws-events-scheduler", 30, monitoringConfig) + open fun eventsScheduler(): Scheduler { + return makeScheduler("ws-events-scheduler", 30) } - private fun makeScheduler(name: String, size: Int, monitoringConfig: MonitoringConfig): Scheduler { - return Schedulers.fromExecutorService(makePool(name, size, monitoringConfig)) + private fun makeScheduler(name: String, size: Int): Scheduler { + return Schedulers.fromExecutorService(makePool(name, size)) } - private fun makePool(name: String, size: Int, monitoringConfig: MonitoringConfig): ExecutorService { + private fun makePool(name: String, size: Int): ExecutorService { val cachedPool = ThreadPoolExecutor( size, size * threadsMultiplier, @@ -100,15 +100,10 @@ open class SchedulersConfig { CustomizableThreadFactory("$name-"), ) - return if (monitoringConfig.enableExtended) { - ExecutorServiceMetrics.monitor( - Metrics.globalRegistry, - cachedPool, - name, - Tag.of("reactor_scheduler_id", "_"), - ) - } else { - cachedPool - } + return ExecutorServiceMetrics.monitor( + Metrics.globalRegistry, + cachedPool, + name, + ) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/context/TraceConfiguration.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/context/TraceConfiguration.kt index 5c06db891..a950cf46c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/context/TraceConfiguration.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/context/TraceConfiguration.kt @@ -1,6 +1,9 @@ package io.emeraldpay.dshackle.config.context +import brave.Tracing +import brave.context.slf4j.MDCScopeDecorator import brave.grpc.GrpcTracing +import brave.propagation.CurrentTraceContext import brave.rpc.RpcTracing import brave.sampler.Sampler import io.grpc.ServerInterceptor @@ -9,14 +12,28 @@ import org.springframework.context.annotation.Configuration @Configuration open class TraceConfiguration { + @Bean + open fun defaultSampler(): Sampler = Sampler.ALWAYS_SAMPLE @Bean - open fun grpcTracing(rpcTracing: RpcTracing): GrpcTracing = GrpcTracing.create(rpcTracing) + open fun braveTracing(defaultSampler: Sampler): Tracing { + val currentTraceContext = + CurrentTraceContext.Default.newBuilder() + .addScopeDecorator(MDCScopeDecorator.newBuilder().build()) + .build() + + return Tracing.newBuilder() + .localServiceName("grpc-server") + .currentTraceContext(currentTraceContext) + .build() + } @Bean - open fun grpcServerBraveInterceptor(grpcTracing: GrpcTracing): ServerInterceptor = - grpcTracing.newServerInterceptor() + open fun rpcTracing(tracing: Tracing): RpcTracing = RpcTracing.create(tracing) @Bean - open fun defaultSampler(): Sampler = Sampler.ALWAYS_SAMPLE + open fun grpcTracing(rpcTracing: RpcTracing): GrpcTracing = GrpcTracing.create(rpcTracing) + + @Bean + open fun grpcServerBraveInterceptor(grpcTracing: GrpcTracing): ServerInterceptor = grpcTracing.newServerInterceptor() } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/hot/AutoReloadbleConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/hot/AutoReloadbleConfig.kt index be60bc7bf..73832205b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/hot/AutoReloadbleConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/hot/AutoReloadbleConfig.kt @@ -1,8 +1,10 @@ package io.emeraldpay.dshackle.config.hot import io.emeraldpay.dshackle.Global +import org.apache.hc.client5.http.classic.methods.HttpGet +import org.apache.hc.client5.http.impl.classic.HttpClients +import org.apache.hc.core5.http.io.entity.EntityUtils import org.slf4j.LoggerFactory -import org.springframework.web.client.RestTemplate import reactor.core.publisher.Flux import java.time.Duration import java.util.concurrent.atomic.AtomicReference @@ -17,12 +19,13 @@ class AutoReloadbleConfig( private val log = LoggerFactory.getLogger(AutoReloadbleConfig::class.java) } - private val restTemplate = RestTemplate() + private val httpClient = HttpClients.createDefault() private val instance = AtomicReference() fun reload() { try { - val response = restTemplate.getForObject(configUrl, String::class.java) + val getReq = HttpGet(configUrl) + val response = httpClient.execute(getReq) { resp -> EntityUtils.toString(resp.entity) } if (response != null) { instance.set(parseConfig(response)) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt index 1d2445675..10244f6fb 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt @@ -5,16 +5,16 @@ import com.fasterxml.jackson.annotation.JsonProperty @JsonIgnoreProperties(ignoreUnknown = true) data class CompatibleVersionsRules( - @JsonProperty("rules") + @param:JsonProperty("rules") val rules: List, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CompatibleVersionsRule( - @JsonProperty("client") + @param:JsonProperty("client") val client: String, - @JsonProperty("blacklist") + @param:JsonProperty("blacklist") val blacklist: List?, - @JsonProperty("whitelist") + @param:JsonProperty("whitelist") val whitelist: List?, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/spans/Export.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/spans/Export.kt deleted file mode 100644 index 204cec26e..000000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/spans/Export.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.emeraldpay.dshackle.config.spans - -import brave.handler.MutableSpan -import io.emeraldpay.dshackle.commons.SPAN_ERROR -import io.emeraldpay.dshackle.commons.SPAN_NO_RESPONSE_MESSAGE -import org.springframework.beans.factory.annotation.Value -import org.springframework.stereotype.Component -import java.util.concurrent.TimeUnit.MICROSECONDS -import java.util.concurrent.TimeUnit.MILLISECONDS - -interface SpanExportable { - fun isExportable(span: MutableSpan): Boolean -} - -@Component -class ErrorSpanExportable : SpanExportable { - override fun isExportable(span: MutableSpan): Boolean = span.tags().containsKey(SPAN_ERROR) -} - -@Component -class NoResponseSpanExportable : SpanExportable { - override fun isExportable(span: MutableSpan): Boolean = span.tags().containsKey(SPAN_NO_RESPONSE_MESSAGE) -} - -@Component -class LongResponseSpanExportable( - @Value("\${spans.collect.long-span-threshold}") - private val longSpanThreshold: Long? = null, -) : SpanExportable { - - override fun isExportable(span: MutableSpan): Boolean { - return MILLISECONDS.convert( - span.finishTimestamp() - span.startTimestamp(), - MICROSECONDS, - ) >= longSpanThreshold!! - } -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/spans/ProviderSpanHandler.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/spans/ProviderSpanHandler.kt deleted file mode 100644 index 2f0c3dd0a..000000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/spans/ProviderSpanHandler.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.emeraldpay.dshackle.config.spans - -import brave.handler.MutableSpan -import brave.handler.SpanHandler -import brave.propagation.TraceContext -import com.github.benmanes.caffeine.cache.Caffeine -import org.slf4j.LoggerFactory -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.stereotype.Component -import java.time.Duration - -@Component -@ConditionalOnProperty(value = ["spring.zipkin.enabled"], havingValue = "true") -class ProviderSpanHandler( - private val spanExportableList: List, - private val zipkinSpanHandler: SpanHandler, -) : SpanHandler() { - private val log = LoggerFactory.getLogger(ProviderSpanHandler::class.java) - - private val spans = Caffeine - .newBuilder() - .expireAfterWrite(Duration.ofMinutes(2)) - .build>() - - override fun end(context: TraceContext, span: MutableSpan, cause: Cause): Boolean { - if (span.traceId().length > 20 && cause == Cause.FINISHED) { - val key = span.parentId() ?: span.id() - val spanList = spans.asMap().computeIfAbsent(key) { mutableListOf() } - spanList.add(span) - } - return false - } - - fun sendSpans(traceContext: TraceContext) { - try { - sendSpansInternal(traceContext) - } catch (e: Exception) { - log.warn("Error while handling and sending spans - ${e.message}") - } - } - - private fun sendSpansInternal(traceContext: TraceContext) { - val spansInfo = SpansInfo() - - processSpans(traceContext.spanIdString(), spansInfo) - - spansInfo.spans - .map { it.parentId() } - .forEach { - if (it != null) { - spans.invalidate(it) - } - } - - if (spansInfo.exportable) { - spansInfo.spans.forEach { - zipkinSpanHandler.end(traceContext, it, Cause.FINISHED) - } - } - } - - private fun processSpans(spanId: String, spansInfo: SpansInfo) { - val currentSpans: List? = spans.getIfPresent(spanId) - - currentSpans?.forEach { - processSpanInfo(it, spansInfo) - if (spanId != it.id()) { - processSpans(it.id(), spansInfo) - } - } - } - - private fun processSpanInfo(span: MutableSpan, spansInfo: SpansInfo) { - spansInfo.spans.add(span) - if (spanExportableList.any { it.isExportable(span) }) { - spansInfo.exportable = true - } - } - - private data class SpansInfo( - var exportable: Boolean = false, - val spans: MutableList = mutableListOf(), - ) -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/spans/ZipkinSSLCustomizer.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/spans/ZipkinSSLCustomizer.kt deleted file mode 100644 index ff908665b..000000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/spans/ZipkinSSLCustomizer.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.emeraldpay.dshackle.config.spans - -import io.emeraldpay.dshackle.config.MainConfig -import org.apache.http.conn.ssl.SSLConnectionSocketFactory -import org.apache.http.impl.client.HttpClients -import org.bouncycastle.openssl.PEMParser -import org.springframework.cloud.sleuth.zipkin2.ZipkinRestTemplateCustomizer -import org.springframework.http.HttpRequest -import org.springframework.http.client.ClientHttpRequestExecution -import org.springframework.http.client.ClientHttpRequestInterceptor -import org.springframework.http.client.ClientHttpResponse -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory -import org.springframework.stereotype.Component -import org.springframework.web.client.RestTemplate -import java.io.ByteArrayOutputStream -import java.io.StringReader -import java.nio.file.Files -import java.nio.file.Paths -import java.security.KeyFactory -import java.security.KeyStore -import java.security.SecureRandom -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import java.security.spec.PKCS8EncodedKeySpec -import java.util.zip.GZIPOutputStream -import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SSLContext - -@Component -class ZipkinSSLCustomizer(private val mainConfig: MainConfig) : ZipkinRestTemplateCustomizer { - override fun customizeTemplate(restTemplate: RestTemplate): RestTemplate { - return if (mainConfig.tls?.enabled == true) { - setupSSL(mainConfig.tls!!.certificate!!, mainConfig.tls!!.key!!) - } else { - restTemplate - }.apply { - interceptors.add(0, GZipInterceptor()) - } - } - - private fun setupSSL(certPath: String, privateKeyPath: String): RestTemplate { - // Load the certificate - val certificateReader = StringReader(Files.readString(Paths.get(certPath))) - val pemObject = PEMParser(certificateReader).readPemObject() - val certificate = CertificateFactory.getInstance("X.509").generateCertificate(pemObject.content.inputStream()) as X509Certificate - // Load the private key - val privateKeyReader = StringReader(Files.readString(Paths.get(privateKeyPath))) - val pemKeyPair = PEMParser(privateKeyReader).readPemObject() - val privKeySpec = PKCS8EncodedKeySpec(pemKeyPair.content) - val privateKey = KeyFactory.getInstance("RSA").generatePrivate(privKeySpec) - - // Create the key store - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { - load(null, null) - setCertificateEntry("certificate", certificate) - setKeyEntry("private-key", privateKey, null, arrayOf(certificate)) - } - - // Create the SSL context - val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { - init(keyStore, null) - } - val sslContext = SSLContext.getInstance("TLS").apply { - init(keyManagerFactory.keyManagers, null, SecureRandom()) - } - - // Create the HTTP client - val socketFactory = SSLConnectionSocketFactory(sslContext) - val httpClient = HttpClients.custom().setSSLSocketFactory(socketFactory).build() - - // Create the request factory - val requestFactory = HttpComponentsClientHttpRequestFactory(httpClient) - - // Create the RestTemplate - return RestTemplate(requestFactory) - } - - private class GZipInterceptor : ClientHttpRequestInterceptor { - override fun intercept( - request: HttpRequest, - body: ByteArray, - execution: ClientHttpRequestExecution, - ): ClientHttpResponse { - request.headers.add("Content-Encoding", "gzip") - val gzipped = ByteArrayOutputStream() - GZIPOutputStream(gzipped).use { compressor -> compressor.write(body) } - return execution.execute(request, gzipped.toByteArray()) - } - } -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/HealthCheckSetup.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/HealthCheckSetup.kt index 66632d3d7..59720c100 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/HealthCheckSetup.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/HealthCheckSetup.kt @@ -18,19 +18,18 @@ package io.emeraldpay.dshackle.monitoring import com.sun.net.httpserver.HttpServer import io.emeraldpay.dshackle.config.HealthConfig import io.emeraldpay.dshackle.upstream.MultistreamHolder +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import org.apache.hc.core5.http.HttpStatus import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import java.io.IOException import java.net.InetSocketAddress -import javax.annotation.PostConstruct -import javax.annotation.PreDestroy @Service class HealthCheckSetup( - @Autowired private val healthConfig: HealthConfig, - @Autowired private val multistreamHolder: MultistreamHolder, + private val healthConfig: HealthConfig, + private val multistreamHolder: MultistreamHolder, ) { companion object { @@ -63,9 +62,9 @@ class HealthCheckSetup( } val ok = response.ok val data = response.details.joinToString("\n") - val code = if (ok) HttpStatus.OK else HttpStatus.SERVICE_UNAVAILABLE - log.debug("Health check response: ${code.value()} ${code.reasonPhrase} $data") - httpExchange.sendResponseHeaders(code.value(), data.toByteArray().size.toLong()) + val code = if (ok) HttpStatus.SC_OK else HttpStatus.SC_SERVICE_UNAVAILABLE + log.debug("Health check response: $code $data") + httpExchange.sendResponseHeaders(code, data.toByteArray().size.toLong()) httpExchange.responseBody.use { os -> os.write(data.toByteArray()) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt index 878158e22..9dff631aa 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt @@ -26,10 +26,10 @@ import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics import io.micrometer.core.instrument.binder.system.ProcessorMetrics import io.micrometer.core.instrument.config.MeterFilter -import io.micrometer.prometheus.PrometheusConfig -import io.micrometer.prometheus.PrometheusMeterRegistry +import io.micrometer.prometheusmetrics.PrometheusConfig +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry +import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import java.io.IOException import java.net.InetAddress @@ -41,11 +41,10 @@ import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.zip.GZIPOutputStream -import javax.annotation.PostConstruct @Service class MonitoringSetup( - @Autowired private val monitoringConfig: MonitoringConfig, + private val monitoringConfig: MonitoringConfig, ) { companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerGrpc.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerGrpc.kt index 3c6a75509..efb54799b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerGrpc.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerGrpc.kt @@ -23,13 +23,12 @@ import io.grpc.ServerCall import io.grpc.ServerCallHandler import io.grpc.ServerInterceptor import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import java.time.Instant @Service class AccessHandlerGrpc( - @Autowired private val accessLogWriter: AccessLogWriter, + private val accessLogWriter: AccessLogWriter, ) : ServerInterceptor { companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerHttp.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerHttp.kt index eb805bfce..8294b569e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerHttp.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandlerHttp.kt @@ -5,7 +5,6 @@ import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.config.MainConfig import io.emeraldpay.dshackle.rpc.NativeCall import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import reactor.netty.http.server.HttpServerRequest import reactor.netty.http.websocket.WebsocketInbound @@ -20,8 +19,8 @@ import kotlin.concurrent.withLock */ @Service class AccessHandlerHttp( - @Autowired private val mainConfig: MainConfig, - @Autowired accessLogWriter: AccessLogWriter, + mainConfig: MainConfig, + accessLogWriter: AccessLogWriter, ) { companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt index bc33d3a68..798c6a164 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt @@ -17,6 +17,7 @@ package io.emeraldpay.dshackle.monitoring.accesslog import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.config.MainConfig +import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Repository @@ -28,7 +29,6 @@ import java.time.Instant import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import javax.annotation.PostConstruct @Repository class AccessLogWriter( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/proxy/HttpHandler.kt b/src/main/kotlin/io/emeraldpay/dshackle/proxy/HttpHandler.kt index def791dd5..cd06499e6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/proxy/HttpHandler.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/proxy/HttpHandler.kt @@ -24,9 +24,9 @@ import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.netty.buffer.ByteBuf import io.netty.buffer.Unpooled +import org.apache.hc.core5.http.HttpHeaders import org.reactivestreams.Publisher import org.slf4j.LoggerFactory -import org.springframework.http.HttpHeaders import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.netty.http.server.HttpServerRequest diff --git a/src/main/kotlin/io/emeraldpay/dshackle/proxy/ProxyServer.kt b/src/main/kotlin/io/emeraldpay/dshackle/proxy/ProxyServer.kt index 180f69c4d..4e7fdfbd9 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/proxy/ProxyServer.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/proxy/ProxyServer.kt @@ -28,7 +28,8 @@ import io.micrometer.core.instrument.Metrics import io.micrometer.core.instrument.Timer import io.netty.channel.ChannelHandler import io.netty.channel.ChannelHandlerContext -import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.MultiThreadIoEventLoopGroup +import io.netty.channel.nio.NioIoHandler import org.slf4j.LoggerFactory import reactor.netty.http.server.HttpServer import reactor.netty.http.server.HttpServerRoutes @@ -108,7 +109,7 @@ class ProxyServer( serverBuilder .route(this::setupRoutes) - .runOn(NioEventLoopGroup()) + .runOn(MultiThreadIoEventLoopGroup(NioIoHandler.newFactory())) .bindNow() } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt index e5aa53606..33bf5a4ca 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt @@ -16,12 +16,9 @@ package io.emeraldpay.dshackle.quorum import io.emeraldpay.dshackle.Global -import io.emeraldpay.dshackle.commons.API_READER -import io.emeraldpay.dshackle.commons.SPAN_NO_RESPONSE_MESSAGE import io.emeraldpay.dshackle.commons.SPAN_REQUEST_API_TYPE import io.emeraldpay.dshackle.commons.SPAN_REQUEST_UPSTREAM_ID import io.emeraldpay.dshackle.reader.RequestReader -import io.emeraldpay.dshackle.reader.SpannedReader import io.emeraldpay.dshackle.upstream.ApiSource import io.emeraldpay.dshackle.upstream.ChainCallUpstreamException import io.emeraldpay.dshackle.upstream.ChainException @@ -32,7 +29,6 @@ import io.emeraldpay.dshackle.upstream.error.UpstreamErrorHandler import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.slf4j.LoggerFactory -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.util.function.Tuple2 @@ -50,7 +46,6 @@ class QuorumRequestReader( private val apiControl: ApiSource, private val quorum: CallQuorum, signer: ResponseSigner?, - private val tracer: Tracer, ) : RequestReader(signer) { private val errorHandler = UpstreamErrorHandler @@ -58,7 +53,7 @@ class QuorumRequestReader( private val log = LoggerFactory.getLogger(QuorumRequestReader::class.java) } - constructor(apiControl: ApiSource, quorum: CallQuorum, tracer: Tracer) : this(apiControl, quorum, null, tracer) + constructor(apiControl: ApiSource, quorum: CallQuorum) : this(apiControl, quorum, null) override fun attempts(): AtomicInteger = apiControl.attempts() @@ -147,7 +142,7 @@ class QuorumRequestReader( SPAN_REQUEST_API_TYPE to apiReader.javaClass.name, SPAN_REQUEST_UPSTREAM_ID to api.getId(), ) - return SpannedReader(apiReader, tracer, API_READER, spanParams) + return apiReader .read(key) .flatMap { response -> log.trace("Received response from upstream ${api.getId()} for method ${key.method}") @@ -173,7 +168,7 @@ class QuorumRequestReader( } } - private fun withErrorResume(api: Upstream, key: ChainRequest): Function, Mono> { + private fun withErrorResume(api: Upstream, key: ChainRequest): Function, Mono> { return Function { src -> src.onErrorResume { err -> errorHandler.handle(api, key, err.message) @@ -232,7 +227,6 @@ class QuorumRequestReader( private fun noResponse(method: String, q: CallQuorum): Mono { return apiControl.upstreamsMatchesResponse()?.run { - tracer.currentSpan()?.tag(SPAN_NO_RESPONSE_MESSAGE, getFullCause()) val cause = getCause(method) ?: return Mono.error(RpcException(1, "No response for method $method", getFullCause())) if (cause.shouldReturnNull) { Mono.just( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt index 72302139e..9df74d250 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt @@ -1,7 +1,5 @@ package io.emeraldpay.dshackle.reader -import io.emeraldpay.dshackle.commons.BROADCAST_READER -import io.emeraldpay.dshackle.commons.SPAN_REQUEST_UPSTREAM_ID import io.emeraldpay.dshackle.quorum.CallQuorum import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest @@ -11,7 +9,6 @@ import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.error.UpstreamErrorHandler import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.slf4j.LoggerFactory -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.util.concurrent.atomic.AtomicInteger @@ -21,7 +18,6 @@ class BroadcastReader( matcher: Selector.Matcher, signer: ResponseSigner?, private val quorum: CallQuorum, - private val tracer: Tracer, ) : RequestReader(signer) { private val errorHandler = UpstreamErrorHandler private val internalMatcher = Selector.MultiMatcher( @@ -77,12 +73,7 @@ class BroadcastReader( key: ChainRequest, upstream: Upstream, ): Mono = - SpannedReader( - upstream.getIngressReader(), - tracer, - BROADCAST_READER, - mapOf(SPAN_REQUEST_UPSTREAM_ID to upstream.getId()), - ) + upstream.getIngressReader() .read(key) .map { BroadcastResponse(it, upstream) } .onErrorResume { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/CompoundReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/CompoundReader.kt index 722c7bfb3..8fc3ee947 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/CompoundReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/CompoundReader.kt @@ -25,7 +25,7 @@ import reactor.core.publisher.Mono * Composition of multiple readers. * Reader returns first value returned by any of the source readers by checking one by one until one of them returns a non-empty result. */ -class CompoundReader ( +class CompoundReader ( private vararg val readers: Reader, ) : Reader { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/EmptyReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/EmptyReader.kt index f25cc08ce..c81485f60 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/EmptyReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/EmptyReader.kt @@ -18,7 +18,7 @@ package io.emeraldpay.dshackle.reader import reactor.core.publisher.Mono -class EmptyReader : Reader { +class EmptyReader : Reader { override fun read(key: K): Mono { return Mono.empty() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/Reader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/Reader.kt index eec6fd9b0..3277c7cb2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/Reader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/Reader.kt @@ -18,7 +18,7 @@ package io.emeraldpay.dshackle.reader import reactor.core.publisher.Mono -interface Reader { +interface Reader { fun read(key: K): Mono } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/RekeyingReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/RekeyingReader.kt index 31c5244fc..37d078456 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/RekeyingReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/RekeyingReader.kt @@ -21,7 +21,7 @@ import java.util.function.Function /** * Reader wrapper that maps the input key from ne value to another (ex. convert from Long to String) */ -class RekeyingReader( +class RekeyingReader( /** * Mapping between original Key and Key supported by the reader */ diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt index 23492e085..f16d36c2b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt @@ -15,7 +15,6 @@ import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.dshackle.upstream.stream.Chunk -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Flux import java.util.concurrent.atomic.AtomicInteger @@ -72,10 +71,10 @@ interface RequestReaderFactory { class Default : RequestReaderFactory { override fun create(data: ReaderData): RequestReader { if (data.quorum is MaximumValueQuorum || data.quorum is BroadcastQuorum) { - return BroadcastReader(data.multistream.getAll(), data.upstreamFilter.matcher, data.signer, data.quorum, data.tracer) + return BroadcastReader(data.multistream.getAll(), data.upstreamFilter.matcher, data.signer, data.quorum) } val apis = data.multistream.getApiSource(data.upstreamFilter) - return QuorumRequestReader(apis, data.quorum, data.signer, data.tracer) + return QuorumRequestReader(apis, data.quorum, data.signer) } } @@ -84,6 +83,5 @@ interface RequestReaderFactory { val upstreamFilter: Selector.UpstreamFilter, val quorum: CallQuorum, val signer: ResponseSigner?, - val tracer: Tracer, ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/SpannedReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/SpannedReader.kt deleted file mode 100644 index b33896b93..000000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/SpannedReader.kt +++ /dev/null @@ -1,58 +0,0 @@ -package io.emeraldpay.dshackle.reader - -import io.emeraldpay.dshackle.commons.SPAN_ERROR -import io.emeraldpay.dshackle.commons.SPAN_READER_NAME -import io.emeraldpay.dshackle.commons.SPAN_READER_RESULT -import io.emeraldpay.dshackle.commons.SPAN_REQUEST_CANCELLED -import io.emeraldpay.dshackle.commons.SPAN_REQUEST_INFO -import io.emeraldpay.dshackle.commons.SPAN_STATUS_MESSAGE -import io.emeraldpay.dshackle.data.HashId -import io.emeraldpay.dshackle.upstream.ChainRequest -import org.springframework.cloud.sleuth.Tracer -import org.springframework.cloud.sleuth.instrument.reactor.ReactorSleuth -import reactor.core.publisher.Mono -import reactor.kotlin.core.publisher.switchIfEmpty - -class SpannedReader( - private val reader: Reader, - private val tracer: Tracer, - private val name: String, - private val additionalParams: Map = emptyMap(), -) : Reader { - - override fun read(key: K): Mono { - val newSpan = tracer.nextSpan(tracer.currentSpan()) - .name(reader.javaClass.name) - .tag(SPAN_READER_NAME, name) - .start() - - extractInfoFromKey(key)?.let { - newSpan.tag(SPAN_REQUEST_INFO, it) - } - additionalParams.forEach { newSpan.tag(it.key, it.value) } - - return reader.read(key) - .contextWrite { ReactorSleuth.putSpanInScope(tracer, it, newSpan) } - .doOnError { - newSpan.tag(SPAN_ERROR, "true") - .tag(SPAN_STATUS_MESSAGE, it.message) - .end() - } - .doOnNext { newSpan.end() } - .doOnCancel { - newSpan.tag(SPAN_STATUS_MESSAGE, SPAN_REQUEST_CANCELLED).end() - } - .switchIfEmpty { - newSpan.tag(SPAN_READER_RESULT, "empty result").end() - Mono.empty() - } - } - - private fun extractInfoFromKey(key: K): String? { - return when (key) { - is ChainRequest -> "method: ${key.method}" - is HashId, Long -> "params: $key" - else -> null - } - } -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/TransformingReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/TransformingReader.kt index 4c408b1cc..a43d469f3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/TransformingReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/TransformingReader.kt @@ -21,7 +21,7 @@ import java.util.function.Function /** * Reader wrapper that transforms output of the reader to a different format */ -class TransformingReader( +class TransformingReader( /** * Actual reader */ diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt index 3e3100137..65a261921 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt @@ -16,19 +16,16 @@ */ package io.emeraldpay.dshackle.rpc -import brave.Tracer import io.emeraldpay.api.proto.BlockchainOuterClass import io.emeraldpay.api.proto.Common import io.emeraldpay.api.proto.ReactorBlockchainGrpc import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.ChainValue -import io.emeraldpay.dshackle.config.spans.ProviderSpanHandler import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.Metrics import io.micrometer.core.instrument.Timer import org.apache.commons.lang3.RandomStringUtils import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.annotation.DependsOn import org.springframework.stereotype.Service @@ -47,11 +44,8 @@ class BlockchainRpc( private val describe: Describe, private val subscribeStatus: SubscribeStatus, private val subscribeNodeStatus: SubscribeNodeStatus, - @Qualifier("rpcScheduler") + @param:Qualifier("rpcScheduler") private val scheduler: Scheduler, - @Autowired(required = false) - private val providerSpanHandler: ProviderSpanHandler?, - private val tracer: Tracer, private val subscribeChainStatus: SubscribeChainStatus, ) : ReactorBlockchainGrpc.BlockchainImplBase() { @@ -100,10 +94,6 @@ class BlockchainRpc( } }.doOnError { failMetric.increment() - }.doFinally { - tracer.currentSpan()?.run { - providerSpanHandler?.sendSpans(this.context()) - } } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt index ea922ad5b..8d5ab1bfc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt @@ -21,14 +21,13 @@ import io.emeraldpay.api.proto.Common import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.upstream.Capability import io.emeraldpay.dshackle.upstream.MultistreamHolder -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import reactor.core.publisher.Mono @Service class Describe( - @Autowired private val multistreamHolder: MultistreamHolder, - @Autowired private val subscribeStatus: SubscribeStatus, + private val multistreamHolder: MultistreamHolder, + private val subscribeStatus: SubscribeStatus, ) { fun describe(requestMono: Mono): Mono { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt index 31ba610d0..32169a30a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt @@ -25,19 +25,12 @@ import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.Global.Companion.nullValue import io.emeraldpay.dshackle.SilentException -import io.emeraldpay.dshackle.commons.LOCAL_READER -import io.emeraldpay.dshackle.commons.RPC_READER -import io.emeraldpay.dshackle.commons.SPAN_ERROR -import io.emeraldpay.dshackle.commons.SPAN_REQUEST_CANCELLED -import io.emeraldpay.dshackle.commons.SPAN_REQUEST_ID -import io.emeraldpay.dshackle.commons.SPAN_STATUS_MESSAGE import io.emeraldpay.dshackle.config.MainConfig import io.emeraldpay.dshackle.quorum.CallQuorum import io.emeraldpay.dshackle.quorum.NotLaggingQuorum import io.emeraldpay.dshackle.reader.RequestReader import io.emeraldpay.dshackle.reader.RequestReaderFactory import io.emeraldpay.dshackle.reader.RequestReaderFactory.ReaderData -import io.emeraldpay.dshackle.reader.SpannedReader import io.emeraldpay.dshackle.upstream.ApiSource import io.emeraldpay.dshackle.upstream.ChainCallError import io.emeraldpay.dshackle.upstream.ChainException @@ -61,21 +54,16 @@ import io.micrometer.core.instrument.Metrics import org.apache.commons.lang3.StringUtils import org.reactivestreams.Publisher import org.slf4j.LoggerFactory -import org.springframework.cloud.sleuth.Span -import org.springframework.cloud.sleuth.Tracer -import org.springframework.cloud.sleuth.instrument.reactor.ReactorSleuth import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.toMono -import reactor.util.context.Context @Service open class NativeCall( private val multistreamHolder: MultistreamHolder, private val signer: ResponseSigner, config: MainConfig, - private val tracer: Tracer, ) { private val log = LoggerFactory.getLogger(NativeCall::class.java) @@ -89,16 +77,14 @@ open class NativeCall( return nativeCallResult(requestMono) .flatMapSequential(this::processCallResult) .onErrorResume(this::processException) + .contextCapture() } open fun nativeCallResult(requestMono: Mono): Flux { - val requestSpan = tracer.currentSpan() return requestMono.flatMapMany(this::prepareCall) .flatMap { - val requestId = it.requestId - val requestCount = it.requestCount val id = it.getContextId() - val result = processCallContext(it, requestSpan) + val result = processCallContext(it) return@flatMap result .onErrorResume { err -> @@ -122,12 +108,7 @@ open class NativeCall( } } } - completeSpan(callRes, requestCount) } - .doOnCancel { - tracer.currentSpan()?.tag(SPAN_STATUS_MESSAGE, SPAN_REQUEST_CANCELLED)?.end() - } - .contextWrite { ctx -> createTracingReactorContext(ctx, requestCount, requestId, requestSpan) } } } @@ -178,42 +159,8 @@ open class NativeCall( return builder } - private fun completeSpan(callResult: CallResult, requestCount: Int) { - val span = tracer.currentSpan() - if (callResult.isError()) { - errorSpan(span, callResult.error?.message ?: "Internal error") - } - if (requestCount > 1) { - span?.end() - } - } - - private fun errorSpan(span: Span?, message: String) { - span?.apply { - tag(SPAN_ERROR, "true") - tag(SPAN_STATUS_MESSAGE, message) - } - } - - private fun createTracingReactorContext( - ctx: Context, - requestCount: Int, - requestId: String, - requestSpan: Span?, - ): Context { - if (requestCount > 1) { - val span = tracer.nextSpan(requestSpan) - .name("emerald.blockchain/nativecall") - .tag(SPAN_REQUEST_ID, requestId) - .start() - return ReactorSleuth.putSpanInScope(tracer, ctx, span) - } - return ctx - } - private fun processCallContext( callContext: CallContext, - requestSpan: Span?, ): Mono { return if (callContext.isValid()) { run { @@ -222,9 +169,6 @@ open class NativeCall( } catch (e: Exception) { return@run Mono.error(e) } - if (callContext.requestCount == 1 && callContext.requestId.isNotBlank()) { - requestSpan?.tag(SPAN_REQUEST_ID, callContext.requestId) - } this.fetch(parsed) .doOnError { e -> log.warn("Error during native call: ${e.message}") } } @@ -306,7 +250,6 @@ open class NativeCall( 0 } val message = it?.message ?: "Internal error" - errorSpan(tracer.currentSpan(), message) return BlockchainOuterClass.NativeCallReplyItem.newBuilder() .setSucceed(false) .setErrorMessage(message) @@ -466,7 +409,7 @@ open class NativeCall( fun fetch(ctx: ValidCallContext): Mono { return ctx.upstream.getLocalReader() .flatMap { api -> - SpannedReader(api, tracer, LOCAL_READER) + api .read(ctx.payload.toChainRequest(ctx.nonce, ctx.forwardedSelector, false, ctx.upstreamFilter)) .map { val result = it.getResult() @@ -503,7 +446,7 @@ open class NativeCall( return Mono.error(RpcException(RpcResponseError.CODE_METHOD_NOT_EXIST, "Unsupported method")) } val reader = requestReaderFactory.create( - ReaderData(ctx.upstream, ctx.upstreamFilter, ctx.callQuorum, signer, tracer), + ReaderData(ctx.upstream, ctx.upstreamFilter, ctx.callQuorum, signer), ) val counter = reader.attempts() val isRipple = ctx.upstream.getChain() in listOf(Chain.RIPPLE__MAINNET, Chain.RIPPLE__TESTNET) @@ -512,7 +455,7 @@ open class NativeCall( streamRequest = false } - return SpannedReader(reader, tracer, RPC_READER) + return reader .read(ctx.payload.toChainRequest(ctx.nonce, ctx.forwardedSelector, streamRequest)) .map { val resolvedUpstreamData = it.resolvedUpstreamData.ifEmpty { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeSubscribe.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeSubscribe.kt index 86135c8be..f33317564 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeSubscribe.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeSubscribe.kt @@ -31,7 +31,6 @@ import io.grpc.Status import io.grpc.StatusException import org.reactivestreams.Publisher import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -39,8 +38,8 @@ import java.util.concurrent.atomic.AtomicLong @Service open class NativeSubscribe( - @Autowired private val multistreamHolder: MultistreamHolder, - @Autowired private val signer: ResponseSigner, + private val multistreamHolder: MultistreamHolder, + private val signer: ResponseSigner, ) { companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt index f72d75d21..c711ddf6b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt @@ -26,14 +26,13 @@ import io.emeraldpay.dshackle.upstream.MultistreamHolder import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Mono @Service class StreamHead( - @Autowired private val multistreamHolder: MultistreamHolder, + private val multistreamHolder: MultistreamHolder, ) { private val log = LoggerFactory.getLogger(StreamHead::class.java) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CurrentMultistreamHolder.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CurrentMultistreamHolder.kt index c3d340564..df499dd5f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CurrentMultistreamHolder.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CurrentMultistreamHolder.kt @@ -17,9 +17,9 @@ package io.emeraldpay.dshackle.upstream import io.emeraldpay.dshackle.Chain +import jakarta.annotation.PreDestroy import org.slf4j.LoggerFactory import org.springframework.stereotype.Component -import javax.annotation.PreDestroy @Component open class CurrentMultistreamHolder( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/IngressSubscription.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/IngressSubscription.kt index af1205c9b..014f0ebfd 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/IngressSubscription.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/IngressSubscription.kt @@ -21,5 +21,5 @@ package io.emeraldpay.dshackle.upstream interface IngressSubscription { fun getAvailableTopics(): List - fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect? + fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect? } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/NoIngressSubscription.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/NoIngressSubscription.kt index 32b1e5d02..b429ed910 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/NoIngressSubscription.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/NoIngressSubscription.kt @@ -25,7 +25,7 @@ open class NoIngressSubscription : IngressSubscription { return listOf() } - override fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect? { + override fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect? { return null } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/SubscriptionConnect.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/SubscriptionConnect.kt index 34ec08acb..4ab001ec1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/SubscriptionConnect.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/SubscriptionConnect.kt @@ -20,7 +20,7 @@ import reactor.core.publisher.Flux /** * Note that T is supposed to be serializable as JSON */ -interface SubscriptionConnect { +interface SubscriptionConnect { fun connect(matcher: Selector.Matcher): Flux } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt index 1ddd05af4..2f021a73a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt @@ -54,7 +54,7 @@ abstract class BasicUpstreamSettingsDetector( protected abstract fun clientVersion(node: JsonNode): String? protected abstract fun clientType(node: JsonNode): String? - protected fun detectNodeType(): Flux?> { + protected fun detectNodeType(): Flux> { val nodeTypeRequest = nodeTypeRequest() return upstream .getIngressReader() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt index 69d043eaa..303b75b8e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt @@ -71,7 +71,7 @@ enum class ValidateUpstreamSettingsResult(val priority: Int) { UPSTREAM_FATAL_SETTINGS_ERROR(2), } -interface SingleValidator { +interface SingleValidator { fun validate(onError: T): Mono } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt index 319f93075..c1e6672be 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt @@ -153,23 +153,23 @@ class BeaconChainBlockHeaderDeserializer : JsonDeserializer castedRead(req: ChainRequest, clazz: Class): Mono { + fun castedRead(req: ChainRequest, clazz: Class): Mono { return upstreams.getDirectApi(Selector.empty).flatMap { api -> api.read(req) .flatMap(ChainResponse::requireResult) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/cosmos/CosmosChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/cosmos/CosmosChainSpecific.kt index ae6a8c8dd..064ff5f31 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/cosmos/CosmosChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/cosmos/CosmosChainSpecific.kt @@ -121,49 +121,49 @@ object CosmosChainSpecific : AbstractPollChainSpecific() { @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosBlockResult( - @JsonProperty("block_id") var blockId: CosmosBlockId, - @JsonProperty("block") var block: CosmosBlockData, + @param:JsonProperty("block_id") var blockId: CosmosBlockId, + @param:JsonProperty("block") var block: CosmosBlockData, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosBlockId( - @JsonProperty("hash") var hash: String, + @param:JsonProperty("hash") var hash: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosHeader( - @JsonProperty("last_block_id") var lastBlockId: CosmosBlockId, - @JsonProperty("height") var height: String, - @JsonProperty("time") var time: Instant, + @param:JsonProperty("last_block_id") var lastBlockId: CosmosBlockId, + @param:JsonProperty("height") var height: String, + @param:JsonProperty("time") var time: Instant, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosStatus( - @JsonProperty("node_info") var nodeInfo: CosmosNodeInfo, - @JsonProperty("sync_info") var syncInfo: CosmosSyncInfo, + @param:JsonProperty("node_info") var nodeInfo: CosmosNodeInfo, + @param:JsonProperty("sync_info") var syncInfo: CosmosSyncInfo, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosNodeInfo( - @JsonProperty("version") var version: String, - @JsonProperty("network") var network: String, + @param:JsonProperty("version") var version: String, + @param:JsonProperty("network") var network: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosSyncInfo( - @JsonProperty("earliest_block_height") var earliestBlockHeight: String, + @param:JsonProperty("earliest_block_height") var earliestBlockHeight: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosBlockEvent( - @JsonProperty("data") var data: CosmosBlockEventData, + @param:JsonProperty("data") var data: CosmosBlockEventData, ) @JsonIgnoreProperties(ignoreUnknown = true) data class CosmosBlockEventData( - @JsonProperty("value") var value: CosmosBlockData, + @param:JsonProperty("value") var value: CosmosBlockData, ) data class CosmosBlockData( - @JsonProperty("header") var header: CosmosHeader, + @param:JsonProperty("header") var header: CosmosHeader, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReader.kt index dbb16b8c7..89d1136ce 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReader.kt @@ -17,11 +17,6 @@ package io.emeraldpay.dshackle.upstream.ethereum import io.emeraldpay.dshackle.cache.Caches import io.emeraldpay.dshackle.cache.CurrentBlockCache -import io.emeraldpay.dshackle.commons.CACHE_BLOCK_BY_HASH_READER -import io.emeraldpay.dshackle.commons.CACHE_BLOCK_BY_HEIGHT_READER -import io.emeraldpay.dshackle.commons.CACHE_RECEIPTS_READER -import io.emeraldpay.dshackle.commons.CACHE_TX_BY_HASH_READER -import io.emeraldpay.dshackle.commons.DIRECT_QUORUM_RPC_READER import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.data.BlockId import io.emeraldpay.dshackle.data.TxContainer @@ -29,7 +24,6 @@ import io.emeraldpay.dshackle.data.TxId import io.emeraldpay.dshackle.reader.CompoundReader import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.reader.RekeyingReader -import io.emeraldpay.dshackle.reader.SpannedReader import io.emeraldpay.dshackle.upstream.CachingReader import io.emeraldpay.dshackle.upstream.Multistream import io.emeraldpay.dshackle.upstream.Selector @@ -43,7 +37,6 @@ import io.emeraldpay.dshackle.upstream.ethereum.domain.Wei import io.emeraldpay.dshackle.upstream.ethereum.json.TransactionLogJson import io.emeraldpay.dshackle.upstream.finalization.FinalizationType import org.apache.commons.collections4.Factory -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Mono import java.util.function.Function @@ -54,29 +47,28 @@ open class EthereumCachingReader( private val up: Multistream, private val caches: Caches, callMethodsFactory: Factory, - private val tracer: Tracer, ) : CachingReader { private val balanceCache = CurrentBlockCache() - private val directReader = EthereumDirectReader(up, caches, balanceCache, callMethodsFactory, tracer) + private val directReader = EthereumDirectReader(up, caches, balanceCache, callMethodsFactory) open fun blockByFinalization(): Reader> { - return SpannedReader(directReader.blockByFinalizationReader, tracer, DIRECT_QUORUM_RPC_READER) + return directReader.blockByFinalizationReader } open fun blocksByIdAsCont(upstreamFilter: Selector.UpstreamFilter): Reader> { val idToBlockHash = Function> { id -> Request(BlockHash.from(id.value), upstreamFilter) } return CompoundReader( - SpannedReader(CacheWithUpstreamIdReader(caches.getBlocksByHash()), tracer, CACHE_BLOCK_BY_HASH_READER), - SpannedReader(RekeyingReader(idToBlockHash, directReader.blockReader), tracer, DIRECT_QUORUM_RPC_READER), + CacheWithUpstreamIdReader(caches.getBlocksByHash()), + RekeyingReader(idToBlockHash, directReader.blockReader), ) } open fun blocksByHeightAsCont(upstreamFilter: Selector.UpstreamFilter): Reader> { val numToRequest = Function> { num -> Request(num, upstreamFilter) } return CompoundReader( - SpannedReader(CacheWithUpstreamIdReader(caches.getBlocksByHeight()), tracer, CACHE_BLOCK_BY_HEIGHT_READER), - SpannedReader(RekeyingReader(numToRequest, directReader.blockByHeightReader), tracer, DIRECT_QUORUM_RPC_READER), + CacheWithUpstreamIdReader(caches.getBlocksByHeight()), + RekeyingReader(numToRequest, directReader.blockByHeightReader), ) } @@ -87,8 +79,8 @@ open class EthereumCachingReader( open fun txByHashAsCont(upstreamFilter: Selector.UpstreamFilter): Reader> { val idToTxHash = Function> { id -> Request(TransactionId.from(id.value), upstreamFilter) } return CompoundReader( - CacheWithUpstreamIdReader(SpannedReader(caches.getTxByHash(), tracer, CACHE_TX_BY_HASH_READER)), - SpannedReader(RekeyingReader(idToTxHash, directReader.txReader), tracer, DIRECT_QUORUM_RPC_READER), + CacheWithUpstreamIdReader(caches.getTxByHash()), + RekeyingReader(idToTxHash, directReader.txReader), ) } @@ -106,8 +98,8 @@ open class EthereumCachingReader( directReader.receiptReader, ) return CompoundReader( - CacheWithUpstreamIdReader(SpannedReader(caches.getReceipts(), tracer, CACHE_RECEIPTS_READER)), - SpannedReader(requested, tracer, DIRECT_QUORUM_RPC_READER), + CacheWithUpstreamIdReader(caches.getReceipts()), + requested, ) } @@ -126,7 +118,7 @@ open class EthereumCachingReader( override fun stop() { } - private class CacheWithUpstreamIdReader( + private class CacheWithUpstreamIdReader( private val reader: Reader, ) : Reader> { override fun read(key: K): Mono> { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumChainSpecific.kt index bc57c9dc4..37d326833 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumChainSpecific.kt @@ -36,7 +36,6 @@ import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.slf4j.Logger import org.slf4j.LoggerFactory -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Mono import reactor.core.scheduler.Scheduler import java.math.BigInteger @@ -89,8 +88,8 @@ object EthereumChainSpecific : AbstractPollChainSpecific() { } } - override fun makeCachingReaderBuilder(tracer: Tracer): CachingReaderBuilder { - return { ms, caches, methodsFactory -> EthereumCachingReader(ms, caches, methodsFactory, tracer) } + override fun makeCachingReaderBuilder(): CachingReaderBuilder { + return { ms, caches, methodsFactory -> EthereumCachingReader(ms, caches, methodsFactory) } } override fun upstreamValidators( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt index a2e00178c..e3706228d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt @@ -37,7 +37,6 @@ import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.apache.commons.collections4.Factory import org.apache.commons.lang3.exception.ExceptionUtils import org.slf4j.LoggerFactory -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Mono import reactor.util.retry.Retry import java.time.Duration @@ -51,7 +50,6 @@ class EthereumDirectReader( private val caches: Caches, private val balanceCache: CurrentBlockCache, private val callMethodsFactory: Factory, - private val tracer: Tracer, ) { companion object { @@ -257,7 +255,6 @@ class EthereumDirectReader( Selector.UpstreamFilter(sort, matcher), callMethodsFactory.create().createQuorumFor(request.method), null, - tracer, ), ) }.flatMap { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt index 08e7aa503..e4131db52 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt @@ -35,7 +35,7 @@ class EthereumUpstreamSettingsDetector( ) } - private fun detectFlashBlocks(): Mono>? { + private fun detectFlashBlocks(): Mono> { return upstream.getIngressReader().read( ChainRequest( "eth_getBlockByNumber", diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/EthereumWsIngressSubscription.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/EthereumWsIngressSubscription.kt index 0114dd057..a7ed95704 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/EthereumWsIngressSubscription.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/EthereumWsIngressSubscription.kt @@ -34,7 +34,7 @@ class EthereumWsIngressSubscription( } @Suppress("UNCHECKED_CAST") - override fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect? { + override fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect? { if (topic == EthereumEgressSubscription.METHOD_PENDING_TXES) { return pendingTxes as SubscriptionConnect } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/AbstractChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/AbstractChainSpecific.kt index 12e3d753e..053d0c1e5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/AbstractChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/AbstractChainSpecific.kt @@ -30,7 +30,6 @@ import io.emeraldpay.dshackle.upstream.calls.CallSelector import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptions import io.emeraldpay.dshackle.upstream.finalization.FinalizationDetector import io.emeraldpay.dshackle.upstream.finalization.NoopFinalizationDetector -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Mono import reactor.core.scheduler.Scheduler import java.util.function.Supplier @@ -48,7 +47,7 @@ abstract class AbstractChainSpecific : ChainSpecific { return NoopFinalizationDetector() } - override fun makeCachingReaderBuilder(tracer: Tracer): CachingReaderBuilder { + override fun makeCachingReaderBuilder(): CachingReaderBuilder { return { _, _, _ -> NoopCachingReader } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt index d211f4fcf..d60ef4c5e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt @@ -48,7 +48,6 @@ import io.emeraldpay.dshackle.upstream.solana.SolanaChainSpecific import io.emeraldpay.dshackle.upstream.starknet.StarknetChainSpecific import io.emeraldpay.dshackle.upstream.ton.TonHttpSpecific import org.apache.commons.collections4.Factory -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Mono import reactor.core.scheduler.Scheduler import java.util.function.Supplier @@ -77,7 +76,7 @@ interface ChainSpecific { fun subscriptionBuilder(headScheduler: Scheduler): (Multistream) -> EgressSubscription - fun makeCachingReaderBuilder(tracer: Tracer): CachingReaderBuilder + fun makeCachingReaderBuilder(): CachingReaderBuilder fun validator( chain: Chain, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt index a48485842..b572b4361 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt @@ -26,7 +26,7 @@ class GenericIngressSubscription( private val holders = ConcurrentHashMap, SubscriptionConnect>() @Suppress("UNCHECKED_CAST") - override fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect { + override fun get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect { return holders.computeIfAbsent(topic to params) { key -> GenericSubscriptionConnect( chain, @@ -58,7 +58,7 @@ class GenericSubscriptionConnect( .flatMapMany { it.t2 } .timeout( Duration.ofSeconds(85), - Mono.empty().doOnEach { + Mono.empty().doOnEach { log.warn("Timeout during subscription to $topic after 85 seconds") }, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreamCreator.kt index 744adef05..012b69f2b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreamCreator.kt @@ -22,11 +22,11 @@ class GrpcUpstreamCreator( private val authorizationConfig: AuthorizationConfig, private val compressionConfig: CompressionConfig, private val fileResolver: FileResolver, - @Qualifier("grpcChannelExecutor") + @param:Qualifier("grpcChannelExecutor") private val channelExecutor: Executor, - private val grpcTracing: GrpcTracing, - @Qualifier("headScheduler") + @param:Qualifier("headScheduler") private val headScheduler: Scheduler, + private val grpcTracing: GrpcTracing, private val grpcAuthContext: GrpcAuthContext, ) { @Value("\${spring.application.max-metadata-size}") diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaChainSpecific.kt index d9ee996fd..87b79f232 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaChainSpecific.kt @@ -107,8 +107,8 @@ object KadenaChainSpecific : AbstractPollChainSpecific() { @JsonIgnoreProperties(ignoreUnknown = true) data class KadenaHeader( - @JsonProperty("height") var height: Long, - @JsonProperty("weight") var weight: String, - @JsonProperty("instance") var instance: String, - @JsonProperty("id") var id: String, + @param:JsonProperty("height") var height: Long, + @param:JsonProperty("weight") var weight: String, + @param:JsonProperty("instance") var instance: String, + @param:JsonProperty("id") var id: String, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaUpstreamSettingsDetector.kt index 0463f35f9..9d0bfc7c2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaUpstreamSettingsDetector.kt @@ -32,7 +32,7 @@ class KadenaUpstreamSettingsDetector( @JsonIgnoreProperties(ignoreUnknown = true) data class KadenaHeader( - @JsonProperty("instance") var instance: String, + @param:JsonProperty("instance") var instance: String, ) override fun nodeTypeRequest(): NodeTypeRequest = NodeTypeRequest(clientVersionRequest()) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearChainSpecific.kt index 2caeebe3d..0060725f6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearChainSpecific.kt @@ -119,26 +119,26 @@ object NearChainSpecific : AbstractPollChainSpecific() { @JsonIgnoreProperties(ignoreUnknown = true) data class NearBlock( - @JsonProperty("header") var header: NearHeader, + @param:JsonProperty("header") var header: NearHeader, ) @JsonIgnoreProperties(ignoreUnknown = true) data class NearHeader( - @JsonProperty("height") var height: Long, - @JsonProperty("hash") var hash: String, - @JsonProperty("prev_hash") var prevHash: String, - @JsonProperty("timestamp") var timestamp: Long, + @param:JsonProperty("height") var height: Long, + @param:JsonProperty("hash") var hash: String, + @param:JsonProperty("prev_hash") var prevHash: String, + @param:JsonProperty("timestamp") var timestamp: Long, ) @JsonIgnoreProperties(ignoreUnknown = true) data class NearStatus( - @JsonProperty("chain_id") var chainId: String, - @JsonProperty("sync_info") var syncInfo: NearSync, + @param:JsonProperty("chain_id") var chainId: String, + @param:JsonProperty("sync_info") var syncInfo: NearSync, ) @JsonIgnoreProperties(ignoreUnknown = true) data class NearSync( - @JsonProperty("syncing") var syncing: Boolean, - @JsonProperty("earliest_block_height") var earliestHeight: Long, - @JsonProperty("earliest_block_time") var earliestBlockTime: Instant, + @param:JsonProperty("syncing") var syncing: Boolean, + @param:JsonProperty("earliest_block_height") var earliestHeight: Long, + @param:JsonProperty("earliest_block_time") var earliestBlockTime: Instant, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearUpstreamSettingsDetector.kt index 120667fa1..34873424b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/near/NearUpstreamSettingsDetector.kt @@ -32,13 +32,13 @@ class NearUpstreamSettingsDetector( @JsonIgnoreProperties(ignoreUnknown = true) private data class NearVersionResponse( - @JsonProperty("version") + @param:JsonProperty("version") val nearVersion: NearVersion, ) @JsonIgnoreProperties(ignoreUnknown = true) private data class NearVersion( - @JsonProperty("version") + @param:JsonProperty("version") val version: String, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/polkadot/PolkadotChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/polkadot/PolkadotChainSpecific.kt index 17a9c8709..967cbab4e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/polkadot/PolkadotChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/polkadot/PolkadotChainSpecific.kt @@ -158,30 +158,30 @@ object PolkadotChainSpecific : AbstractPollChainSpecific() { @JsonIgnoreProperties(ignoreUnknown = true) data class PolkadotBlockResponse( - @JsonProperty("block") var block: PolkadotBlock, + @param:JsonProperty("block") var block: PolkadotBlock, ) @JsonIgnoreProperties(ignoreUnknown = true) data class PolkadotBlock( - @JsonProperty("header") var header: PolkadotHeader, + @param:JsonProperty("header") var header: PolkadotHeader, ) @JsonIgnoreProperties(ignoreUnknown = true) data class PolkadotHeader( - @JsonProperty("parentHash") var parentHash: String, - @JsonProperty("number") var number: String, - @JsonProperty("stateRoot") var stateRoot: String, - @JsonProperty("extrinsicsRoot") var extrinsicsRoot: String, - @JsonProperty("digest") var digest: PolkadotDigest, + @param:JsonProperty("parentHash") var parentHash: String, + @param:JsonProperty("number") var number: String, + @param:JsonProperty("stateRoot") var stateRoot: String, + @param:JsonProperty("extrinsicsRoot") var extrinsicsRoot: String, + @param:JsonProperty("digest") var digest: PolkadotDigest, ) data class PolkadotDigest( - @JsonProperty("logs") var logs: List, + @param:JsonProperty("logs") var logs: List, ) @JsonIgnoreProperties(ignoreUnknown = true) data class PolkadotHealth( - @JsonProperty("peers") var peers: Long, - @JsonProperty("isSyncing") var isSyncing: Boolean, - @JsonProperty("shouldHavePeers") var shouldHavePeers: Boolean, + @param:JsonProperty("peers") var peers: Long, + @param:JsonProperty("isSyncing") var isSyncing: Boolean, + @param:JsonProperty("shouldHavePeers") var shouldHavePeers: Boolean, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt index a2a869fff..4a77d2cec 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt @@ -3,7 +3,6 @@ package io.emeraldpay.dshackle.upstream.ripple import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.readValue import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.config.ChainsConfig.ChainConfig @@ -181,124 +180,124 @@ class RippleUpstreamSettingsDetector(val upstream: Upstream) : BasicUpstreamSett @JsonIgnoreProperties(ignoreUnknown = true) data class RippleInfoWrapper( - @JsonProperty("info") var info: RippleInfo, + @param:JsonProperty("info") var info: RippleInfo, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleInfo( - @JsonProperty("clio_version") var clio: String?, - @JsonProperty("build_version") var buildVersion: String?, + @param:JsonProperty("clio_version") var clio: String?, + @param:JsonProperty("build_version") var buildVersion: String?, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleBlock( - @JsonProperty("closed") var closed: RippleClosed, - @JsonProperty("open") var open: RippleOpen?, - @JsonProperty("status") var status: String?, + @param:JsonProperty("closed") var closed: RippleClosed, + @param:JsonProperty("open") var open: RippleOpen?, + @param:JsonProperty("status") var status: String?, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleClosed( - @JsonProperty("ledger") var ledger: RippleClosedLedger, + @param:JsonProperty("ledger") var ledger: RippleClosedLedger, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleOpen( - @JsonProperty("ledger") var ledger: RippleOpenLedger, + @param:JsonProperty("ledger") var ledger: RippleOpenLedger, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleClosedLedger( - @JsonProperty("account_hash") var accountHash: String, - @JsonProperty("close_flags") var closeFlags: Short, - @JsonProperty("close_time") var closeTime: Long, - @JsonProperty("close_time_human") var closeTimeHuman: String, - @JsonProperty("close_time_iso") var closeTimeIso: String, - @JsonProperty("close_time_resolution") var closeTimeResolution: Short, - @JsonProperty("closed") var closed: Boolean, - @JsonProperty("ledger_hash") var ledgerHash: String, - @JsonProperty("ledger_index") var ledgerIndex: String, - @JsonProperty("parent_close_time") var parentCloseTime: Long, - @JsonProperty("parent_hash") var parentHash: String, - @JsonProperty("total_coins") var totalCoins: String, - @JsonProperty("transaction_hash") var transactionHash: String, + @param:JsonProperty("account_hash") var accountHash: String, + @param:JsonProperty("close_flags") var closeFlags: Short, + @param:JsonProperty("close_time") var closeTime: Long, + @param:JsonProperty("close_time_human") var closeTimeHuman: String, + @param:JsonProperty("close_time_iso") var closeTimeIso: String, + @param:JsonProperty("close_time_resolution") var closeTimeResolution: Short, + @param:JsonProperty("closed") var closed: Boolean, + @param:JsonProperty("ledger_hash") var ledgerHash: String, + @param:JsonProperty("ledger_index") var ledgerIndex: String, + @param:JsonProperty("parent_close_time") var parentCloseTime: Long, + @param:JsonProperty("parent_hash") var parentHash: String, + @param:JsonProperty("total_coins") var totalCoins: String, + @param:JsonProperty("transaction_hash") var transactionHash: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleOpenLedger( - @JsonProperty("closed") var closed: Boolean, - @JsonProperty("ledger_index") var ledgerIndex: String, - @JsonProperty("parent_hash") var parentHash: String, + @param:JsonProperty("closed") var closed: Boolean, + @param:JsonProperty("ledger_index") var ledgerIndex: String, + @param:JsonProperty("parent_hash") var parentHash: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleState( - @JsonProperty("state") var state: RippleServerState, - @JsonProperty("status") var status: String? = null, + @param:JsonProperty("state") var state: RippleServerState, + @param:JsonProperty("status") var status: String? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) data class RippleServerState( - @JsonProperty("build_version") val buildVersion: String, - @JsonProperty("complete_ledgers") val completeLedgers: String, - @JsonProperty("initial_sync_duration_us") val initialSyncDurationUs: String?, - @JsonProperty("io_latency_ms") val ioLatencyMs: Int, - @JsonProperty("jq_trans_overflow") val jqTransOverflow: String, - @JsonProperty("last_close") val lastClose: LastClose, - @JsonProperty("load_base") val loadBase: Int, - @JsonProperty("load_factor") val loadFactor: Int, - @JsonProperty("load_factor_fee_escalation") val loadFactorFeeEscalation: Int, - @JsonProperty("load_factor_fee_queue") val loadFactorFeeQueue: Int, - @JsonProperty("load_factor_fee_reference") val loadFactorFeeReference: Int, - @JsonProperty("load_factor_server") val loadFactorServer: Int, - @JsonProperty("network_id") val networkId: Int, - @JsonProperty("peer_disconnects") val peerDisconnects: String?, - @JsonProperty("peer_disconnects_resources") val peerDisconnectsResources: String?, - @JsonProperty("peers") val peers: Int, - @JsonProperty("ports") val ports: List, - @JsonProperty("pubkey_node") val pubkeyNode: String?, - @JsonProperty("server_state") val serverState: String?, - @JsonProperty("server_state_duration_us") val serverStateDurationUs: String?, - @JsonProperty("state_accounting") val stateAccounting: StateAccounting?, - @JsonProperty("time") val time: String, - @JsonProperty("uptime") val uptime: Long, - @JsonProperty("validated_ledger") val validatedLedger: ValidatedLedger, - @JsonProperty("validation_quorum") val validationQuorum: Int, + @param:JsonProperty("build_version") val buildVersion: String, + @param:JsonProperty("complete_ledgers") val completeLedgers: String, + @param:JsonProperty("initial_sync_duration_us") val initialSyncDurationUs: String?, + @param:JsonProperty("io_latency_ms") val ioLatencyMs: Int, + @param:JsonProperty("jq_trans_overflow") val jqTransOverflow: String, + @param:JsonProperty("last_close") val lastClose: LastClose, + @param:JsonProperty("load_base") val loadBase: Int, + @param:JsonProperty("load_factor") val loadFactor: Int, + @param:JsonProperty("load_factor_fee_escalation") val loadFactorFeeEscalation: Int, + @param:JsonProperty("load_factor_fee_queue") val loadFactorFeeQueue: Int, + @param:JsonProperty("load_factor_fee_reference") val loadFactorFeeReference: Int, + @param:JsonProperty("load_factor_server") val loadFactorServer: Int, + @param:JsonProperty("network_id") val networkId: Int, + @param:JsonProperty("peer_disconnects") val peerDisconnects: String?, + @param:JsonProperty("peer_disconnects_resources") val peerDisconnectsResources: String?, + @param:JsonProperty("peers") val peers: Int, + @param:JsonProperty("ports") val ports: List, + @param:JsonProperty("pubkey_node") val pubkeyNode: String?, + @param:JsonProperty("server_state") val serverState: String?, + @param:JsonProperty("server_state_duration_us") val serverStateDurationUs: String?, + @param:JsonProperty("state_accounting") val stateAccounting: StateAccounting?, + @param:JsonProperty("time") val time: String, + @param:JsonProperty("uptime") val uptime: Long, + @param:JsonProperty("validated_ledger") val validatedLedger: ValidatedLedger, + @param:JsonProperty("validation_quorum") val validationQuorum: Int, ) @JsonIgnoreProperties(ignoreUnknown = true) data class LastClose( - @JsonProperty("converge_time") val convergeTime: Int, - @JsonProperty("proposers") val proposers: Int, + @param:JsonProperty("converge_time") val convergeTime: Int, + @param:JsonProperty("proposers") val proposers: Int, ) @JsonIgnoreProperties(ignoreUnknown = true) data class Port( - @JsonProperty("port") val port: String, - @JsonProperty("protocol") val protocol: List, + @param:JsonProperty("port") val port: String, + @param:JsonProperty("protocol") val protocol: List, ) @JsonIgnoreProperties(ignoreUnknown = true) data class StateAccounting( - @JsonProperty("connected") val connected: StateDuration, - @JsonProperty("disconnected") val disconnected: StateDuration, - @JsonProperty("full") val full: StateDuration, - @JsonProperty("syncing") val syncing: StateDuration, - @JsonProperty("tracking") val tracking: StateDuration, + @param:JsonProperty("connected") val connected: StateDuration, + @param:JsonProperty("disconnected") val disconnected: StateDuration, + @param:JsonProperty("full") val full: StateDuration, + @param:JsonProperty("syncing") val syncing: StateDuration, + @param:JsonProperty("tracking") val tracking: StateDuration, ) @JsonIgnoreProperties(ignoreUnknown = true) data class StateDuration( - @JsonProperty("duration_us") val durationUs: String, - @JsonProperty("transitions") val transitions: String, + @param:JsonProperty("duration_us") val durationUs: String, + @param:JsonProperty("transitions") val transitions: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class ValidatedLedger( - @JsonProperty("base_fee") val baseFee: Int, - @JsonProperty("close_time") val closeTime: Long, - @JsonProperty("hash") val hash: String, - @JsonProperty("reserve_base") val reserveBase: Long, - @JsonProperty("reserve_inc") val reserveInc: Long, - @JsonProperty("seq") val seq: Long, + @param:JsonProperty("base_fee") val baseFee: Int, + @param:JsonProperty("close_time") val closeTime: Long, + @param:JsonProperty("hash") val hash: String, + @param:JsonProperty("reserve_base") val reserveBase: Long, + @param:JsonProperty("reserve_inc") val reserveInc: Long, + @param:JsonProperty("seq") val seq: Long, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt index 22690b532..59ed24264 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt @@ -8,9 +8,9 @@ import org.bouncycastle.math.ec.ECPoint import org.bouncycastle.util.io.pem.PemObject import org.bouncycastle.util.io.pem.PemReader import org.slf4j.LoggerFactory -import org.springframework.beans.factory.FactoryBean -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Repository +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.stereotype.Component import java.nio.ByteBuffer import java.nio.file.Files import java.nio.file.Path @@ -20,10 +20,17 @@ import java.security.PublicKey import java.security.interfaces.ECPrivateKey import java.security.spec.PKCS8EncodedKeySpec -@Repository +@Configuration +open class SignatureBeans { + @Bean + open fun signer(factory: ResponseSignerFactory): ResponseSigner = + factory.createSigner() +} + +@Component open class ResponseSignerFactory( - @Autowired private val config: SignatureConfig, -) : FactoryBean { + private val signatureConfig: SignatureConfig, +) { companion object { private val log = LoggerFactory.getLogger(ResponseSignerFactory::class.java) @@ -70,19 +77,15 @@ open class ResponseSignerFactory( return ByteBuffer.wrap(fullId).asLongBuffer().get() } - override fun getObject(): ResponseSigner { - if (!config.enabled) { + fun createSigner(): ResponseSigner { + if (!signatureConfig.enabled) { return NoSigner() } - if (config.privateKey == null) { + if (signatureConfig.privateKey == null) { log.warn("Private Key for response signature is not set") return NoSigner() } - val key = readKey(config.algorithm, config.privateKey!!) + val key = readKey(signatureConfig.algorithm, signatureConfig.privateKey!!) return EcdsaSigner(key.first, key.second) } - - override fun getObjectType(): Class<*>? { - return ResponseSigner::class.java - } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt index 62e25582b..d4a09f75e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt @@ -167,24 +167,24 @@ object SolanaChainSpecific : AbstractChainSpecific() { @JsonIgnoreProperties(ignoreUnknown = true) data class SolanaWrapper( - @JsonProperty("context") var context: SolanaContext, - @JsonProperty("value") var value: SolanaResult, + @param:JsonProperty("context") var context: SolanaContext, + @param:JsonProperty("value") var value: SolanaResult, ) @JsonIgnoreProperties(ignoreUnknown = true) data class SolanaContext( - @JsonProperty("slot") var slot: Long, + @param:JsonProperty("slot") var slot: Long, ) @JsonIgnoreProperties(ignoreUnknown = true) data class SolanaResult( - @JsonProperty("block") var block: SolanaBlock, + @param:JsonProperty("block") var block: SolanaBlock, ) @JsonIgnoreProperties(ignoreUnknown = true) data class SolanaBlock( - @JsonProperty("blockHeight") var height: Long, - @JsonProperty("blockTime") var timestamp: Long, - @JsonProperty("blockhash") var hash: String, - @JsonProperty("previousBlockhash") var parent: String, + @param:JsonProperty("blockHeight") var height: Long, + @param:JsonProperty("blockTime") var timestamp: Long, + @param:JsonProperty("blockhash") var hash: String, + @param:JsonProperty("previousBlockhash") var parent: String, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaUpstreamSettingsDetector.kt index b0e1ee99e..b118a58fe 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaUpstreamSettingsDetector.kt @@ -32,7 +32,7 @@ class SolanaUpstreamSettingsDetector( @JsonIgnoreProperties(ignoreUnknown = true) private data class SolanaVersion( - @JsonProperty("solana-core") + @param:JsonProperty("solana-core") val version: String, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetChainSpecific.kt index 5e508e9bb..94b23f3da 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetChainSpecific.kt @@ -119,14 +119,14 @@ object StarknetChainSpecific : AbstractPollChainSpecific() { @JsonIgnoreProperties(ignoreUnknown = true) data class StarknetBlock( - @JsonProperty("block_hash") var hash: String, - @JsonProperty("block_number") var number: Long, - @JsonProperty("timestamp") var timestamp: Instant, - @JsonProperty("parent_hash") var parent: String, + @param:JsonProperty("block_hash") var hash: String, + @param:JsonProperty("block_number") var number: Long, + @param:JsonProperty("timestamp") var timestamp: Instant, + @param:JsonProperty("parent_hash") var parent: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class StarknetSyncing( - @JsonProperty("current_block_num") var current: Long, - @JsonProperty("highest_block_num") var highest: Long, + @param:JsonProperty("current_block_num") var current: Long, + @param:JsonProperty("highest_block_num") var highest: Long, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetUpstreamSettingsDetector.kt index b77509125..2ba903034 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetUpstreamSettingsDetector.kt @@ -18,7 +18,7 @@ class StarknetUpstreamSettingsDetector( ) } - private fun detectNodeType(): Flux?> { + private fun detectNodeType(): Flux> { return upstream .getIngressReader() .read(pathfinderVersionRequest()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index db5bfba84..336256b85 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,26 +1,9 @@ + + spring: application: max-metadata-size: ${MAX_METADATA_SIZE:16384} name: ${DSHACKLE_APP_NAME:dshackle} - zipkin: - enabled: ${ENABLE_COLLECT_SPANS:false} - base-url: ${ZIPKIN_URL:https://trace.drpc.dev/} - sleuth: - span-filter: - additional-span-name-patterns-to-ignore: - - "^grpcChannelExecutor$" - - ".+NativeSubscribe" - - ".+SubscribeHead" - - ".+SubscribeBalance" - - ".+SubscribeTxStatus" - - ".+SubscribeStatus" - - ".+SubscribeNodeStatus" - - ".+Describe" - - ".+ServerReflectionInfo" - -spans: - collect: - long-span-threshold: ${LONG_SPAN_THRESHOLD:1000} compatibility: enabled: ${DSHACKLE_COMPATIBILITY_ENABLED:true} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 7bd936616..af4bb0cdb 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -4,7 +4,7 @@ + value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) [%X{traceId},%X{spanId}] %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/> diff --git a/src/test/groovy/io/emeraldpay/dshackle/quorum/QuorumRequestReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/quorum/QuorumRequestReaderSpec.groovy index cd2449719..4c334363a 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/quorum/QuorumRequestReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/quorum/QuorumRequestReaderSpec.groovy @@ -29,7 +29,6 @@ import io.emeraldpay.dshackle.upstream.UpstreamAvailability import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Mono import reactor.test.StepVerifier import spock.lang.Specification @@ -55,7 +54,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new AlwaysQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new AlwaysQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -87,7 +86,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new AlwaysQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new AlwaysQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -125,7 +124,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new AlwaysQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new AlwaysQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -158,7 +157,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new NotNullQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new NotNullQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -191,7 +190,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new NotNullQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new NotNullQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -224,7 +223,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new NotNullQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new NotNullQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -255,7 +254,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new AlwaysQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new AlwaysQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -289,7 +288,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new NotLaggingQuorum(1), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new NotLaggingQuorum(1)) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -315,7 +314,7 @@ class QuorumRequestReaderSpec extends Specification { Chain.ETHEREUM__MAINNET, [up], Selector.empty ) - def reader = new QuorumRequestReader(apis, new AlwaysQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new AlwaysQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) @@ -365,7 +364,7 @@ class QuorumRequestReaderSpec extends Specification { new Selector.HeightMatcher(100000000), ) )) - def reader = new QuorumRequestReader(apis, new AlwaysQuorum(), Stub(Tracer)) + def reader = new QuorumRequestReader(apis, new AlwaysQuorum()) when: def act = reader.read(new ChainRequest("eth_test", new ListParams())) diff --git a/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy index 37caa0e2c..17c487d1b 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy @@ -7,7 +7,6 @@ import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.rpcclient.ListParams -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Mono import reactor.test.StepVerifier import spock.lang.Specification @@ -43,7 +42,7 @@ class BroadcastReaderSpec extends Specification { Mono.just(new ChainResponse(result, null)) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum(), Stub(Tracer)) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -81,7 +80,7 @@ class BroadcastReaderSpec extends Specification { 1 * read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) >> Mono.error(new ChainException(1, "too low")) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum(), Stub(Tracer)) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -114,7 +113,7 @@ class BroadcastReaderSpec extends Specification { 0 * getId() >> "id" 0 * getIngressReader() >> Mock(Reader) } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum(), Stub(Tracer)) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -152,7 +151,7 @@ class BroadcastReaderSpec extends Specification { Mono.error(new ChainException(1, "too low")) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum(), Stub(Tracer)) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -178,7 +177,7 @@ class BroadcastReaderSpec extends Specification { 0 * getId() >> "id" 0 * getIngressReader() >> Mock(Reader) } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum(), Stub(Tracer)) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) when: def act = reader .read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy index ff32787b9..8815e2e35 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy @@ -39,7 +39,6 @@ import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.test.StepVerifier @@ -67,7 +66,7 @@ class NativeCallSpec extends Specification { config.cache = cacheConfig config.passthrough = passthrough - new NativeCall(upstreams, signer, config, Stub(Tracer)) + new NativeCall(upstreams, signer, config) } def "Tries router first"() { diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy index 2b69bed3e..3cd3d090f 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy @@ -45,10 +45,6 @@ class NativeSubscribeSpec extends Specification { } def up = Mock(GenericMultistream) { 1 * it.start() - _ * it.getSubscriptionTopics() >> { - println("getSubscriptionTopics called") - return ["newHeads"] - } _ * it.tryProxySubscribe(_, _) >> { println("tryProxySubscribe called") return null diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/MultistreamHolderMock.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/MultistreamHolderMock.groovy index ee98b05e9..c2ab614f8 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/test/MultistreamHolderMock.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/test/MultistreamHolderMock.groovy @@ -28,7 +28,6 @@ import io.emeraldpay.dshackle.upstream.calls.DefaultEthereumMethods import io.emeraldpay.dshackle.upstream.ethereum.EthereumCachingReader import io.emeraldpay.dshackle.upstream.ethereum.EthereumChainSpecific import org.jetbrains.annotations.NotNull -import org.springframework.cloud.sleuth.brave.bridge.BraveTracer import reactor.core.scheduler.Schedulers class MultistreamHolderMock implements MultistreamHolder { @@ -49,7 +48,7 @@ class MultistreamHolderMock implements MultistreamHolder { upstreams[chain] = new GenericMultistream( chain, Schedulers.immediate(), null, new ArrayList(), Caches.default(), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(TestingCommons.tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, io.emeraldpay.dshackle.upstream.starknet.StarknetChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic()), ) @@ -102,7 +101,7 @@ class MultistreamHolderMock implements MultistreamHolder { EthereumMultistreamMock(@NotNull Chain chain, @NotNull List upstreams, @NotNull Caches caches) { super(chain, Schedulers.immediate(), null, upstreams, caches, Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(new BraveTracer(null, null, null)), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic()), ) diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/TestingCommons.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/TestingCommons.groovy index b0af252bd..6c2b9b524 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/test/TestingCommons.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/test/TestingCommons.groovy @@ -52,10 +52,6 @@ class TestingCommons { return new ApiReaderMock() } - static TracerMock tracerMock() { - return new TracerMock(null, null, null) - } - static GenericUpstreamMock upstream() { return new GenericUpstreamMock(Chain.ETHEREUM__MAINNET, api()) } @@ -99,7 +95,7 @@ class TestingCommons { static Multistream multistream(GenericUpstreamMock up) { return new GenericMultistream(Chain.ETHEREUM__MAINNET, Schedulers.immediate(), null, new ArrayList(), Caches.default(), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic()), ).tap { @@ -126,7 +122,7 @@ class TestingCommons { static Multistream multistreamWithoutUpstreams(Chain chain) { return new GenericMultistream(chain, Schedulers.immediate(), null, [], emptyCaches().getCaches(chain), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic()), ) @@ -134,7 +130,7 @@ class TestingCommons { static Multistream multistreamClassicWithoutUpstreams(Chain chain) { return new GenericMultistream(chain, Schedulers.immediate(), null, [], emptyCaches().getCaches(chain), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic()), ) diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/TracerMock.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/TracerMock.groovy deleted file mode 100644 index 7d0efbbe9..000000000 --- a/src/test/groovy/io/emeraldpay/dshackle/test/TracerMock.groovy +++ /dev/null @@ -1,64 +0,0 @@ -package io.emeraldpay.dshackle.test - -import brave.Tracer -import org.springframework.cloud.sleuth.CurrentTraceContext -import org.springframework.cloud.sleuth.Span -import org.springframework.cloud.sleuth.TraceContext -import org.springframework.cloud.sleuth.brave.bridge.BraveBaggageManager -import org.springframework.cloud.sleuth.brave.bridge.BraveSpan -import org.springframework.cloud.sleuth.brave.bridge.BraveTracer -import spock.mock.DetachedMockFactory - -class TracerMock extends BraveTracer { - private final spanMock = new SpanMock(null) - private static final mockFactory = new DetachedMockFactory() - - TracerMock(Tracer tracer, CurrentTraceContext context, BraveBaggageManager braveBaggageManager) { - super(tracer, context, braveBaggageManager) - } - - @Override - Span nextSpan(Span parent) { - return spanMock - } - - @Override - Span currentSpan() { - return spanMock - } - - @Override - SpanInScope withSpan(Span span) { - return mockFactory.Stub(SpanInScope) - } - - private static class SpanMock extends BraveSpan { - SpanMock(brave.Span delegate) { - super(delegate) - } - - @Override - Span start() { - return this - } - - @Override - Span name(String name) { - return this - } - - @Override - Span tag(String key, String value) { - return this - } - - @Override - void end() { - } - - @Override - TraceContext context() { - return mockFactory.Stub(TraceContext) - } - } -} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy index e17746d23..58df1dd21 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy @@ -55,7 +55,7 @@ class MultistreamSpec extends Specification { def up2 = new GenericUpstreamMock("test1", Chain.ETHEREUM__MAINNET, TestingCommons.api(), new DirectCallMethods(["eth_test2", "eth_test3"])) def aggr = new GenericMultistream(Chain.ETHEREUM__MAINNET, Schedulers.immediate(), null, [up1, up2], Caches.default(), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(TestingCommons.tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic())) when: @@ -190,7 +190,7 @@ class MultistreamSpec extends Specification { def up3 = TestingCommons.upstream("test-3", "external") def multistream = new GenericMultistream(Chain.ETHEREUM__MAINNET, Schedulers.immediate(), null, [up1, up2, up3], Caches.default(), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(TestingCommons.tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic())) @@ -263,7 +263,7 @@ class MultistreamSpec extends Specification { def up2 = new GenericUpstreamMock("test2", Chain.ETHEREUM__MAINNET, TestingCommons.api(), new DirectCallMethods(["eth_test1", "eth_test2"])) def ms = new GenericMultistream(Chain.ETHEREUM__MAINNET, Schedulers.immediate(), null, new ArrayList(), Caches.default(), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(TestingCommons.tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic())) when: @@ -294,7 +294,7 @@ class MultistreamSpec extends Specification { def up2 = new GenericUpstreamMock("test2", Chain.ETHEREUM__MAINNET, TestingCommons.api(), new DirectCallMethods(["eth_test1", "eth_test2"])) def ms = new GenericMultistream(Chain.ETHEREUM__MAINNET, Schedulers.immediate(), null, new ArrayList(), Caches.default(), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(TestingCommons.tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic())) def head1 = createBlock(250, "0x0d050c785de17179f935b9b93aca09c442964cc59972c71ae68e74731448401b") @@ -329,7 +329,7 @@ class MultistreamSpec extends Specification { def up3 = TestingCommons.upstream("test-3", "external") def multistream = new GenericMultistream(Chain.ETHEREUM__MAINNET, Schedulers.immediate(), null, new ArrayList(), Caches.default(), Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(TestingCommons.tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, EthereumChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic())) multistream.processUpstreamsEvents( @@ -367,7 +367,7 @@ class MultistreamSpec extends Specification { TestEthereumPosMultistream(@NotNull Chain chain, @NotNull List upstreams, @NotNull Caches caches) { super(chain, Schedulers.immediate(), null, upstreams, caches, Schedulers.boundedElastic(), - EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(TestingCommons.tracerMock()), + EthereumChainSpecific.INSTANCE.makeCachingReaderBuilder(), EthereumChainSpecific.INSTANCE.&localReaderBuilder, StarknetChainSpecific.INSTANCE.subscriptionBuilder(Schedulers.boundedElastic())) } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy index ea686f168..bc7af0f0b 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy @@ -1,6 +1,7 @@ package io.emeraldpay.dshackle.upstream.bitcoin import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent +import org.bitcoinj.core.Address /** * Copyright (c) 2020 EmeraldPay, Inc @@ -18,13 +19,11 @@ import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent * limitations under the License. */ -import org.bitcoinj.core.Address import org.bitcoinj.params.MainNetParams import org.bitcoinj.params.TestNet3Params import org.mockserver.integration.ClientAndServer import org.mockserver.model.HttpRequest import org.mockserver.model.HttpResponse -import org.springframework.util.SocketUtils import reactor.test.StepVerifier import spock.lang.Specification @@ -33,11 +32,9 @@ import java.time.Duration class EsploraClientSpec extends Specification { ClientAndServer mockServer - int port = 23001 def setup() { - port = SocketUtils.findAvailableTcpPort(23001) - mockServer = ClientAndServer.startClientAndServer(port); + mockServer = ClientAndServer.startClientAndServer(0) } def cleanup() { @@ -54,7 +51,7 @@ class EsploraClientSpec extends Specification { ).respond( HttpResponse.response(responseJson) ) - def client = new EsploraClient(new URI("http://localhost:${port}"), null, null) + def client = new EsploraClient(new URI("http://localhost:${mockServer.port}"), null, null) when: def act = client.getUtxo(Address.fromString(new MainNetParams(), "35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY")) @@ -95,7 +92,7 @@ class EsploraClientSpec extends Specification { ).respond( HttpResponse.response(responseJson) ) - def client = new EsploraClient(new URI("http://localhost:${port}"), null, null) + def client = new EsploraClient(new URI("http://localhost:${mockServer.port}"), null, null) when: def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) @@ -125,7 +122,7 @@ class EsploraClientSpec extends Specification { ).respond( HttpResponse.response("[]") ) - def client = new EsploraClient(new URI("http://localhost:${port}"), null, null) + def client = new EsploraClient(new URI("http://localhost:${mockServer.port}"), null, null) when: def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReaderSpec.groovy index 8db74557d..5fcf7ec96 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumCachingReaderSpec.groovy @@ -8,19 +8,18 @@ import io.emeraldpay.dshackle.data.BlockId import io.emeraldpay.dshackle.data.DefaultContainer import io.emeraldpay.dshackle.reader.RequestReader import io.emeraldpay.dshackle.reader.RequestReaderFactory -import io.emeraldpay.dshackle.test.TestingCommons import io.emeraldpay.dshackle.upstream.* import io.emeraldpay.dshackle.upstream.calls.DefaultEthereumMethods -import io.emeraldpay.dshackle.upstream.ethereum.json.BlockJson -import io.emeraldpay.dshackle.upstream.finalization.FinalizationType -import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import io.emeraldpay.dshackle.upstream.ethereum.domain.Address import io.emeraldpay.dshackle.upstream.ethereum.domain.BlockHash import io.emeraldpay.dshackle.upstream.ethereum.domain.TransactionId import io.emeraldpay.dshackle.upstream.ethereum.domain.Wei +import io.emeraldpay.dshackle.upstream.ethereum.json.BlockJson import io.emeraldpay.dshackle.upstream.ethereum.json.TransactionJson import io.emeraldpay.dshackle.upstream.ethereum.json.TransactionLogJson import io.emeraldpay.dshackle.upstream.ethereum.json.TransactionReceiptJson +import io.emeraldpay.dshackle.upstream.finalization.FinalizationType +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.apache.commons.collections4.Factory import reactor.core.publisher.Mono import reactor.test.StepVerifier @@ -48,7 +47,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create({ it.upstreamFilter.sort == Selector.Sort.safe }) >> Mock(RequestReader) { @@ -83,7 +82,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -110,7 +109,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -143,7 +142,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -175,7 +174,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -208,7 +207,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -241,7 +240,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -275,7 +274,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * cacheReceipt(Caches.Tag.REQUESTED, { DefaultContainer data -> data.txId.toHex() == hash1.substring(2) && data.height == 100 }) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), caches, new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), caches, new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -300,7 +299,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -330,7 +329,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - up, Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + up, Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -361,7 +360,7 @@ class EthereumDirectReaderSpec extends Specification { 1 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - up, Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + up, Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 1 * create(_) >> Mock(RequestReader) { @@ -399,7 +398,7 @@ class EthereumDirectReaderSpec extends Specification { Global.objectMapper.writeValueAsBytes(json), null, 1, data, null) ) EthereumDirectReader ethereumDirectReader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) ethereumDirectReader.requestReaderFactory = Mock(RequestReaderFactory) { 2 * create(_) >> Mock(RequestReader) { @@ -439,7 +438,7 @@ class EthereumDirectReaderSpec extends Specification { Global.objectMapper.writeValueAsBytes(json), null, 1, data, null) ) EthereumDirectReader ethereumDirectReader = new EthereumDirectReader( - Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + Stub(Multistream), Caches.default(), new CurrentBlockCache(), calls ) ethereumDirectReader.requestReaderFactory = Mock(RequestReaderFactory) { 2 * create(_) >> Mock(RequestReader) { @@ -472,7 +471,7 @@ class EthereumDirectReaderSpec extends Specification { 4 * create() >> new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) } EthereumDirectReader reader = new EthereumDirectReader( - up, Caches.default(), new CurrentBlockCache(), calls, TestingCommons.tracerMock() + up, Caches.default(), new CurrentBlockCache(), calls ) reader.requestReaderFactory = Mock(RequestReaderFactory) { 4 * create(_) >> Mock(RequestReader) { diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy index 4109030ee..d167ce330 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy @@ -21,7 +21,6 @@ class EthereumLocalReaderSpec extends Specification { TestingCommons.multistream(TestingCommons.api()), Caches.default(), ConstantFactory.constantFactory(new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET)), - TestingCommons.tracerMock() ), methods ) @@ -39,7 +38,6 @@ class EthereumLocalReaderSpec extends Specification { TestingCommons.multistream(TestingCommons.api()), Caches.default(), ConstantFactory.constantFactory(new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET)), - TestingCommons.tracerMock() ), methods ) diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy index d14ec38d0..3ad63420d 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy @@ -26,7 +26,6 @@ import io.micrometer.core.instrument.Timer import org.mockserver.integration.ClientAndServer import org.mockserver.model.HttpRequest import org.mockserver.model.HttpResponse -import org.springframework.util.SocketUtils import reactor.core.scheduler.Schedulers import spock.lang.Specification @@ -35,7 +34,6 @@ import java.time.Duration class JsonRpcHttpReaderSpec extends Specification { ClientAndServer mockServer - int port = 19332 RequestMetrics metrics = new RequestMetrics( Timer.builder("test1").register(TestingCommons.meterRegistry), Counter.builder("test2").register(TestingCommons.meterRegistry), @@ -43,8 +41,7 @@ class JsonRpcHttpReaderSpec extends Specification { ) def setup() { - port = SocketUtils.findAvailableTcpPort(19332) - mockServer = ClientAndServer.startClientAndServer(port); + mockServer = ClientAndServer.startClientAndServer(0); } def cleanup() { @@ -53,7 +50,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Make a request"() { setup: - JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(),null, null, [:]) + JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(),null, null, [:]) def resp = '{' + ' "jsonrpc": "2.0",' + ' "result": "0x98de45",' + @@ -74,7 +71,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Produces RPC Exception on error status code"() { setup: - def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) + def client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) mockServer.when( HttpRequest.request() ).respond( @@ -97,7 +94,7 @@ class JsonRpcHttpReaderSpec extends Specification { def "Tries to extract message if HTTP error if it still contains a JSON RPC message"() { setup: - def client = new JsonRpcHttpReader("localhost:${port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) + def client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) mockServer.when( HttpRequest.request() diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy index 98a8acbb8..c49a5b6fd 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy @@ -50,7 +50,7 @@ class EcdsaSignerSpec extends Specification { def conf = new SignatureConfig() conf.enabled = true conf.privateKey = "src/test/resources/signer/test_key" - def signer = new ResponseSignerFactory(conf).getObject() as EcdsaSigner + def signer = new ResponseSignerFactory(conf).createSigner() as EcdsaSigner // To verify the test, check the hash of test key above: // @@ -123,7 +123,7 @@ class EcdsaSignerSpec extends Specification { verifier.initVerify(pk) verifier.update("DSHACKLESIG/10/infura/${Hex.encodeHexString(sha256.digest(result))}".getBytes()) - def signer = factory.getObject() as EcdsaSigner + def signer = factory.createSigner() as EcdsaSigner when: def sig = signer.sign(10, result, up.id) diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy index 0e275a7bf..91f268539 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy @@ -11,7 +11,7 @@ class ResponseSignerFactorySpec extends Specification { setup: def conf = new SignatureConfig() when: - def signer = new ResponseSignerFactory(conf).getObject() + def signer = new ResponseSignerFactory(conf).createSigner() then: signer instanceof NoSigner } @@ -20,7 +20,7 @@ class ResponseSignerFactorySpec extends Specification { setup: def conf = new SignatureConfig() when: - def signer = new ResponseSignerFactory(conf).getObject() + def signer = new ResponseSignerFactory(conf).createSigner() then: signer instanceof NoSigner } diff --git a/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt index 0beb17f6a..57c7c8f5b 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt @@ -13,11 +13,9 @@ import io.emeraldpay.dshackle.upstream.Selector import io.grpc.BindableService import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration -import org.springframework.cloud.sleuth.Tracer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.context.annotation.Profile @@ -57,7 +55,6 @@ class IntegrationTest { fun testUpstreamTxMethodQuorumAndReader() { val ms = multistreamHolder.getUpstream(Chain.ETHEREUM__MAINNET) val ethUpstream = ms.getUpstreams()[0] - val tracer = mock() val reqReader = RequestReaderFactory.default() val txQuorum = ethUpstream.getMethods().createQuorumFor("eth_sendRawTransaction") @@ -69,7 +66,6 @@ class IntegrationTest { Selector.UpstreamFilter.default, txQuorum, null, - tracer, ), ) val txCountReader = reqReader.create( @@ -78,7 +74,6 @@ class IntegrationTest { Selector.UpstreamFilter.default, txCountQuorum, null, - tracer, ), ) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/config/reload/ReloadConfigTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/config/reload/ReloadConfigTest.kt index f825d7362..4c82b2d12 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/config/reload/ReloadConfigTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/config/reload/ReloadConfigTest.kt @@ -29,7 +29,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.springframework.cloud.sleuth.Tracer import org.springframework.util.ResourceUtils import reactor.core.publisher.Flux import reactor.core.scheduler.Schedulers @@ -194,7 +193,7 @@ class ReloadConfigTest { ArrayList(), Caches.default(), Schedulers.boundedElastic(), - cs.makeCachingReaderBuilder(mock()), + cs.makeCachingReaderBuilder(), cs::localReaderBuilder, cs.subscriptionBuilder(Schedulers.boundedElastic()), ) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt index 62fac3496..169a4ade3 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt @@ -9,7 +9,6 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.kotlin.mock -import org.springframework.cloud.sleuth.Tracer class RequestReaderFactoryTest { private val defaultFactory = RequestReaderFactory.Default() @@ -26,7 +25,6 @@ class RequestReaderFactoryTest { companion object { private val ms = mock() - private val tracer = mock() @JvmStatic fun data(): List { @@ -37,7 +35,6 @@ class RequestReaderFactoryTest { Selector.UpstreamFilter(Selector.empty), MaximumValueQuorum(), null, - tracer, ), ), Arguments.of( @@ -46,7 +43,6 @@ class RequestReaderFactoryTest { Selector.UpstreamFilter(Selector.empty), BroadcastQuorum(), null, - tracer, ), ), ) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/rpc/NativeCallTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/rpc/NativeCallTest.kt index 95443a3b3..5c3a405f7 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/rpc/NativeCallTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/rpc/NativeCallTest.kt @@ -13,7 +13,6 @@ import org.junit.jupiter.api.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.spy -import org.springframework.cloud.sleuth.Tracer import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.test.StepVerifier @@ -29,7 +28,6 @@ class NativeCallTest { mock(), mock(), MainConfig(), - mock(), ), ) { on { nativeCallResult(request) } doReturn Flux.just( @@ -59,7 +57,6 @@ class NativeCallTest { mock(), mock(), MainConfig(), - mock(), ), ) { on { nativeCallResult(request) } doReturn Flux.just( @@ -95,7 +92,6 @@ class NativeCallTest { mock(), mock(), MainConfig(), - mock(), ), ) { on { nativeCallResult(request) } doReturn Flux.just( From 8dec46fb27e9290a136679e9db4cc9b3cf75f2ed Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Wed, 7 Jan 2026 12:46:00 +0400 Subject: [PATCH 023/108] New btc methods (#768) --- .../dshackle/upstream/calls/DefaultBitcoinMethods.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBitcoinMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBitcoinMethods.kt index 6a1691b89..69da49083 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBitcoinMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultBitcoinMethods.kt @@ -37,6 +37,9 @@ class DefaultBitcoinMethods(balances: Boolean) : CallMethods { "gettransaction", "gettxout", "getmemorypool", + "getrawmempool", + "getmempoolinfo", + "getblockheader", ).sorted() private val anyResponseMethods = listOf( From 4a9de838152327dcf686385aeae7f3d4388161b0 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Thu, 8 Jan 2026 17:42:29 +0400 Subject: [PATCH 024/108] New chains (#769) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index bb18b3dcd..5e7ebda9a 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit bb18b3dcd8bea2b989ace8d857e28b7ced7a5281 +Subproject commit 5e7ebda9a1a667583b6304fa86943728f2c1b93f diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index e64a77eec..de2e459ee 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit e64a77eeccce71b8b5de21e56f3ce840acb76de8 +Subproject commit de2e459ee1133fc0338b5ee2fb9aded6bf432cdd From a5320bd9e539b489e1047aec2d87bc22ac6d5053 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 14 Jan 2026 13:49:34 +0300 Subject: [PATCH 025/108] Add WebSocket subscription support for Ripple and migrate to ledger_closed (#770) * Add WebSocket subscription support for Ripple and migrate to ledger_closed - Replace deprecated `ledger` RPC method with `ledger_closed` for polling - Add WebSocket subscription support for Ripple ledger stream - Add RippleCommandParams for native Ripple WS command format (uses "command" instead of "method") - Extend ResponseWSParser to handle Ripple subscription format (type: "ledgerClosed") - Update WsSubscriptionsImpl to extract stream type for Ripple subscriptions - Implement getFromHeader() for parsing ledgerClosed WS events - Add RippleLedgerStreamEvent data class for WS subscription events - Add unit tests for ResponseWSParser and RippleChainSpecific * Remove legacy format --- .../upstream/ethereum/WsSubscriptionsImpl.kt | 28 ++- .../upstream/ripple/RippleChainSpecific.kt | 138 +++++++------- .../dshackle/upstream/rpcclient/CallParams.kt | 17 ++ .../upstream/rpcclient/ResponseWSParser.kt | 12 ++ .../rpcclient/ResponseWSParserSpec.groovy | 43 +++++ .../ripple/RippleChainSpecificTest.kt | 168 ++++++++++++++++++ 6 files changed, 334 insertions(+), 72 deletions(-) create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecificTest.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsSubscriptionsImpl.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsSubscriptionsImpl.kt index 6b7be8c27..089aa291e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsSubscriptionsImpl.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsSubscriptionsImpl.kt @@ -19,6 +19,8 @@ import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import io.emeraldpay.dshackle.upstream.rpcclient.ObjectParams +import io.emeraldpay.dshackle.upstream.rpcclient.RippleCommandParams import org.slf4j.LoggerFactory import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -47,8 +49,10 @@ class WsSubscriptionsImpl( log.warn("Failed to establish subscription: ${it.error?.message}") Mono.error(ChainException(it.id, it.error!!)) } else { - val id = if (it.getResultAsRawString() == "{}") { - request.id.toString() // in case empty result - match by request id + val rawResult = it.getResultAsRawString() + val id = if (rawResult.startsWith("{") || rawResult == "{}") { + // Ripple returns object result; use stream type as subscription ID + extractRippleStreamType(request) ?: request.id.toString() } else { it.getResultAsProcessedString() } @@ -60,6 +64,26 @@ class WsSubscriptionsImpl( return WsSubscriptions.SubscribeData(message, conn.connectionId(), subscriptionId) } + /** + * Extract expected event type from Ripple subscribe request. + * For `subscribe` with `streams: ["ledger"]`, returns "ledgerClosed" as this is the event type + * that will be received in subscription messages. + */ + private fun extractRippleStreamType(request: ChainRequest): String? { + if (request.method == "subscribe") { + val params = request.params + val streams: List<*>? = when (params) { + is RippleCommandParams -> params.params["streams"] as? List<*> + is ObjectParams -> params.obj["streams"] as? List<*> + else -> null + } + if (streams?.contains("ledger") == true) { + return "ledgerClosed" + } + } + return null + } + override fun unsubscribe(request: ChainRequest): Mono { if (request.params is ListParams && (request.params.list.isEmpty() || request.params.list.contains("")) ) { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt index 4a77d2cec..3ce071bbc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecific.kt @@ -12,71 +12,84 @@ import io.emeraldpay.dshackle.foundation.ChainOptions.Options import io.emeraldpay.dshackle.reader.ChainReader import io.emeraldpay.dshackle.upstream.BasicUpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.EgressSubscription import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator +import io.emeraldpay.dshackle.upstream.IngressSubscription +import io.emeraldpay.dshackle.upstream.Multistream import io.emeraldpay.dshackle.upstream.NodeTypeRequest import io.emeraldpay.dshackle.upstream.SingleValidator import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.UpstreamAvailability import io.emeraldpay.dshackle.upstream.UpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult +import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptions import io.emeraldpay.dshackle.upstream.generic.AbstractPollChainSpecific +import io.emeraldpay.dshackle.upstream.generic.GenericEgressSubscription +import io.emeraldpay.dshackle.upstream.generic.GenericIngressSubscription import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService import io.emeraldpay.dshackle.upstream.rpcclient.ListParams -import org.slf4j.LoggerFactory +import io.emeraldpay.dshackle.upstream.rpcclient.RippleCommandParams import reactor.core.publisher.Flux import reactor.core.publisher.Mono +import reactor.core.scheduler.Scheduler import java.math.BigInteger import java.time.Instant object RippleChainSpecific : AbstractPollChainSpecific() { - private val log = LoggerFactory.getLogger(RippleChainSpecific::class.java) - override fun parseBlock(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + // Parse ledger_closed response: { "ledger_hash": "...", "ledger_index": 123 } val jsonNode = Global.objectMapper.readTree(data) - - val block: RippleClosedLedger = if (jsonNode.has("ledger")) { - Global.objectMapper.treeToValue(jsonNode.get("ledger"), RippleClosedLedger::class.java) - } else { - val result = Global.objectMapper.readValue(data, RippleBlock::class.java) - result.closed.ledger - } - - var height: Long = 0 - try { - height = block.ledgerIndex.toLong() - } catch (e: NumberFormatException) { - log.error("Invalid ledgerIndex ${block.ledgerIndex}, upstreamId:$upstreamId") - } + val ledgerHash = jsonNode.get("ledger_hash").asText() + val ledgerIndex = jsonNode.get("ledger_index").asLong() return Mono.just( BlockContainer( - height = height, - hash = BlockId.from(block.ledgerHash), + height = ledgerIndex, + hash = BlockId.from(ledgerHash), difficulty = BigInteger.ZERO, timestamp = Instant.EPOCH, full = false, json = data, - parsed = block, + parsed = jsonNode, transactions = emptyList(), upstreamId = upstreamId, - parentHash = BlockId.from(block.parentHash), + parentHash = null, ), ) } override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono { - throw NotImplementedError() - } + // Parse Ripple ledger stream event: { "type": "ledgerClosed", "ledger_hash": "...", "ledger_index": 123, ... } + val event = Global.objectMapper.readValue(data, RippleLedgerStreamEvent::class.java) - override fun listenNewHeadsRequest(): ChainRequest { - throw NotImplementedError() - } + // Ripple epoch starts at 2000-01-01 00:00:00 UTC (946684800 seconds after Unix epoch) + val timestamp = event.ledgerTime?.let { + Instant.ofEpochSecond(it + 946684800L) + } ?: Instant.EPOCH - override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest { - throw NotImplementedError() + return Mono.just( + BlockContainer( + height = event.ledgerIndex, + hash = BlockId.from(event.ledgerHash), + difficulty = BigInteger.ZERO, + timestamp = timestamp, + full = false, + json = data, + parsed = event, + transactions = emptyList(), + upstreamId = upstreamId, + parentHash = null, // Not available in stream event + ), + ) } + override fun listenNewHeadsRequest(): ChainRequest = + ChainRequest("subscribe", RippleCommandParams("streams" to listOf("ledger"))) + + override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest = + ChainRequest("unsubscribe", RippleCommandParams("streams" to listOf("ledger"))) + override fun upstreamValidators( chain: Chain, upstream: Upstream, @@ -131,11 +144,19 @@ object RippleChainSpecific : AbstractPollChainSpecific() { } override fun latestBlockRequest(): ChainRequest = - ChainRequest("ledger", ListParams()) + ChainRequest("ledger_closed", ListParams()) override fun upstreamSettingsDetector(chain: Chain, upstream: Upstream): UpstreamSettingsDetector? { return RippleUpstreamSettingsDetector(upstream) } + + override fun makeIngressSubscription(chain: Chain, ws: WsSubscriptions): IngressSubscription { + return GenericIngressSubscription(chain, ws, listOf("ledger")) + } + + override fun subscriptionBuilder(headScheduler: Scheduler): (Multistream) -> EgressSubscription { + return { ms -> GenericEgressSubscription(ms, headScheduler) } + } } class RippleUpstreamSettingsDetector(val upstream: Upstream) : BasicUpstreamSettingsDetector(upstream) { @@ -189,47 +210,6 @@ data class RippleInfo( @param:JsonProperty("build_version") var buildVersion: String?, ) -@JsonIgnoreProperties(ignoreUnknown = true) -data class RippleBlock( - @param:JsonProperty("closed") var closed: RippleClosed, - @param:JsonProperty("open") var open: RippleOpen?, - @param:JsonProperty("status") var status: String?, -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class RippleClosed( - @param:JsonProperty("ledger") var ledger: RippleClosedLedger, -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class RippleOpen( - @param:JsonProperty("ledger") var ledger: RippleOpenLedger, -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class RippleClosedLedger( - @param:JsonProperty("account_hash") var accountHash: String, - @param:JsonProperty("close_flags") var closeFlags: Short, - @param:JsonProperty("close_time") var closeTime: Long, - @param:JsonProperty("close_time_human") var closeTimeHuman: String, - @param:JsonProperty("close_time_iso") var closeTimeIso: String, - @param:JsonProperty("close_time_resolution") var closeTimeResolution: Short, - @param:JsonProperty("closed") var closed: Boolean, - @param:JsonProperty("ledger_hash") var ledgerHash: String, - @param:JsonProperty("ledger_index") var ledgerIndex: String, - @param:JsonProperty("parent_close_time") var parentCloseTime: Long, - @param:JsonProperty("parent_hash") var parentHash: String, - @param:JsonProperty("total_coins") var totalCoins: String, - @param:JsonProperty("transaction_hash") var transactionHash: String, -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class RippleOpenLedger( - @param:JsonProperty("closed") var closed: Boolean, - @param:JsonProperty("ledger_index") var ledgerIndex: String, - @param:JsonProperty("parent_hash") var parentHash: String, -) - @JsonIgnoreProperties(ignoreUnknown = true) data class RippleState( @param:JsonProperty("state") var state: RippleServerState, @@ -301,3 +281,21 @@ data class ValidatedLedger( @param:JsonProperty("reserve_inc") val reserveInc: Long, @param:JsonProperty("seq") val seq: Long, ) + +/** + * Ripple ledger stream subscription event. + * Received when subscribed to the "ledger" stream via WebSocket. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RippleLedgerStreamEvent( + @param:JsonProperty("type") val type: String, // "ledgerClosed" + @param:JsonProperty("ledger_hash") val ledgerHash: String, + @param:JsonProperty("ledger_index") val ledgerIndex: Long, + @param:JsonProperty("ledger_time") val ledgerTime: Long? = null, + @param:JsonProperty("txn_count") val txnCount: Int? = null, + @param:JsonProperty("validated_ledgers") val validatedLedgers: String? = null, + @param:JsonProperty("reserve_base") val reserveBase: Long? = null, + @param:JsonProperty("reserve_inc") val reserveInc: Long? = null, + @param:JsonProperty("fee_base") val feeBase: Int? = null, + @param:JsonProperty("fee_ref") val feeRef: Int? = null, +) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/CallParams.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/CallParams.kt index bd031edd7..e9764fc8f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/CallParams.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/CallParams.kt @@ -39,6 +39,23 @@ data class ObjectParams(val obj: Map) : JsonRpcParams() { } } +/** + * Ripple native WebSocket command format. + * Unlike JSON-RPC, Ripple uses "command" field and flat structure. + */ +data class RippleCommandParams(val params: Map) : CallParams { + constructor(vararg pairs: Pair) : this(mapOf(*pairs)) + + override fun toJson(id: Int, method: String): ByteArray { + val json = mutableMapOf( + "id" to id, + "command" to method, + ) + json.putAll(params) + return Global.objectMapper.writeValueAsBytes(json) + } +} + data class RestParams( val headers: List>, val queryParams: List>, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParser.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParser.kt index b99c65c2a..3f5a7dcde 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParser.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParser.kt @@ -55,6 +55,18 @@ class ResponseWSParser : ResponseParser() { val method = parser.valueAsString return state.copy(subMethod = method) } + // Handle Ripple subscription format: { "type": "ledgerClosed", "ledger_hash": "...", ... } + if ("type" == field) { + parser.nextToken() + val type = parser.valueAsString + // "ledgerClosed" is a Ripple subscription notification + if (type == "ledgerClosed") { + // Use type as subscription identifier, entire JSON as result + return state.copy(subId = type, result = json) + } + // "response" is a normal RPC response, let it pass through + return state + } if ("params" == field) { // example: // newHeads diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParserSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParserSpec.groovy index 460e5e023..a0f91a6f3 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParserSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseWSParserSpec.groovy @@ -103,4 +103,47 @@ class ResponseWSParserSpec extends Specification { act.error == null new String(act.value) == "null" } + + def "Parse Ripple ledgerClosed subscription event"() { + setup: + def msg = '''{ + "type": "ledgerClosed", + "fee_base": 10, + "fee_ref": 10, + "ledger_hash": "17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02", + "ledger_index": 6643099, + "ledger_time": 780804221, + "reserve_base": 10000000, + "reserve_inc": 2000000, + "txn_count": 5, + "validated_ledgers": "6643000-6643099" + }''' + when: + def act = parser.parse(msg.bytes) + then: + act.type == ResponseWSParser.Type.SUBSCRIPTION + act.id.asString() == "ledgerClosed" + act.error == null + with(new String(act.value)) { + it.contains("\"ledger_hash\"") + it.contains("17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02") + it.contains("\"ledger_index\": 6643099") + } + } + + def "Parse Ripple RPC response with type field"() { + setup: + def msg = '''{ + "id": 1, + "status": "success", + "type": "response", + "result": {} + }''' + when: + def act = parser.parse(msg.bytes) + then: + act.type == ResponseWSParser.Type.RPC + act.id.asNumber() == 1L + act.error == null + } } diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecificTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecificTest.kt new file mode 100644 index 000000000..343aa84c0 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ripple/RippleChainSpecificTest.kt @@ -0,0 +1,168 @@ +package io.emeraldpay.dshackle.upstream.ripple + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.data.BlockId +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import reactor.core.publisher.Mono +import java.time.Instant + +// ledger_closed response format +val ledgerClosedResponse = """ +{ + "ledger_hash": "17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02", + "ledger_index": 6643099 +} +""".trimIndent() + +// ledgerClosed WebSocket subscription event +val ledgerClosedEvent = """ +{ + "type": "ledgerClosed", + "fee_base": 10, + "fee_ref": 10, + "ledger_hash": "17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02", + "ledger_index": 6643099, + "ledger_time": 780804221, + "reserve_base": 10000000, + "reserve_inc": 2000000, + "txn_count": 5, + "validated_ledgers": "6643000-6643099" +} +""".trimIndent() + +// server_state response for validation +val serverStateOk = """ +{ + "state": { + "build_version": "1.12.0", + "complete_ledgers": "32570-6643099", + "io_latency_ms": 1, + "jq_trans_overflow": "0", + "last_close": { + "converge_time": 2000, + "proposers": 34 + }, + "load_base": 256, + "load_factor": 256, + "load_factor_fee_escalation": 256, + "load_factor_fee_queue": 256, + "load_factor_fee_reference": 256, + "load_factor_server": 256, + "network_id": 0, + "peers": 21, + "ports": [], + "server_state": "full", + "time": "2024-Jan-01 00:00:00", + "uptime": 123456, + "validated_ledger": { + "base_fee": 10, + "close_time": 780804221, + "hash": "17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02", + "reserve_base": 10000000, + "reserve_inc": 2000000, + "seq": 6643099 + }, + "validation_quorum": 28 + } +} +""".trimIndent() + +val serverStateSyncing = """ +{ + "state": { + "build_version": "1.12.0", + "complete_ledgers": "32570-6643099", + "io_latency_ms": 1, + "jq_trans_overflow": "0", + "last_close": { + "converge_time": 2000, + "proposers": 34 + }, + "load_base": 256, + "load_factor": 256, + "load_factor_fee_escalation": 256, + "load_factor_fee_queue": 256, + "load_factor_fee_reference": 256, + "load_factor_server": 256, + "network_id": 0, + "peers": 21, + "ports": [], + "server_state": "connected", + "time": "2024-Jan-01 00:00:00", + "uptime": 123456, + "validated_ledger": { + "base_fee": 10, + "close_time": 780804221, + "hash": "17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02", + "reserve_base": 10000000, + "reserve_inc": 2000000, + "seq": 6643099 + }, + "validation_quorum": 28 + } +} +""".trimIndent() + +class RippleChainSpecificTest { + + private val dummyReader = object : ChainReader { + override fun read(key: ChainRequest): Mono = Mono.empty() + } + + @Test + fun `parseBlock with ledger_closed format`() { + val result = RippleChainSpecific.parseBlock( + ledgerClosedResponse.toByteArray(), + "test-upstream", + dummyReader, + ).block()!! + + assertThat(result.height).isEqualTo(6643099) + assertThat(result.hash) + .isEqualTo(BlockId.from("17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02")) + assertThat(result.upstreamId).isEqualTo("test-upstream") + assertThat(result.parentHash).isNull() + } + + @Test + fun `getFromHeader with ledgerClosed WS event`() { + val result = RippleChainSpecific.getFromHeader( + ledgerClosedEvent.toByteArray(), + "test-upstream", + dummyReader, + ).block()!! + + assertThat(result.height).isEqualTo(6643099) + assertThat(result.hash) + .isEqualTo(BlockId.from("17ACB57A0F73B5160713E81FE72B2AC9F6064541004E272BD09F257D57C30C02")) + assertThat(result.upstreamId).isEqualTo("test-upstream") + assertThat(result.parentHash).isNull() + // Ripple epoch (2000-01-01) + 780804221 seconds = expected timestamp + assertThat(result.timestamp).isEqualTo(Instant.ofEpochSecond(780804221 + 946684800L)) + } + + @Test + fun `validate returns OK for full server state`() { + val result = RippleChainSpecific.validate(serverStateOk.toByteArray()) + assertThat(result).isEqualTo(UpstreamAvailability.OK) + } + + @Test + fun `validate returns SYNCING for connected server state`() { + val result = RippleChainSpecific.validate(serverStateSyncing.toByteArray()) + assertThat(result).isEqualTo(UpstreamAvailability.SYNCING) + } + + @Test + fun `validateSettings returns VALID for matching network`() { + val chain = Chain.RIPPLE__MAINNET + val result = RippleChainSpecific.validateSettings(serverStateOk.toByteArray(), chain) + assertThat(result).isEqualTo(ValidateUpstreamSettingsResult.UPSTREAM_VALID) + } +} From 7f9e350a9c5d854326c815a91f3ea650eaf67e46 Mon Sep 17 00:00:00 2001 From: EugeneDrpc Date: Wed, 14 Jan 2026 17:09:34 +0100 Subject: [PATCH 026/108] add more logs to doge upstream (#773) * add more logs to doge upstream * fix other similar cases --- src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt | 2 +- .../kotlin/io/emeraldpay/dshackle/upstream/HeadLagObserver.kt | 2 +- .../io/emeraldpay/dshackle/upstream/bitcoin/BitcoinRpcHead.kt | 4 ++-- .../io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt index c711ddf6b..bfacb428e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/StreamHead.kt @@ -44,7 +44,7 @@ class StreamHead( ms.getHead() .getFlux() .map { asProto(ms, chain, it!!) } - .onErrorContinue { t, _ -> + .onErrorContinue { t, _: Any? -> log.warn("Head subscription error", t) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HeadLagObserver.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HeadLagObserver.kt index 76cb719c0..5c116266a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HeadLagObserver.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HeadLagObserver.kt @@ -75,7 +75,7 @@ class HeadLagObserver( .parallel(followers.size) .flatMap { up -> mapLagging(top, up, getCurrentBlocks(up)).subscribeOn(lagObserverScheduler) } .sequential() - .onErrorContinue { t, _ -> log.warn("Failed to update lagging distance", t) } + .onErrorContinue { t, _: Any? -> log.warn("Failed to update lagging distance", t) } } open fun getCurrentBlocks(up: Upstream): Flux { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinRpcHead.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinRpcHead.kt index af7624552..c5ad9c634 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinRpcHead.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinRpcHead.kt @@ -71,8 +71,8 @@ class BitcoinRpcHead( .map(extractBlock::extract) .timeout(Defaults.timeout, Mono.error(Exception("Block data is not received"))) } - .onErrorContinue { err, _ -> - log.debug("RPC error ${err.message}") + .onErrorContinue { err, _: Any? -> + log.warn("RPC error: ${err.message}") } refreshSubscription = super.follow(base) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt index dbdf582d7..e08198499 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt @@ -146,7 +146,7 @@ class GrpcUpstreams( return Flux.interval(Duration.ZERO, Duration.ofSeconds(20)) .flatMap { authAndDescribe(grpcUpstreamsAuth) - }.onErrorContinue { t, _ -> + }.onErrorContinue { t, _: Any? -> if (ExceptionUtils.indexOfType(t, IOException::class.java) >= 0) { log.warn("gRPC upstream $host:$port is unavailable. (${t.javaClass}: ${t.message})") known.values.forEach { From be77e4caeb2f3d704cf22ee6778727077cb76111 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Thu, 15 Jan 2026 13:15:59 +0400 Subject: [PATCH 027/108] Fid monad gas detection (#771) --- .../EthereumLowerBoundBlockDetector.kt | 1 + .../EthereumLowerBoundReceiptsDetector.kt | 1 + .../ethereum/EthereumLowerBoundTxDetector.kt | 1 + .../EthereumUpstreamSettingsDetector.kt | 50 +++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt index 86487f869..dde67f04c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt @@ -28,6 +28,7 @@ class EthereumLowerBoundBlockDetector( "method handler crashed", // sei "Unexpected error", // hyperliquid "invalid block height", // hyperliquid + "pruned history unavailable", // xlayer ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt index dd04709dc..f29a6d7ae 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt @@ -28,6 +28,7 @@ class EthereumLowerBoundReceiptsDetector( "Unexpected error", // hyperliquid "invalid block height", // hyperliquid "header not found", + "pruned history unavailable", // xlayer ).plus(EthereumLowerBoundBlockDetector.NO_BLOCK_ERRORS) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt index e984d068e..48f398331 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt @@ -23,6 +23,7 @@ class EthereumLowerBoundTxDetector( NO_TX_DATA, "Unexpected error", // hyperliquid "invalid block height", // hyperliquids + "pruned history unavailable", // xlayer blocks ).plus(EthereumLowerBoundBlockDetector.NO_BLOCK_ERRORS) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt index e4131db52..022191e99 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt @@ -89,6 +89,22 @@ class EthereumUpstreamSettingsDetector( * */ private fun detectGasLabels(): Flux> { + return if (chain == Chain.MONAD__MAINNET || chain == Chain.MONAD__TESTNET) { + basicGasLabels() + .collectList() + .flatMapMany { labels -> + if (labels.find { it.first == "extra_gas_limit" }?.second == "600000000") { + Flux.fromIterable(labels) + } else { + monadGasLabels() + } + } + } else { + basicGasLabels() + } + } + + private fun basicGasLabels(): Flux> { return upstream.getIngressReader().read( ChainRequest( "eth_call", @@ -123,6 +139,40 @@ class EthereumUpstreamSettingsDetector( } } + // the basic gas check doesn't work on monad nodes, let's check then by an error message + private fun monadGasLabels(): Flux> { + return upstream.getIngressReader().read( + ChainRequest( + "eth_call", + ListParams( + mapOf( + "to" to "0x53Daa71B04d589429f6d3DF52db123913B818F22", + "data" to "0x51be4eaa", + "gas" to "0x232AAF80", + ), + "latest", + mapOf( + "0x53Daa71B04d589429f6d3DF52db123913B818F22" to mapOf( + "code" to "0x6080604052348015600f57600080fd5b506004361060285760003560e01c806351be4eaa14602d575b600080fd5b60336047565b604051603e91906066565b60405180910390f35b60005a905090565b6000819050919050565b606081604f565b82525050565b6000602082019050607960008301846059565b9291505056fea26469706673582212201c0202887c1afe66974b06ee355dee07542bbc424cf4d1659c91f56c08c3dcc064736f6c63430008130033", + ), + ), + ), + ), + ).flatMapMany { + Flux.fromIterable(emptyList>()) + }.onErrorResume { + if (it.message?.contains("gas limit too high") ?: false) { + val labels = mutableListOf( + Pair("gas-limit", 600_000_000.toString()), + Pair("extra_gas_limit", 600_000_000.toString()), + ) + Flux.fromIterable(labels) + } else { + Flux.empty() + } + } + } + /* Some clients on hyperliquid don't include system topup transactions, set either one of labels */ From 236f560f2a56e7bd736c361c1e9e58881f934643 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 15 Jan 2026 12:25:29 +0300 Subject: [PATCH 028/108] =?UTF-8?q?Support=20=E2=80=9Cnetwork=E2=80=9D=20f?= =?UTF-8?q?ield=20of=20compatible=20clients=20config=20bans=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/hot/CompatibleVersionsRules.kt | 2 + .../dshackle/upstream/UpstreamValidator.kt | 12 +++-- .../config/hot/CompatibleVersionsRulesTest.kt | 48 +++++++++++++++++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt index 10244f6fb..3f5683c72 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRules.kt @@ -13,6 +13,8 @@ data class CompatibleVersionsRules( data class CompatibleVersionsRule( @param:JsonProperty("client") val client: String, + @param:JsonProperty("networks") + val networks: List?, @param:JsonProperty("blacklist") val blacklist: List?, @param:JsonProperty("whitelist") diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt index 303b75b8e..863ec57aa 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamValidator.kt @@ -115,17 +115,21 @@ class VersionValidator( } val type = upstream.getLabels().first().getOrDefault("client_type", "unknown") val version = upstream.getLabels().first().getOrDefault("client_version", "unknown") - val rule = versionsConfig.get()!!.rules.find { it.client == type } + val chain = upstream.getChain() + val rule = versionsConfig.get()!!.rules.find { rule -> + rule.client == type && + (rule.networks.isNullOrEmpty() || rule.networks.any { chain.shortNames.contains(it) }) + } if (rule == null) { - log.info("No rules for client type $type, skipping validation for upstream ${upstream.getId()}") + log.info("No rules for client type $type on chain ${chain.chainCode}, skipping validation for upstream ${upstream.getId()}") return Mono.just(OK) } if (!rule.whitelist.isNullOrEmpty() && !rule.whitelist.contains(version)) { - log.warn("Version $version is in not in defined whitelist for $type, please change client version for upstream ${upstream.getId()}") + log.warn("Version $version is in not in defined whitelist for $type on chain ${chain.chainCode}, please change client version for upstream ${upstream.getId()}") return Mono.just(UNAVAILABLE) } if (!rule.blacklist.isNullOrEmpty() && rule.blacklist.contains(version)) { - log.warn("Version $version is in defined blacklist for $type, please change client version for upstream ${upstream.getId()}") + log.warn("Version $version is in defined blacklist for $type on chain ${chain.chainCode}, please change client version for upstream ${upstream.getId()}") return Mono.just(UNAVAILABLE) } return Mono.just(OK) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRulesTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRulesTest.kt index 4af6e4e5e..31ee97c1c 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRulesTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/config/hot/CompatibleVersionsRulesTest.kt @@ -10,17 +10,17 @@ class CompatibleVersionsRulesTest { val raw = """ rules: - client: "client1" - blacklist: + blacklist: - 1.0.0 - 1.0.1 - whitelist: + whitelist: - 1.0.2 - 1.0.3 - client: "client2" - blacklist: + blacklist: - 1.0.0 - 1.0.1 - whitelist: + whitelist: - 1.0.2 - 1.0.3 """.trimIndent() @@ -42,4 +42,44 @@ class CompatibleVersionsRulesTest { assertEquals("1.0.2", rules[1].whitelist!![0]) assertEquals("1.0.3", rules[1].whitelist!![1]) } + + @Test + fun `test parsing with networks`() { + val raw = """ + rules: + - client: "erigon" + networks: + - ethereum + blacklist: + - v2.40.0 + - 3.1.0 + - client: "reth" + blacklist: + - v1.4.0 + - v1.4.1 + - client: "reth" + networks: + - bsc + blacklist: + - "reth/v1.6.0-2a4968e/x86_64-unknown-linux-gnu" + """.trimIndent() + + val rules = Global.yamlMapper.readValue(raw, CompatibleVersionsRules::class.java)!!.rules + assertEquals(3, rules.size) + + // First rule: erigon with networks + assertEquals("erigon", rules[0].client) + assertEquals(listOf("ethereum"), rules[0].networks) + assertEquals(listOf("v2.40.0", "3.1.0"), rules[0].blacklist) + + // Second rule: reth without networks (applies to all) + assertEquals("reth", rules[1].client) + assertEquals(null, rules[1].networks) + assertEquals(listOf("v1.4.0", "v1.4.1"), rules[1].blacklist) + + // Third rule: reth with networks (bsc only) + assertEquals("reth", rules[2].client) + assertEquals(listOf("bsc"), rules[2].networks) + assertEquals(listOf("reth/v1.6.0-2a4968e/x86_64-unknown-linux-gnu"), rules[2].blacklist) + } } From d61eda2a6b81095bad8aae5b7bbcf0de3820f21a Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Fri, 16 Jan 2026 16:55:54 +0400 Subject: [PATCH 029/108] New state error (#775) --- .../upstream/ethereum/EthereumLowerBoundStateDetector.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt index c0d669fe1..156590151 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt @@ -26,6 +26,7 @@ class EthereumLowerBoundStateDetector( Regex("block #\\d not found"), Regex("state at block #\\d is pruned"), Regex("historical state .+ is not available"), + Regex("state .+ is not available"), ), ), ) From cd573007516f217cb6cba0259e61a28cd24079c9 Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 19 Jan 2026 11:25:16 +0300 Subject: [PATCH 030/108] Solana Head Detection: slotSubscribe (#772) * Implement Solana head detection strategies with metrics tracking and testing * Add benchmark tests for Solana BlockSubscribe and SlotSubscribe strategies * Implement metrics logging and strategy configuration for Solana head detection * Add metrics logging and comparison for SLOT_SUBSCRIBE strategy * Refactor SolanaChainSpecificTest: Consolidate tests for slotSubscribe strategy, remove BlockSubscribe tests, and enhance caching logic. Delete SolanaStrategyBenchmark for performance comparison. * add synthetic parentHash, improve tests * Fix linter * Optimize Solana getLatestBlock to use single getEpochInfo call Replace two separate RPC calls (getSlot + getBlockHeight) with a single getEpochInfo call that returns both absoluteSlot and blockHeight * Enhance height estimation logic in SolanaChainSpecific: Implement optimistic height estimation and improve error handling for RPC failures. Update tests to validate new behavior. * Refactor height verification in SolanaChainSpecific: Replace getBlockHeight with getEpochInfo for improved efficiency and accuracy in height estimation. Update logic to handle actual slot and height retrieval. --- foundation/src/main/resources/public | 2 +- .../upstream/solana/SolanaChainSpecific.kt | 206 ++++++++++------ .../solana/SolanaChainSpecificTest.kt | 227 ++++++++++++++++-- 3 files changed, 332 insertions(+), 103 deletions(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index de2e459ee..a93f66d7b 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit de2e459ee1133fc0338b5ee2fb9aded6bf432cdd +Subproject commit a93f66d7b0f10c37a6b25ee2befa888d84349e94 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt index d4a09f75e..722681676 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt @@ -30,91 +30,141 @@ import org.slf4j.LoggerFactory import reactor.core.publisher.Mono import reactor.core.scheduler.Scheduler import java.math.BigInteger +import java.nio.ByteBuffer import java.time.Instant - +import java.util.concurrent.ConcurrentHashMap + +/** + * Solana chain-specific implementation using slotSubscribe for head detection. + * + * Uses lightweight slotSubscribe WebSocket subscription instead of expensive blockSubscribe: + * - ~50 bytes per notification vs ~1KB for blockSubscribe + * - Stable API (no special node flags required) + * - Universal provider support + * - Throttled getEpochInfo calls (every N slots) to get actual slot and block height + * - Synthetic hash based on slot for ForkChoice deduplication + */ object SolanaChainSpecific : AbstractChainSpecific() { private val log = LoggerFactory.getLogger(SolanaChainSpecific::class.java) + // Throttle: check actual block height every N slots + private const val HEIGHT_CHECK_INTERVAL = 5 + + // Cache per upstream for throttling + private val lastKnownHeights = ConcurrentHashMap() + private val lastCheckedSlots = ConcurrentHashMap() + override fun getLatestBlock(api: ChainReader, upstreamId: String): Mono { - return api.read(ChainRequest("getSlot", ListParams())).flatMap { - val slot = it.getResultAsProcessedString().toLong() - api.read( - ChainRequest( - "getBlocks", - ListParams( - slot - 10, - slot, - ), - ), - ).flatMap { - val response = Global.objectMapper.readValue(it.getResult(), LongArray::class.java) - if (response == null || response.isEmpty()) { - Mono.empty() - } else { - api.read( - ChainRequest( - "getBlock", - ListParams( - response.max(), - mapOf( - "commitment" to "confirmed", - "showRewards" to false, - "transactionDetails" to "none", - "maxSupportedTransactionVersion" to 0, - ), - ), - ), - ).map { - val raw = it.getResult() - val block = Global.objectMapper.readValue(it.getResult(), SolanaBlock::class.java) - makeBlock(raw, block, upstreamId, response.max()) - }.onErrorResume { - log.debug("error during getting last solana block - ${it.message}") - Mono.empty() + return api.read(ChainRequest("getEpochInfo", ListParams())) + .map { response -> + val epochInfo = Global.objectMapper.readValue( + response.getResult(), + SolanaEpochInfo::class.java, + ) + lastKnownHeights[upstreamId] = epochInfo.blockHeight + lastCheckedSlots[upstreamId] = epochInfo.absoluteSlot + makeBlockFromSlot(epochInfo.absoluteSlot, epochInfo.absoluteSlot - 1, epochInfo.blockHeight, upstreamId, ByteArray(0)) + } + .onErrorResume { error -> + log.debug("error during getting latest solana block - ${error.message}") + Mono.empty() + } + } + + override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + return try { + val notification = Global.objectMapper.readValue(data, SolanaSlotNotification::class.java) + val slot = notification.slot + + val lastChecked = lastCheckedSlots[upstreamId] ?: 0L + val lastHeight = lastKnownHeights[upstreamId] + val shouldCheckHeight = slot - lastChecked >= HEIGHT_CHECK_INTERVAL + + // Optimistic height estimation: assume 1:1 slot-to-block ratio + val estimatedHeight = if (lastHeight != null && lastChecked > 0) { + lastHeight + (slot - lastChecked) + } else { + null + } + + if (shouldCheckHeight || estimatedHeight == null) { + // Verify actual height using getEpochInfo (single call for both slot and height) + api.read(ChainRequest("getEpochInfo", ListParams())) + .map { response -> + val epochInfo = Global.objectMapper.readValue( + response.getResult(), + SolanaEpochInfo::class.java, + ) + val actualSlot = epochInfo.absoluteSlot + val actualHeight = epochInfo.blockHeight + + if (estimatedHeight != null && estimatedHeight != actualHeight) { + log.debug( + "Height drift detected for {}: estimated={}, actual={}, diff={}", + upstreamId, + estimatedHeight, + actualHeight, + estimatedHeight - actualHeight, + ) + } + + lastKnownHeights[upstreamId] = actualHeight + lastCheckedSlots[upstreamId] = actualSlot + makeBlockFromSlot(actualSlot, actualSlot - 1, actualHeight, upstreamId, data) } - } + .onErrorResume { error -> + log.warn("Failed to get block height, using estimated value: ${error.message}") + val height = estimatedHeight ?: lastHeight ?: slot + Mono.just(makeBlockFromSlot(slot, notification.parent, height, upstreamId, data)) + } + } else { + // Use optimistic estimated height + Mono.just(makeBlockFromSlot(slot, notification.parent, estimatedHeight, upstreamId, data)) } + } catch (e: Exception) { + log.error("Failed to parse slotSubscribe notification", e) + Mono.empty() } } - override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono { - val res = Global.objectMapper.readValue(data, SolanaWrapper::class.java) - return Mono.just(makeBlock(data, res.value.block, upstreamId, res.context.slot)) + override fun listenNewHeadsRequest(): ChainRequest { + return ChainRequest("slotSubscribe", ListParams()) } - private fun makeBlock(raw: ByteArray, block: SolanaBlock, upstreamId: String, slot: Long): BlockContainer { + override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest { + return ChainRequest("slotUnsubscribe", ListParams(subId)) + } + + private fun makeBlockFromSlot(slot: Long, parentSlot: Long, height: Long, upstreamId: String, data: ByteArray): BlockContainer { + // Synthetic hash from slot for ForkChoice deduplication + val syntheticHash = BlockId.from( + ByteBuffer.allocate(32).putLong(slot).array(), + ) + // Synthetic parent hash from parent slot for chain tracking + val syntheticParentHash = BlockId.from( + ByteBuffer.allocate(32).putLong(parentSlot).array(), + ) + return BlockContainer( - height = block.height, - hash = BlockId.fromBase64(block.hash), + height = height, + hash = syntheticHash, difficulty = BigInteger.ZERO, - timestamp = Instant.ofEpochMilli(block.timestamp), + timestamp = Instant.now(), full = false, - json = raw, - parsed = block, + json = data, + parsed = null, transactions = emptyList(), upstreamId = upstreamId, - parentHash = BlockId.fromBase64(block.parent), + parentHash = syntheticParentHash, slot = slot, ) } - override fun listenNewHeadsRequest(): ChainRequest { - return ChainRequest( - "blockSubscribe", - ListParams( - "all", - mapOf( - "commitment" to "confirmed", - "showRewards" to false, - "transactionDetails" to "none", - ), - ), - ) - } - - override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest { - return ChainRequest("blockUnsubscribe", ListParams(subId)) + // For testing only - clear height cache + internal fun clearCache() { + lastKnownHeights.clear() + lastCheckedSlots.clear() } override fun upstreamValidators( @@ -165,26 +215,28 @@ object SolanaChainSpecific : AbstractChainSpecific() { } } +// slotSubscribe response format @JsonIgnoreProperties(ignoreUnknown = true) -data class SolanaWrapper( - @param:JsonProperty("context") var context: SolanaContext, - @param:JsonProperty("value") var value: SolanaResult, -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class SolanaContext( - @param:JsonProperty("slot") var slot: Long, +data class SolanaSlotNotification( + @param:JsonProperty("slot") val slot: Long, + @param:JsonProperty("parent") val parent: Long, + @param:JsonProperty("root") val root: Long, ) +// getEpochInfo response format @JsonIgnoreProperties(ignoreUnknown = true) -data class SolanaResult( - @param:JsonProperty("block") var block: SolanaBlock, +data class SolanaEpochInfo( + @param:JsonProperty("absoluteSlot") val absoluteSlot: Long, + @param:JsonProperty("blockHeight") val blockHeight: Long, + @param:JsonProperty("epoch") val epoch: Long, + @param:JsonProperty("slotIndex") val slotIndex: Long, + @param:JsonProperty("slotsInEpoch") val slotsInEpoch: Long, ) +// getBlock response format (used by SolanaLowerBoundSlotDetector) @JsonIgnoreProperties(ignoreUnknown = true) data class SolanaBlock( - @param:JsonProperty("blockHeight") var height: Long, - @param:JsonProperty("blockTime") var timestamp: Long, - @param:JsonProperty("blockhash") var hash: String, - @param:JsonProperty("previousBlockhash") var parent: String, + @param:JsonProperty("blockHeight") val height: Long, + @param:JsonProperty("blockhash") val hash: String, + @param:JsonProperty("blockTime") val time: Long, ) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecificTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecificTest.kt index f2eafb137..9636affa8 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecificTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecificTest.kt @@ -1,39 +1,216 @@ package io.emeraldpay.dshackle.upstream.solana +import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.data.BlockId import io.emeraldpay.dshackle.reader.ChainReader -import org.assertj.core.api.Assertions +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import reactor.core.publisher.Mono +import java.nio.ByteBuffer +import java.time.Instant +import java.time.temporal.ChronoUnit -val example = """{ - "context": { - "slot": 112301554 - }, - "value": { - "slot": 112301554, - "block": { - "previousBlockhash": "GJp125YAN4ufCSUvZJVdCyWQJ7RPWMmwxoyUQySydZA", - "blockhash": "6ojMHjctdqfB55JDpEpqfHnP96fiaHEcvzEQ2NNcxzHP", - "parentSlot": 112301553, - "blockTime": 1639926816, - "blockHeight": 101210751 - }, - "err": null - } - } -""".trimIndent() class SolanaChainSpecificTest { + @BeforeEach + fun setup() { + // Clear cache before each test + SolanaChainSpecific.clearCache() + } + + private fun epochInfoResponse(absoluteSlot: Long, blockHeight: Long): ChainResponse { + val json = """{"absoluteSlot": $absoluteSlot, "blockHeight": $blockHeight, "epoch": 100, "slotIndex": 1000, "slotsInEpoch": 432000}""" + return ChainResponse(json.toByteArray(), null) + } + + @Test + fun `parseBlock from slotSubscribe notification`() { + val reader = mock { + on { read(any()) }.thenReturn( + Mono.just(epochInfoResponse(112301554, 101210751)), + ) + } + + val beforeCall = Instant.now() + val json = """{"slot": 112301554, "parent": 112301553, "root": 112301500}""" + val result = SolanaChainSpecific.getFromHeader(json.toByteArray(), "upstream-1", reader).block()!! + val afterCall = Instant.now() + + // Uses actual data from getEpochInfo + assertThat(result.slot).isEqualTo(112301554) + assertThat(result.height).isEqualTo(101210751) + assertThat(result.upstreamId).isEqualTo("upstream-1") + + // Synthetic hash based on actualSlot from getEpochInfo + val expectedHash = BlockId.from(ByteBuffer.allocate(32).putLong(112301554).array()) + assertThat(result.hash).isEqualTo(expectedHash) + + // Synthetic parent hash based on actualSlot - 1 + val expectedParentHash = BlockId.from(ByteBuffer.allocate(32).putLong(112301553).array()) + assertThat(result.parentHash).isEqualTo(expectedParentHash) + + // Timestamp is synthetic (Instant.now() at call time) + assertThat(result.timestamp).isBetween(beforeCall, afterCall.plus(1, ChronoUnit.SECONDS)) + } + + @Test + fun `listenNewHeadsRequest returns slotSubscribe`() { + val request = SolanaChainSpecific.listenNewHeadsRequest() + + assertThat(request.method).isEqualTo("slotSubscribe") + } + + @Test + fun `unsubscribeNewHeadsRequest returns slotUnsubscribe`() { + val request = SolanaChainSpecific.unsubscribeNewHeadsRequest("sub-123") + + assertThat(request.method).isEqualTo("slotUnsubscribe") + } + + @Test + fun `throttle HTTP calls every 5 slots`() { + val reader = mock { + on { read(any()) }.thenReturn( + Mono.just(epochInfoResponse(100, 100000000)), + ) + } + + // First slot - should trigger HTTP call (no cache) + val slot1 = """{"slot": 100, "parent": 99, "root": 50}""" + SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block() + + // Next 4 slots - should use cached height (within interval of 5) + for (i in 101..104) { + val slotN = """{"slot": $i, "parent": ${i - 1}, "root": 50}""" + SolanaChainSpecific.getFromHeader(slotN.toByteArray(), "upstream-1", reader).block() + } + + // Only 1 HTTP call so far + verify(reader, times(1)).read(any()) + + // Slot 105 - should trigger new HTTP call (interval reached) + val slot105 = """{"slot": 105, "parent": 104, "root": 50}""" + SolanaChainSpecific.getFromHeader(slot105.toByteArray(), "upstream-1", reader).block() + + // Now 2 HTTP calls + verify(reader, times(2)).read(any()) + } + + @Test + fun `synthetic hash is deterministic based on slot`() { + val slot = 12345L + val hash1 = BlockId.from(ByteBuffer.allocate(32).putLong(slot).array()) + val hash2 = BlockId.from(ByteBuffer.allocate(32).putLong(slot).array()) + + assertThat(hash1).isEqualTo(hash2) + + val differentSlot = 12346L + val hash3 = BlockId.from(ByteBuffer.allocate(32).putLong(differentSlot).array()) + + assertThat(hash1).isNotEqualTo(hash3) + } + @Test - fun parseBlock() { - val reader = mock {} + fun `SolanaSlotNotification parses correctly`() { + val json = """{"slot": 123456, "parent": 123455, "root": 123400}""" + val notification = Global.objectMapper.readValue(json, SolanaSlotNotification::class.java) + + assertThat(notification.slot).isEqualTo(123456) + assertThat(notification.parent).isEqualTo(123455) + assertThat(notification.root).isEqualTo(123400) + } + + @Test + fun `uses slot as height when cache is empty and no HTTP call`() { + val reader = mock { + on { read(any()) }.thenReturn(Mono.error(RuntimeException("Network error"))) + } + + // First slot with HTTP error - should fallback to slot as height + val json = """{"slot": 112301554, "parent": 112301553, "root": 112301500}""" + val result = SolanaChainSpecific.getFromHeader(json.toByteArray(), "upstream-1", reader).block()!! + + assertThat(result.slot).isEqualTo(112301554) + // When cache empty and HTTP fails, uses slot as height + assertThat(result.height).isEqualTo(112301554) + } + + @Test + fun `uses optimistic estimated height between throttle intervals`() { + val reader = mock { + on { read(any()) }.thenReturn( + Mono.just(epochInfoResponse(100, 100000000)), + ) + } + + // First call sets cache at slot 100 with height 100000000 + val slot1 = """{"slot": 100, "parent": 99, "root": 50}""" + val result1 = SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block()!! + assertThat(result1.height).isEqualTo(100000000) + + // Second call uses optimistic estimated height: 100000000 + (101 - 100) = 100000001 + val slot2 = """{"slot": 101, "parent": 100, "root": 50}""" + val result2 = SolanaChainSpecific.getFromHeader(slot2.toByteArray(), "upstream-1", reader).block()!! + + assertThat(result2.slot).isEqualTo(101) + assertThat(result2.height).isEqualTo(100000001) // optimistic estimated height + + // Third call: 100000000 + (103 - 100) = 100000003 + val slot3 = """{"slot": 103, "parent": 102, "root": 50}""" + val result3 = SolanaChainSpecific.getFromHeader(slot3.toByteArray(), "upstream-1", reader).block()!! + + assertThat(result3.slot).isEqualTo(103) + assertThat(result3.height).isEqualTo(100000003) // optimistic estimated height + } + + @Test + fun `height estimation resets after RPC check`() { + val reader = mock { + on { read(any()) } + .thenReturn(Mono.just(epochInfoResponse(100, 100000000))) + .thenReturn(Mono.just(epochInfoResponse(105, 100000004))) + } + + // First call at slot 100 - sets cache with height 100000000 + val slot1 = """{"slot": 100, "parent": 99, "root": 50}""" + SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block() + + // Slot 105 triggers RPC check - gets actual slot 105 and height 100000004 (1 slot was skipped) + val slot105 = """{"slot": 105, "parent": 104, "root": 50}""" + val result105 = SolanaChainSpecific.getFromHeader(slot105.toByteArray(), "upstream-1", reader).block()!! + assertThat(result105.slot).isEqualTo(105) + assertThat(result105.height).isEqualTo(100000004) + + // Slot 107 uses new baseline: 100000004 + (107 - 105) = 100000006 + val slot107 = """{"slot": 107, "parent": 106, "root": 50}""" + val result107 = SolanaChainSpecific.getFromHeader(slot107.toByteArray(), "upstream-1", reader).block()!! + assertThat(result107.height).isEqualTo(100000006) + } + + @Test + fun `uses estimated height on RPC error when estimation available`() { + val reader = mock { + on { read(any()) } + .thenReturn(Mono.just(epochInfoResponse(100, 100000000))) + .thenReturn(Mono.error(RuntimeException("Network error"))) + } + + // First call succeeds at slot 100 + val slot1 = """{"slot": 100, "parent": 99, "root": 50}""" + SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block() - val result = SolanaChainSpecific.getFromHeader(example.toByteArray(), "1", reader).block()!! + // Slot 105 triggers RPC check but fails - should use estimated height: 100000000 + (105 - 100) = 100000005 + val slot105 = """{"slot": 105, "parent": 104, "root": 50}""" + val result = SolanaChainSpecific.getFromHeader(slot105.toByteArray(), "upstream-1", reader).block()!! - Assertions.assertThat(result.height).isEqualTo(101210751) - Assertions.assertThat(result.hash).isEqualTo(BlockId.fromBase64("6ojMHjctdqfB55JDpEpqfHnP96fiaHEcvzEQ2NNcxzHP")) - Assertions.assertThat(result.upstreamId).isEqualTo("1") - Assertions.assertThat(result.parentHash).isEqualTo(BlockId.fromBase64("GJp125YAN4ufCSUvZJVdCyWQJ7RPWMmwxoyUQySydZA")) + assertThat(result.slot).isEqualTo(105) + assertThat(result.height).isEqualTo(100000005) // estimated height used as fallback } } From 01c51a6f068ed8800a74e9f0fd5651424b4cef13 Mon Sep 17 00:00:00 2001 From: EugeneDrpc Date: Tue, 20 Jan 2026 11:55:38 +0100 Subject: [PATCH 031/108] blob detector npe fix, other detectors fix 404 scenario (#776) * blob detector npe fix, other detectors fix 404 scenario * Fix flaky GenericRpcHeadTest scheduler --- .../beaconchain/BeaconChainLowerBoundBlobDetector.kt | 12 ++++++++---- .../BeaconChainLowerBoundBlockDetector.kt | 7 +++++-- .../BeaconChainLowerBoundEpochDetector.kt | 8 +++++--- .../BeaconChainLowerBoundStateDetector.kt | 8 +++++--- .../dshackle/upstream/generic/GenericRpcHeadTest.kt | 2 +- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlobDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlobDetector.kt index 6e83959d6..96b502c8d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlobDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlobDetector.kt @@ -55,12 +55,16 @@ class BeaconChainLowerBoundBlobDetector( private fun parseHeadersResponse(data: ByteArray): ChainResponse { val node = Global.objectMapper.readValue(data) - if (node.get("code") != null && node.get("message") != null && node.get("code").textValue() == "404") { - return ChainResponse(null, ChainCallError(node.get("code").asInt(), node.get("message").asText(), node.get("message").asText())) + val codeNode = node.get("code") + val messageNode = node.get("message") + val is404 = codeNode != null && (codeNode.asInt() == 404 || codeNode.asText() == "404") + if (is404 && messageNode != null) { + return ChainResponse(null, ChainCallError(404, messageNode.asText(), messageNode.asText())) } - if (node.get("data").toString() == "[]") { + val dataNode = node.get("data") + if (dataNode == null || dataNode.isNull || (dataNode.isArray && dataNode.size() == 0)) { return ChainResponse(null, ChainCallError(404, notFoundError)) } - return ChainResponse(node.get("data").toString().toByteArray(), null) + return ChainResponse(dataNode.toString().toByteArray(), null) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlockDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlockDetector.kt index d44f2c5c0..d38407739 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlockDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundBlockDetector.kt @@ -57,8 +57,11 @@ class BeaconChainLowerBoundBlockDetector( private fun parseHeadersResponse(data: ByteArray): ChainResponse { val node = Global.objectMapper.readValue(data) - if (node.get("code") != null && node.get("message") != null && node.get("code").textValue() == "404") { - return ChainResponse(null, ChainCallError(node.get("code").asInt(), node.get("message").asText(), node.get("message").asText())) + val codeNode = node.get("code") + val messageNode = node.get("message") + val is404 = codeNode != null && (codeNode.asInt() == 404 || codeNode.asText() == "404") + if (is404 && messageNode != null) { + return ChainResponse(null, ChainCallError(404, messageNode.asText(), messageNode.asText())) } if (node.get("data")?.get("message")?.get("slot") != null) { return ChainResponse(node.get("data").toString().toByteArray(), null) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundEpochDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundEpochDetector.kt index 4be970207..bd2fa2f13 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundEpochDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundEpochDetector.kt @@ -80,10 +80,12 @@ class BeaconChainLowerBoundEpochDetector( private fun parseHeadersResponse(data: ByteArray): ChainResponse { val node = Global.objectMapper.readValue(data) - if (node.get("code") != null && node.get("message") != null && node.get("code").textValue() == "404") { - return ChainResponse(null, ChainCallError(node.get("code").asInt(), node.get("message").asText(), node.get("message").asText())) + val codeNode = node.get("code") + val messageNode = node.get("message") + val is404 = codeNode != null && (codeNode.asInt() == 404 || codeNode.asText() == "404") + if (is404 && messageNode != null) { + return ChainResponse(null, ChainCallError(404, messageNode.asText(), messageNode.asText())) } - val jsonData = node.get("data") if (jsonData != null) { val str = jsonData.toString() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundStateDetector.kt index e84fdca50..d68b1c1fa 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainLowerBoundStateDetector.kt @@ -54,10 +54,12 @@ class BeaconChainLowerBoundStateDetector( private fun parseHeadersResponse(data: ByteArray): ChainResponse { val node = Global.objectMapper.readValue(data) - if (node.get("code") != null && node.get("message") != null && node.get("code").textValue() == "404") { - return ChainResponse(null, ChainCallError(node.get("code").asInt(), node.get("message").asText(), node.get("message").asText())) + val codeNode = node.get("code") + val messageNode = node.get("message") + val is404 = codeNode != null && (codeNode.asInt() == 404 || codeNode.asText() == "404") + if (is404 && messageNode != null) { + return ChainResponse(null, ChainCallError(404, messageNode.asText(), messageNode.asText())) } - val jsonData = node.get("data") if (jsonData != null) { val str = jsonData.toString() diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericRpcHeadTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericRpcHeadTest.kt index 225111009..2bb097212 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericRpcHeadTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericRpcHeadTest.kt @@ -133,7 +133,7 @@ class GenericRpcHeadTest { "id", BlockValidator.ALWAYS_VALID, upstream, - Schedulers.boundedElastic(), + Schedulers.immediate(), EthereumChainSpecific, Duration.ofSeconds(5), ) From 40fdabe7905cb8838ef0b825f09ce551fc21d375 Mon Sep 17 00:00:00 2001 From: msizov Date: Wed, 21 Jan 2026 18:31:22 +0700 Subject: [PATCH 032/108] add tempo moderato testnet (#778) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 5e7ebda9a..fd3043d02 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 5e7ebda9a1a667583b6304fa86943728f2c1b93f +Subproject commit fd3043d02d93bcafe23b1117d3d27acdc8e57e6b diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index a93f66d7b..ffab4b27f 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit a93f66d7b0f10c37a6b25ee2befa888d84349e94 +Subproject commit ffab4b27fa4cba0b6cccf3173ced6179b0fb68c0 From 897d98dfb18fd739be7978f54c1a7d2517fd9bc5 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Fri, 23 Jan 2026 13:49:58 +0400 Subject: [PATCH 033/108] MegaETH support (#779) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index fd3043d02..38db8b3fe 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit fd3043d02d93bcafe23b1117d3d27acdc8e57e6b +Subproject commit 38db8b3fefb81f105c64a61ce0b2027c654066c6 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index ffab4b27f..5ad28fd81 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit ffab4b27fa4cba0b6cccf3173ced6179b0fb68c0 +Subproject commit 5ad28fd817b3615ce3534f9df63b14735f5f7f01 From f3ecbdaaa7262a09edf1c2f1c2d09bf8b7c4141c Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Fri, 23 Jan 2026 14:33:45 +0400 Subject: [PATCH 034/108] Lower bounds fixes (#777) --- .../dshackle/config/UpstreamsConfig.kt | 12 ++ .../dshackle/config/UpstreamsConfigReader.kt | 37 ++++ .../dshackle/upstream/DefaultUpstream.kt | 4 + .../dshackle/upstream/Multistream.kt | 4 + .../emeraldpay/dshackle/upstream/Upstream.kt | 1 + .../EthereumStateLowerBoundErrorHandler.kt | 4 +- .../EthereumLowerBoundBlockDetector.kt | 1 + .../upstream/generic/GenericUpstream.kt | 6 + .../upstream/lowerbound/LowerBoundData.kt | 18 +- .../upstream/lowerbound/LowerBoundDetector.kt | 22 ++- .../upstream/lowerbound/LowerBoundService.kt | 6 +- .../lowerbound/ManualLowerBoundService.kt | 87 +++++++++ .../dshackle/test/GenericUpstreamMock.groovy | 1 + .../dshackle/upstream/FilteredApisSpec.groovy | 1 + .../config/UpstreamsConfigReaderTest.kt | 58 ++++++ ...EthereumStateLowerBoundErrorHandlerTest.kt | 14 ++ .../KadenaLowerBoundStateDetectorTest.kt | 3 +- .../BaseManualLowerBoundServiceTest.kt | 176 ++++++++++++++++++ .../lowerbound/LowerBoundDetectorTest.kt | 95 ++++++++++ .../solana/SolanaLowerBoundServiceTest.kt | 66 +++++++ .../StarknetLowerBoundStateDetectorTest.kt | 3 +- 21 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/ManualLowerBoundService.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/BaseManualLowerBoundServiceTest.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetectorTest.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt index f1b51ec84..9aff5935d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo import io.emeraldpay.dshackle.foundation.ChainOptions import io.emeraldpay.dshackle.upstream.generic.connectors.GenericConnectorFactory.ConnectorMode +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.ManualLowerBoundType import java.net.URI import java.util.Arrays import java.util.Locale @@ -42,6 +44,7 @@ data class UpstreamsConfig( var methodGroups: MethodGroups? = null, var role: UpstreamRole = UpstreamRole.PRIMARY, var customHeaders: Map = emptyMap(), + var additionalSettings: AdditionalSettings? = null, ) { @Suppress("UNCHECKED_CAST") @@ -188,6 +191,15 @@ data class UpstreamsConfig( } } + data class AdditionalSettings( + val manualLowerBounds: Map, + ) + + data class ManualBoundSetting( + val type: ManualLowerBoundType, + val value: Long, + ) + data class Methods( val enabled: Set, val disabled: Set, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt index 7e16342ad..dfcad8992 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt @@ -17,12 +17,16 @@ package io.emeraldpay.dshackle.config import io.emeraldpay.dshackle.FileResolver +import io.emeraldpay.dshackle.config.UpstreamsConfig.ManualBoundSetting import io.emeraldpay.dshackle.foundation.ChainOptions import io.emeraldpay.dshackle.foundation.ChainOptionsReader import io.emeraldpay.dshackle.foundation.YamlConfigReader +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.ManualLowerBoundType import org.apache.commons.lang3.StringUtils import org.slf4j.LoggerFactory import org.yaml.snakeyaml.nodes.MappingNode +import org.yaml.snakeyaml.nodes.Tag import java.io.InputStream import java.net.URI import java.util.Locale @@ -287,6 +291,7 @@ class UpstreamsConfigReader( upstream.options = optionsReader.read(upNode) upstream.methods = tryReadMethods(upNode) upstream.methodGroups = tryReadMethodGroups(upNode) + upstream.additionalSettings = readAdditionalSettings(upNode) getValueAsBool(upNode, "enabled")?.let { upstream.isEnabled = it } @@ -311,6 +316,38 @@ class UpstreamsConfigReader( } } + private fun readAdditionalSettings(upNode: MappingNode): UpstreamsConfig.AdditionalSettings? { + return getMapping(upNode, "additional-settings") + ?.let { settings -> + val manualLowerBounds = getMapping(settings, "manual-lower-bounds") + ?.let { bounds -> + bounds.value.asSequence() + .filter { + StringUtils.isNotBlank(it.keyNode.valueAsString()) && it.valueNode.tag == Tag.MAP + } + .map { + val setting = readManualBoundSetting(it.valueNode as MappingNode) + + if (setting != null) { + LowerBoundType.byName(it.keyNode.valueAsString()!!) to setting + } else { + null + } + } + .filterNotNull() + .associate { it.first to it.second } + } ?: emptyMap() + UpstreamsConfig.AdditionalSettings(manualLowerBounds) + } + } + + private fun readManualBoundSetting(node: MappingNode): ManualBoundSetting? { + val type = getValueAsString(node, "type") + ?.let { ManualLowerBoundType.byName(it) } ?: return null + val value = getValueAsLong(node, "value") ?: return null + return ManualBoundSetting(type, value) + } + private fun readUpstreamGrpc( upNode: MappingNode, ) { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt index 3ea8649b6..5fd5d758b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt @@ -172,6 +172,10 @@ abstract class DefaultUpstream( return 0 } + override fun getAdditionalSettings(): UpstreamsConfig.AdditionalSettings? { + return null + } + protected fun sendUpstreamStateEvent(eventType: UpstreamChangeEvent.ChangeType) { stateEventStream.emitNext( UpstreamChangeEvent(chain, this, eventType), diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt index 6f04efcc7..c4680e4fb 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt @@ -261,6 +261,10 @@ abstract class Multistream( return 0 } + override fun getAdditionalSettings(): UpstreamsConfig.AdditionalSettings? { + return null + } + override fun getStatus(): UpstreamAvailability { return state.getStatus() } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstream.kt index 2a091cb69..b1fdac7e0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstream.kt @@ -56,6 +56,7 @@ interface Upstream : Lifecycle { fun getUpstreamSettingsData(): UpstreamSettingsData? fun updateLowerBound(lowerBound: Long, type: LowerBoundType) fun predictLowerBound(type: LowerBoundType, timeOffsetSeconds: Long): Long + fun getAdditionalSettings(): UpstreamsConfig.AdditionalSettings? fun getChain(): Chain diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandler.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandler.kt index 1916b788c..ea0565e25 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandler.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandler.kt @@ -21,7 +21,9 @@ object EthereumStateLowerBoundErrorHandler : EthereumLowerBoundErrorHandler() { private val applicableMethods = firstTagIndexMethods + secondTagIndexMethods override fun canHandle(request: ChainRequest, errorMessage: String?): Boolean { - return stateErrors.any { errorMessage?.contains(it) ?: false } && applicableMethods.contains(request.method) + return !(errorMessage?.contains("execution reverted") ?: false) && + stateErrors.any { errorMessage?.contains(it) ?: false } && + applicableMethods.contains(request.method) } override fun tagIndex(method: String): Int { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt index dde67f04c..c67d6ea46 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt @@ -29,6 +29,7 @@ class EthereumLowerBoundBlockDetector( "Unexpected error", // hyperliquid "invalid block height", // hyperliquid "pruned history unavailable", // xlayer + "no transactions snapshot file for", ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt index ecd3e63af..c539d2615 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt @@ -64,6 +64,7 @@ open class GenericUpstream( lowerBoundServiceBuilder: LowerBoundServiceBuilder, finalizationDetectorBuilder: FinalizationDetectorBuilder, versionRules: Supplier, + private val additionalSettings: UpstreamsConfig.AdditionalSettings?, ) : DefaultUpstream(id, hash, null, UpstreamAvailability.OK, options, role, targets, node, chainConfig, chain), Lifecycle { constructor( @@ -96,6 +97,7 @@ open class GenericUpstream( lowerBoundServiceBuilder, finalizationDetectorBuilder, versionRules, + config.additionalSettings, ) { rpcMethodsDetector = upstreamRpcMethodsDetectorBuilder(this, config) detectRpcMethods(config, buildMethods) @@ -443,6 +445,10 @@ open class GenericUpstream( return lowerBoundService.predictLowerBound(type, timeOffsetSeconds) } + override fun getAdditionalSettings(): UpstreamsConfig.AdditionalSettings? { + return additionalSettings + } + fun isValid(): Boolean = isUpstreamValid.get() companion object { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundData.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundData.kt index 20ccc7a0d..175686f47 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundData.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundData.kt @@ -20,7 +20,23 @@ data class LowerBoundData( } enum class LowerBoundType { - UNKNOWN, STATE, SLOT, BLOCK, TX, LOGS, TRACE, PROOF, BLOB, EPOCH, RECEIPTS + UNKNOWN, STATE, SLOT, BLOCK, TX, LOGS, TRACE, PROOF, BLOB, EPOCH, RECEIPTS; + + companion object { + fun byName(name: String): LowerBoundType { + return entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: UNKNOWN + } + } +} + +enum class ManualLowerBoundType { + HEAD, FIXED; + + companion object { + fun byName(name: String): ManualLowerBoundType? { + return entries.firstOrNull { it.name.equals(name, ignoreCase = true) } + } + } } fun BlockchainOuterClass.LowerBoundType.fromProtoType(): LowerBoundType { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt index 857ee9608..2dbd50026 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt @@ -18,7 +18,7 @@ abstract class LowerBoundDetector( protected val lowerBounds = LowerBounds(chain) private val lowerBoundSink = Sinks.many().multicast().directBestEffort() - fun detectLowerBound(): Flux { + fun detectLowerBound(manualBoundsService: ManualLowerBoundService): Flux { val notProcessing = AtomicBoolean(true) return Flux.merge( @@ -29,11 +29,21 @@ abstract class LowerBoundDetector( ) .filter { notProcessing.get() } .flatMap { - notProcessing.set(false) - internalDetectLowerBound() - .onErrorResume { Mono.just(LowerBoundData.default()) } - .switchIfEmpty(Flux.just(LowerBoundData.default())) - .doFinally { notProcessing.set(true) } + if (types() == manualBoundsService.manualBoundTypes()) { + Flux.fromIterable(types()) + .mapNotNull { manualBoundsService.manualLowerBound(it) } + } else { + notProcessing.set(false) + Flux.merge( + internalDetectLowerBound() + .filter { !manualBoundsService.hasManualBound(it.type) } + .onErrorResume { Mono.just(LowerBoundData.default()) } + .switchIfEmpty(Flux.just(LowerBoundData.default())) + .doFinally { notProcessing.set(true) }, + Flux.fromIterable(types()) + .mapNotNull { manualBoundsService.manualLowerBound(it) }, + ) + } }, ) .filter { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundService.kt index c0693aa8b..c82ef43b2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundService.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundService.kt @@ -16,10 +16,14 @@ abstract class LowerBoundService( private val lowerBounds = ConcurrentHashMap() private val detectors: List by lazy { detectors() } + private val manualBoundsService = BaseManualLowerBoundService( + upstream, + upstream.getAdditionalSettings()?.manualLowerBounds ?: emptyMap(), + ) fun detectLowerBounds(): Flux { return Flux.merge( - detectors.map { it.detectLowerBound() }, + detectors.map { it.detectLowerBound(manualBoundsService) }, ) .doOnNext { log.info("Lower bound of type ${it.type} is ${it.lowerBound} for upstream ${upstream.getId()} of chain $chain") diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/ManualLowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/ManualLowerBoundService.kt new file mode 100644 index 000000000..2bbadcdf3 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/ManualLowerBoundService.kt @@ -0,0 +1,87 @@ +package io.emeraldpay.dshackle.upstream.lowerbound + +import io.emeraldpay.dshackle.config.UpstreamsConfig +import io.emeraldpay.dshackle.upstream.Upstream + +interface ManualLowerBoundService { + fun manualLowerBound(type: LowerBoundType): LowerBoundData? + fun hasManualBound(type: LowerBoundType): Boolean + fun manualBoundTypes(): Set +} + +class NoopManualLowerBoundService : ManualLowerBoundService { + override fun manualLowerBound(type: LowerBoundType): LowerBoundData? { + return null + } + + override fun hasManualBound(type: LowerBoundType): Boolean { + return false + } + + override fun manualBoundTypes(): Set { + return emptySet() + } +} + +class BaseManualLowerBoundService( + private val upstream: Upstream, + private val manualLowerBoundSettings: Map, +) : ManualLowerBoundService { + private val providers = manualLowerBoundSettings.mapValues { provider(it.value) } + + override fun manualLowerBound(type: LowerBoundType): LowerBoundData? { + val provider = providers[type] ?: return null + if (!provider.canHandle()) { + return null + } + return LowerBoundData(provider.getManualLowerBound(), type) + } + + override fun hasManualBound(type: LowerBoundType): Boolean { + return manualLowerBoundSettings.contains(type) + } + + override fun manualBoundTypes(): Set { + return manualLowerBoundSettings.keys + } + + private fun provider(setting: UpstreamsConfig.ManualBoundSetting): ManualLowerBoundProvider { + return when (setting.type) { + ManualLowerBoundType.HEAD -> HeadManualLowerBoundProvider(upstream, setting) + ManualLowerBoundType.FIXED -> FixedManualLowerBoundProvider(setting) + } + } +} + +interface ManualLowerBoundProvider { + fun getManualLowerBound(): Long + fun canHandle(): Boolean +} + +class HeadManualLowerBoundProvider( + private val upstream: Upstream, + private val setting: UpstreamsConfig.ManualBoundSetting, +) : ManualLowerBoundProvider { + override fun getManualLowerBound(): Long { + return upstream.getHead().getCurrentHeight()!! + setting.value + } + + override fun canHandle(): Boolean { + return setting.type == ManualLowerBoundType.HEAD && + setting.value < 0 && + upstream.getHead().getCurrentHeight() != null && + upstream.getHead().getCurrentHeight()!! + setting.value >= 0 + } +} + +class FixedManualLowerBoundProvider( + private val setting: UpstreamsConfig.ManualBoundSetting, +) : ManualLowerBoundProvider { + override fun getManualLowerBound(): Long { + return setting.value + } + + override fun canHandle(): Boolean { + return setting.type == ManualLowerBoundType.FIXED && setting.value > 0 + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/GenericUpstreamMock.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/GenericUpstreamMock.groovy index b22bff24f..7fd0eb6bd 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/test/GenericUpstreamMock.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/test/GenericUpstreamMock.groovy @@ -79,6 +79,7 @@ class GenericUpstreamMock extends GenericUpstream { io.emeraldpay.dshackle.upstream.starknet.StarknetChainSpecific.INSTANCE.&lowerBoundService, io.emeraldpay.dshackle.upstream.starknet.StarknetChainSpecific.INSTANCE.&finalizationDetectorBuilder, [get: { null }] as java.util.function.Supplier, + null, ) this.ethereumHeadMock = this.getHead() as EthereumHeadMock setLag(0) diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy index 417a3227d..0a9bdda7d 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy @@ -80,6 +80,7 @@ class FilteredApisSpec extends Specification { cs.&lowerBoundService, cs.&finalizationDetectorBuilder, [get: { null }] as java.util.function.Supplier, + null, ) } def matcher = new Selector.LabelMatcher("test", ["foo"]) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt index ccd273ff7..b28d89f64 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReaderTest.kt @@ -2,6 +2,8 @@ package io.emeraldpay.dshackle.config import io.emeraldpay.dshackle.FileResolver import io.emeraldpay.dshackle.foundation.ChainOptionsReader +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.ManualLowerBoundType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue @@ -10,6 +12,62 @@ import java.io.File class UpstreamsConfigReaderTest { + @Test + fun `should parse additional settings with lower bounds`() { + val yaml = """ + version: v1 + upstreams: + - id: test-upstream + chain: ethereum + additional-settings: + manual-lower-bounds: + state: + type: head + value: -5000000 + block: + type: fixed + value: 1 + tx: + type: fixed + value: 123 + receipts: + type: fixed + value: 15661 + slot: + type: head + value: -34566 + connection: + ethereum: + rpc: + url: "http://localhost:8545" + """.trimIndent() + + val reader = UpstreamsConfigReader( + FileResolver(File(".")), + ChainOptionsReader(), + ) + + val config = reader.readInternal(yaml.byteInputStream()) + + assertNotNull(config) + assertEquals(1, config.upstreams.size) + + val upstream = config.upstreams[0] + assertEquals("test-upstream", upstream.id) + assertEquals( + UpstreamsConfig.AdditionalSettings( + mapOf( + LowerBoundType.SLOT to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, -34566), + LowerBoundType.RECEIPTS to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 15661), + LowerBoundType.TX to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 123), + LowerBoundType.BLOCK to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 1), + LowerBoundType.STATE to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, -5000000), + ), + ), + upstream.additionalSettings, + ) + } + @Test fun `should parse customHeaders from YAML`() { val yaml = """ diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt index 12ea47f9c..ab1908e43 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt @@ -42,6 +42,20 @@ class EthereumStateLowerBoundErrorHandlerTest { verify(upstream, never()).updateLowerBound(anyLong(), any()) } + @Test + fun `no update lower bound if execution reverted`() { + val upstream = mock() + val request = ChainRequest( + "eth_call", + ListParams("0x343", "0xCB5A0A8"), + ) + val handler = EthereumStateLowerBoundErrorHandler + + handler.handle(upstream, request, "execution reverted: Fallback not supported") + + verify(upstream, never()).updateLowerBound(anyLong(), any()) + } + @Test fun `no update lower bound if there is a non-state method`() { val upstream = mock() diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaLowerBoundStateDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaLowerBoundStateDetectorTest.kt index 6217fbc80..5ba02c467 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaLowerBoundStateDetectorTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/kadena/KadenaLowerBoundStateDetectorTest.kt @@ -3,6 +3,7 @@ package io.emeraldpay.dshackle.upstream.kadena import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.NoopManualLowerBoundService import org.junit.jupiter.api.Test import reactor.test.StepVerifier import java.time.Duration @@ -13,7 +14,7 @@ class KadenaLowerBoundStateDetectorTest { fun `kadena lower block is 1`() { val detector = KadenaLowerBoundStateDetector(Chain.KADENA__MAINNET) - StepVerifier.withVirtualTime { detector.detectLowerBound() } + StepVerifier.withVirtualTime { detector.detectLowerBound(NoopManualLowerBoundService()) } .expectSubscription() .expectNoEvent(Duration.ofSeconds(15)) .expectNext(LowerBoundData(1, LowerBoundType.STATE)) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/BaseManualLowerBoundServiceTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/BaseManualLowerBoundServiceTest.kt new file mode 100644 index 000000000..ef4fc11ba --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/BaseManualLowerBoundServiceTest.kt @@ -0,0 +1,176 @@ +package io.emeraldpay.dshackle.upstream.lowerbound + +import io.emeraldpay.dshackle.config.UpstreamsConfig +import io.emeraldpay.dshackle.upstream.Head +import io.emeraldpay.dshackle.upstream.Upstream +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class BaseManualLowerBoundServiceTest { + + @Test + fun `no manual settings then return nothing`() { + val upstream = mock() + val service = BaseManualLowerBoundService(upstream, emptyMap()) + + assertThat(service.manualBoundTypes()).isEmpty() + LowerBoundType.entries + .forEach { + assertThat(service.manualLowerBound(it)).isNull() + assertThat(service.hasManualBound(it)).isFalse + } + } + + @ParameterizedTest + @MethodSource("headProviderData") + fun `test head provider - canHandle`( + upstream: Upstream, + setting: UpstreamsConfig.ManualBoundSetting, + expected: Boolean, + ) { + val provider = HeadManualLowerBoundProvider(upstream, setting) + + assertThat(provider.canHandle()).isEqualTo(expected) + } + + @ParameterizedTest + @MethodSource("fixedProviderData") + fun `test fixed provider - canHandle`( + setting: UpstreamsConfig.ManualBoundSetting, + expected: Boolean, + ) { + val provider = FixedManualLowerBoundProvider(setting) + + assertThat(provider.canHandle()).isEqualTo(expected) + } + + @Test + fun `get value from head provider`() { + val upstream = mock { + val head = mock { + on { getCurrentHeight() } doReturn 100 + } + on { getHead() } doReturn head + } + val provider = HeadManualLowerBoundProvider(upstream, UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, -45)) + + assertThat(provider.getManualLowerBound()).isEqualTo(55) + } + + @Test + fun `get value from fixed provider`() { + val provider = FixedManualLowerBoundProvider(UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 200)) + + assertThat(provider.getManualLowerBound()).isEqualTo(200) + } + + @Test + fun `no supported manual type then null otherwise get a value`() { + val upstream = mock() + val settings = mapOf( + LowerBoundType.STATE to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 100), + ) + val service = BaseManualLowerBoundService(upstream, settings) + + assertThat(service.manualLowerBound(LowerBoundType.STATE)) + .usingRecursiveComparison() + .ignoringFields("timestamp") + .isEqualTo(LowerBoundData(100, LowerBoundType.STATE)) + assertThat(service.manualBoundTypes()).containsExactly(LowerBoundType.STATE) + assertThat(service.hasManualBound(LowerBoundType.STATE)).isTrue() + LowerBoundType.entries + .filter { it != LowerBoundType.STATE } + .forEach { + assertThat(service.manualLowerBound(it)).isNull() + assertThat(service.hasManualBound(it)).isFalse + } + } + + @Test + fun `can not be handled then null`() { + val upstream = mock() + val settings = mapOf( + LowerBoundType.STATE to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, -100), + ) + val service = BaseManualLowerBoundService(upstream, settings) + + assertThat(service.manualLowerBound(LowerBoundType.STATE)).isNull() + assertThat(service.manualBoundTypes()).containsExactly(LowerBoundType.STATE) + assertThat(service.hasManualBound(LowerBoundType.STATE)).isTrue() + LowerBoundType.entries + .filter { it != LowerBoundType.STATE } + .forEach { + assertThat(service.manualLowerBound(it)).isNull() + assertThat(service.hasManualBound(it)).isFalse + } + } + + companion object { + @JvmStatic + fun fixedProviderData(): List = + listOf( + Arguments.of( + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, 1), + false, + ), + Arguments.of( + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, -121), + false, + ), + Arguments.of( + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 55), + true, + ), + ) + + @JvmStatic + fun headProviderData(): List = + listOf( + Arguments.of( + mock(), + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 1), + false, + ), + Arguments.of( + mock(), + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, 1241), + false, + ), + Arguments.of( + mock { + val head = mock { + on { getCurrentHeight() } doReturn null + } + on { getHead() } doReturn head + }, + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, -100), + false, + ), + Arguments.of( + mock { + val head = mock { + on { getCurrentHeight() } doReturn 20 + } + on { getHead() } doReturn head + }, + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, -100), + false, + ), + Arguments.of( + mock { + val head = mock { + on { getCurrentHeight() } doReturn 150 + } + on { getHead() } doReturn head + }, + UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.HEAD, -100), + true, + ), + ) + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetectorTest.kt new file mode 100644 index 000000000..9768f07a0 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetectorTest.kt @@ -0,0 +1,95 @@ +package io.emeraldpay.dshackle.upstream.lowerbound + +import io.emeraldpay.dshackle.Chain +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import reactor.core.publisher.Flux +import reactor.test.StepVerifier +import java.time.Duration + +class LowerBoundDetectorTest { + + @Test + fun `receive only manual bounds`() { + val detector = TestLowerBoundDetector() + val manualBoundService = mock { + on { manualBoundTypes() } doReturn setOf(LowerBoundType.STATE, LowerBoundType.BLOCK) + on { hasManualBound(LowerBoundType.STATE) } doReturn true + on { hasManualBound(LowerBoundType.BLOCK) } doReturn true + on { manualLowerBound(LowerBoundType.STATE) } doReturn LowerBoundData(50L, 1000, LowerBoundType.STATE) + on { manualLowerBound(LowerBoundType.BLOCK) } doReturn LowerBoundData(80L, 1000, LowerBoundType.BLOCK) + } + + StepVerifier.withVirtualTime { detector.detectLowerBound(manualBoundService) } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNextSequence( + setOf( + LowerBoundData(50L, 1000, LowerBoundType.STATE), + LowerBoundData(80L, 1000, LowerBoundType.BLOCK), + ), + ) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `receive one manual and one calculated bounds`() { + val detector = TestLowerBoundDetector() + val manualBoundService = mock { + on { manualBoundTypes() } doReturn setOf(LowerBoundType.STATE) + on { hasManualBound(LowerBoundType.STATE) } doReturn true + on { hasManualBound(LowerBoundType.BLOCK) } doReturn false + on { manualLowerBound(LowerBoundType.STATE) } doReturn LowerBoundData(50L, 1000, LowerBoundType.STATE) + on { manualLowerBound(LowerBoundType.BLOCK) } doReturn null + } + + StepVerifier.withVirtualTime { detector.detectLowerBound(manualBoundService) } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNextSequence( + setOf( + LowerBoundData(5000L, 1000, LowerBoundType.BLOCK), + LowerBoundData(50L, 1000, LowerBoundType.STATE), + ), + ) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `receive only calculated bounds`() { + val detector = TestLowerBoundDetector() + val manualBoundService = BaseManualLowerBoundService(mock(), emptyMap()) + + StepVerifier.withVirtualTime { detector.detectLowerBound(manualBoundService) } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNextSequence( + setOf( + LowerBoundData(1000L, 1000, LowerBoundType.STATE), + LowerBoundData(5000L, 1000, LowerBoundType.BLOCK), + ), + ) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } +} + +private class TestLowerBoundDetector : LowerBoundDetector(Chain.CORE__MAINNET) { + override fun period(): Long { + return 1 + } + + override fun internalDetectLowerBound(): Flux { + return Flux.just( + LowerBoundData(1000L, 1000, LowerBoundType.STATE), + LowerBoundData(5000L, 1000, LowerBoundType.BLOCK), + ) + } + + override fun types(): Set { + return setOf(LowerBoundType.STATE, LowerBoundType.BLOCK) + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaLowerBoundServiceTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaLowerBoundServiceTest.kt index 7d0be676b..d0d48809f 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaLowerBoundServiceTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaLowerBoundServiceTest.kt @@ -2,12 +2,14 @@ package io.emeraldpay.dshackle.upstream.solana import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.reader.ChainReader import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.ManualLowerBoundType import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -76,4 +78,68 @@ class SolanaLowerBoundServiceTest { ), ) } + + @Test + fun `get solana lower block and slot with manual bounds`() { + val reader = mock { + on { read(ChainRequest("getFirstAvailableBlock", ListParams())) } doReturn + Mono.just(ChainResponse("25000000".toByteArray(), null)) + on { + read( + ChainRequest( + "getBlock", + ListParams( + 25000000L, + mapOf( + "showRewards" to false, + "transactionDetails" to "none", + "maxSupportedTransactionVersion" to 0, + ), + ), + ), + ) + } doReturn Mono.just( + ChainResponse( + Global.objectMapper.writeValueAsBytes( + mapOf( + "blockHeight" to 21000000, + "blockTime" to 111, + "blockhash" to "22", + "previousBlockhash" to "33", + ), + ), + null, + ), + ) + } + val settings = UpstreamsConfig.AdditionalSettings( + mapOf( + LowerBoundType.SLOT to UpstreamsConfig.ManualBoundSetting(ManualLowerBoundType.FIXED, 54L), + ), + ) + val upstream = mock { + on { getIngressReader() } doReturn reader + on { getChain() } doReturn Chain.UNSPECIFIED + on { getAdditionalSettings() } doReturn settings + } + + val detector = SolanaLowerBoundService(Chain.UNSPECIFIED, upstream) + + StepVerifier.withVirtualTime { detector.detectLowerBounds() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNextMatches { it.lowerBound == 21000000L && it.type == LowerBoundType.STATE } + .expectNextMatches { it.lowerBound == 54L && it.type == LowerBoundType.SLOT } + .thenCancel() + .verify(Duration.ofSeconds(3)) + + assertThat(detector.getLowerBounds().toList()) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("timestamp") + .hasSameElementsAs( + listOf( + LowerBoundData(21000000L, LowerBoundType.STATE), + LowerBoundData(54L, LowerBoundType.SLOT), + ), + ) + } } diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetLowerBoundStateDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetLowerBoundStateDetectorTest.kt index 473aefbae..79820bedc 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetLowerBoundStateDetectorTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/starknet/StarknetLowerBoundStateDetectorTest.kt @@ -3,6 +3,7 @@ package io.emeraldpay.dshackle.upstream.starknet import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.NoopManualLowerBoundService import org.junit.jupiter.api.Test import reactor.test.StepVerifier import java.time.Duration @@ -13,7 +14,7 @@ class StarknetLowerBoundStateDetectorTest { fun `starknet lower block is 1`() { val detector = StarknetLowerBoundStateDetector(Chain.STARKNET__MAINNET) - StepVerifier.withVirtualTime { detector.detectLowerBound() } + StepVerifier.withVirtualTime { detector.detectLowerBound(NoopManualLowerBoundService()) } .expectSubscription() .expectNoEvent(Duration.ofSeconds(15)) .expectNext(LowerBoundData(1, LowerBoundType.STATE)) From 2e4c99b20200e271ac9edfab619fa7d8e2b7ebd4 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:41:56 +0200 Subject: [PATCH 035/108] Fix Hyperliquid native tx detection on testnet (#780) --- .../ethereum/EthereumUpstreamSettingsDetector.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt index 022191e99..beca4ca91 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt @@ -14,6 +14,8 @@ import reactor.core.publisher.Mono import java.util.concurrent.atomic.AtomicInteger const val ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +const val HL_NATIVE_TX_FROM_MAINNET = "0x2222222222222222222222222222222222222222" +const val HL_NATIVE_TX_FROM_TESTNET = "0x6ed35e7d6de4b45f4efb8a91eff31afa49362569" class EthereumUpstreamSettingsDetector( private val _upstream: Upstream, @@ -181,6 +183,11 @@ class EthereumUpstreamSettingsDetector( if (chain != Chain.HYPERLIQUID__MAINNET && chain != Chain.HYPERLIQUID__TESTNET) { return Flux.empty() } + val hlNativeTxFrom = when (chain) { + Chain.HYPERLIQUID__MAINNET -> HL_NATIVE_TX_FROM_MAINNET + Chain.HYPERLIQUID__TESTNET -> HL_NATIVE_TX_FROM_TESTNET + else -> return Flux.empty() + } if (detectCounter.get() % 5 != 1) { return Flux.empty() // reduce frequency of detection } @@ -211,7 +218,7 @@ class EthereumUpstreamSettingsDetector( if (receiptsJson.isArray) { receiptsJson.forEach { receipt -> val from = receipt.get("from")?.asText() - if (from == "0x2222222222222222222222222222222222222222") { // system topup transaction + if (from == hlNativeTxFrom) { // system topup transaction foundHlNativeTx = true } } From 3b5940cc208cd39713d9623bea53b9ad2520d3f3 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Wed, 28 Jan 2026 12:54:50 +0400 Subject: [PATCH 036/108] Integrate rx/receipts gold bounds (#781) --- .../org/drpc/chainsconfig/ChainsConfig.kt | 25 ++++++++ .../drpc/chainsconfig/ChainsConfigReader.kt | 38 +++++++++++ foundation/src/main/resources/public | 2 +- .../chainsconfig/ChainsConfigReaderTest.kt | 15 ++++- .../test/resources/configs/chains-basic.yaml | 13 +++- .../startup/configure/UpstreamCreator.kt | 7 +++ .../EthereumLowerBoundReceiptsDetector.kt | 37 +++++++++-- .../ethereum/EthereumLowerBoundTxDetector.kt | 37 +++++++++-- .../upstream/lowerbound/GoldLowerBounds.kt | 28 +++++++++ .../upstream/lowerbound/LowerBoundDetector.kt | 2 +- .../EthereumLowerBoundReceiptsDetectorTest.kt | 56 +++++++++++++++++ .../EthereumLowerBoundTxDetectorTest.kt | 56 +++++++++++++++++ .../lowerbound/GoldLowerBoundsTest.kt | 63 +++++++++++++++++++ .../RecursiveLowerBoundServiceTest.kt | 7 +++ 14 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBounds.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetectorTest.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetectorTest.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBoundsTest.kt diff --git a/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt b/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt index 7ffa3c7b0..4d174a6ff 100644 --- a/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt +++ b/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfig.kt @@ -44,6 +44,25 @@ data class ChainsConfig(private val chains: List) : Iterable "$op $limit" } } + enum class LowerBoundType { + UNKNOWN, STATE, SLOT, BLOCK, TX, LOGS, TRACE, PROOF, BLOB, EPOCH, RECEIPTS; + + companion object { + fun byName(name: String): LowerBoundType { + return LowerBoundType.entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: UNKNOWN + } + } + } + + open class GoldLowerBound( + val block: Long, + ) + + class GoldLowerBoundWithHash( + block: Long, + val hash: String, + ) : GoldLowerBound(block) + data class ChainConfig( val expectedBlockTime: Duration, val syncingLagSize: Int, @@ -59,6 +78,7 @@ data class ChainsConfig(private val chains: List) : Iterable ) { companion object { @JvmStatic @@ -80,6 +100,7 @@ data class ChainsConfig(private val chains: List) : Iterable) : Iterable { + return chainMap.values + } } diff --git a/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfigReader.kt b/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfigReader.kt index 19c0666b7..e79340047 100644 --- a/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfigReader.kt +++ b/foundation/src/main/kotlin/org/drpc/chainsconfig/ChainsConfigReader.kt @@ -137,9 +137,47 @@ class ChainsConfigReader( blockchain = blockchain, type = type, gasPriceCondition = ChainsConfig.GasPriceCondition(gasPriceConditions), + goldLowerBounds = readGoldLowerBounds(node), ) } + private fun readGoldLowerBounds(node: MappingNode): Map { + val goldLowerBounds = getMapping(node, "lower-bounds") ?: return emptyMap() + + return goldLowerBounds.value.asSequence() + .filter { + (it.keyNode.valueAsString()?.isNotBlank() ?: false) && it.valueNode.tag == Tag.MAP + }.map { + val type = ChainsConfig.LowerBoundType.byName(it.keyNode.valueAsString()!!) + val bound = if (type == ChainsConfig.LowerBoundType.TX || type == ChainsConfig.LowerBoundType.RECEIPTS) { + readHashGoldLowerBound(it.valueNode as MappingNode) + } else { + readGoldLowerBound(it.valueNode as MappingNode) + } + + if (bound != null) { + type to bound + } else { + null + } + } + .filterNotNull() + .associate { it } + } + + private fun readGoldLowerBound(node: MappingNode): ChainsConfig.GoldLowerBound? { + val block = getValueAsLong(node, "block") ?: return null + + return ChainsConfig.GoldLowerBound(block) + } + + private fun readHashGoldLowerBound(node: MappingNode): ChainsConfig.GoldLowerBound? { + val hash = getValueAsString(node, "hash") ?: return null + val block = getValueAsLong(node, "block") ?: return null + + return ChainsConfig.GoldLowerBoundWithHash(block, hash) + } + override fun read(input: MappingNode?): ChainsConfig { val default = readChains(readNode(defaultConfig)) val current = readChains(input) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 5ad28fd81..b381391a2 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 5ad28fd817b3615ce3534f9df63b14735f5f7f01 +Subproject commit b381391a23f75edc1c86dddf3a9aa91cc7a852e1 diff --git a/foundation/src/test/kotlin/org/drpc/chainsconfig/ChainsConfigReaderTest.kt b/foundation/src/test/kotlin/org/drpc/chainsconfig/ChainsConfigReaderTest.kt index abc1f6a12..c1a529d3f 100644 --- a/foundation/src/test/kotlin/org/drpc/chainsconfig/ChainsConfigReaderTest.kt +++ b/foundation/src/test/kotlin/org/drpc/chainsconfig/ChainsConfigReaderTest.kt @@ -1,12 +1,13 @@ package org.drpc.chainsconfig +import io.emeraldpay.dshackle.config.ChainsConfig import io.emeraldpay.dshackle.config.ChainsConfigReader import io.emeraldpay.dshackle.foundation.ChainOptionsReader import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.math.BigInteger -internal class ChainsConfigReaderTest { +class ChainsConfigReaderTest { @Test fun `read standard config without custom`() { @@ -41,5 +42,17 @@ internal class ChainsConfigReaderTest { assertEquals(config.code, "FTA") assertEquals(config.grpcId, 102) assertEquals(config.netVersion, BigInteger.valueOf(251)) + assertEquals(15L, config.goldLowerBounds[ChainsConfig.LowerBoundType.STATE]!!.block) + assertEquals(150L, config.goldLowerBounds[ChainsConfig.LowerBoundType.BLOCK]!!.block) + assertEquals(1L, config.goldLowerBounds[ChainsConfig.LowerBoundType.TX]!!.block) + assertEquals( + "0x5e77a04531c7c107af1882d76cbff9486d0a9aa53701c30888509d4f5f2b003a", + (config.goldLowerBounds[ChainsConfig.LowerBoundType.TX] as ChainsConfig.GoldLowerBoundWithHash).hash, + ) + assertEquals(1000L, config.goldLowerBounds[ChainsConfig.LowerBoundType.RECEIPTS]!!.block) + assertEquals( + "0x4e72a04531c7c107af1882d76cbff9486d0a9aa53701c30888509d4f5f2b003a", + (config.goldLowerBounds[ChainsConfig.LowerBoundType.RECEIPTS] as ChainsConfig.GoldLowerBoundWithHash).hash, + ) } } diff --git a/foundation/src/test/resources/configs/chains-basic.yaml b/foundation/src/test/resources/configs/chains-basic.yaml index 8ba3e3af9..cae9937fa 100644 --- a/foundation/src/test/resources/configs/chains-basic.yaml +++ b/foundation/src/test/resources/configs/chains-basic.yaml @@ -16,4 +16,15 @@ chain-settings: short-names: [fantom] code: FTA grpcId: 102 - chain-id: 0xfb \ No newline at end of file + chain-id: 0xfb + lower-bounds: + tx: + block: 1 + hash: "0x5e77a04531c7c107af1882d76cbff9486d0a9aa53701c30888509d4f5f2b003a" + receipts: + block: 1000 + hash: "0x4e72a04531c7c107af1882d76cbff9486d0a9aa53701c30888509d4f5f2b003a" + state: + block: 15 + block: + block: 150 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt index 5a7e7f276..ec36a2d79 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt @@ -9,6 +9,8 @@ import io.emeraldpay.dshackle.foundation.ChainOptions.Options import io.emeraldpay.dshackle.upstream.CallTargetsHolder import io.emeraldpay.dshackle.upstream.calls.CallMethods import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods +import io.emeraldpay.dshackle.upstream.lowerbound.GoldLowerBounds +import jakarta.annotation.PostConstruct import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.function.Function @@ -20,6 +22,11 @@ abstract class UpstreamCreator( ) { protected val log: Logger = LoggerFactory.getLogger(this::class.java) + @PostConstruct + fun init() { + GoldLowerBounds.init(chainsConfig.getChainConfigs()) + } + companion object { fun getHash(nodeId: Int?, obj: Any, hashes: MutableSet): Short { val hash = nodeId?.toShort() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt index f29a6d7ae..872df450f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt @@ -1,15 +1,19 @@ package io.emeraldpay.dshackle.upstream.ethereum import io.emeraldpay.dshackle.Defaults +import io.emeraldpay.dshackle.config.ChainsConfig import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.lowerbound.GoldLowerBounds import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType import io.emeraldpay.dshackle.upstream.lowerbound.detector.RecursiveLowerBound import io.emeraldpay.dshackle.upstream.lowerbound.toHex import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import reactor.core.publisher.Flux +import reactor.core.publisher.Mono class EthereumLowerBoundReceiptsDetector( private val upstream: Upstream, @@ -29,6 +33,7 @@ class EthereumLowerBoundReceiptsDetector( "invalid block height", // hyperliquid "header not found", "pruned history unavailable", // xlayer + "transaction indexing is in progress", ).plus(EthereumLowerBoundBlockDetector.NO_BLOCK_ERRORS) } @@ -39,6 +44,34 @@ class EthereumLowerBoundReceiptsDetector( } override fun internalDetectLowerBound(): Flux { + val receiptsGoldBound = GoldLowerBounds.getBound(upstream.getChain(), LowerBoundType.RECEIPTS) + if (receiptsGoldBound == null || receiptsGoldBound !is ChainsConfig.GoldLowerBoundWithHash) { + return recursiveDetectReceiptsLowerBound() + } + return Mono.just(receiptsGoldBound) + .flatMapMany { bound -> + upstream.getIngressReader() + .read(ChainRequest("eth_getTransactionReceipt", ListParams(bound.hash))) + .timeout(Defaults.internalCallsTimeout) + .flatMap(ChainResponse::requireResult) + .flatMapMany { + if (it.contentEquals("null".toByteArray())) { + throw IllegalStateException("no gold bound") + } else { + Flux.just(LowerBoundData(1, LowerBoundType.RECEIPTS)) + } + } + .onErrorResume { + recursiveDetectReceiptsLowerBound() + } + } + } + + override fun types(): Set { + return setOf(LowerBoundType.RECEIPTS) + } + + private fun recursiveDetectReceiptsLowerBound(): Flux { return recursiveLowerBound.recursiveDetectLowerBoundWithOffset(MAX_OFFSET) { block -> upstream.getIngressReader() .read( @@ -72,8 +105,4 @@ class EthereumLowerBoundReceiptsDetector( } } } - - override fun types(): Set { - return setOf(LowerBoundType.RECEIPTS) - } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt index 48f398331..5a0051369 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt @@ -1,15 +1,19 @@ package io.emeraldpay.dshackle.upstream.ethereum import io.emeraldpay.dshackle.Defaults +import io.emeraldpay.dshackle.config.ChainsConfig import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.lowerbound.GoldLowerBounds import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType import io.emeraldpay.dshackle.upstream.lowerbound.detector.RecursiveLowerBound import io.emeraldpay.dshackle.upstream.lowerbound.toHex import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import reactor.core.publisher.Flux +import reactor.core.publisher.Mono class EthereumLowerBoundTxDetector( private val upstream: Upstream, @@ -24,6 +28,7 @@ class EthereumLowerBoundTxDetector( "Unexpected error", // hyperliquid "invalid block height", // hyperliquids "pruned history unavailable", // xlayer blocks + "transaction indexing is in progress", ).plus(EthereumLowerBoundBlockDetector.NO_BLOCK_ERRORS) } @@ -34,6 +39,34 @@ class EthereumLowerBoundTxDetector( } override fun internalDetectLowerBound(): Flux { + val txGoldBound = GoldLowerBounds.getBound(upstream.getChain(), LowerBoundType.TX) + if (txGoldBound == null || txGoldBound !is ChainsConfig.GoldLowerBoundWithHash) { + return recursiveDetectTxLowerBound() + } + return Mono.just(txGoldBound) + .flatMapMany { bound -> + upstream.getIngressReader() + .read(ChainRequest("eth_getTransactionByHash", ListParams(bound.hash))) + .timeout(Defaults.internalCallsTimeout) + .flatMap(ChainResponse::requireResult) + .flatMapMany { + if (it.contentEquals("null".toByteArray())) { + throw IllegalStateException("no gold bound") + } else { + Flux.just(LowerBoundData(1, LowerBoundType.TX)) + } + } + .onErrorResume { + recursiveDetectTxLowerBound() + } + } + } + + override fun types(): Set { + return setOf(LowerBoundType.TX) + } + + private fun recursiveDetectTxLowerBound(): Flux { return recursiveLowerBound.recursiveDetectLowerBoundWithOffset(MAX_OFFSET) { block -> upstream.getIngressReader() .read( @@ -67,8 +100,4 @@ class EthereumLowerBoundTxDetector( } } } - - override fun types(): Set { - return setOf(LowerBoundType.TX) - } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBounds.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBounds.kt new file mode 100644 index 000000000..82a8ab78d --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBounds.kt @@ -0,0 +1,28 @@ +package io.emeraldpay.dshackle.upstream.lowerbound + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.config.ChainsConfig + +object GoldLowerBounds { + + private lateinit var bounds: Map> + + fun init(chainConfigs: Collection) { + bounds = chainConfigs.filter { + it.shortNames.isNotEmpty() && + Global.chainById(it.shortNames[0]) != Chain.UNSPECIFIED && + it.goldLowerBounds.isNotEmpty() + }.associate { cfg -> + Global.chainById(cfg.shortNames[0]) to cfg.goldLowerBounds.mapKeys { toLowerBoundType(it.key) } + } + } + + fun getBound(chain: Chain, boundType: LowerBoundType): ChainsConfig.GoldLowerBound? { + return bounds[chain]?.get(boundType) + } + + private fun toLowerBoundType(type: ChainsConfig.LowerBoundType): LowerBoundType { + return LowerBoundType.byName(type.name) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt index 2dbd50026..e56226bf0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/LowerBoundDetector.kt @@ -47,7 +47,7 @@ abstract class LowerBoundDetector( }, ) .filter { - it.lowerBound >= (lowerBounds.getLastBound(it.type)?.lowerBound ?: 0) + (it.lowerBound >= (lowerBounds.getLastBound(it.type)?.lowerBound ?: 0)) || it.lowerBound == 1L } .map { lowerBounds.updateBound(it) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetectorTest.kt new file mode 100644 index 000000000..f3bbdecab --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetectorTest.kt @@ -0,0 +1,56 @@ +package io.emeraldpay.dshackle.upstream.ethereum + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.config.ChainsConfig +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.lowerbound.GoldLowerBounds +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.NoopManualLowerBoundService +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import java.time.Duration + +class EthereumLowerBoundReceiptsDetectorTest { + + @Test + fun `archival bound if there is a gold bound`() { + val reader = mock { + on { read(ChainRequest("eth_getTransactionReceipt", ListParams("goldHash"))) } doReturn + Mono.just(ChainResponse("result".toByteArray(), null)) + } + val upstream = mock { + on { getChain() } doReturn Chain.POLYGON__MAINNET + on { getIngressReader() } doReturn reader + } + GoldLowerBounds.init( + listOf( + ChainsConfig.ChainConfig + .default() + .copy( + shortNames = listOf("polygon"), + goldLowerBounds = mapOf(ChainsConfig.LowerBoundType.RECEIPTS to ChainsConfig.GoldLowerBoundWithHash(10L, "goldHash")), + ), + ), + ) + + val txDetector = EthereumLowerBoundReceiptsDetector(upstream) + + StepVerifier.withVirtualTime { txDetector.detectLowerBound(NoopManualLowerBoundService()) } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNextMatches { it.lowerBound == 1L && it.type == LowerBoundType.RECEIPTS } + .thenCancel() + .verify(Duration.ofSeconds(1)) + + verify(reader).read(ChainRequest("eth_getTransactionReceipt", ListParams("goldHash"))) + verify(upstream).getIngressReader() + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetectorTest.kt new file mode 100644 index 000000000..61e38d572 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetectorTest.kt @@ -0,0 +1,56 @@ +package io.emeraldpay.dshackle.upstream.ethereum + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.config.ChainsConfig +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.lowerbound.GoldLowerBounds +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.NoopManualLowerBoundService +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import java.time.Duration + +class EthereumLowerBoundTxDetectorTest { + + @Test + fun `archival bound if there is a gold bound`() { + val reader = mock { + on { read(ChainRequest("eth_getTransactionByHash", ListParams("goldHash"))) } doReturn + Mono.just(ChainResponse("result".toByteArray(), null)) + } + val upstream = mock { + on { getChain() } doReturn Chain.POLYGON__MAINNET + on { getIngressReader() } doReturn reader + } + GoldLowerBounds.init( + listOf( + ChainsConfig.ChainConfig + .default() + .copy( + shortNames = listOf("polygon"), + goldLowerBounds = mapOf(ChainsConfig.LowerBoundType.TX to ChainsConfig.GoldLowerBoundWithHash(10L, "goldHash")), + ), + ), + ) + + val txDetector = EthereumLowerBoundTxDetector(upstream) + + StepVerifier.withVirtualTime { txDetector.detectLowerBound(NoopManualLowerBoundService()) } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNextMatches { it.lowerBound == 1L && it.type == LowerBoundType.TX } + .thenCancel() + .verify(Duration.ofSeconds(1)) + + verify(reader).read(ChainRequest("eth_getTransactionByHash", ListParams("goldHash"))) + verify(upstream).getIngressReader() + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBoundsTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBoundsTest.kt new file mode 100644 index 000000000..12d5faa87 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/GoldLowerBoundsTest.kt @@ -0,0 +1,63 @@ +package io.emeraldpay.dshackle.upstream.lowerbound + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.config.ChainsConfig +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class GoldLowerBoundsTest { + + @Test + fun `init with all bounds`() { + val cfg = ChainsConfig.ChainConfig.default() + .copy( + shortNames = listOf("polygon"), + goldLowerBounds = ChainsConfig.LowerBoundType.entries + .associateWith { + if (it == ChainsConfig.LowerBoundType.TX || it == ChainsConfig.LowerBoundType.RECEIPTS) { + ChainsConfig.GoldLowerBoundWithHash(5L, "hash_$it") + } else { + ChainsConfig.GoldLowerBound(10L) + } + }, + ) + + GoldLowerBounds.init(listOf(cfg)) + + LowerBoundType.entries + .filter { it != LowerBoundType.UNKNOWN } + .forEach { + val bound = GoldLowerBounds.getBound(Chain.POLYGON__MAINNET, it) + + assertThat(bound).isNotNull + + if (it == LowerBoundType.TX || it == LowerBoundType.RECEIPTS) { + assertThat(bound) + .usingRecursiveComparison() + .isEqualTo(ChainsConfig.GoldLowerBoundWithHash(5L, "hash_$it")) + } else { + assertThat(bound) + .usingRecursiveComparison() + .isEqualTo(ChainsConfig.GoldLowerBound(10L)) + } + } + } + + @Test + fun `no gold bound`() { + val cfg = ChainsConfig.ChainConfig.default() + .copy( + shortNames = listOf("polygon"), + ) + + GoldLowerBounds.init(listOf(cfg)) + + LowerBoundType.entries + .filter { it != LowerBoundType.UNKNOWN } + .forEach { + val bound = GoldLowerBounds.getBound(Chain.POLYGON__MAINNET, it) + + assertThat(bound).isNull() + } + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/RecursiveLowerBoundServiceTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/RecursiveLowerBoundServiceTest.kt index a5fd2bc4b..bbe29ad24 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/RecursiveLowerBoundServiceTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/lowerbound/RecursiveLowerBoundServiceTest.kt @@ -13,6 +13,7 @@ import io.emeraldpay.dshackle.upstream.ethereum.ZERO_ADDRESS import io.emeraldpay.dshackle.upstream.polkadot.PolkadotLowerBoundService import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -247,6 +248,12 @@ class RecursiveLowerBoundServiceTest { private const val STATE_CHECKER_CALL_DATA = "0x1eaf190c" private const val STATE_CHECKER_BYTECODE = "0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c80631eaf190c14602a575b5f5ffd5b60306044565b604051603b91906078565b60405180910390f35b5f5f73ffffffffffffffffffffffffffffffffffffffff1631905090565b5f819050919050565b6072816062565b82525050565b5f60208201905060895f830184606b565b9291505056fea2646970667358221220251f5b4d2ed1abe77f66fde198a57ada08562dc3b0afbc6bac0261d1bf516b5d64736f6c634300081e0033" + @BeforeAll + @JvmStatic + fun init() { + GoldLowerBounds.init(emptyList()) + } + @JvmStatic fun detectorsFirstBlock(): List = listOf( Arguments.of( From 5b4e8884ba446088853f73cb03ad7ffa25b72658 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Wed, 28 Jan 2026 18:06:14 +0700 Subject: [PATCH 037/108] Handle 429 error (#782) --- foundation/src/main/resources/public | 2 +- .../upstream/ethereum/EthereumLowerBoundProofDetector.kt | 1 + .../emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index b381391a2..78bdaca75 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit b381391a23f75edc1c86dddf3a9aa91cc7a852e1 +Subproject commit 78bdaca754a2fda851f3bffe8a7e5ae83ffe3985 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt index 6a0a3db66..20404b7a3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt @@ -29,6 +29,7 @@ class EthereumLowerBoundProofDetector( "no historical RPC is available", "Method not found", // Monad error bc they don't have eth_getProofs "invalid block height", // hyperliquid + "not supported", ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt index dce03d80d..843a0be84 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt @@ -107,8 +107,8 @@ class JsonRpcHttpReader( resp.map { when (it) { is AggregateResponse -> { - val parsed = parser.parse(it.response) val statusCode = it.code + val parsed = parser.parse(it.response) if (statusCode != 200) { if (parsed.hasError() && parsed.error!!.code != RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE) { // extracted the error details from the HTTP Body From 28108a11db97373496c9a22b48e76e0dec28fa7c Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 29 Jan 2026 12:34:31 +0700 Subject: [PATCH 038/108] Update grpc (#783) --- emerald-grpc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emerald-grpc b/emerald-grpc index 38db8b3fe..1062d0d92 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 38db8b3fefb81f105c64a61ce0b2027c654066c6 +Subproject commit 1062d0d92840f5f9277c3e204697e3c0edcda426 From 116807054a3878f09367a38ecc49941c108a6a8c Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 29 Jan 2026 21:12:40 +0700 Subject: [PATCH 039/108] Add tempo support (#784) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 1062d0d92..0986b4b9d 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 1062d0d92840f5f9277c3e204697e3c0edcda426 +Subproject commit 0986b4b9db80f84e61121b1b0c7dd17b6824fa13 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 78bdaca75..23fcd3989 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 78bdaca754a2fda851f3bffe8a7e5ae83ffe3985 +Subproject commit 23fcd39890ef1fd2ff0fdb73021361043d513feb From 4d4a2ed655c4b7a59168bf33e3cf628eace6301e Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:04:05 +0200 Subject: [PATCH 040/108] add aztec networks (#785) * add aztec networks * fix linter errors & checkout from main (public) * fix AztecLowerBoundService --- .../kotlin/chainsconfig.codegen.gradle.kts | 1 + emerald-grpc | 2 +- .../io/emeraldpay/dshackle/BlockchainType.kt | 3 +- foundation/src/main/resources/public | 2 +- .../dshackle/upstream/CallTargetsHolder.kt | 3 + .../upstream/aztec/AztecChainSpecific.kt | 177 ++++++++++++++++++ .../upstream/aztec/AztecLowerBoundService.kt | 15 ++ .../aztec/AztecLowerBoundStateDetector.kt | 24 +++ .../upstream/calls/DefaultAztecMethods.kt | 94 ++++++++++ .../upstream/generic/ChainSpecific.kt | 3 + 10 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt diff --git a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts index 445d89cca..40d5f3a4e 100644 --- a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts +++ b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts @@ -128,6 +128,7 @@ open class CodeGen(private val config: ChainsConfig) { private fun type(type: String): String { return when(type) { + "aztec" -> "BlockchainType.AZTEC" "eth" -> "BlockchainType.ETHEREUM" "bitcoin" -> "BlockchainType.BITCOIN" "starknet" -> "BlockchainType.STARKNET" diff --git a/emerald-grpc b/emerald-grpc index 0986b4b9d..38db8b3fe 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 0986b4b9db80f84e61121b1b0c7dd17b6824fa13 +Subproject commit 38db8b3fefb81f105c64a61ce0b2027c654066c6 diff --git a/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt b/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt index 1f10d9bcd..227f6dded 100644 --- a/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt +++ b/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt @@ -5,6 +5,7 @@ enum class BlockchainType( ) { UNKNOWN(ApiType.JSON_RPC), BITCOIN(ApiType.JSON_RPC), + AZTEC(ApiType.JSON_RPC), ETHEREUM(ApiType.JSON_RPC), STARKNET(ApiType.JSON_RPC), POLKADOT(ApiType.JSON_RPC), @@ -19,4 +20,4 @@ enum class BlockchainType( enum class ApiType { JSON_RPC, REST; -} \ No newline at end of file +} diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 23fcd3989..91a3853af 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 23fcd39890ef1fd2ff0fdb73021361043d513feb +Subproject commit 91a3853af9b88b951674cd872644225108ec5868 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt index 6e5e05c4d..fa51101b3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt @@ -1,5 +1,6 @@ package io.emeraldpay.dshackle.upstream +import io.emeraldpay.dshackle.BlockchainType.AZTEC import io.emeraldpay.dshackle.BlockchainType.BITCOIN import io.emeraldpay.dshackle.BlockchainType.COSMOS import io.emeraldpay.dshackle.BlockchainType.ETHEREUM @@ -16,6 +17,7 @@ import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.foundation.ChainOptions import io.emeraldpay.dshackle.upstream.calls.CallMethods +import io.emeraldpay.dshackle.upstream.calls.DefaultAztecMethods import io.emeraldpay.dshackle.upstream.calls.DefaultBeaconChainMethods import io.emeraldpay.dshackle.upstream.calls.DefaultBitcoinMethods import io.emeraldpay.dshackle.upstream.calls.DefaultCosmosMethods @@ -47,6 +49,7 @@ class CallTargetsHolder { ): CallMethods { val created = when (chain.type) { BITCOIN -> DefaultBitcoinMethods(options.providesBalance == true) + AZTEC -> DefaultAztecMethods() ETHEREUM -> DefaultEthereumMethods(chain) STARKNET -> DefaultStarknetMethods(chain) POLKADOT -> DefaultPolkadotMethods(chain) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt new file mode 100644 index 000000000..10b3a253f --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt @@ -0,0 +1,177 @@ +package io.emeraldpay.dshackle.upstream.aztec + +import com.fasterxml.jackson.databind.JsonNode +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.config.ChainsConfig.ChainConfig +import io.emeraldpay.dshackle.data.BlockContainer +import io.emeraldpay.dshackle.data.BlockId +import io.emeraldpay.dshackle.foundation.ChainOptions.Options +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator +import io.emeraldpay.dshackle.upstream.SingleValidator +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult +import io.emeraldpay.dshackle.upstream.generic.AbstractPollChainSpecific +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import java.math.BigInteger +import java.time.Instant + +object AztecChainSpecific : AbstractPollChainSpecific() { + private val log = LoggerFactory.getLogger(AztecChainSpecific::class.java) + + override fun parseBlock(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + val root = Global.objectMapper.readTree(data) + val height = parseLong( + findNode( + root, + "number", + "header.number", + "blockNumber", + "header.blockNumber", + "header.globalVariables.blockNumber", + ), + ) ?: 0L + val hashValue = parseText(findNode(root, "hash", "header.hash", "blockHash", "header.blockHash")) + val parentValue = parseText( + findNode( + root, + "parentHash", + "header.parentHash", + "parent_hash", + "header.parent_hash", + "prevHash", + "header.prevHash", + ), + ) + val timestamp = parseInstant( + findNode( + root, + "timestamp", + "header.timestamp", + "header.globalVariables.timestamp", + ), + ) ?: Instant.EPOCH + + if (hashValue == null) { + log.warn("Aztec block hash is missing in response from upstream {}", upstreamId) + } + + return Mono.just( + BlockContainer( + height = height, + hash = BlockId.from(hashValue ?: "0x0"), + difficulty = BigInteger.ZERO, + timestamp = timestamp, + full = false, + json = data, + parsed = root, + transactions = emptyList(), + upstreamId = upstreamId, + parentHash = parentValue?.let { BlockId.from(it) }, + ), + ) + } + + override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + throw NotImplementedError() + } + + override fun listenNewHeadsRequest(): ChainRequest { + throw NotImplementedError() + } + + override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest { + throw NotImplementedError() + } + + override fun upstreamValidators( + chain: Chain, + upstream: Upstream, + options: Options, + config: ChainConfig, + ): List> { + return listOf( + GenericSingleCallValidator( + ChainRequest("node_isReady", ListParams()), + upstream, + ) { data -> + val raw = Global.objectMapper.readTree(data) + val ready = when { + raw.isBoolean -> raw.asBoolean() + raw.isTextual -> raw.asText().equals("true", ignoreCase = true) + else -> raw.asBoolean(false) + } + if (ready) UpstreamAvailability.OK else UpstreamAvailability.SYNCING + }, + ) + } + + override fun upstreamSettingsValidators( + chain: Chain, + upstream: Upstream, + options: Options, + config: ChainConfig, + ): List> { + return emptyList() + } + + override fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService { + return AztecLowerBoundService(chain, upstream) + } + + override fun latestBlockRequest(): ChainRequest = + ChainRequest("node_getBlock", ListParams("latest")) + + private fun findNode(root: JsonNode, vararg paths: String): JsonNode? { + for (path in paths) { + var current: JsonNode? = root + for (part in path.split(".")) { + current = current?.get(part) + if (current == null || current.isMissingNode) { + break + } + } + if (current != null && !current.isMissingNode && !current.isNull) { + return current + } + } + return null + } + + private fun parseText(node: JsonNode?): String? { + if (node == null || node.isNull || node.isMissingNode) { + return null + } + return node.asText().ifBlank { null } + } + + private fun parseLong(node: JsonNode?): Long? { + if (node == null || node.isNull || node.isMissingNode) { + return null + } + return when { + node.isNumber -> node.asLong() + node.isTextual -> parseNumericString(node.asText()) + else -> null + } + } + + private fun parseNumericString(value: String): Long? { + val trimmed = value.trim() + if (trimmed.isEmpty()) return null + val isHex = trimmed.startsWith("0x") || trimmed.startsWith("0X") + val raw = if (isHex) trimmed.substring(2) else trimmed + return runCatching { BigInteger(raw, if (isHex) 16 else 10).toLong() }.getOrNull() + } + + private fun parseInstant(node: JsonNode?): Instant? { + val ts = parseLong(node) ?: return null + return if (ts >= 1_000_000_000_000L) Instant.ofEpochMilli(ts) else Instant.ofEpochSecond(ts) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt new file mode 100644 index 000000000..69f327aa0 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt @@ -0,0 +1,15 @@ +package io.emeraldpay.dshackle.upstream.aztec + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService + +class AztecLowerBoundService( + chain: Chain, + private val upstream: Upstream, +) : LowerBoundService(chain, upstream) { + override fun detectors(): List { + return listOf(AztecLowerBoundStateDetector(upstream.getChain())) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt new file mode 100644 index 000000000..fc29f1ab7 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt @@ -0,0 +1,24 @@ +package io.emeraldpay.dshackle.upstream.aztec + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import reactor.core.publisher.Flux + +class AztecLowerBoundStateDetector( + chain: Chain, +) : LowerBoundDetector(chain) { + + override fun period(): Long { + return 120 + } + + override fun internalDetectLowerBound(): Flux { + return Flux.just(LowerBoundData(1, LowerBoundType.STATE)) + } + + override fun types(): Set { + return setOf(LowerBoundType.STATE) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt new file mode 100644 index 000000000..ede7c9026 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt @@ -0,0 +1,94 @@ +package io.emeraldpay.dshackle.upstream.calls + +import io.emeraldpay.dshackle.quorum.AlwaysQuorum +import io.emeraldpay.dshackle.quorum.BroadcastQuorum +import io.emeraldpay.dshackle.quorum.CallQuorum +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException + +class DefaultAztecMethods : CallMethods { + + private val broadcast = setOf( + "node_sendTx", + ) + + private val allowedMethods: Set = setOf( + "node_getBlockNumber", + "node_getProvenBlockNumber", + "node_getL2Tips", + "node_getBlock", + "node_getBlocks", + "node_getBlockHeader", + "node_sendTx", + "node_getTxReceipt", + "node_getTxEffect", + "node_getTxByHash", + "node_getPendingTxs", + "node_getPendingTxCount", + "node_isValidTx", + "node_simulatePublicCalls", + "node_getPublicStorageAt", + "node_getWorldStateSyncStatus", + "node_findLeavesIndexes", + "node_getNullifierSiblingPath", + "node_getNoteHashSiblingPath", + "node_getArchiveSiblingPath", + "node_getPublicDataSiblingPath", + "node_getNullifierMembershipWitness", + "node_getLowNullifierMembershipWitness", + "node_getPublicDataWitness", + "node_getArchiveMembershipWitness", + "node_getNoteHashMembershipWitness", + "node_getL1ToL2MessageMembershipWitness", + "node_getL1ToL2MessageBlock", + "node_isL1ToL2MessageSynced", + "node_getL2ToL1Messages", + "node_getPrivateLogs", + "node_getPublicLogs", + "node_getContractClassLogs", + "node_getLogsByTags", + "node_getContractClass", + "node_getContract", + "node_isReady", + "node_getNodeInfo", + "node_getNodeVersion", + "node_getVersion", + "node_getChainId", + "node_getL1ContractAddresses", + "node_getProtocolContractAddresses", + "node_getEncodedEnr", + "node_getCurrentBaseFees", + "node_getValidatorsStats", + "node_getValidatorStats", + "node_registerContractFunctionSignatures", + "node_getAllowedPublicSetup", + ) + + override fun createQuorumFor(method: String): CallQuorum { + return when { + broadcast.contains(method) -> BroadcastQuorum() + else -> AlwaysQuorum() + } + } + + override fun isCallable(method: String): Boolean { + return allowedMethods.contains(method) + } + + override fun isHardcoded(method: String): Boolean { + return false + } + + override fun executeHardcoded(method: String): ByteArray { + throw RpcException(-32601, "Method not found") + } + + override fun getGroupMethods(groupName: String): Set = + when (groupName) { + "default" -> getSupportedMethods() + else -> emptySet() + } + + override fun getSupportedMethods(): Set { + return allowedMethods.toSortedSet() + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt index d60ef4c5e..ddb6863f6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt @@ -1,5 +1,6 @@ package io.emeraldpay.dshackle.upstream.generic +import io.emeraldpay.dshackle.BlockchainType.AZTEC import io.emeraldpay.dshackle.BlockchainType.BITCOIN import io.emeraldpay.dshackle.BlockchainType.COSMOS import io.emeraldpay.dshackle.BlockchainType.ETHEREUM @@ -32,6 +33,7 @@ import io.emeraldpay.dshackle.upstream.UpstreamRpcMethodsDetector import io.emeraldpay.dshackle.upstream.UpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.UpstreamValidator import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult +import io.emeraldpay.dshackle.upstream.aztec.AztecChainSpecific import io.emeraldpay.dshackle.upstream.beaconchain.BeaconChainSpecific import io.emeraldpay.dshackle.upstream.calls.CallMethods import io.emeraldpay.dshackle.upstream.calls.CallSelector @@ -113,6 +115,7 @@ object ChainSpecificRegistry { @JvmStatic fun resolve(chain: Chain): ChainSpecific { return when (chain.type) { + AZTEC -> AztecChainSpecific ETHEREUM -> EthereumChainSpecific STARKNET -> StarknetChainSpecific POLKADOT -> PolkadotChainSpecific From 21d36f07afd4c3a891c371490e3a8a7fcab66726 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:20:42 +0200 Subject: [PATCH 041/108] update chains.yaml (#786) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 91a3853af..61c95a772 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 91a3853af9b88b951674cd872644225108ec5868 +Subproject commit 61c95a772bf5dc472255d5ec4de28521147fcfd0 From 1b12d83066a1571eadffec81f1d3a19d7945afd6 Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Sun, 1 Feb 2026 14:01:02 +0300 Subject: [PATCH 042/108] update proto (#787) --- emerald-grpc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emerald-grpc b/emerald-grpc index 38db8b3fe..53bd8c209 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 38db8b3fefb81f105c64a61ce0b2027c654066c6 +Subproject commit 53bd8c20955a87ab5da76c22d6e702bde97bc037 From 9b5e7ca54e95c1ef0fae5d8a3f31632cb161c9de Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Wed, 4 Feb 2026 16:01:25 +0400 Subject: [PATCH 043/108] Timeouts for http server (#788) --- src/main/kotlin/io/emeraldpay/dshackle/Starter.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt b/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt index f1d00c8a1..2c899b577 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt @@ -42,6 +42,14 @@ fun main(args: Array) { Hooks.enableAutomaticContextPropagation() OpenSsl.ensureAvailability() + if (!System.getProperties().containsKey("sun.net.httpserver.maxReqTime")) { + System.setProperty("sun.net.httpserver.maxReqTime", "60") + } + + if (!System.getProperties().containsKey("sun.net.httpserver.maxRspTime")) { + System.setProperty("sun.net.httpserver.maxRspTime", "600") + } + // add metrics for internal reactor schedulers Schedulers.addExecutorServiceDecorator("key") { scheduler, execService -> val schedulerName = Scannable.from(scheduler).scanOrDefault(Attr.NAME, scheduler.javaClass.name) From 1c4b0e93d2420db35e8b6539d74bab026d3dd2af Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 5 Feb 2026 16:17:59 +0300 Subject: [PATCH 044/108] feat: add Moca mainnet and testnet chain (#789) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 53bd8c209..3ed1dff9d 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 53bd8c20955a87ab5da76c22d6e702bde97bc037 +Subproject commit 3ed1dff9d41777e1ad4ffbc62f7dc5b687e386a6 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 61c95a772..e4648bc72 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 61c95a772bf5dc472255d5ec4de28521147fcfd0 +Subproject commit e4648bc72ba4a4c9b9b612507aa1a554e01b3a33 From ee72ca37bee3dc86730856a0bf14e8224892939a Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Fri, 6 Feb 2026 04:10:48 +0700 Subject: [PATCH 045/108] Update saigon chain id (#790) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index e4648bc72..68411e3ee 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit e4648bc72ba4a4c9b9b612507aa1a554e01b3a33 +Subproject commit 68411e3eecbd75d23f634f78d70cf0dae6303704 From b375fd34b4fd8d7150f002f5b546f718e84022c6 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 6 Feb 2026 00:15:11 +0300 Subject: [PATCH 046/108] Update saigon chain id (#791) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 68411e3ee..98dc9e758 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 68411e3eecbd75d23f634f78d70cf0dae6303704 +Subproject commit 98dc9e7587821dde8e97e02921963454b9e893fb From 15047c7d26a3335d69dd271703068b4f24e3f4dc Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 6 Feb 2026 15:41:04 +0300 Subject: [PATCH 047/108] Fix Flux.merge() concurrency limit in SubscribeChainStatus (#792) --- .../dshackle/rpc/SubscribeChainStatus.kt | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/SubscribeChainStatus.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/SubscribeChainStatus.kt index 319361fbe..1072e145a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/SubscribeChainStatus.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/SubscribeChainStatus.kt @@ -25,25 +25,25 @@ class SubscribeChainStatus( } fun chainStatuses(): Flux { - return Flux.merge( - // we need to track not only multistreams with upstreams but all of them - // because upstreams can be added in runtime with hot config reload - multistreamHolder.all() - .filter { Common.ChainRef.forNumber(it.getChain().id) != null } - .map { ms -> - Flux.concat( - // the first event must be filled with all fields - firstFullEvent(ms), - Flux.merge( - // head events are separated from others - headEvents(ms), - multistreamEvents(ms), - ), - ) - }, - ).doOnError { - log.error("Error during sending chain statuses", it) - } + // we need to track not only multistreams with upstreams but all of them + // because upstreams can be added in runtime with hot config reload + val sources = multistreamHolder.all() + .filter { Common.ChainRef.forNumber(it.getChain().id) != null } + .map { ms -> + Flux.concat( + // the first event must be filled with all fields + firstFullEvent(ms), + Flux.merge( + // head events are separated from others + headEvents(ms), + multistreamEvents(ms), + ), + ) + } + return Flux.merge(Flux.fromIterable(sources), sources.size) + .doOnError { + log.error("Error during sending chain statuses", it) + } } private fun multistreamEvents(ms: Multistream): Flux { From 9108f89eee2e31d0ea614348c628e5351237d7bd Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Wed, 18 Feb 2026 17:04:17 +0400 Subject: [PATCH 048/108] Use external prom server (#793) --- .github/workflows/test.yaml | 2 + build.gradle | 1 + gradle/libs.versions.toml | 3 +- .../kotlin/io/emeraldpay/dshackle/Starter.kt | 8 -- .../dshackle/monitoring/MonitoringSetup.kt | 104 ++---------------- 5 files changed, 13 insertions(+), 105 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3311e3351..9d58ef0d9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -35,10 +35,12 @@ jobs: - name: Install System Libs run: sudo apt-get install -y openssl libapr1 + - name: Setup gradle uses: gradle/gradle-build-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Check run: make test env: diff --git a/build.gradle b/build.gradle index cdcfd32cb..cf0ef3539 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,7 @@ dependencies { implementation(libs.reactor.grpc.stub) implementation(libs.grpc.proto.util) + implementation libs.prom.exporter.server implementation libs.micrometer.registry.prometheus implementation libs.micrometer.ctx.prop implementation libs.lettuce.core diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 294d7f6ab..ea4dd788f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = lettuce-core = "io.lettuce:lettuce-core:5.2.2.RELEASE" +prom-exporter-server = "io.prometheus:prometheus-metrics-exporter-httpserver:1.5.0" micrometer-registry-prometheus = "io.micrometer:micrometer-registry-prometheus:1.16.0" micrometer-ctx-prop = "io.micrometer:context-propagation:1.2.0" @@ -104,7 +105,7 @@ spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot- spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } -testcontainers = "org.testcontainers:testcontainers:1.21.3" +testcontainers = "org.testcontainers:testcontainers:2.0.3" testcontainers-ganache = "io.github.ganchix:testcontainers-java-module-ganache:0.0.4" assertj = "org.assertj:assertj-core:3.23.1" diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt b/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt index 2c899b577..f1d00c8a1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Starter.kt @@ -42,14 +42,6 @@ fun main(args: Array) { Hooks.enableAutomaticContextPropagation() OpenSsl.ensureAvailability() - if (!System.getProperties().containsKey("sun.net.httpserver.maxReqTime")) { - System.setProperty("sun.net.httpserver.maxReqTime", "60") - } - - if (!System.getProperties().containsKey("sun.net.httpserver.maxRspTime")) { - System.setProperty("sun.net.httpserver.maxRspTime", "600") - } - // add metrics for internal reactor schedulers Schedulers.addExecutorServiceDecorator("key") { scheduler, execService -> val schedulerName = Scannable.from(scheduler).scanOrDefault(Attr.NAME, scheduler.javaClass.name) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt index 9dff631aa..c7b578a62 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/MonitoringSetup.kt @@ -15,7 +15,6 @@ */ package io.emeraldpay.dshackle.monitoring -import com.sun.net.httpserver.HttpServer import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.config.MonitoringConfig import io.micrometer.core.instrument.Meter @@ -28,19 +27,10 @@ import io.micrometer.core.instrument.binder.system.ProcessorMetrics import io.micrometer.core.instrument.config.MeterFilter import io.micrometer.prometheusmetrics.PrometheusConfig import io.micrometer.prometheusmetrics.PrometheusMeterRegistry +import io.prometheus.metrics.exporter.httpserver.HTTPServer import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory import org.springframework.stereotype.Service -import java.io.IOException -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.nio.charset.StandardCharsets -import java.util.concurrent.Executors -import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import java.util.zip.GZIPOutputStream @Service class MonitoringSetup( @@ -51,20 +41,6 @@ class MonitoringSetup( private val log = LoggerFactory.getLogger(MonitoringSetup::class.java) } - private fun isTcpPortAvailable(host: String, port: Int): Boolean { - try { - ServerSocket().use { serverSocket -> - // setReuseAddress(false) is required only on macOS, - // otherwise the code will not work correctly on that platform - serverSocket.reuseAddress = false - serverSocket.bind(InetSocketAddress(InetAddress.getByName(host), port), 1) - return true - } - } catch (ex: java.lang.Exception) { - return false - } - } - @PostConstruct fun setup() { val prometheusRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT) @@ -93,77 +69,13 @@ class MonitoringSetup( } if (monitoringConfig.prometheus.enabled) { - // use standard JVM server with a single thread blocking processing - // prometheus is a single thread periodic call, no reason to setup anything complex - Thread { - var started = false - val scrapeThreadCounter = AtomicInteger(0) - val scrapeExecutor = Executors.newFixedThreadPool( - 4, - ThreadFactory { runnable -> - Thread(runnable, "prometheus-scrape-${scrapeThreadCounter.incrementAndGet()}").apply { - isDaemon = true - } - }, - ) - while (true) { - if (isTcpPortAvailable(monitoringConfig.prometheus.host, monitoringConfig.prometheus.port)) { - started = true - try { - log.info("Run Prometheus metrics on ${monitoringConfig.prometheus.host}:${monitoringConfig.prometheus.port}${monitoringConfig.prometheus.path}") - val server = HttpServer.create( - InetSocketAddress( - monitoringConfig.prometheus.host, - monitoringConfig.prometheus.port, - ), - 0, - ) - server.executor = scrapeExecutor - server.createContext(monitoringConfig.prometheus.path) { httpExchange -> - val startTime = System.nanoTime() - try { - val response = prometheusRegistry.scrape() - val responseBytes = response.toByteArray(StandardCharsets.UTF_8) - httpExchange.responseHeaders.add("Content-Encoding", "gzip") - httpExchange.responseHeaders.add("Content-Type", "text/plain; version=0.0.4; charset=utf-8") - httpExchange.sendResponseHeaders(200, 0) - httpExchange.responseBody.use { os -> - GZIPOutputStream(os).use { gzos -> - gzos.write(responseBytes) - } - } - val durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) - if (durationMs > 5000) { - log.warn("Prometheus scrape served in {} ms", durationMs) - } else { - log.debug("Prometheus scrape served in {} ms", durationMs) - } - } catch (e: Exception) { - log.error("Failed to serve Prometheus metrics", e) - val messageBytes = "internal error".toByteArray(StandardCharsets.UTF_8) - runCatching { - httpExchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8") - httpExchange.sendResponseHeaders(500, messageBytes.size.toLong()) - httpExchange.responseBody.use { os -> - os.write(messageBytes) - } - } - } finally { - httpExchange.close() - } - } - Thread(server::start).start() - } catch (e: IOException) { - log.error("Failed to start Prometheus Server", e) - } - } else { - if (!started) { - log.error("Can't start prometheus metrics on ${monitoringConfig.prometheus.host}:${monitoringConfig.prometheus.port}${monitoringConfig.prometheus.path}") - } - Thread.sleep(1000) - } - } - }.start() + HTTPServer + .builder() + .hostname(monitoringConfig.prometheus.host) + .port(monitoringConfig.prometheus.port) + .registry(prometheusRegistry.prometheusRegistry) + .metricsHandlerPath(monitoringConfig.prometheus.path) + .buildAndStart() } } } From 1dc553527faf55ac1a8891d423233db37ce8f933 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Thu, 26 Feb 2026 16:46:55 +0400 Subject: [PATCH 049/108] New prune err (#796) --- .../upstream/ethereum/EthereumLowerBoundBlockDetector.kt | 1 + .../upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt | 1 + .../upstream/ethereum/EthereumLowerBoundStateDetector.kt | 1 + .../dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt | 1 + 4 files changed, 4 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt index c67d6ea46..7e38c0c1e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt @@ -30,6 +30,7 @@ class EthereumLowerBoundBlockDetector( "invalid block height", // hyperliquid "pruned history unavailable", // xlayer "no transactions snapshot file for", + "has been pruned; earliest available is", ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt index 872df450f..b71a7aaaf 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundReceiptsDetector.kt @@ -34,6 +34,7 @@ class EthereumLowerBoundReceiptsDetector( "header not found", "pruned history unavailable", // xlayer "transaction indexing is in progress", + "has been pruned; earliest available is", ).plus(EthereumLowerBoundBlockDetector.NO_BLOCK_ERRORS) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt index 156590151..2e207110a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundStateDetector.kt @@ -85,6 +85,7 @@ class EthereumLowerBoundStateDetector( "state histories haven't been fully indexed yet", "but it out-of-bounds", "not supported", + "has been pruned; earliest available is", ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt index 5a0051369..7bc2800c5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundTxDetector.kt @@ -29,6 +29,7 @@ class EthereumLowerBoundTxDetector( "invalid block height", // hyperliquids "pruned history unavailable", // xlayer blocks "transaction indexing is in progress", + "has been pruned; earliest available is", ).plus(EthereumLowerBoundBlockDetector.NO_BLOCK_ERRORS) } From 7d73099e723938145d29e12f53e3110a5c20506b Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Thu, 26 Feb 2026 17:39:00 +0400 Subject: [PATCH 050/108] New pub (#797) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 98dc9e758..cd72cf044 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 98dc9e7587821dde8e97e02921963454b9e893fb +Subproject commit cd72cf04449e284962ca25f526598301adaab06f From fc85b681750de6c8757c9f2c944e2f9cfb45ff92 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Mon, 2 Mar 2026 17:43:47 +0400 Subject: [PATCH 051/108] New pub (#798) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index cd72cf044..cc031a52c 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit cd72cf04449e284962ca25f526598301adaab06f +Subproject commit cc031a52c11e507c989efc56f53db489a7649b7b From b9d0a8c60c933831fde2c158f58fe250ea0dc2b0 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 3 Mar 2026 12:54:53 +0400 Subject: [PATCH 052/108] New sei errors (#799) --- .../upstream/ethereum/EthereumLowerBoundBlockDetector.kt | 1 + .../upstream/ethereum/EthereumLowerBoundProofDetector.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt index 7e38c0c1e..c3523e726 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundBlockDetector.kt @@ -31,6 +31,7 @@ class EthereumLowerBoundBlockDetector( "pruned history unavailable", // xlayer "no transactions snapshot file for", "has been pruned; earliest available is", + "could not find block for height", ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt index 20404b7a3..5476e0d47 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt @@ -30,6 +30,7 @@ class EthereumLowerBoundProofDetector( "Method not found", // Monad error bc they don't have eth_getProofs "invalid block height", // hyperliquid "not supported", + "evm module does not exist on height", ) } From 9a72a3232cefe44a69bacdc67fcf007e3d0c80ee Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Fri, 13 Mar 2026 16:00:52 +0100 Subject: [PATCH 053/108] Introduce Morph chain (#802) * Introduce Morph chain * Temp bind app type to ETHEREUM --- .sdkmanrc | 1 + buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts | 1 + emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 .sdkmanrc diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 000000000..b25f687cd --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +gradle=9.2.0 \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts index 40d5f3a4e..0d8109058 100644 --- a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts +++ b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts @@ -140,6 +140,7 @@ open class CodeGen(private val config: ChainsConfig) { "cosmos" -> "BlockchainType.COSMOS" "ripple" -> "BlockchainType.RIPPLE" "kadena" -> "BlockchainType.KADENA" + "app" -> "BlockchainType.ETHEREUM" else -> throw IllegalArgumentException("unknown blockchain type $type") } } diff --git a/emerald-grpc b/emerald-grpc index 3ed1dff9d..797a753f6 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 3ed1dff9d41777e1ad4ffbc62f7dc5b687e386a6 +Subproject commit 797a753f697a38a1368dd3857bbf60534d06e49c diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index cc031a52c..b2af98e90 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit cc031a52c11e507c989efc56f53db489a7649b7b +Subproject commit b2af98e9090739952478b1b9bd0f46ece32f85f5 From d259c3933a409167995d4c1dc1bdeab4c5badcb6 Mon Sep 17 00:00:00 2001 From: msizov Date: Tue, 17 Mar 2026 17:41:18 +0700 Subject: [PATCH 054/108] update polygon's chains.yaml entry (#803) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index b2af98e90..3e4a847a5 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit b2af98e9090739952478b1b9bd0f46ece32f85f5 +Subproject commit 3e4a847a55c18e0637825a2c4765c3a268574193 From 56f6bb3a7b596454a759640492233d41660b2992 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 17 Mar 2026 19:31:18 +0300 Subject: [PATCH 055/108] Add Robinhood and TAC Chains (#804) * add Robinhood Chain testnet support Update emerald-grpc submodule with CHAIN_ROBINHOOD__TESTNET = 10190. * update submodules to include TAC chain merge Sync emerald-grpc and public submodules to latest. --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 797a753f6..a0df7d714 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 797a753f697a38a1368dd3857bbf60534d06e49c +Subproject commit a0df7d714482fcea2445abd81abce7805ab78995 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 3e4a847a5..cd5c4ba78 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 3e4a847a55c18e0637825a2c4765c3a268574193 +Subproject commit cd5c4ba78cff593f767dc00e69e1b81b952a0ca2 From cbf6ae9723500155a92b265e2b17176b5686dfd0 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 20 Mar 2026 11:02:18 +0300 Subject: [PATCH 056/108] fix(beacon): forward Content-Type header from upstream responses (#806) Add Content-Type to the beacon chain response headers whitelist. Without this, REST endpoints returning SSZ binary data (e.g. /eth/v2/debug/beacon/states) are mislabeled as application/json by dproxy, breaking client-side parsing. Companion fix: drpcorg/dproxy#2348 --- .../dshackle/upstream/beaconchain/BeaconChainSpecific.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt index c1e6672be..c756cc29f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/beaconchain/BeaconChainSpecific.kt @@ -123,6 +123,7 @@ object BeaconChainSpecific : AbstractPollChainSpecific() { } override fun getResponseHeadersToForward(): List = listOf( + "Content-Type", "Eth-Consensus-Version", "Eth-Consensus-Finalized", "Eth-Execution-Optimistic", From 93b8ad0aebdf8237445169559e1d38ace22d9e8c Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 31 Mar 2026 18:25:22 +0400 Subject: [PATCH 057/108] Fix aztec (#807) --- .../upstream/aztec/AztecChainSpecific.kt | 37 +++---------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt index 10b3a253f..af4ca0283 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt @@ -30,50 +30,23 @@ object AztecChainSpecific : AbstractPollChainSpecific() { val height = parseLong( findNode( root, - "number", - "header.number", - "blockNumber", - "header.blockNumber", - "header.globalVariables.blockNumber", + "proposed.number", ), ) ?: 0L - val hashValue = parseText(findNode(root, "hash", "header.hash", "blockHash", "header.blockHash")) - val parentValue = parseText( - findNode( - root, - "parentHash", - "header.parentHash", - "parent_hash", - "header.parent_hash", - "prevHash", - "header.prevHash", - ), - ) - val timestamp = parseInstant( - findNode( - root, - "timestamp", - "header.timestamp", - "header.globalVariables.timestamp", - ), - ) ?: Instant.EPOCH - - if (hashValue == null) { - log.warn("Aztec block hash is missing in response from upstream {}", upstreamId) - } + val hashValue = parseText(findNode(root, "proposed.hash")) return Mono.just( BlockContainer( height = height, hash = BlockId.from(hashValue ?: "0x0"), difficulty = BigInteger.ZERO, - timestamp = timestamp, + timestamp = Instant.EPOCH, full = false, json = data, parsed = root, transactions = emptyList(), upstreamId = upstreamId, - parentHash = parentValue?.let { BlockId.from(it) }, + parentHash = null, ), ) } @@ -126,7 +99,7 @@ object AztecChainSpecific : AbstractPollChainSpecific() { } override fun latestBlockRequest(): ChainRequest = - ChainRequest("node_getBlock", ListParams("latest")) + ChainRequest("node_getL2Tips", ListParams()) private fun findNode(root: JsonNode, vararg paths: String): JsonNode? { for (path in paths) { From 2432e2567c156d422f7e7a030eacb4387d9cdec9 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 7 Apr 2026 12:14:18 +0400 Subject: [PATCH 058/108] Fix sol head detection (#808) --- .../dshackle/upstream/solana/SolanaChainSpecific.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt index 722681676..5f1ea2c23 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/solana/SolanaChainSpecific.kt @@ -55,8 +55,10 @@ object SolanaChainSpecific : AbstractChainSpecific() { private val lastKnownHeights = ConcurrentHashMap() private val lastCheckedSlots = ConcurrentHashMap() + private val getEpochInfoReq = ChainRequest("getEpochInfo", ListParams(mapOf("commitment" to "confirmed"))) + override fun getLatestBlock(api: ChainReader, upstreamId: String): Mono { - return api.read(ChainRequest("getEpochInfo", ListParams())) + return api.read(getEpochInfoReq) .map { response -> val epochInfo = Global.objectMapper.readValue( response.getResult(), @@ -90,7 +92,7 @@ object SolanaChainSpecific : AbstractChainSpecific() { if (shouldCheckHeight || estimatedHeight == null) { // Verify actual height using getEpochInfo (single call for both slot and height) - api.read(ChainRequest("getEpochInfo", ListParams())) + api.read(getEpochInfoReq) .map { response -> val epochInfo = Global.objectMapper.readValue( response.getResult(), From f38bc660c31c41df8e62495c002a3729fc59b0b6 Mon Sep 17 00:00:00 2001 From: msizov Date: Thu, 16 Apr 2026 21:26:15 +0900 Subject: [PATCH 059/108] update public repo (#809) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index cd5c4ba78..2d85f996c 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit cd5c4ba78cff593f767dc00e69e1b81b952a0ca2 +Subproject commit 2d85f996c13123d71eef5327b8fb0fbd314de384 From e8232231df499b31cd3fd3f160fe86a1df2cfc81 Mon Sep 17 00:00:00 2001 From: msizov Date: Thu, 16 Apr 2026 22:20:46 +0900 Subject: [PATCH 060/108] add lower bound cronos (#810) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 2d85f996c..4510b37a5 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 2d85f996c13123d71eef5327b8fb0fbd314de384 +Subproject commit 4510b37a56e97c188b30dff63093f10c560c68d6 From 25dbc7c3880218800c5c216ed8812fae27128d0f Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Tue, 21 Apr 2026 10:33:37 +0300 Subject: [PATCH 061/108] response signing with auth key (#811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: spec for unifying response signing with auth key Design для замены отдельной SignatureConfig на подпись ответов RSA-ключом, взятым из auth.server.provider-private-key. Made-with: Cursor * docs: implementation plan for auth-based response signing План реализации по спеке 2026-04-20-response-signing-with-auth-key-design.md. Разбит на 9 задач: новые RsaSigner/DisabledSigner по TDD, переработка фабрики под AuthorizationConfig, удаление SignatureConfig/signed-response, снятие cache-гейта в EthereumLocalReader, обновление документации. Made-with: Cursor * feat(signature): add RsaSigner using SHA256withRSA Made-with: Cursor * feat(signature): add DisabledSigner that throws on sign() Made-with: Cursor * refactor(signature): ResponseSigner.sign returns non-null Signature Made-with: Cursor * refactor(signature): build signer from AuthorizationConfig Made-with: Cursor * refactor(config): remove signed-response YAML section and SignatureConfig Made-with: Cursor * refactor(signature): remove EcdsaSigner and NoSigner Remove the obsolete ECDSA signer and the transitional NoSigner stub. Replace NoSigner usages in tests with DisabledSigner, and pass DisabledSigner to BroadcastReader in tests (signer is now non-null). Made-with: Cursor * refactor(local-reader): serve cached results for requests with nonce Made-with: Cursor * docs: replace signed-response with auth section incl. response signing Made-with: Cursor * docs: correct auth section YAML and expand Response Signing note - docs/reference-configuration.adoc: fix the YAML shape of the auth section (server.keys.{provider-private-key,external-public-key} and publicKeyOwner) to match AuthorizationConfigReader, and expand the Response Signing subsection with the actual signed-message format. - .gitignore: ignore /demo/response-signing/ (local sandbox with keys and generated gRPC stubs — regenerate with generate-keys.sh + protoc) and /docs/superpowers/ (specs/plans kept local-only for now). Any previously tracked files under these paths are untracked here. Made-with: Cursor * feat(upstream): auto-inject secure-signed label when signing is enabled Expose ResponseSigner.enabled and thread the bean through UpstreamCreator so Generic/Ethereum/Bitcoin creators can enrich each upstream's labels with secure-signed=true whenever auth-backed response signing is active. A user-provided value for the label is preserved. Made-with: Cursor --- .gitignore | 8 +- docs/reference-configuration.adoc | 71 ++++++--- .../kotlin/io/emeraldpay/dshackle/Config.kt | 6 - .../emeraldpay/dshackle/config/MainConfig.kt | 1 - .../dshackle/config/MainConfigReader.kt | 4 - .../dshackle/config/SignatureConfig.kt | 41 ------ .../dshackle/config/SignatureConfigReader.kt | 29 ---- .../dshackle/quorum/QuorumRequestReader.kt | 5 +- .../dshackle/reader/BroadcastReader.kt | 2 +- .../dshackle/reader/RequestReaderFactory.kt | 6 +- .../configure/BitcoinUpstreamCreator.kt | 6 +- .../configure/EthereumUpstreamCreator.kt | 4 +- .../configure/GenericUpstreamCreator.kt | 6 +- .../startup/configure/UpstreamCreator.kt | 18 +++ .../upstream/ethereum/EthereumDirectReader.kt | 3 +- .../upstream/ethereum/EthereumLocalReader.kt | 4 - .../upstream/signature/DisabledSigner.kt | 15 ++ .../dshackle/upstream/signature/NoSigner.kt | 7 - .../upstream/signature/ResponseSigner.kt | 8 +- .../signature/ResponseSignerFactory.kt | 82 +++++------ .../{EcdsaSigner.kt => RsaSigner.kt} | 26 +--- .../config/SignatureConfigReaderSpec.groovy | 40 ------ .../reader/BroadcastReaderSpec.groovy | 11 +- .../dshackle/rpc/NativeSubscribeSpec.groovy | 4 +- .../UpstreamCreatorLabelsSpec.groovy | 74 ++++++++++ .../ethereum/EthereumLocalReaderSpec.groovy | 13 +- .../signature/DisabledSignerSpec.groovy | 28 ++++ .../upstream/signature/EcdsaSignerSpec.groovy | 135 ------------------ .../ResponseSignerFactorySpec.groovy | 82 +++++++++-- .../upstream/signature/RsaSignerSpec.groovy | 75 ++++++++++ .../io/emeraldpay/dshackle/IntegrationTest.kt | 5 +- .../reader/RequestReaderFactoryTest.kt | 5 +- 32 files changed, 432 insertions(+), 392 deletions(-) delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt rename src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/{EcdsaSigner.kt => RsaSigner.kt} (51%) delete mode 100644 src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy delete mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy diff --git a/.gitignore b/.gitignore index 25ca0b167..5c031af25 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,10 @@ http-client.env.json .DS_Store mise.toml -.claude/ \ No newline at end of file +.claude/ + +# demo sandbox (local-only, regenerate with demo/response-signing/generate-keys.sh + protoc) +/demo/response-signing/ + +# superpowers scratch: specs and plans live locally only for now +/docs/superpowers/ \ No newline at end of file diff --git a/docs/reference-configuration.adoc b/docs/reference-configuration.adoc index b3a40cc7b..5deeaf1d3 100644 --- a/docs/reference-configuration.adoc +++ b/docs/reference-configuration.adoc @@ -47,10 +47,8 @@ cache: db: 0 password: I1y0dGKy01by -signed-response: - enabled: true - algorithm: SECP256K1 - private-key: /path/key.pem +auth: + enabled: false proxy: host: 0.0.0.0 @@ -207,10 +205,10 @@ See <> section | Caching configuration. See <> section. -| `signed-response` +| `auth` | -| Signed responses -See <> section. +| Authorization and response signing. +See <> section. | `cluster` | @@ -559,37 +557,66 @@ cache: |=== -[#signed-response] -== Signed Response +[#auth] +== Authorization + +dshackle supports optional client authentication via signed JWT tokens (RS256). When +`auth.enabled` is `true`, dshackle validates tokens issued by a trusted provider and +rejects unauthenticated requests. [source,yaml] ---- -signed-response: +auth: enabled: true - algorithm: SECP256K1 - private-key: /path/key.pem + publicKeyOwner: "token-issuer-name" + server: + keys: + provider-private-key: "/etc/dshackle/auth/jwt-rsa.pem" + external-public-key: "/etc/dshackle/auth/jwt-rsa.pub" ---- -.Redis Config -[cols="2a,2,5"] |=== -| Option | Default Value | Description +| Name | Default | Description | `enabled` | `false` -| Enable/disable Signed Responses +| Enables authorization and response signing. -| `algorithm` -| `SECP256K1` -| `SECP256K1` or `NIST-P256` +| `publicKeyOwner` +| +| Expected value of the `iss` claim on inbound JWT tokens. -| `private-key` +| `server.keys.provider-private-key` | -| Path to a private key in PEM format +| Path to a PKCS#8 PEM RSA private key. Used both to sign session JWTs issued by + dshackle and to sign `NativeCall` response payloads (see <>). +| `server.keys.external-public-key` +| +| Path to a PEM-encoded RSA public key (X.509 SubjectPublicKeyInfo) used to verify + the JWTs clients present to `emerald.Auth/Authenticate`. |=== -See more details at xref:07-methods.adoc#signatures[Signed Response] in gRPC Methods. +[#response-signing] +==== Response Signing + +When `auth.enabled` is `true` and `auth.server.keys.provider-private-key` points to a +valid PKCS#8 RSA private key, dshackle automatically signs gRPC responses with +`SHA256withRSA` for any `NativeCall` request that provides a non-zero `nonce`. The same +key used for issuing JWT tokens (RS256) is reused for response signatures — no separate +configuration is required. + +The signed blob is `DSHACKLESIG///`. The +returned `NativeCallReplySignature` carries the original `nonce`, the signature bytes +and a `key_id` (first 8 bytes of the SHA-256 of the public key). Clients verify with +the public half of `provider-private-key`. + +If a client sends a nonce but the signing key is not configured (auth disabled or the +path is empty), dshackle returns an error with code `-32603` and message +"Response signing requested via nonce but signing key is not configured". + +A runnable end-to-end example (dshackle config, demo RSA keys and a Go client) lives +in `demo/response-signing/` in the repository. [#cluster] == Cluster diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt index b33ab61b4..51e8f08d5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt @@ -24,7 +24,6 @@ import io.emeraldpay.dshackle.config.HealthConfig import io.emeraldpay.dshackle.config.MainConfig import io.emeraldpay.dshackle.config.MainConfigReader import io.emeraldpay.dshackle.config.MonitoringConfig -import io.emeraldpay.dshackle.config.SignatureConfig import io.emeraldpay.dshackle.config.TokensConfig import io.emeraldpay.dshackle.config.UpstreamsConfig import org.bouncycastle.jce.provider.BouncyCastleProvider @@ -130,11 +129,6 @@ open class Config( return mainConfig.cache ?: CacheConfig() } - @Bean - open fun signatureConfig(@Autowired mainConfig: MainConfig): SignatureConfig { - return mainConfig.signature ?: SignatureConfig() - } - @Bean open fun tokensConfig(@Autowired mainConfig: MainConfig): TokensConfig { return mainConfig.tokens ?: TokensConfig(emptyList()) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt index a1d8e4b4d..0d579aa36 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt @@ -41,7 +41,6 @@ class MainConfig { var monitoring: MonitoringConfig = MonitoringConfig.default() var accessLogConfig: AccessLogConfig = AccessLogConfig.default() var health: HealthConfig = HealthConfig.default() - var signature: SignatureConfig? = null var compression: CompressionConfig = CompressionConfig.default() var chains: ChainsConfig = ChainsConfig.default() var authorization: AuthorizationConfig = AuthorizationConfig.default() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt index 90cdef656..5664eaf6a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt @@ -33,7 +33,6 @@ class MainConfigReader( private val monitoringConfigReader = MonitoringConfigReader() private val accessLogReader = AccessLogReader() private val healthConfigReader = HealthConfigReader() - private val signatureConfigReader = SignatureConfigReader(fileResolver) private val compressionConfigReader = CompressionConfigReader() private val chainsConfigReader = ChainsConfigReader(optionsReader) private val authorizationConfigReader = AuthorizationConfigReader() @@ -75,9 +74,6 @@ class MainConfigReader( healthConfigReader.read(input).let { config.health = it } - signatureConfigReader.read(input).let { - config.signature = it - } compressionConfigReader.read(input).let { config.compression = it } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt deleted file mode 100644 index 3239382af..000000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.emeraldpay.dshackle.config - -import java.util.Locale - -class SignatureConfig { - - enum class Algorithm { - NIST_P256, - ; - - fun getCurveName(): String { - return if (this == NIST_P256) { - "secp256r1" - } else { - throw IllegalStateException() - } - } - } - - companion object { - fun algorithmOfString(algo: String): Algorithm { - val algorithm = when (algo.uppercase(Locale.getDefault())) { - "NIST_P256", "NIST-P256", "NISTP256", "SECP256R1" -> Algorithm.NIST_P256 - else -> throw IllegalArgumentException("Unknown algorithm or not allowed") - } - return algorithm - } - } - - /** - * Signature scheme that we should use - */ - var algorithm: Algorithm = Algorithm.NIST_P256 - - /** - * Should we generate signature on this instance if it's not already present - */ - var enabled: Boolean = false - - var privateKey: String? = null -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt deleted file mode 100644 index 21fe1b6df..000000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.emeraldpay.dshackle.config - -import io.emeraldpay.dshackle.FileResolver -import io.emeraldpay.dshackle.foundation.YamlConfigReader -import org.yaml.snakeyaml.nodes.MappingNode - -class SignatureConfigReader(val fileResolver: FileResolver) : YamlConfigReader() { - override fun read(input: MappingNode?): SignatureConfig? { - return getMapping(input, "signed-response")?.let { node -> - val config = SignatureConfig() - getValueAsBool(node, "enabled")?.let { - config.enabled = it - } - if (config.enabled) { - getValueAsString(node, "algorithm")?.let { - config.algorithm = SignatureConfig.algorithmOfString(it) - } - getValueAsString(node, "private-key")?.let { - val key = fileResolver.resolve(it) - config.privateKey = key.absolutePath - } - } - if (config.enabled && config.privateKey == null) { - throw IllegalStateException("Path to a private key (`signature.private-key`) is required when Response signature is enabled.") - } - config - } - } -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt index 33bf5a4ca..54bca259c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt @@ -27,6 +27,7 @@ import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.error.UpstreamErrorHandler import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.slf4j.LoggerFactory import reactor.core.publisher.Flux @@ -45,7 +46,7 @@ import java.util.function.Function class QuorumRequestReader( private val apiControl: ApiSource, private val quorum: CallQuorum, - signer: ResponseSigner?, + signer: ResponseSigner, ) : RequestReader(signer) { private val errorHandler = UpstreamErrorHandler @@ -53,7 +54,7 @@ class QuorumRequestReader( private val log = LoggerFactory.getLogger(QuorumRequestReader::class.java) } - constructor(apiControl: ApiSource, quorum: CallQuorum) : this(apiControl, quorum, null) + constructor(apiControl: ApiSource, quorum: CallQuorum) : this(apiControl, quorum, DisabledSigner()) override fun attempts(): AtomicInteger = apiControl.attempts() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt index 9df74d250..b4c221d13 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt @@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger class BroadcastReader( private val upstreams: List, matcher: Selector.Matcher, - signer: ResponseSigner?, + signer: ResponseSigner, private val quorum: CallQuorum, ) : RequestReader(signer) { private val errorHandler = UpstreamErrorHandler diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt index f16d36c2b..f58161624 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt @@ -19,7 +19,7 @@ import reactor.core.publisher.Flux import java.util.concurrent.atomic.AtomicInteger abstract class RequestReader( - private val signer: ResponseSigner?, + private val signer: ResponseSigner, ) : Reader { abstract fun attempts(): AtomicInteger @@ -43,7 +43,7 @@ abstract class RequestReader( protected fun getSignature(key: ChainRequest, response: ChainResponse, upstreamId: String) = response.providedSignature ?: if (key.nonce != null) { - signer?.sign(key.nonce, response.getResult(), upstreamId) + signer.sign(key.nonce, response.getResult(), upstreamId) } else { null } @@ -82,6 +82,6 @@ interface RequestReaderFactory { val multistream: Multistream, val upstreamFilter: Selector.UpstreamFilter, val quorum: CallQuorum, - val signer: ResponseSigner?, + val signer: ResponseSigner, ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt index 305650a46..2579466d6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt @@ -16,6 +16,7 @@ import io.emeraldpay.dshackle.upstream.bitcoin.EsploraClient import io.emeraldpay.dshackle.upstream.bitcoin.ExtractBlock import io.emeraldpay.dshackle.upstream.bitcoin.ZMQServer import io.emeraldpay.dshackle.upstream.forkchoice.MostWorkForkChoice +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.springframework.stereotype.Component import reactor.core.scheduler.Scheduler import java.util.concurrent.atomic.AtomicInteger @@ -27,7 +28,8 @@ class BitcoinUpstreamCreator( private val genericConnectorFactoryCreator: ConnectorFactoryCreator, private val fileResolver: FileResolver, private val headScheduler: Scheduler, -) : UpstreamCreator(chainsConfig, callTargets) { + signer: ResponseSigner, +) : UpstreamCreator(chainsConfig, callTargets, signer) { private var seq = AtomicInteger(0) override fun createUpstream( @@ -67,7 +69,7 @@ class BitcoinUpstreamCreator( ?: "bitcoin-${seq.getAndIncrement()}", chain, directApi, head, options, config.role, - QuorumForLabels.QuorumItem(1, UpstreamsConfig.Labels.fromMap(config.labels)), + QuorumForLabels.QuorumItem(1, buildUpstreamLabels(config.labels)), methods, esplora, chainConf, ) upstream.start() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt index 2b9243fbe..154508fa3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt @@ -6,6 +6,7 @@ import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.config.hot.CompatibleVersionsRules import io.emeraldpay.dshackle.foundation.ChainOptions import io.emeraldpay.dshackle.upstream.CallTargetsHolder +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.springframework.stereotype.Component import java.util.function.Supplier @@ -15,7 +16,8 @@ class EthereumUpstreamCreator( callTargets: CallTargetsHolder, connectorFactoryCreatorResolver: ConnectorFactoryCreatorResolver, versionRules: Supplier, -) : GenericUpstreamCreator(chainsConfig, callTargets, connectorFactoryCreatorResolver, versionRules) { + signer: ResponseSigner, +) : GenericUpstreamCreator(chainsConfig, callTargets, connectorFactoryCreatorResolver, versionRules, signer) { override fun createUpstream( upstreamsConfig: UpstreamsConfig.Upstream<*>, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt index e38bcca2e..c4d0c7b08 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt @@ -12,6 +12,7 @@ import io.emeraldpay.dshackle.upstream.forkchoice.NoChoiceWithPriorityForkChoice import io.emeraldpay.dshackle.upstream.generic.ChainSpecificRegistry import io.emeraldpay.dshackle.upstream.generic.GenericUpstream import io.emeraldpay.dshackle.upstream.generic.connectors.GenericConnectorFactory +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.springframework.stereotype.Component import java.util.function.Supplier @@ -21,7 +22,8 @@ open class GenericUpstreamCreator( callTargets: CallTargetsHolder, private val connectorFactoryCreatorResolver: ConnectorFactoryCreatorResolver, private val versionRules: Supplier, -) : UpstreamCreator(chainsConfig, callTargets) { + signer: ResponseSigner, +) : UpstreamCreator(chainsConfig, callTargets, signer) { private val hashes = HashSet() override fun createUpstream( @@ -78,7 +80,7 @@ open class GenericUpstreamCreator( chain, hash, options, - QuorumForLabels.QuorumItem(1, UpstreamsConfig.Labels.fromMap(config.labels)), + QuorumForLabels.QuorumItem(1, buildUpstreamLabels(config.labels)), chainConfig, connectorFactory, cs::validator, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt index ec36a2d79..1ef9a8f46 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt @@ -10,6 +10,7 @@ import io.emeraldpay.dshackle.upstream.CallTargetsHolder import io.emeraldpay.dshackle.upstream.calls.CallMethods import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods import io.emeraldpay.dshackle.upstream.lowerbound.GoldLowerBounds +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import jakarta.annotation.PostConstruct import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -19,15 +20,32 @@ import kotlin.math.abs abstract class UpstreamCreator( private val chainsConfig: ChainsConfig, private val callTargets: CallTargetsHolder, + private val signer: ResponseSigner, ) { protected val log: Logger = LoggerFactory.getLogger(this::class.java) + /** + * Builds the label map for an upstream starting from the user-provided labels. + * When response signing is actually enabled on this dshackle instance, the + * `secure-signed=true` label is injected automatically (unless the user has + * explicitly overridden it). + */ + protected fun buildUpstreamLabels(userLabels: Map): UpstreamsConfig.Labels { + val labels = UpstreamsConfig.Labels.fromMap(userLabels) + if (signer.enabled && !labels.containsKey(SECURE_SIGNED_LABEL)) { + labels[SECURE_SIGNED_LABEL] = "true" + } + return labels + } + @PostConstruct fun init() { GoldLowerBounds.init(chainsConfig.getChainConfigs()) } companion object { + const val SECURE_SIGNED_LABEL = "secure-signed" + fun getHash(nodeId: Int?, obj: Any, hashes: MutableSet): Short { val hash = nodeId?.toShort() ?: run { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt index e3706228d..324acb515 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt @@ -34,6 +34,7 @@ import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError import io.emeraldpay.dshackle.upstream.finalization.FinalizationType import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import org.apache.commons.collections4.Factory import org.apache.commons.lang3.exception.ExceptionUtils import org.slf4j.LoggerFactory @@ -254,7 +255,7 @@ class EthereumDirectReader( up, Selector.UpstreamFilter(sort, matcher), callMethodsFactory.create().createQuorumFor(request.method), - null, + DisabledSigner(), ), ) }.flatMap { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt index 925451a5c..5983dbfed 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt @@ -48,10 +48,6 @@ class EthereumLocalReader( if (!methods.isCallable(key.method)) { return Mono.error(RpcException(RpcResponseError.CODE_METHOD_NOT_EXIST, "Unsupported method")) } - if (key.nonce != null) { - // we do not want to serve any requests (except hardcoded) that have nonces from cache - return Mono.empty() - } return commonRequests(key)?.switchIfEmpty { // we need to explicitly return null to prevent executeOnRemote // for example diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt new file mode 100644 index 000000000..17cd084c4 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt @@ -0,0 +1,15 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError + +class DisabledSigner : ResponseSigner { + override val enabled: Boolean = false + + override fun sign(nonce: Long, message: ByteArray, source: String): ResponseSigner.Signature { + throw RpcException( + RpcResponseError.CODE_INTERNAL_ERROR, + "Response signing requested via nonce but signing key is not configured", + ) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt deleted file mode 100644 index 5c963b0a6..000000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.emeraldpay.dshackle.upstream.signature - -class NoSigner : ResponseSigner { - override fun sign(nonce: Long, message: ByteArray, source: String): ResponseSigner.Signature? { - return null - } -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt index 395499413..96cd5cbc4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt @@ -2,7 +2,13 @@ package io.emeraldpay.dshackle.upstream.signature interface ResponseSigner { - fun sign(nonce: Long, message: ByteArray, source: String): Signature? + /** + * `true` when an actual signing key is configured and [sign] can produce signatures. + * `false` for placeholder signers that reject any signing attempt (e.g. when auth is disabled). + */ + val enabled: Boolean + + fun sign(nonce: Long, message: ByteArray, source: String): Signature data class Signature( val value: ByteArray, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt index 59ed24264..d7080150d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt @@ -1,24 +1,23 @@ package io.emeraldpay.dshackle.upstream.signature -import io.emeraldpay.dshackle.config.SignatureConfig +import io.emeraldpay.dshackle.config.AuthorizationConfig import org.apache.commons.codec.binary.Hex -import org.bouncycastle.jce.ECNamedCurveTable -import org.bouncycastle.jce.spec.ECPublicKeySpec -import org.bouncycastle.math.ec.ECPoint -import org.bouncycastle.util.io.pem.PemObject -import org.bouncycastle.util.io.pem.PemReader +import org.bouncycastle.openssl.PEMParser import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.stereotype.Component +import java.io.StringReader import java.nio.ByteBuffer import java.nio.file.Files -import java.nio.file.Path +import java.nio.file.Paths import java.security.KeyFactory import java.security.MessageDigest import java.security.PublicKey -import java.security.interfaces.ECPrivateKey +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPrivateKey import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec @Configuration open class SignatureBeans { @@ -29,45 +28,50 @@ open class SignatureBeans { @Component open class ResponseSignerFactory( - private val signatureConfig: SignatureConfig, + private val authorizationConfig: AuthorizationConfig, ) { companion object { private val log = LoggerFactory.getLogger(ResponseSignerFactory::class.java) } - fun readKey(algorithm: SignatureConfig.Algorithm, keyPath: String): Pair { - val reader = PemReader(Files.newBufferedReader(Path.of(keyPath))) - return readKey(algorithm, reader.readPemObject()) + fun createSigner(): ResponseSigner { + if (!authorizationConfig.enabled) { + log.info("Response signing disabled: auth is not enabled") + return DisabledSigner() + } + val path = authorizationConfig.serverConfig.providerPrivateKeyPath + if (path.isBlank()) { + log.warn("Response signing disabled: auth.server.provider-private-key is not set") + return DisabledSigner() + } + + val (privateKey, keyId) = readRsaKey(path) + return RsaSigner(privateKey, keyId) } - private fun readKey(algorithm: SignatureConfig.Algorithm, pem: PemObject): Pair { - val keyFactory = KeyFactory.getInstance("EC") - val key = when (algorithm) { - SignatureConfig.Algorithm.NIST_P256 -> { - val keySpec = PKCS8EncodedKeySpec(pem.content) - keyFactory.generatePrivate(keySpec) - } - } + internal fun readRsaKey(path: String): Pair { + val pemContent = StringReader(Files.readString(Paths.get(path))) + val pemObject = PEMParser(pemContent).readPemObject() + ?: throw IllegalStateException("Cannot parse PEM key at $path") - if (key !is ECPrivateKey) { - throw IllegalStateException("Only EC keys are allowed") - } + val keyFactory = KeyFactory.getInstance("RSA") + val privateKey = keyFactory.generatePrivate(PKCS8EncodedKeySpec(pemObject.content)) - if (algorithm == SignatureConfig.Algorithm.NIST_P256 && key.params.toString().indexOf(SignatureConfig.Algorithm.NIST_P256.getCurveName()) < 0) { - throw IllegalStateException("Key is not NIST P256, generate NIST P256 or use another algorithm") + if (privateKey !is RSAPrivateKey) { + throw IllegalStateException("Only RSA keys are supported for response signing") } - val publicKey = extractPublicKey(keyFactory, key, algorithm) - val id = getPublicKeyId(publicKey) - - return Pair(key, id) + val publicKey = extractPublicKey(keyFactory, privateKey) + val keyId = getPublicKeyId(publicKey) + return Pair(privateKey, keyId) } - fun extractPublicKey(keyFactory: KeyFactory, privateKey: ECPrivateKey, algorithm: SignatureConfig.Algorithm): PublicKey { - val ecSpec = ECNamedCurveTable.getParameterSpec(algorithm.getCurveName()) - val q: ECPoint = ecSpec.g.multiply(privateKey.s) - return keyFactory.generatePublic(ECPublicKeySpec(q, ecSpec)) + private fun extractPublicKey(keyFactory: KeyFactory, privateKey: RSAPrivateKey): PublicKey { + val crt = privateKey as? RSAPrivateCrtKey + ?: throw IllegalStateException("RSA private key does not expose public exponent; use a PKCS#8 key that contains CRT parameters") + val spec = RSAPublicKeySpec(crt.modulus, crt.publicExponent) + return keyFactory.generatePublic(spec) } private fun getPublicKeyId(publicKey: PublicKey): Long { @@ -76,16 +80,4 @@ open class ResponseSignerFactory( log.info("Using key to sign responses: ${Hex.encodeHexString(fullId).substring(0..15)}") return ByteBuffer.wrap(fullId).asLongBuffer().get() } - - fun createSigner(): ResponseSigner { - if (!signatureConfig.enabled) { - return NoSigner() - } - if (signatureConfig.privateKey == null) { - log.warn("Private Key for response signature is not set") - return NoSigner() - } - val key = readKey(signatureConfig.algorithm, signatureConfig.privateKey!!) - return EcdsaSigner(key.first, key.second) - } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/EcdsaSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt similarity index 51% rename from src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/EcdsaSigner.kt rename to src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt index 902ff4e36..ee0e8f048 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/EcdsaSigner.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt @@ -3,19 +3,21 @@ package io.emeraldpay.dshackle.upstream.signature import org.apache.commons.codec.binary.Hex import java.security.MessageDigest import java.security.Signature -import java.security.interfaces.ECPrivateKey +import java.security.interfaces.RSAPrivateKey -class EcdsaSigner( - private val privateKey: ECPrivateKey, +class RsaSigner( + private val privateKey: RSAPrivateKey, val keyId: Long, ) : ResponseSigner { companion object { - const val SIGN_SCHEME = "SHA256withECDSA" + const val SIGN_SCHEME = "SHA256withRSA" const val MSG_PREFIX = "DSHACKLESIG" const val MSG_SEPARATOR = '/' } + override val enabled: Boolean = true + override fun sign(nonce: Long, message: ByteArray, source: String): ResponseSigner.Signature { val sig = Signature.getInstance(SIGN_SCHEME, "BC") sig.initSign(privateKey) @@ -26,29 +28,15 @@ class EcdsaSigner( } /** - * To avoid various attacks, such as various kinds of padding and message alternating attacks, - * we (1) tag the original message to specify the source, (2) ensure no parts of the message can affect each other - * and (3) ensure the message cannot break the wrapping. - * - * We're doing that by converting the message as `"DSHACKLESIG/" || str(nonce) || "/" || hex(sha256(msg))` - * - * I.e.: - * - three elements in the wrapped message - * - separated by "/" which is not a part of any element - * - first element is DSHACKLESIG tag - * - second is the nonce value encode as decimal string - * - third is SHA256 hash of the original message encoded as hex string + * Wrapping format: `"DSHACKLESIG/" || str(nonce) || "/" || source || "/" || hex(sha256(msg))` */ fun wrapMessage(nonce: Long, message: ByteArray, source: String): String { val sha256 = MessageDigest.getInstance("SHA-256") - // we create it with max capacity that we expect for the result, which is total lengths of its parts val formatterMsg = StringBuilder(11 + 1 + 18 + 1 + 64 + 1 + 64) formatterMsg.append(MSG_PREFIX) .append(MSG_SEPARATOR) .append(nonce.toString()) .append(MSG_SEPARATOR) - // We expect that the id is short enough (less than 64 symbols) and also it doesn't contain the `/` symbol - // which is verified in UpstreamConfigReader and DefaultUpstream constructor .append(source) .append(MSG_SEPARATOR) .append(Hex.encodeHexString(sha256.digest(message))) diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy deleted file mode 100644 index 2beb419f1..000000000 --- a/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy +++ /dev/null @@ -1,40 +0,0 @@ -package io.emeraldpay.dshackle.config - -import io.emeraldpay.dshackle.test.TestingCommons -import spock.lang.Specification - -class SignatureConfigReaderSpec extends Specification { - - def "Parse enabled"() { - setup: - def config = "signed-response:\n" + - " enabled: true\n" + - " algorithm: NIST_P256\n" + - " private-key: /root/key.pem\n" - - when: - def reader = new SignatureConfigReader(TestingCommons.fileResolver()) - def act = reader.read(new ByteArrayInputStream(config.bytes)) - - then: - act.enabled - act.privateKey == "/root/key.pem" - act.algorithm == SignatureConfig.Algorithm.NIST_P256 - } - - def "No path when disabled"() { - setup: - def config = "signed-response:\n" + - " enabled: false\n" + - " private-key: /root/key.pem\n" - - when: - def reader = new SignatureConfigReader(TestingCommons.fileResolver()) - def act = reader.read(new ByteArrayInputStream(config.bytes)) - - then: - !act.enabled - act.privateKey == null - } - -} diff --git a/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy index 17c487d1b..7695c5516 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy @@ -7,6 +7,7 @@ import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import reactor.core.publisher.Mono import reactor.test.StepVerifier import spock.lang.Specification @@ -42,7 +43,7 @@ class BroadcastReaderSpec extends Specification { Mono.just(new ChainResponse(result, null)) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -80,7 +81,7 @@ class BroadcastReaderSpec extends Specification { 1 * read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) >> Mono.error(new ChainException(1, "too low")) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -113,7 +114,7 @@ class BroadcastReaderSpec extends Specification { 0 * getId() >> "id" 0 * getIngressReader() >> Mock(Reader) } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -151,7 +152,7 @@ class BroadcastReaderSpec extends Specification { Mono.error(new ChainException(1, "too low")) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -177,7 +178,7 @@ class BroadcastReaderSpec extends Specification { 0 * getId() >> "id" 0 * getIngressReader() >> Mock(Reader) } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader .read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy index 3cd3d090f..70c18801e 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy @@ -22,7 +22,7 @@ import io.emeraldpay.dshackle.test.MultistreamHolderMock import io.emeraldpay.dshackle.upstream.Selector import io.emeraldpay.dshackle.upstream.ethereum.EthereumEgressSubscription import io.emeraldpay.dshackle.upstream.generic.GenericMultistream -import io.emeraldpay.dshackle.upstream.signature.NoSigner +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import reactor.core.publisher.Flux import reactor.test.StepVerifier import spock.lang.Specification @@ -30,7 +30,7 @@ import spock.lang.Specification import java.time.Duration class NativeSubscribeSpec extends Specification { - def signer = new NoSigner() + def signer = new DisabledSigner() def "Call with empty params when not provided"() { setup: diff --git a/src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy new file mode 100644 index 000000000..29a4060aa --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy @@ -0,0 +1,74 @@ +package io.emeraldpay.dshackle.startup.configure + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.config.ChainsConfig +import io.emeraldpay.dshackle.config.UpstreamsConfig +import io.emeraldpay.dshackle.foundation.ChainOptions +import io.emeraldpay.dshackle.upstream.CallTargetsHolder +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner +import io.emeraldpay.dshackle.upstream.signature.RsaSigner +import spock.lang.Specification + +import java.security.interfaces.RSAPrivateKey + +class UpstreamCreatorLabelsSpec extends Specification { + + static class TestCreator extends UpstreamCreator { + TestCreator(ChainsConfig chainsConfig, CallTargetsHolder callTargets, ResponseSigner signer) { + super(chainsConfig, callTargets, signer) + } + + @Override + protected UpstreamCreationData createUpstream( + UpstreamsConfig.Upstream upstreamsConfig, + Chain chain, + ChainOptions.Options options, + ChainsConfig.ChainConfig chainConf) { + return UpstreamCreationData.default() + } + + UpstreamsConfig.Labels callBuildLabels(Map src) { + return buildUpstreamLabels(src) + } + } + + TestCreator makeCreator(ResponseSigner signer) { + return new TestCreator(Mock(ChainsConfig), Mock(CallTargetsHolder), signer) + } + + def "Adds secure-signed label when signer is enabled"() { + setup: + def creator = makeCreator(new RsaSigner(Stub(RSAPrivateKey), 1L)) + + when: + def labels = creator.callBuildLabels(["provider": "drpc"]) + + then: + labels["provider"] == "drpc" + labels["secure-signed"] == "true" + } + + def "Does not add secure-signed label when signer is disabled"() { + setup: + def creator = makeCreator(new DisabledSigner()) + + when: + def labels = creator.callBuildLabels(["provider": "drpc"]) + + then: + labels["provider"] == "drpc" + !labels.containsKey("secure-signed") + } + + def "Does not override user-provided secure-signed label"() { + setup: + def creator = makeCreator(new RsaSigner(Stub(RSAPrivateKey), 1L)) + + when: + def labels = creator.callBuildLabels(["secure-signed": "false"]) + + then: + labels["secure-signed"] == "false" + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy index d167ce330..d970c4bea 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy @@ -30,21 +30,26 @@ class EthereumLocalReaderSpec extends Specification { act.resultAsProcessedString == "0x0000000000000000000000000000000000000000" } - def "Returns empty if nonce set"() { + def "Serves non-hardcoded call when nonce is set"() { setup: def methods = new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) + def api = TestingCommons.api() + api.answer("eth_getTransactionByHash", + ["0x0000000000000000000000000000000000000000000000000000000000000001"], null) def router = new EthereumLocalReader( new EthereumCachingReader( - TestingCommons.multistream(TestingCommons.api()), + TestingCommons.multistream(api), Caches.default(), ConstantFactory.constantFactory(new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET)), ), methods ) when: - def act = router.read(new ChainRequest("eth_getTransactionByHash", new ListParams(["test"]), 10)) + def act = router.read(new ChainRequest("eth_getTransactionByHash", + new ListParams(["0x0000000000000000000000000000000000000000000000000000000000000001"]), + 10)) .block(Duration.ofSeconds(1)) then: - act == null + act != null } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy new file mode 100644 index 000000000..54f423643 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy @@ -0,0 +1,28 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import spock.lang.Specification + +class DisabledSignerSpec extends Specification { + + def "sign throws RpcException with CODE_INTERNAL_ERROR"() { + setup: + def signer = new DisabledSigner() + + when: + signer.sign(1L, "data".bytes, "upstreamId") + + then: + def ex = thrown(RpcException) + ex.code == -32603 + ex.rpcMessage.contains("signing key is not configured") + } + + def "Signer is not enabled"() { + setup: + def signer = new DisabledSigner() + + expect: + !signer.enabled + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy deleted file mode 100644 index c49a5b6fd..000000000 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy +++ /dev/null @@ -1,135 +0,0 @@ -package io.emeraldpay.dshackle.upstream.signature - -import io.emeraldpay.dshackle.config.SignatureConfig -import io.emeraldpay.dshackle.upstream.Upstream -import org.apache.commons.codec.binary.Hex -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.bouncycastle.util.io.pem.PemObject -import org.bouncycastle.util.io.pem.PemWriter -import spock.lang.Specification - -import java.security.KeyFactory -import java.security.KeyPairGenerator -import java.security.MessageDigest -import java.security.Security -import java.security.Signature -import java.security.interfaces.ECPrivateKey -import java.security.spec.ECGenParameterSpec -import java.security.spec.PKCS8EncodedKeySpec - -class EcdsaSignerSpec extends Specification { - - def setupSpec() { - Security.addProvider(new BouncyCastleProvider()) - } - - def "Reads private key NIST P256"() { - setup: - def file = File.createTempFile("test", ".pem") - def keygen = KeyPairGenerator.getInstance("EC") - keygen.initialize(new ECGenParameterSpec("secp256r1")) - def key = keygen.generateKeyPair() - def keyBuilder = new PKCS8EncodedKeySpec(key.getPrivate().getEncoded()) - def writer = new PemWriter(new FileWriter(file.path)) - writer.writeObject(new PemObject("PRIVATE KEY", keyBuilder.getEncoded())) - writer.close() - - when: - def signer = new ResponseSignerFactory(new SignatureConfig()) - def act = signer.readKey(SignatureConfig.Algorithm.NIST_P256, file.absolutePath).first - - then: - act == key.getPrivate() - - cleanup: - file.delete() - } - - def "Id is a hash of x509 public key"() { - setup: - def conf = new SignatureConfig() - conf.enabled = true - conf.privateKey = "src/test/resources/signer/test_key" - def signer = new ResponseSignerFactory(conf).createSigner() as EcdsaSigner - - // To verify the test, check the hash of test key above: - // - // echo MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE3zetdMdyTO/sTFCLeOrI5moiZt2RjfUVdavhorgqd+gxAqM01cf5Q4QZ8INne9RykcQsbLYXQfDXJbGMm5+gdg== | base64 -d - | shasum -a 256 - // d25f1ff2c1a57235a9bc7725cd645ab0e9631475a12402f2881579d3f6887597 - - // - - when: - def id = signer.keyId - - then: - id == 0xed397068b172b393L - } - - def "Wrap message"() { - setup: - def up = Mock(Upstream) { - _ * getId() >> "infura" - } - def signer = new EcdsaSigner(Stub(ECPrivateKey), 100L) - - when: - def act = signer.wrapMessage(10, "test".bytes, up.id) - - then: - act == "DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" - } - - def "Signed message is valid"() { - setup: - def result = "test".bytes - def up = Mock(Upstream) { - _ * getId() >> "infura" - } - - def keyPairGen = KeyPairGenerator.getInstance("EC") - keyPairGen.initialize(new ECGenParameterSpec("secp256r1")) - def pair = keyPairGen.generateKeyPair() - def verifier = Signature.getInstance("SHA256withECDSA") - verifier.initVerify(pair.getPublic()) - verifier.update("DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".getBytes()) - - def signer = new EcdsaSigner((pair.getPrivate() as ECPrivateKey), 100L) - - when: - def sig = signer.sign(10, result, up.id) - - then: - verifier.verify(sig.value) - } - - def "Signed message is valid - for docs"() { - // it's the example used in docs - setup: - def result = '["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", true]'.bytes - def up = Mock(Upstream) { - _ * getId() >> "infura" - } - - def sha256 = MessageDigest.getInstance("SHA-256") - - def conf = new SignatureConfig() - conf.enabled = true - conf.privateKey = "src/test/resources/signer/test_key" - def factory = new ResponseSignerFactory(conf) - - def sk = factory.readKey(conf.algorithm, conf.privateKey).first - def pk = factory.extractPublicKey(KeyFactory.getInstance("EC"), sk, SignatureConfig.Algorithm.NIST_P256) - def verifier = Signature.getInstance("SHA256withECDSA") - verifier.initVerify(pk) - verifier.update("DSHACKLESIG/10/infura/${Hex.encodeHexString(sha256.digest(result))}".getBytes()) - - def signer = factory.createSigner() as EcdsaSigner - - when: - def sig = signer.sign(10, result, up.id) - println("Signature: ${Hex.encodeHexString(sig.value)}") - - then: - verifier.verify(sig.value) - } -} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy index 91f268539..4769bb2fd 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy @@ -1,28 +1,90 @@ package io.emeraldpay.dshackle.upstream.signature - -import io.emeraldpay.dshackle.config.SignatureConfig +import io.emeraldpay.dshackle.config.AuthorizationConfig +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.springframework.util.ResourceUtils import spock.lang.Specification +import java.security.Security + class ResponseSignerFactorySpec extends Specification { + def setupSpec() { + Security.addProvider(new BouncyCastleProvider()) + } + + def "DisabledSigner when auth disabled"() { + setup: + def auth = AuthorizationConfig.default() + + when: + def signer = new ResponseSignerFactory(auth).createSigner() + + then: + signer instanceof DisabledSigner + } - def "No signer if not enabled"() { + def "DisabledSigner when provider-private-key path is blank"() { setup: - def conf = new SignatureConfig() + def auth = new AuthorizationConfig( + true, + "owner", + new AuthorizationConfig.ServerConfig("", "classpath:keys/public.pem"), + AuthorizationConfig.ClientConfig.default(), + ) + when: - def signer = new ResponseSignerFactory(conf).createSigner() + def signer = new ResponseSignerFactory(auth).createSigner() + then: - signer instanceof NoSigner + signer instanceof DisabledSigner } - def "No signer if privkey is not configured"() { + def "RsaSigner built from valid RSA key"() { setup: - def conf = new SignatureConfig() + def privPath = ResourceUtils.getFile("classpath:keys/priv.p8.key").absolutePath + def pubPath = ResourceUtils.getFile("classpath:keys/public.pem").absolutePath + def auth = new AuthorizationConfig( + true, + "owner", + new AuthorizationConfig.ServerConfig(privPath, pubPath), + AuthorizationConfig.ClientConfig.default(), + ) + when: - def signer = new ResponseSignerFactory(conf).createSigner() + def signer = new ResponseSignerFactory(auth).createSigner() + then: - signer instanceof NoSigner + signer instanceof RsaSigner + (signer as RsaSigner).keyId != 0L } + def "Fails on missing key file"() { + setup: + def auth = new AuthorizationConfig( + true, + "owner", + new AuthorizationConfig.ServerConfig("/no/such/file.pem", "classpath:keys/public.pem"), + AuthorizationConfig.ClientConfig.default(), + ) + + when: + new ResponseSignerFactory(auth).createSigner() + + then: + thrown(Exception) + } + + def "DisabledSigner.sign throws RpcException"() { + setup: + def signer = new ResponseSignerFactory(AuthorizationConfig.default()).createSigner() + + when: + signer.sign(1L, "data".bytes, "up") + + then: + def ex = thrown(RpcException) + ex.code == -32603 + } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy new file mode 100644 index 000000000..2e212a454 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy @@ -0,0 +1,75 @@ +package io.emeraldpay.dshackle.upstream.signature + +import org.apache.commons.codec.binary.Hex +import org.bouncycastle.jce.provider.BouncyCastleProvider +import spock.lang.Specification + +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Security +import java.security.Signature +import java.security.interfaces.RSAPrivateKey + +class RsaSignerSpec extends Specification { + + def setupSpec() { + Security.addProvider(new BouncyCastleProvider()) + } + + def "Wrap message"() { + setup: + def signer = new RsaSigner(Stub(RSAPrivateKey), 100L) + + when: + def act = signer.wrapMessage(10, "test".bytes, "infura") + + then: + act == "DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + + def "Signed message is valid"() { + setup: + def result = "test".bytes + def keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + def pair = keyGen.generateKeyPair() + + def sha256 = MessageDigest.getInstance("SHA-256") + def verifier = Signature.getInstance("SHA256withRSA", "BC") + verifier.initVerify(pair.getPublic()) + verifier.update("DSHACKLESIG/10/infura/${Hex.encodeHexString(sha256.digest(result))}".getBytes()) + + def signer = new RsaSigner((pair.getPrivate() as RSAPrivateKey), 100L) + + when: + def sig = signer.sign(10, result, "infura") + + then: + verifier.verify(sig.value) + sig.upstreamId == "infura" + sig.keyId == 100L + } + + def "Signer is enabled"() { + setup: + def signer = new RsaSigner(Stub(RSAPrivateKey), 1L) + + expect: + signer.enabled + } + + def "Different nonce produces different signature"() { + setup: + def keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + def pair = keyGen.generateKeyPair() + def signer = new RsaSigner((pair.getPrivate() as RSAPrivateKey), 1L) + + when: + def sig1 = signer.sign(1, "test".bytes, "up") + def sig2 = signer.sign(2, "test".bytes, "up") + + then: + !Arrays.equals(sig1.value, sig2.value) + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt index 57c7c8f5b..83191b308 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt @@ -10,6 +10,7 @@ import io.emeraldpay.dshackle.reader.BroadcastReader import io.emeraldpay.dshackle.reader.RequestReaderFactory import io.emeraldpay.dshackle.upstream.MultistreamHolder import io.emeraldpay.dshackle.upstream.Selector +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import io.grpc.BindableService import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -65,7 +66,7 @@ class IntegrationTest { ms, Selector.UpstreamFilter.default, txQuorum, - null, + DisabledSigner(), ), ) val txCountReader = reqReader.create( @@ -73,7 +74,7 @@ class IntegrationTest { ms, Selector.UpstreamFilter.default, txCountQuorum, - null, + DisabledSigner(), ), ) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt index 169a4ade3..22bd2627a 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt @@ -4,6 +4,7 @@ import io.emeraldpay.dshackle.quorum.BroadcastQuorum import io.emeraldpay.dshackle.quorum.MaximumValueQuorum import io.emeraldpay.dshackle.upstream.Multistream import io.emeraldpay.dshackle.upstream.Selector +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -34,7 +35,7 @@ class RequestReaderFactoryTest { ms, Selector.UpstreamFilter(Selector.empty), MaximumValueQuorum(), - null, + DisabledSigner(), ), ), Arguments.of( @@ -42,7 +43,7 @@ class RequestReaderFactoryTest { ms, Selector.UpstreamFilter(Selector.empty), BroadcastQuorum(), - null, + DisabledSigner(), ), ), ) From aa40578664eab2631bae282f83081e6ed0516232 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 21 Apr 2026 18:24:33 +0200 Subject: [PATCH 062/108] Add safe error parsing for REST HTTP responses (#812) * fix(rest): handle non-JSON error bodies on non-200 HTTP responses When a REST upstream (e.g. TON) returns a non-200 status with a plain-text body such as "Service Unavailable" but advertises application/json as the content-type, the error parser would throw JsonParseException ("Unrecognized token 'Service'"), which bubbled up as a generic -32005 ChainException and obscured the real cause. Catch JsonParseException/IOException around readError and fall back to a "HTTP Code: X" ChainCallError (CODE_UPSTREAM_INVALID_RESPONSE), matching the behavior already implemented in JsonRpcHttpReader. Also return the fallback when readError returns null. * fix(rest): close parser, log stack traces, fall back on empty errors Address PR review feedback: - Close the Jackson JsonParser via `use { ... }` to release buffers. - Switch to parameterized SLF4J logging and pass the exception so stack traces are preserved on parsing/IO failures. - Treat a parsed error with code=0 and blank message (e.g. from a bare `{}` body) as no useful error info and fall back to the generic HTTP-status error so the upstream status isn't silently dropped. * fix(rest): short-circuit HTTP 503 without JSON parsing Narrow the fix to exactly the case reported: HTTP 503 Service Unavailable responses (typically from Cloudflare) carry a plain-text or HTML body but can be served with Content-Type: application/json, so attempting to parse the body as JSON raises JsonParseException and surfaces as a confusing -32005 "Unrecognized token 'Service'" error. For status 503, skip parsing entirely and return a ChainCallError directly. All other non-200 responses keep the existing behavior. * docs(rest): align 503 branch comment with error code --------- Co-authored-by: Claude --- .../dshackle/upstream/restclient/RestHttpReader.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt index ebd3694fd..0b4a90750 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt @@ -3,10 +3,12 @@ package io.emeraldpay.dshackle.upstream.restclient import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.config.AuthConfig +import io.emeraldpay.dshackle.upstream.ChainCallError import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.HttpReader import io.emeraldpay.dshackle.upstream.RequestMetrics +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError import io.emeraldpay.dshackle.upstream.generic.ChainSpecificRegistry import io.emeraldpay.dshackle.upstream.rpcclient.ResponseRpcParser import io.emeraldpay.dshackle.upstream.rpcclient.RestParams @@ -64,7 +66,15 @@ class RestHttpReader( when (it) { is StreamResponse -> sink.next(ChainResponse(it.stream, key.id, it.headers)) is AggregateResponse -> { - if (it.code != 200) { + if (it.code == 503) { + // 503 bodies (e.g. Cloudflare) are typically plain text / HTML, not JSON, + // so skip JSON parsing to avoid a misleading "Unrecognized token" error + val error = ChainCallError( + RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, + "HTTP Code: 503", + ) + sink.next(ChainResponse(null, error, it.headers)) + } else if (it.code != 200) { val error = parser.readError(Global.objectMapper.createParser(it.response)) sink.next(ChainResponse(null, error, it.headers)) } else { From 5414e06e46f39ee9aa6dff7df12c17d3f3794f1d Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 23 Apr 2026 13:10:56 +0200 Subject: [PATCH 063/108] Add support for Algorand Virtual Machine (AVM) blockchain (#813) * add AVM blockchain type support for Algorand Introduce a new blockchain type `avm` (Algorand Virtual Machine) so Algorand-based chains (Mainnet, Testnet, Betanet) declared in chains.yaml can be parsed and driven through dshackle's generic polling pipeline. Key additions: - BlockchainType.AVM (JSON_RPC) and `avm` mapping in chain codegen - AvmChainSpecific: polls `algod_getBlock` for latest block and uses `algod_status` / `algod_genesis` for health and settings validation - DefaultAvmMethods: allowed algod_* RPC surface with send-transaction broadcast quorum and hardcoded `algod_chainId` / `algod_genesisId` - AvmLowerBoundService / AvmLowerBoundStateDetector for lower-bound tracking - Registration in ChainSpecificRegistry and CallTargetsHolder - Unit tests for block parsing, sync validation and method policies https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * inline DummyChainReader in AVM test Match the Starknet test pattern and drop the dedicated DummyChainReader file; instead pass an anonymous ChainReader directly where needed. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * fix AVM network validation logic Validation used AND across all conditions, so when netVersion was 0 (typical for non-EVM chains) the check short-circuited and allowed a mismatching chain.chainId through. Simplify to the Cosmos pattern: reject when chain.chainId is set and doesn't match genesis.network. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * switch AVM to REST transport for native algod nodes Native Algorand algod nodes expose a REST API (/v2/status, /v2/blocks, /v2/transactions, ...) rather than JSON-RPC, so drive AVM upstreams through dshackle's REST reader instead: - BlockchainType.AVM is now ApiType.REST - AvmChainSpecific polls GET#/v2/status for latest block info and uses GET#/v2/genesis for chain-id validation, both via RestParams - DefaultAvmMethods exposes the algod /v2 endpoints with VERB#/path identifiers (POST#/v2/transactions uses BroadcastQuorum for sends) - Tests updated to match the REST-based method names https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * align AVM endpoints with algod OpenAPI spec Validated the AVM method surface against the official algod spec (algorand/go-algorand algod.oas3.yml) and corrected mismatches: Wrong paths removed: - GET /v2/genesis, /v2/versions, /v2/health, /v2/ready, /v2/metrics (these exist at root level, not under /v2/) - GET /v2/blocks/{round}/header (use header-only query on /v2/blocks/{round}) - GET /v2/blocks/{round}/transactions (no such path) - GET /v2/lightheader/{round} (real path is /v2/blocks/{round}/lightheader/proof) - POST /v2/transactions/dryrun (real path is /v2/teal/dryrun, already listed) Added real endpoints: - Root: GET /genesis, /health, /ready, /metrics, /versions, /swagger.json - /v2/blocks/*/txids, /v2/blocks/*/logs, /v2/blocks/*/lightheader/proof - /v2/accounts/*/transactions/pending - /v2/deltas/*, /v2/deltas/*/txn/group, /v2/deltas/txn/group/* Also fixed AvmChainSpecific settings validator: use GET#/genesis (root) instead of the non-existent GET#/v2/genesis. Added regression test asserting the spurious paths are NOT callable. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * fetch real AVM block data via chained api.read parseBlock now uses the ChainReader passed through AbstractPollChainSpecific to fetch GET#/v2/blocks/{lastRound}?header-only=true after /v2/status, so BlockContainer.timestamp comes from block.ts and hash/parentHash decode from block.seed/block.prev (base64 -> 32 raw bytes) instead of being synthesized from the round number. Also differentiate quorum per method in DefaultAvmMethods: single- resource lookups (/v2/blocks/{round}, /v2/accounts/{addr}, etc.) now use NotNullQuorum so a single replica returning empty/404 doesn't shadow a valid response from another upstream. List and status endpoints keep AlwaysQuorum; send endpoints keep BroadcastQuorum. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * Update submodules * Update chains * Fix settings validation --------- Co-authored-by: Claude --- Makefile | 2 +- .../kotlin/chainsconfig.codegen.gradle.kts | 1 + emerald-grpc | 2 +- .../io/emeraldpay/dshackle/BlockchainType.kt | 3 +- foundation/src/main/resources/public | 2 +- .../dshackle/upstream/CallTargetsHolder.kt | 3 + .../dshackle/upstream/avm/AvmChainSpecific.kt | 175 ++++++++++++++++++ .../upstream/avm/AvmLowerBoundService.kt | 15 ++ .../avm/AvmLowerBoundStateDetector.kt | 24 +++ .../upstream/calls/DefaultAvmMethods.kt | 150 +++++++++++++++ .../upstream/generic/ChainSpecific.kt | 3 + .../upstream/avm/AvmChainSpecificTest.kt | 93 ++++++++++ .../upstream/calls/DefaultAvmMethodsTest.kt | 119 ++++++++++++ 13 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt diff --git a/Makefile b/Makefile index bc1bac9bb..800186e36 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ build-foundation: cd foundation && ../gradlew build publishToMavenLocal run-main: - ./gradlew run + ./gradlew run -x test build-main: ./gradlew build diff --git a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts index 0d8109058..ec0699f2f 100644 --- a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts +++ b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts @@ -140,6 +140,7 @@ open class CodeGen(private val config: ChainsConfig) { "cosmos" -> "BlockchainType.COSMOS" "ripple" -> "BlockchainType.RIPPLE" "kadena" -> "BlockchainType.KADENA" + "avm" -> "BlockchainType.AVM" "app" -> "BlockchainType.ETHEREUM" else -> throw IllegalArgumentException("unknown blockchain type $type") } diff --git a/emerald-grpc b/emerald-grpc index a0df7d714..67b4cccdd 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit a0df7d714482fcea2445abd81abce7805ab78995 +Subproject commit 67b4cccdd71bb86db0e93e4b118c38e8f6b386e2 diff --git a/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt b/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt index 227f6dded..a81006c03 100644 --- a/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt +++ b/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt @@ -15,7 +15,8 @@ enum class BlockchainType( COSMOS(ApiType.JSON_RPC), TON(ApiType.REST), RIPPLE(ApiType.JSON_RPC), - KADENA(ApiType.REST),; + KADENA(ApiType.REST), + AVM(ApiType.REST),; } enum class ApiType { diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 4510b37a5..c0fc62c41 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 4510b37a56e97c188b30dff63093f10c560c68d6 +Subproject commit c0fc62c41fb4048fa91875f8f3a5ec432b628ff2 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt index fa51101b3..b877d56f4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt @@ -1,5 +1,6 @@ package io.emeraldpay.dshackle.upstream +import io.emeraldpay.dshackle.BlockchainType.AVM import io.emeraldpay.dshackle.BlockchainType.AZTEC import io.emeraldpay.dshackle.BlockchainType.BITCOIN import io.emeraldpay.dshackle.BlockchainType.COSMOS @@ -17,6 +18,7 @@ import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.foundation.ChainOptions import io.emeraldpay.dshackle.upstream.calls.CallMethods +import io.emeraldpay.dshackle.upstream.calls.DefaultAvmMethods import io.emeraldpay.dshackle.upstream.calls.DefaultAztecMethods import io.emeraldpay.dshackle.upstream.calls.DefaultBeaconChainMethods import io.emeraldpay.dshackle.upstream.calls.DefaultBitcoinMethods @@ -49,6 +51,7 @@ class CallTargetsHolder { ): CallMethods { val created = when (chain.type) { BITCOIN -> DefaultBitcoinMethods(options.providesBalance == true) + AVM -> DefaultAvmMethods() AZTEC -> DefaultAztecMethods() ETHEREUM -> DefaultEthereumMethods(chain) STARKNET -> DefaultStarknetMethods(chain) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt new file mode 100644 index 000000000..955e44ef8 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -0,0 +1,175 @@ +package io.emeraldpay.dshackle.upstream.avm + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.config.ChainsConfig.ChainConfig +import io.emeraldpay.dshackle.data.BlockContainer +import io.emeraldpay.dshackle.data.BlockId +import io.emeraldpay.dshackle.foundation.ChainOptions.Options +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator +import io.emeraldpay.dshackle.upstream.SingleValidator +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult +import io.emeraldpay.dshackle.upstream.generic.AbstractPollChainSpecific +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService +import io.emeraldpay.dshackle.upstream.rpcclient.RestParams +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import java.math.BigInteger +import java.time.Instant + +object AvmChainSpecific : AbstractPollChainSpecific() { + + private val log = LoggerFactory.getLogger(AvmChainSpecific::class.java) + + override fun latestBlockRequest(): ChainRequest = + ChainRequest("GET#/v2/status", RestParams.emptyParams()) + + override fun parseBlock(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + val status = Global.objectMapper.readValue(data, AvmStatus::class.java) + val round = status.lastRound + val blockRequest = ChainRequest( + "GET#/v2/blocks/$round", + RestParams( + headers = emptyList(), + queryParams = listOf("format" to "json", "header-only" to "true"), + pathParams = emptyList(), + payload = ByteArray(0), + ), + ) + return api.read(blockRequest) + .map { resp -> + val blockData = resp.getResult() + val block = Global.objectMapper.readValue(blockData, AvmBlockResult::class.java).block + BlockContainer( + height = block.round, + hash = BlockId.from(toHashBytes(block.seed ?: block.txnRoot, block.round)), + difficulty = BigInteger.ZERO, + timestamp = Instant.ofEpochSecond(block.timestamp), + full = false, + json = blockData, + parsed = block, + transactions = emptyList(), + upstreamId = upstreamId, + parentHash = BlockId.from(toHashBytes(block.previousBlockHash, block.round - 1)), + ) + } + } + + override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + throw NotImplementedError() + } + + override fun listenNewHeadsRequest(): ChainRequest { + throw NotImplementedError() + } + + override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest { + throw NotImplementedError() + } + + override fun upstreamValidators( + chain: Chain, + upstream: Upstream, + options: Options, + config: ChainConfig, + ): List> { + return listOf( + GenericSingleCallValidator( + ChainRequest("GET#/v2/status", RestParams.emptyParams()), + upstream, + ) { data -> + validate(data, upstream.getId()) + }, + ) + } + + override fun upstreamSettingsValidators( + chain: Chain, + upstream: Upstream, + options: Options, + config: ChainConfig, + ): List> { + return emptyList() + } + + override fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService { + return AvmLowerBoundService(chain, upstream) + } + + fun validate(data: ByteArray, upstreamId: String): UpstreamAvailability { + val status = Global.objectMapper.readValue(data, AvmStatus::class.java) + return if (status.catchupTime > 0L) { + log.warn("AVM node {} is catching up: catchupTime={}ns", upstreamId, status.catchupTime) + UpstreamAvailability.SYNCING + } else { + UpstreamAvailability.OK + } + } + + // Algorand JSON blocks encode 32-byte fields (seed, prev, txn) in base64. + // Decode to raw bytes; if decoding fails or the field is absent, fall back + // to a deterministic 32-byte encoding of the round number. + private fun toHashBytes(raw: String?, round: Long): ByteArray { + if (raw.isNullOrBlank()) { + return roundToBytes(round) + } + val stripped = raw.removePrefix("blk-") + return try { + java.util.Base64.getDecoder().decode(stripped) + } catch (_: IllegalArgumentException) { + try { + java.util.Base64.getUrlDecoder().decode(stripped) + } catch (_: IllegalArgumentException) { + roundToBytes(round) + } + } + } + + private fun roundToBytes(round: Long): ByteArray { + val bytes = ByteArray(32) + var value = if (round < 0) 0L else round + for (i in 0 until 8) { + bytes[31 - i] = (value and 0xff).toByte() + value = value ushr 8 + } + return bytes + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmStatus( + @param:JsonProperty("last-round") var lastRound: Long = 0, + @param:JsonProperty("catchup-time") var catchupTime: Long = 0, + @param:JsonProperty("time-since-last-round") var timeSinceLastRound: Long = 0, + @param:JsonProperty("last-version") var lastVersion: String? = null, + @param:JsonProperty("next-version") var nextVersion: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmBlockResult( + @param:JsonProperty("block") var block: AvmBlock, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmBlock( + @param:JsonProperty("rnd") var round: Long, + @param:JsonProperty("ts") var timestamp: Long, + @param:JsonProperty("prev") var previousBlockHash: String? = null, + @param:JsonProperty("seed") var seed: String? = null, + @param:JsonProperty("txn") var txnRoot: String? = null, + @param:JsonProperty("gh") var genesisHash: String? = null, + @param:JsonProperty("gen") var genesisId: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmGenesis( + @param:JsonProperty("network") var network: String = "", + @param:JsonProperty("id") var id: String = "", + @param:JsonProperty("proto") var proto: String = "", +) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt new file mode 100644 index 000000000..6fe24d36c --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt @@ -0,0 +1,15 @@ +package io.emeraldpay.dshackle.upstream.avm + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService + +class AvmLowerBoundService( + private val chain: Chain, + upstream: Upstream, +) : LowerBoundService(chain, upstream) { + override fun detectors(): List { + return listOf(AvmLowerBoundStateDetector(chain)) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt new file mode 100644 index 000000000..c83e8ab21 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt @@ -0,0 +1,24 @@ +package io.emeraldpay.dshackle.upstream.avm + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import reactor.core.publisher.Flux + +class AvmLowerBoundStateDetector( + chain: Chain, +) : LowerBoundDetector(chain) { + + override fun period(): Long { + return 120 + } + + override fun internalDetectLowerBound(): Flux { + return Flux.just(LowerBoundData(1, LowerBoundType.STATE)) + } + + override fun types(): Set { + return setOf(LowerBoundType.STATE) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt new file mode 100644 index 000000000..f75455a70 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * Copyright (c) 2019 ETCDEV GmbH + * + * 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.calls + +import io.emeraldpay.dshackle.quorum.AlwaysQuorum +import io.emeraldpay.dshackle.quorum.BroadcastQuorum +import io.emeraldpay.dshackle.quorum.CallQuorum +import io.emeraldpay.dshackle.quorum.NotNullQuorum +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException + +/** + * Default configuration for AVM (Algorand Virtual Machine) REST API, matching + * the algod OpenAPI spec. Method identifiers use the `VERB#/path` convention + * consumed by dshackle's REST HTTP reader. + */ +class DefaultAvmMethods : CallMethods { + + // Root-level common endpoints (not under /v2/*) + private val commonMethods = setOf( + getMethod("/genesis"), + getMethod("/health"), + getMethod("/ready"), + getMethod("/metrics"), + getMethod("/versions"), + getMethod("/swagger.json"), + ) + + // Node / ledger / blocks read endpoints under /v2/* + private val nodeMethods = setOf( + getMethod("/v2/status"), + getMethod("/v2/status/wait-for-block-after/*"), + getMethod("/v2/ledger/supply"), + getMethod("/v2/ledger/sync"), + getMethod("/v2/blocks/*"), + getMethod("/v2/blocks/*/hash"), + getMethod("/v2/blocks/*/txids"), + getMethod("/v2/blocks/*/logs"), + getMethod("/v2/blocks/*/lightheader/proof"), + getMethod("/v2/blocks/*/transactions/*/proof"), + getMethod("/v2/stateproofs/*"), + getMethod("/v2/deltas/*"), + getMethod("/v2/deltas/*/txn/group"), + getMethod("/v2/deltas/txn/group/*"), + ) + + private val accountMethods = setOf( + getMethod("/v2/accounts/*"), + getMethod("/v2/accounts/*/assets"), + getMethod("/v2/accounts/*/assets/*"), + getMethod("/v2/accounts/*/applications/*"), + getMethod("/v2/accounts/*/transactions/pending"), + getMethod("/v2/applications/*"), + getMethod("/v2/applications/*/box"), + getMethod("/v2/applications/*/boxes"), + getMethod("/v2/assets/*"), + ) + + private val transactionReadMethods = setOf( + getMethod("/v2/transactions/params"), + getMethod("/v2/transactions/pending"), + getMethod("/v2/transactions/pending/*"), + ) + + private val sendMethods = setOf( + postMethod("/v2/transactions"), + postMethod("/v2/transactions/async"), + ) + + private val computeMethods = setOf( + postMethod("/v2/transactions/simulate"), + postMethod("/v2/teal/compile"), + postMethod("/v2/teal/disassemble"), + postMethod("/v2/teal/dryrun"), + ) + + private val allowedMethods: Set = + commonMethods + nodeMethods + accountMethods + transactionReadMethods + sendMethods + computeMethods + + // Paths that look up a specific resource by id/round and should reject + // empty/404 answers via NotNullQuorum, so a single missing-replica + // response doesn't silently beat valid ones from other upstreams. + private val notNullReadMethods: Set = setOf( + getMethod("/v2/blocks/*"), + getMethod("/v2/blocks/*/hash"), + getMethod("/v2/blocks/*/txids"), + getMethod("/v2/blocks/*/logs"), + getMethod("/v2/blocks/*/lightheader/proof"), + getMethod("/v2/blocks/*/transactions/*/proof"), + getMethod("/v2/accounts/*"), + getMethod("/v2/accounts/*/assets/*"), + getMethod("/v2/accounts/*/applications/*"), + getMethod("/v2/applications/*"), + getMethod("/v2/applications/*/box"), + getMethod("/v2/applications/*/boxes"), + getMethod("/v2/assets/*"), + getMethod("/v2/transactions/pending/*"), + getMethod("/v2/stateproofs/*"), + getMethod("/v2/deltas/*"), + getMethod("/v2/deltas/*/txn/group"), + getMethod("/v2/deltas/txn/group/*"), + ) + + override fun createQuorumFor(method: String): CallQuorum { + return when { + sendMethods.contains(method) -> BroadcastQuorum() + notNullReadMethods.contains(method) -> NotNullQuorum() + else -> AlwaysQuorum() + } + } + + override fun isCallable(method: String): Boolean { + return allowedMethods.contains(method) + } + + override fun isHardcoded(method: String): Boolean { + return false + } + + override fun executeHardcoded(method: String): ByteArray { + throw RpcException(-32601, "Method not found") + } + + override fun getGroupMethods(groupName: String): Set = + when (groupName) { + "default" -> getSupportedMethods() + else -> emptySet() + } + + override fun getSupportedMethods(): Set { + return allowedMethods.toSortedSet() + } + + private fun getMethod(path: String) = "GET#$path" + + private fun postMethod(path: String) = "POST#$path" +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt index ddb6863f6..1e03b59e4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt @@ -1,5 +1,6 @@ package io.emeraldpay.dshackle.upstream.generic +import io.emeraldpay.dshackle.BlockchainType.AVM import io.emeraldpay.dshackle.BlockchainType.AZTEC import io.emeraldpay.dshackle.BlockchainType.BITCOIN import io.emeraldpay.dshackle.BlockchainType.COSMOS @@ -33,6 +34,7 @@ import io.emeraldpay.dshackle.upstream.UpstreamRpcMethodsDetector import io.emeraldpay.dshackle.upstream.UpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.UpstreamValidator import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult +import io.emeraldpay.dshackle.upstream.avm.AvmChainSpecific import io.emeraldpay.dshackle.upstream.aztec.AztecChainSpecific import io.emeraldpay.dshackle.upstream.beaconchain.BeaconChainSpecific import io.emeraldpay.dshackle.upstream.calls.CallMethods @@ -115,6 +117,7 @@ object ChainSpecificRegistry { @JvmStatic fun resolve(chain: Chain): ChainSpecific { return when (chain.type) { + AVM -> AvmChainSpecific AZTEC -> AztecChainSpecific ETHEREUM -> EthereumChainSpecific STARKNET -> StarknetChainSpecific diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt new file mode 100644 index 000000000..2aaa86f57 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt @@ -0,0 +1,93 @@ +package io.emeraldpay.dshackle.upstream.avm + +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import reactor.core.publisher.Mono + +val avmStatusSynced = """ + { + "last-round": 30000000, + "last-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version-round": 30000001, + "next-version-supported": true, + "time-since-last-round": 1500000000, + "catchup-time": 0, + "last-catchpoint": "" + } +""".trimIndent() + +val avmStatusCatchingUp = """ + { + "last-round": 30000000, + "last-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version-round": 30000001, + "next-version-supported": true, + "time-since-last-round": 1500000000, + "catchup-time": 1500000000, + "last-catchpoint": "30000000#QWERTYU" + } +""".trimIndent() + +val avmBlockHeader = """ + { + "block": { + "rnd": 30000000, + "ts": 1696802363, + "prev": "blk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "seed": "c29tZXNlZWRieXRlc3RoYXRpczMyYnl0ZXNsb25nISEh", + "txn": "dHhucm9vdGhhc2h2YWx1ZWZvcnRlc3RpbmcxMjM0NTY=", + "gen": "mainnet-v1.0", + "proto": "https://github.com/algorandfoundation/specs/tree/somehash" + }, + "cert": {} + } +""".trimIndent() + +class AvmChainSpecificTest { + + @Test + fun parseBlockChainsThroughBlockEndpoint() { + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + Assertions.assertThat(key.method).isEqualTo("GET#/v2/blocks/30000000") + return Mono.just(ChainResponse(avmBlockHeader.toByteArray(), null)) + } + } + + val result = AvmChainSpecific.parseBlock( + avmStatusSynced.toByteArray(), + "upstream-1", + reader, + ).block()!! + + Assertions.assertThat(result.height).isEqualTo(30000000L) + Assertions.assertThat(result.upstreamId).isEqualTo("upstream-1") + Assertions.assertThat(result.timestamp.epochSecond).isEqualTo(1696802363L) + Assertions.assertThat(result.hash.toHex()).isNotEmpty() + Assertions.assertThat(result.parentHash?.toHex()).isNotEmpty() + } + + @Test + fun validateSyncedNode() { + Assertions.assertThat(AvmChainSpecific.validate(avmStatusSynced.toByteArray(), "test")) + .isEqualTo(UpstreamAvailability.OK) + } + + @Test + fun validateCatchingUpNode() { + Assertions.assertThat(AvmChainSpecific.validate(avmStatusCatchingUp.toByteArray(), "test")) + .isEqualTo(UpstreamAvailability.SYNCING) + } + + @Test + fun latestBlockRequestUsesStatusEndpoint() { + val request = AvmChainSpecific.latestBlockRequest() + Assertions.assertThat(request.method).isEqualTo("GET#/v2/status") + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt new file mode 100644 index 000000000..8f6ed15c7 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt @@ -0,0 +1,119 @@ +package io.emeraldpay.dshackle.upstream.calls + +import io.emeraldpay.dshackle.quorum.AlwaysQuorum +import io.emeraldpay.dshackle.quorum.BroadcastQuorum +import io.emeraldpay.dshackle.quorum.NotNullQuorum +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test + +class DefaultAvmMethodsTest { + + private val methods = DefaultAvmMethods() + + @Test + fun rootLevelCommonEndpointsAreCallable() { + Assertions.assertThat(methods.isCallable("GET#/genesis")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/health")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/ready")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/versions")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/metrics")).isTrue() + } + + @Test + fun v2ReadMethodsAreCallable() { + Assertions.assertThat(methods.isCallable("GET#/v2/status")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/hash")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/txids")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/lightheader/proof")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/accounts/*")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/accounts/*/transactions/pending")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/transactions/pending")).isTrue() + } + + @Test + fun sendMethodsAreCallable() { + Assertions.assertThat(methods.isCallable("POST#/v2/transactions")).isTrue() + Assertions.assertThat(methods.isCallable("POST#/v2/transactions/async")).isTrue() + } + + @Test + fun spuriousAlgodEndpointsAreNotCallable() { + // These paths don't exist in algod's OpenAPI spec — regression guards. + Assertions.assertThat(methods.isCallable("GET#/v2/genesis")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/versions")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/health")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/ready")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/metrics")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/header")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/transactions")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/lightheader/*")).isFalse() + Assertions.assertThat(methods.isCallable("POST#/v2/transactions/dryrun")).isFalse() + } + + @Test + fun unknownMethodsAreNotCallable() { + Assertions.assertThat(methods.isCallable("GET#/eth/blockNumber")).isFalse() + Assertions.assertThat(methods.isCallable("algod_status")).isFalse() + Assertions.assertThat(methods.isCallable("DELETE#/v2/status")).isFalse() + } + + @Test + fun sendMethodsUseBroadcastQuorum() { + Assertions.assertThat(methods.createQuorumFor("POST#/v2/transactions")) + .isInstanceOf(BroadcastQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("POST#/v2/transactions/async")) + .isInstanceOf(BroadcastQuorum::class.java) + } + + @Test + fun listReadMethodsUseAlwaysQuorum() { + Assertions.assertThat(methods.createQuorumFor("GET#/v2/status")) + .isInstanceOf(AlwaysQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/transactions/pending")) + .isInstanceOf(AlwaysQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/ledger/supply")) + .isInstanceOf(AlwaysQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/genesis")) + .isInstanceOf(AlwaysQuorum::class.java) + } + + @Test + fun byIdLookupsUseNotNullQuorum() { + Assertions.assertThat(methods.createQuorumFor("GET#/v2/blocks/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/blocks/*/hash")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/accounts/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/applications/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/assets/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/transactions/pending/*")) + .isInstanceOf(NotNullQuorum::class.java) + } + + @Test + fun noHardcodedMethods() { + Assertions.assertThat(methods.isHardcoded("GET#/v2/status")).isFalse() + Assertions.assertThat(methods.isHardcoded("GET#/genesis")).isFalse() + } + + @Test + fun defaultGroupReturnsAllSupported() { + Assertions.assertThat(methods.getGroupMethods("default")).isEqualTo(methods.getSupportedMethods()) + Assertions.assertThat(methods.getGroupMethods("unknown")).isEmpty() + } + + @Test + fun supportedMethodsIncludeCoreEndpoints() { + val supported = methods.getSupportedMethods() + Assertions.assertThat(supported).contains( + "GET#/v2/status", + "GET#/genesis", + "POST#/v2/transactions", + "POST#/v2/teal/compile", + ) + } +} From ed7cfdc90b6d6034f31af344ebd01f430c44ef91 Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:26:59 +0000 Subject: [PATCH 064/108] Add client_type label to upstream lag and availability metrics (#815) --- .../kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt index c4680e4fb..7c7bb332a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Multistream.kt @@ -108,6 +108,7 @@ abstract class Multistream( private fun monitorUpstream(upstream: Upstream) { val upstreamId = upstream.getId() + val clientType = upstream.getLabels().firstOrNull()?.get("client_type") ?: "" // otherwise metric will stuck with prev upstream instance removeUpstreamMeters(upstreamId) @@ -118,11 +119,13 @@ abstract class Multistream( } .tag("chain", chain.chainCode) .tag("upstream", upstreamId) + .tag("client_type", clientType) .register(Metrics.globalRegistry) .id, Gauge.builder("$metrics.availability.status", upstream) { it.getStatus().grpcId.toDouble() } .tag("chain", chain.chainCode) .tag("upstream", upstreamId) + .tag("client_type", clientType) .register(Metrics.globalRegistry) .id, ) @@ -449,6 +452,7 @@ abstract class Multistream( UpstreamChangeEvent.ChangeType.REVALIDATED -> {} UpstreamChangeEvent.ChangeType.UPDATED -> { onUpstreamsUpdated() + monitorUpstream(event.upstream) updateUpstreams.emitNext(event.upstream) { _, res -> res == Sinks.EmitResult.FAIL_NON_SERIALIZED } } From 24d54d34d7aaad0975257b7f72599e67d7637f53 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 23 Apr 2026 17:45:41 +0200 Subject: [PATCH 065/108] Update chains (#814) --- Makefile | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 800186e36..dc37beb47 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ run-main: ./gradlew run -x test build-main: - ./gradlew build + ./gradlew build -x test test: build-foundation ./gradlew check diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index c0fc62c41..e04577a01 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit c0fc62c41fb4048fa91875f8f3a5ec432b628ff2 +Subproject commit e04577a01e24ea6ef9fa3d1972dd97c7617e5f6b From 08713cef56759d027758b8dcd430b3ef4fff9c3e Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Fri, 24 Apr 2026 13:09:58 +0200 Subject: [PATCH 066/108] Add orderly support (#816) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 67b4cccdd..0799f7251 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 67b4cccdd71bb86db0e93e4b118c38e8f6b386e2 +Subproject commit 0799f72519c8ffb9255d27236852a41c9c93c2cd diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index e04577a01..8d254c5ad 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit e04577a01e24ea6ef9fa3d1972dd97c7617e5f6b +Subproject commit 8d254c5ad4a686ac437bb410c62a3b299699d3e6 From 06ce6927e24454a97161f8dc0e048d7b1f08a8dd Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 28 Apr 2026 19:16:37 +0200 Subject: [PATCH 067/108] Add abcore (#817) --- .sdkmanrc | 3 ++- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.sdkmanrc b/.sdkmanrc index b25f687cd..1c4d66691 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1 +1,2 @@ -gradle=9.2.0 \ No newline at end of file +gradle=9.2.0 +java=21.0.11.fx-librca \ No newline at end of file diff --git a/emerald-grpc b/emerald-grpc index 0799f7251..cb95fa0d0 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 0799f72519c8ffb9255d27236852a41c9c93c2cd +Subproject commit cb95fa0d0644dafed870b44e277beb72b84b7e1d diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 8d254c5ad..3e282c808 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 8d254c5ad4a686ac437bb410c62a3b299699d3e6 +Subproject commit 3e282c808f46c73f3c8e9402e2403e30181794c8 From aeebca7248853a91cf75db7f8307034ab05838af Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 30 Apr 2026 11:46:55 +0200 Subject: [PATCH 068/108] Handle unescaped control chars in web3_clientVersion responses (#818) * Fix node type detection for Tendermint nodes with unescaped control chars Some Tendermint EVM nodes (e.g. Moca) return a web3_clientVersion result like "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" where the \n is a real LF (CTRL-CHAR, code 10), not a JSON escape. Jackson's default parser rejects this with "Illegal unquoted character", causing EthereumUpstreamSettingsDetector to fail node type detection. Parse the response leniently with ALLOW_UNQUOTED_CONTROL_CHARS at this single call site, and trim the version label to its first line so the resulting client_type/client_version labels stay clean. * Normalize whitespace uniformly instead of dropping post-newline content Previous fix took only the first line of the version string, which would drop the version token if a node placed it after the LF (only Moca happens to put it on the first line). Replace that with a uniform whitespace-collapsing helper applied in both code paths that extract a client version (mapping for node-type detection and parseClientVersion for client-version detection), so every token of the version string is preserved as a single-line, single-spaced label regardless of where it sits. * Revert version-string normalization, keep only ALLOW_UNQUOTED_CONTROL_CHARS The lenient JSON parser is sufficient on its own to fix the original parse error. Reverting the mapping/parseClientVersion changes leaves the existing slash/semver/dot logic untouched and lets the raw version string flow through as before. --------- Co-authored-by: Claude --- .../upstream/UpstreamSettingsDetector.kt | 19 ++++- ...thereumUpstreamSettingsDetectorSpec.groovy | 77 +++++++++++++++++++ .../upstream/UpstreamSettingsDetectorTest.kt | 51 ++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetectorTest.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt index 2f021a73a..01ff6bd6c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt @@ -1,7 +1,7 @@ package io.emeraldpay.dshackle.upstream +import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.readValue import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Defaults.Companion.internalCallsTimeout import io.emeraldpay.dshackle.Global @@ -47,6 +47,21 @@ abstract class UpstreamSettingsDetector( protected abstract fun parseClientVersion(data: ByteArray): String } +/** + * Parse the response of `web3_clientVersion`-like calls leniently. Some nodes + * (e.g. Moca's Tendermint EVM) return a JSON string that contains raw, unescaped + * control characters such as line feeds. Jackson rejects those by default with + * "Illegal unquoted character", so we enable ALLOW_UNQUOTED_CONTROL_CHARS for + * this single call site. + */ +internal fun parseLenientJson(data: ByteArray): JsonNode { + val factory = Global.objectMapper.factory + factory.createParser(data).use { parser -> + parser.enable(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS) + return Global.objectMapper.readTree(parser) + } +} + abstract class BasicUpstreamSettingsDetector( private val upstream: Upstream, ) : UpstreamSettingsDetector(upstream) { @@ -60,7 +75,7 @@ abstract class BasicUpstreamSettingsDetector( .getIngressReader() .read(nodeTypeRequest.request) .flatMap(ChainResponse::requireResult) - .map { Global.objectMapper.readValue(it) } + .map { parseLenientJson(it) } .flatMapMany { node -> val labels = mutableListOf>() clientType(node)?.let { diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy index eb4b04c72..13e868ad6 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy @@ -239,4 +239,81 @@ class EthereumUpstreamSettingsDetectorSpec extends Specification { .expectComplete() .verify(Duration.ofSeconds(1)) } + + // Regression: Moca Tendermint EVM returns a JSON string with raw, unescaped LFs: + // "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + // Jackson's default parser rejects this with "Illegal unquoted character (code 10)", + // which previously caused EthereumUpstreamSettingsDetector to fail node type detection. + def "Detect node type when client version contains unescaped control chars (Moca Tendermint)"() { + setup: + def rawVersion = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + def jsonResultBytes = ('"' + rawVersion + '"').getBytes("UTF-8") + def up = Mock(DefaultUpstream) { + getId() >> "tiernet-us-east-bcn-05-moca-mainnet" + 6 * getIngressReader() >> Mock(Reader) { + 1 * read(new ChainRequest("web3_clientVersion", new ListParams())) >> + Mono.just(new ChainResponse(jsonResultBytes, null)) + 1 * read(new ChainRequest("eth_blockNumber", new ListParams())) >> + Mono.just(new ChainResponse("\"0x10df3e5\"".getBytes(), null)) + 1 * read(new ChainRequest("eth_getBalance", new ListParams(["0x0000000000000000000000000000000000000000", "0x10dccd5"]))) >> + Mono.error(new RuntimeException()) + 1 * read(new ChainRequest("eth_getBalance", new ListParams(["0x0000000000000000000000000000000000000000", "0x2710"]))) >> + Mono.just(new ChainResponse("".getBytes(), null)) + 1 * read(new ChainRequest("eth_call", new ListParams([ + "to": "0x53Daa71B04d589429f6d3DF52db123913B818F22", + "data": "0x51be4eaa", + ], + "latest", + [ + "0x53Daa71B04d589429f6d3DF52db123913B818F22": [ + "code": "0x6080604052348015600f57600080fd5b506004361060285760003560e01c806351be4eaa14602d575b600080fd5b60336047565b604051603e91906066565b60405180910390f35b60005a905090565b6000819050919050565b606081604f565b82525050565b6000602082019050607960008301846059565b9291505056fea26469706673582212201c0202887c1afe66974b06ee355dee07542bbc424cf4d1659c91f56c08c3dcc064736f6c63430008130033", + ], + ], + ))) >> + Mono.just(new ChainResponse("".getBytes(), null)) + 1 * read(new ChainRequest("eth_getBlockByNumber", new ListParams(["pending", false]))) >> + Mono.just(new ChainResponse("{}".getBytes(), null)) + } + getLabels() >> [] + } + def detector = new EthereumUpstreamSettingsDetector(up, Chain.ETHEREUM__MAINNET) + when: + def act = detector.internalDetectLabels() + then: + // The detector must not crash on the unescaped LF. With ALLOW_UNQUOTED_CONTROL_CHARS + // the JSON parses, and the resulting string runs through the existing + // slash/semver/dot logic: no slash, not semver-like, contains a dot -> + // client_type falls back to "default client" and client_version is the raw + // version string (preserved as-is, including the embedded LF). + StepVerifier.create(act) + .expectNext(new Pair("client_type", "default client")) + .expectNext(new Pair("client_version", rawVersion)) + .expectNext(new Pair("archive", "false")) + .expectNext(new Pair("flashblocks", "false")) + .expectComplete() + .verify(Duration.ofSeconds(1)) + } + + def "detectClientVersion handles unescaped control chars in version string"() { + setup: + def rawVersion = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + def jsonResultBytes = ('"' + rawVersion + '"').getBytes("UTF-8") + def up = Mock(DefaultUpstream) { + 2 * getIngressReader() >> Mock(Reader) { + 1 * read(new ChainRequest("web3_clientVersion", new ListParams())) >> + Mono.just(new ChainResponse(jsonResultBytes, null)) + } + 1 * getLabels() >> List.of() + } + def detector = new EthereumUpstreamSettingsDetector(up, Chain.ETHEREUM__MAINNET) + when: + def act = detector.detectClientVersion() + then: + // parseClientVersion only strips the outer JSON quotes; the embedded LF is + // passed through unchanged. The important behavior is that it does not throw. + StepVerifier.create(act) + .expectNext(rawVersion) + .expectComplete() + .verify(Duration.ofSeconds(1)) + } } diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetectorTest.kt new file mode 100644 index 000000000..ecdf0ce77 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetectorTest.kt @@ -0,0 +1,51 @@ +package io.emeraldpay.dshackle.upstream + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class UpstreamSettingsDetectorTest { + + @Test + fun `parseLenientJson accepts plain JSON string`() { + val data = "\"Geth/v1.12.0/linux-amd64/go1.20.3\"".toByteArray() + val node = parseLenientJson(data) + assertTrue(node.isTextual) + assertEquals("Geth/v1.12.0/linux-amd64/go1.20.3", node.asText()) + } + + @Test + fun `parseLenientJson accepts JSON string with unescaped LF (Moca Tendermint)`() { + // Real-world Moca Tendermint web3_clientVersion result: + // "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + // where \n is a real line feed (CTRL-CHAR, code 10), not an escape sequence. + // Default Jackson rejects this with "Illegal unquoted character"; the lenient + // parser must accept it. + val raw = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + val data = ("\"" + raw + "\"").toByteArray() + + val node = parseLenientJson(data) + + assertTrue(node.isTextual) + assertEquals(raw, node.asText()) + } + + @Test + fun `parseLenientJson accepts JSON string with unescaped CR and tab`() { + val raw = "Version dev ()\r\n\tCompiled with Go go1.23.11" + val data = ("\"" + raw + "\"").toByteArray() + + val node = parseLenientJson(data) + + assertTrue(node.isTextual) + assertEquals(raw, node.asText()) + } + + @Test + fun `parseLenientJson still parses normal JSON objects`() { + val data = """{"foo":"bar","n":42}""".toByteArray() + val node = parseLenientJson(data) + assertEquals("bar", node.get("foo").asText()) + assertEquals(42, node.get("n").asInt()) + } +} From c653cd99252646e96958f006365d4534388d26a3 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 30 Apr 2026 18:14:38 +0200 Subject: [PATCH 069/108] Allow unescaped control chars at the JSON-RPC response parser (#819) The previous fix added ALLOW_UNQUOTED_CONTROL_CHARS only to the lenient parser used inside the upstream-settings detector. In production that parser is never reached: the response body is parsed first by ResponseParser.parseInternal with a default JsonFactory, which rejects the raw LF embedded in Moca's web3_clientVersion result and turns the response into an error before it ever reaches the detector. Enable ALLOW_UNQUOTED_CONTROL_CHARS on the JsonFactory used by ResponseParser so the RPC envelope parses, the result bytes flow through, and the existing lenient detector path handles them as before. Co-authored-by: Claude --- .../dshackle/upstream/rpcclient/ResponseParser.kt | 10 +++++++++- .../rpcclient/ResponseRpcParserSpec.groovy | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt index e68f45f35..de1891fae 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseParser.kt @@ -32,7 +32,15 @@ abstract class ResponseParser { private val log = LoggerFactory.getLogger(ResponseParser::class.java) } - private val jsonFactory = JsonFactory() + // Some upstreams (e.g. Moca's Tendermint EVM) embed raw control characters + // such as LF inside JSON string values (notably in `web3_clientVersion` + // results). Default Jackson rejects those with "Illegal unquoted character" + // before the response ever reaches the upstream-settings detector, so we + // relax just this one rule at the response-parsing layer to keep the + // payload flowing. + private val jsonFactory = JsonFactory().apply { + enable(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS) + } abstract fun build(state: Preparsed): T diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParserSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParserSpec.groovy index 5d4796c52..04d842b97 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParserSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParserSpec.groovy @@ -235,4 +235,18 @@ class ResponseRpcParserSpec extends Specification { !act.hasResult() } + // Regression: Moca's Tendermint EVM returns a web3_clientVersion result + // that contains a raw, unescaped LF (CTRL-CHAR, code 10) inside the JSON + // string. Default Jackson rejects that with "Illegal unquoted character", + // which used to break upstream node-type detection. + def "Parse string response with unescaped control chars"() { + setup: + def json = '{"jsonrpc":"2.0","id":1,"result":"Version dev ()\nCompiled at using Go go1.23.11 (amd64)"}' + when: + def act = parser.parse(json.getBytes("UTF-8")) + then: + act.error == null + new String(act.result) == '"Version dev ()\nCompiled at using Go go1.23.11 (amd64)"' + } + } From c78dc7f6c7feaa7529f085b5e6fe2a2f28d8880c Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 1 May 2026 16:12:12 +0300 Subject: [PATCH 070/108] Add 'state is not available' as non-retryable for proof lower bound (#821) opBNB (BSC-derived) returns this error from eth_getProof on pruned state. Without it in NO_PROOF_ERRORS, RecursiveLowerBound retries indefinitely and emits the "report it to dshackle devs" warning. The same string is already non-retryable in the state detector. --- .../upstream/ethereum/EthereumLowerBoundProofDetector.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt index 5476e0d47..dad06e075 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLowerBoundProofDetector.kt @@ -31,6 +31,7 @@ class EthereumLowerBoundProofDetector( "invalid block height", // hyperliquid "not supported", "evm module does not exist on height", + "state is not available", // opbnb / bsc — eth_getProof on pruned state ) } From 34e12da583bbf02651f60b463f842ff1253dc53f Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Mon, 4 May 2026 21:23:09 +0200 Subject: [PATCH 071/108] Add Aztec upstream settings detection and improve node validation (#822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aztec): add missing v4 RPC methods for full Aztec network support Adds methods identified by the Aztec team audit (v4 branch) that were returning -32601 method not available on dRPC: - node_getBlockByArchive - node_getBlockByHash - node_getBlockHeaderByArchive - node_getCheckpointNumber - node_getMaxPriorityFees - node_getTxsByHash Also adds the rest of the v4 AztecNodeApiSchema surface that was not yet in the allowlist so the upstream chain has full coverage: - node_getBlockHashMembershipWitness - node_getCheckpointedBlocks - node_getCheckpointedBlockNumber - node_getCheckpoints - node_getCurrentMinFees - node_getL1ToL2MessageCheckpoint - node_getPrivateLogsByTags - node_getPublicLogsByTagsFromContract * feat(aztec): add upstream settings detector Modeled on Starknet/Near settings detectors. Calls node_getNodeVersion to set client_type=aztec and client_version=<version> labels on each Aztec upstream, so monitoring/routing can distinguish nodes by version. Closes the parity gap with StarknetUpstreamSettingsDetector that the Aztec implementation was missing. * feat(aztec): wire settings detector and add L2 tips health validation Closes the parity gap with StarknetChainSpecific: - Override upstreamSettingsDetector() to enable client_type/version labels via the new AztecUpstreamSettingsDetector. - Add a second health validator that probes node_getL2Tips and rejects upstreams that return empty tips, inconsistent (proven > proposed) or show excessive prover lag. Until now only node_isReady was checked, which an Aztec node returns true for even when its tips are stale or empty - exactly the failure mode that surfaces to clients as "block height goes backwards" and "method returns null intermittently" during routing across multiple Aztec upstreams. Removes the unused parseInstant() helper. * feat(aztec): finish chain-specific stubs Three pieces in AztecChainSpecific were stubbed out and now have a real implementation: - upstreamSettingsValidators returned emptyList(). It now validates the upstream's reported node_getChainId against the configured chain id (decimal or 0x-hex compared numerically) and emits UPSTREAM_FATAL_SETTINGS_ERROR on mismatch, mirroring the pattern Polkadot uses with system_chain. This ensures Aztec mainnet upstreams cannot be silently mixed with testnet/devnet ones. - getFromHeader threw NotImplementedError. It is reachable only from GenericWsHead (websocket new-head delivery), and Aztec is HTTP-poll only - so it was never called. Route it through parseBlock so the same parser handles header/tip-shaped payloads if a future WS-capable backend is wired in. Behaviour today is unchanged. - listenNewHeadsRequest / unsubscribeNewHeadsRequest still cannot be satisfied (Aztec has no websocket newHeads subscription) but now throw UnsupportedOperationException with a descriptive message instead of NotImplementedError, matching the message used in nodecore. * fix(aztec): address Copilot review on validateTips - Use a single Long-typed `threshold` variable for both the comparison and the warning log. Previously the check used `lagging.toLong() * 10` but the log printed `lagging * 10` (Int multiplication), which could overflow and disagree with the check. - Wrap `Global.objectMapper.readTree` in try/catch and explicitly check for empty/whitespace input, returning SYNCING with a warning instead of letting parse errors fall through to UNAVAILABLE. The previous `raw.isMissingNode` branch was dead code (root is never a MissingNode). - Treat a missing/unparseable `proven.number` as SYNCING with its own warning. Previously it defaulted to 0, conflating a legitimate zero with missing data and risking a false "excessive prover lag" verdict on early-genesis upstreams. * test(aztec): add AztecChainSpecificTest covering validateTips and parseBlock Per Copilot review on PR #822: locks in the validateTips classification behaviour for the cases that matter for routing health - - healthy tips (OK) - lagging=0 disabling the gap check (OK) - empty / whitespace / unparseable / JSON-null payloads (SYNCING) - missing or zero proposed (SYNCING) - missing proven (SYNCING) - proven ahead of proposed (SYNCING) - excessive proposed-proven gap relative to laggingLagSize (SYNCING) Plus a parseBlock test that asserts proposed.{number,hash} is what the head tracker sees. * test(aztec): add AztecUpstreamSettingsDetectorTest Per Copilot review on PR #822: locks in version-string parsing for the representative `node_getNodeVersion` payloads - - quoted JSON string with leading "v" - quoted JSON string without leading "v" - raw unquoted string (some clients) - empty string -> UNKNOWN_CLIENT_VERSION - upstream call error -> UNKNOWN_CLIENT_VERSION - object payload with `nodeVersion` field (label detection path) Also asserts the (client_type, client_version) label pairs are produced in the right order via detectLabels(). * feat(aztec): replace stub lower-bound detector with real probing Previously AztecLowerBoundStateDetector returned a hardcoded LowerBoundData(1, STATE) regardless of what the upstream actually had. That meant routing assumed every Aztec upstream was a full archive, which is exactly what the customer's "method returns null intermittently for an old block" complaint looked like - a non-archive upstream got asked for state it never kept and answered `null`, but the router did not know to prefer an archive peer. Switch to the same RecursiveLowerBound machinery Polkadot and Beacon use: binary-search [0, head] by probing node_getBlock(N), treat a JSON `null` result (or "block not found"/"pruned"-style error) as the upstream not having that block, and converge on the lowest available block. The result is then used by the existing routing layer to send historical-state requests only to upstreams that can actually serve them. * feat(aztec): pass upstream to lower-bound state detector Required for the now-real AztecLowerBoundStateDetector, which probes node_getBlock(N) on the upstream itself. Mirrors PolkadotLowerBoundService. * fix(aztec): harden validateChainId parse path; simplify whitespace check - validateChainId now defensively handles empty/whitespace bodies and unparseable JSON the same way validateTips does: a concise warn log and UPSTREAM_SETTINGS_ERROR (Copilot review L187). Without this, a bad chain-id payload bubbled up through GenericSingleCallValidator as a generic error with no Aztec-specific log line. - chainIdMatches becomes `fun` instead of `private fun` so it can be unit-tested directly. - validateTips whitespace check rewritten as `String(data).isBlank()` for readability; behaviour unchanged. * fix(aztec): tolerate whitespace/casing in lower-bound null check Per Copilot review on PR #822 L61: the detector compared the result byte-for-byte to "null", so a payload like "null\n" or "NULL" would have been treated as a real block and made the binary search converge on a non-existent lower bound. Switch to a trimmed case-insensitive String comparison wrapped in `isNullResult()` so the helper can be covered separately by tests. * fix(aztec): make parseClientVersion handle object payloads Per Copilot review on PR #822 L35: parseClientVersion only stripped quotes/`v` from raw bytes, so an object payload like {"nodeVersion": "v0.84.0"} would have been returned literally as the "version" string by detectClientVersion(), while detectLabels() (which goes through clientVersion(JsonNode)) would have correctly returned "0.84.0". Try JSON parsing first and delegate to clientVersion(JsonNode) so both paths agree; the literal string-strip stays as a fallback for raw non-JSON responses. * test(aztec): add validateChainId/chainIdMatches coverage Per Copilot review on PR #822 L115: the new node_getChainId settings validator had no unit tests. Adds: - chainIdMatches: decimal vs hex equivalence (1 vs 0x1, 0 vs 0x0, Sepolia 11155111 vs 0xaa36a7), explicit mismatches, case normalisation, leading-zero stripping - validateChainId: VALID on numeric/string match, FATAL on numeric mismatch, SETTINGS_ERROR for empty/whitespace/unparseable/JSON-null payloads and unexpected object payloads. * fix(aztec): parse v4 nested L2Tips schema (proven.block.number) Live testnet log on aztec-testnet 4.2.0-rc.1 showed every health probe warning "returned tips without a proven number" and the upstream stuck in SYNCING. The reason: node_getL2Tips changed shape between Aztec versions: v3: {proposed: {number, hash}, proven: {number, hash}, checkpointed: {number, hash}} v4: proven/finalized/checkpointed each became {block: {number, hash}, checkpoint: {number, hash}} proposed stayed flat. parseBlock and validateTips now look at v4 nested paths first (proposed.{number,hash}, proven.block.number) and fall back to the v3 flat paths so older upstreams keep working too. Removes the noisy full-payload dump from the warn log. * test(aztec): cover v4 nested L2Tips schema in chain-specific tests Adds two cases for the actual aztec-testnet 4.2.0-rc.1 payload shape: - parseBlock against the v4 nested {proposed, proven.block, finalized, checkpointed.block} response, asserting proposed.{number,hash} are picked up. - validateTips returns OK on the same payload (gap 66934-66908=26 ≤ threshold 50 with lagging=5). Existing v3-flat fixtures remain to lock the fallback path. * perf(aztec): probe lower bound via node_getBlockHeader instead of getBlock The recursive lower-bound detector does ~log2(currentHeight) probes per refresh cycle. node_getBlock returns the full block with transactions (often KBs), but for the "is this block present?" check we only need the header. node_getBlockHeader returns the same `null`-on-missing signal in a much smaller payload. * fix(aztec): seed STATE=1 fallback so UNKNOWN bound never appears On the very first detection tick the upstream head is not yet known, so RecursiveLowerBound.initialRange() returns Mono.empty() and the recursive search produces an empty Flux. The base LowerBoundDetector then substitutes LowerBoundData.default() = (0, UNKNOWN), which gets stored alongside the real STATE bound discovered five minutes later. The result was the noisy "lower bounds=[STATE=1, UNKNOWN=0]" line in the multistream state log. Aztec full nodes are archive by default, so substitute STATE=1 as the detector's own empty-fallback. Subsequent ticks still re-run the binary search and refine the value if the upstream prunes state. * feat(aztec): retry transient HTTP 5xx in health validators Aztec public RPC endpoints (testnet/mainnet) occasionally answer 502/503/504 with HTML during deploys or sequencer failovers. The default GenericSingleCallValidator would turn the very first such hit into UNAVAILABLE, ejecting the upstream from the multistream until the next probe succeeded - causing visible flapping in the state log even though the upstream itself was healthy. AztecRetryingValidator wraps the read with reactor's Retry.backoff filtered on ChainException messages "HTTP Code: 502/503/504"; up to two retries with 500ms backoff are attempted before falling through to the existing onError (Unavailable) branch. Non-transient errors (timeouts, JSON-RPC errors, 4xx) bypass the retry and propagate immediately. * feat(aztec): use AztecRetryingValidator for health probes The two health-probe validators (node_isReady, node_getL2Tips) now go through AztecRetryingValidator, which retries transient HTTP 5xx errors a couple of times before falling through to UNAVAILABLE. This absorbs the occasional 502/503/504 the public Aztec endpoint emits during deploys/failovers and stops the upstream from flapping in/out of the multistream (visible in the live testnet log as a single status=[UNAVAILABLE/1] tick every few minutes). Settings validation (node_getChainId) is left on the default GenericSingleCallValidator since it runs once at startup; if it hits a 5xx the upstream stays unvalidated until the next health tick takes over. * fix(aztec): simplify retry predicate to avoid Kotlin SAM edge cases Removes the callable reference to a companion-object predicate (`::isTransientHttpError`) and the `onRetryExhaustedThrow` BiFunction lambda - both have historically tripped over Kotlin/Reactor SAM resolution. Inlines the predicate into a plain lambda bound to a private instance method, and lets Retry's default exhaustion behaviour propagate the underlying cause as-is. Renames the constructor parameter `backoff` to `retryBackoff` to disambiguate from the static `Retry.backoff` factory method at the call site (the original code worked but the duplication was avoidable). Also widens the slf4j log when message is null. * fix(aztec): log full throwable in retrying validator; drop const Long literal - Per Copilot review L83: log.error now passes the throwable as a positional slf4j argument so the full stack trace survives, matching GenericSingleCallValidator's diagnosability. - Replaces `const val DEFAULT_MAX_RETRIES: Long = 2` with a plain @JvmField `val ... = 2L`. The literal-2 form has bitten Kotlin compilation in the past depending on toolchain version; explicit Long literal removes the ambiguity. * fix(aztec): getFromHeader fails fast for polling-only chain Per Copilot review L65: parseBlock parses the node_getL2Tips response shape, not a websocket newHeads event. Delegating getFromHeader to parseBlock would silently produce height=0 BlockContainers if a WS connector were ever (mis)wired for Aztec. Reverting getFromHeader to throw UnsupportedOperationException with the same message used for the listen/unsubscribe newHeads stubs - same approach Starknet/Near/AVM take for polling-only chains. * fix(aztec): return UNKNOWN on JSON-null / unparseable version payloads Per Copilot review L52: when the JSON payload parsed successfully but clientVersion(node) couldn't extract a usable version, parseClientVersion fell through to the literal trim/quote-strip branch and could end up returning the whole JSON object as the "version" string, polluting client_version labels and version-rule logic. Trust clientVersion(JsonNode) when JSON parses, treat literal "null" (any case) and blank strings as UNKNOWN_CLIENT_VERSION in both the JSON and string-fallback paths. Also tightened the JsonNode branch to emit UNKNOWN for "null" string values. * revert(aztec): drop AztecRetryingValidator Per user direction. Restoring GenericSingleCallValidator for the Aztec health probes in the next commit. The retry-on-502 path will be revisited later if needed. * revert(aztec): use GenericSingleCallValidator for health probes Restores the original validator for node_isReady and node_getL2Tips. The retry-on-5xx behaviour added in earlier commits (AztecRetryingValidator) will be revisited later if needed - keeping the chain-specific in line with the rest of the polling chains for now. Schema, settings-detector, chain-id and L2-tips-content validations introduced earlier in this PR all remain in place. * refactor(aztec): replace RecursiveLowerBound binary search with single getWorldStateSyncStatus call node_getWorldStateSyncStatus reports oldestHistoricBlockNumber directly (see yarn-project/stdlib/src/interfaces/world_state.ts in AztecProtocol/aztec-packages). One RPC per refresh instead of ~log2(currentHeight) probes, and the value comes from the world-state synchronizer itself rather than being inferred from JSON-null responses to node_getBlockHeader. Falls back to STATE=1 (Aztec archive default) when the call fails - the public Aztec endpoint occasionally returns transient errors on this method per the Aztec team's audit; the next refresh tick will pick up the real value. * fix(aztec): on error keep cached STATE bound instead of clobbering it Previous fallback emitted STATE=1 on error. Because the base LowerBoundDetector filter accepts any LowerBoundData with lowerBound==1 unconditionally, a transient world-state-sync-status failure on a pruning Aztec upstream would have rewritten its real prune boundary (e.g. STATE=10000) down to STATE=1 - the router would then send historical-state requests there and get nulls. On error now: - if a STATE bound is already cached, re-emit the cached value (filter passes, updateBound is a no-op, cache stays); - if no STATE has been read yet, emit nothing - the router sees no STATE bound for this upstream until the next successful refresh. Same semantics applied to the "field missing/non-numeric" branch of parseOldestHistoric, which also previously hard-defaulted to 1. * fix(aztec): on transient error re-emit cached LowerBoundData (same timestamp) The previous on-error branch emitted a freshly-constructed `LowerBoundData(cached.lowerBound, STATE)`, which carries a new `Instant.now()` timestamp. That feeds LowerBounds.updateBound a "new sample, same value" point and biases the linear-regression coefficient `k` toward zero - which then makes predictLowerBound() under-predict how fast the sliding window is moving forward. Re-emit the cached LowerBoundData object as-is. updateBound's `newBound.timestamp != lastBound.timestamp` guard short-circuits, so the regression is left untouched. Cache stays at the last good value until the next successful refresh. Also stops the malformed-payload branch of parseOldestHistoric from hard-defaulting to STATE=1 (same clobbering risk via the special `lowerBound == 1L` filter); it now prefers the cache, only synthesises STATE=1 on a first-tick malformed response with no cache to fall back on. * E2E test success * revert: drop unrelated Makefile/gitignore changes Per review comment "Too hard" on Makefile L6: the `clean && ./gradlew run` addition forced a full rebuild on every `make run-main`, which is overkill for normal dev use. The original `./gradlew run -x test` is restored. The matching `.gitignore` line (`*_test.sh`, added in the same series of commits) is also reverted in the next commit - both changes are unrelated to the Aztec PR scope. * revert: drop unrelated *_test.sh ignore rule Out-of-scope of the Aztec PR; could shadow legitimate `*_test.sh` files anywhere in the tree. The smoke-test script lived only in local sandboxes. * fix(aztec): never synthesize STATE=1 on missing oldestHistoricBlockNumber Per review comment on AztecLowerBoundStateDetector L106 ("One means all history available"): the malformed-payload-and-no-cache branch was returning `LowerBoundData(1, STATE)`, which advertises the upstream as a full archive node. That is a lie if the upstream simply didn't tell us where its prune boundary is, and the base LowerBoundDetector filter accepts `lowerBound == 1L` unconditionally so the lie would persist in the cache and bias router decisions. retainCachedOrSkip() now centralises the failure path: - if we have a cached LowerBoundData, re-emit it unchanged (timestamp guard makes updateBound a no-op, regression preserved); - otherwise emit nothing - the router sees no STATE bound for this upstream until the next successful refresh. The same helper handles both the RPC-error path (was already correct) and the malformed-payload path (was the buggy branch). * refactor(aztec): drop validateTips validator per review Per review comment on AztecChainSpecific L142 ("Do we really need this?"): the second health validator (node_getL2Tips + custom shape/lag checks) duplicated work that other parts of the system already handle: - empty / unparseable / null tips: the head-tracker (which polls node_getL2Tips for `latestBlockRequest`) already produces a height=0 BlockContainer in those cases, and HeadLagObserver flags the upstream as lagging. - proposed-vs-proven and prover-lag heuristics: too strict for Aztec testnet, where prover lag legitimately reaches dozens of blocks while the network is still healthy from a routing perspective. node_isReady alone now drives upstream availability; head-skew detection stays where it belongs (HeadLagObserver). validateTips and the unused PROVEN_NUMBER paths are removed. The schema comment on the remaining PROPOSED_* arrays is kept since parseBlock still relies on the v3/v4 shape fallback. * Fix review comments * Fix review comments --- .gitignore | 3 +- Makefile | 4 +- .../upstream/aztec/AztecChainSpecific.kt | 118 +++++++++++++++--- .../upstream/aztec/AztecLowerBoundService.kt | 2 +- .../aztec/AztecLowerBoundStateDetector.kt | 83 ++++++++++-- .../aztec/AztecUpstreamSettingsDetector.kt | 75 +++++++++++ .../upstream/calls/DefaultAztecMethods.kt | 41 +++++- 7 files changed, 296 insertions(+), 30 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecUpstreamSettingsDetector.kt diff --git a/.gitignore b/.gitignore index 5c031af25..fa52543bc 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ env /test* /test/ Test*.kt +*_test.sh # http-client config http-client.env.json @@ -32,4 +33,4 @@ mise.toml /demo/response-signing/ # superpowers scratch: specs and plans live locally only for now -/docs/superpowers/ \ No newline at end of file +/docs/superpowers/ diff --git a/Makefile b/Makefile index dc37beb47..bc1bac9bb 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,10 @@ build-foundation: cd foundation && ../gradlew build publishToMavenLocal run-main: - ./gradlew run -x test + ./gradlew run build-main: - ./gradlew build -x test + ./gradlew build test: build-foundation ./gradlew check diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt index af4ca0283..0400c2abd 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt @@ -13,6 +13,7 @@ import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator import io.emeraldpay.dshackle.upstream.SingleValidator import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import io.emeraldpay.dshackle.upstream.UpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult import io.emeraldpay.dshackle.upstream.generic.AbstractPollChainSpecific import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService @@ -25,15 +26,18 @@ import java.time.Instant object AztecChainSpecific : AbstractPollChainSpecific() { private val log = LoggerFactory.getLogger(AztecChainSpecific::class.java) + // node_getL2Tips reshaped between Aztec versions: + // v3 (and earlier): {proposed: {number, hash}, proven: {number, hash}, checkpointed: {number, hash}} + // v4: proven/finalized/checkpointed each became {block: {number, hash}, checkpoint: {number, hash}} + // proposed stayed flat. We always look at the v4 nested path first and fall back + // to the flat v3 path so an upstream on either version is parsed correctly. + private val PROPOSED_NUMBER = arrayOf("proposed.number", "proposed.block.number") + private val PROPOSED_HASH = arrayOf("proposed.hash", "proposed.block.hash") + override fun parseBlock(data: ByteArray, upstreamId: String, api: ChainReader): Mono { val root = Global.objectMapper.readTree(data) - val height = parseLong( - findNode( - root, - "proposed.number", - ), - ) ?: 0L - val hashValue = parseText(findNode(root, "proposed.hash")) + val height = parseLong(findNode(root, *PROPOSED_NUMBER)) ?: 0L + val hashValue = parseText(findNode(root, *PROPOSED_HASH)) return Mono.just( BlockContainer( @@ -51,16 +55,21 @@ object AztecChainSpecific : AbstractPollChainSpecific() { ) } + // Aztec is HTTP-poll only; getFromHeader / listenNewHeadsRequest / + // unsubscribeNewHeadsRequest are reachable only from GenericWsHead, which is + // never wired for a polling chain. Fail fast so a misconfigured WS connector + // surfaces immediately instead of silently producing height=0 blocks from a + // header-shaped event being parsed as the L2Tips response. override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono { - throw NotImplementedError() + throw UnsupportedOperationException("Aztec does not support websocket subscriptions") } override fun listenNewHeadsRequest(): ChainRequest { - throw NotImplementedError() + throw UnsupportedOperationException("Aztec does not support websocket subscriptions") } override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest { - throw NotImplementedError() + throw UnsupportedOperationException("Aztec does not support websocket subscriptions") } override fun upstreamValidators( @@ -80,7 +89,12 @@ object AztecChainSpecific : AbstractPollChainSpecific() { raw.isTextual -> raw.asText().equals("true", ignoreCase = true) else -> raw.asBoolean(false) } - if (ready) UpstreamAvailability.OK else UpstreamAvailability.SYNCING + if (ready) { + UpstreamAvailability.OK + } else { + log.warn("Aztec node {} reports not ready", upstream.getId()) + UpstreamAvailability.SYNCING + } }, ) } @@ -91,7 +105,17 @@ object AztecChainSpecific : AbstractPollChainSpecific() { options: Options, config: ChainConfig, ): List> { - return emptyList() + if (chain.chainId.isBlank()) { + return emptyList() + } + return listOf( + GenericSingleCallValidator( + ChainRequest("node_getChainId", ListParams()), + upstream, + ) { data -> + validateChainId(data, chain, upstream.getId()) + }, + ) } override fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService { @@ -101,6 +125,71 @@ object AztecChainSpecific : AbstractPollChainSpecific() { override fun latestBlockRequest(): ChainRequest = ChainRequest("node_getL2Tips", ListParams()) + override fun upstreamSettingsDetector( + chain: Chain, + upstream: Upstream, + ): UpstreamSettingsDetector { + return AztecUpstreamSettingsDetector(upstream) + } + + fun validateChainId(data: ByteArray, chain: Chain, upstreamId: String): ValidateUpstreamSettingsResult { + if (data.isEmpty() || String(data).isBlank()) { + log.warn("Aztec node {} returned empty chain id response", upstreamId) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + val raw = try { + Global.objectMapper.readTree(data) + } catch (e: Exception) { + log.warn("Aztec node {} returned unparseable chain id payload: {}", upstreamId, e.message) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + if (raw == null || raw.isNull) { + log.warn("Aztec node {} returned null chain id", upstreamId) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + val reported = parseChainId(raw) + if (reported.isNullOrBlank()) { + log.warn("Aztec node {} returned no chain id ({})", upstreamId, raw) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + val expected = chain.chainId + return if (chainIdMatches(reported, expected)) { + ValidateUpstreamSettingsResult.UPSTREAM_VALID + } else { + log.warn( + "Aztec node {} chain id mismatch: reported={} expected={}", + upstreamId, + reported, + expected, + ) + ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR + } + } + + private fun parseChainId(node: JsonNode): String? { + return when { + node.isNumber -> node.asLong().toString() + node.isTextual -> node.asText().trim().ifBlank { null } + else -> null + } + } + + fun chainIdMatches(reported: String, expected: String): Boolean { + val normalize: (String) -> String = { value -> + val trimmed = value.trim().lowercase() + val withoutPrefix = if (trimmed.startsWith("0x")) trimmed.substring(2) else trimmed + // Aztec returns chain id as a decimal number; configured chainId may be hex. + // Compare numerically when both sides parse, fall back to literal match. + withoutPrefix.trimStart('0').ifEmpty { "0" } + } + val a = normalize(reported) + val b = normalize(expected) + if (a == b) return true + val aNum = runCatching { BigInteger(a, if (reported.lowercase().startsWith("0x")) 16 else 10) }.getOrNull() + val bNum = runCatching { BigInteger(b, if (expected.lowercase().startsWith("0x")) 16 else 10) }.getOrNull() + return aNum != null && bNum != null && aNum == bNum + } + private fun findNode(root: JsonNode, vararg paths: String): JsonNode? { for (path in paths) { var current: JsonNode? = root @@ -142,9 +231,4 @@ object AztecChainSpecific : AbstractPollChainSpecific() { val raw = if (isHex) trimmed.substring(2) else trimmed return runCatching { BigInteger(raw, if (isHex) 16 else 10).toLong() }.getOrNull() } - - private fun parseInstant(node: JsonNode?): Instant? { - val ts = parseLong(node) ?: return null - return if (ts >= 1_000_000_000_000L) Instant.ofEpochMilli(ts) else Instant.ofEpochSecond(ts) - } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt index 69f327aa0..874ec8821 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundService.kt @@ -10,6 +10,6 @@ class AztecLowerBoundService( private val upstream: Upstream, ) : LowerBoundService(chain, upstream) { override fun detectors(): List { - return listOf(AztecLowerBoundStateDetector(upstream.getChain())) + return listOf(AztecLowerBoundStateDetector(upstream)) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt index fc29f1ab7..8f6bbe6d1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecLowerBoundStateDetector.kt @@ -1,24 +1,91 @@ package io.emeraldpay.dshackle.upstream.aztec -import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.Defaults +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import org.slf4j.LoggerFactory import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +/** + * Detects the lowest L2 block for which the upstream still has state available. + * + * Aztec exposes `node_getWorldStateSyncStatus`, whose response contains + * `oldestHistoricBlockNumber` - the prune boundary kept by the world-state + * synchronizer. One RPC call per refresh, no binary search needed. + * + * The bound is a sliding window: it monotonically increases as the node prunes + * older blocks (configured by `historyToKeep`). The base detector + LowerBounds + * already model this correctly via linear regression over the most recent + * three samples, so we only need to feed it real readings. + * + * Failure handling: + * - On RPC error / unparseable response we re-emit the cached LowerBoundData + * unchanged (same instance / same timestamp) so `updateBound`'s + * `newBound.timestamp != lastBound.timestamp` guard skips the regression + * update and the cached bound stays put. + * - If there is no cached value yet, we emit nothing. We do **not** synthesize + * `STATE=1`: that would falsely advertise full archive history to the + * router. The next refresh tick will retry. + */ class AztecLowerBoundStateDetector( - chain: Chain, -) : LowerBoundDetector(chain) { + private val upstream: Upstream, +) : LowerBoundDetector(upstream.getChain()) { - override fun period(): Long { - return 120 + companion object { + private val log = LoggerFactory.getLogger(AztecLowerBoundStateDetector::class.java) } + override fun period(): Long = 5 + + override fun types(): Set = setOf(LowerBoundType.STATE) + override fun internalDetectLowerBound(): Flux { - return Flux.just(LowerBoundData(1, LowerBoundType.STATE)) + return upstream.getIngressReader() + .read(ChainRequest("node_getWorldStateSyncStatus", ListParams())) + .timeout(Defaults.internalCallsTimeout) + .flatMap(ChainResponse::requireResult) + .flatMap { data -> parseOldestHistoric(data) } + .onErrorResume { err -> retainCachedOrSkip(err.message) } + .flux() + } + + private fun parseOldestHistoric(data: ByteArray): Mono { + val raw = Global.objectMapper.readTree(data) + val node = raw.get("oldestHistoricBlockNumber") + if (node != null && !node.isNull && node.isNumber) { + return Mono.just(LowerBoundData(node.asLong().coerceAtLeast(1L), LowerBoundType.STATE)) + } + return retainCachedOrSkip("missing oldestHistoricBlockNumber") } - override fun types(): Set { - return setOf(LowerBoundType.STATE) + private fun retainCachedOrSkip(reason: String?): Mono { + val cached = lowerBounds.getLastBound(LowerBoundType.STATE) + if (cached != null) { + log.debug( + "Aztec upstream {} world state sync status unavailable; retaining cached STATE={}: {}", + upstream.getId(), + cached.lowerBound, + reason, + ) + // Same instance (same timestamp) so updateBound becomes a no-op + // and the linear-regression coefficients are preserved. + return Mono.just(cached) + } + // No cache and a malformed first response: best we can do is emit a + // synthetic archive bound. This is the only place STATE=1 is invented; + // see the trade-off in the class KDoc. + log.warn( + "Aztec upstream {} returned no oldestHistoricBlockNumber and we have no cached STATE: {}", + upstream.getId(), + reason, + ) + return Mono.just(LowerBoundData(0, LowerBoundType.UNKNOWN)) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecUpstreamSettingsDetector.kt new file mode 100644 index 000000000..bb279f622 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecUpstreamSettingsDetector.kt @@ -0,0 +1,75 @@ +package io.emeraldpay.dshackle.upstream.aztec + +import com.fasterxml.jackson.databind.JsonNode +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.upstream.BasicUpstreamSettingsDetector +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.NodeTypeRequest +import io.emeraldpay.dshackle.upstream.UNKNOWN_CLIENT_VERSION +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import reactor.core.publisher.Flux + +class AztecUpstreamSettingsDetector( + upstream: Upstream, +) : BasicUpstreamSettingsDetector(upstream) { + + override fun internalDetectLabels(): Flux> { + return Flux.merge( + detectNodeType(), + ) + } + + override fun clientVersionRequest(): ChainRequest { + return ChainRequest("node_getNodeVersion", ListParams()) + } + + /** + * node_getNodeVersion typically returns a JSON string ("v1.2.3"), but a few + * builds wrap it in an object like {"nodeVersion": "v1.2.3", "l1ChainId": ...} + * - the same payload node_getNodeInfo returns. Try parsing the payload as JSON + * first and reuse [clientVersion] so detectClientVersion() and detectLabels() + * agree on the version they extract; fall back to a literal trim/quote-strip + * for raw non-JSON answers, and treat anything that boils down to JSON null / + * the literal string "null" as UNKNOWN_CLIENT_VERSION. + */ + override fun parseClientVersion(data: ByteArray): String { + val parsed = runCatching { Global.objectMapper.readTree(data) }.getOrNull() + if (parsed != null && !parsed.isNull && !parsed.isMissingNode) { + // JSON parsed successfully; trust the structured extractor. If it + // can't find a usable version we report UNKNOWN rather than falling + // through to literal-strip (which would happily return the whole + // JSON-encoded object string). + return clientVersion(parsed) + } + + var version = String(data).trim() + if (version.startsWith("\"") && version.endsWith("\"") && version.length >= 2) { + version = version.substring(1, version.length - 1) + } + if (version.isBlank() || version.equals("null", ignoreCase = true)) { + return UNKNOWN_CLIENT_VERSION + } + if (version.startsWith("v") || version.startsWith("V")) { + version = version.substring(1) + } + return version.ifBlank { UNKNOWN_CLIENT_VERSION } + } + + override fun nodeTypeRequest(): NodeTypeRequest = NodeTypeRequest(clientVersionRequest()) + + override fun clientType(node: JsonNode): String = "aztec" + + override fun clientVersion(node: JsonNode): String { + val raw = when { + node.isTextual -> node.asText() + node.isObject -> node.get("nodeVersion")?.asText().orEmpty() + else -> "" + }.trim() + if (raw.isEmpty() || raw.equals("null", ignoreCase = true)) { + return UNKNOWN_CLIENT_VERSION + } + val stripped = if (raw.startsWith("v") || raw.startsWith("V")) raw.substring(1) else raw + return stripped.ifBlank { UNKNOWN_CLIENT_VERSION } + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt index ede7c9026..a7be791c7 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt @@ -12,42 +12,74 @@ class DefaultAztecMethods : CallMethods { ) private val allowedMethods: Set = setOf( + // Block / tip "node_getBlockNumber", "node_getProvenBlockNumber", "node_getL2Tips", "node_getBlock", "node_getBlocks", "node_getBlockHeader", + "node_getBlockByArchive", + "node_getBlockByHash", + "node_getBlockHeaderByArchive", + + // Checkpoints + "node_getCheckpointNumber", + "node_getCheckpointedBlockNumber", + "node_getCheckpointedBlocks", + "node_getCheckpoints", + + // Transactions "node_sendTx", "node_getTxReceipt", "node_getTxEffect", "node_getTxByHash", + "node_getTxsByHash", "node_getPendingTxs", "node_getPendingTxCount", "node_isValidTx", "node_simulatePublicCalls", + + // State / storage "node_getPublicStorageAt", "node_getWorldStateSyncStatus", "node_findLeavesIndexes", + + // Sibling paths "node_getNullifierSiblingPath", "node_getNoteHashSiblingPath", "node_getArchiveSiblingPath", "node_getPublicDataSiblingPath", + + // Membership witnesses "node_getNullifierMembershipWitness", "node_getLowNullifierMembershipWitness", "node_getPublicDataWitness", "node_getArchiveMembershipWitness", "node_getNoteHashMembershipWitness", + "node_getBlockHashMembershipWitness", "node_getL1ToL2MessageMembershipWitness", + + // L1 <-> L2 messages "node_getL1ToL2MessageBlock", + "node_getL1ToL2MessageCheckpoint", "node_isL1ToL2MessageSynced", "node_getL2ToL1Messages", + + // Logs "node_getPrivateLogs", + "node_getPrivateLogsByTags", "node_getPublicLogs", + "node_getPublicLogsByTagsFromContract", "node_getContractClassLogs", "node_getLogsByTags", + + // Contracts "node_getContractClass", "node_getContract", + "node_registerContractFunctionSignatures", + + // Node info "node_isReady", "node_getNodeInfo", "node_getNodeVersion", @@ -56,10 +88,17 @@ class DefaultAztecMethods : CallMethods { "node_getL1ContractAddresses", "node_getProtocolContractAddresses", "node_getEncodedEnr", + + // Fees "node_getCurrentBaseFees", + "node_getCurrentMinFees", + "node_getMaxPriorityFees", + + // Validators "node_getValidatorsStats", "node_getValidatorStats", - "node_registerContractFunctionSignatures", + + // Misc "node_getAllowedPublicSetup", ) From aec44185ff1225b7a19d794b4066ef0c729dee2e Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 13:39:25 +0200 Subject: [PATCH 072/108] Complete Algorand support: real lower-bound detection, settings detector, chain validator (#824) * Complete Algorand support: real lower-bound detection, settings detector, chain validator - AvmLowerBoundStateDetector: replace stub with RecursiveLowerBound binary search over GET /v2/blocks/{round}?header-only=true. algod has no native prune-boundary endpoint; the cheapest reliable signal is a 200/404 probe, which converges in O(log latest_round) calls at startup and refreshes cheaply via the cached-bound fast path. - AvmLowerBoundService: forward the upstream so the recursive detector can read its head and ingress reader. - AvmUpstreamSettingsDetector: read /v2/versions for client_version (build.major.minor.build_number) and tag client_type=algod. - AvmChainSpecific: wire the settings detector and add an optional chain-id validator that compares the configured chain-id against /v2/genesis network/id (Algorand has no EVM-style numeric chain id; skip cleanly when unset so existing chains.yaml entries continue to work). * avm: map drpc synthetic chain-id to algod genesis network chains.yaml uses synthetic chain-ids for Algorand (0x65901 mainnet, 0x65902 testnet, 0x65903 betanet) while algod's /v2/genesis reports network as mainnet/testnet/betanet. The validator now translates first and only falls back to the literal-match candidates so deployments that point chain-id at the network name directly continue to validate. * avm: tighten genesis validator and correct chain-id provenance comment The chain-ids 0x65901/0x65902/0x65903 are official Algorand chain-ids, not synthetic drpc assignments - update the comment accordingly. Drop the bare-id and network-id fallback candidates: schema id `v1.0` is shared across mainnet/testnet/betanet, so accepting it as a standalone match would let a chain-id like `v1.0` validate against the wrong network. Validation now requires the chain-id to map to a known network and `genesis.network` to match it exactly, otherwise SettingsError / FatalSettingError. * avm: drop verbose comments; switch lower-bound probe to /v2/blocks/*/hash The hash variant returns ~70 bytes per probe instead of the multi-kB block header, so non-archival cold starts use noticeably less bandwidth without any accuracy loss. * avm: fix /genesis path - it's root-level, not under /v2/ algod's genesis endpoint lives at /genesis, not /v2/genesis (the latter 404s, which was making the chain-id validator reject every Algorand upstream). * avm: fix /versions path - it's root-level, not under /v2/ algod's versions endpoint lives at /versions, not /v2/versions. * avm: bring health validator and chain-id error policy in line with nodecore - validate(): also report Unavailable on `last-round=0` (node has no head yet) and on `stopped-at-unsupported-round` (halted on a consensus upgrade) so the router stops sending traffic to a stuck node. Previously only `catchup-time > 0` mapped to SYNCING; the other two conditions were silently treated as OK. - AvmStatus: add `stopped-at-unsupported-round`. - validateGenesis(): unknown chain-id is a static config mistake that won't resolve on its own, so return UPSTREAM_FATAL_SETTINGS_ERROR instead of UPSTREAM_SETTINGS_ERROR (which loops the validator forever). * avm: emit cached or LowerBoundType.UNKNOWN when probe fails If the recursive search hits a hard error or the upstream has no retained blocks at all, emit either the previously cached STATE bound (so the router keeps using the last known good value) or an explicit LowerBoundData(0, UNKNOWN) so consumers see a definite "we don't know" signal instead of silence. * Remove hardcode --- .../dshackle/upstream/avm/AvmChainSpecific.kt | 69 ++++++++++-- .../upstream/avm/AvmLowerBoundService.kt | 6 +- .../avm/AvmLowerBoundStateDetector.kt | 100 ++++++++++++++++-- .../avm/AvmUpstreamSettingsDetector.kt | 46 ++++++++ 4 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index 955e44ef8..fa49bf701 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -14,6 +14,7 @@ import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator import io.emeraldpay.dshackle.upstream.SingleValidator import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import io.emeraldpay.dshackle.upstream.UpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult import io.emeraldpay.dshackle.upstream.generic.AbstractPollChainSpecific import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService @@ -95,26 +96,77 @@ object AvmChainSpecific : AbstractPollChainSpecific() { options: Options, config: ChainConfig, ): List> { - return emptyList() + if (chain.chainId.isBlank()) { + return emptyList() + } + return listOf( + GenericSingleCallValidator( + ChainRequest("GET#/genesis", RestParams.emptyParams()), + upstream, + ) { data -> + validateGenesis(data, chain, upstream.getId()) + }, + ) + } + + override fun upstreamSettingsDetector( + chain: Chain, + upstream: Upstream, + ): UpstreamSettingsDetector { + return AvmUpstreamSettingsDetector(upstream) } override fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService { return AvmLowerBoundService(chain, upstream) } + fun validateGenesis(data: ByteArray, chain: Chain, upstreamId: String): ValidateUpstreamSettingsResult { + val expected = chain.chainId.trim() + if (expected.isBlank()) { + return ValidateUpstreamSettingsResult.UPSTREAM_VALID + } + if (data.isEmpty()) { + log.warn("AVM node {} returned empty genesis response", upstreamId) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + val genesis = try { + Global.objectMapper.readValue(data, AvmGenesis::class.java) + } catch (e: Exception) { + log.warn("AVM node {} returned unparseable genesis payload: {}", upstreamId, e.message) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + val expectedNetwork = chain.chainName.substringAfterLast(" ").lowercase() + if (genesis.network.equals(expectedNetwork, ignoreCase = true)) { + return ValidateUpstreamSettingsResult.UPSTREAM_VALID + } + log.warn( + "AVM node {} chain mismatch: chain={} (expected network={}) but node reports network={} id={}", + upstreamId, + chain.chainName, + expectedNetwork, + genesis.network, + genesis.id, + ) + return ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR + } + fun validate(data: ByteArray, upstreamId: String): UpstreamAvailability { val status = Global.objectMapper.readValue(data, AvmStatus::class.java) - return if (status.catchupTime > 0L) { + if (status.lastRound == 0L) { + log.warn("AVM node {} reports no last-round", upstreamId) + return UpstreamAvailability.UNAVAILABLE + } + if (status.stoppedAtUnsupportedRound) { + log.warn("AVM node {} halted on an unsupported consensus round", upstreamId) + return UpstreamAvailability.UNAVAILABLE + } + if (status.catchupTime > 0L) { log.warn("AVM node {} is catching up: catchupTime={}ns", upstreamId, status.catchupTime) - UpstreamAvailability.SYNCING - } else { - UpstreamAvailability.OK + return UpstreamAvailability.SYNCING } + return UpstreamAvailability.OK } - // Algorand JSON blocks encode 32-byte fields (seed, prev, txn) in base64. - // Decode to raw bytes; if decoding fails or the field is absent, fall back - // to a deterministic 32-byte encoding of the round number. private fun toHashBytes(raw: String?, round: Long): ByteArray { if (raw.isNullOrBlank()) { return roundToBytes(round) @@ -149,6 +201,7 @@ data class AvmStatus( @param:JsonProperty("time-since-last-round") var timeSinceLastRound: Long = 0, @param:JsonProperty("last-version") var lastVersion: String? = null, @param:JsonProperty("next-version") var nextVersion: String? = null, + @param:JsonProperty("stopped-at-unsupported-round") var stoppedAtUnsupportedRound: Boolean = false, ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt index 6fe24d36c..d264e55e0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt @@ -6,10 +6,10 @@ import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService class AvmLowerBoundService( - private val chain: Chain, - upstream: Upstream, + chain: Chain, + private val upstream: Upstream, ) : LowerBoundService(chain, upstream) { override fun detectors(): List { - return listOf(AvmLowerBoundStateDetector(chain)) + return listOf(AvmLowerBoundStateDetector(upstream)) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt index c83e8ab21..6e2d1eacc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt @@ -1,24 +1,108 @@ package io.emeraldpay.dshackle.upstream.avm -import io.emeraldpay.dshackle.Chain +import com.fasterxml.jackson.databind.JsonNode +import io.emeraldpay.dshackle.Defaults +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.upstream.ChainCallError +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.detector.RecursiveLowerBound +import io.emeraldpay.dshackle.upstream.rpcclient.RestParams +import org.slf4j.LoggerFactory import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.kotlin.core.publisher.toFlux class AvmLowerBoundStateDetector( - chain: Chain, -) : LowerBoundDetector(chain) { + private val upstream: Upstream, +) : LowerBoundDetector(upstream.getChain()) { - override fun period(): Long { - return 120 + private val recursiveLowerBound = RecursiveLowerBound(upstream, LowerBoundType.STATE, notFoundErrors, lowerBounds) + + companion object { + private val log = LoggerFactory.getLogger(AvmLowerBoundStateDetector::class.java) + + val notFoundErrors = setOf( + "block not found", + "not available", + "does not have entry", + "failed to retrieve information", + "no information found", + ) } + override fun period(): Long = 60 + override fun internalDetectLowerBound(): Flux { - return Flux.just(LowerBoundData(1, LowerBoundType.STATE)) + return recursiveLowerBound.recursiveDetectLowerBound { block -> + val round = if (block <= 0L) 1L else block + val params = RestParams( + headers = emptyList(), + queryParams = emptyList(), + pathParams = listOf(round.toString()), + payload = ByteArray(0), + ) + upstream.getIngressReader() + .read(ChainRequest("GET#/v2/blocks/*/hash", params)) + .timeout(Defaults.internalCallsTimeout) + .map { response -> interpretHashResponse(round, response) } + } + .switchIfEmpty(Mono.fromSupplier { cachedOrUnknown("recursive search returned no bound") }) + .onErrorResume { err -> Mono.just(cachedOrUnknown(err.message ?: "unknown error")) } + .toFlux() + } + + override fun types(): Set = setOf(LowerBoundType.STATE, LowerBoundType.UNKNOWN) + + private fun cachedOrUnknown(reason: String): LowerBoundData { + val cached = lowerBounds.getLastBound(LowerBoundType.STATE) + if (cached != null) { + log.debug( + "AVM upstream {} lower-bound search failed ({}); retaining cached STATE={}", + upstream.getId(), + reason, + cached.lowerBound, + ) + return cached + } + log.warn( + "AVM upstream {} lower-bound search failed ({}) and no cache is available; emitting UNKNOWN", + upstream.getId(), + reason, + ) + return LowerBoundData(0, LowerBoundType.UNKNOWN) + } + + private fun interpretHashResponse(round: Long, response: ChainResponse): ChainResponse { + if (response.hasError()) { + return response + } + val raw = response.getResult() + if (raw.isEmpty()) { + return ChainResponse(null, ChainCallError(404, "empty body for round $round")) + } + val node = runCatching { Global.objectMapper.readTree(raw) }.getOrNull() ?: return response + val message = node.get("message")?.asText().orEmpty() + if (message.isNotBlank() && looksLikeNotFound(message)) { + return ChainResponse(null, ChainCallError(404, message)) + } + if (!hasHashPayload(node)) { + return ChainResponse(null, ChainCallError(404, "round $round not available")) + } + return response + } + + private fun looksLikeNotFound(message: String): Boolean { + val lower = message.lowercase() + return notFoundErrors.any { lower.contains(it) } } - override fun types(): Set { - return setOf(LowerBoundType.STATE) + private fun hasHashPayload(node: JsonNode): Boolean { + val hash = node.get("blockHash")?.asText().orEmpty() + return hash.isNotBlank() } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt new file mode 100644 index 000000000..4ef66a5cf --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt @@ -0,0 +1,46 @@ +package io.emeraldpay.dshackle.upstream.avm + +import com.fasterxml.jackson.databind.JsonNode +import io.emeraldpay.dshackle.upstream.BasicUpstreamSettingsDetector +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.NodeTypeRequest +import io.emeraldpay.dshackle.upstream.UNKNOWN_CLIENT_VERSION +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.rpcclient.RestParams +import reactor.core.publisher.Flux + +class AvmUpstreamSettingsDetector( + upstream: Upstream, +) : BasicUpstreamSettingsDetector(upstream) { + + override fun internalDetectLabels(): Flux> { + return Flux.merge( + detectNodeType(), + ) + } + + override fun clientVersionRequest(): ChainRequest = + ChainRequest("GET#/versions", RestParams.emptyParams()) + + override fun parseClientVersion(data: ByteArray): String { + if (data.isEmpty()) return UNKNOWN_CLIENT_VERSION + val node = runCatching { io.emeraldpay.dshackle.Global.objectMapper.readTree(data) }.getOrNull() + ?: return UNKNOWN_CLIENT_VERSION + return clientVersion(node) ?: UNKNOWN_CLIENT_VERSION + } + + override fun nodeTypeRequest(): NodeTypeRequest = NodeTypeRequest(clientVersionRequest()) + + override fun clientType(node: JsonNode): String = "algod" + + override fun clientVersion(node: JsonNode): String { + val build = node.get("build") ?: return UNKNOWN_CLIENT_VERSION + val major = build.get("major")?.asInt(-1) ?: -1 + val minor = build.get("minor")?.asInt(-1) ?: -1 + val patch = build.get("build_number")?.asInt(-1) ?: -1 + if (major < 0 || minor < 0 || patch < 0) { + return UNKNOWN_CLIENT_VERSION + } + return "$major.$minor.$patch" + } +} From 5f454f5247ef0e08bdba968de99aa28748d529a5 Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Tue, 5 May 2026 16:25:17 +0300 Subject: [PATCH 073/108] Feat/autodetect trace rawtx and storage values (#825) --- foundation/build.gradle | 15 ++ .../upstream/calls/DefaultEthereumMethods.kt | 1 + .../BasicEthUpstreamRpcMethodsDetector.kt | 2 + .../BasicEthUpstreamRpcMethodsDetectorTest.kt | 163 +++++++++++++++++- 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/foundation/build.gradle b/foundation/build.gradle index 61e9ca555..6cadb0743 100644 --- a/foundation/build.gradle +++ b/foundation/build.gradle @@ -1,6 +1,9 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id 'org.jetbrains.kotlin.jvm' version '2.3.0' id 'maven-publish' + id 'java' } repositories { @@ -10,6 +13,18 @@ repositories { group = 'dshackle' +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +compileKotlin { + compilerOptions.jvmTarget.set(JvmTarget.JVM_21) +} +compileTestKotlin { + compilerOptions.jvmTarget.set(JvmTarget.JVM_21) +} + dependencies { implementation 'org.yaml:snakeyaml:1.24' testImplementation 'org.junit.jupiter:junit-jupiter:6.0.1' diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt index 1476391ad..3c3616bc0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultEthereumMethods.kt @@ -179,6 +179,7 @@ class DefaultEthereumMethods( private val firstValueMethods = listOf( "eth_call", "eth_getStorageAt", + "eth_getStorageValues", "eth_getCode", "eth_getLogs", "eth_maxPriorityFeePerGas", diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetector.kt index 5e9c01134..25e8223c3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetector.kt @@ -34,7 +34,9 @@ class BasicEthUpstreamRpcMethodsDetector( setOf( "eth_getBlockReceipts" to ListParams("latest"), "trace_callMany" to ListParams(listOf(listOf())), + "trace_rawTransaction" to ListParams(listOf()), "eth_simulateV1" to ListParams(listOf()), + "eth_getStorageValues" to ListParams(listOf()), "debug_storageRangeAt" to ListParams(listOf()), "eth_getTdByNumber" to ListParams(listOf()), "eth_callBundle" to ListParams(listOf()), diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetectorTest.kt index 182ca8b8f..2142824b8 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetectorTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/BasicEthUpstreamRpcMethodsDetectorTest.kt @@ -84,6 +84,24 @@ class BasicEthUpstreamRpcMethodsDetectorTest { ChainCallError(32602, "missing value for required argument 0"), ), ) + on { + read(ChainRequest("trace_rawTransaction", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) + on { + read(ChainRequest("eth_getStorageValues", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) } val upstream = @@ -169,6 +187,24 @@ class BasicEthUpstreamRpcMethodsDetectorTest { ChainCallError(32602, "missing value for required argument 0"), ), ) + on { + read(ChainRequest("trace_rawTransaction", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) + on { + read(ChainRequest("eth_getStorageValues", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) } val upstream = @@ -180,10 +216,12 @@ class BasicEthUpstreamRpcMethodsDetectorTest { val detector = BasicEthUpstreamRpcMethodsDetector(upstream, config) Assertions.assertThat(detector.detectRpcMethods().block()).apply { isNotNull() - hasSize(6) + hasSize(8) containsEntry("eth_getBlockReceipts", true) containsEntry("trace_callMany", true) + containsEntry("trace_rawTransaction", true) containsEntry("eth_simulateV1", true) + containsEntry("eth_getStorageValues", true) containsEntry("debug_storageRangeAt", true) containsEntry("eth_getTdByNumber", true) containsEntry("eth_callBundle", true) @@ -259,6 +297,24 @@ class BasicEthUpstreamRpcMethodsDetectorTest { ChainCallError(32602, "missing value for required argument 0"), ), ) + on { + read(ChainRequest("trace_rawTransaction", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) + on { + read(ChainRequest("eth_getStorageValues", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) } val upstream = @@ -286,4 +342,109 @@ class BasicEthUpstreamRpcMethodsDetectorTest { containsEntry("eth_callBundle", true) } } + + @Test + fun `rpc_modules present but trace_rawTransaction and eth_getStorageValues not implemented`() { + val reader = + mock { + on { + read(ChainRequest("rpc_modules", ListParams())) + } doReturn + Mono.just( + ChainResponse( + """{"net": "1.0","debug": "1.0","txpool": "1.0","drpc": "1.0","erigon": "1.0","eth": "1.0","trace": "1.0"}""" + .toByteArray(), + null, + ), + ) + on { + read(ChainRequest("eth_getBlockReceipts", ListParams("latest"))) + } doReturn + Mono.just( + ChainResponse( + """[{"blockHash": "0xd12897f54acaa79f4824aa4f8e1d0f045b5568f5b942073555e9977202c5c474","blockNumber": "0x13c1108"}]""" + .toByteArray(), + null, + ), + ) + on { + read(ChainRequest("trace_callMany", ListParams(listOf(listOf())))) + } doReturn + Mono.just( + ChainResponse( + "[]".toByteArray(), + null, + ), + ) + on { + read(ChainRequest("trace_rawTransaction", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32601, "the method trace_rawTransaction does not exist/is not available"), + ), + ) + on { + read(ChainRequest("eth_simulateV1", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + "[]".toByteArray(), + null, + ), + ) + on { + read(ChainRequest("eth_getStorageValues", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32601, "the method eth_getStorageValues does not exist/is not available"), + ), + ) + on { + read(ChainRequest("debug_storageRangeAt", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) + on { + read(ChainRequest("eth_getTdByNumber", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) + on { + read(ChainRequest("eth_callBundle", ListParams(listOf()))) + } doReturn + Mono.just( + ChainResponse( + null, + ChainCallError(32602, "missing value for required argument 0"), + ), + ) + } + + val upstream = + mock { + on { getIngressReader() } doReturn reader + on { getChain() } doReturn Chain.ETHEREUM__MAINNET + } + val config = mock> { } + val detector = BasicEthUpstreamRpcMethodsDetector(upstream, config) + Assertions.assertThat(detector.detectRpcMethods().block()).apply { + isNotNull() + containsEntry("trace_rawTransaction", false) + containsEntry("eth_getStorageValues", false) + containsEntry("trace_callMany", true) + containsEntry("eth_getBlockReceipts", true) + } + } } From 43b9d50484164d1af2895f0a93665a3416082354 Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Fri, 8 May 2026 11:56:37 +0300 Subject: [PATCH 074/108] =?UTF-8?q?Add=20B=C2=B2=20Network=20mainnet=20sup?= =?UTF-8?q?port=20(#826)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index cb95fa0d0..59ca0a126 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit cb95fa0d0644dafed870b44e277beb72b84b7e1d +Subproject commit 59ca0a12675dbdc9fba7c5c6e302c37d3d7b2e2a diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 3e282c808..c84e64a05 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 3e282c808f46c73f3c8e9402e2403e30181794c8 +Subproject commit c84e64a0519539d00a22e0907d2f3de517772165 From 3c4fe8985efa1a416f873bee445f556102a07921 Mon Sep 17 00:00:00 2001 From: EugeneDrpc Date: Wed, 13 May 2026 11:09:59 +0200 Subject: [PATCH 075/108] Update submodules (#828) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 59ca0a126..dcaddcdaa 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 59ca0a12675dbdc9fba7c5c6e302c37d3d7b2e2a +Subproject commit dcaddcdaabeb37a81154948e44f4d3fd33b1dcfb diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index c84e64a05..d602a2716 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit c84e64a0519539d00a22e0907d2f3de517772165 +Subproject commit d602a2716952bc421d54bebf40b1340e62fc687a From b99b74fbe1588c3c3a8d1bb466027634ba12a13d Mon Sep 17 00:00:00 2001 From: EugeneDrpc Date: Wed, 13 May 2026 12:06:55 +0200 Subject: [PATCH 076/108] Sig nonce fix (#829) * Update submodules * sign fix * copilot comment fix --- .../dshackle/upstream/signature/RsaSigner.kt | 9 ++++- .../upstream/signature/RsaSignerSpec.groovy | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt index ee0e8f048..f54396661 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt @@ -29,13 +29,18 @@ class RsaSigner( /** * Wrapping format: `"DSHACKLESIG/" || str(nonce) || "/" || source || "/" || hex(sha256(msg))` + * + * `nonce` carries an unsigned uint64 bit pattern (proto3 uint64 ↔ Java long). + * Format it as unsigned decimal so verifiers reconstruct the same wrap for + * nonces ≥ 2^63 — otherwise `Long.toString` emits a negative number and + * signature verification fails. */ fun wrapMessage(nonce: Long, message: ByteArray, source: String): String { val sha256 = MessageDigest.getInstance("SHA-256") - val formatterMsg = StringBuilder(11 + 1 + 18 + 1 + 64 + 1 + 64) + val formatterMsg = StringBuilder(11 + 1 + 20 + 1 + 64 + 1 + 64) formatterMsg.append(MSG_PREFIX) .append(MSG_SEPARATOR) - .append(nonce.toString()) + .append(java.lang.Long.toUnsignedString(nonce)) .append(MSG_SEPARATOR) .append(source) .append(MSG_SEPARATOR) diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy index 2e212a454..d73a1a434 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy @@ -50,6 +50,43 @@ class RsaSignerSpec extends Specification { sig.keyId == 100L } + def "Wrap nonce >= 2^63 as unsigned"() { + setup: + def signer = new RsaSigner(Stub(RSAPrivateKey), 100L) + // bit pattern of 12241848404401059555 (uint64) == -6204895669308492061 (signed Long) + def nonceBits = -6204895669308492061L + + when: + def act = signer.wrapMessage(nonceBits, "test".bytes, "infura") + + then: + act == "DSHACKLESIG/12241848404401059555/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + !act.contains("-") + } + + def "Signed message is valid for nonce >= 2^63"() { + setup: + def result = "test".bytes + def keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + def pair = keyGen.generateKeyPair() + // bit pattern of 12241848404401059555 (uint64) == -6204895669308492061 (signed Long) + def nonceBits = -6204895669308492061L + + def sha256 = MessageDigest.getInstance("SHA-256") + def verifier = Signature.getInstance("SHA256withRSA", "BC") + verifier.initVerify(pair.getPublic()) + verifier.update("DSHACKLESIG/12241848404401059555/infura/${Hex.encodeHexString(sha256.digest(result))}".getBytes()) + + def signer = new RsaSigner((pair.getPrivate() as RSAPrivateKey), 100L) + + when: + def sig = signer.sign(nonceBits, result, "infura") + + then: + verifier.verify(sig.value) + } + def "Signer is enabled"() { setup: def signer = new RsaSigner(Stub(RSAPrivateKey), 1L) From 6bf86803599dbebd82c38bad06822fd841abf377 Mon Sep 17 00:00:00 2001 From: EugeneDrpc Date: Fri, 15 May 2026 16:05:24 +0200 Subject: [PATCH 077/108] implement lowerbound fix (#830) * implement fix --- .../error/EthereumLowerBoundErrorHandler.kt | 24 ++++++++- ...EthereumStateLowerBoundErrorHandlerTest.kt | 53 ++++++++++++++++++- ...EthereumTraceLowerBoundErrorHandlerTest.kt | 45 ++++++++++++++-- .../error/UpstreamErrorHandlerTest.kt | 7 ++- 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumLowerBoundErrorHandler.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumLowerBoundErrorHandler.kt index 587d02d4e..e13409b96 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumLowerBoundErrorHandler.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumLowerBoundErrorHandler.kt @@ -12,8 +12,28 @@ abstract class EthereumLowerBoundErrorHandler : ErrorHandler { override fun handle(upstream: Upstream, request: ChainRequest, errorMessage: String?) { try { if (canHandle(request, errorMessage)) { - parseTagParam(request, tagIndex(request.method))?.let { - upstream.updateLowerBound(it, type()) + parseTagParam(request, tagIndex(request.method))?.let { parsed -> + val currentHeight = upstream.getHead().getCurrentHeight() + if (currentHeight == null) { + log.warn( + "Skip {} lower bound update for {}: head height unknown (parsed={})", + type(), + upstream.getId(), + parsed, + ) + return@let + } + if (parsed > currentHeight) { + log.warn( + "Skip {} lower bound update for {}: parsed tag {} exceeds head {}", + type(), + upstream.getId(), + parsed, + currentHeight, + ) + return@let + } + upstream.updateLowerBound(parsed, type()) } } } catch (e: RuntimeException) { diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt index ab1908e43..3d2ce0c2d 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumStateLowerBoundErrorHandlerTest.kt @@ -1,6 +1,7 @@ package io.emeraldpay.dshackle.upstream.error import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType import io.emeraldpay.dshackle.upstream.rpcclient.ListParams @@ -10,17 +11,18 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments.of import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito.anyLong -import org.mockito.Mockito.mock import org.mockito.kotlin.any +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class EthereumStateLowerBoundErrorHandlerTest { @ParameterizedTest @MethodSource("requests") fun `update lower bound`(request: ChainRequest) { - val upstream = mock() + val upstream = mockUpstreamWithHead(300_000_000L) val handler = EthereumStateLowerBoundErrorHandler handler.handle(upstream, request, "missing trie node d5648cc9aef48154159d53800f2f") @@ -28,6 +30,53 @@ class EthereumStateLowerBoundErrorHandlerTest { verify(upstream).updateLowerBound(213229736, LowerBoundType.STATE) } + @Test + fun `no update lower bound if parsed tag exceeds head`() { + val upstream = mockUpstreamWithHead(100_000_000L) + + EthereumStateLowerBoundErrorHandler.handle( + upstream, + ChainRequest("eth_getBalance", ListParams("0x343", "0xCB5A0A8")), + "missing trie node d5648cc9aef48154159d53800f2f", + ) + + verify(upstream, never()).updateLowerBound(anyLong(), any()) + } + + @Test + fun `no update lower bound if head height is null`() { + val upstream = mockUpstreamWithHead(null) + + EthereumStateLowerBoundErrorHandler.handle( + upstream, + ChainRequest("eth_getBalance", ListParams("0x343", "0xCB5A0A8")), + "missing trie node d5648cc9aef48154159d53800f2f", + ) + + verify(upstream, never()).updateLowerBound(anyLong(), any()) + } + + @Test + fun `no update lower bound for the Base prod incident value`() { + val upstream = mockUpstreamWithHead(30_000_000L) + + EthereumStateLowerBoundErrorHandler.handle( + upstream, + ChainRequest("eth_getBalance", ListParams("0x343", "0xD150C7F1")), + "missing trie node d5648cc9aef48154159d53800f2f", + ) + + verify(upstream, never()).updateLowerBound(anyLong(), any()) + } + + private fun mockUpstreamWithHead(height: Long?): Upstream { + val head = mock() + whenever(head.getCurrentHeight()).thenReturn(height) + val upstream = mock() + whenever(upstream.getHead()).thenReturn(head) + return upstream + } + @Test fun `no update lower bound if error is not about state`() { val upstream = mock() diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumTraceLowerBoundErrorHandlerTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumTraceLowerBoundErrorHandlerTest.kt index 2d5d824af..170a402f4 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumTraceLowerBoundErrorHandlerTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/EthereumTraceLowerBoundErrorHandlerTest.kt @@ -1,6 +1,7 @@ package io.emeraldpay.dshackle.upstream.error import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType import io.emeraldpay.dshackle.upstream.rpcclient.ListParams @@ -9,15 +10,19 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments.of import org.junit.jupiter.params.provider.MethodSource -import org.mockito.Mockito.mock +import org.mockito.Mockito.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class EthereumTraceLowerBoundErrorHandlerTest { @ParameterizedTest @MethodSource("requests") fun `update lower bound`(request: ChainRequest) { - val upstream = mock() + val upstream = mockUpstreamWithHead(300_000_000L) val handler = EthereumTraceLowerBoundErrorHandler handler.handle(upstream, request, "missing trie node d5648cc9aef48154159d53800f2f") @@ -27,7 +32,7 @@ class EthereumTraceLowerBoundErrorHandlerTest { @Test fun `update lower bound base on regexp`() { - val upstream = mock() + val upstream = mockUpstreamWithHead(300_000_000L) val handler = EthereumTraceLowerBoundErrorHandler handler.handle(upstream, ChainRequest("trace_block", ListParams("0xCB5A0A8")), "block #1 not found") @@ -35,6 +40,40 @@ class EthereumTraceLowerBoundErrorHandlerTest { verify(upstream).updateLowerBound(213229736, LowerBoundType.TRACE) } + @Test + fun `no update lower bound if parsed tag exceeds head`() { + val upstream = mockUpstreamWithHead(100_000_000L) + + EthereumTraceLowerBoundErrorHandler.handle( + upstream, + ChainRequest("trace_block", ListParams("0xCB5A0A8")), + "missing trie node d5648cc9aef48154159d53800f2f", + ) + + verify(upstream, never()).updateLowerBound(anyLong(), any()) + } + + @Test + fun `no update lower bound if head height is null`() { + val upstream = mockUpstreamWithHead(null) + + EthereumTraceLowerBoundErrorHandler.handle( + upstream, + ChainRequest("trace_block", ListParams("0xCB5A0A8")), + "missing trie node d5648cc9aef48154159d53800f2f", + ) + + verify(upstream, never()).updateLowerBound(anyLong(), any()) + } + + private fun mockUpstreamWithHead(height: Long?): Upstream { + val head = mock() + whenever(head.getCurrentHeight()).thenReturn(height) + val upstream = mock() + whenever(upstream.getHead()).thenReturn(head) + return upstream + } + companion object { @JvmStatic fun requests(): List = diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/UpstreamErrorHandlerTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/UpstreamErrorHandlerTest.kt index 5504d12bf..d2da73d95 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/UpstreamErrorHandlerTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/error/UpstreamErrorHandlerTest.kt @@ -1,18 +1,23 @@ package io.emeraldpay.dshackle.upstream.error import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock +import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class UpstreamErrorHandlerTest { @Test fun `use lower bound error handler`() { + val head = mock() + whenever(head.getCurrentHeight()).thenReturn(300_000_000L) val upstream = mock() + whenever(upstream.getHead()).thenReturn(head) val request = ChainRequest("eth_getCode", ListParams("0x343", "0xCB5A0A8")) val handler = UpstreamErrorHandler From 0e978979ab47cd898a88720a71c46b37783b0b05 Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Mon, 18 May 2026 12:28:24 +0300 Subject: [PATCH 078/108] chore: add Dependabot configuration (#831) --- .github/dependabot.yml | 79 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..6b325c773 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,79 @@ +# Dependabot configuration +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 + +updates: + # Gradle — main module + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + target-branch: "master" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "gradle" + groups: + gradle-minor-and-patch: + update-types: + - "minor" + - "patch" + + # Gradle — foundation module + - package-ecosystem: "gradle" + directory: "/foundation" + schedule: + interval: "weekly" + target-branch: "master" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "gradle" + groups: + gradle-minor-and-patch: + update-types: + - "minor" + - "patch" + + # Gradle — buildSrc (build logic / plugins) + - package-ecosystem: "gradle" + directory: "/buildSrc" + schedule: + interval: "weekly" + target-branch: "master" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "gradle" + groups: + gradle-minor-and-patch: + update-types: + - "minor" + - "patch" + + # Docker — base image in Dockerfile + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + target-branch: "master" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "docker" + + # GitHub Actions — workflow action versions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: "master" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + groups: + actions-minor-and-patch: + update-types: + - "minor" + - "patch" From 7ee5375a533ebed7d291b9afbab0134d6945983f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:48:27 +0300 Subject: [PATCH 079/108] Bump com.palantir.git-version from 0.12.3 to 5.0.0 (#842) Bumps com.palantir.git-version from 0.12.3 to 5.0.0. --- updated-dependencies: - dependency-name: com.palantir.git-version dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea4dd788f..2f4e620ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -137,6 +137,6 @@ bouncycastle = ["bouncycastle-pkix", "bouncycastle-prov"] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jib = { id = "com.google.cloud.tools.jib", version = "3.5.2" } spring = { id = "org.springframework.boot", version = "4.0.1" } -git = { id = "com.palantir.git-version", version = "0.12.3" } +git = { id = "com.palantir.git-version", version = "5.0.0" } protobuf = { id = "com.google.protobuf", version = "0.9.6" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "11.6.0" } From f4a821eb6e90f8a191692e2e397a45a0067da0ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:49:11 +0300 Subject: [PATCH 080/108] Bump eclipse-temurin from 21 to 25 (#832) Bumps eclipse-temurin from 21 to 25. --- updated-dependencies: - dependency-name: eclipse-temurin dependency-version: '25' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ec4742053..da9146281 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,3 @@ -FROM eclipse-temurin:21 +FROM eclipse-temurin:25 RUN apt-get update -y && apt-get install -y libcurl4-openssl-dev libcjson-dev From 08763f381585b3a4417b3e1c5cfb412ddd68cf24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:49:20 +0300 Subject: [PATCH 081/108] Bump com.squareup:kotlinpoet from 1.14.2 to 2.3.0 in /buildSrc (#833) Bumps [com.squareup:kotlinpoet](https://github.com/square/kotlinpoet) from 1.14.2 to 2.3.0. - [Release notes](https://github.com/square/kotlinpoet/releases) - [Changelog](https://github.com/square/kotlinpoet/blob/main/docs/changelog.md) - [Commits](https://github.com/square/kotlinpoet/compare/1.14.2...2.3.0) --- updated-dependencies: - dependency-name: com.squareup:kotlinpoet dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 7f4908de4..93e3ca6af 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,5 +10,5 @@ repositories { dependencies { implementation("org.yaml:snakeyaml:1.24") implementation("dshackle:foundation:1.0.0") - implementation("com.squareup:kotlinpoet:1.14.2") + implementation("com.squareup:kotlinpoet:2.3.0") } From e56bfae287b2ab55c7ce38253ef9b4c737e5d0e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:49:36 +0300 Subject: [PATCH 082/108] Bump org.yaml:snakeyaml from 1.24 to 2.6 in /buildSrc (#834) Bumps [org.yaml:snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml) from 1.24 to 2.6. - [Commits](https://bitbucket.org/snakeyaml/snakeyaml/branches/compare/snakeyaml-2.6..snakeyaml-1.24) --- updated-dependencies: - dependency-name: org.yaml:snakeyaml dependency-version: '2.6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 93e3ca6af..a9b04aa8a 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -8,7 +8,7 @@ repositories { } dependencies { - implementation("org.yaml:snakeyaml:1.24") + implementation("org.yaml:snakeyaml:2.6") implementation("dshackle:foundation:1.0.0") implementation("com.squareup:kotlinpoet:2.3.0") } From fada5a22b9fa61ace9093ce6d8e9857c5bf91016 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:49:46 +0300 Subject: [PATCH 083/108] Bump actions/setup-java from 4 to 5 (#835) Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yaml | 4 ++-- .github/workflows/test.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index d7df5fb45..b670b8b1b 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -13,7 +13,7 @@ jobs: ref: ${{ github.ref }} submodules: recursive - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 @@ -33,7 +33,7 @@ jobs: ref: ${{ github.ref }} submodules: recursive - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9d58ef0d9..3fbd1973a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -28,7 +28,7 @@ jobs: fetch-depth: 0 submodules: recursive - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 From aae179dad6b7651d5115899f1c77cf3f3e6846d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:49:59 +0300 Subject: [PATCH 084/108] Bump actions/checkout from 4 to 6 (#836) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yaml | 4 ++-- .github/workflows/static.yml | 2 +- .github/workflows/test.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b670b8b1b..262e231a5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -8,7 +8,7 @@ jobs: publish-docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ github.ref }} submodules: recursive @@ -28,7 +28,7 @@ jobs: publish-github: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ github.ref }} submodules: recursive diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 95052fb22..a83278303 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: asciidoctor-ghpages uses: manoelcampos/asciidoctor-ghpages-action@v2 env: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3fbd1973a..7921b5a15 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -23,7 +23,7 @@ jobs: unit-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 submodules: recursive From 0266af2eef363f0cf35ad9718cf7377129bea42a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:50:08 +0300 Subject: [PATCH 085/108] Bump svenstaro/upload-release-action from 1.pre.release to 2 (#837) Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 1.pre.release to 2. - [Release notes](https://github.com/svenstaro/upload-release-action/releases) - [Changelog](https://github.com/svenstaro/upload-release-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/svenstaro/upload-release-action/compare/v1-release...v2) --- updated-dependencies: - dependency-name: svenstaro/upload-release-action dependency-version: '2' dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 262e231a5..b382c0cbd 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -43,7 +43,7 @@ jobs: run: make distZip - name: Upload Release id: upload-release-asset - uses: svenstaro/upload-release-action@v1-release + uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ github.ref }} From 4da5bc5281cf6442a1467bd31cfeae3343fac846 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:50:18 +0300 Subject: [PATCH 086/108] Bump gradle/gradle-build-action from 2 to 3 (#838) Bumps [gradle/gradle-build-action](https://github.com/gradle/gradle-build-action) from 2 to 3. - [Release notes](https://github.com/gradle/gradle-build-action/releases) - [Commits](https://github.com/gradle/gradle-build-action/compare/v2...v3) --- updated-dependencies: - dependency-name: gradle/gradle-build-action dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yaml | 4 ++-- .github/workflows/test.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b382c0cbd..025f42764 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -18,7 +18,7 @@ jobs: distribution: 'zulu' java-version: 21 - name: Setup gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 - name: Upload to Docker run: make jib env: @@ -38,7 +38,7 @@ jobs: distribution: 'zulu' java-version: 21 - name: Setup gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 - name: Build zip run: make distZip - name: Upload Release diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7921b5a15..dcaa82885 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -37,7 +37,7 @@ jobs: run: sudo apt-get install -y openssl libapr1 - name: Setup gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 with: github-token: ${{ secrets.GITHUB_TOKEN }} From 91d2745382db5e915f2748710582388deef15471 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:50:27 +0300 Subject: [PATCH 087/108] Bump the gradle-minor-and-patch group in /foundation with 3 updates (#839) Bumps the gradle-minor-and-patch group in /foundation with 3 updates: [org.junit.jupiter:junit-jupiter](https://github.com/junit-team/junit-framework), [org.jetbrains.kotlin.jvm](https://github.com/JetBrains/kotlin) and [gradle-wrapper](https://github.com/gradle/gradle). Updates `org.junit.jupiter:junit-jupiter` from 6.0.1 to 6.0.3 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.0.1...r6.0.3) Updates `org.jetbrains.kotlin.jvm` from 2.3.0 to 2.3.21 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.0...v2.3.21) Updates `gradle-wrapper` from 9.2.0 to 9.5.1 - [Release notes](https://github.com/gradle/gradle/releases) - [Commits](https://github.com/gradle/gradle/compare/v9.2.0...v9.5.1) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: gradle-minor-and-patch - dependency-name: org.jetbrains.kotlin.jvm dependency-version: 2.3.21 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: gradle-minor-and-patch - dependency-name: gradle-wrapper dependency-version: 9.5.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- foundation/build.gradle | 4 ++-- foundation/gradle/wrapper/gradle-wrapper.jar | Bin 59536 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/foundation/build.gradle b/foundation/build.gradle index 6cadb0743..46cfe7047 100644 --- a/foundation/build.gradle +++ b/foundation/build.gradle @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - id 'org.jetbrains.kotlin.jvm' version '2.3.0' + id 'org.jetbrains.kotlin.jvm' version '2.3.21' id 'maven-publish' id 'java' } @@ -27,7 +27,7 @@ compileTestKotlin { dependencies { implementation 'org.yaml:snakeyaml:1.24' - testImplementation 'org.junit.jupiter:junit-jupiter:6.0.1' + testImplementation 'org.junit.jupiter:junit-jupiter:6.0.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/foundation/gradle/wrapper/gradle-wrapper.jar b/foundation/gradle/wrapper/gradle-wrapper.jar index 7454180f2ae8848c63b8b4dea2cb829da983f2fa..f8e1ee3125fe0768e9a76ee977ac089eb657005e 100644 GIT binary patch literal 45633 zcma&NV|1n6wyqu9PQ|uu+csuwn-$x(T~Woh?Nr6KUD3(A)@l1Yd+oj6Z_U=8`RAE` z#vE6_`?!1WLs1443=Ieh3JM4ai0JG2|2{}S&_HrxszP*9^5P7#QX*pVDq?D?;6T8C z{bWO1$9at%!*8ax*TT&F99vwf1Ls+3lklsb|bC`H`~Q z_w}*E9P=Wq;PYlGYhZ^lt#N97bt5aZ#mQcOr~h^B;R>f-b0gf{y(;VA{noAt`RZzU z7vQWD{%|q!urW2j0Z&%ChtL(^9m` zgaU%|B;V#N_?%iPvu0PVkX=1m9=*SEGt-Lp#&Jh%rz6EJXlV^O5B5YfM5j{PCeElx z8sipzw8d=wVhFK+@mgrWyA)Sv3BJq=+q+cL@=wuH$2;LjY z^{&+X4*HFA0{QvlM_V4PTQjIdd;d|2YuN;s|bi!@<)r-G%TuOCHz$O(_-K z)5in&6uNN<0UfwY=K>d;cL{{WK2FR|NihJMN0Q4X+(1lE)$kY?T$7UWleIU`i zQG#X-&&m-8x^(;n@o}$@vPMYRoq~|FqC~CU3MnoiifD{(CwAGd%X#kFHq#4~%_a!{ zeX{XXDT#(DvX7NtAs7S}2ZuiZ>gtd;tCR7E)3{J^`~#Vd**9qz%~JRFAiZf{zt|Dr zvQw!)n7fNUn_gH`o9?8W8t_%x6~=y*`r46bjj(t{YU*qfqd}J}*mkgUfsXTI>Uxl6 z)Fj>#RMy{`wINIR;{_-!xGLgVaTfNJ2-)%YUfO&X5z&3^E#4?k-_|Yv$`fpgYkvnA%E{CiV zP|-zAf8+1@R`sT{rSE#)-nuU7Pwr-z>0_+CLQT|3vc-R22ExKT4ym@Gj77j$aTVns zp4Kri#Ml?t7*n(;>nkxKdhOU9Qbwz%*#i9_%K<`m4T{3aPbQ?J(Mo`6E5cDdbAk%X z+4bN%E#a(&ZXe{G#V!2Nt+^L$msKVHP z|APpBhq7knz(O2yY)$$VyI_Xg4UIC*$!i7qQG~KEZnO@Q1i89@4ZKW*3^Wh?o?zSkfPxdhnTxlO!3tAqe_ zuEqHVcAk3uQIFTpP~C{d$?>7yt3G3Fo>syXTus>o0tJdFpQWC27hDiwC%O09i|xCq z@H6l|+maB;%CYQIChyhu;PVYz9e&5a@EEQs3$DS6dLIS+;N@I0)V}%B`jdYv;JDck zd|xxp(I?aedivE7*19hesoa-@Xm$^EHbbVmh$2^W-&aTejsyc$i+}A#n2W*&0Qt`5 zJS!2A|LVV;L!(*x2N)GjJC;b1RB_f(#D&g_-};a*|BTRvfdIX}Gau<;uCylMNC;UG zzL((>6KQBQ01wr%7u9qI2HLEDY!>XisIKb#6=F?pAz)!_JX}w|>1V>X^QkMdFi@Jr z`1N*V4xUl{qvECHoF?#lXuO#Dg2#gh|AU$Wc=nuIbmVPBEGd(R#&Z`TP9*o%?%#ob zWN%ByU+55yBNfjMjkJnBjT!cVDi}+PR3N&H(f8$d^Pu;A_WV*{)c2Q{IiE7&LPsd4 z!rvkUf{sco_WNSIdW+btM#O+4n`JiceH6%`7pDV zRqJ@lj=Dt(e-Gkz$b!c2>b)H$lf(fuAPdIsLSe(dZ4E~9+Ge!{3j~>nS%r)eQZ;Iq ztWGpp=2Ptc!LK_TQ8cgJXUlU5mRu|7F2{eu*;a>_5S<;bus=t*IXcfzJRPv4xIs;s zt2<&}OM>KxkTxa=dFMfNr42=DL~I}6+_{`HT_YJBiWkpVZND1Diad~Yr*Fuq{zljr z*_+jXk=qVBdwlQkYuIrB4GG*#voba$?h*u0uRNL+87-?AjzG2X_R9mzQ7BJEawutObr|ey~%in>6k%A`K*`pb-|DF5m})!`b=~osoiW2)IFh?_y9y<3Cix_ znvC=bjBX1J820!%%9FaB@v?hAsd05e@w$^ZAvtUp*=Bi+Owkl?rLa6F#yl{s+?563 zmn2 zV95%gySAJ$L!Vvk4kx!n@mo`3Mfi`2lXUkBmd%)u)7C?Pa;oK~zUQ#p0u{a|&0;zNO#9a4`v^3df90X#~l_k$q7n&L5 z?TszF842~g+}tgUP}UG?ObLCE1(Js_$e>XS7m%o7j@@VdxePtg)w{i5an+xK95r?s zDeEhgMO-2$H?@0{p-!4NJ)}zP+3LzZB?FVap)ObHV6wp}Lrxvz$cjBND1T6ln$EfJ zZRPeR2lP}K0p8x`ahxB??Ud;i7$Y5X!5}qBFS+Zp=P^#)08nQi_HuJcN$0=x;2s53 zwoH}He9BlKT4GdWfWt)@o@$4zN$B@5gVIN~aHtwIhh{O$uHiMgYl=&Vd$w#B2 zRv+xK3>4E{!)+LXA2#*K6H~HpovXAQeXV(^Pd%G_>ro0(4_@`{2Ag(+8{9pqJ>Co$ zRRV(oX;nD+Jel_2^BlNO=cQP8q*G#~R3PTERUxvug_C4T3qwb9MQE|^{5(H*nt`fn z^%*p-RwkAhT6(r>E@5w8FaB)Q<{#`H9fTdc6QBuSr9D-x!Tb9f?wI=M{^$cB5@1;0 z+yLHh?3^c-Qte@JI<SW`$bs5Vv9!yWjJD%oY z8Cdc$a(LLy@tB2)+rUCt&0$&+;&?f~W6+3Xk3g zy9L�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 literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL diff --git a/foundation/gradle/wrapper/gradle-wrapper.properties b/foundation/gradle/wrapper/gradle-wrapper.properties index 63e0e8303..cf71939ed 100644 --- a/foundation/gradle/wrapper/gradle-wrapper.properties +++ b/foundation/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From e865173c4db01eb98aaf93c959116811827142f0 Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Mon, 18 May 2026 14:53:22 +0300 Subject: [PATCH 088/108] Fix silent stalls in native_subscribe WS subscriptions (Solana, eth pending tx) (#827) --- .../subscribe/WebsocketPendingTxes.kt | 15 +++- .../generic/GenericIngressSubscription.kt | 18 ++--- .../subscribe/WebsocketPendingTxesSpec.groovy | 24 +++++++ .../generic/GenericSubscriptionConnectTest.kt | 69 +++++++++++++++++++ 4 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxes.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxes.kt index ddd71c835..b2097626a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxes.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxes.kt @@ -27,6 +27,7 @@ import org.slf4j.LoggerFactory import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.time.Duration +import java.util.concurrent.TimeoutException class WebsocketPendingTxes( private val chain: Chain, @@ -35,6 +36,7 @@ class WebsocketPendingTxes( companion object { private val log = LoggerFactory.getLogger(WebsocketPendingTxes::class.java) + private val IDLE_TIMEOUT: Duration = Duration.ofSeconds(85) } override fun createConnection(): Flux { @@ -42,7 +44,15 @@ class WebsocketPendingTxes( return sub .data .flatMapMany { it.t2 } - .timeout(Duration.ofSeconds(85), Mono.empty()) + // Surface stalls as TimeoutException so the surrounding DurableFlux re-invokes + // createConnection() and issues a fresh eth_subscribe. Without this, a silent + // upstream pubsub leaves the shared Flux completed and clients stop receiving events. + .timeout( + IDLE_TIMEOUT, + Mono.error( + TimeoutException("No events from eth_subscribe newPendingTransactions in $IDLE_TIMEOUT, forcing resubscribe"), + ), + ) .map { // comes as a JS string, i.e., within quotes val value = ByteArray(it.size - 2) @@ -60,7 +70,6 @@ class WebsocketPendingTxes( log.info("unsubscribed from ${sub.subId.get()}") } } - .doOnError { t -> log.warn("Invalid pending transaction", t) } - .onErrorResume { Mono.empty() } + .doOnError { t -> log.warn("Error during pending tx subscription: {}", t.message) } } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt index b572b4361..fb5d10c07 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericIngressSubscription.kt @@ -13,6 +13,7 @@ import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.time.Duration import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeoutException class GenericIngressSubscription( val chain: Chain, @@ -49,6 +50,7 @@ class GenericSubscriptionConnect( companion object { private val log = LoggerFactory.getLogger(GenericSubscriptionConnect::class.java) + private val IDLE_TIMEOUT: Duration = Duration.ofSeconds(85) } @Suppress("UNCHECKED_CAST") @@ -56,16 +58,16 @@ class GenericSubscriptionConnect( val sub = conn.subscribe(ChainRequest(topic, ListParams(getParams(params) as List))) return sub.data .flatMapMany { it.t2 } + // Some upstreams (notably Solana RPCs) silently stop delivering events on a subscription + // while keeping the WebSocket connection alive. Emit a TimeoutException so the surrounding + // DurableFlux re-invokes createConnection() and re-issues `subscribe` with a fresh subId. .timeout( - Duration.ofSeconds(85), - Mono.empty().doOnEach { - log.warn("Timeout during subscription to $topic after 85 seconds") - }, + IDLE_TIMEOUT, + Mono.error( + TimeoutException("No events from subscription to $topic in $IDLE_TIMEOUT, forcing resubscribe"), + ), ) - .onErrorResume { - log.error("Error during subscription to $topic", it) - Mono.empty() - } + .doOnError { log.warn("Error during subscription to $topic: {}", it.message) } .doFinally { if (unsubscribeMethod != "") { conn.unsubscribe( diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxesSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxesSpec.groovy index 68d453e09..f1dd5a021 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxesSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/subscribe/WebsocketPendingTxesSpec.groovy @@ -22,10 +22,12 @@ import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptions import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import reactor.core.publisher.Flux import reactor.core.publisher.Mono +import reactor.test.StepVerifier import reactor.util.function.Tuples import spock.lang.Specification import java.time.Duration +import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicReference class WebsocketPendingTxesSpec extends Specification { @@ -55,4 +57,26 @@ class WebsocketPendingTxesSpec extends Specification { "0x67f22a3b441ea312306f97694ca8159f8d6faaccf0f5ce6442c84b13991f1d23", ] } + + def "Emits TimeoutException when upstream goes silent past idle timeout"() { + // Pins the fix for the silent-stall bug: a healthy WS that simply stops delivering + // newPendingTransactions events must surface a TimeoutException so DurableFlux + // re-issues eth_subscribe. + setup: + def ws = Stub(WsSubscriptions) { + subscribe(new ChainRequest("eth_subscribe", new ListParams(["newPendingTransactions"]))) >> new WsSubscriptions.SubscribeData( + Mono.just(Tuples.of("sub-1", Flux.never())), "conn-1", new AtomicReference("sub-1") + ) + unsubscribe(_) >> Mono.empty() + } + def pending = new WebsocketPendingTxes(Chain.ETHEREUM__MAINNET, ws) + + expect: + StepVerifier.withVirtualTime { pending.createConnection() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(84)) + .thenAwait(Duration.ofSeconds(2)) + .expectError(TimeoutException) + .verify(Duration.ofSeconds(5)) + } } diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericSubscriptionConnectTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericSubscriptionConnectTest.kt index f8b048169..a7af6e938 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericSubscriptionConnectTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericSubscriptionConnectTest.kt @@ -6,13 +6,16 @@ import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptions import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.junit.jupiter.api.Test import org.mockito.Mockito.verify +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.times import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.test.StepVerifier import reactor.util.function.Tuples import java.time.Duration +import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicReference class GenericSubscriptionConnectTest { @@ -36,4 +39,70 @@ class GenericSubscriptionConnectTest { verify(ws).subscribe(ChainRequest(topic, ListParams(param))) } + + @Test + fun `emits TimeoutException when upstream goes silent past idle timeout`() { + // Simulates the Solana-like failure: WS subscription is established, but upstream stops + // delivering events without closing the connection. The Flux must surface a TimeoutException + // so the surrounding DurableFlux re-subscribes. + val param: List = listOf("all") + val topic = "slotSubscribe" + val ws = mock { + on { subscribe(ChainRequest(topic, ListParams(param))) } doReturn + WsSubscriptions.SubscribeData( + Mono.just(Tuples.of("sub-id-1", Flux.never())), + "conn-1", + AtomicReference("sub-id-1"), + ) + } + + val genericSubscriptionConnect = GenericSubscriptionConnect(Chain.SOLANA__MAINNET, ws, topic, param, "") + + StepVerifier.withVirtualTime { genericSubscriptionConnect.createConnection() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(84)) + .thenAwait(Duration.ofSeconds(2)) + .expectError(TimeoutException::class.java) + .verify(Duration.ofSeconds(5)) + } + + @Test + fun `idle timeout triggers resubscribe via DurableFlux retry`() { + // End-to-end on the GenericSubscriptionConnect: when the first subscription stalls, + // DurableFlux must call createConnection() again, which issues a fresh `subscribe` RPC. + val param: List = listOf("all") + val topic = "slotSubscribe" + + val secondAttempt = WsSubscriptions.SubscribeData( + Mono.just(Tuples.of("sub-id-2", Flux.just("recovered".toByteArray()))), + "conn-1", + AtomicReference("sub-id-2"), + ) + val firstAttempt = WsSubscriptions.SubscribeData( + Mono.just(Tuples.of("sub-id-1", Flux.never())), + "conn-1", + AtomicReference("sub-id-1"), + ) + + val ws = mock { + on { subscribe(ChainRequest(topic, ListParams(param))) } + .doReturn(firstAttempt, secondAttempt) + on { unsubscribe(any()) } doReturn Mono.empty() + } + + val genericSubscriptionConnect = GenericSubscriptionConnect(Chain.SOLANA__MAINNET, ws, topic, param, "slotUnsubscribe") + + // GenericPersistentConnect wraps createConnection() with DurableFlux + SharedFluxHolder. + // The first subscription stalls (Flux.never) → timeout (85s) → TimeoutException → + // DurableFlux schedules retry → second subscription delivers "recovered". + StepVerifier.withVirtualTime { genericSubscriptionConnect.connect(io.emeraldpay.dshackle.upstream.Selector.empty) } + .expectSubscription() + .thenAwait(Duration.ofSeconds(90)) + .expectNextMatches { it is ByteArray && String(it) == "recovered" } + .thenCancel() + .verify(Duration.ofSeconds(5)) + + // Verifies we re-subscribed (= sent slotSubscribe again on the same WS). + verify(ws, times(2)).subscribe(ChainRequest(topic, ListParams(param))) + } } From 14c0e13ba624759ad19c352f7ea56f691e2635ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:55:33 +0300 Subject: [PATCH 089/108] Bump org.mockito:mockito-inline from 4.0.0 to 5.2.0 (#850) Bumps [org.mockito:mockito-inline](https://github.com/mockito/mockito) from 4.0.0 to 5.2.0. - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v4.0.0...v5.2.0) --- updated-dependencies: - dependency-name: org.mockito:mockito-inline dependency-version: 5.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f4e620ce..abc0023db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -115,7 +115,7 @@ brave-ctx-slf4j = "io.zipkin.brave:brave-context-slf4j:6.3.0" auth0-jwt = "com.auth0:java-jwt:4.4.0" -mockito-inline = "org.mockito:mockito-inline:4.0.0" +mockito-inline = "org.mockito:mockito-inline:5.2.0" semver4j = "com.vdurmont:semver4j:3.1.0" From a215f77ea8b70d0fd1f94202c5b03697a6f8e250 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:55:51 +0300 Subject: [PATCH 090/108] Bump io.lettuce:lettuce-core from 5.2.2.RELEASE to 7.5.2.RELEASE (#849) Bumps [io.lettuce:lettuce-core](https://github.com/redis/lettuce) from 5.2.2.RELEASE to 7.5.2.RELEASE. - [Release notes](https://github.com/redis/lettuce/releases) - [Changelog](https://github.com/redis/lettuce/blob/7.5.2.RELEASE/RELEASE-NOTES.md) - [Commits](https://github.com/redis/lettuce/compare/5.2.2.RELEASE...7.5.2.RELEASE) --- updated-dependencies: - dependency-name: io.lettuce:lettuce-core dependency-version: 7.5.2.RELEASE dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index abc0023db..140fe5b83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,7 +59,7 @@ javax-annotations = "javax.annotation:javax.annotation-api:1.3.2" kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } -lettuce-core = "io.lettuce:lettuce-core:5.2.2.RELEASE" +lettuce-core = "io.lettuce:lettuce-core:7.5.2.RELEASE" prom-exporter-server = "io.prometheus:prometheus-metrics-exporter-httpserver:1.5.0" micrometer-registry-prometheus = "io.micrometer:micrometer-registry-prometheus:1.16.0" From f9244cf9abe4fbabee8d55bc7f28e408d232a6b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:55:59 +0300 Subject: [PATCH 091/108] Bump nl.jqno.equalsverifier:equalsverifier from 3.10.1 to 4.5 (#848) Bumps [nl.jqno.equalsverifier:equalsverifier](https://github.com/jqno/equalsverifier) from 3.10.1 to 4.5. - [Release notes](https://github.com/jqno/equalsverifier/releases) - [Changelog](https://github.com/jqno/equalsverifier/blob/main/CHANGELOG.md) - [Commits](https://github.com/jqno/equalsverifier/compare/equalsverifier-3.10.1...equalsverifier-4.5) --- updated-dependencies: - dependency-name: nl.jqno.equalsverifier:equalsverifier dependency-version: '4.5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 140fe5b83..e7bb1d3c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ commons-io = "commons-io:commons-io:2.6" cglib-nodep = "cglib:cglib-nodep:3.3.0" -equals-verifier = "nl.jqno.equalsverifier:equalsverifier:3.10.1" +equals-verifier = "nl.jqno.equalsverifier:equalsverifier:4.5" groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } From b33e439873a31016620f8d92a65dc012249185fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:57:40 +0300 Subject: [PATCH 092/108] Bump net.logstash.logback:logstash-logback-encoder from 7.2 to 9.0 (#843) Bumps [net.logstash.logback:logstash-logback-encoder](https://github.com/logfellow/logstash-logback-encoder) from 7.2 to 9.0. - [Release notes](https://github.com/logfellow/logstash-logback-encoder/releases) - [Commits](https://github.com/logfellow/logstash-logback-encoder/compare/logstash-logback-encoder-7.2...logstash-logback-encoder-9.0) --- updated-dependencies: - dependency-name: net.logstash.logback:logstash-logback-encoder dependency-version: '9.0' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7bb1d3c2..e6e394045 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ commons-codec = "commons-codec:commons-codec:1.20.0" bitcoinj = "org.bitcoinj:bitcoinj-core:0.15.8" -logstash-encoder = "net.logstash.logback:logstash-logback-encoder:7.2" +logstash-encoder = "net.logstash.logback:logstash-logback-encoder:9.0" janino = "org.codehaus.janino:janino:3.1.9" From 612231ef94e6b994732a7573c27807237d0f64b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:57:49 +0300 Subject: [PATCH 093/108] Bump org.yaml:snakeyaml from 1.24 to 2.6 (#847) Bumps [org.yaml:snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml) from 1.24 to 2.6. - [Commits](https://bitbucket.org/snakeyaml/snakeyaml/branches/compare/snakeyaml-2.6..snakeyaml-1.24) --- updated-dependencies: - dependency-name: org.yaml:snakeyaml dependency-version: '2.6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6e394045..2896e9d6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -96,7 +96,7 @@ reactor-test = { module = "io.projectreactor:reactor-test", version.ref = "react reactor-grpc-stub = "com.salesforce.servicelibs:reactor-grpc-stub:1.2.0" -snake-yaml = "org.yaml:snakeyaml:1.24" +snake-yaml = "org.yaml:snakeyaml:2.6" spockframework-core = "org.spockframework:spock-core:2.4-groovy-5.0" From afa254d3c0e80b887128e79246a3a55ae25389ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:58:26 +0300 Subject: [PATCH 094/108] Bump org.yaml:snakeyaml from 1.24 to 2.6 in /foundation (#840) Bumps [org.yaml:snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml) from 1.24 to 2.6. - [Commits](https://bitbucket.org/snakeyaml/snakeyaml/branches/compare/snakeyaml-2.6..snakeyaml-1.24) --- updated-dependencies: - dependency-name: org.yaml:snakeyaml dependency-version: '2.6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- foundation/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/build.gradle b/foundation/build.gradle index 46cfe7047..e2cc05d00 100644 --- a/foundation/build.gradle +++ b/foundation/build.gradle @@ -26,7 +26,7 @@ compileTestKotlin { } dependencies { - implementation 'org.yaml:snakeyaml:1.24' + implementation 'org.yaml:snakeyaml:2.6' testImplementation 'org.junit.jupiter:junit-jupiter:6.0.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } From ca08c8f47413be53a99e1d3c9d760fc51092408a Mon Sep 17 00:00:00 2001 From: EugeneDrpc Date: Mon, 18 May 2026 16:41:33 +0200 Subject: [PATCH 095/108] adi update (#854) --- foundation/src/main/resources/public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index d602a2716..96474849a 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit d602a2716952bc421d54bebf40b1340e62fc687a +Subproject commit 96474849abaeb8b5529ed77b4f42b6a3f6668a8b From 52beea3141fbb09978544572209a80d6c4185c34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 17:52:41 +0300 Subject: [PATCH 096/108] Bump the gradle-minor-and-patch group across 1 directory with 65 updates (#841) --- build.gradle | 2 +- gradle.properties | 2 +- gradle/libs.versions.toml | 72 +++++++-------- gradle/wrapper/gradle-wrapper.jar | Bin 59536 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 50 +++++++---- gradlew.bat | 59 ++++++------- .../upstream/bitcoin/AddressActiveCheck.kt | 2 +- .../upstream/bitcoin/BitcoinReader.kt | 2 +- .../upstream/bitcoin/EsploraClient.kt | 2 +- .../upstream/bitcoin/EsploraUnspentReader.kt | 2 +- .../upstream/bitcoin/LocalCallRouter.kt | 5 +- .../upstream/bitcoin/RemoteUnspentReader.kt | 2 +- .../upstream/bitcoin/RpcUnspentReader.kt | 2 +- .../upstream/bitcoin/UnspentReader.kt | 2 +- .../upstream/bitcoin/XpubAddresses.kt | 43 +++++---- .../ethereum/EthereumUpstreamValidator.kt | 82 +++++++++++------- .../upstream/ethereum/LogIndexValidator.kt | 19 ++-- .../dshackle/upstream/grpc/GrpcHead.kt | 18 ++-- .../upstream/bitcoin/EsploraClientSpec.groovy | 12 ++- .../bitcoin/RpcUnspentReaderSpec.groovy | 9 +- .../upstream/bitcoin/XpubAddressesSpec.groovy | 13 ++- 22 files changed, 221 insertions(+), 184 deletions(-) diff --git a/build.gradle b/build.gradle index cf0ef3539..66ec98170 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ buildscript { gradlePluginPortal() } dependencies { - classpath 'com.google.protobuf:protobuf-gradle-plugin:0.9.1' + classpath 'com.google.protobuf:protobuf-gradle-plugin:0.10.0' } } diff --git a/gradle.properties b/gradle.properties index fd99adb0b..eacee52bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ kotlin.code.style=official grpcVersion=1.49.2 -reactiveGrpcVersion=1.2.0 +reactiveGrpcVersion=1.2.4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2896e9d6f..217a3f164 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,33 @@ [versions] -groovy = "5.0.3" -protoc = "4.33.2" -jackson = "2.20.1" -grpc = "1.78.0" -spring-boot = "4.0.1" -reactor = "3.8.1" -netty = "4.2.9.Final" -netty-tcnative = "2.0.74.Final" -kotlin = "2.3.0" -httpcomponents = "5.5.1" +groovy = "5.0.6" +protoc = "4.34.1" +jackson = "2.21.3" +grpc = "1.81.0" +spring-boot = "4.0.6" +reactor = "3.8.5" +netty = "4.2.13.Final" +netty-tcnative = "2.0.77.Final" +kotlin = "2.3.21" +httpcomponents = "5.6.1" [libraries] -apache-commons-lang3 = "org.apache.commons:commons-lang3:3.9" -apache-commons-collections4 = "org.apache.commons:commons-collections4:4.3" +apache-commons-lang3 = "org.apache.commons:commons-lang3:3.20.0" +apache-commons-collections4 = "org.apache.commons:commons-collections4:4.5.0" apache-commons-math3 = "org.apache.commons:commons-math3:3.6.1" -commons-codec = "commons-codec:commons-codec:1.20.0" +commons-codec = "commons-codec:commons-codec:1.22.0" -bitcoinj = "org.bitcoinj:bitcoinj-core:0.15.8" +bitcoinj = "org.bitcoinj:bitcoinj-core:0.17.1" logstash-encoder = "net.logstash.logback:logstash-logback-encoder:9.0" -janino = "org.codehaus.janino:janino:3.1.9" +janino = "org.codehaus.janino:janino:3.1.12" -bouncycastle-prov = "org.bouncycastle:bcprov-jdk15on:1.61" -bouncycastle-pkix = 'org.bouncycastle:bcpkix-jdk15on:1.61' +bouncycastle-prov = "org.bouncycastle:bcprov-jdk15on:1.70" +bouncycastle-pkix = 'org.bouncycastle:bcpkix-jdk15on:1.70' caffeine = "com.github.ben-manes.caffeine:caffeine:2.8.5" -commons-io = "commons-io:commons-io:2.6" +commons-io = "commons-io:commons-io:2.22.0" cglib-nodep = "cglib:cglib-nodep:3.3.0" @@ -52,7 +52,7 @@ jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-dat jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jackson-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } -java-websocket = "org.java-websocket:Java-WebSocket:1.5.1" +java-websocket = "org.java-websocket:Java-WebSocket:1.6.0" javax-annotations = "javax.annotation:javax.annotation-api:1.3.2" @@ -61,13 +61,13 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = lettuce-core = "io.lettuce:lettuce-core:7.5.2.RELEASE" -prom-exporter-server = "io.prometheus:prometheus-metrics-exporter-httpserver:1.5.0" -micrometer-registry-prometheus = "io.micrometer:micrometer-registry-prometheus:1.16.0" -micrometer-ctx-prop = "io.micrometer:context-propagation:1.2.0" +prom-exporter-server = "io.prometheus:prometheus-metrics-exporter-httpserver:1.6.1" +micrometer-registry-prometheus = "io.micrometer:micrometer-registry-prometheus:1.16.5" +micrometer-ctx-prop = "io.micrometer:context-propagation:1.2.1" mockserver-netty = "org.mock-server:mockserver-netty:5.15.0" -mockito-kotlin = "org.mockito.kotlin:mockito-kotlin:6.1.0" +mockito-kotlin = "org.mockito.kotlin:mockito-kotlin:6.3.0" netty-common = { module = "io.netty:netty-common", version.ref = "netty" } netty-transport = { module = "io.netty:netty-transport", version.ref = "netty" } @@ -83,18 +83,18 @@ netty-tcnative-core = { module = "io.netty:netty-tcnative", version.ref = "netty netty-tcnative-boringssl = { module = "io.netty:netty-tcnative-boringssl-static", version.ref = "netty-tcnative"} netty-macos = { module = "io.netty:netty-resolver-dns-native-macos", version.ref = "netty" } -zeromq = "org.zeromq:jeromq:0.5.2" +zeromq = "org.zeromq:jeromq:0.6.0" -objgenesis = "org.objenesis:objenesis:3.1" +objgenesis = "org.objenesis:objenesis:3.5" reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" } reactor-micrometer = { module = "io.projectreactor:reactor-core-micrometer", version.ref = "reactor" } -reactor-netty = { module = "io.projectreactor.netty:reactor-netty", version = "1.3.1" } +reactor-netty = { module = "io.projectreactor.netty:reactor-netty", version = "1.3.5" } reactor-extra = { module = "io.projectreactor.addons:reactor-extra", version = "3.6.0" } -reactor-kotlin = { module = "io.projectreactor.kotlin:reactor-kotlin-extensions", version = "1.1.7" } +reactor-kotlin = { module = "io.projectreactor.kotlin:reactor-kotlin-extensions", version = "1.3.0" } reactor-test = { module = "io.projectreactor:reactor-test", version.ref = "reactor" } -reactor-grpc-stub = "com.salesforce.servicelibs:reactor-grpc-stub:1.2.0" +reactor-grpc-stub = "com.salesforce.servicelibs:reactor-grpc-stub:1.2.4" snake-yaml = "org.yaml:snakeyaml:2.6" @@ -105,15 +105,15 @@ spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot- spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } -testcontainers = "org.testcontainers:testcontainers:2.0.3" +testcontainers = "org.testcontainers:testcontainers:2.0.5" testcontainers-ganache = "io.github.ganchix:testcontainers-java-module-ganache:0.0.4" -assertj = "org.assertj:assertj-core:3.23.1" +assertj = "org.assertj:assertj-core:3.27.7" -brave-instrumentation-grpc = "io.zipkin.brave:brave-instrumentation-grpc:6.3.0" -brave-ctx-slf4j = "io.zipkin.brave:brave-context-slf4j:6.3.0" +brave-instrumentation-grpc = "io.zipkin.brave:brave-instrumentation-grpc:6.3.1" +brave-ctx-slf4j = "io.zipkin.brave:brave-context-slf4j:6.3.1" -auth0-jwt = "com.auth0:java-jwt:4.4.0" +auth0-jwt = "com.auth0:java-jwt:4.5.2" mockito-inline = "org.mockito:mockito-inline:5.2.0" @@ -135,8 +135,8 @@ bouncycastle = ["bouncycastle-pkix", "bouncycastle-prov"] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -jib = { id = "com.google.cloud.tools.jib", version = "3.5.2" } -spring = { id = "org.springframework.boot", version = "4.0.1" } +jib = { id = "com.google.cloud.tools.jib", version = "3.5.3" } +spring = { id = "org.springframework.boot", version = "4.0.6" } git = { id = "com.palantir.git-version", version = "5.0.0" } -protobuf = { id = "com.google.protobuf", version = "0.9.6" } +protobuf = { id = "com.google.protobuf", version = "0.10.0" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "11.6.0" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180f2ae8848c63b8b4dea2cb829da983f2fa..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 63e0e8303..4dcb8425b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c78733..b9bb139f7 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -133,22 +132,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -165,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -193,18 +198,27 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index ac1b06f93..aa5f10b06 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,19 +13,22 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,15 +43,15 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -56,34 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt index 5b65b3ed6..27166fc94 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt @@ -15,7 +15,7 @@ */ package io.emeraldpay.dshackle.upstream.bitcoin -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address import org.slf4j.LoggerFactory import reactor.core.publisher.Mono diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinReader.kt index 5a85eba30..14fb535cc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinReader.kt @@ -25,7 +25,7 @@ import io.emeraldpay.dshackle.upstream.Lifecycle import io.emeraldpay.dshackle.upstream.Selector import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent import io.emeraldpay.dshackle.upstream.rpcclient.ListParams -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address import org.slf4j.LoggerFactory import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.cast diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt index f9364dbae..eb9709d79 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt @@ -22,7 +22,7 @@ import io.netty.handler.codec.http.HttpHeaderNames import io.netty.handler.codec.http.HttpHeaders import io.netty.handler.ssl.SslContextBuilder import io.netty.resolver.DefaultAddressResolverGroup -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address import org.slf4j.LoggerFactory import reactor.core.publisher.Mono import reactor.netty.http.client.HttpClient diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraUnspentReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraUnspentReader.kt index 58d39d673..4575c2d6e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraUnspentReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraUnspentReader.kt @@ -18,7 +18,7 @@ package io.emeraldpay.dshackle.upstream.bitcoin import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address import org.slf4j.LoggerFactory import reactor.core.publisher.Mono import java.util.function.Function diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/LocalCallRouter.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/LocalCallRouter.kt index 5617eaccd..77c58c116 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/LocalCallRouter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/LocalCallRouter.kt @@ -26,7 +26,8 @@ import io.emeraldpay.dshackle.upstream.calls.CallMethods import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError import io.emeraldpay.dshackle.upstream.rpcclient.ListParams -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address +import org.bitcoinj.base.AddressParser import org.slf4j.LoggerFactory import reactor.core.publisher.Mono @@ -70,7 +71,7 @@ class LocalCallRouter( } val addresses = key.params.list[2] if (addresses is List<*> && addresses.size > 0) { - val address = addresses[0].toString().let { Address.fromString(null, it) } + val address = addresses[0].toString().let { AddressParser.getDefault().parseAddress(it) } return reader.listUnspent(address).map { val rpc = it.map(convertUnspent(address)) val json = Global.objectMapper.writeValueAsBytes(rpc) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RemoteUnspentReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RemoteUnspentReader.kt index 28e836b83..3331a03c1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RemoteUnspentReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RemoteUnspentReader.kt @@ -3,7 +3,7 @@ package io.emeraldpay.dshackle.upstream.bitcoin import io.emeraldpay.dshackle.upstream.Capability import io.emeraldpay.dshackle.upstream.Selector import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address import reactor.core.publisher.Mono class RemoteUnspentReader( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReader.kt index ecc53cd8b..3dca232bf 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReader.kt @@ -24,7 +24,7 @@ import io.emeraldpay.dshackle.upstream.Selector import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent import io.emeraldpay.dshackle.upstream.rpcclient.ListParams -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address import org.slf4j.LoggerFactory import reactor.core.publisher.Mono diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/UnspentReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/UnspentReader.kt index d922c5877..cb1c88cbd 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/UnspentReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/UnspentReader.kt @@ -17,6 +17,6 @@ package io.emeraldpay.dshackle.upstream.bitcoin import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent -import org.bitcoinj.core.Address +import org.bitcoinj.base.Address interface UnspentReader : Reader> diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt index 4f55f8767..64d5b3af1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt @@ -15,15 +15,16 @@ */ package io.emeraldpay.dshackle.upstream.bitcoin -import org.bitcoinj.core.Address -import org.bitcoinj.core.ECKey +import org.bitcoinj.base.Address +import org.bitcoinj.base.BitcoinNetwork +import org.bitcoinj.base.LegacyAddress +import org.bitcoinj.base.ScriptType +import org.bitcoinj.base.SegwitAddress import org.bitcoinj.core.NetworkParameters import org.bitcoinj.crypto.ChildNumber import org.bitcoinj.crypto.DeterministicKey +import org.bitcoinj.crypto.ECKey import org.bitcoinj.crypto.HDKeyDerivation -import org.bitcoinj.params.MainNetParams -import org.bitcoinj.params.TestNet3Params -import org.bitcoinj.script.Script import org.slf4j.LoggerFactory import reactor.core.publisher.Flux import reactor.util.function.Tuples @@ -35,8 +36,6 @@ open class XpubAddresses( companion object { private val log = LoggerFactory.getLogger(XpubAddresses::class.java) - private val MAINNET = MainNetParams() - private val TESTNET = TestNet3Params() private val INACTIVE_LIMIT = 20 } @@ -45,39 +44,45 @@ open class XpubAddresses( // https://electrum.readthedocs.io/en/latest/xpub_version_bytes.html // TODO doesn't support SH keys right now. should? val prefix = xpub.substring(0, 4) - val type: Script.ScriptType - val network: NetworkParameters + val type: ScriptType + val params: NetworkParameters when (prefix) { "xpub" -> { - type = Script.ScriptType.P2PKH - network = MAINNET + type = ScriptType.P2PKH + params = NetworkParameters.of(BitcoinNetwork.MAINNET) } "zpub" -> { - type = Script.ScriptType.P2WPKH - network = MAINNET + type = ScriptType.P2WPKH + params = NetworkParameters.of(BitcoinNetwork.MAINNET) } "tpub" -> { - type = Script.ScriptType.P2PKH - network = TESTNET + type = ScriptType.P2PKH + params = NetworkParameters.of(BitcoinNetwork.TESTNET) } "vpub" -> { - type = Script.ScriptType.P2WPKH - network = TESTNET + type = ScriptType.P2WPKH + params = NetworkParameters.of(BitcoinNetwork.TESTNET) } else -> return Flux.error(IllegalArgumentException("Unsupported type: $prefix")) } val key: DeterministicKey try { - key = DeterministicKey.deserializeB58(xpub, network) + key = DeterministicKey.deserializeB58(xpub, params) } catch (t: Throwable) { return Flux.error(t) } return Flux.range(start, limit) .map { HDKeyDerivation.deriveChildKey(key, ChildNumber(it, false)) } - .map { Address.fromKey(network, ECKey.fromPublicOnly(it.pubKeyPoint), type) } + .map { addressFromKey(params, ECKey.fromPublicOnly(it.pubKey), type) } + } + + private fun addressFromKey(params: NetworkParameters, key: ECKey, type: ScriptType): Address = when (type) { + ScriptType.P2PKH -> LegacyAddress.fromKey(params, key) + ScriptType.P2WPKH -> SegwitAddress.fromKey(params, key) + else -> throw IllegalArgumentException("Unsupported script type: $type") } open fun activeAddresses(xpub: String, start: Int, limit: Int): Flux
{ diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidator.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidator.kt index 4921d2367..a2ecb61e4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamValidator.kt @@ -34,7 +34,7 @@ import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.slf4j.Logger import org.slf4j.LoggerFactory import reactor.core.publisher.Mono -import reactor.kotlin.extra.retry.retryRandomBackoff +import reactor.util.retry.Retry import java.math.BigInteger import java.time.Duration import java.util.concurrent.TimeoutException @@ -75,12 +75,17 @@ abstract class AbstractCallLimitValidator( Mono.fromCallable { log.error("No response for eth_call limit check from ${upstream.getId()}") } .then(Mono.error(TimeoutException("Validation timeout for call limit"))), ) - .retryRandomBackoff(3, Duration.ofMillis(100), Duration.ofMillis(500)) { ctx -> - log.warn( - "error during validateCallLimit for ${upstream.getId()}, iteration ${ctx.iteration()}, " + - "message ${ctx.exception().message}", - ) - } + .retryWhen( + Retry.backoff(3, Duration.ofMillis(100)) + .maxBackoff(Duration.ofMillis(500)) + .jitter(1.0) + .doBeforeRetry { signal -> + log.warn( + "error during validateCallLimit for ${upstream.getId()}, iteration ${signal.totalRetries() + 1}, " + + "message ${signal.failure().message}", + ) + }, + ) .onErrorReturn(ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR) } @@ -164,14 +169,19 @@ class ChainIdValidator( private fun chainId(): Mono { return validatorReader.get() .read(ChainRequest("eth_chainId", ListParams())) - .retryRandomBackoff(3, Duration.ofMillis(100), Duration.ofMillis(500)) { ctx -> - log.warn( - "error during chainId retrieving for {}, iteration {}, reason - {}", - upstream.getId(), - ctx.iteration(), - ctx.exception().message, - ) - } + .retryWhen( + Retry.backoff(3, Duration.ofMillis(100)) + .maxBackoff(Duration.ofMillis(500)) + .jitter(1.0) + .doBeforeRetry { signal -> + log.warn( + "error during chainId retrieving for {}, iteration {}, reason - {}", + upstream.getId(), + signal.totalRetries() + 1, + signal.failure().message, + ) + }, + ) .doOnError { log.error("Error during execution 'eth_chainId' - {} for {}", it.message, upstream.getId()) } .flatMap(ChainResponse::requireStringResult) } @@ -179,14 +189,19 @@ class ChainIdValidator( private fun netVersion(): Mono { return validatorReader.get() .read(ChainRequest("net_version", ListParams())) - .retryRandomBackoff(3, Duration.ofMillis(100), Duration.ofMillis(500)) { ctx -> - log.warn( - "error during netVersion retrieving for {}, iteration {}, reason - {}", - upstream.getId(), - ctx.iteration(), - ctx.exception().message, - ) - } + .retryWhen( + Retry.backoff(3, Duration.ofMillis(100)) + .maxBackoff(Duration.ofMillis(500)) + .jitter(1.0) + .doBeforeRetry { signal -> + log.warn( + "error during netVersion retrieving for {}, iteration {}, reason - {}", + upstream.getId(), + signal.totalRetries() + 1, + signal.failure().message, + ) + }, + ) .doOnError { log.error("Error during execution 'net_version' - {} for {}", it.message, upstream.getId()) } .flatMap(ChainResponse::requireStringResult) } @@ -264,14 +279,19 @@ class ErigonBuggedValidator( private fun isErigon(): Mono = upstream.getIngressReader() .read(ChainRequest("web3_clientVersion", ListParams())) - .retryRandomBackoff(3, Duration.ofMillis(100), Duration.ofMillis(500)) { ctx -> - log.warn( - "error during clientVersion retrieving for {}, iteration {}, reason - {}", - upstream.getId(), - ctx.iteration(), - ctx.exception().message, - ) - } + .retryWhen( + Retry.backoff(3, Duration.ofMillis(100)) + .maxBackoff(Duration.ofMillis(500)) + .jitter(1.0) + .doBeforeRetry { signal -> + log.warn( + "error during clientVersion retrieving for {}, iteration {}, reason - {}", + upstream.getId(), + signal.totalRetries() + 1, + signal.failure().message, + ) + }, + ) .flatMap(ChainResponse::requireStringResult) .map { it.lowercase().contains("erigon") } .doOnError { log.error("Error during execution 'web3_clientVersion' - {} for {}", it.message, upstream.getId()) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LogIndexValidator.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LogIndexValidator.kt index d3c1bb338..bea004d3a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LogIndexValidator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LogIndexValidator.kt @@ -26,7 +26,7 @@ import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.slf4j.Logger import org.slf4j.LoggerFactory import reactor.core.publisher.Mono -import reactor.kotlin.extra.retry.retryRandomBackoff +import reactor.util.retry.Retry import java.time.Duration import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference @@ -84,12 +84,17 @@ class LogIndexValidator( } } .timeout(Duration.ofSeconds(15)) - .retryRandomBackoff(2, Duration.ofMillis(200), Duration.ofMillis(1000)) { ctx -> - log.debug( - "Retry logIndex validation for ${upstream.getId()}, iteration ${ctx.iteration()}, " + - "error: ${ctx.exception().message}", - ) - } + .retryWhen( + Retry.backoff(2, Duration.ofMillis(200)) + .maxBackoff(Duration.ofMillis(1000)) + .jitter(1.0) + .doBeforeRetry { signal -> + log.debug( + "Retry logIndex validation for ${upstream.getId()}, iteration ${signal.totalRetries() + 1}, " + + "error: ${signal.failure().message}", + ) + }, + ) .onErrorResume { err -> log.warn("Error during logIndex validation for ${upstream.getId()}: ${err.message}") // In case of error, return last known state to avoid false positives diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcHead.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcHead.kt index 493c24863..2acd0b75b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcHead.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcHead.kt @@ -35,7 +35,7 @@ import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.core.publisher.Sinks import reactor.core.scheduler.Scheduler -import reactor.kotlin.extra.retry.retryExponentialBackoff +import reactor.util.retry.Retry import java.time.Duration import java.util.function.Function @@ -108,14 +108,14 @@ class GrpcHead( blocks = blocks.doOnNext { log.trace("Received block ${it.height}") - }.retryExponentialBackoff( - Long.MAX_VALUE, - Duration.ofMillis(100), - Duration.ofSeconds(60), - true, - ) { - log.debug("Retry grpc head connection ${parent.getId()}") - } + }.retryWhen( + Retry.backoff(Long.MAX_VALUE, Duration.ofMillis(100)) + .maxBackoff(Duration.ofSeconds(60)) + .jitter(0.5) + .doBeforeRetry { + log.debug("Retry grpc head connection ${parent.getId()}") + }, + ) headSubscription = super.follow(blocks) } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy index bc7af0f0b..5975f71b5 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy @@ -1,7 +1,7 @@ package io.emeraldpay.dshackle.upstream.bitcoin import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent -import org.bitcoinj.core.Address +import org.bitcoinj.base.AddressParser /** * Copyright (c) 2020 EmeraldPay, Inc @@ -19,8 +19,6 @@ import org.bitcoinj.core.Address * limitations under the License. */ -import org.bitcoinj.params.MainNetParams -import org.bitcoinj.params.TestNet3Params import org.mockserver.integration.ClientAndServer import org.mockserver.model.HttpRequest import org.mockserver.model.HttpResponse @@ -53,7 +51,7 @@ class EsploraClientSpec extends Specification { ) def client = new EsploraClient(new URI("http://localhost:${mockServer.port}"), null, null) when: - def act = client.getUtxo(Address.fromString(new MainNetParams(), "35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY")) + def act = client.getUtxo(AddressParser.getDefault().parseAddress("35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY")) then: StepVerifier.create(act) @@ -74,7 +72,7 @@ class EsploraClientSpec extends Specification { .verify(Duration.ofSeconds(3)) when: - def actTotal = client.getUtxo(Address.fromString(new MainNetParams(), "35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY")) + def actTotal = client.getUtxo(AddressParser.getDefault().parseAddress("35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY")) .block() .sum { it.value } @@ -94,7 +92,7 @@ class EsploraClientSpec extends Specification { ) def client = new EsploraClient(new URI("http://localhost:${mockServer.port}"), null, null) when: - def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) + def act = client.getTransactions(AddressParser.getDefault().parseAddress("tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) then: StepVerifier.create(act) @@ -124,7 +122,7 @@ class EsploraClientSpec extends Specification { ) def client = new EsploraClient(new URI("http://localhost:${mockServer.port}"), null, null) when: - def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) + def act = client.getTransactions(AddressParser.getDefault().parseAddress("tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) then: StepVerifier.create(act) diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReaderSpec.groovy index 578ad8321..c80f238a6 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/RpcUnspentReaderSpec.groovy @@ -19,8 +19,7 @@ import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.rpcclient.ListParams -import org.bitcoinj.core.Address -import org.bitcoinj.params.MainNetParams +import org.bitcoinj.base.AddressParser import reactor.core.publisher.Mono import spock.lang.Specification @@ -38,7 +37,7 @@ class RpcUnspentReaderSpec extends Specification { def reader = new RpcUnspentReader(upstreams) when: - def act = reader.read(Address.fromString(new MainNetParams(), "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")).block() + def act = reader.read(AddressParser.getDefault().parseAddress("1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")).block() then: // cat src/test/resources/bitcoin/unspent-one-addr.json | jq '. | length' @@ -72,7 +71,7 @@ class RpcUnspentReaderSpec extends Specification { def reader = new RpcUnspentReader(upstreams) when: - def act = reader.read(Address.fromString(new MainNetParams(), "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP")).block() + def act = reader.read(AddressParser.getDefault().parseAddress("35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP")).block() then: // cat src/test/resources/bitcoin/unspent-two-addr.json | jq '[.[] | select(.address == "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP")] | length' @@ -107,7 +106,7 @@ class RpcUnspentReaderSpec extends Specification { def reader = new RpcUnspentReader(upstreams) when: - def act = reader.read(Address.fromString(new MainNetParams(), "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")).block() + def act = reader.read(AddressParser.getDefault().parseAddress("1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")).block() then: // cat src/test/resources/bitcoin/unspent-two-addr.json | jq '[.[] | select(.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")] | length' diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy index c3f6d096f..8ddd648d8 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy @@ -15,8 +15,7 @@ */ package io.emeraldpay.dshackle.upstream.bitcoin -import org.bitcoinj.core.Address -import org.bitcoinj.params.MainNetParams +import org.bitcoinj.base.AddressParser import reactor.core.publisher.Mono import reactor.test.StepVerifier import spock.lang.Specification @@ -119,7 +118,7 @@ class XpubAddressesSpec extends Specification { def "list when only fist is active"() { setup: AddressActiveCheck check = Mock(AddressActiveCheck) { - 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qaexx257l7sgm62szw2ulj6n2v99t5ph9ekkul3")) >> Mono.just(true) + 1 * isActive(AddressParser.getDefault().parseAddress("bc1qaexx257l7sgm62szw2ulj6n2v99t5ph9ekkul3")) >> Mono.just(true) 20 * isActive(_) >> Mono.just(false) } XpubAddresses addresses = new XpubAddresses(check) @@ -143,7 +142,7 @@ class XpubAddressesSpec extends Specification { def "list when only 3rd is active"() { setup: AddressActiveCheck check = Mock(AddressActiveCheck) { - 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true) + 1 * isActive(AddressParser.getDefault().parseAddress("bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true) // 2 times before, 20 times after 22 * isActive(_) >> Mono.just(false) } @@ -169,11 +168,11 @@ class XpubAddressesSpec extends Specification { setup: AddressActiveCheck check = Mock(AddressActiveCheck) { // 2 - 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true) + 1 * isActive(AddressParser.getDefault().parseAddress("bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true) // 11 - 1 * isActive(Address.fromString(MainNetParams.get(), "bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8")) >> Mono.just(true) + 1 * isActive(AddressParser.getDefault().parseAddress("bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8")) >> Mono.just(true) // 22 - 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qf7m2rrmrksj34vhgxmm04y43dlj7c5f58f8ku6")) >> Mono.just(true) + 1 * isActive(AddressParser.getDefault().parseAddress("bc1qf7m2rrmrksj34vhgxmm04y43dlj7c5f58f8ku6")) >> Mono.just(true) // 0..1 = 2 // + 3..10 = 8 // + 12..21 = 10 From e8977066d43804620487f445788653e97e81cfe8 Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Mon, 18 May 2026 18:22:46 +0300 Subject: [PATCH 097/108] chore: remove unused dshackle-cli Node.js tool (#855) --- dshackle-cli/README.md | 36 -- dshackle-cli/bin/dshackle-cli-tool | 4 - dshackle-cli/package-lock.json | 910 ----------------------------- dshackle-cli/package.json | 28 - dshackle-cli/src/cli.js | 144 ----- dshackle-cli/src/grpc-clent.js | 53 -- 6 files changed, 1175 deletions(-) delete mode 100644 dshackle-cli/README.md delete mode 100755 dshackle-cli/bin/dshackle-cli-tool delete mode 100644 dshackle-cli/package-lock.json delete mode 100644 dshackle-cli/package.json delete mode 100644 dshackle-cli/src/cli.js delete mode 100644 dshackle-cli/src/grpc-clent.js diff --git a/dshackle-cli/README.md b/dshackle-cli/README.md deleted file mode 100644 index b43b6f945..000000000 --- a/dshackle-cli/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# DSHACKLE CLI - -A CLI tool to verify the [dshackle](https://github.com/emeraldpay/dshackle) installation. - -## Usage -``` -npx dshackle-cli-tool [-p|--print][--ca][--cert][--key] -``` - -### Options -```-p | --print``` will print the describe response as is - -### TLS -```--ca``` the root certificate data - -```--cert``` the client certificate key chain, if available -```--key``` the client certificate private key, if available - -## Example - -``` -> dshackle-cli-tool localhost:2449 -Connecting to: localhost:2449... -Connected to localhost:2449 -CHAIN_ETHEREUM -> AVAIL_OK -CHAIN_KOVAN -> AVAIL_OK -``` - -With TLS -``` ->dshackle-cli % dshackle-cli-tool --ca ./../out/ca.myhost.dev.crt --cert ./../out/client_1.crt --key ./../out/client_1.key 127.0.0.1:2450 -Using TLS -Connecting to: 127.0.0.1:2450... -Connected to 127.0.0.1:2450 -CHAIN_ETHEREUM -> AVAIL_OK -``` \ No newline at end of file diff --git a/dshackle-cli/bin/dshackle-cli-tool b/dshackle-cli/bin/dshackle-cli-tool deleted file mode 100755 index c2ecc4bba..000000000 --- a/dshackle-cli/bin/dshackle-cli-tool +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node - -require = require('esm')(module /*, options*/); -require('../src/cli').cli(process.argv); \ No newline at end of file diff --git a/dshackle-cli/package-lock.json b/dshackle-cli/package-lock.json deleted file mode 100644 index 23c555c49..000000000 --- a/dshackle-cli/package-lock.json +++ /dev/null @@ -1,910 +0,0 @@ -{ - "name": "dshackle-cli-tool", - "version": "1.0.7", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "dshackle-cli-tool", - "version": "1.0.7", - "license": "MIT", - "dependencies": { - "@grpc/grpc-js": "^1.6.12", - "@grpc/proto-loader": "^0.7.2", - "arg": "^5.0.2", - "cli-color": "^2.0.3", - "esm": "^3.2.25" - }, - "bin": { - "dshackle-cli-tool": "bin/dshackle-cli-tool" - } - }, - "node_modules/@grpc/grpc-js": { - "version": "1.6.12", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.6.12.tgz", - "integrity": "sha512-JmvQ03OTSpVd9JTlj/K3IWHSz4Gk/JMLUTtW7Zb0KvO1LcOYGATh5cNuRYzCAeDR3O8wq+q8FZe97eO9MBrkUw==", - "dependencies": { - "@grpc/proto-loader": "^0.7.0", - "@types/node": ">=12.12.47" - }, - "engines": { - "node": "^8.13.0 || >=10.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.2.tgz", - "integrity": "sha512-jCdyLIT/tdQ1zhrbTQnJNK5nbDf0GoBpy5jVNywBzzMDF+Vs6uEaHnfz46dMtDxkvwrF2hzk5Z67goliceH0sA==", - "dependencies": { - "@types/long": "^4.0.1", - "lodash.camelcase": "^4.3.0", - "long": "^4.0.0", - "protobufjs": "^7.0.0", - "yargs": "^16.2.0" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, - "node_modules/@types/node": { - "version": "18.7.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.15.tgz", - "integrity": "sha512-XnjpaI8Bgc3eBag2Aw4t2Uj/49lLBSStHWfqKvIuXD7FIrZyMLWp8KuAFHAqxMZYTF9l08N1ctUn9YNybZJVmQ==" - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, - "node_modules/cli-color": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.3.tgz", - "integrity": "sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.61", - "es6-iterator": "^2.0.3", - "memoizee": "^0.4.15", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dependencies": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "node_modules/es5-ext": { - "version": "0.10.62", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dependencies": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/ext/node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" - }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" - }, - "node_modules/lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "dependencies": { - "es5-ext": "~0.10.2" - } - }, - "node_modules/memoizee": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", - "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.53", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "node_modules/protobufjs": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.1.0.tgz", - "integrity": "sha512-rCuxKlh0UQKSMjrpIcTLbR5TtGQ52cgs1a5nUoPBAKOccdPblN67BJtjrbtudUJK6HmBvUdsmymyYOzO7lxZEA==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/protobufjs/node_modules/long": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", - "integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/timers-ext": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", - "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", - "dependencies": { - "es5-ext": "~0.10.46", - "next-tick": "1" - } - }, - "node_modules/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - } - }, - "dependencies": { - "@grpc/grpc-js": { - "version": "1.6.12", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.6.12.tgz", - "integrity": "sha512-JmvQ03OTSpVd9JTlj/K3IWHSz4Gk/JMLUTtW7Zb0KvO1LcOYGATh5cNuRYzCAeDR3O8wq+q8FZe97eO9MBrkUw==", - "requires": { - "@grpc/proto-loader": "^0.7.0", - "@types/node": ">=12.12.47" - } - }, - "@grpc/proto-loader": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.2.tgz", - "integrity": "sha512-jCdyLIT/tdQ1zhrbTQnJNK5nbDf0GoBpy5jVNywBzzMDF+Vs6uEaHnfz46dMtDxkvwrF2hzk5Z67goliceH0sA==", - "requires": { - "@types/long": "^4.0.1", - "lodash.camelcase": "^4.3.0", - "long": "^4.0.0", - "protobufjs": "^7.0.0", - "yargs": "^16.2.0" - } - }, - "@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "requires": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" - }, - "@types/node": { - "version": "18.7.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.15.tgz", - "integrity": "sha512-XnjpaI8Bgc3eBag2Aw4t2Uj/49lLBSStHWfqKvIuXD7FIrZyMLWp8KuAFHAqxMZYTF9l08N1ctUn9YNybZJVmQ==" - }, - "arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, - "cli-color": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.3.tgz", - "integrity": "sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ==", - "requires": { - "d": "^1.0.1", - "es5-ext": "^0.10.61", - "es6-iterator": "^2.0.3", - "memoizee": "^0.4.15", - "timers-ext": "^0.1.7" - } - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "es5-ext": { - "version": "0.10.62", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", - "requires": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "requires": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "requires": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" - }, - "esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==" - }, - "event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "requires": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "requires": { - "type": "^2.7.2" - }, - "dependencies": { - "type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - } - } - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" - }, - "lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" - }, - "long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" - }, - "lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "requires": { - "es5-ext": "~0.10.2" - } - }, - "memoizee": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", - "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", - "requires": { - "d": "^1.0.1", - "es5-ext": "^0.10.53", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - } - }, - "next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "protobufjs": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.1.0.tgz", - "integrity": "sha512-rCuxKlh0UQKSMjrpIcTLbR5TtGQ52cgs1a5nUoPBAKOccdPblN67BJtjrbtudUJK6HmBvUdsmymyYOzO7lxZEA==", - "requires": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "dependencies": { - "long": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", - "integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==" - } - } - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" - }, - "timers-ext": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", - "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", - "requires": { - "es5-ext": "~0.10.46", - "next-tick": "1" - } - }, - "type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" - } - } -} diff --git a/dshackle-cli/package.json b/dshackle-cli/package.json deleted file mode 100644 index 495305f4e..000000000 --- a/dshackle-cli/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "dshackle-cli-tool", - "version": "1.0.7", - "description": "", - "main": "src/index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "bin": { - "@mfomenkov/dshackle-cli-tool": "bin/dshackle-cli-tool", - "dshackle-cli-tool": "bin/dshackle-cli-tool" - }, - "keywords": [], - "author": "", - "license": "MIT", - "dependencies": { - "@grpc/grpc-js": "^1.6.12", - "@grpc/proto-loader": "^0.7.2", - "arg": "^5.0.2", - "cli-color": "^2.0.3", - "esm": "^3.2.25" - }, - "files": [ - "bin/", - "src/", - "grpc/" - ] -} diff --git a/dshackle-cli/src/cli.js b/dshackle-cli/src/cli.js deleted file mode 100644 index b0eee8966..000000000 --- a/dshackle-cli/src/cli.js +++ /dev/null @@ -1,144 +0,0 @@ -import {describe, connect, nativeCall} from "./grpc-clent"; -import arg from 'arg'; -import clc from "cli-color"; -import util from "util"; - -const path = require('path') -const protoLoader = require("@grpc/proto-loader"); -const grpc = require("@grpc/grpc-js"); - -const options = { - keepCase: true, - longs: String, - enums: String, - defaults: true, - oneofs: true, -}; - -const PROTO_PATH = path.join(__dirname, "../../emerald-grpc/proto/blockchain.proto"); -const packageDefinition = protoLoader.loadSync(PROTO_PATH, options); -const emerald = grpc.loadPackageDefinition(packageDefinition).emerald - -export function cli(args) { - let opts = parseArgumentsIntoOptions(args); - if (!opts.url) { - console.log("Err: URL not specified!!!") - return - } - - const chains = mapChains() - - const client = connect(opts.url, opts.ca, opts.cert, opts.key, emerald) - describe(client, (error, response) => { - if (error) { - console.error(clc.red('Connection to ' + opts.url + ' failed: ' + error.message)); - return - } - console.log(clc.green('Connected to ', opts.url)); - if (opts.print) { - console.log(util.inspect(response, false, null, true /* enable colors */)); - } - processDescribe(client, response, opts.testRun, chains) - }) -} - -function processDescribe(client, response, testRun, chains) { - let promises = [] - let statuses = new Map() - - response.chains.forEach((item) => { - const state = item.status.availability - const chain = item.status.chain - let status = { - state: 'AVAIL_UNKNOWN', - grpc: clc.yellow('UNKNOWN'), - failed: false - } - - switch (state) { - case 'AVAIL_OK': - status.state = clc.green(state); - break - case 'AVAIL_UNKNOWN': - case 'AVAIL_UNAVAILABLE': - status.state = clc.red(state); - break - default: - status.state = clc.yellow(state); - } - - if (state === 'AVAIL_OK') { - promises.push(nativeCall(client, chains.get(chain), chain)) - } else { - status.failed = true - } - statuses.set(chain, status) - }) - - Promise.all(promises).then(responses => { - responses.forEach((resp) => { - let status = statuses.get(resp.chain) - if (resp.error) { - status.failed = true - status.grpc = clc.red(resp.error.message) - } else { - if (resp.payload.succeed) { - status.grpc = clc.green('OK') - } else { - status.grpc = clc.red('FAILED') - status.failed = true - } - } - }) - - let hasError = false - statuses.forEach((status, chain) => { - printState(chain, status) - if (status.failed) { - hasError = true - } - }) - - if (hasError && testRun) { - process.exit(1) - } - }) -} - -function mapChains() { - return new Map( - emerald.ChainRef.type.value.map(obj => { - return [obj.name, obj.number] - }) - ) -} - -function printState(chain, status) { - console.log(chain + ' -> ' + "state: " + clc.bold(status.state) + " gRPC: " + clc.bold(status.grpc)) -} - -function parseArgumentsIntoOptions(rawArgs) { - const args = arg( - { - '--print': Boolean, - '--test-run': Boolean, - '--ca': String, - '--cert': String, - '--key': String, - '-p': '--print' - }, - { - argv: rawArgs.slice(2), - } - ); - return { - print: args['--print'] || false, - testRun: args['--test-run'] || false, - url: args._[0], - ca: args['--ca'], - cert: args['--cert'], - key: args['--key'] - }; -} - - diff --git a/dshackle-cli/src/grpc-clent.js b/dshackle-cli/src/grpc-clent.js deleted file mode 100644 index b6ed75b1a..000000000 --- a/dshackle-cli/src/grpc-clent.js +++ /dev/null @@ -1,53 +0,0 @@ -const grpc = require("@grpc/grpc-js"); -const fs = require('fs'); - -var id = 100 - -export function connect(url, ca, cert, key, emerald) { - let credentials = grpc.credentials.createInsecure() - if (ca || cert || key) { - console.log("Using TLS") - credentials = grpc.credentials.createSsl( - ca ? fs.readFileSync(ca) : null, - key ? fs.readFileSync(key) : null, - cert ? fs.readFileSync(cert) : null - ); - } - console.log('Connecting to: ' + url + '...') - return new emerald.Blockchain( - url, - credentials - ); -} - -export function describe(client, handler) { - client.Describe({}, handler); -} - -export function nativeCall(client, chainCode, chain) { - return new Promise((resolve, reject) => { - const call = client.NativeCall({ - chain: chainCode, - items: [{ - id: id++, - method: "eth_getBalance", - payload: "WyIweDhEOTc2ODlDOTgxODg5MkI3MDBlMjdGMzE2Y2MzRTQxZTE3ZkJlYjkiLCAibGF0ZXN0Il0=" - }], - quorum: 1, - min_availability: 0 - }) - call.on('data', (item) => { - resolve(toResult(chain, item, null)) - }) - call.on('end', () => resolve(toResult(chain, null, null))) - call.on('error', (e) => reject(toResult(chain, null, e))) - }) -} - -function toResult(chain, obj, err) { - return { - chain: chain, - payload: obj, - error: err - } -} From c4502ce513591dc2d8e08248269f3b1752a919dc Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 19 May 2026 12:57:20 +0400 Subject: [PATCH 098/108] Add chains (#856) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index dcaddcdaa..07d567802 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit dcaddcdaabeb37a81154948e44f4d3fd33b1dcfb +Subproject commit 07d567802ba82b231aea4d4ec50a541b3eeabe2f diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 96474849a..70551a45b 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 96474849abaeb8b5529ed77b4f42b6a3f6668a8b +Subproject commit 70551a45be0ccb4fcd3ef64e098e8658afcd31ab From 4b5524d75c310bd62d641def47b18d59a1a82408 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 19 May 2026 14:19:53 +0400 Subject: [PATCH 099/108] Fix java flag (#858) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 66ec98170..1d728f365 100644 --- a/build.gradle +++ b/build.gradle @@ -183,7 +183,7 @@ jib { } container { creationTime = 'USE_CURRENT_TIMESTAMP' - jvmFlags = ['-XX:+UseG1GC', '-XX:+ExitOnOutOfMemoryError', '-Xms1024M', '-XX:NativeMemoryTracking=summary', '-XX:+UnlockDiagnosticVMOptions', '-XX:GCLockerRetryAllocationCount=10', '--enable-preview'] + jvmFlags = ['-XX:+UseG1GC', '-XX:+ExitOnOutOfMemoryError', '-Xms1024M', '-XX:NativeMemoryTracking=summary', '-XX:+UnlockDiagnosticVMOptions', '--enable-preview'] mainClass = 'io.emeraldpay.dshackle.StarterKt' args = [] ports = ['2448', '2449', '8545'] From 47dcf9a8322028057b72aac3dc602b361d398382 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Fri, 22 May 2026 15:23:02 +0400 Subject: [PATCH 100/108] Botanix (#859) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index 07d567802..d78eab978 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 07d567802ba82b231aea4d4ec50a541b3eeabe2f +Subproject commit d78eab978dcc1d52653834def52faf1601d7f844 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 70551a45b..8cb97b71e 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 70551a45be0ccb4fcd3ef64e098e8658afcd31ab +Subproject commit 8cb97b71ed27ea6e687fec232bf73c39f88195a6 From f93f350a29d43250a01392fe864246735bd43faa Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Tue, 26 May 2026 12:11:13 +0300 Subject: [PATCH 101/108] Add method label to upstream.rpc.conn metric (#863) --- .../dshackle/upstream/BasicHttpFactory.kt | 13 ++++++++----- .../io/emeraldpay/dshackle/upstream/HttpReader.kt | 2 +- .../dshackle/upstream/RequestMetrics.kt | 15 +++++++++++++-- .../upstream/ethereum/WsConnectionImpl.kt | 4 ++-- .../upstream/restclient/RestHttpReader.kt | 2 +- .../upstream/rpcclient/JsonRpcGrpcClient.kt | 2 +- .../upstream/rpcclient/JsonRpcHttpReader.kt | 2 +- 7 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt index fcb4c9f24..38abb0bb0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt @@ -34,11 +34,14 @@ class BasicHttpFactory( Tag.of("chain", chain.chainCode), ) val metrics = RequestMetrics( - Timer.builder("upstream.rpc.conn") - .description("Request time through a HTTP JSON RPC connection") - .tags(metricsTags) - .publishPercentileHistogram() - .register(Metrics.globalRegistry), + { method -> + Timer.builder("upstream.rpc.conn") + .description("Request time through a HTTP JSON RPC connection") + .tags(metricsTags) + .tag("method", method ?: "unknown") + .publishPercentileHistogram() + .register(Metrics.globalRegistry) + }, Counter.builder("upstream.rpc.fail") .description("Number of failures of HTTP JSON RPC requests") .tags(metricsTags) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt index b944ac3d2..28e19d750 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt @@ -101,7 +101,7 @@ abstract class HttpReader( open fun onStop() { if (metrics != null) { - Metrics.globalRegistry.remove(metrics.timer) + metrics.registeredTimers().forEach { Metrics.globalRegistry.remove(it) } Metrics.globalRegistry.remove(metrics.fails) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/RequestMetrics.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/RequestMetrics.kt index 75ef2f1f3..7caca387a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/RequestMetrics.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/RequestMetrics.kt @@ -17,9 +17,20 @@ package io.emeraldpay.dshackle.upstream import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.Timer +import java.util.concurrent.ConcurrentHashMap class RequestMetrics( - val timer: Timer, + private val timerFactory: (String?) -> Timer, val fails: Counter, val nettyMetricsEnabled: Boolean, -) +) { + private val timerCache = ConcurrentHashMap() + + constructor(timer: Timer, fails: Counter, nettyMetricsEnabled: Boolean) : + this({ _ -> timer }, fails, nettyMetricsEnabled) + + fun timer(method: String? = null): Timer = + timerCache.computeIfAbsent(method ?: "") { timerFactory(method) } + + fun registeredTimers(): Collection = timerCache.values +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt index f7bbfef7c..a7ec18e9d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt @@ -404,7 +404,7 @@ open class WsConnectionImpl( return Mono.from(onResponse.asMono()).or(failOnDisconnect) .doOnSubscribe { sendRpc(request) } .take(Defaults.timeout) - .doOnNext { requestMetrics?.timer?.record(System.nanoTime() - startTime, TimeUnit.NANOSECONDS) } + .doOnNext { requestMetrics?.timer()?.record(System.nanoTime() - startTime, TimeUnit.NANOSECONDS) } .doOnError { requestMetrics?.fails?.increment() } .map { it.copyWithId(ChainResponse.Id.from(originalId)) } .switchIfEmpty( @@ -424,7 +424,7 @@ open class WsConnectionImpl( it.close() Metrics.globalRegistry.remove(it) } - requestMetrics?.timer?.let { + requestMetrics?.registeredTimers()?.forEach { it.close() Metrics.globalRegistry.remove(it) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt index 0b4a90750..aa0b2da7a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt @@ -59,7 +59,7 @@ class RestHttpReader( .flatMap(this::execute) .doOnNext { if (startTime.isStarted) { - metrics?.timer?.record(startTime.nanoTime, TimeUnit.NANOSECONDS) + metrics?.timer(key.method)?.record(startTime.nanoTime, TimeUnit.NANOSECONDS) } } .handle { it, sink -> diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt index 4d0528ec0..641cf3dd0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt @@ -114,7 +114,7 @@ class JsonRpcGrpcClient( } .doOnNext { if (timer.isStarted) { - metrics?.timer?.record(timer.getTime(TimeUnit.NANOSECONDS), TimeUnit.NANOSECONDS) + metrics?.timer()?.record(timer.getTime(TimeUnit.NANOSECONDS), TimeUnit.NANOSECONDS) } } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt index 843a0be84..6fc78e520 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt @@ -92,7 +92,7 @@ class JsonRpcHttpReader( .flatMap(this@JsonRpcHttpReader::execute) .doOnNext { if (startTime.isStarted) { - metrics?.timer?.record(startTime.nanoTime, TimeUnit.NANOSECONDS) + metrics?.timer(key.method)?.record(startTime.nanoTime, TimeUnit.NANOSECONDS) } } .transform(asJsonRpcResponse(key)) From 998e1ac933e76b986e624244e7fc18ec28407b33 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 26 May 2026 17:13:31 +0400 Subject: [PATCH 102/108] newPedningTransactions validator (#864) --- .../dshackle/foundation/ChainOptions.kt | 6 +- .../dshackle/foundation/ChainOptionsReader.kt | 3 + .../dshackle/rpc/ChainEventMapper.kt | 3 +- .../io/emeraldpay/dshackle/rpc/Describe.kt | 3 +- .../dshackle/upstream/Capability.kt | 1 + .../ethereum/EthereumEgressSubscription.kt | 3 +- .../ethereum/PendingTransactionValidator.kt | 66 ++++++ .../upstream/generic/GenericUpstream.kt | 23 +- .../generic/connectors/GenericConnector.kt | 2 + .../generic/connectors/GenericRpcConnector.kt | 19 ++ .../generic/connectors/GenericWsConnector.kt | 21 ++ .../config/UpstreamsConfigReaderSpec.groovy | 2 +- .../dshackle/test/GenericConnectorMock.groovy | 7 + .../EthereumEgressSubscriptionSpec.groovy | 21 ++ .../PendingTransactionValidatorTest.kt | 211 ++++++++++++++++++ 15 files changed, 382 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidator.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidatorTest.kt 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 16470c705..6a1aae22a 100644 --- a/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptions.kt +++ b/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptions.kt @@ -20,6 +20,7 @@ class ChainOptions { val disableBoundValidation: Boolean = false, val valdateErigonBug: Boolean, val disableLogIndexValidation: Boolean = false, + val disablePendingTxValidation: Boolean = false, ) data class DefaultOptions( @@ -43,7 +44,8 @@ class ChainOptions { var disableLivenessSubscriptionValidation: Boolean? = null, var disableBoundValidation: Boolean? = null, var validateErigonBug: Boolean? = null, - var disableLogIndexValidation: Boolean? = null + var disableLogIndexValidation: Boolean? = null, + var disablePendingTxValidation: Boolean? = null ) { companion object { @JvmStatic @@ -76,6 +78,7 @@ class ChainOptions { copy.disableBoundValidation = overwrites.disableBoundValidation ?: this.disableBoundValidation copy.validateErigonBug = overwrites.validateErigonBug ?: this.validateErigonBug copy.disableLogIndexValidation = overwrites.disableLogIndexValidation ?: this.disableLogIndexValidation + copy.disablePendingTxValidation = overwrites.disablePendingTxValidation ?: this.disablePendingTxValidation return copy } @@ -97,6 +100,7 @@ class ChainOptions { this.disableBoundValidation ?: false, this.validateErigonBug ?: true, this.disableLogIndexValidation ?: false, + this.disablePendingTxValidation ?: false, ) } } 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 b2b951b83..7c7cfc335 100644 --- a/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptionsReader.kt +++ b/foundation/src/main/kotlin/io/emeraldpay/dshackle/foundation/ChainOptionsReader.kt @@ -61,6 +61,9 @@ class ChainOptionsReader : YamlConfigReader() { getValueAsBool(values, "disable-log-index-validation")?.let { options.disableLogIndexValidation = it } + getValueAsBool(values, "disable-pending-tx-validation")?.let { + options.disablePendingTxValidation = it + } return options } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/ChainEventMapper.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/ChainEventMapper.kt index d193a4ed3..a5d037935 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/ChainEventMapper.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/ChainEventMapper.kt @@ -32,11 +32,12 @@ class ChainEventMapper { } fun mapCapabilities(capabilities: Collection): BlockchainOuterClass.ChainEvent { - val caps = capabilities.map { + val caps = capabilities.filter { it != Capability.WS_PENDING_TX }.map { when (it) { Capability.RPC -> BlockchainOuterClass.Capabilities.CAP_CALLS Capability.BALANCE -> BlockchainOuterClass.Capabilities.CAP_BALANCE Capability.WS_HEAD -> BlockchainOuterClass.Capabilities.CAP_WS_HEAD + else -> null } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt index 8d5ab1bfc..27b79b622 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/Describe.kt @@ -63,11 +63,12 @@ class Describe( } capabilities.addAll(chainUpstreams.getCapabilities()) chainDescription.addAllCapabilities( - capabilities.map { + capabilities.filter { it != Capability.WS_PENDING_TX }.map { when (it) { Capability.RPC -> BlockchainOuterClass.Capabilities.CAP_CALLS Capability.BALANCE -> BlockchainOuterClass.Capabilities.CAP_BALANCE Capability.WS_HEAD -> BlockchainOuterClass.Capabilities.CAP_WS_HEAD + else -> null } }, ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Capability.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Capability.kt index 5e2189e59..949bb1d73 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Capability.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Capability.kt @@ -4,4 +4,5 @@ enum class Capability { RPC, BALANCE, WS_HEAD, + WS_PENDING_TX, } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscription.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscription.kt index 3c2a4bc8b..6cd99d93c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscription.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscription.kt @@ -11,6 +11,7 @@ import io.emeraldpay.dshackle.upstream.ethereum.domain.Address import io.emeraldpay.dshackle.upstream.ethereum.hex.Hex32 import io.emeraldpay.dshackle.upstream.ethereum.subscribe.ConnectLogs import io.emeraldpay.dshackle.upstream.ethereum.subscribe.ConnectNewHeads +import io.emeraldpay.dshackle.upstream.ethereum.subscribe.NoPendingTxes import io.emeraldpay.dshackle.upstream.ethereum.subscribe.PendingTxesSource import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.slf4j.LoggerFactory @@ -72,7 +73,7 @@ open class EthereumEgressSubscription( } else { listOf() } - return if (pendingTxesSource != null) { + return if (pendingTxesSource != null && pendingTxesSource !is NoPendingTxes && upstream.getCapabilities().contains(Capability.WS_PENDING_TX)) { subs.plus(listOf(METHOD_PENDING_TXES, METHOD_DRPC_PENDING_TXES)) } else { subs diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidator.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidator.kt new file mode 100644 index 000000000..259ed0dbe --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidator.kt @@ -0,0 +1,66 @@ +package io.emeraldpay.dshackle.upstream.ethereum + +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import org.slf4j.LoggerFactory +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Duration + +const val BASE_TX_LIMIT = 1000L + +interface PendingTransactionValidator { + fun pendingTxExists(): Flux +} + +class NoopPendingTransactionValidator : PendingTransactionValidator { + override fun pendingTxExists(): Flux { + return Flux.just(true) + } +} + +class PendingTransactionValidatorImpl( + private val upstreamId: String, + private val directReader: ChainReader, + private val interval: Duration, + private val txLimit: Long, +) : PendingTransactionValidator { + private val log = LoggerFactory.getLogger(this::class.java) + + override fun pendingTxExists(): Flux { + return Flux.interval( + Duration.ofSeconds(15), + interval, + ) + .flatMap { + directReader.read(ChainRequest("txpool_content", ListParams())) + .flatMap(ChainResponse::requireResult) + .map { + val node = Global.objectMapper.readTree(it) + val pendingTxsNode = node.get("pending") + val queuedTxsNode = node.get("queued") + + val pendingTxsCount = if (pendingTxsNode != null) { + pendingTxsNode.fieldNames().asSequence().toList().size + } else { + 0 + } + val queuedTxsCount = if (queuedTxsNode != null) { + queuedTxsNode.fieldNames().asSequence().toList().size + } else { + 0 + } + + ((pendingTxsCount + queuedTxsCount) >= txLimit) + } + .timeout(Duration.ofSeconds(30)) + .onErrorResume { + log.error("unable to read txs from txpool_content of upstream {}", upstreamId, it) + Mono.just(false) + } + } + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt index c539d2615..d5c5ddb35 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt @@ -110,6 +110,8 @@ open class GenericUpstream( private val settingsDetectorSubscription = AtomicReference() private val hasLiveSubscriptionHead: AtomicBoolean = AtomicBoolean(getOptions().disableLivenessSubscriptionValidation) + private val hasPendingTxs = AtomicBoolean(getOptions().disablePendingTxValidation) + protected val connector: GenericConnector = connectorFactory.create(this, chain) .also { upConnector -> Gauge.builder("upstream_head", upConnector.getHead()) { @@ -120,6 +122,7 @@ open class GenericUpstream( .register(Metrics.globalRegistry) } private val livenessSubscription = AtomicReference() + private val pendingTxSubscription = AtomicReference() private val settingsDetector = upstreamSettingsDetectorBuilder(chain, this) private var rpcMethodsDetector: UpstreamRpcMethodsDetector? = null @@ -150,11 +153,14 @@ open class GenericUpstream( // outdated, looks like applicable only for bitcoin and our ws_head trick override fun getCapabilities(): Set { - return if (hasLiveSubscriptionHead.get()) { - setOf(Capability.RPC, Capability.BALANCE, Capability.WS_HEAD) - } else { - setOf(Capability.RPC, Capability.BALANCE) + val caps = mutableSetOf(Capability.RPC, Capability.BALANCE) + if (hasLiveSubscriptionHead.get()) { + caps.add(Capability.WS_HEAD) } + if (hasPendingTxs.get()) { + caps.add(Capability.WS_PENDING_TX) + } + return caps } override fun isGrpc(): Boolean { @@ -358,6 +364,14 @@ open class GenericUpstream( ), ) } + if (!getOptions().disablePendingTxValidation) { + pendingTxSubscription.set( + connector.pendingTxEvents().subscribe { + hasPendingTxs.set(it) + sendUpstreamStateEvent(UPDATED) + }, + ) + } detectSettings() if (!getOptions().disableBoundValidation) { @@ -380,6 +394,7 @@ open class GenericUpstream( lowerBlockDetectorSubscription.getAndSet(null)?.dispose() finalizationDetectorSubscription.getAndSet(null)?.dispose() settingsDetectorSubscription.getAndSet(null)?.dispose() + pendingTxSubscription.getAndSet(null)?.dispose() connector.getHead().stop() } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnector.kt index 9d58c153b..bb12a6b5d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericConnector.kt @@ -15,4 +15,6 @@ interface GenericConnector : Lifecycle { fun getIngressReader(): ChainReader fun getIngressSubscription(): IngressSubscription + + fun pendingTxEvents(): Flux } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericRpcConnector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericRpcConnector.kt index 7e0a60b12..d8a5be096 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericRpcConnector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericRpcConnector.kt @@ -13,11 +13,15 @@ import io.emeraldpay.dshackle.upstream.Lifecycle import io.emeraldpay.dshackle.upstream.MergedHead import io.emeraldpay.dshackle.upstream.NoIngressSubscription import io.emeraldpay.dshackle.upstream.ethereum.AlwaysHeadLivenessValidator +import io.emeraldpay.dshackle.upstream.ethereum.BASE_TX_LIMIT import io.emeraldpay.dshackle.upstream.ethereum.GenericWsHead import io.emeraldpay.dshackle.upstream.ethereum.HeadLivenessState import io.emeraldpay.dshackle.upstream.ethereum.HeadLivenessValidator import io.emeraldpay.dshackle.upstream.ethereum.HeadLivenessValidatorImpl import io.emeraldpay.dshackle.upstream.ethereum.NoHeadLivenessValidator +import io.emeraldpay.dshackle.upstream.ethereum.NoopPendingTransactionValidator +import io.emeraldpay.dshackle.upstream.ethereum.PendingTransactionValidator +import io.emeraldpay.dshackle.upstream.ethereum.PendingTransactionValidatorImpl import io.emeraldpay.dshackle.upstream.ethereum.WsConnectionPool import io.emeraldpay.dshackle.upstream.ethereum.WsConnectionPoolFactory import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptions @@ -58,6 +62,7 @@ class GenericRpcConnector( private val head: Head private val liveness: HeadLivenessValidator private val jsonRpcWsClient: JsonRpcWsClient? + private val pendingTxValidator: PendingTransactionValidator companion object { private val log = LoggerFactory.getLogger(GenericRpcConnector::class.java) @@ -136,6 +141,16 @@ class GenericRpcConnector( ) } } + pendingTxValidator = if (chain == Chain.BASE__MAINNET) { + PendingTransactionValidatorImpl( + upstream.getId(), + getIngressReader(), + Duration.ofMinutes(5), + BASE_TX_LIMIT, + ) + } else { + NoopPendingTransactionValidator() + } liveness = if (connectorType != RPC_ONLY && isSpecialChain(chain)) { AlwaysHeadLivenessValidator() @@ -186,6 +201,10 @@ class GenericRpcConnector( return ingressSubscription ?: NoIngressSubscription() } + override fun pendingTxEvents(): Flux { + return pendingTxValidator.pendingTxExists() + } + override fun getHead(): Head { return head } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericWsConnector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericWsConnector.kt index 98794195b..15c1b2d99 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericWsConnector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/connectors/GenericWsConnector.kt @@ -6,9 +6,13 @@ import io.emeraldpay.dshackle.upstream.BlockValidator import io.emeraldpay.dshackle.upstream.DefaultUpstream import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.IngressSubscription +import io.emeraldpay.dshackle.upstream.ethereum.BASE_TX_LIMIT import io.emeraldpay.dshackle.upstream.ethereum.GenericWsHead import io.emeraldpay.dshackle.upstream.ethereum.HeadLivenessState import io.emeraldpay.dshackle.upstream.ethereum.HeadLivenessValidatorImpl +import io.emeraldpay.dshackle.upstream.ethereum.NoopPendingTransactionValidator +import io.emeraldpay.dshackle.upstream.ethereum.PendingTransactionValidator +import io.emeraldpay.dshackle.upstream.ethereum.PendingTransactionValidatorImpl import io.emeraldpay.dshackle.upstream.ethereum.WsConnectionPool import io.emeraldpay.dshackle.upstream.ethereum.WsConnectionPoolFactory import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptionsImpl @@ -36,6 +40,8 @@ class GenericWsConnector( private val head: GenericWsHead private val subscriptions: IngressSubscription private val liveness: HeadLivenessValidatorImpl + private val pendingTxValidator: PendingTransactionValidator + init { pool = wsFactory.create(upstream) reader = JsonRpcWsClient(pool) @@ -54,6 +60,17 @@ class GenericWsConnector( ) liveness = HeadLivenessValidatorImpl(head, expectedBlockTime, headLivenessScheduler, upstream.getId()) subscriptions = chainSpecific.makeIngressSubscription(chain, wsSubscriptions) + + pendingTxValidator = if (chain == Chain.BASE__MAINNET) { + PendingTransactionValidatorImpl( + upstream.getId(), + getIngressReader(), + Duration.ofMinutes(10), + BASE_TX_LIMIT, + ) + } else { + NoopPendingTransactionValidator() + } } override fun headLivenessEvents(): Flux { @@ -81,6 +98,10 @@ class GenericWsConnector( return subscriptions } + override fun pendingTxEvents(): Flux { + return pendingTxValidator.pendingTxExists() + } + override fun getHead(): Head { return head } diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy index 2f8b83ed0..58c15109a 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, true, 1_000_000, false, false, true, false + false, false, 30, Duration.ofSeconds(60), null, true, 1, true, true, true, true, 1_000_000, false, false, true, false, false ) } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/GenericConnectorMock.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/GenericConnectorMock.groovy index f66ddf659..ea92c16a8 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/test/GenericConnectorMock.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/test/GenericConnectorMock.groovy @@ -14,11 +14,13 @@ class GenericConnectorMock implements GenericConnector { Reader api Head head Flux liveness + Flux pendingTxs GenericConnectorMock(Reader api, Head head) { this.api = api this.head = head this.liveness = Flux.just(HeadLivenessState.NON_CONSECUTIVE) + this.pendingTxs = Flux.just(false) } @Override @@ -51,4 +53,9 @@ class GenericConnectorMock implements GenericConnector { IngressSubscription getIngressSubscription() { return NoEthereumIngressSubscription.DEFAULT } + + @Override + Flux pendingTxEvents() { + return pendingTxs + } } \ No newline at end of file diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscriptionSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscriptionSpec.groovy index 452ae5b3c..6bde1fda1 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscriptionSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumEgressSubscriptionSpec.groovy @@ -17,6 +17,8 @@ package io.emeraldpay.dshackle.upstream.ethereum import io.emeraldpay.dshackle.test.TestingCommons +import io.emeraldpay.dshackle.upstream.ethereum.subscribe.AggregatedPendingTxes +import io.emeraldpay.dshackle.upstream.ethereum.subscribe.NoPendingTxes import io.emeraldpay.dshackle.upstream.generic.GenericMultistream import io.emeraldpay.dshackle.upstream.ethereum.subscribe.PendingTxesSource import io.emeraldpay.dshackle.upstream.ethereum.domain.Address @@ -216,6 +218,7 @@ class EthereumEgressSubscriptionSpec extends Specification { when: def up3 = TestingCommons.upstream("test") up3.getConnectorMock().setLiveness(Flux.just(HeadLivenessState.OK)) + up3.getConnectorMock().setPendingTxs(Flux.just(true)) up3.stop() up3.start() def ethereumSubscribe3 = new EthereumEgressSubscription(TestingCommons.multistream(up3) as GenericMultistream, Schedulers.boundedElastic(), Stub(PendingTxesSource)) @@ -230,5 +233,23 @@ class EthereumEgressSubscriptionSpec extends Specification { then: ethereumSubscribe4.getAvailableTopics().toSet() == [EthereumEgressSubscription.METHOD_NEW_HEADS].toSet() + when: + def up5 = TestingCommons.upstream("test") + up5.getConnectorMock().setLiveness(Flux.just(HeadLivenessState.OK)) + up5.stop() + up5.start() + def ethereumSubscribe5 = new EthereumEgressSubscription(TestingCommons.multistream(up5) as GenericMultistream, Schedulers.boundedElastic(), Stub(PendingTxesSource)) + then: + ethereumSubscribe5.getAvailableTopics().toSet() == [EthereumEgressSubscription.METHOD_LOGS, EthereumEgressSubscription.METHOD_NEW_HEADS].toSet() + + when: + def up6 = TestingCommons.upstream("test") + up6.getConnectorMock().setLiveness(Flux.just(HeadLivenessState.OK)) + up6.getConnectorMock().setPendingTxs(Flux.just(true)) + up6.stop() + up6.start() + def ethereumSubscribe6 = new EthereumEgressSubscription(TestingCommons.multistream(up6) as GenericMultistream, Schedulers.boundedElastic(), new NoPendingTxes()) + then: + ethereumSubscribe6.getAvailableTopics().toSet() == [EthereumEgressSubscription.METHOD_LOGS, EthereumEgressSubscription.METHOD_NEW_HEADS].toSet() } } diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidatorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidatorTest.kt new file mode 100644 index 000000000..6568f8cd6 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/ethereum/PendingTransactionValidatorTest.kt @@ -0,0 +1,211 @@ +package io.emeraldpay.dshackle.upstream.ethereum + +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainCallError +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import java.time.Duration + +class PendingTransactionValidatorTest { + + @Test + fun `noop validator always emits true`() { + val validator = NoopPendingTransactionValidator() + + StepVerifier.create(validator.pendingTxExists()) + .expectNext(true) + .expectComplete() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `emits true when pending plus queued exceeds limit`() { + val reader = mockReader( + response = txpoolContent(pendingAddresses = 6, queuedAddresses = 0), + ) + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 5, + ) + + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(true) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `emits true when pending plus queued is at limit`() { + val reader = mockReader( + response = txpoolContent(pendingAddresses = 3, queuedAddresses = 2), + ) + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 5, + ) + + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(true) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `counts both pending and queued buckets`() { + // 3 pending + 4 queued = 7 > limit of 5 + val reader = mockReader( + response = txpoolContent(pendingAddresses = 3, queuedAddresses = 4), + ) + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 5, + ) + + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(true) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `handles missing pending field`() { + val reader = mockReader(response = """{"queued": {"0xaa": {}, "0xbb": {}}}""") + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 5, + ) + + // Only 2 queued (no pending node) -> below limit -> false + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(false) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `handles missing queued field`() { + val reader = mockReader(response = """{"pending": {"0xaa": {}, "0xbb": {}, "0xcc": {}}}""") + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 2, + ) + + // 3 pending (no queued node) > limit 2 -> true + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(true) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `emits false on rpc error`() { + val reader = mock { + on { read(ChainRequest("txpool_content", ListParams())) } doReturn + Mono.just(ChainResponse(null, ChainCallError(-32000, "method not supported"))) + } + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 5, + ) + + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(false) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `continues polling after an error response`() { + val errorResponse = Mono.just(ChainResponse(null, ChainCallError(-32000, "method not supported"))) + val okResponse = Mono.just( + ChainResponse( + txpoolContent(pendingAddresses = 10, queuedAddresses = 0).toByteArray(), + null, + ), + ) + val reader = mock { + on { read(ChainRequest("txpool_content", ListParams())) } doReturn errorResponse doReturn okResponse + } + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 5, + ) + + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(false) // first poll: error -> false + .expectNoEvent(Duration.ofSeconds(30)) + .expectNext(true) // second poll: success + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `polls at configured interval`() { + val reader = mockReader( + response = txpoolContent(pendingAddresses = 10, queuedAddresses = 0), + ) + val validator = PendingTransactionValidatorImpl( + upstreamId = "test-upstream", + directReader = reader, + interval = Duration.ofSeconds(30), + txLimit = 5, + ) + + StepVerifier.withVirtualTime { validator.pendingTxExists() } + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(15)) + .expectNext(true) + .expectNoEvent(Duration.ofSeconds(30)) + .expectNext(true) + .expectNoEvent(Duration.ofSeconds(30)) + .expectNext(true) + .thenCancel() + .verify(Duration.ofSeconds(3)) + } + + private fun mockReader(response: String): ChainReader = + mock { + on { read(ChainRequest("txpool_content", ListParams())) } doReturn + Mono.just(ChainResponse(response.toByteArray(), null)) + } + + private fun txpoolContent(pendingAddresses: Int, queuedAddresses: Int): String { + val pending = (0 until pendingAddresses).joinToString(",") { """"0xp$it": {}""" } + val queued = (0 until queuedAddresses).joinToString(",") { """"0xq$it": {}""" } + return """{"pending": {$pending}, "queued": {$queued}}""" + } +} From 6dd2ee566f4aeb8d28e4f9072cc4c97a1c9c0ef5 Mon Sep 17 00:00:00 2001 From: msizov Date: Fri, 29 May 2026 18:19:47 +0700 Subject: [PATCH 103/108] add kite mainnet, robinhood mainnet (#865) --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index d78eab978..ad144230e 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit d78eab978dcc1d52653834def52faf1601d7f844 +Subproject commit ad144230e42b6ab929f796beb4d04069f935f6a7 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 8cb97b71e..15b60dae4 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 8cb97b71ed27ea6e687fec232bf73c39f88195a6 +Subproject commit 15b60dae4ab341ff66a70a5ec42759491cd63975 From 2ddd9c8889596cb323c79dce0a853a2b5e817256 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 2 Jun 2026 14:47:19 +0200 Subject: [PATCH 104/108] Update emerald-grpc and foundation submodule references (#866) * add Humanity chain (grpcId 1167) Bump emerald-grpc and foundation public submodules to include CHAIN_HUMANITY__MAINNET. Chain.kt is generated at build time. * add Humanity testnet (grpcId 10202) bump emerald-grpc and foundation public submodules --------- Co-authored-by: Claude --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index ad144230e..d047f442f 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit ad144230e42b6ab929f796beb4d04069f935f6a7 +Subproject commit d047f442f7aecc0e1363604477152eeddfc14713 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 15b60dae4..099987759 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 15b60dae4ab341ff66a70a5ec42759491cd63975 +Subproject commit 09998775904b398716c7c058e5ed6ddefa4125da From 634291bc98c04db648f40b82eedeaa3b56b1ec4d Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Fri, 5 Jun 2026 13:41:41 +0200 Subject: [PATCH 105/108] Fix hl (#869) * fix(hyperliquid): derive native-tx routing labels from the ?hl= URL flag The include_hl_native_tx/exclude_hl_native_tx detector classified a node by scanning the last 300 blocks for a system (native) topup tx from a per-chain address. That cannot work on testnet: - the configured testnet address 0x6ed35e7d6de4b45f4efb8a91eff31afa49362569 was a regular bot (non-zero gasPrice, not filtered by hl-compliant mode, present on both node types); - real testnet system txs (from 0x2222...) are far too sparse and bursty (median gap ~1200 blocks, max ~9000 = ~2.5h at ~1s/block) for any practical window; - eth_getLogs is identical between compliant and non-compliant modes, so there is no cheap wide-range signal either. Our hl-node upstreams already encode the mode in the URL (?hl=false serves native txs, ?hl=true is compliant). Read that flag directly: - GenericUpstream captures the configured RPC/WS URL and exposes getRpcConnectionUrl(); - detectHlNativeTx emits include/exclude_hl_native_tx straight from ?hl= when present (cheap, exact, drift-free), for both mainnet and testnet; - it falls back to the recent-blocks scan only when there is no ?hl= flag, and only on mainnet (testnet without the flag is too sparse to classify); - the bogus HL_NATIVE_TX_FROM_TESTNET constant is removed. Co-Authored-By: Claude Opus 4.8 * Fix hl --------- Co-authored-by: Claude Opus 4.8 --- emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- .../EthereumUpstreamSettingsDetector.kt | 27 ++++++++++++++----- .../upstream/generic/GenericUpstream.kt | 8 ++++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/emerald-grpc b/emerald-grpc index d047f442f..7a0f39a98 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit d047f442f7aecc0e1363604477152eeddfc14713 +Subproject commit 7a0f39a98b8848a0a1106395dce2152f5a81e7f8 diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 099987759..08e44000e 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 09998775904b398716c7c058e5ed6ddefa4125da +Subproject commit 08e44000e0445e21872b66fa81f129d23d3d7931 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt index beca4ca91..31caa0f5b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetector.kt @@ -8,6 +8,7 @@ import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.NodeTypeRequest import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.generic.GenericUpstream import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -15,7 +16,6 @@ import java.util.concurrent.atomic.AtomicInteger const val ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" const val HL_NATIVE_TX_FROM_MAINNET = "0x2222222222222222222222222222222222222222" -const val HL_NATIVE_TX_FROM_TESTNET = "0x6ed35e7d6de4b45f4efb8a91eff31afa49362569" class EthereumUpstreamSettingsDetector( private val _upstream: Upstream, @@ -179,15 +179,17 @@ class EthereumUpstreamSettingsDetector( Some clients on hyperliquid don't include system topup transactions, set either one of labels */ private fun detectHlNativeTx(): Flux> { - // Only run HL native tx detection on Hyperliquid chains if (chain != Chain.HYPERLIQUID__MAINNET && chain != Chain.HYPERLIQUID__TESTNET) { return Flux.empty() } - val hlNativeTxFrom = when (chain) { - Chain.HYPERLIQUID__MAINNET -> HL_NATIVE_TX_FROM_MAINNET - Chain.HYPERLIQUID__TESTNET -> HL_NATIVE_TX_FROM_TESTNET - else -> return Flux.empty() + // prefer the explicit ?hl= flag from the upstream URL + hlNativeTxLabelsFromUrl()?.let { return Flux.fromIterable(it) } + + // no ?hl= flag: block-scan fallback (reliable only on mainnet) + if (chain != Chain.HYPERLIQUID__MAINNET) { + return Flux.empty() } + val hlNativeTxFrom = HL_NATIVE_TX_FROM_MAINNET if (detectCounter.get() % 5 != 1) { return Flux.empty() // reduce frequency of detection } @@ -255,6 +257,19 @@ class EthereumUpstreamSettingsDetector( } } + // maps the upstream URL's ?hl= flag to routing labels, or null if absent + private fun hlNativeTxLabelsFromUrl(): List>? { + val url = (upstream as? GenericUpstream)?.getRpcConnectionUrl()?.toString() ?: return null + // ?hl=false => serves native txs (include); ?hl=true => hl-node compliant (exclude) + return when { + url.contains(Regex("[?&]hl=false\\b")) -> + listOf("include_hl_native_tx" to "true", "exclude_hl_native_tx" to "false") + url.contains(Regex("[?&]hl=true\\b")) -> + listOf("exclude_hl_native_tx" to "true", "include_hl_native_tx" to "false") + else -> null + } + } + private fun detectArchiveNode(notArchived: Boolean): Mono> { if (notArchived) { return Mono.empty() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt index d5c5ddb35..0db63e420 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/GenericUpstream.kt @@ -43,6 +43,7 @@ import reactor.core.Disposable import reactor.core.publisher.Flux import reactor.core.publisher.Sinks import reactor.core.scheduler.Schedulers +import java.net.URI import java.time.Duration import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean @@ -101,6 +102,8 @@ open class GenericUpstream( ) { rpcMethodsDetector = upstreamRpcMethodsDetectorBuilder(this, config) detectRpcMethods(config, buildMethods) + rpcConnectionUrl = (config.connection as? UpstreamsConfig.RpcConnection) + ?.let { it.rpc?.url ?: it.ws?.url } } private val validator: UpstreamValidator? = validatorBuilder(chain, this, getOptions(), chainConfig, versionRules) @@ -126,6 +129,9 @@ open class GenericUpstream( private val settingsDetector = upstreamSettingsDetectorBuilder(chain, this) private var rpcMethodsDetector: UpstreamRpcMethodsDetector? = null + // configured RPC/WS URL (carries query flags like ?hl=) + private var rpcConnectionUrl: URI? = null + private val lowerBoundService = lowerBoundServiceBuilder(chain, this) private val started = AtomicBoolean(false) @@ -184,6 +190,8 @@ open class GenericUpstream( ) } + fun getRpcConnectionUrl(): URI? = rpcConnectionUrl + @Suppress("UNCHECKED_CAST") override fun cast(selfType: Class): T { if (!selfType.isAssignableFrom(this.javaClass)) { From 2b79458bf295965090762dbfeabc0117e8d7b08c Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Thu, 25 Jun 2026 14:07:55 +0300 Subject: [PATCH 106/108] Add node_getChainTips fallback for Aztec v5 (#873) --- .../upstream/aztec/AztecChainSpecific.kt | 69 ++++++++- .../upstream/calls/DefaultAztecMethods.kt | 17 +- .../upstream/aztec/AztecChainSpecificTest.kt | 146 ++++++++++++++++++ 3 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt index 0400c2abd..c4bf14b96 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt @@ -1,6 +1,7 @@ package io.emeraldpay.dshackle.upstream.aztec import com.fasterxml.jackson.databind.JsonNode +import com.github.benmanes.caffeine.cache.Caffeine import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.config.ChainsConfig.ChainConfig @@ -8,6 +9,7 @@ import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.data.BlockId import io.emeraldpay.dshackle.foundation.ChainOptions.Options import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator import io.emeraldpay.dshackle.upstream.SingleValidator @@ -21,16 +23,36 @@ import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.slf4j.LoggerFactory import reactor.core.publisher.Mono import java.math.BigInteger +import java.time.Duration import java.time.Instant object AztecChainSpecific : AbstractPollChainSpecific() { private val log = LoggerFactory.getLogger(AztecChainSpecific::class.java) - // node_getL2Tips reshaped between Aztec versions: + private const val METHOD_NOT_FOUND = -32601 + + // Aztec v5 (v5.0.0-rc.1) renamed the tips RPC: node_getL2Tips -> node_getChainTips. + // Older nodes (incl. current mainnet) expose only the legacy name; v5+ nodes expose + // only the new one. We probe the legacy method first (most upstreams are still on it) + // and fall back to the new one on "method not found", remembering the working method + // per upstream so we stop probing the dead one on every poll. + private val LEGACY_TIPS_REQUEST = ChainRequest("node_getL2Tips", ListParams()) + private val CHAIN_TIPS_REQUEST = ChainRequest("node_getChainTips", ListParams()) + + // Bounded so per-upstream entries can't accumulate without limit (e.g. across config + // reloads): a hard size cap plus idle expiry evict stale ids, and an evicted entry just + // costs one re-probe. Only upstreams that actually fall back take a slot — legacy-only + // ones keep using the default and never populate it. + private val workingTipsRequest = Caffeine.newBuilder() + .maximumSize(1024) + .expireAfterAccess(Duration.ofHours(1)) + .build() + + // The tips response reshaped between Aztec versions: // v3 (and earlier): {proposed: {number, hash}, proven: {number, hash}, checkpointed: {number, hash}} - // v4: proven/finalized/checkpointed each became {block: {number, hash}, checkpoint: {number, hash}} - // proposed stayed flat. We always look at the v4 nested path first and fall back - // to the flat v3 path so an upstream on either version is parsed correctly. + // v4/v5: proven/finalized/checkpointed each became {block: {number, hash}, checkpoint: {number, hash}} + // proposed stayed flat across all versions. We look at the flat path first and fall back + // to the nested path so an upstream on any version is parsed correctly. private val PROPOSED_NUMBER = arrayOf("proposed.number", "proposed.block.number") private val PROPOSED_HASH = arrayOf("proposed.hash", "proposed.block.hash") @@ -122,8 +144,43 @@ object AztecChainSpecific : AbstractPollChainSpecific() { return AztecLowerBoundService(chain, upstream) } - override fun latestBlockRequest(): ChainRequest = - ChainRequest("node_getL2Tips", ListParams()) + override fun latestBlockRequest(): ChainRequest = LEGACY_TIPS_REQUEST + + // Try the per-upstream remembered method (legacy by default); on "method not found" + // fall back to the other one and remember whichever succeeds, so subsequent polls go + // straight to the working method. Any other error propagates as before. + override fun getLatestBlock(api: ChainReader, upstreamId: String): Mono { + val preferred = workingTipsRequest.getIfPresent(upstreamId) ?: LEGACY_TIPS_REQUEST + val fallback = if (preferred === LEGACY_TIPS_REQUEST) CHAIN_TIPS_REQUEST else LEGACY_TIPS_REQUEST + return fetchTips(api, upstreamId, preferred) + .onErrorResume { err -> + if (isMethodNotFound(err)) { + log.info( + "Aztec upstream {} does not support {}, falling back to {}", + upstreamId, + preferred.method, + fallback.method, + ) + fetchTips(api, upstreamId, fallback) + .doOnNext { workingTipsRequest.put(upstreamId, fallback) } + } else { + Mono.error(err) + } + } + } + + private fun fetchTips(api: ChainReader, upstreamId: String, request: ChainRequest): Mono { + return api.read(request).flatMap { + parseBlock(it.getResult(), upstreamId, api) + } + } + + private fun isMethodNotFound(err: Throwable): Boolean { + if (err is ChainException && err.error.code == METHOD_NOT_FOUND) { + return true + } + return err.message?.contains("method not found", ignoreCase = true) ?: false + } override fun upstreamSettingsDetector( chain: Chain, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt index a7be791c7..d2d89a081 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt @@ -16,18 +16,24 @@ class DefaultAztecMethods : CallMethods { "node_getBlockNumber", "node_getProvenBlockNumber", "node_getL2Tips", + "node_getChainTips", "node_getBlock", "node_getBlocks", + "node_getBlockData", "node_getBlockHeader", "node_getBlockByArchive", "node_getBlockByHash", "node_getBlockHeaderByArchive", - // Checkpoints + // Checkpoints / consensus "node_getCheckpointNumber", + "node_getCheckpoint", "node_getCheckpointedBlockNumber", "node_getCheckpointedBlocks", "node_getCheckpoints", + "node_getCheckpointsData", + "node_getCheckpointAttestationsForSlot", + "node_getProposalsForSlot", // Transactions "node_sendTx", @@ -45,6 +51,11 @@ class DefaultAztecMethods : CallMethods { "node_getWorldStateSyncStatus", "node_findLeavesIndexes", + // Sync status + "node_getSyncedL1Timestamp", + "node_getSyncedL2EpochNumber", + "node_getSyncedL2SlotNumber", + // Sibling paths "node_getNullifierSiblingPath", "node_getNoteHashSiblingPath", @@ -65,12 +76,14 @@ class DefaultAztecMethods : CallMethods { "node_getL1ToL2MessageCheckpoint", "node_isL1ToL2MessageSynced", "node_getL2ToL1Messages", + "node_getL2ToL1MembershipWitness", // Logs "node_getPrivateLogs", "node_getPrivateLogsByTags", "node_getPublicLogs", "node_getPublicLogsByTagsFromContract", + "node_getPublicLogsByTags", "node_getContractClassLogs", "node_getLogsByTags", @@ -86,12 +99,14 @@ class DefaultAztecMethods : CallMethods { "node_getVersion", "node_getChainId", "node_getL1ContractAddresses", + "node_getL1Constants", "node_getProtocolContractAddresses", "node_getEncodedEnr", // Fees "node_getCurrentBaseFees", "node_getCurrentMinFees", + "node_getPredictedMinFees", "node_getMaxPriorityFees", // Validators diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt new file mode 100644 index 000000000..fa45433f2 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt @@ -0,0 +1,146 @@ +package io.emeraldpay.dshackle.upstream.aztec + +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainCallError +import io.emeraldpay.dshackle.upstream.ChainCallUpstreamException +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import reactor.core.publisher.Mono +import java.util.concurrent.atomic.AtomicInteger + +// v5 (v5.0.0-rc.1) node_getChainTips response: proposed stays flat, the rest nested. +private val chainTipsResponse = """ + { + "proposed": {"number": 12345, "hash": "0xaaaa"}, + "checkpointed": {"block": {"number": 12340, "hash": "0xbbbb"}, "checkpoint": {"number": 100, "hash": "0x1111"}}, + "proven": {"block": {"number": 12330, "hash": "0xcccc"}, "checkpoint": {"number": 99, "hash": "0x2222"}}, + "finalized": {"block": {"number": 12320, "hash": "0xdddd"}, "checkpoint": {"number": 98, "hash": "0x3333"}} + } +""".trimIndent() + +// legacy node_getL2Tips response (v3-era flat proposed) +private val l2TipsResponse = """ + { + "proposed": {"number": 999, "hash": "0x0999"}, + "proven": {"number": 990, "hash": "0x0990"}, + "checkpointed": {"number": 980, "hash": "0x0980"} + } +""".trimIndent() + +// defensive: some versions nest proposed under .block +private val nestedProposedResponse = """ + { + "proposed": {"block": {"number": 777, "hash": "0x0777"}} + } +""".trimIndent() + +private fun methodNotFound(method: String) = + Mono.error( + ChainCallUpstreamException( + ChainResponse.NumberId(1), + ChainCallError(-32601, "Method not found: $method"), + ), + ) + +class AztecChainSpecificTest { + + @Test + fun latestBlockRequestUsesL2Tips() { + Assertions.assertThat(AztecChainSpecific.latestBlockRequest().method) + .isEqualTo("node_getL2Tips") + } + + @Test + fun parseBlockReadsFlatProposed() { + val result = AztecChainSpecific.parseBlock( + chainTipsResponse.toByteArray(), + "up-flat", + noopReader(), + ).block()!! + + Assertions.assertThat(result.height).isEqualTo(12345L) + Assertions.assertThat(result.hash.toHex()).contains("aaaa") + } + + @Test + fun parseBlockReadsNestedProposed() { + val result = AztecChainSpecific.parseBlock( + nestedProposedResponse.toByteArray(), + "up-nested", + noopReader(), + ).block()!! + + Assertions.assertThat(result.height).isEqualTo(777L) + Assertions.assertThat(result.hash.toHex()).contains("0777") + } + + @Test + fun getLatestBlockUsesL2TipsWhenAvailable() { + val calls = mutableListOf() + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + calls += key.method + return Mono.just(ChainResponse(l2TipsResponse.toByteArray(), null)) + } + } + + val result = AztecChainSpecific.getLatestBlock(reader, "up-legacy").block()!! + + Assertions.assertThat(result.height).isEqualTo(999L) + Assertions.assertThat(calls).containsExactly("node_getL2Tips") + } + + @Test + fun getLatestBlockFallsBackToChainTipsAndCaches() { + val calls = mutableListOf() + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + calls += key.method + return when (key.method) { + "node_getL2Tips" -> methodNotFound("node_getL2Tips") + "node_getChainTips" -> Mono.just(ChainResponse(chainTipsResponse.toByteArray(), null)) + else -> Mono.error(IllegalStateException("unexpected ${key.method}")) + } + } + } + + // first poll: probes legacy, falls back to v5 + val first = AztecChainSpecific.getLatestBlock(reader, "up-v5").block()!! + Assertions.assertThat(first.height).isEqualTo(12345L) + Assertions.assertThat(calls).containsExactly("node_getL2Tips", "node_getChainTips") + + // second poll: must hit the cached working method directly, no dead probe + calls.clear() + val second = AztecChainSpecific.getLatestBlock(reader, "up-v5").block()!! + Assertions.assertThat(second.height).isEqualTo(12345L) + Assertions.assertThat(calls).containsExactly("node_getChainTips") + } + + @Test + fun getLatestBlockDoesNotFallBackOnOtherErrors() { + val attempts = AtomicInteger(0) + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + attempts.incrementAndGet() + return Mono.error( + ChainCallUpstreamException( + ChainResponse.NumberId(1), + ChainCallError(-32000, "internal error"), + ), + ) + } + } + + val thrown = runCatching { AztecChainSpecific.getLatestBlock(reader, "up-err").block() } + Assertions.assertThat(thrown.isFailure).isTrue() + // only the primary method is attempted; no fallback probe on a non-method-not-found error + Assertions.assertThat(attempts.get()).isEqualTo(1) + } + + private fun noopReader() = object : ChainReader { + override fun read(key: ChainRequest): Mono = + Mono.error(IllegalStateException("not expected")) + } +} From f2c1bd062887b9f41a0f28a397aa7ca6e9d70799 Mon Sep 17 00:00:00 2001 From: KirillPamPam Date: Tue, 30 Jun 2026 16:59:58 +0400 Subject: [PATCH 107/108] Update deps (#877) --- buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts | 1 + emerald-grpc | 2 +- foundation/src/main/resources/public | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts index ec0699f2f..6c0c9a52d 100644 --- a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts +++ b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts @@ -142,6 +142,7 @@ open class CodeGen(private val config: ChainsConfig) { "kadena" -> "BlockchainType.KADENA" "avm" -> "BlockchainType.AVM" "app" -> "BlockchainType.ETHEREUM" + "aptos" -> "BlockchainType.ETHEREUM" else -> throw IllegalArgumentException("unknown blockchain type $type") } } diff --git a/emerald-grpc b/emerald-grpc index 7a0f39a98..519173182 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit 7a0f39a98b8848a0a1106395dce2152f5a81e7f8 +Subproject commit 519173182997d0cff06c5338982b18e81aba99ea diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 08e44000e..0a070aaca 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 08e44000e0445e21872b66fa81f129d23d3d7931 +Subproject commit 0a070aaca7b5e80541d277ebadf172a66629ec8e From 481c8f172100df721f3d2ae5f9a312a28154130e Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Fri, 3 Jul 2026 17:54:11 +0300 Subject: [PATCH 108/108] Add bearer token authorization for upstreams (#878) --- docs/reference-configuration.adoc | 15 +++++++++ .../emeraldpay/dshackle/config/AuthConfig.kt | 4 +++ .../dshackle/config/AuthConfigReader.kt | 12 +++++++ .../dshackle/config/UpstreamsConfig.kt | 2 ++ .../dshackle/config/UpstreamsConfigReader.kt | 16 ++++++++++ .../configure/BitcoinUpstreamCreator.kt | 2 +- .../GenericConnectorFactoryCreator.kt | 2 ++ .../dshackle/upstream/BasicHttpFactory.kt | 5 +-- .../dshackle/upstream/HttpReader.kt | 8 +++++ .../upstream/bitcoin/EsploraClient.kt | 10 +++++- .../upstream/ethereum/WsConnectionFactory.kt | 3 +- .../upstream/ethereum/WsConnectionImpl.kt | 6 ++++ .../upstream/restclient/RestHttpReader.kt | 3 +- .../upstream/rpcclient/JsonRpcHttpReader.kt | 5 +-- .../config/AuthConfigReaderSpec.groovy | 23 +++++++++++++ .../config/UpstreamsConfigReaderSpec.groovy | 32 +++++++++++++++++++ .../rpcclient/JsonRpcHttpReaderSpec.groovy | 23 +++++++++++++ .../configs/upstreams-bearer-auth.yaml | 27 ++++++++++++++++ 18 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 src/test/resources/configs/upstreams-bearer-auth.yaml diff --git a/docs/reference-configuration.adoc b/docs/reference-configuration.adoc index 5deeaf1d3..f6a35348e 100644 --- a/docs/reference-configuration.adoc +++ b/docs/reference-configuration.adoc @@ -849,6 +849,18 @@ rpc: password: "${ETH_PASSWORD}" ---- +| `rpc.bearer-auth` + `rpc.bearer-auth.token` +a| HTTP Bearer token authorization (`Authorization: Bearer ` header), if required by the remote server. + +Cannot be used together with `basic-auth`. +Value can also reference env variables, for example: +[source,yaml] +---- +rpc: + url: "https://ethereum.com:8545" + bearer-auth: + token: "${ETH_TOKEN}" +---- + | `ws.url` | WebSocket URL to connect to. Optional, but optimizes performance if it's available. @@ -859,6 +871,9 @@ Optional, but optimizes performance if it's available. | `ws.basic-auth` + ... | WebSocket Basic Auth configuration, if required by the remote server +| `ws.bearer-auth` + `ws.bearer-auth.token` +| WebSocket Bearer token authorization, if required by the remote server + | `ws.frameSize` | WebSocket frame size limit. Ex `1kb`, `1024` (same as `1kb), `2mb`, etc. diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt index 8c30950d5..21d0c69cb 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt @@ -33,6 +33,10 @@ class AuthConfig { val password: String, ) : ClientAuth() + class ClientBearerAuth( + val token: String, + ) : ClientAuth() + class ClientTlsAuth( var ca: String? = null, var certificate: String? = null, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt index 7545904c5..027c5a3d5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt @@ -40,6 +40,18 @@ class AuthConfigReader : YamlConfigReader() { } } + fun readClientBearerAuth(node: MappingNode?): AuthConfig.ClientBearerAuth? { + return getMapping(node, "bearer-auth")?.let { authNode -> + val token = getValueAsString(authNode, "token") + if (token != null) { + AuthConfig.ClientBearerAuth(token) + } else { + log.warn("Bearer auth is not fully configured, token is required") + null + } + } + } + fun readClientTls(node: MappingNode?): AuthConfig.ClientTlsAuth? { return getMapping(node, "tls")?.let { authNode -> val auth = AuthConfig.ClientTlsAuth() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt index 9aff5935d..5c333c780 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt @@ -136,12 +136,14 @@ data class UpstreamsConfig( constructor(url: URI) : this(url, DEFAULT_MAX_CONNECTIONS, DEFAULT_QUEUE_SIZE) var basicAuth: AuthConfig.ClientBasicAuth? = null + var bearerAuth: AuthConfig.ClientBearerAuth? = null var tls: AuthConfig.ClientTlsAuth? = null } data class WsEndpoint(val url: URI) { var origin: URI? = null var basicAuth: AuthConfig.ClientBasicAuth? = null + var bearerAuth: AuthConfig.ClientBearerAuth? = null var frameSize: Int? = null var msgSize: Int? = null var connections: Int = 1 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt index dfcad8992..c51a493cd 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt @@ -147,6 +147,7 @@ class UpstreamsConfigReader( getValueAsString(node, "url")?.let { url -> val http = UpstreamsConfig.HttpEndpoint(URI(url), DEFAULT_MAX_CONNECTIONS, DEFAULT_QUEUE_SIZE) http.basicAuth = authConfigReader.readClientBasicAuth(node) + http.bearerAuth = readBearerAuth(node, http.basicAuth, url) http.tls = authConfigReader.readClientTls(node) connection.esplora = http } @@ -173,6 +174,19 @@ class UpstreamsConfigReader( return connection } + private fun readBearerAuth( + node: MappingNode?, + basicAuth: AuthConfig.ClientBasicAuth?, + url: String, + ): AuthConfig.ClientBearerAuth? { + val bearerAuth = authConfigReader.readClientBearerAuth(node) + if (bearerAuth != null && basicAuth != null) { + log.warn("Both basic-auth and bearer-auth are configured for $url, basic-auth is used") + return null + } + return bearerAuth + } + private fun readRpcConfig(connConfigNode: MappingNode): UpstreamsConfig.HttpEndpoint? { return getMapping(connConfigNode, "rpc")?.let { node -> val maxConnections = getValueAsInt(node, "max-connections") ?: DEFAULT_MAX_CONNECTIONS @@ -181,6 +195,7 @@ class UpstreamsConfigReader( getValueAsString(node, "url")?.let { url -> val http = UpstreamsConfig.HttpEndpoint(URI(url), maxConnections, queueSize) http.basicAuth = authConfigReader.readClientBasicAuth(node) + http.bearerAuth = readBearerAuth(node, http.basicAuth, url) http.tls = authConfigReader.readClientTls(node) http } @@ -224,6 +239,7 @@ class UpstreamsConfigReader( ws.origin = URI(origin) } ws.basicAuth = authConfigReader.readClientBasicAuth(node) + ws.bearerAuth = readBearerAuth(node, ws.basicAuth, url) getValueAsBytes(node, "frameSize")?.let { if (it < 65_535) { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt index 2579466d6..e16a2e8e8 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt @@ -52,7 +52,7 @@ class BitcoinUpstreamCreator( fileResolver.resolve(ca).readBytes() } } - EsploraClient(endpoint.url, endpoint.basicAuth, tls) + EsploraClient(endpoint.url, endpoint.basicAuth, tls, endpoint.bearerAuth) } val extractBlock = ExtractBlock() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt index ee46c29c4..fa2a650b2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericConnectorFactoryCreator.kt @@ -84,6 +84,7 @@ open class GenericConnectorFactoryCreator( monitoringCfg.nettyMetricsConfig.enabled, httpScheduler, customHeaders, + conn.bearerAuth, ) } } @@ -106,6 +107,7 @@ open class GenericConnectorFactoryCreator( ).apply { config = endpoint basicAuth = endpoint.basicAuth + bearerAuth = endpoint.bearerAuth this.customHeaders = customHeaders } val wsApi = WsConnectionPoolFactory( diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt index 38abb0bb0..259e83605 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/BasicHttpFactory.kt @@ -21,6 +21,7 @@ class BasicHttpFactory( private val nettyMetricsEnabled: Boolean, private val httpScheduler: Scheduler, private val customHeaders: Map = emptyMap(), + private val bearerAuth: AuthConfig.ClientBearerAuth? = null, ) : HttpFactory { private val log = LoggerFactory.getLogger(this::class.java) @@ -50,8 +51,8 @@ class BasicHttpFactory( ) if (chain.type.apiType == ApiType.REST) { - return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, chain, basicAuth, tls, customHeaders) + return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, chain, basicAuth, tls, customHeaders, bearerAuth) } - return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls, customHeaders) + return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls, customHeaders, bearerAuth) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt index 28e19d750..818a96d2a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/HttpReader.kt @@ -28,6 +28,7 @@ abstract class HttpReader( basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, customHeaders: Map = emptyMap(), + bearerAuth: AuthConfig.ClientBearerAuth? = null, ) : ChainReader { constructor() : this("", 1500, 1000, null) @@ -66,6 +67,13 @@ abstract class HttpReader( build = build.headers(headers) } + if (basicAuth == null) { + bearerAuth?.let { auth -> + val headers = Consumer { h: HttpHeaders -> h.add(HttpHeaderNames.AUTHORIZATION, "Bearer ${auth.token}") } + build = build.headers(headers) + } + } + if (customHeaders.isNotEmpty()) { val headers = Consumer { h: HttpHeaders -> customHeaders.forEach { (key, value) -> diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt index eb9709d79..7c5ad1a61 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt @@ -34,10 +34,11 @@ import java.security.cert.X509Certificate import java.util.Base64 import java.util.function.Consumer -class EsploraClient( +class EsploraClient @JvmOverloads constructor( private val url: URI, basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, + bearerAuth: AuthConfig.ClientBearerAuth? = null, ) { companion object { @@ -62,6 +63,13 @@ class EsploraClient( build = build.headers(headers) } + if (basicAuth == null) { + bearerAuth?.let { auth -> + val headers = Consumer { h: HttpHeaders -> h.add(HttpHeaderNames.AUTHORIZATION, "Bearer ${auth.token}") } + build = build.headers(headers) + } + } + tlsCAAuth?.let { auth -> val cf = CertificateFactory.getInstance("X.509") val cert = cf.generateCertificate(ByteArrayInputStream(auth)) as X509Certificate diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt index 526950bd8..87bc84650 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionFactory.kt @@ -21,6 +21,7 @@ open class WsConnectionFactory( ) { var basicAuth: AuthConfig.ClientBasicAuth? = null + var bearerAuth: AuthConfig.ClientBearerAuth? = null var config: UpstreamsConfig.WsEndpoint? = null var customHeaders: Map = emptyMap() @@ -47,7 +48,7 @@ open class WsConnectionFactory( } open fun createWsConnection(connIndex: Int = 0): WsConnection = - WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler, eventsScheduler, customHeaders).also { ws -> + WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler, eventsScheduler, customHeaders, bearerAuth).also { ws -> config?.frameSize?.let { ws.frameSize = it } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt index a7ec18e9d..238896566 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionImpl.kt @@ -67,6 +67,7 @@ open class WsConnectionImpl( private val scheduler: Scheduler, private val eventsScheduler: Scheduler, private val customHeaders: Map = emptyMap(), + private val bearerAuth: AuthConfig.ClientBearerAuth? = null, ) : AutoCloseable, WsConnection, Cloneable { companion object { @@ -227,6 +228,11 @@ open class WsConnectionImpl( val base64password = Base64.getEncoder().encodeToString(tmp.toByteArray()) headers.add(HttpHeaderNames.AUTHORIZATION, "Basic $base64password") } + if (basicAuth == null) { + bearerAuth?.let { auth -> + headers.add(HttpHeaderNames.AUTHORIZATION, "Bearer ${auth.token}") + } + } customHeaders.forEach { (key, value) -> headers.add(key, value) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt index aa0b2da7a..ee83d6e72 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/restclient/RestHttpReader.kt @@ -36,7 +36,8 @@ class RestHttpReader( basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, customHeaders: Map = emptyMap(), -) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders) { + bearerAuth: AuthConfig.ClientBearerAuth? = null, +) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders, bearerAuth) { private val parser = ResponseRpcParser() private val requestParser = RestRequestParser diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt index 6fc78e520..e3e58b0c0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReader.kt @@ -36,7 +36,7 @@ import java.util.function.Function /** * JSON RPC client */ -class JsonRpcHttpReader( +class JsonRpcHttpReader @JvmOverloads constructor( target: String, maxConnections: Int, queueSize: Int, @@ -45,7 +45,8 @@ class JsonRpcHttpReader( basicAuth: AuthConfig.ClientBasicAuth? = null, tlsCAAuth: ByteArray? = null, customHeaders: Map = emptyMap(), -) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders) { + bearerAuth: AuthConfig.ClientBearerAuth? = null, +) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders, bearerAuth) { private val parser = ResponseRpcParser() private val streamParser = JsonRpcStreamParser() diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/AuthConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/AuthConfigReaderSpec.groovy index c31a95a90..51593497f 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/config/AuthConfigReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/config/AuthConfigReaderSpec.groovy @@ -35,6 +35,29 @@ class AuthConfigReaderSpec extends Specification { act.password == "258fe4149c199ad8f2811a68f20154fc" } + def "Read bearer-auth for client"() { + setup: + def yaml = + "bearer-auth:\n" + + " token: 9c199ad8f281f20154fc258fe41a6814" + when: + def act = reader.readClientBearerAuth(reader.readNode(yaml)) + then: + act != null + act.token == "9c199ad8f281f20154fc258fe41a6814" + } + + def "Read bearer-auth without token as null"() { + setup: + def yaml = + "bearer-auth:\n" + + " something: else" + when: + def act = reader.readClientBearerAuth(reader.readNode(yaml)) + then: + act == null + } + def "Read tls for client"() { setup: def yaml = diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy index 58c15109a..d0824fffb 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsConfigReaderSpec.groovy @@ -75,6 +75,38 @@ class UpstreamsConfigReaderSpec extends Specification { } } + def "Parse config with bearer auth"() { + setup: + def config = this.class.getClassLoader().getResourceAsStream("configs/upstreams-bearer-auth.yaml") + when: + def act = reader.readInternal(config) + then: + act != null + act.upstreams.size() == 2 + with(act.upstreams.get(0)) { + id == "local" + connection instanceof UpstreamsConfig.RpcConnection + with((UpstreamsConfig.RpcConnection) connection) { + rpc.basicAuth == null + rpc.bearerAuth != null + rpc.bearerAuth.token == "9c199ad8f281f20154fc258fe41a6814" + ws != null + ws.basicAuth == null + ws.bearerAuth != null + ws.bearerAuth.token == "258fe4149c199ad8f2811a68f20154fc" + } + } + with(act.upstreams.get(1)) { + id == "both-auth" + connection instanceof UpstreamsConfig.RpcConnection + with((UpstreamsConfig.RpcConnection) connection) { + // when both are configured basic-auth wins and bearer-auth is dropped + rpc.basicAuth != null + rpc.bearerAuth == null + } + } + } + def "Parse websocket-only config"() { setup: def config = this.class.getClassLoader().getResourceAsStream("configs/upstreams-ws-only.yaml") diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy index 3ad63420d..588a3e54f 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcHttpReaderSpec.groovy @@ -16,6 +16,7 @@ package io.emeraldpay.dshackle.upstream.rpcclient +import io.emeraldpay.dshackle.config.AuthConfig import io.emeraldpay.dshackle.test.TestingCommons import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest @@ -69,6 +70,28 @@ class JsonRpcHttpReaderSpec extends Specification { new String(act.result) == '"0x98de45"' } + def "Make a request with bearer auth"() { + setup: + def bearerAuth = new AuthConfig.ClientBearerAuth("test-token-123") + JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:], bearerAuth) + def resp = '{' + + ' "jsonrpc": "2.0",' + + ' "result": "0x98de45",' + + ' "error": null,' + + ' "id": 15' + + '}' + mockServer.when( + HttpRequest.request().withHeader("authorization", "Bearer test-token-123") + ).respond( + HttpResponse.response(resp) + ) + when: + def act = client.read(new ChainRequest("test", new ListParams())).block(Duration.ofSeconds(5)) + then: + act.error == null + new String(act.result) == '"0x98de45"' + } + def "Produces RPC Exception on error status code"() { setup: def client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) diff --git a/src/test/resources/configs/upstreams-bearer-auth.yaml b/src/test/resources/configs/upstreams-bearer-auth.yaml new file mode 100644 index 000000000..b9ad19d2d --- /dev/null +++ b/src/test/resources/configs/upstreams-bearer-auth.yaml @@ -0,0 +1,27 @@ +version: v1 + +upstreams: + - id: local + chain: ethereum + connection: + ethereum: + rpc: + url: "http://localhost:8545" + bearer-auth: + token: 9c199ad8f281f20154fc258fe41a6814 + ws: + url: "ws://localhost:8546" + origin: "http://localhost" + bearer-auth: + token: 258fe4149c199ad8f2811a68f20154fc + - id: both-auth + chain: ethereum + connection: + ethereum: + rpc: + url: "http://localhost:8545" + basic-auth: + username: 4fc258fe41a68149c199ad8f281f2015 + password: 1a68f20154fc258fe4149c199ad8f281 + bearer-auth: + token: 9c199ad8f281f20154fc258fe41a6814