Merge pull request #114 from emeraldpay/feat/calls-with-ws

fix #107
This commit is contained in:
Igor Artamonov
2021-09-23 19:26:23 -04:00
committed by GitHub
37 changed files with 1743 additions and 294 deletions

View File

@@ -32,6 +32,8 @@ jobs:
uses: eskatos/gradle-command-action@v1 uses: eskatos/gradle-command-action@v1
with: with:
arguments: check arguments: check
env:
CI: true
- name: Upload Coverage Report - name: Upload Coverage Report
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v1
@@ -64,6 +66,7 @@ jobs:
with: with:
arguments: check arguments: check
env: env:
CI: true
DSHACKLE_TEST_ENABLED: redis DSHACKLE_TEST_ENABLED: redis
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: 6379 REDIS_PORT: 6379

View File

@@ -132,6 +132,7 @@ dependencies {
testImplementation "io.projectreactor:reactor-test:$reactorVersion" testImplementation "io.projectreactor:reactor-test:$reactorVersion"
testImplementation 'org.objenesis:objenesis:3.1' testImplementation 'org.objenesis:objenesis:3.1'
testImplementation 'org.mock-server:mockserver-netty:5.11.2' testImplementation 'org.mock-server:mockserver-netty:5.11.2'
testImplementation "org.java-websocket:Java-WebSocket:1.5.1"
testImplementation "nl.jqno.equalsverifier:equalsverifier:3.3" testImplementation "nl.jqno.equalsverifier:equalsverifier:3.3"
testImplementation "org.codehaus.groovy:groovy:${groovyVersion}" testImplementation "org.codehaus.groovy:groovy:${groovyVersion}"
} }

View File

@@ -527,6 +527,8 @@ configuration, and may be omitted for most of the situations.
basic-auth: basic-auth:
username: 9c199ad8f281f20154fc258fe41a6814 username: 9c199ad8f281f20154fc258fe41a6814
password: 258fe4149c199ad8f2811a68f20154fc password: 258fe4149c199ad8f2811a68f20154fc
frameSize: 5mb
msgSize: 15mb
---- ----
.Main Config .Main Config
@@ -592,7 +594,7 @@ Example: `https://kovan.infura.io/v3/${INFURA_USER}`
| `rpc.basic-auth` + `rpc.basic-auth.username`, `rpc.basic-auth.password` | `rpc.basic-auth` + `rpc.basic-auth.username`, `rpc.basic-auth.password`
a| HTTP Basic Auth configuration, if required by the remote server. + a| HTTP Basic Auth configuration, if required by the remote server. +
Values can also reference env variables, for example: Values can also reference env variables, for example:
[source,yaml] [source,yaml]
---- ----
rpc: rpc:
@@ -603,7 +605,8 @@ rpc:
---- ----
| `ws.url` | `ws.url`
| Websocket URL to connect to. Optional, but optimizes performance if it's available. | Websocket URL to connect to.
Optional, but optimizes performance if it's available.
| `ws.origin` | `ws.origin`
| HTTP `Origin` if required by Websocket remote server. | HTTP `Origin` if required by Websocket remote server.
@@ -611,6 +614,15 @@ rpc:
| `ws.basic-auth` + ... | `ws.basic-auth` + ...
| Websocket Basic Auth configuration, if required by the remote server | Websocket Basic Auth configuration, if required by the remote server
| `ws.frameSize`
| WebSocket frame size limit.
Ex `1kb`, `1024` (same as `1kb), `2mb`, etc.
Default is 5Mb
| `ws.msgSize`
| Total limit for a message size consisting from multiple frames.
Ex `1kb`, `1024` (same as `1kb), `2mb`, etc.
Default is 15Mb
|=== |===

View File

