solution: Ethereum WS connection fetches block through the same WS channel, instead of HTTP RPC
This commit is contained in:
@@ -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.RpcUnspent
|
||||
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspentDeserializer
|
||||
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
|
||||
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -48,6 +49,7 @@ class Global {
|
||||
|
||||
module.addDeserializer(EsploraUnspent::class.java, EsploraUnspentDeserializer())
|
||||
module.addDeserializer(RpcUnspent::class.java, RpcUnspentDeserializer())
|
||||
module.addDeserializer(JsonRpcRequest::class.java, JsonRpcRequest.Deserializer())
|
||||
|
||||
val objectMapper = ObjectMapper()
|
||||
objectMapper.registerModule(module)
|
||||
|
||||
@@ -79,7 +79,7 @@ open class EthereumRpcUpstream(
|
||||
|
||||
open fun createHead(): Head {
|
||||
return if (ethereumWsFactory != null) {
|
||||
val ws = ethereumWsFactory.create(this).apply {
|
||||
val ws = ethereumWsFactory.create().apply {
|
||||
connect()
|
||||
}
|
||||
val wsHead = EthereumWsHead(ws).apply {
|
||||
|
||||
@@ -23,25 +23,33 @@ import io.emeraldpay.dshackle.config.AuthConfig
|
||||
import io.emeraldpay.dshackle.data.BlockContainer
|
||||
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
|
||||
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
|
||||
import io.emeraldpay.dshackle.upstream.rpcclient.ResponseWSParser
|
||||
import io.emeraldpay.etherjar.rpc.json.BlockJson
|
||||
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.Unpooled
|
||||
import io.netty.handler.codec.http.HttpHeaderNames
|
||||
import org.reactivestreams.Publisher
|
||||
import org.slf4j.LoggerFactory
|
||||
import reactor.core.Disposable
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import reactor.core.publisher.Sinks
|
||||
import reactor.core.scheduler.Schedulers
|
||||
import reactor.netty.http.client.HttpClient
|
||||
import reactor.netty.http.client.WebsocketClientSpec
|
||||
import reactor.netty.http.websocket.WebsocketInbound
|
||||
import reactor.netty.http.websocket.WebsocketOutbound
|
||||
import reactor.retry.Repeat
|
||||
import java.io.InputStream
|
||||
import reactor.util.function.Tuples
|
||||
import java.net.URI
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
|
||||
class EthereumWsFactory(
|
||||
private val uri: URI,
|
||||
@@ -50,27 +58,39 @@ class EthereumWsFactory(
|
||||
|
||||
var basicAuth: AuthConfig.ClientBasicAuth? = null
|
||||
|
||||
fun create(upstream: EthereumUpstream): EthereumWs {
|
||||
return EthereumWs(uri, origin, upstream, basicAuth)
|
||||
fun create(): EthereumWs {
|
||||
return EthereumWs(uri, origin, basicAuth)
|
||||
}
|
||||
|
||||
class EthereumWs(
|
||||
private val uri: URI,
|
||||
private val origin: URI,
|
||||
private val upstream: EthereumUpstream,
|
||||
private val basicAuth: AuthConfig.ClientBasicAuth?
|
||||
) : AutoCloseable {
|
||||
|
||||
companion object {
|
||||
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 val topic = Sinks
|
||||
private val parser = ResponseWSParser()
|
||||
|
||||
private val blocks = Sinks
|
||||
.many()
|
||||
.multicast()
|
||||
.directBestEffort<BlockContainer>()
|
||||
private val 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 connection: Disposable? = null
|
||||
|
||||
@@ -89,11 +109,6 @@ class EthereumWsFactory(
|
||||
private fun connectInternal() {
|
||||
log.info("Connecting to WebSocket: $uri")
|
||||
connection?.dispose()
|
||||
connection = null
|
||||
|
||||
val subscriptionId = AtomicReference<String>("NOTSET")
|
||||
|
||||
val objectMapper = Global.objectMapper
|
||||
connection = HttpClient.create()
|
||||
.doOnError(
|
||||
{ _, t ->
|
||||
@@ -101,9 +116,7 @@ class EthereumWsFactory(
|
||||
// going to try to reconnect later
|
||||
tryReconnectLater()
|
||||
},
|
||||
{ _, _ ->
|
||||
|
||||
}
|
||||
{ _, _ -> }
|
||||
)
|
||||
.headers { headers ->
|
||||
headers.add(HttpHeaderNames.ORIGIN, origin)
|
||||
@@ -114,11 +127,7 @@ class EthereumWsFactory(
|
||||
}
|
||||
}
|
||||
.let {
|
||||
if (uri.scheme == "wss") {
|
||||
it.secure()
|
||||
} else {
|
||||
it
|
||||
}
|
||||
if (uri.scheme == "wss") it.secure() else it
|
||||
}
|
||||
.websocket(
|
||||
WebsocketClientSpec.builder()
|
||||
@@ -128,57 +137,93 @@ class EthereumWsFactory(
|
||||
)
|
||||
.uri(uri)
|
||||
.handle { inbound, outbound ->
|
||||
val consumer = inbound.aggregateFrames()
|
||||
.aggregateFrames(8 * 65_536)
|
||||
.receiveFrames()
|
||||
.flatMap {
|
||||
val msg: SubscriptionJson = objectMapper.readerFor(SubscriptionJson::class.java)
|
||||
.readValue(ByteBufInputStream(it.content()) as InputStream)
|
||||
when {
|
||||
msg.error != null -> {
|
||||
Mono.error(IllegalStateException("Received error from WS upstream"))
|
||||
}
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onErrorResume { t ->
|
||||
log.warn("Connection dropped to $uri. Error: ${t.message}")
|
||||
// going to try to reconnect later
|
||||
tryReconnectLater()
|
||||
// completes current outbound flow
|
||||
Mono.empty()
|
||||
}
|
||||
|
||||
|
||||
outbound.sendString(Mono.just(START_REQUEST)
|
||||
.doOnError { log.warn("Failed to start WS subscription. ${it.javaClass}: ${it.message}") })
|
||||
.then(consumer.then())
|
||||
handle(inbound, outbound)
|
||||
}
|
||||
.doOnError {
|
||||
println(it)
|
||||
log.error("Failed to setup WS connection", it)
|
||||
}
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
fun onNewBlock(block: BlockJson<TransactionRefJson>) {
|
||||
// WS returns incomplete blocks, i.e. without some fields, so need to fetch full block data
|
||||
if (block.difficulty == null || block.transactions == null) {
|
||||
fun handle(inbound: WebsocketInbound, outbound: WebsocketOutbound): Publisher<Void> {
|
||||
val consumer = inbound.aggregateFrames()
|
||||
// accept up to 1Mb messages
|
||||
.aggregateFrames(16 * 65_536)
|
||||
.receiveFrames()
|
||||
.map { ByteBufInputStream(it.content()).readAllBytes() }
|
||||
.flatMap {
|
||||
try {
|
||||
val msg = parser.parse(it)
|
||||
if (msg.type == ResponseWSParser.Type.SUBSCRIPTION) {
|
||||
onSubscription(msg)
|
||||
} else {
|
||||
onRpc(msg)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
log.warn("Failed to process WS message. ${t.javaClass}: ${t.message}")
|
||||
Mono.empty()
|
||||
}
|
||||
}
|
||||
.onErrorResume { t ->
|
||||
log.warn("Connection dropped to $uri. Error: ${t.message}")
|
||||
// going to try to reconnect later
|
||||
tryReconnectLater()
|
||||
// completes current outbound flow
|
||||
Mono.empty()
|
||||
}
|
||||
|
||||
val start = Mono.just(START_REQUEST).map {
|
||||
Unpooled.wrappedBuffer(it.toByteArray())
|
||||
}
|
||||
val calls = rpcSend
|
||||
.asFlux()
|
||||
.map {
|
||||
Unpooled.wrappedBuffer(Global.objectMapper.writeValueAsBytes(it))
|
||||
}
|
||||
|
||||
return outbound.send(
|
||||
Flux.merge(
|
||||
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)
|
||||
.flatMap { hash ->
|
||||
upstream.getApi()
|
||||
.read(JsonRpcRequest("eth_getBlockByHash", listOf(hash.toHex(), false)))
|
||||
call(JsonRpcRequest("eth_getBlockByHash", listOf(hash.toHex(), false)))
|
||||
.flatMap { resp ->
|
||||
if (resp.isNull()) {
|
||||
Mono.error(SilentException("Received null for block $hash"))
|
||||
@@ -188,6 +233,8 @@ class EthereumWsFactory(
|
||||
}
|
||||
.flatMap(JsonRpcResponse::requireResult)
|
||||
.map { BlockContainer.fromEthereumJson(it) }
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.timeout(Defaults.timeoutInternal, Mono.empty())
|
||||
}.repeatWhenEmpty { n ->
|
||||
Repeat.times<Any>(5)
|
||||
.exponentialBackoff(Duration.ofMillis(50), Duration.ofMillis(500))
|
||||
@@ -195,17 +242,53 @@ class EthereumWsFactory(
|
||||
}
|
||||
.timeout(Defaults.timeout, Mono.empty())
|
||||
.onErrorResume { Mono.empty() }
|
||||
.subscribe {
|
||||
topic.tryEmitNext(it)
|
||||
.doOnNext {
|
||||
blocks.tryEmitNext(it)
|
||||
}
|
||||
|
||||
.then()
|
||||
} else {
|
||||
topic.tryEmitNext(BlockContainer.from(block))
|
||||
Mono.fromCallable {
|
||||
blocks.tryEmitNext(BlockContainer.from(block))
|
||||
}.then()
|
||||
}
|
||||
}
|
||||
|
||||
fun getFlux(): Flux<BlockContainer> {
|
||||
return this.topic.asFlux()
|
||||
fun call(originalRequest: JsonRpcRequest): Mono<JsonRpcResponse> {
|
||||
return Mono.fromCallable {
|
||||
// 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)
|
||||
}.flatMap { request ->
|
||||
waitForResponse(request.t1, request.t2)
|
||||
}
|
||||
}
|
||||
|
||||
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): 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()
|
||||
.map { it.copyWithId(JsonRpcResponse.Id.from(originalId)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getBlocksFlux(): Flux<BlockContainer> {
|
||||
return this.blocks.asFlux()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
@@ -216,5 +299,4 @@ class EthereumWsFactory(
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class EthereumWsHead(
|
||||
|
||||
override fun start() {
|
||||
this.subscription?.dispose()
|
||||
this.subscription = super.follow(ws.getFlux())
|
||||
this.subscription = super.follow(ws.getBlocksFlux())
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
|
||||
@@ -123,8 +123,8 @@ class NativeCallRouter(
|
||||
}
|
||||
}
|
||||
|
||||
fun getBlockByNumber(params: List<Any>): Mono<ByteArray>? {
|
||||
if (params.size != 2) {
|
||||
fun getBlockByNumber(params: List<Any?>): Mono<ByteArray>? {
|
||||
if (params.size != 2 || params[0] == null || params[1] == null) {
|
||||
throw RpcException(RpcResponseError.CODE_INVALID_METHOD_PARAMS, "Must provide 2 parameters")
|
||||
}
|
||||
val number: Long
|
||||
@@ -155,7 +155,7 @@ class NativeCallRouter(
|
||||
}
|
||||
}
|
||||
} 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()
|
||||
var block = reader.blocksByHeightAsCont()
|
||||
|
||||
@@ -52,5 +52,9 @@ class JsonRpcError(val code: Int, val message: String, val details: Any?) {
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "JsonRpcError(code=$code, message='$message', details=$details)"
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -19,8 +19,6 @@ import io.emeraldpay.dshackle.config.AuthConfig
|
||||
import io.emeraldpay.dshackle.reader.Reader
|
||||
import io.emeraldpay.etherjar.rpc.RpcException
|
||||
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.handler.codec.http.HttpHeaderNames
|
||||
import io.netty.handler.codec.http.HttpHeaders
|
||||
@@ -50,7 +48,7 @@ class JsonRpcHttpClient(
|
||||
private val log = LoggerFactory.getLogger(JsonRpcHttpClient::class.java)
|
||||
}
|
||||
|
||||
private val parser = JsonRpcParser()
|
||||
private val parser = ResponseRpcParser()
|
||||
private val httpClient: HttpClient
|
||||
|
||||
init {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -15,40 +15,55 @@
|
||||
*/
|
||||
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
|
||||
|
||||
class JsonRpcRequest(
|
||||
data class JsonRpcRequest(
|
||||
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 {
|
||||
val json = mapOf(
|
||||
"jsonrpc" to "2.0",
|
||||
"id" to 1,
|
||||
"id" to id,
|
||||
"method" to method,
|
||||
"params" to params
|
||||
)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,10 @@ class JsonRpcResponse(
|
||||
}
|
||||
}
|
||||
|
||||
fun copyWithId(id: Id): JsonRpcResponse {
|
||||
return JsonRpcResponse(result, error, id)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is JsonRpcResponse) return false
|
||||
@@ -177,6 +181,10 @@ class JsonRpcResponse(
|
||||
override fun hashCode(): Int {
|
||||
return id.hashCode()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return id.toString()
|
||||
}
|
||||
}
|
||||
|
||||
class StringId(val id: String) : Id {
|
||||
@@ -205,6 +213,9 @@ class JsonRpcResponse(
|
||||
return id.hashCode()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseJsonSerializer : JsonSerializer<JsonRpcResponse>() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user