solution: Ethereum WS connection fetches block through the same WS channel, instead of HTTP RPC

This commit is contained in:
Igor Artamonov
2021-09-19 22:52:21 -04:00
parent 817f794c04
commit 568a045271
18 changed files with 1003 additions and 232 deletions

View File

@@ -27,13 +27,34 @@ import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.grpc.stub.StreamObserver
import io.emeraldpay.etherjar.rpc.RpcResponseError
import io.emeraldpay.etherjar.rpc.json.ResponseJson
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator
import io.netty.buffer.ByteBufInputStream
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.reactivestreams.Publisher
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
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.util.concurrent.Callable
import java.util.function.BiFunction
import java.util.function.Consumer
import java.util.function.Predicate
class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
@@ -57,7 +78,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
}
@Override
Mono<JsonRpcResponse> read(JsonRpcRequest request) {
Mono<JsonRpcResponse> read(JsonRpcRequest request, boolean required = true) {
Callable<JsonRpcResponse> call = {
def predefined = predefined.find { it.isSame(request.method, request.params) }
byte[] result = null
@@ -65,7 +86,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
if (predefined != null) {
if (predefined.exception != null) {
predefined.onCalled()
predefined.print()
predefined.print(request.id)
throw predefined.exception
}
if (predefined.result instanceof RpcResponseError) {
@@ -77,12 +98,15 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
result = objectMapper.writeValueAsBytes(predefined.result)
}
predefined.onCalled()
predefined.print()
predefined.print(request.id)
} else {
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")
}
return new JsonRpcResponse(result, error)
return new JsonRpcResponse(result, error, JsonRpcResponse.Id.from(request.id))
} as Callable<JsonRpcResponse>
return Mono.fromCallable(call)
}
@@ -104,6 +128,10 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
responseObserver.onCompleted()
}
WebsocketApi asWebsocket() {
return new WebsocketApi(this)
}
class PredefinedResponse {
String method
List params
@@ -132,8 +160,202 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
}
}
void print() {
println "Execute API: $method ${params ? params : '_'} >> $result"
void print(int id) {
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

@@ -15,11 +15,15 @@
*/
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.test.TestingCommons
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
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.TransactionJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import reactor.core.publisher.Flux
import reactor.test.StepVerifier
@@ -34,7 +38,6 @@ class EthereumWsFactorySpec extends Specification {
def "Fetch block"() {
setup:
def wsf = new EthereumWsFactory(new URI("http://localhost"), new URI("http://localhost"))
def blocksCache = Mock(BlocksMemCache)
def block = new BlockJson<TransactionRefJson>()
block.number = 100
@@ -44,20 +47,98 @@ class EthereumWsFactorySpec extends Specification {
block.uncles = []
block.totalDifficulty = BigInteger.ONE
def headBlock = block.copy().tap {
it.transactions = null
}
def apiMock = TestingCommons.api()
def upstream = TestingCommons.upstream(apiMock)
def ws = wsf.create(upstream)
def wsApiMock = apiMock.asWebsocket()
def ws = wsf.create()
apiMock.answerOnce("eth_getBlockByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200", false], block)
when:
def act = Flux.from(ws.getFlux())
Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe()
def act = Flux.from(ws.getBlocksFlux())
then:
StepVerifier.create(act)
.then { ws.onNewBlock(block) }
.then { ws.onNewHeads(headBlock).subscribe() }
.expectNext(BlockContainer.from(block))
.thenCancel()
.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()
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()
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()
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 spock.lang.Specification
class JsonRpcParserSpec extends Specification {
class ResponseRpcParserSpec extends Specification {
JsonRpcParser parser = new JsonRpcParser()
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!"'
}
ResponseRpcParser parser = new ResponseRpcParser()
def "Parse string response"() {
setup:
@@ -178,6 +168,19 @@ class JsonRpcParserSpec extends Specification {
!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"() {
setup:
// 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"
}
}