@@ -25,6 +25,7 @@ import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspentDeserializer import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspentDeserializer
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspentDeserializer import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspentDeserializer
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -48,6 +49,7 @@ class Global {
module.addDeserializer(EsploraUnspent::class.java, EsploraUnspentDeserializer()) module.addDeserializer(EsploraUnspent::class.java, EsploraUnspentDeserializer())
module.addDeserializer(RpcUnspent::class.java, RpcUnspentDeserializer()) module.addDeserializer(RpcUnspent::class.java, RpcUnspentDeserializer())
module.addDeserializer(JsonRpcRequest::class.java, JsonRpcRequest.Deserializer())
val objectMapper = ObjectMapper() val objectMapper = ObjectMapper()
objectMapper.registerModule(module) objectMapper.registerModule(module)

View File

@@ -106,6 +106,7 @@ open class UpstreamsConfig {
class EthereumConnection : RpcConnection() { class EthereumConnection : RpcConnection() {
var ws: WsEndpoint? = null var ws: WsEndpoint? = null
var preferHttp: Boolean = false
} }
class BitcoinConnection : RpcConnection() { class BitcoinConnection : RpcConnection() {
@@ -120,10 +121,11 @@ open class UpstreamsConfig {
class WsEndpoint(val url: URI) { class WsEndpoint(val url: URI) {
var origin: URI? = null var origin: URI? = null
var basicAuth: AuthConfig.ClientBasicAuth? = null var basicAuth: AuthConfig.ClientBasicAuth? = null
var frameSize: Int? = null
var msgSize: Int? = null
} }
//TODO make it unmodifiable after initial load //TODO make it unmodifiable after initial load
class Labels: HashMap<String, String>() { class Labels: HashMap<String, String>() {

View File

@@ -112,6 +112,19 @@ class UpstreamsConfigReader(
ws.origin = URI(origin) ws.origin = URI(origin)
} }
ws.basicAuth = authConfigReader.readClientBasicAuth(node) ws.basicAuth = authConfigReader.readClientBasicAuth(node)
getValueAsBytes(node, "frameSize")?.let {
if (it < 65_535) {
throw IllegalStateException("frameSize cannot be less than 64Kb")
}
ws.frameSize = it
}
getValueAsBytes(node, "msgSize")?.let {
if (it < 65_535) {
throw IllegalStateException("msgSize cannot be less than 64Kb")
}
ws.msgSize = it
}
} }
} }
} else { } else {

View File

@@ -127,6 +127,22 @@ abstract class YamlConfigReader {
} }
} }
fun getValueAsBytes(mappingNode: MappingNode?, key: String): Int? {
return getValueAsString(mappingNode, key)?.let(envVariables::postProcess)?.let {
val m = Regex("^(\\d+)(m|mb|k|kb|b)?$").find(it.lowercase().trim())
?: throw IllegalArgumentException("Not a data size: ${it}. Example of correct values: '1024', '1kb', '5mb'")
val multiplier = m.groups[2]?.let {
when (it.value) {
"k", "kb" -> 1024
"m", "mb" -> 1024 * 1024
else -> 1
}
} ?: 1
val base = m.groups[1]!!.value.toInt()
base * multiplier
}
}
// ---- // ----
fun getBlockchain(id: String): Chain { fun getBlockchain(id: String): Chain {

View File

@@ -28,6 +28,7 @@ import io.emeraldpay.dshackle.upstream.calls.CallMethods
import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods
import io.emeraldpay.dshackle.upstream.ethereum.EthereumRpcUpstream import io.emeraldpay.dshackle.upstream.ethereum.EthereumRpcUpstream
import io.emeraldpay.dshackle.upstream.ethereum.EthereumWsFactory import io.emeraldpay.dshackle.upstream.ethereum.EthereumWsFactory
import io.emeraldpay.dshackle.upstream.ethereum.EthereumWsUpstream
import io.emeraldpay.dshackle.upstream.grpc.GrpcUpstreams import io.emeraldpay.dshackle.upstream.grpc.GrpcUpstreams
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcHttpClient import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcHttpClient
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
@@ -184,11 +185,6 @@ open class ConfiguredUpstreams(
chain: Chain, chain: Chain,
options: UpstreamsConfig.Options) { options: UpstreamsConfig.Options) {
val conn = config.connection!! val conn = config.connection!!
val directApi: Reader<JsonRpcRequest, JsonRpcResponse>? = buildHttpClient(config)
if (directApi == null) {
log.warn("Upstream doesn't have API configuration")
return
}
val urls = ArrayList<URI>() val urls = ArrayList<URI>()
val methods = buildMethods(config, chain) val methods = buildMethods(config, chain)
@@ -199,8 +195,9 @@ open class ConfiguredUpstreams(
val wsFactoryApi: EthereumWsFactory? = conn.ws?.let { endpoint -> val wsFactoryApi: EthereumWsFactory? = conn.ws?.let { endpoint ->
val wsApi = EthereumWsFactory( val wsApi = EthereumWsFactory(
endpoint.url, endpoint.url,
endpoint.origin ?: URI("http://localhost") endpoint.origin ?: URI("http://localhost"),
) )
wsApi.config = endpoint
endpoint.basicAuth?.let { auth -> endpoint.basicAuth?.let { auth ->
wsApi.basicAuth = auth wsApi.basicAuth = auth
} }
@@ -209,13 +206,29 @@ open class ConfiguredUpstreams(
} }
log.info("Using ${chain.chainName} upstream, at ${urls.joinToString()}") log.info("Using ${chain.chainName} upstream, at ${urls.joinToString()}")
val ethereumUpstream = EthereumRpcUpstream( val ethereumUpstream = if (wsFactoryApi != null && !conn.preferHttp) {
EthereumWsUpstream(
config.id!!,
chain, wsFactoryApi,
options, config.role,
QuorumForLabels.QuorumItem(1, config.labels),
methods
)
} else {
val directApi: Reader<JsonRpcRequest, JsonRpcResponse>? = buildHttpClient(config)
if (directApi == null) {
log.warn("Upstream doesn't have API configuration")
return
}
EthereumRpcUpstream(
config.id!!, config.id!!,
chain, directApi, wsFactoryApi, chain, directApi, wsFactoryApi,
options, config.role, options, config.role,
QuorumForLabels.QuorumItem(1, config.labels), QuorumForLabels.QuorumItem(1, config.labels),
methods methods
) )
}
ethereumUpstream.start() ethereumUpstream.start()
currentUpstreams.update(UpstreamChange(chain, ethereumUpstream, UpstreamChange.ChangeType.ADDED)) currentUpstreams.update(UpstreamChange(chain, ethereumUpstream, UpstreamChange.ChangeType.ADDED))
} }

View File

@@ -61,7 +61,7 @@ abstract class DefaultUpstream(
return status.get().status return status.get().status
} }
fun setStatus(avail: UpstreamAvailability) { open fun setStatus(avail: UpstreamAvailability) {
status.updateAndGet { curr -> status.updateAndGet { curr ->
Status(curr.lag, avail, statusByLag(curr.lag, avail)) Status(curr.lag, avail, statusByLag(curr.lag, avail))
} }

View File

@@ -15,9 +15,48 @@
*/ */
package io.emeraldpay.dshackle.upstream.ethereum package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.Defaults
import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.AbstractHead import io.emeraldpay.dshackle.upstream.AbstractHead
import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.emeraldpay.etherjar.hex.HexQuantity
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
open class DefaultEthereumHead : Head, AbstractHead() { open class DefaultEthereumHead : Head, AbstractHead() {
companion object {
private val log = LoggerFactory.getLogger(DefaultEthereumHead::class.java)
}
fun getLatestBlock(api: Reader<JsonRpcRequest, JsonRpcResponse>): Mono<BlockContainer> {
return api.read(JsonRpcRequest("eth_blockNumber", emptyList()))
.subscribeOn(EthereumRpcHead.scheduler)
.timeout(Defaults.timeout, Mono.error(Exception("Block number not received")))
.flatMap {
if (it.error != null) {
Mono.error(it.error.asException(null))
} else {
val value = it.getResultAsProcessedString()
Mono.just(HexQuantity.from(value))
}
}
.flatMap {
//fetching by Block Height here, critical to use the same upstream as in previous call,
//b/c different upstreams may have different blocks on the same height
api.read(JsonRpcRequest("eth_getBlockByNumber", listOf(it.toHex(), false)))
.subscribeOn(EthereumRpcHead.scheduler)
.timeout(Defaults.timeout, Mono.error(Exception("Block data not received")))
}
.map {
BlockContainer.fromEthereumJson(it.getResult())
}
.onErrorResume { err ->
log.debug("Failed to fetch latest block: ${err.message}")
Mono.empty()
}
}
} }

View File

@@ -49,30 +49,7 @@ class EthereumRpcHead(
val base = Flux.interval(interval) val base = Flux.interval(interval)
.publishOn(scheduler) .publishOn(scheduler)
.flatMap { .flatMap {
api.read(JsonRpcRequest("eth_blockNumber", emptyList())) getLatestBlock(api)
.subscribeOn(scheduler)
.timeout(Defaults.timeout, Mono.error(Exception("Block number not received")))
.flatMap {
if (it.error != null) {
Mono.error(it.error.asException(null))
} else {
val value = it.getResultAsProcessedString()
Mono.just(HexQuantity.from(value))
}
}
}
.flatMap {
//fetching by Block Height here, critical to use same upstream,
//different upstreams may have different blocks on the same height
api.read(JsonRpcRequest("eth_getBlockByNumber", listOf(it.toHex(), false)))
.subscribeOn(scheduler)
.timeout(Defaults.timeout, Mono.error(Exception("Block data not received")))
}
.map {
BlockContainer.fromEthereumJson(it.getResult())
}
.onErrorContinue { err, _ ->
log.debug("RPC error ${err.message}")
} }
refreshSubscription = super.follow(base) refreshSubscription = super.follow(base)
} }

View File

@@ -38,11 +38,6 @@ open class EthereumRpcUpstream(
private val head: Head = this.createHead() private val head: Head = this.createHead()
private var validatorSubscription: Disposable? = null private var validatorSubscription: Disposable? = null
private val capabilities = if (options.providesBalance != false) {
setOf(Capability.RPC, Capability.BALANCE)
} else {
setOf(Capability.RPC)
}
override fun setCaches(caches: Caches) { override fun setCaches(caches: Caches) {
if (head is CachesEnabled) { if (head is CachesEnabled) {
@@ -79,7 +74,8 @@ open class EthereumRpcUpstream(
open fun createHead(): Head { open fun createHead(): Head {
return if (ethereumWsFactory != null) { return if (ethereumWsFactory != null) {
val ws = ethereumWsFactory.create(this).apply { // do not set upstream to the WS, since it doesn't control the RPC upstream
val ws = ethereumWsFactory.create(null, null, null).apply {
connect() connect()
} }
val wsHead = EthereumWsHead(ws).apply { val wsHead = EthereumWsHead(ws).apply {
@@ -108,14 +104,6 @@ open class EthereumRpcUpstream(
return directReader return directReader
} }
override fun getLabels(): Collection<UpstreamsConfig.Labels> {
return listOf(node.labels)
}
override fun getCapabilities(): Set<Capability> {
return capabilities
}
override fun isGrpc(): Boolean { override fun isGrpc(): Boolean {
return false return false
} }

View File

@@ -26,5 +26,20 @@ abstract class EthereumUpstream(
options: UpstreamsConfig.Options, options: UpstreamsConfig.Options,
role: UpstreamsConfig.UpstreamRole, role: UpstreamsConfig.UpstreamRole,
targets: CallMethods?, targets: CallMethods?,
node: QuorumForLabels.QuorumItem? private val node: QuorumForLabels.QuorumItem?
) : DefaultUpstream(id, options, role, targets, node) ) : DefaultUpstream(id, options, role, targets, node) {
private val capabilities = if (options.providesBalance != false) {
setOf(Capability.RPC, Capability.BALANCE)
} else {
setOf(Capability.RPC)
}
override fun getCapabilities(): Set<Capability> {
return capabilities
}
override fun getLabels(): Collection<UpstreamsConfig.Labels> {
return node?.let { listOf(it.labels) } ?: emptyList()
}
}

View File

@@ -32,7 +32,7 @@ import reactor.core.scheduler.Schedulers
import java.time.Duration import java.time.Duration
import java.util.concurrent.Executors import java.util.concurrent.Executors
class EthereumUpstreamValidator( open class EthereumUpstreamValidator(
private val upstream: EthereumUpstream, private val upstream: EthereumUpstream,
private val options: UpstreamsConfig.Options private val options: UpstreamsConfig.Options
) { ) {
@@ -43,7 +43,7 @@ class EthereumUpstreamValidator(
private val objectMapper: ObjectMapper = Global.objectMapper private val objectMapper: ObjectMapper = Global.objectMapper
fun validate(): Mono<UpstreamAvailability> { open fun validate(): Mono<UpstreamAvailability> {
return upstream return upstream
.getApi() .getApi()
.read(JsonRpcRequest("eth_syncing", listOf())) .read(JsonRpcRequest("eth_syncing", listOf()))

View File

@@ -20,28 +20,45 @@ import io.emeraldpay.dshackle.Defaults
import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.SilentException import io.emeraldpay.dshackle.SilentException
import io.emeraldpay.dshackle.config.AuthConfig import io.emeraldpay.dshackle.config.AuthConfig
import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.upstream.DefaultUpstream
import io.emeraldpay.dshackle.upstream.UpstreamAvailability
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.emeraldpay.dshackle.upstream.rpcclient.ResponseWSParser
import io.emeraldpay.dshackle.upstream.rpcclient.RpcMetrics
import io.emeraldpay.etherjar.rpc.json.BlockJson import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import io.emeraldpay.etherjar.rpc.ws.SubscriptionJson import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufInputStream import io.netty.buffer.ByteBufInputStream
import io.netty.buffer.Unpooled
import io.netty.handler.codec.http.HttpHeaderNames import io.netty.handler.codec.http.HttpHeaderNames
import org.reactivestreams.Publisher
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.util.backoff.BackOff
import org.springframework.util.backoff.BackOffExecution
import org.springframework.util.backoff.ExponentialBackOff
import org.springframework.util.backoff.FixedBackOff
import reactor.core.Disposable import reactor.core.Disposable
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import reactor.core.scheduler.Schedulers
import reactor.netty.http.client.HttpClient import reactor.netty.http.client.HttpClient
import reactor.netty.http.client.WebsocketClientSpec import reactor.netty.http.client.WebsocketClientSpec
import reactor.netty.http.websocket.WebsocketInbound
import reactor.netty.http.websocket.WebsocketOutbound
import reactor.retry.Repeat import reactor.retry.Repeat
import java.io.InputStream import reactor.util.function.Tuples
import java.net.URI import java.net.URI
import java.time.Duration import java.time.Duration
import java.util.* import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class EthereumWsFactory( class EthereumWsFactory(
private val uri: URI, private val uri: URI,
@@ -49,30 +66,79 @@ class EthereumWsFactory(
) { ) {
var basicAuth: AuthConfig.ClientBasicAuth? = null var basicAuth: AuthConfig.ClientBasicAuth? = null
var config: UpstreamsConfig.WsEndpoint? = null
fun create(upstream: EthereumUpstream): EthereumWs { fun create(upstream: DefaultUpstream?, validator: EthereumUpstreamValidator?, rpcMetrics: RpcMetrics?): EthereumWs {
return EthereumWs(uri, origin, upstream, basicAuth) return EthereumWs(uri, origin, basicAuth, rpcMetrics, upstream, validator).also { ws ->
config?.frameSize?.let {
ws.frameSize = it
}
config?.msgSize?.let {
ws.msgSizeLimit = it
}
}
} }
class EthereumWs( class EthereumWs(
private val uri: URI, private val uri: URI,
private val origin: URI, private val origin: URI,
private val upstream: EthereumUpstream, private val basicAuth: AuthConfig.ClientBasicAuth?,
private val basicAuth: AuthConfig.ClientBasicAuth? private val rpcMetrics: RpcMetrics?,
private val upstream: DefaultUpstream?,
private val validator: EthereumUpstreamValidator?
) : AutoCloseable { ) : AutoCloseable {
companion object { companion object {
private val log = LoggerFactory.getLogger(EthereumWs::class.java) private val log = LoggerFactory.getLogger(EthereumWs::class.java)
private const val IDS_START = 100
private const val START_REQUEST = "{\"jsonrpc\":\"2.0\", \"method\":\"eth_subscribe\", \"id\":\"blocks\", \"params\":[\"newHeads\"]}" private const val START_REQUEST = "{\"jsonrpc\":\"2.0\", \"method\":\"eth_subscribe\", \"id\":\"blocks\", \"params\":[\"newHeads\"]}"
// WebSocket Frame limit.
// Default is 65_536, but Geth responds with larger frames,
// and connection gets dropped with:
// > io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException: Max frame length of 65536 has been exceeded
// It's unclear what is a right limit here, but 5mb seems to be working (1mb wasn't always working)
private const val DEFAULT_FRAME_SIZE = 5 * 1024 * 1024
// The max size from multiple frames that may represent a single message
// Accept up to 15Mb messages, because Geth is using 15mb, though it's not clear what it limits
private const val DEFAULT_MSG_SIZE = 15 * 1024 * 1024
} }
private val topic = Sinks var frameSize: Int = DEFAULT_FRAME_SIZE
var msgSizeLimit: Int = DEFAULT_MSG_SIZE
private var reconnectBackoff: BackOff = ExponentialBackOff().also {
it.initialInterval = Duration.ofMillis(100).toMillis()
it.maxInterval = Duration.ofMinutes(1).toMillis()
}
private var currentBackOff = reconnectBackoff.start()
private val parser = ResponseWSParser()
private val blocks = Sinks
.many() .many()
.multicast() .multicast()
.directBestEffort<BlockContainer>() .directBestEffort<BlockContainer>()
private var rpcSend = Sinks
.many()
.unicast()
.onBackpressureBuffer<JsonRpcRequest>()
private val rpcReceive = Sinks
.many()
.multicast()
.directBestEffort<JsonRpcResponse>()
private val sendIdSeq = AtomicInteger(IDS_START)
private val sendExecutor = Executors.newSingleThreadExecutor()
private var keepConnection = true private var keepConnection = true
private var connection: Disposable? = null private var connection: Disposable? = null
private val reconnecting = AtomicBoolean(false)
fun setReconnectIntervalSeconds(value: Long) {
reconnectBackoff = FixedBackOff(value * 1000, FixedBackOff.UNLIMITED_ATTEMPTS)
currentBackOff = reconnectBackoff.start()
}
fun connect() { fun connect() {
if (keepConnection) { if (keepConnection) {
@@ -81,29 +147,53 @@ class EthereumWsFactory(
} }
private fun tryReconnectLater() { private fun tryReconnectLater() {
if (!keepConnection) {
return
}
val alreadyReconnecting = reconnecting.getAndSet(true)
if (alreadyReconnecting) {
return
}
// rpcSend is already CANCELLED, since the subscription owned by the previous connection is gone
// so we need to create a new Sink. Emit Complete is probably useless, and just in case
rpcSend.tryEmitComplete()
rpcSend = Sinks
.many()
.unicast()
.onBackpressureBuffer<JsonRpcRequest>()
val retryInterval = currentBackOff.nextBackOff()
if (retryInterval == BackOffExecution.STOP) {
log.warn("Reconnect backoff exhausted. Permanently closing the connection")
return
}
log.info("Reconnect to $uri in ${retryInterval}ms...")
Global.control.schedule( Global.control.schedule(
{ connectInternal() }, {
Defaults.retryConnection.seconds, TimeUnit.SECONDS) reconnecting.set(false)
connectInternal()
},
retryInterval, TimeUnit.MILLISECONDS)
} }
private fun connectInternal() { private fun connectInternal() {
log.info("Connecting to WebSocket: $uri") log.info("Connecting to WebSocket: $uri")
connection?.dispose() connection?.dispose()
connection = null
val subscriptionId = AtomicReference<String>("NOTSET")
val objectMapper = Global.objectMapper
connection = HttpClient.create() connection = HttpClient.create()
.doOnDisconnected {
log.info("Disconnected from $uri")
// mark upstream as UNAVAIL
upstream?.setStatus(UpstreamAvailability.UNAVAILABLE)
if (keepConnection) {
tryReconnectLater()
}
}
.doOnError( .doOnError(
{ _, t -> { _, t ->
log.warn("Failed to connect to $uri. Error: ${t.message}") log.warn("Failed to connect to $uri. Error: ${t.message}")
// going to try to reconnect later // going to try to reconnect later
tryReconnectLater() tryReconnectLater()
}, },
{ _, _ -> { _, _ -> }
}
) )
.headers { headers -> .headers { headers ->
headers.add(HttpHeaderNames.ORIGIN, origin) headers.add(HttpHeaderNames.ORIGIN, origin)
@@ -114,71 +204,110 @@ class EthereumWsFactory(
} }
} }
.let { .let {
if (uri.scheme == "wss") { if (uri.scheme == "wss") it.secure() else it
it.secure()
} else {
it
}
} }
.websocket( .websocket(
WebsocketClientSpec.builder() WebsocketClientSpec.builder()
.handlePing(true) .handlePing(true)
.compress(false) .compress(false)
.maxFramePayloadLength(frameSize)
.build() .build()
) )
.uri(uri) .uri(uri)
.handle { inbound, outbound -> .handle { inbound, outbound ->
val consumer = inbound.aggregateFrames() handle(inbound, outbound)
.aggregateFrames(8 * 65_536) }
.onErrorResume { t ->
log.debug("Dropping WS connection to $uri. Error: ${t.message}")
Mono.empty<Void>()
}
.subscribe()
}
fun handle(inbound: WebsocketInbound, outbound: WebsocketOutbound): Publisher<Void> {
//restart backoff after connection
currentBackOff = reconnectBackoff.start()
//validate the connection, it can also be UNAVAIL if market as such after disconnect
validator?.validate()
val consumer = inbound
.aggregateFrames(msgSizeLimit)
.receiveFrames() .receiveFrames()
.map { ByteBufInputStream(it.content()).readAllBytes() }
.flatMap { .flatMap {
val msg: SubscriptionJson = objectMapper.readerFor(SubscriptionJson::class.java) try {
.readValue(ByteBufInputStream(it.content()) as InputStream) val msg = parser.parse(it)
when { if (msg.type == ResponseWSParser.Type.SUBSCRIPTION) {
msg.error != null -> { onSubscription(msg)
Mono.error(IllegalStateException("Received error from WS upstream")) } else {
} onRpc(msg)
msg.subscription == subscriptionId.get() -> {
onNewBlock(msg.blockResult)
Mono.empty<Int>()
}
msg.subscription == null -> {
// received ID for subscription
subscriptionId.set(msg.result.asText())
log.debug("Connected to $uri")
Mono.empty<Int>()
}
else -> {
Mono.error(IllegalStateException("Unknown message received: ${msg.subscription}"))
} }
} catch (t: Throwable) {
log.warn("Failed to process WS message. ${t.javaClass}: ${t.message}")
Mono.empty()
} }
} }
.onErrorResume { t -> .onErrorResume { t ->
log.warn("Connection dropped to $uri. Error: ${t.message}") log.warn("Connection dropped to $uri. Error: ${t.message}", t)
// going to try to reconnect later // going to try to reconnect later
tryReconnectLater() tryReconnectLater()
// completes current outbound flow // completes current outbound flow
Mono.empty() Mono.empty()
} }
val start = Mono.just(START_REQUEST).map {
outbound.sendString(Mono.just(START_REQUEST) Unpooled.wrappedBuffer(it.toByteArray())
.doOnError { log.warn("Failed to start WS subscription. ${it.javaClass}: ${it.message}") })
.then(consumer.then())
} }
.doOnError { val calls = rpcSend
println(it) .asFlux()
} .map {
.subscribe() Unpooled.wrappedBuffer(Global.objectMapper.writeValueAsBytes(it))
} }
fun onNewBlock(block: BlockJson<TransactionRefJson>) { return outbound.send(
// WS returns incomplete blocks, i.e. without some fields, so need to fetch full block data Flux.merge(
if (block.difficulty == null || block.transactions == null) { start,
calls.subscribeOn(Schedulers.boundedElastic()),
consumer.then(Mono.empty<ByteBuf>()).subscribeOn(Schedulers.boundedElastic())
)
)
}
fun onRpc(msg: ResponseWSParser.WsResponse): Mono<Void> {
return if (msg.id.isNumber()) {
val resp = JsonRpcResponse(
msg.value, msg.error, msg.id
)
Mono.fromCallable {
val status = rpcReceive.tryEmitNext(resp)
if (status.isFailure) {
log.warn("Failed to proceed with a RPC message: $status")
}
}.then()
} else {
//it's a response to the newHeads subscription, just ignore it
Mono.empty<Void>()
}
}
fun onSubscription(msg: ResponseWSParser.WsResponse): Mono<Void> {
if (msg.error != null) {
return Mono.error(IllegalStateException("Received error from WS upstream: ${msg.error.message}"))
}
// we always expect an answer to the `newHeads`, since we are not initiating any other subscriptions
return Mono.fromCallable {
Global.objectMapper.readValue(msg.value, BlockJson::class.java) as BlockJson<TransactionRefJson>
}.flatMap { onNewHeads(it) }.then()
}
fun onNewHeads(block: BlockJson<TransactionRefJson>): Mono<Void> {
// newHeads returns incomplete blocks, i.e. without some fields and without transaction hashes,
// so we need to fetch the full block data
return if (block.difficulty == null || block.transactions == null) {
Mono.just(block.hash) Mono.just(block.hash)
.flatMap { hash -> .flatMap { hash ->
upstream.getApi() call(JsonRpcRequest("eth_getBlockByHash", listOf(hash.toHex(), false)))
.read(JsonRpcRequest("eth_getBlockByHash", listOf(hash.toHex(), false)))
.flatMap { resp -> .flatMap { resp ->
if (resp.isNull()) { if (resp.isNull()) {
Mono.error(SilentException("Received null for block $hash")) Mono.error(SilentException("Received null for block $hash"))
@@ -188,6 +317,8 @@ class EthereumWsFactory(
} }
.flatMap(JsonRpcResponse::requireResult) .flatMap(JsonRpcResponse::requireResult)
.map { BlockContainer.fromEthereumJson(it) } .map { BlockContainer.fromEthereumJson(it) }
.subscribeOn(Schedulers.boundedElastic())
.timeout(Defaults.timeoutInternal, Mono.empty())
}.repeatWhenEmpty { n -> }.repeatWhenEmpty { n ->
Repeat.times<Any>(5) Repeat.times<Any>(5)
.exponentialBackoff(Duration.ofMillis(50), Duration.ofMillis(500)) .exponentialBackoff(Duration.ofMillis(50), Duration.ofMillis(500))
@@ -195,17 +326,60 @@ class EthereumWsFactory(
} }
.timeout(Defaults.timeout, Mono.empty()) .timeout(Defaults.timeout, Mono.empty())
.onErrorResume { Mono.empty() } .onErrorResume { Mono.empty() }
.subscribe { .doOnNext {
topic.tryEmitNext(it) blocks.tryEmitNext(it)
} }
.then()
} else { } else {
topic.tryEmitNext(BlockContainer.from(block)) Mono.fromCallable {
blocks.tryEmitNext(BlockContainer.from(block))
}.then()
} }
} }
fun getFlux(): Flux<BlockContainer> { fun call(originalRequest: JsonRpcRequest): Mono<JsonRpcResponse> {
return this.topic.asFlux() return Mono.fromCallable {
val startTime = System.nanoTime()
// use an internal id sequence, to avoid id conflicts with user calls
val internalId = sendIdSeq.getAndIncrement()
val originalId = originalRequest.id
Tuples.of(originalRequest.copy(id = internalId), originalId, startTime)
}.flatMap { request ->
waitForResponse(request.t1, request.t2, request.t3)
}
}
fun sendRpc(request: JsonRpcRequest) {
// submit to upstream in a separate thread, to free current thread (needs for subscription, etc)
sendExecutor.execute {
val result = rpcSend.tryEmitNext(request)
if (result.isFailure) {
log.warn("Failed to send RPC request: $result")
}
}
}
fun waitForResponse(request: JsonRpcRequest, originalId: Int, startTime: Long): Mono<JsonRpcResponse> {
val expectedId = request.id.toLong()
return Mono.just(request)
.flatMap {
Flux.from(rpcReceive.asFlux())
.doOnSubscribe { sendRpc(request) }
.filter { resp -> resp.id.asNumber() == expectedId }
.take(1)
.singleOrEmpty()
.doOnNext {
rpcMetrics?.timer?.record(System.nanoTime() - startTime, TimeUnit.NANOSECONDS)
}
.doOnError {
rpcMetrics?.errors?.increment()
}
.map { it.copyWithId(JsonRpcResponse.Id.from(originalId)) }
}
}
fun getBlocksFlux(): Flux<BlockContainer> {
return this.blocks.asFlux()
} }
override fun close() { override fun close() {
@@ -216,5 +390,4 @@ class EthereumWsFactory(
} }
} }

View File

@@ -16,9 +16,11 @@
*/ */
package io.emeraldpay.dshackle.upstream.ethereum package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcWsClient
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.Lifecycle import org.springframework.context.Lifecycle
import reactor.core.Disposable import reactor.core.Disposable
import reactor.core.publisher.Flux
class EthereumWsHead( class EthereumWsHead(
private val ws: EthereumWsFactory.EthereumWs private val ws: EthereumWsFactory.EthereumWs
@@ -34,7 +36,12 @@ class EthereumWsHead(
override fun start() { override fun start() {
this.subscription?.dispose() this.subscription?.dispose()
this.subscription = super.follow(ws.getFlux()) val heads = Flux.merge(
// get the current block, not just wait for the next update
getLatestBlock(JsonRpcWsClient(ws)),
ws.getBlocksFlux()
)
this.subscription = super.follow(heads)
} }
override fun stop() { override fun stop() {

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.startup.QuorumForLabels
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.Upstream
import io.emeraldpay.dshackle.upstream.calls.CallMethods
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcWsClient
import io.emeraldpay.dshackle.upstream.rpcclient.RpcMetrics
import io.emeraldpay.grpc.Chain
import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.Metrics
import io.micrometer.core.instrument.Tag
import io.micrometer.core.instrument.Timer
import org.slf4j.LoggerFactory
import org.springframework.context.Lifecycle
import reactor.core.Disposable
class EthereumWsUpstream(
id: String,
val chain: Chain,
ethereumWsFactory: EthereumWsFactory,
options: UpstreamsConfig.Options,
role: UpstreamsConfig.UpstreamRole,
node: QuorumForLabels.QuorumItem,
targets: CallMethods
) : EthereumUpstream(id, options, role, targets, node), Upstream, Lifecycle {
companion object {
private val log = LoggerFactory.getLogger(EthereumWsUpstream::class.java)
}
private val head: EthereumWsHead
private val connection: EthereumWsFactory.EthereumWs
private val api: JsonRpcWsClient
private var validatorSubscription: Disposable? = null
private val validator: EthereumUpstreamValidator
init {
val metricsTags = listOf(
Tag.of("upstream", id),
// UNSPECIFIED shouldn't happen too
Tag.of("chain", chain.chainCode)
)
val metrics = RpcMetrics(
Timer.builder("upstream.ws.conn")
.description("Request time through a WebSocket JSON RPC connection")
.tags(metricsTags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry),
Counter.builder("upstream.ws.err")
.description("Errors received on request through WebSocket JSON RPC connection")
.tags(metricsTags)
.register(Metrics.globalRegistry)
)
validator = EthereumUpstreamValidator(this, getOptions())
connection = ethereumWsFactory.create(this, validator, metrics)
head = EthereumWsHead(connection)
api = JsonRpcWsClient(connection)
}
override fun getHead(): Head {
return head
}
override fun getApi(): Reader<JsonRpcRequest, JsonRpcResponse> {
return api
}
override fun isGrpc(): Boolean {
return false
}
@Suppress("UNCHECKED_CAST")
override fun <T : Upstream> cast(selfType: Class<T>): T {
if (!selfType.isAssignableFrom(this.javaClass)) {
throw ClassCastException("Cannot cast ${this.javaClass} to $selfType")
}
return this as T
}
override fun start() {
connection.connect()
head.start()
log.debug("Start validation for upstream ${this.getId()}")
validatorSubscription = validator.start()
.subscribe(this::setStatus)
}
override fun stop() {
validatorSubscription?.dispose()
validatorSubscription = null
head.stop()
connection.close()
}
override fun isRunning(): Boolean {
return head.isRunning
}
}

View File

@@ -123,8 +123,8 @@ class NativeCallRouter(
} }
} }
fun getBlockByNumber(params: List<Any>): Mono<ByteArray>? { fun getBlockByNumber(params: List<Any?>): Mono<ByteArray>? {
if (params.size != 2) { if (params.size != 2 || params[0] == null || params[1] == null) {
throw RpcException(RpcResponseError.CODE_INVALID_METHOD_PARAMS, "Must provide 2 parameters") throw RpcException(RpcResponseError.CODE_INVALID_METHOD_PARAMS, "Must provide 2 parameters")
} }
val number: Long val number: Long
@@ -155,7 +155,7 @@ class NativeCallRouter(
} }
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
throw RpcException(RpcResponseError.CODE_INVALID_METHOD_PARAMS, "[0] must be block number") throw RpcException(RpcResponseError.CODE_INVALID_METHOD_PARAMS, "[0] must be a block number")
} }
val withTx = params[1].toString().toBoolean() val withTx = params[1].toString().toBoolean()
var block = reader.blocksByHeightAsCont() var block = reader.blocksByHeightAsCont()

View File

@@ -52,5 +52,9 @@ class JsonRpcError(val code: Int, val message: String, val details: Any?) {
return result return result
} }
override fun toString(): String {
return "JsonRpcError(code=$code, message='$message', details=$details)"
}
} }

View File

@@ -19,8 +19,6 @@ import io.emeraldpay.dshackle.config.AuthConfig
import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.etherjar.rpc.RpcException import io.emeraldpay.etherjar.rpc.RpcException
import io.emeraldpay.etherjar.rpc.RpcResponseError import io.emeraldpay.etherjar.rpc.RpcResponseError
import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.Timer
import io.netty.buffer.Unpooled import io.netty.buffer.Unpooled
import io.netty.handler.codec.http.HttpHeaderNames import io.netty.handler.codec.http.HttpHeaderNames
import io.netty.handler.codec.http.HttpHeaders import io.netty.handler.codec.http.HttpHeaders
@@ -50,7 +48,7 @@ class JsonRpcHttpClient(
private val log = LoggerFactory.getLogger(JsonRpcHttpClient::class.java) private val log = LoggerFactory.getLogger(JsonRpcHttpClient::class.java)
} }
private val parser = JsonRpcParser() private val parser = ResponseRpcParser()
private val httpClient: HttpClient private val httpClient: HttpClient
init { init {

View File

@@ -1,111 +0,0 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.rpcclient
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import io.emeraldpay.dshackle.Global
import io.emeraldpay.etherjar.rpc.RpcResponseError
import org.slf4j.LoggerFactory
class JsonRpcParser() {
companion object {
private val log = LoggerFactory.getLogger(JsonRpcParser::class.java)
}
private val jsonFactory = JsonFactory()
fun parse(json: ByteArray): JsonRpcResponse {
try {
val parser: JsonParser = jsonFactory.createParser(json)
parser.nextToken()
if (parser.currentToken != JsonToken.START_OBJECT) {
return JsonRpcResponse(null, JsonRpcError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON"))
}
var nullResponse: JsonRpcResponse? = null
while (parser.nextToken() != JsonToken.END_OBJECT) {
val field = parser.currentName
if (field == "jsonrpc" || field == "id") {
if (!parser.nextToken().isScalarValue) {
return JsonRpcResponse(null, JsonRpcError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON (id or jsonrpc value)"))
}
// just skip the field
} else if (field == "result") {
val value = parser.nextToken()
val start = parser.tokenLocation
if (value.isScalarValue) {
val text = parser.text
if (value == JsonToken.VALUE_STRING) {
return JsonRpcResponse(("\"" + text + "\"").toByteArray(), null)
} else if (value == JsonToken.VALUE_NULL) {
//if null we should check if error is present
nullResponse = JsonRpcResponse(text.toByteArray(), null)
} else {
return JsonRpcResponse(text.toByteArray(), null)
}
} else if (value == JsonToken.START_OBJECT || value == JsonToken.START_ARRAY) {
parser.skipChildren()
val end = parser.currentLocation.byteOffset.toInt()
val copy = ByteArray((end - start.byteOffset).toInt())
System.arraycopy(json, start.byteOffset.toInt(), copy, 0, copy.size)
return JsonRpcResponse(copy, null)
}
} else if (field == "error") {
val err = readError(parser)
if (err != null) {
return JsonRpcResponse(null, err)
}
}
}
if (nullResponse != null) {
return nullResponse
}
} catch (e: JsonParseException) {
log.warn("Failed to parse JSON from upstream: ${e.message}")
}
return JsonRpcResponse(null, JsonRpcError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON structure"))
}
fun readError(parser: JsonParser): JsonRpcError? {
var code = 0
var message = ""
var details: Any? = null
while (parser.nextToken() != JsonToken.END_OBJECT) {
if (parser.currentToken() == JsonToken.VALUE_NULL) {
// error is just null
return null
}
val field = parser.currentName()
if (field == "code" && parser.currentToken == JsonToken.VALUE_NUMBER_INT) {
code = parser.intValue
} else if (field == "message" && parser.currentToken == JsonToken.VALUE_STRING) {
message = parser.valueAsString
} else if (field == "data") {
when (val value = parser.nextToken()) {
JsonToken.VALUE_NULL -> details = null
JsonToken.VALUE_STRING -> details = parser.valueAsString
JsonToken.START_OBJECT -> details = Global.objectMapper.readValue(parser, java.util.Map::class.java)
else -> log.warn("Unsupported error data type $value")
}
}
}
return JsonRpcError(code, message, details)
}
}

View File

@@ -15,40 +15,55 @@
*/ */
package io.emeraldpay.dshackle.upstream.rpcclient package io.emeraldpay.dshackle.upstream.rpcclient
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.Global
class JsonRpcRequest( data class JsonRpcRequest(
val method: String, val method: String,
val params: List<Any> val params: List<Any?>,
val id: Int
) { ) {
constructor(method: String, params: List<Any?>) : this(method, params, 1)
fun toJson(): ByteArray { fun toJson(): ByteArray {
val json = mapOf( val json = mapOf(
"jsonrpc" to "2.0", "jsonrpc" to "2.0",
"id" to 1, "id" to id,
"method" to method, "method" to method,
"params" to params "params" to params
) )
return Global.objectMapper.writeValueAsBytes(json) return Global.objectMapper.writeValueAsBytes(json)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is JsonRpcRequest) return false
if (method != other.method) return false
if (params != other.params) return false
return true
}
override fun hashCode(): Int {
var result = method.hashCode()
result = 31 * result + params.hashCode()
return result
}
override fun toString(): String { override fun toString(): String {
return String(this.toJson()) return String(this.toJson())
} }
class Deserializer : JsonDeserializer<JsonRpcRequest>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): JsonRpcRequest {
val node: JsonNode = p.readValueAsTree()
val id = node.get("id").intValue()
val method = node.get("method").textValue()
val params = node.get("params").map {
if (it.isNumber) {
it.asInt()
} else if (it.isTextual) {
it.textValue()
} else if (it.isBoolean) {
it.booleanValue()
} else if (it.isNull) {
null
} else {
throw IllegalStateException("Unsupported param type: ${it.asToken()}")
}
}
return JsonRpcRequest(method, params, id)
}
}
} }

View File

@@ -106,6 +106,10 @@ class JsonRpcResponse(
} }
} }
fun copyWithId(id: Id): JsonRpcResponse {
return JsonRpcResponse(result, error, id)
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is JsonRpcResponse) return false if (other !is JsonRpcResponse) return false
@@ -177,6 +181,10 @@ class JsonRpcResponse(
override fun hashCode(): Int { override fun hashCode(): Int {
return id.hashCode() return id.hashCode()
} }
override fun toString(): String {
return id.toString()
}
} }
class StringId(val id: String) : Id { class StringId(val id: String) : Id {
@@ -205,6 +213,9 @@ class JsonRpcResponse(
return id.hashCode() return id.hashCode()
} }
override fun toString(): String {
return id
}
} }
class ResponseJsonSerializer : JsonSerializer<JsonRpcResponse>() { class ResponseJsonSerializer : JsonSerializer<JsonRpcResponse>() {

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.rpcclient
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.ethereum.EthereumWsFactory
import reactor.core.publisher.Mono
class JsonRpcWsClient(
private val ws: EthereumWsFactory.EthereumWs
) : Reader<JsonRpcRequest, JsonRpcResponse> {
override fun read(key: JsonRpcRequest): Mono<JsonRpcResponse> {
return ws.call(key)
}
}

View File

@@ -0,0 +1,182 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.rpcclient
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import io.emeraldpay.dshackle.Global
import io.emeraldpay.etherjar.rpc.RpcResponseError
import org.slf4j.LoggerFactory
import java.io.IOException
abstract class ResponseParser<T> {
companion object {
private val log = LoggerFactory.getLogger(ResponseParser::class.java)
}
private val jsonFactory = JsonFactory()
abstract fun build(state: Preparsed): T
fun parse(json: ByteArray): T {
return build(parseInternal(json))
}
private fun parseInternal(json: ByteArray): Preparsed {
var state = Preparsed()
try {
val parser: JsonParser = jsonFactory.createParser(json)
parser.nextToken()
if (parser.currentToken != JsonToken.START_OBJECT) {
return Preparsed(error = JsonRpcError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON: not an Object"))
}
while (parser.nextToken() != JsonToken.END_OBJECT) {
val field = parser.currentName
state = process(parser, json, field, state)
}
} catch (e: JsonParseException) {
log.warn("Failed to parse JSON from upstream: ${e.message}")
}
if (state.isReady) {
return state
}
return Preparsed(error = JsonRpcError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON structure: never finalized"))
}
open fun process(parser: JsonParser, json: ByteArray, field: String, state: Preparsed): Preparsed {
if (field == "jsonrpc") {
if (!parser.nextToken().isScalarValue) {
return state.copy(error = JsonRpcError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON (jsonrpc value)"))
}
// just skip the field
return state
} else if (field == "id") {
return state.copy(id = readId(parser))
} else if (field == "result") {
val result = readResult(json, parser)
return if (result == null) {
//if result is null we should check if an error is also present, and if it's set then return only the error
state.copy(nullResult = true)
} else {
state.copy(result = result)
}
} else if (field == "error") {
val err = readError(parser)
if (err != null) {
return state.copy(error = err)
}
}
return state
}
private fun readId(parser: JsonParser): JsonRpcResponse.Id {
if (parser.currentToken() == JsonToken.FIELD_NAME) {
parser.nextToken()
}
return if (parser.currentToken() == JsonToken.VALUE_NUMBER_INT) {
JsonRpcResponse.NumberId(parser.intValue)
} else if (parser.currentToken() == JsonToken.VALUE_STRING) {
JsonRpcResponse.StringId(parser.text)
} else {
throw IllegalStateException("Not a string or number: ${parser.currentToken()}")
}
}
@Throws(IOException::class)
private fun readNumber(parser: JsonParser): Int {
if (parser.currentToken() != JsonToken.VALUE_NUMBER_INT) {
parser.nextToken()
}
if (!parser.currentToken().isNumeric) {
throw IllegalStateException("Not a number: ${parser.currentToken.name}")
}
return parser.intValue
}
fun readResult(json: ByteArray, parser: JsonParser): ByteArray? {
val value = parser.nextToken()
val start = parser.tokenLocation
if (value.isScalarValue) {
val text = parser.text
return if (value == JsonToken.VALUE_STRING) {
("\"" + text + "\"").toByteArray()
} else if (value == JsonToken.VALUE_NULL) {
null
} else {
text.toByteArray()
}
} else if (value == JsonToken.START_OBJECT || value == JsonToken.START_ARRAY) {
parser.skipChildren()
val end = parser.currentLocation.byteOffset.toInt()
val copy = ByteArray((end - start.byteOffset).toInt())
System.arraycopy(json, start.byteOffset.toInt(), copy, 0, copy.size)
return copy
} else {
throw IllegalStateException("Invalid JSON structure, cannot read result from ${value.name}")
}
}
fun readError(parser: JsonParser): JsonRpcError? {
var code = 0
var message = ""
var details: Any? = null
while (parser.nextToken() != JsonToken.END_OBJECT) {
if (parser.currentToken() == JsonToken.VALUE_NULL) {
// error is just null
return null
}
val field = parser.currentName()
if (field == "code" && parser.currentToken == JsonToken.VALUE_NUMBER_INT) {
code = parser.intValue
} else if (field == "message" && parser.currentToken == JsonToken.VALUE_STRING) {
message = parser.valueAsString
} else if (field == "data") {
when (val value = parser.nextToken()) {
JsonToken.VALUE_NULL -> details = null
JsonToken.VALUE_STRING -> details = parser.valueAsString
JsonToken.START_OBJECT -> details = Global.objectMapper.readValue(parser, java.util.Map::class.java)
else -> log.warn("Unsupported error data type $value")
}
}
}
return JsonRpcError(code, message, details)
}
data class Preparsed(
val id: JsonRpcResponse.Id? = null,
val result: ByteArray? = null,
val nullResult: Boolean = false,
val error: JsonRpcError? = null,
val subMethod: String? = null,
val subId: String? = null
) {
private val isResultSet = result != null || nullResult
val isRpcReady: Boolean = id != null &&
(error != null || isResultSet)
val isSubReady: Boolean = subId != null &&
isResultSet
val isReady: Boolean = isRpcReady || isSubReady
}
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.rpcclient
import org.slf4j.LoggerFactory
open class ResponseRpcParser() : ResponseParser<JsonRpcResponse>() {
companion object {
private val log = LoggerFactory.getLogger(ResponseRpcParser::class.java)
}
override fun build(state: Preparsed): JsonRpcResponse {
if (state.error != null) {
return JsonRpcResponse(null, state.error, state.id ?: JsonRpcResponse.Id.from(-1))
}
if (state.nullResult) {
return JsonRpcResponse("null".toByteArray(), null, state.id ?: JsonRpcResponse.Id.from(-1))
}
return JsonRpcResponse(state.result, null, state.id ?: JsonRpcResponse.Id.from(-1))
}
}

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.rpcclient
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import org.slf4j.LoggerFactory
import java.io.IOException
class ResponseWSParser : ResponseParser<ResponseWSParser.WsResponse>() {
companion object {
private val log = LoggerFactory.getLogger(ResponseWSParser::class.java)
private val NULL_RESULT = "null".toByteArray()
}
override fun build(state: Preparsed): WsResponse {
if (state.isRpcReady) {
return WsResponse(
Type.RPC,
state.id!!,
if (state.nullResult) NULL_RESULT else state.result,
state.error
)
}
if (state.isSubReady) {
return WsResponse(
Type.SUBSCRIPTION,
JsonRpcResponse.Id.from(state.subId!!),
if (state.nullResult) NULL_RESULT else state.result,
state.error
)
}
throw IllegalStateException("State is not ready")
}
override fun process(parser: JsonParser, json: ByteArray, field: String, state: Preparsed): Preparsed {
if ("method" == field) {
parser.nextToken()
val method = parser.getValueAsString()
return state.copy(subMethod = method)
}
if ("params" == field) {
// example:
// newHeads
// {
// "jsonrpc": "2.0",
// "method": "eth_subscription",
// "params": {
// "result": {
// "difficulty": ......
// },
// "subscription": "...."
// }
//}
return decodeSubscription(parser, json, state)
}
return super.process(parser, json, field, state)
}
@Throws(IOException::class)
private fun decodeString(parser: JsonParser): String {
if (parser.currentToken() != JsonToken.VALUE_STRING) {
parser.nextToken()
}
check(parser.currentToken().isScalarValue) { "Id is not a string" }
return parser.valueAsString
}
@Throws(IOException::class)
protected fun decodeSubscription(parser: JsonParser, json: ByteArray, stateOriginal: Preparsed): Preparsed {
var state = stateOriginal
while (parser.nextToken() != JsonToken.END_OBJECT) {
checkNotNull(parser.currentToken()) { "JSON finished before data received" }
val field = parser.currentName()
if ("subscription" == field) {
state = state.copy(subId = decodeString(parser))
} else if ("result" == field) {
state = state.copy(result = readResult(json, parser))
}
}
return state
}
enum class Type {
SUBSCRIPTION, RPC
}
data class WsResponse(
val type: Type,
val id: JsonRpcResponse.Id,
val value: ByteArray?,
val error: JsonRpcError?
)
}

View File

@@ -74,6 +74,58 @@ class UpstreamsConfigReaderSpec extends Specification {
} }
} }
def "Parse websocket-only config"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-ws-only.yaml")
when:
def act = reader.read(config)
then:
act != null
act.upstreams.size() == 1
with(act.upstreams.get(0)) {
id == "local"
chain == "ethereum"
connection instanceof UpstreamsConfig.EthereumConnection
with((UpstreamsConfig.EthereumConnection) connection) {
rpc == null
ws != null
ws.url == new URI("ws://localhost:8546")
ws.basicAuth != null
with(ws.basicAuth) {
username == "9c199ad8f281f20154fc258fe41a6814"
password == "258fe4149c199ad8f2811a68f20154fc"
}
}
}
}
def "Parse full defined websocket config"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-ws-full.yaml")
when:
def act = reader.read(config)
then:
act != null
act.upstreams.size() == 1
with(act.upstreams.get(0)) {
id == "local"
chain == "ethereum"
connection instanceof UpstreamsConfig.EthereumConnection
with((UpstreamsConfig.EthereumConnection) connection) {
rpc == null
ws != null
ws.url == new URI("ws://localhost:8546")
ws.basicAuth != null
with(ws.basicAuth) {
username == "9c199ad8f281f20154fc258fe41a6814"
password == "258fe4149c199ad8f2811a68f20154fc"
}
ws.frameSize == 10 * 1024 * 1024
ws.msgSize == 25 * 1024 * 1024
}
}
}
def "Parse bitcoin upstreams"() { def "Parse bitcoin upstreams"() {
setup: setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-bitcoin.yaml") def config = this.class.getClassLoader().getResourceAsStream("upstreams-bitcoin.yaml")

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.config
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.nodes.MappingNode
import spock.lang.Specification
class YamlConfigReaderSpec extends Specification {
def "reads bytes values"() {
setup:
def rdr = new Impl()
expect:
rdr.getValueAsBytes(asNode("test", input), "test") == exp
where:
input | exp
"1024" | 1024
"1k" | 1024
"1kb" | 1024
"1K" | 1024
"16kb" | 16 * 1024
"1M" | 1024 * 1024
"4mb" | 4 * 1024 * 1024
}
private MappingNode asNode(String key, String value) {
return new Yaml().compose(new StringReader("$key: $value")) as MappingNode
}
class Impl extends YamlConfigReader {}
}

View File

@@ -27,13 +27,34 @@ import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.grpc.stub.StreamObserver import io.grpc.stub.StreamObserver
import io.emeraldpay.etherjar.rpc.RpcResponseError import io.emeraldpay.etherjar.rpc.RpcResponseError
import io.emeraldpay.etherjar.rpc.json.ResponseJson import io.emeraldpay.etherjar.rpc.json.ResponseJson
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator
import io.netty.buffer.ByteBufInputStream
import io.netty.buffer.Unpooled
import io.netty.handler.codec.http.HttpHeaders
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame
import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus
import io.netty.handler.codec.http.websocketx.WebSocketFrame
import org.jetbrains.annotations.NotNull import org.jetbrains.annotations.NotNull
import org.reactivestreams.Publisher
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.publisher.Sinks
import reactor.netty.ByteBufFlux
import reactor.netty.Connection
import reactor.netty.NettyInbound
import reactor.netty.NettyOutbound
import reactor.netty.http.websocket.WebsocketInbound
import reactor.netty.http.websocket.WebsocketOutbound
import reactor.util.annotation.Nullable
import java.time.Duration import java.time.Duration
import java.util.concurrent.Callable import java.util.concurrent.Callable
import java.util.function.BiFunction
import java.util.function.Consumer
import java.util.function.Predicate
class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> { class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
@@ -57,7 +78,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
} }
@Override @Override
Mono<JsonRpcResponse> read(JsonRpcRequest request) { Mono<JsonRpcResponse> read(JsonRpcRequest request, boolean required = true) {
Callable<JsonRpcResponse> call = { Callable<JsonRpcResponse> call = {
def predefined = predefined.find { it.isSame(request.method, request.params) } def predefined = predefined.find { it.isSame(request.method, request.params) }
byte[] result = null byte[] result = null
@@ -65,7 +86,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
if (predefined != null) { if (predefined != null) {
if (predefined.exception != null) { if (predefined.exception != null) {
predefined.onCalled() predefined.onCalled()
predefined.print() predefined.print(request.id)
throw predefined.exception throw predefined.exception
} }
if (predefined.result instanceof RpcResponseError) { if (predefined.result instanceof RpcResponseError) {
@@ -77,12 +98,15 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
result = objectMapper.writeValueAsBytes(predefined.result) result = objectMapper.writeValueAsBytes(predefined.result)
} }
predefined.onCalled() predefined.onCalled()
predefined.print() predefined.print(request.id)
} else { } else {
log.error("Method ${request.method} with ${request.params} is not mocked") log.error("Method ${request.method} with ${request.params} is not mocked")
if (!required) {
return null
}
error = new JsonRpcError(-32601, "Method ${request.method} with ${request.params} is not mocked") error = new JsonRpcError(-32601, "Method ${request.method} with ${request.params} is not mocked")
} }
return new JsonRpcResponse(result, error) return new JsonRpcResponse(result, error, JsonRpcResponse.Id.from(request.id))
} as Callable<JsonRpcResponse> } as Callable<JsonRpcResponse>
return Mono.fromCallable(call) return Mono.fromCallable(call)
} }
@@ -104,6 +128,10 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
responseObserver.onCompleted() responseObserver.onCompleted()
} }
WebsocketApi asWebsocket() {
return new WebsocketApi(this)
}
class PredefinedResponse { class PredefinedResponse {
String method String method
List params List params
@@ -132,8 +160,202 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
} }
} }
void print() { void print(int id) {
println "Execute API: $method ${params ? params : '_'} >> $result" println "Execute API: $id $method ${params ? params : '_'} >> $result"
}
}
class WebsocketApi {
private final EthereumApiMock api
private Sinks.Many<JsonRpcResponse> responses = Sinks
.many()
.unicast()
.onBackpressureBuffer()
private Sinks.Many<String> jsonResponses = Sinks
.many()
.unicast()
.onBackpressureBuffer()
private WebsocketOutboundMock outbound
private WebsocketInboundMock inbound
WebsocketApi(EthereumApiMock api) {
this.api = api
outbound = new WebsocketOutboundMock(api, responses)
inbound = new WebsocketInboundMock(responses.asFlux(), jsonResponses.asFlux())
}
boolean send(String json) {
jsonResponses.tryEmitNext(json).success
}
WebsocketOutbound getOutbound() {
return outbound
}
WebsocketInbound getInbound() {
return inbound
}
}
class WebsocketInboundMock implements WebsocketInbound {
private final Flux<JsonRpcResponse> responses
private final Flux<String> jsonResponses
WebsocketInboundMock(Flux<JsonRpcResponse> responses, Flux<String> jsonResponses) {
this.responses = responses
this.jsonResponses = jsonResponses
}
@Override
String selectedSubprotocol() {
throw new UnsupportedOperationException()
}
@Override
HttpHeaders headers() {
throw new UnsupportedOperationException()
}
@Override
Mono<WebSocketCloseStatus> receiveCloseStatus() {
return Mono.empty()
}
@Override
ByteBufFlux receive() {
throw new UnsupportedOperationException()
}
@Override
Flux<?> receiveObject() {
throw new UnsupportedOperationException()
}
@Override
NettyInbound withConnection(Consumer<? super Connection> withConnection) {
return this
}
@Override
Flux<WebSocketFrame> receiveFrames() {
return Flux.merge(
jsonResponses,
responses.map {
Global.objectMapper.writeValueAsString(it)
})
.map {
println("WS server->client msg: $it")
new TextWebSocketFrame(it)
}
.doOnError { t ->
t.printStackTrace()
}
}
}
class WebsocketOutboundMock implements WebsocketOutbound {
private final EthereumApiMock api
private final Sinks.Many<JsonRpcResponse> responses
WebsocketOutboundMock(EthereumApiMock api, Sinks.Many<JsonRpcResponse> responses) {
this.api = api
this.responses = responses
}
@Override
String selectedSubprotocol() {
throw new UnsupportedOperationException()
}
@Override
ByteBufAllocator alloc() {
throw new UnsupportedOperationException()
}
private void handle(Publisher<ByteBuf> dataStream) {
Flux.from(dataStream)
.map { it ->
Global.objectMapper.readValue(new ByteBufInputStream(it), JsonRpcRequest)
}
.flatMap { JsonRpcRequest request ->
api.read(request, false)
}
.doOnNext {
def status = responses.tryEmitNext(it)
if (status.isFailure()) {
println("Failed to send through mock: $status")
}
}
.subscribe()
}
@Override
NettyOutbound send(Publisher<? extends ByteBuf> dataStream) {
handle(dataStream)
return this
}
@Override
NettyOutbound send(Publisher<? extends ByteBuf> dataStream, Predicate<ByteBuf> predicate) {
handle(dataStream)
return this
}
@Override
NettyOutbound sendObject(Publisher<?> dataStream, Predicate<Object> predicate) {
def msgs = Flux.from(dataStream)
.cast(TextWebSocketFrame)
.map {
Unpooled.wrappedBuffer(it.text().bytes)
}
handle(msgs)
return this
}
@Override
NettyOutbound sendObject(Object message) {
return this
}
@Override
def <S> NettyOutbound sendUsing(Callable<? extends S> sourceInput, BiFunction<? super Connection, ? super S, ?> mappedInput, Consumer<? super S> sourceCleanup) {
return this
}
@Override
NettyOutbound withConnection(Consumer<? super Connection> withConnection) {
return this
}
@Override
Mono<Void> sendClose() {
return Mono.fromCallable {
responses.tryEmitComplete()
}.then()
}
@Override
Mono<Void> sendClose(int rsv) {
return Mono.fromCallable {
responses.tryEmitComplete()
}.then()
}
@Override
Mono<Void> sendClose(int statusCode, @Nullable String reasonText) {
return Mono.fromCallable {
responses.tryEmitComplete()
}.then()
}
@Override
Mono<Void> sendClose(int rsv, int statusCode, @Nullable String reasonText) {
return Mono.fromCallable {
responses.tryEmitComplete()
}.then()
} }
} }
} }

View File

@@ -0,0 +1,90 @@
package io.emeraldpay.dshackle.test
import com.fasterxml.jackson.databind.util.ByteBufferBackedInputStream
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import org.joda.time.format.DateTimeFormat
import java.nio.ByteBuffer
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
class MockWSServer extends WebSocketServer {
private def format = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
List<ReceivedMessage> received = []
private WebSocket conn
private String next
MockWSServer(int port) {
super(new InetSocketAddress("127.0.0.1", port))
}
void log(String msg) {
println(format.format(Instant.now().atZone(ZoneId.systemDefault())) + " MOCKWS: " + msg)
}
void reply(String message) {
log(">> $message")
if (conn == null) {
log("MOCKWS: ERROR, no active connection")
}
conn.send(message)
}
void onNextReply(String message) {
next = message
}
@Override
void onOpen(WebSocket conn, ClientHandshake handshake) {
this.conn = conn
log("Opened connection from ${conn.remoteSocketAddress}")
}
@Override
void onClose(WebSocket conn, int code, String reason, boolean remote) {
this.conn = null
log("Connection closed, code ${code} with msg '${reason}' ${remote ? 'by remote' : 'by server'}")
}
@Override
void onMessage(WebSocket conn, String message) {
log("<< $message")
received.add(new ReceivedMessage(message))
if (next != null) {
reply(next)
next = null
}
}
@Override
void onMessage(WebSocket conn, ByteBuffer message) {
onMessage(conn, new ByteBufferBackedInputStream(message).text)
}
@Override
void onError(WebSocket conn, Exception ex) {
log("ERROR, $ex.message")
received.add(new ReceivedMessage("Err: ${ex.message}"))
}
@Override
void onStart() {
log("Server started")
}
class ReceivedMessage {
final String value
ReceivedMessage(String value) {
this.value = value
}
}
}

View File

@@ -0,0 +1,164 @@
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.test.MockWSServer
import io.emeraldpay.dshackle.upstream.DefaultUpstream
import io.emeraldpay.dshackle.upstream.UpstreamAvailability
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import reactor.test.StepVerifier
import spock.lang.Shared
import spock.lang.Specification
import java.time.Duration
class EthereumWsFactoryRealSpec extends Specification {
static SLEEP = 500
static int port = 19900 + new Random().nextInt(100)
@Shared
MockWSServer server
@Shared
EthereumWsFactory.EthereumWs conn
def setup() {
if (System.getenv("CI") == "true") {
println("RUN IN CI ENVIRONMENT")
// needs large timeouts on CI where it's much slower to run
SLEEP = 1500
}
port++
server = new MockWSServer(port)
server.start()
Thread.sleep(SLEEP)
conn = new EthereumWsFactory("ws://localhost:${port}".toURI(), "http://localhost:${port}".toURI()).create(null, null, null)
}
def cleanup() {
conn.close()
server.stop()
}
def "Connects to server"() {
when:
conn.connect()
Thread.sleep(SLEEP)
println("verify....")
def act = server.received
then:
act.size() > 0
act[0].value.contains("\"method\":\"eth_subscribe\"")
act[0].value.contains("\"params\":[\"newHeads\"]")
}
def "Makes RPC request"() {
when:
conn.connect()
def resp = conn.call(new JsonRpcRequest("foo_bar", []))
then:
StepVerifier.create(resp)
.then {
server.onNextReply('{"jsonrpc":"2.0", "id":100, "result": "baz"}')
}
.expectNextMatches {
it.hasResult() && it.resultAsProcessedString == "baz"
}
.expectComplete()
.verify(Duration.ofSeconds(3))
when:
Thread.sleep(SLEEP)
def act = server.received
then:
act.size() == 2
act[1].value.contains("\"method\":\"foo_bar\"")
}
def "Reconnects after server disconnect"() {
when:
conn.connect()
conn.reconnectIntervalSeconds = 2
Thread.sleep(SLEEP)
server.stop()
Thread.sleep(SLEEP)
server = new MockWSServer(port)
server.start()
def resp = conn.call(new JsonRpcRequest("foo_bar", []))
// reconnects in 2 seconds, give 1 extra
Thread.sleep(3_000)
def act = server.received
then:
act.size() > 0
act[0].value.contains("\"method\":\"eth_subscribe\"")
act[0].value.contains("\"params\":[\"newHeads\"]")
}
def "Gets UNAVAIL status right after disconnect"() {
setup:
def up = Mock(DefaultUpstream)
conn = new EthereumWsFactory("ws://localhost:${port}".toURI(), "http://localhost:${port}".toURI()).create(up, null, null)
when:
conn.connect()
conn.reconnectIntervalSeconds = 10
Thread.sleep(SLEEP)
server.stop()
Thread.sleep(100)
then:
1 * up.setStatus(UpstreamAvailability.UNAVAILABLE)
}
def "Validates after connect"() {
setup:
def validator = Mock(EthereumUpstreamValidator)
conn = new EthereumWsFactory("ws://localhost:${port}".toURI(), "http://localhost:${port}".toURI()).create(null, validator, null)
when:
conn.connect()
Thread.sleep(100)
then:
1 * validator.validate()
}
def "Try to connects to server until it's available"() {
when:
server.stop()
Thread.sleep(SLEEP)
conn.reconnectIntervalSeconds = 1
conn.connect()
Thread.sleep(3_000)
server = new MockWSServer(port)
server.start()
Thread.sleep(2_000)
def act = server.received
then:
act.size() > 0
act[0].value.contains("\"method\":\"eth_subscribe\"")
act[0].value.contains("\"params\":[\"newHeads\"]")
}
def "Call after reconnect"() {
when:
conn.connect()
conn.reconnectIntervalSeconds = 2
Thread.sleep(SLEEP)
server.stop()
Thread.sleep(SLEEP)
server = new MockWSServer(port)
server.start()
// reconnects in 2 seconds, give 1 extra
Thread.sleep(3_000)
def resp = conn.call(new JsonRpcRequest("foo_bar", []))
then:
StepVerifier.create(resp)
.then {
server.onNextReply('{"jsonrpc":"2.0", "id":100, "result": "baz"}')
}
.expectNextMatches {
it.hasResult() && it.resultAsProcessedString == "baz"
}
.expectComplete()
.verify(Duration.ofSeconds(3))
}
}

View File

@@ -15,11 +15,15 @@
*/ */
package io.emeraldpay.dshackle.upstream.ethereum package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.cache.BlocksMemCache import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.test.TestingCommons import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.etherjar.domain.BlockHash import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId
import io.emeraldpay.etherjar.rpc.RpcResponseError
import io.emeraldpay.etherjar.rpc.json.BlockJson import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.test.StepVerifier import reactor.test.StepVerifier
@@ -34,7 +38,6 @@ class EthereumWsFactorySpec extends Specification {
def "Fetch block"() { def "Fetch block"() {
setup: setup:
def wsf = new EthereumWsFactory(new URI("http://localhost"), new URI("http://localhost")) def wsf = new EthereumWsFactory(new URI("http://localhost"), new URI("http://localhost"))
def blocksCache = Mock(BlocksMemCache)
def block = new BlockJson<TransactionRefJson>() def block = new BlockJson<TransactionRefJson>()
block.number = 100 block.number = 100
@@ -44,20 +47,98 @@ class EthereumWsFactorySpec extends Specification {
block.uncles = [] block.uncles = []
block.totalDifficulty = BigInteger.ONE block.totalDifficulty = BigInteger.ONE
def headBlock = block.copy().tap {
it.transactions = null
}
def apiMock = TestingCommons.api() def apiMock = TestingCommons.api()
def upstream = TestingCommons.upstream(apiMock) def wsApiMock = apiMock.asWebsocket()
def ws = wsf.create(upstream) def ws = wsf.create(null, null, null)
apiMock.answerOnce("eth_getBlockByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200", false], block) apiMock.answerOnce("eth_getBlockByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200", false], block)
when: when:
def act = Flux.from(ws.getFlux()) Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe()
def act = Flux.from(ws.getBlocksFlux())
then: then:
StepVerifier.create(act) StepVerifier.create(act)
.then { ws.onNewBlock(block) } .then { ws.onNewHeads(headBlock).subscribe() }
.expectNext(BlockContainer.from(block)) .expectNext(BlockContainer.from(block))
.thenCancel() .thenCancel()
.verify(Duration.ofSeconds(1)) .verify(Duration.ofSeconds(1))
} }
def "Makes a RPC call"() {
setup:
def wsf = new EthereumWsFactory(new URI("http://localhost"), new URI("http://localhost"))
def apiMock = TestingCommons.api()
def wsApiMock = apiMock.asWebsocket()
def ws = wsf.create(null, null, null)
def tx = new TransactionJson().tap {
hash = TransactionId.from("0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200")
}
apiMock.answerOnce("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], tx)
when:
Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe()
def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15))
then:
StepVerifier.create(act)
.expectNextMatches {
it.id.asNumber() == 15L && Global.objectMapper.readValue(it.result, TransactionJson) == tx
}
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Makes a RPC call - return null"() {
setup:
def wsf = new EthereumWsFactory(new URI("http://localhost"), new URI("http://localhost"))
def apiMock = TestingCommons.api()
def wsApiMock = apiMock.asWebsocket()
def ws = wsf.create(null, null, null)
apiMock.answerOnce("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], null)
when:
Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe()
def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15))
then:
StepVerifier.create(act)
.expectNextMatches {
it.id.asNumber() == 15L &&
it.resultAsRawString == 'null'
}
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Makes a RPC call - return error"() {
setup:
def wsf = new EthereumWsFactory(new URI("http://localhost"), new URI("http://localhost"))
def apiMock = TestingCommons.api()
def wsApiMock = apiMock.asWebsocket()
def ws = wsf.create(null, null, null)
apiMock.answerOnce("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"],
new RpcResponseError(RpcResponseError.CODE_METHOD_NOT_EXIST, "test"))
when:
Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe()
def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15))
then:
StepVerifier.create(act)
.expectNextMatches {
it.id.asNumber() == 15L &&
it.error != null &&
it.error.code == RpcResponseError.CODE_METHOD_NOT_EXIST && it.error.message == "test"
}
.expectComplete()
.verify(Duration.ofSeconds(1))
}
} }

View File

@@ -18,19 +18,9 @@ package io.emeraldpay.dshackle.upstream.rpcclient
import io.emeraldpay.etherjar.rpc.RpcResponseError import io.emeraldpay.etherjar.rpc.RpcResponseError
import spock.lang.Specification import spock.lang.Specification
class JsonRpcParserSpec extends Specification { class ResponseRpcParserSpec extends Specification {
JsonRpcParser parser = new JsonRpcParser() ResponseRpcParser parser = new ResponseRpcParser()
def "Parse just result"() {
setup:
def json = '{"result": "Hello world!"}'
when:
def act = parser.parse(json.getBytes())
then:
act.error == null
new String(act.result) == '"Hello world!"'
}
def "Parse string response"() { def "Parse string response"() {
setup: setup:
@@ -178,6 +168,19 @@ class JsonRpcParserSpec extends Specification {
!act.hasResult() !act.hasResult()
} }
def "Parse error with no result field"() {
setup:
def json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -1111, "message": "test"}}'
when:
def act = parser.parse(json.getBytes())
then:
act.error != null
act.error.code == -1111
act.error.message == "test"
act.hasError()
!act.hasResult()
}
def "Parse error with data"() { def "Parse error with data"() {
setup: setup:
// 0 8 16 32 // 0 8 16 32

View File

@@ -0,0 +1,106 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.rpcclient
import spock.lang.Specification
class ResponseWSParserSpec extends Specification {
ResponseWSParser parser = new ResponseWSParser()
def "Parse subscription response"() {
setup:
def msg = "{\n" +
" \"id\": \"blocks\", \n" +
" \"jsonrpc\": \"2.0\", \n" +
" \"result\": \"0x9cef478923ff08bf67fde6c64013158d\"\n" +
"}"
when:
def act = parser.parse(msg.bytes)
then:
act.type == ResponseWSParser.Type.RPC
act.id.asString() == "blocks"
act.error == null
act.value == "\"0x9cef478923ff08bf67fde6c64013158d\"".bytes
}
def "Parse newHeads event"() {
setup:
def msg = "{\n" +
" \"jsonrpc\": \"2.0\",\n" +
" \"method\": \"eth_subscription\",\n" +
" \"params\": {\n" +
" \"result\": {\n" +
" \"difficulty\": \"0x15d9223a23aa\",\n" +
" \"extraData\": \"0xd983010305844765746887676f312e342e328777696e646f7773\",\n" +
" \"gasLimit\": \"0x47e7c4\",\n" +
" \"gasUsed\": \"0x38658\",\n" +
" \"logsBloom\": \"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\n" +
" \"miner\": \"0xf8b483dba2c3b7176a3da549ad41a48bb3121069\",\n" +
" \"nonce\": \"0x084149998194cc5f\",\n" +
" \"number\": \"0x1348c9\",\n" +
" \"parentHash\": \"0x7736fab79e05dc611604d22470dadad26f56fe494421b5b333de816ce1f25701\",\n" +
" \"receiptRoot\": \"0x2fab35823ad00c7bb388595cb46652fe7886e00660a01e867824d3dceb1c8d36\",\n" +
" \"sha3Uncles\": \"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347\",\n" +
" \"stateRoot\": \"0xb3346685172db67de536d8765c43c31009d0eb3bd9c501c9be3229203f15f378\",\n" +
" \"timestamp\": \"0x56ffeff8\",\n" +
" \"transactionsRoot\": \"0x0167ffa60e3ebc0b080cdb95f7c0087dd6c0e61413140e39d94d3468d7c9689f\"\n" +
" },\n" +
" \"subscription\": \"0x9ce59a13059e417087c02d3236a0b1cc\"\n" +
" }\n" +
"}"
when:
def act = parser.parse(msg.bytes)
then:
act.type == ResponseWSParser.Type.SUBSCRIPTION
act.id.asString() == "0x9ce59a13059e417087c02d3236a0b1cc"
act.error == null
with(new String(act.value)) {
it.length() > 0
it.startsWith("{")
it.endsWith("}")
it.contains("\"difficulty\": \"0x15d9223a23aa\"")
}
}
def "Parse RPC with error"() {
setup:
def msg = "{\"jsonrpc\":\"2.0\",\"id\":151,\"error\":{\"code\":-32602,\"message\":\"invalid blocknumber\"}}"
when:
def act = parser.parse(msg.bytes)
then:
act.type == ResponseWSParser.Type.RPC
act.id.asNumber() == 151L
act.error != null
act.value == null
with(act.error) {
it.code == -32602
it.message == "invalid blocknumber"
}
}
def "Parse RPC with null result"() {
setup:
def msg = "{\"jsonrpc\":\"2.0\",\"id\":100,\"result\":null}"
when:
def act = parser.parse(msg.bytes)
then:
act.type == ResponseWSParser.Type.RPC
act.id.asNumber() == 100L
act.error == null
new String(act.value) == "null"
}
}

View File

@@ -0,0 +1,15 @@
version: v1
upstreams:
- id: local
chain: ethereum
connection:
ethereum:
ws:
url: "ws://localhost:8546"
origin: "http://localhost"
frameSize: 10Mb
msgSize: 25Mb
basic-auth:
username: 9c199ad8f281f20154fc258fe41a6814
password: 258fe4149c199ad8f2811a68f20154fc

View File

@@ -0,0 +1,13 @@
version: v1
upstreams:
- id: local
chain: ethereum
connection:
ethereum:
ws:
url: "ws://localhost:8546"
origin: "http://localhost"
basic-auth:
username: 9c199ad8f281f20154fc258fe41a6814
password: 258fe4149c199ad8f2811a68f20154fc